@kici-dev/orchestrator 0.1.11 → 0.1.13

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 (33) hide show
  1. package/dist/cli/commands/agent-service/install.d.ts +7 -0
  2. package/dist/cli/commands/agent-service/logs.d.ts +8 -3
  3. package/dist/cli/commands/agent-service/restart.d.ts +5 -1
  4. package/dist/cli/commands/agent-service/start.d.ts +5 -1
  5. package/dist/cli/commands/agent-service/status.d.ts +5 -1
  6. package/dist/cli/commands/agent-service/stop.d.ts +5 -1
  7. package/dist/cli/commands/agent-service/uninstall.d.ts +6 -2
  8. package/dist/cli/commands/agent-service/upgrade.d.ts +13 -3
  9. package/dist/cli/commands/orchestrator-service/logs.d.ts +4 -0
  10. package/dist/cli/commands/orchestrator-service/restart.d.ts +5 -1
  11. package/dist/cli/commands/orchestrator-service/start.d.ts +5 -1
  12. package/dist/cli/commands/orchestrator-service/status.d.ts +4 -0
  13. package/dist/cli/commands/orchestrator-service/stop.d.ts +5 -1
  14. package/dist/cli/commands/orchestrator-service/uninstall.d.ts +6 -2
  15. package/dist/cli/commands/orchestrator-service/upgrade.d.ts +9 -0
  16. package/dist/cli/commands/shared/versioned-upgrade.d.ts +54 -2
  17. package/dist/cli/service/compose.d.ts +3 -2
  18. package/dist/cli/service/index.d.ts +7 -1
  19. package/dist/cli/service/instance/index-file.d.ts +30 -0
  20. package/dist/cli/service/instance/manifest.d.ts +21 -0
  21. package/dist/cli/service/instance/resolve.d.ts +59 -0
  22. package/dist/cli/service/instance/types.d.ts +55 -0
  23. package/dist/cli/service/launchd.d.ts +19 -1
  24. package/dist/cli/service/platform-detect.d.ts +29 -9
  25. package/dist/cli/service/systemd.d.ts +2 -1
  26. package/dist/cli/service/types.d.ts +28 -0
  27. package/dist/cli/service/windows.d.ts +2 -1
  28. package/dist/cli.js +1670 -1029
  29. package/dist/metrics/prometheus.d.ts +32 -4
  30. package/dist/server.js +19 -19
  31. package/dist/standalone.js +19 -19
  32. package/package.json +4 -4
  33. package/sbom.spdx.json +36 -36
package/dist/cli.js CHANGED
@@ -25,8 +25,8 @@ import path from "node:path";
25
25
  import archiver from "archiver";
26
26
  import JSZip from "jszip";
27
27
  import { fileURLToPath } from "node:url";
28
- import { confirm, input, password, select } from "@inquirer/prompts";
29
28
  import { pipeline } from "node:stream/promises";
29
+ import { confirm, input, password, select } from "@inquirer/prompts";
30
30
  import "pg";
31
31
  import { DASHBOARD_WRITE_OPERATIONS, DASHBOARD_WRITE_OPERATIONS_BY_NAME, DashboardWriteCategory, DashboardWriteSensitivity } from "@kici-dev/engine/protocol/dashboard-write-operations";
32
32
  import { CLUSTER_NAME_FORMAT_MESSAGE } from "@kici-dev/engine/protocol/cluster-name";
@@ -6092,7 +6092,8 @@ function isRoot() {
6092
6092
  return typeof process.getuid === "function" && process.getuid() === 0;
6093
6093
  }
6094
6094
  /**
6095
- * Get the configuration directory for a KiCI service.
6095
+ * Name-agnostic KiCI config root — the directory that contains every
6096
+ * per-instance config subdir for this privilege level.
6096
6097
  *
6097
6098
  * Paths follow platform conventions:
6098
6099
  * - System Linux/macOS: /etc/kici/
@@ -6100,8 +6101,11 @@ function isRoot() {
6100
6101
  * - User macOS: ~/Library/Application Support/kici/
6101
6102
  * - System Windows: C:\ProgramData\kici\
6102
6103
  * - User Windows: %LOCALAPPDATA%\kici\
6104
+ *
6105
+ * Used by the instance index (`<kiciRoot>/instances.json`) which lives
6106
+ * outside any per-instance subdir, and as the base for {@link getConfigDir}.
6103
6107
  */
6104
- function getConfigDir(_serviceName, isUserLevel) {
6108
+ function kiciConfigRoot(isUserLevel) {
6105
6109
  const plat = os.platform();
6106
6110
  if (plat === "win32") {
6107
6111
  if (isUserLevel) return (process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local")) + "\\kici\\";
@@ -6112,20 +6116,40 @@ function getConfigDir(_serviceName, isUserLevel) {
6112
6116
  return path.join(os.homedir(), ".config", "kici") + "/";
6113
6117
  }
6114
6118
  /**
6115
- * Get the log directory for a KiCI service.
6119
+ * Get the configuration directory for a specific KiCI service instance.
6116
6120
  *
6117
- * Paths follow platform conventions:
6118
- * - System Linux/macOS: /var/log/kici/
6119
- * - User Linux: ~/.local/share/kici/logs/
6120
- * - User macOS: ~/Library/Logs/kici/
6121
- * - Windows: C:\ProgramData\kici\logs\
6121
+ * Returns `<kiciConfigRoot>/<serviceName>/`. The name-scoped subdir is the
6122
+ * folder-anchored home for everything belonging to one installed instance
6123
+ * (env file, generated unit references, future per-instance state).
6124
+ *
6125
+ * Examples:
6126
+ * - System Linux: /etc/kici/<name>/
6127
+ * - User Linux: ~/.config/kici/<name>/
6128
+ * - User macOS: ~/Library/Application Support/kici/<name>/
6129
+ * - System Windows: C:\ProgramData\kici\<name>\
6130
+ * - User Windows: %LOCALAPPDATA%\kici\<name>\
6131
+ */
6132
+ function getConfigDir(serviceName, isUserLevel) {
6133
+ const root = kiciConfigRoot(isUserLevel);
6134
+ const sep = os.platform() === "win32" ? "\\" : "/";
6135
+ return root + serviceName + sep;
6136
+ }
6137
+ /**
6138
+ * Get the log directory for a specific KiCI service instance.
6139
+ *
6140
+ * The per-platform layout matches the existing matrix; the service name is
6141
+ * injected as the per-instance segment so each instance has its own log dir:
6142
+ * - System Linux/macOS: /var/log/kici/<name>/
6143
+ * - User Linux: ~/.local/share/kici/<name>/logs/
6144
+ * - User macOS: ~/Library/Logs/kici/<name>/
6145
+ * - Windows: C:\ProgramData\kici\<name>\logs\
6122
6146
  */
6123
- function getLogDir(_serviceName, isUserLevel) {
6147
+ function getLogDir(serviceName, isUserLevel) {
6124
6148
  const plat = os.platform();
6125
- if (plat === "win32") return "C:\\ProgramData\\kici\\logs\\";
6126
- if (!isUserLevel) return "/var/log/kici/";
6127
- if (plat === "darwin") return path.join(os.homedir(), "Library", "Logs", "kici") + "/";
6128
- return path.join(os.homedir(), ".local", "share", "kici", "logs") + "/";
6149
+ if (plat === "win32") return "C:\\ProgramData\\kici\\" + serviceName + "\\logs\\";
6150
+ if (!isUserLevel) return "/var/log/kici/" + serviceName + "/";
6151
+ if (plat === "darwin") return path.join(os.homedir(), "Library", "Logs", "kici", serviceName) + "/";
6152
+ return path.join(os.homedir(), ".local", "share", "kici", serviceName, "logs") + "/";
6129
6153
  }
6130
6154
  /**
6131
6155
  * Get the cache directory for lazy dependency downloads.
@@ -6158,6 +6182,221 @@ function resolveUserLevel(opts) {
6158
6182
  return !isRoot();
6159
6183
  }
6160
6184
  //#endregion
6185
+ //#region src/cli/service/instance/manifest.ts
6186
+ /**
6187
+ * Instance manifest — the single source of truth for a folder-anchored
6188
+ * service install. Written by `install` into the deploy folder, read by
6189
+ * every lifecycle command to reconstruct the ServiceConfig.
6190
+ */
6191
+ const REQUIRED_FIELDS = [
6192
+ "component",
6193
+ "name",
6194
+ "platform",
6195
+ "isUserLevel",
6196
+ "envFilePath",
6197
+ "configDir",
6198
+ "logDir",
6199
+ "installBase",
6200
+ "createdAt",
6201
+ "kiciVersion"
6202
+ ];
6203
+ /** Per-component manifest filename. */
6204
+ function manifestFilename(component) {
6205
+ return `.kici-${component}.json`;
6206
+ }
6207
+ /** Resolve the manifest path inside an instance directory. */
6208
+ function manifestPath(instanceDir, component) {
6209
+ return path.join(instanceDir, manifestFilename(component));
6210
+ }
6211
+ /**
6212
+ * Read the manifest for `component` from `instanceDir`.
6213
+ * Returns null when the file does not exist; throws on parse or schema errors.
6214
+ */
6215
+ function readManifest(instanceDir, component) {
6216
+ const file = manifestPath(instanceDir, component);
6217
+ if (!fs.existsSync(file)) return null;
6218
+ const raw = fs.readFileSync(file, "utf-8");
6219
+ let parsed;
6220
+ try {
6221
+ parsed = JSON.parse(raw);
6222
+ } catch (err) {
6223
+ throw new Error(`Malformed instance manifest at ${file}: ${err.message}`);
6224
+ }
6225
+ if (!parsed || typeof parsed !== "object") throw new Error(`Invalid instance manifest at ${file}: not an object`);
6226
+ for (const field of REQUIRED_FIELDS) if (!(field in parsed)) throw new Error(`Invalid instance manifest at ${file}: missing field "${field}"`);
6227
+ return parsed;
6228
+ }
6229
+ /**
6230
+ * Write the manifest for `manifest.component` into `instanceDir`.
6231
+ * Returns the full path written.
6232
+ */
6233
+ function writeManifest(instanceDir, manifest) {
6234
+ fs.mkdirSync(instanceDir, { recursive: true });
6235
+ const file = manifestPath(instanceDir, manifest.component);
6236
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
6237
+ return file;
6238
+ }
6239
+ //#endregion
6240
+ //#region src/cli/service/instance/index-file.ts
6241
+ /**
6242
+ * Instance index — a reconciled CACHE of all installed instances on the host.
6243
+ *
6244
+ * Lives at <kiciRoot>/instances.json, where kiciRoot is the name-agnostic
6245
+ * config root (~/.config/kici/ for user-level, /etc/kici/ for system). The
6246
+ * index is convenience: discovery is authoritative against the init system
6247
+ * (see resolve.ts#listInstances which reconciles this file against scans).
6248
+ *
6249
+ * Corrupt index → warn and treat as empty; the next install/uninstall
6250
+ * rewrites it cleanly.
6251
+ */
6252
+ const FILE = "instances.json";
6253
+ /** Resolve <kiciRoot>/instances.json. */
6254
+ function indexPath(kiciRoot) {
6255
+ return path.join(kiciRoot, FILE);
6256
+ }
6257
+ /** Read the index. Missing or corrupt → []. */
6258
+ function readIndex(kiciRoot) {
6259
+ const file = indexPath(kiciRoot);
6260
+ if (!fs.existsSync(file)) return [];
6261
+ try {
6262
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
6263
+ if (!Array.isArray(parsed)) {
6264
+ console.warn(`[kici] instance index at ${file} is invalid (not an array); ignoring`);
6265
+ return [];
6266
+ }
6267
+ return parsed;
6268
+ } catch (err) {
6269
+ console.warn(`[kici] instance index at ${file} is corrupt (${err.message}); ignoring`);
6270
+ return [];
6271
+ }
6272
+ }
6273
+ /** Overwrite the index. */
6274
+ function writeIndex(kiciRoot, entries) {
6275
+ fs.mkdirSync(kiciRoot, { recursive: true });
6276
+ fs.writeFileSync(indexPath(kiciRoot), JSON.stringify(entries, null, 2) + "\n", "utf-8");
6277
+ }
6278
+ /**
6279
+ * Append an entry. Idempotent for an exact (component,name,instanceDir) match.
6280
+ * Throws on a (component,name) collision with a different instanceDir — the
6281
+ * caller must use a different name or pass --force to overwrite.
6282
+ */
6283
+ function appendIndexEntry(kiciRoot, entry) {
6284
+ const current = readIndex(kiciRoot);
6285
+ const existing = current.find((e) => e.component === entry.component && e.name === entry.name);
6286
+ if (existing) {
6287
+ if (existing.instanceDir === entry.instanceDir) return;
6288
+ throw new Error(`Already an ${entry.component} instance "${entry.name}" registered at ${existing.instanceDir}`);
6289
+ }
6290
+ writeIndex(kiciRoot, [...current, entry]);
6291
+ }
6292
+ /** Remove the matching entry (no-op when absent). */
6293
+ function removeIndexEntry(kiciRoot, key) {
6294
+ const current = readIndex(kiciRoot);
6295
+ const next = current.filter((e) => !(e.component === key.component && e.name === key.name));
6296
+ if (next.length !== current.length) writeIndex(kiciRoot, next);
6297
+ }
6298
+ //#endregion
6299
+ //#region src/cli/service/instance/resolve.ts
6300
+ /**
6301
+ * resolveInstance — the single entry point every lifecycle command uses to
6302
+ * decide which installed service it's operating on.
6303
+ *
6304
+ * Priority:
6305
+ * 1. --instance-dir <path> → read manifest at <path>
6306
+ * 2. --name <name> → match against listInstances() result
6307
+ * 3. CWD manifest → read ./.kici-<component>.json
6308
+ * 4. otherwise → refuse with a candidate list (throws)
6309
+ *
6310
+ * listInstances reconciles the on-disk index (cache) with the driver's
6311
+ * native scan (source of truth). The reconciled result is rewritten back to
6312
+ * the index to self-heal stale entries.
6313
+ */
6314
+ /**
6315
+ * Reconcile <kiciRoot>/instances.json with the driver's native scan, then
6316
+ * rewrite the index dropping entries whose units no longer exist. Returns
6317
+ * the merged list filtered to the requested component + isUserLevel.
6318
+ */
6319
+ async function listInstances(args) {
6320
+ const { component, isUserLevel, kiciRoot, manager } = args;
6321
+ const scanForComponent = (await manager.list(isUserLevel)).filter((s) => s.component === component);
6322
+ const scanNames = new Set(scanForComponent.map((s) => s.name));
6323
+ const index = readIndex(kiciRoot);
6324
+ const relevantIndex = index.filter((e) => e.component === component && e.isUserLevel === isUserLevel);
6325
+ const survivors = relevantIndex.filter((e) => scanNames.has(e.name));
6326
+ if (survivors.length !== relevantIndex.length) writeIndex(kiciRoot, [...index.filter((e) => !(e.component === component && e.isUserLevel === isUserLevel)), ...survivors]);
6327
+ const indexByName = new Map(survivors.map((e) => [e.name, e]));
6328
+ return scanForComponent.map((s) => {
6329
+ const idx = indexByName.get(s.name);
6330
+ return {
6331
+ ...s,
6332
+ component,
6333
+ instanceDir: idx?.instanceDir,
6334
+ source: idx ? "index+scan" : "scan"
6335
+ };
6336
+ });
6337
+ }
6338
+ /**
6339
+ * Resolve the target instance for the current lifecycle invocation.
6340
+ * Throws with a refusal/candidate-listing error when ambiguous.
6341
+ */
6342
+ async function resolveInstance(args) {
6343
+ const { component, opts, cwd, kiciRoot, manager, isUserLevel } = args;
6344
+ if (opts.instanceDir) {
6345
+ const dir = path.resolve(opts.instanceDir);
6346
+ const m = readManifest(dir, component);
6347
+ if (!m) throw new Error(`No ${component} manifest at ${manifestPath(dir, component)}. Did you install with --instance-dir ${dir}?`);
6348
+ return {
6349
+ manifest: m,
6350
+ manifestPath: manifestPath(dir, component),
6351
+ instanceDir: dir
6352
+ };
6353
+ }
6354
+ if (opts.name) {
6355
+ const candidates = await listInstances({
6356
+ component,
6357
+ isUserLevel,
6358
+ kiciRoot,
6359
+ manager
6360
+ });
6361
+ const match = candidates.find((c) => c.name === opts.name);
6362
+ if (!match) throw new Error(formatNameNotFound(component, opts.name, candidates));
6363
+ if (!match.instanceDir) throw new Error(`${component} instance "${opts.name}" exists in the init system but has no manifest. Pass --instance-dir <deploy folder> instead.`);
6364
+ const manifest = readManifest(match.instanceDir, component);
6365
+ if (!manifest) throw new Error(`Manifest for ${component} instance "${opts.name}" missing at ${manifestPath(match.instanceDir, component)}.`);
6366
+ return {
6367
+ manifest,
6368
+ manifestPath: manifestPath(match.instanceDir, component),
6369
+ instanceDir: match.instanceDir
6370
+ };
6371
+ }
6372
+ const cwdManifest = readManifest(cwd, component);
6373
+ if (cwdManifest) return {
6374
+ manifest: cwdManifest,
6375
+ manifestPath: manifestPath(cwd, component),
6376
+ instanceDir: path.resolve(cwd)
6377
+ };
6378
+ const candidates = await listInstances({
6379
+ component,
6380
+ isUserLevel,
6381
+ kiciRoot,
6382
+ manager
6383
+ });
6384
+ throw new Error(formatRefusal(component, candidates));
6385
+ }
6386
+ /**
6387
+ * Format the refusal message and candidate table.
6388
+ *
6389
+ * When candidates is empty, returns the "no instances installed" guidance.
6390
+ * When candidates exist, lists them with their instanceDir (or "(no manifest)").
6391
+ */
6392
+ function formatRefusal(component, candidates) {
6393
+ if (candidates.length === 0) return `No ${component} instances installed on this host. Run \`kici-admin ${component} install --instance-dir <deploy folder>\` first.`;
6394
+ return `No instance specified and no manifest in CWD. Candidates on this host:\n${candidates.map((c) => ` - ${c.name} ${c.platform} ${c.instanceDir ?? "(no manifest)"}`).join("\n")}\nPass --instance-dir <path> or --name <name>, or cd into the deploy folder.`;
6395
+ }
6396
+ function formatNameNotFound(component, name, candidates) {
6397
+ return `${component} instance "${name}" not found. Installed:\n${candidates.length ? candidates.map((c) => ` - ${c.name} ${c.platform} ${c.instanceDir ?? "(no manifest)"}`).join("\n") : " (none)"}`;
6398
+ }
6399
+ //#endregion
6161
6400
  //#region src/cli/service/systemd.ts
6162
6401
  /**
6163
6402
  * systemd service manager implementation.
@@ -6215,6 +6454,7 @@ var init_systemd = __esmMin((() => {
6215
6454
  const lines = [];
6216
6455
  lines.push("[Unit]");
6217
6456
  lines.push(`Description=${config.description}`);
6457
+ if (config.component) lines.push(`X-KiCI-Component=${config.component}`);
6218
6458
  lines.push("After=network.target postgresql.service");
6219
6459
  lines.push("");
6220
6460
  lines.push("[Service]");
@@ -6351,6 +6591,24 @@ var init_systemd = __esmMin((() => {
6351
6591
  async isInstalled(config) {
6352
6592
  return fs.existsSync(this.unitFilePath(config));
6353
6593
  }
6594
+ async list(isUserLevel) {
6595
+ const unitDir = isUserLevel ? path.join(os.homedir(), ".config", "systemd", "user") : "/etc/systemd/system";
6596
+ if (!fs.existsSync(unitDir)) return [];
6597
+ const out = [];
6598
+ for (const entry of fs.readdirSync(unitDir)) {
6599
+ if (typeof entry !== "string") continue;
6600
+ if (!entry.startsWith("kici-") || !entry.endsWith(".service")) continue;
6601
+ const m = fs.readFileSync(path.join(unitDir, entry), "utf-8").match(/^X-KiCI-Component=(orchestrator|agent)\s*$/m);
6602
+ if (!m) continue;
6603
+ out.push({
6604
+ name: entry.replace(/\.service$/, ""),
6605
+ platform: "systemd",
6606
+ isUserLevel,
6607
+ component: m[1]
6608
+ });
6609
+ }
6610
+ return out;
6611
+ }
6354
6612
  };
6355
6613
  }));
6356
6614
  //#endregion
@@ -6363,6 +6621,10 @@ var init_systemd = __esmMin((() => {
6363
6621
  * user-level (~/Library/LaunchAgents/) agents.
6364
6622
  */
6365
6623
  var launchd_exports = /* @__PURE__ */ __exportAll({ LaunchdServiceManager: () => LaunchdServiceManager });
6624
+ /** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
6625
+ function sleep(ms) {
6626
+ return new Promise((resolve) => setTimeout(resolve, ms));
6627
+ }
6366
6628
  var LABEL_PREFIX, SYSTEM_LOG_DIR, LaunchdServiceManager;
6367
6629
  var init_launchd = __esmMin((() => {
6368
6630
  LABEL_PREFIX = "dev.kici";
@@ -6416,6 +6678,10 @@ var init_launchd = __esmMin((() => {
6416
6678
  lines.push("<dict>");
6417
6679
  lines.push(" <key>Label</key>");
6418
6680
  lines.push(` <string>${this.escapeXml(label)}</string>`);
6681
+ if (config.component) {
6682
+ lines.push(" <key>KiCIComponent</key>");
6683
+ lines.push(` <string>${config.component}</string>`);
6684
+ }
6419
6685
  lines.push(" <key>ProgramArguments</key>");
6420
6686
  lines.push(" <array>");
6421
6687
  lines.push(` <string>${this.escapeXml(config.executablePath)}</string>`);
@@ -6497,14 +6763,61 @@ var init_launchd = __esmMin((() => {
6497
6763
  logDirectory
6498
6764
  ], { stdio: "inherit" });
6499
6765
  fs.writeFileSync(plistFile, plistContent, "utf-8");
6500
- if (this.isLoaded(config)) try {
6501
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6502
- } catch {}
6503
- execFileSync("launchctl", [
6504
- "bootstrap",
6505
- this.domain(config),
6506
- plistFile
6507
- ], { stdio: "inherit" });
6766
+ if (this.isLoaded(config)) {
6767
+ try {
6768
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6769
+ } catch {}
6770
+ await this.waitUntilUnloaded(config);
6771
+ }
6772
+ await this.bootstrapWithRetry(config, plistFile);
6773
+ }
6774
+ /**
6775
+ * Poll until the service is no longer loaded in its target domain, or a
6776
+ * short deadline elapses. `launchctl bootout` is asynchronous — it returns
6777
+ * before launchd has finished releasing the service — so a bootstrap issued
6778
+ * immediately afterward races the teardown and fails with EIO. Waiting for
6779
+ * the unload to complete closes that race for the common case; the residual
6780
+ * window is covered by bootstrapWithRetry.
6781
+ */
6782
+ async waitUntilUnloaded(config) {
6783
+ const deadline = Date.now() + 15e3;
6784
+ while (this.isLoaded(config)) {
6785
+ if (Date.now() >= deadline) return;
6786
+ await sleep(500);
6787
+ }
6788
+ }
6789
+ /**
6790
+ * Bootstrap into the target domain, retrying on the transient EIO
6791
+ * ("5: Input/output error") launchd returns when a just-removed service has
6792
+ * not finished tearing down. A genuine, non-transient failure (bad plist,
6793
+ * permission denied) is re-thrown on the first attempt. If a stale instance
6794
+ * reappears between attempts, it is booted out before the next try.
6795
+ */
6796
+ async bootstrapWithRetry(config, plistFile) {
6797
+ const attempts = 5;
6798
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
6799
+ execFileSync("launchctl", [
6800
+ "bootstrap",
6801
+ this.domain(config),
6802
+ plistFile
6803
+ ], { stdio: [
6804
+ "inherit",
6805
+ "inherit",
6806
+ "pipe"
6807
+ ] });
6808
+ return;
6809
+ } catch (err) {
6810
+ const e = err;
6811
+ const stderr = (e.stderr ?? "").toString();
6812
+ if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
6813
+ if (stderr) process.stderr.write(stderr);
6814
+ throw err;
6815
+ }
6816
+ if (this.isLoaded(config)) try {
6817
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6818
+ } catch {}
6819
+ await sleep(1e3 * attempt);
6820
+ }
6508
6821
  }
6509
6822
  async uninstall(config) {
6510
6823
  const plistFile = this.plistPath(config);
@@ -6573,6 +6886,30 @@ var init_launchd = __esmMin((() => {
6573
6886
  async isInstalled(config) {
6574
6887
  return fs.existsSync(this.plistPath(config));
6575
6888
  }
6889
+ async list(isUserLevel) {
6890
+ const baseDir = isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : "/Library/LaunchDaemons";
6891
+ if (!fs.existsSync(baseDir)) return [];
6892
+ const out = [];
6893
+ for (const entry of fs.readdirSync(baseDir)) {
6894
+ if (typeof entry !== "string") continue;
6895
+ if (!entry.endsWith(".plist")) continue;
6896
+ let content;
6897
+ try {
6898
+ content = fs.readFileSync(path.join(baseDir, entry), "utf-8");
6899
+ } catch {
6900
+ continue;
6901
+ }
6902
+ const match = content.match(/<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/);
6903
+ if (!match) continue;
6904
+ out.push({
6905
+ name: entry.replace(/\.plist$/, ""),
6906
+ platform: "launchd",
6907
+ isUserLevel,
6908
+ component: match[1]
6909
+ });
6910
+ }
6911
+ return out;
6912
+ }
6576
6913
  };
6577
6914
  }));
6578
6915
  //#endregion
@@ -6870,6 +7207,8 @@ var init_windows = __esmMin((() => {
6870
7207
  cmdParts.push("--", `"${config.executablePath}"`);
6871
7208
  for (const arg of config.args ?? []) cmdParts.push(`"${arg}"`);
6872
7209
  execSync(cmdParts.join(" "), { stdio: "pipe" });
7210
+ const descText = config.component ? `[KiCI:${config.component}] ${config.description}` : config.description;
7211
+ execSync(`sc.exe description ${config.name} "${descText.replace(/"/g, "\\\"")}"`, { stdio: "pipe" });
6873
7212
  execSync(`sc.exe config ${config.name} start= auto`, { stdio: "pipe" });
6874
7213
  const actions = config.restartPolicy.delays.map((d) => `restart/${d * 1e3}`).join("/");
6875
7214
  const resetSeconds = config.restartPolicy.windowSeconds;
@@ -6960,6 +7299,37 @@ var init_windows = __esmMin((() => {
6960
7299
  return false;
6961
7300
  }
6962
7301
  }
7302
+ async list(isUserLevel) {
7303
+ let raw;
7304
+ try {
7305
+ raw = execSync("powershell -Command \"Get-CimInstance Win32_Service -Filter \\\"Name LIKE 'kici-%'\\\" | Select-Object Name,Description | ConvertTo-Json\"", { stdio: "pipe" }).toString();
7306
+ } catch {
7307
+ return [];
7308
+ }
7309
+ if (!raw.trim()) return [];
7310
+ let parsed;
7311
+ try {
7312
+ parsed = JSON.parse(raw);
7313
+ } catch {
7314
+ return [];
7315
+ }
7316
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
7317
+ const out = [];
7318
+ for (const row of rows) {
7319
+ if (!row || typeof row !== "object") continue;
7320
+ const r = row;
7321
+ const match = (typeof r.Description === "string" ? r.Description : "").match(/^\[KiCI:(orchestrator|agent)\]/);
7322
+ if (!match) continue;
7323
+ if (typeof r.Name !== "string") continue;
7324
+ out.push({
7325
+ name: r.Name,
7326
+ platform: "windows",
7327
+ isUserLevel,
7328
+ component: match[1]
7329
+ });
7330
+ }
7331
+ return out;
7332
+ }
6963
7333
  };
6964
7334
  }));
6965
7335
  //#endregion
@@ -6982,11 +7352,11 @@ var compose_exports = /* @__PURE__ */ __exportAll({ ComposeServiceManager: () =>
6982
7352
  function detectRuntime() {
6983
7353
  try {
6984
7354
  execSync("podman compose version", { stdio: "pipe" });
6985
- return "podman compose";
7355
+ return "podman";
6986
7356
  } catch {}
6987
7357
  try {
6988
7358
  execSync("docker compose version", { stdio: "pipe" });
6989
- return "docker compose";
7359
+ return "docker";
6990
7360
  } catch {}
6991
7361
  throw new Error("No container runtime found. Install Docker or Podman with compose support.");
6992
7362
  }
@@ -7029,7 +7399,7 @@ function getRestartMode(config) {
7029
7399
  */
7030
7400
  function generateComposeYaml(config) {
7031
7401
  const restartMode = getRestartMode(config);
7032
- return [
7402
+ const lines = [
7033
7403
  "# Generated by KiCI service installer",
7034
7404
  `# Service: ${config.displayName}`,
7035
7405
  "",
@@ -7043,20 +7413,25 @@ function generateComposeYaml(config) {
7043
7413
  " volumes:",
7044
7414
  ` - ${config.workingDirectory}:${config.workingDirectory}`,
7045
7415
  " network_mode: host"
7046
- ].join("\n") + "\n";
7416
+ ];
7417
+ if (config.component) {
7418
+ lines.push(" labels:");
7419
+ lines.push(` dev.kici.component: ${config.component}`);
7420
+ }
7421
+ return lines.join("\n") + "\n";
7047
7422
  }
7048
7423
  var ComposeServiceManager;
7049
7424
  var init_compose = __esmMin((() => {
7050
7425
  ComposeServiceManager = class {
7051
7426
  runtime = null;
7052
- /** Get the runtime command, detecting it if not already done. */
7427
+ /** Get the runtime binary (`podman` or `docker`), detecting it if not already done. */
7053
7428
  getRuntime() {
7054
7429
  if (!this.runtime) this.runtime = detectRuntime();
7055
7430
  return this.runtime;
7056
7431
  }
7057
7432
  /** Run a compose command for the given service config. */
7058
7433
  runCompose(config, args) {
7059
- execSync(`${this.getRuntime()} -f "${getComposeFilePath(config)}" ${args}`, { stdio: "pipe" });
7434
+ execSync(`${this.getRuntime()} compose -f "${getComposeFilePath(config)}" ${args}`, { stdio: "pipe" });
7060
7435
  }
7061
7436
  async install(config) {
7062
7437
  this.getRuntime();
@@ -7084,7 +7459,7 @@ var init_compose = __esmMin((() => {
7084
7459
  }
7085
7460
  async status(config) {
7086
7461
  try {
7087
- const output = execSync(`${this.getRuntime()} -f "${getComposeFilePath(config)}" ps --format json`, { stdio: "pipe" }).toString();
7462
+ const output = execSync(`${this.getRuntime()} compose -f "${getComposeFilePath(config)}" ps --format json`, { stdio: "pipe" }).toString();
7088
7463
  const data = JSON.parse(output);
7089
7464
  const container = Array.isArray(data) ? data[0] : data;
7090
7465
  if (!container) return { state: "stopped" };
@@ -7108,6 +7483,44 @@ var init_compose = __esmMin((() => {
7108
7483
  const composeFile = getComposeFilePath(config);
7109
7484
  return fs.existsSync(composeFile);
7110
7485
  }
7486
+ async list(isUserLevel) {
7487
+ let runtime;
7488
+ try {
7489
+ runtime = this.getRuntime();
7490
+ } catch {
7491
+ return [];
7492
+ }
7493
+ let raw;
7494
+ try {
7495
+ raw = execSync(`${runtime} ps -a --filter label=dev.kici.component --format '{{json .}}'`, { encoding: "utf-8" }).toString();
7496
+ } catch {
7497
+ return [];
7498
+ }
7499
+ const out = [];
7500
+ for (const line of raw.split("\n")) {
7501
+ const trimmed = line.trim();
7502
+ if (!trimmed) continue;
7503
+ let row;
7504
+ try {
7505
+ row = JSON.parse(trimmed);
7506
+ } catch {
7507
+ continue;
7508
+ }
7509
+ if (!row || typeof row !== "object") continue;
7510
+ const r = row;
7511
+ const m = (typeof r.Labels === "string" ? r.Labels : "").match(/dev\.kici\.component=(orchestrator|agent)/);
7512
+ if (!m) continue;
7513
+ const name = typeof r.Names === "string" ? r.Names : String(r.Names ?? "");
7514
+ if (!name) continue;
7515
+ out.push({
7516
+ name,
7517
+ platform: "compose",
7518
+ isUserLevel,
7519
+ component: m[1]
7520
+ });
7521
+ }
7522
+ return out;
7523
+ }
7111
7524
  };
7112
7525
  }));
7113
7526
  //#endregion
@@ -7172,976 +7585,1114 @@ function resolveServiceExecutable(opts) {
7172
7585
  };
7173
7586
  }
7174
7587
  //#endregion
7175
- //#region src/cli/wizard/prompts.ts
7588
+ //#region src/cli/commands/shared/versioned-upgrade.ts
7176
7589
  /**
7177
- * Shared prompt utilities for the setup wizards.
7590
+ * Shared versioned directory upgrade logic for kici-admin upgrade commands.
7178
7591
  *
7179
- * Wraps @inquirer/prompts with consistent formatting and
7180
- * validation for common input types (DB URLs, ports, etc.).
7592
+ * Implements the versioned directory layout:
7593
+ * - Extract new version alongside old versions
7594
+ * - Update symlink (Unix) or service registration (Windows) atomically
7595
+ * - Preserve old versions for rollback
7596
+ * - Optional cleanup of old versions
7181
7597
  */
7182
- /** Prompt for a PostgreSQL database URL with validation. */
7183
- async function promptDbUrl() {
7184
- return input({
7185
- message: "PostgreSQL database URL:",
7186
- validate: (value) => {
7187
- const v = value.trim();
7188
- if (!v.startsWith("postgresql://") && !v.startsWith("postgres://")) return "Must start with postgresql:// or postgres://";
7189
- return true;
7190
- }
7191
- });
7192
- }
7193
- /** Prompt for a port number with validation. */
7194
- async function promptPort(defaultPort) {
7195
- const value = await input({
7196
- message: "Port:",
7197
- default: String(defaultPort),
7198
- validate: (value) => {
7199
- const n = parseInt(value.trim(), 10);
7200
- if (isNaN(n) || n < 1 || n > 65535) return "Must be a number between 1 and 65535";
7201
- return true;
7202
- }
7203
- });
7204
- return parseInt(value.trim(), 10);
7205
- }
7206
- /** Prompt for a yes/no confirmation. */
7207
- async function promptConfirm(message, defaultValue = true) {
7208
- return confirm({
7209
- message,
7210
- default: defaultValue
7598
+ /**
7599
+ * Resolve the upgrade target via the folder-anchored model and build the
7600
+ * ServiceConfig + installBase the rest of the upgrade flow needs.
7601
+ *
7602
+ * Priority chain (delegated to {@link resolveInstance}):
7603
+ * 1. `opts.instanceDir` read manifest at that path.
7604
+ * 2. `opts.name` — match against listInstances() output.
7605
+ * 3. CWD manifest — read `./.kici-<component>.json`.
7606
+ * 4. otherwise — refuse with a candidate-list error.
7607
+ *
7608
+ * The returned `installBase` comes from the manifest, NEVER re-derived from
7609
+ * the service name. Instances installed with a non-default base must
7610
+ * continue to resolve to that base on upgrade.
7611
+ */
7612
+ async function resolveUpgradeTarget(args) {
7613
+ const { component, opts, manager, isUserLevel, kiciRoot } = args;
7614
+ const resolved = await resolveInstance({
7615
+ component,
7616
+ opts: {
7617
+ instanceDir: opts.instanceDir,
7618
+ name: opts.name
7619
+ },
7620
+ cwd: process.cwd(),
7621
+ kiciRoot,
7622
+ manager,
7623
+ isUserLevel
7211
7624
  });
7625
+ return {
7626
+ config: {
7627
+ name: resolved.manifest.name,
7628
+ displayName: `KiCI ${component}`,
7629
+ description: `KiCI ${component} service`,
7630
+ executablePath: "",
7631
+ envFilePath: resolved.manifest.envFilePath,
7632
+ workingDirectory: resolved.manifest.configDir,
7633
+ isUserLevel: resolved.manifest.isUserLevel,
7634
+ restartPolicy: {
7635
+ enabled: true,
7636
+ delays: [
7637
+ 1,
7638
+ 5,
7639
+ 15,
7640
+ 30
7641
+ ],
7642
+ maxRetries: 5,
7643
+ windowSeconds: 300
7644
+ },
7645
+ component
7646
+ },
7647
+ installBase: resolved.manifest.installBase,
7648
+ resolvedInstance: resolved
7649
+ };
7212
7650
  }
7213
- /** Prompt for a URL with http(s):// validation. */
7214
- async function promptUrl(message, defaultValue) {
7215
- return input({
7216
- message,
7217
- default: defaultValue,
7218
- validate: (value) => {
7219
- const v = value.trim();
7220
- if (!v.startsWith("http://") && !v.startsWith("https://") && !v.startsWith("wss://") && !v.startsWith("ws://")) return "Must start with http://, https://, ws://, or wss://";
7221
- return true;
7222
- }
7651
+ /** Prompt the user for confirmation (returns true if yes). */
7652
+ async function confirm$2(message) {
7653
+ const rl = createInterface({
7654
+ input: process.stdin,
7655
+ output: process.stdout
7223
7656
  });
7224
- }
7225
- /** Prompt for a secret/password (masked input). */
7226
- async function promptSecret(message) {
7227
- return password({
7228
- message,
7229
- mask: "*",
7230
- validate: (value) => {
7231
- if (!value.trim()) return "This field is required";
7232
- return true;
7233
- }
7657
+ return new Promise((resolve) => {
7658
+ rl.question(`${message} [y/N] `, (answer) => {
7659
+ rl.close();
7660
+ resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
7661
+ });
7234
7662
  });
7235
7663
  }
7236
- /** Prompt for a selection from a list of options. */
7237
- async function promptSelect(message, choices, defaultValue) {
7238
- return select({
7239
- message,
7240
- choices,
7241
- default: defaultValue
7242
- });
7664
+ /** Download a file from a URL to a local path. */
7665
+ async function downloadArchive(url, destPath) {
7666
+ console.log(`Downloading from ${url}...`);
7667
+ const res = await fetch(url);
7668
+ if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
7669
+ const fileStream = createWriteStream(destPath);
7670
+ await pipeline(res.body, fileStream);
7671
+ console.log(`Downloaded to ${destPath}`);
7243
7672
  }
7244
- var init_prompts = __esmMin((() => {}));
7245
- //#endregion
7246
- //#region src/cli/wizard/orchestrator-wizard.ts
7247
7673
  /**
7248
- * Interactive wizard for orchestrator service setup.
7674
+ * Install base for a KiCI component instance.
7249
7675
  *
7250
- * Walks the user through essential configuration (mode, DB URL,
7251
- * port, secrets key) with sensible defaults. Returns a config
7252
- * object that the install command uses to write the env file.
7676
+ * Name-scoped so that two instances of the same component (e.g. an org's
7677
+ * dogfood orchestrator and an E2E test orchestrator) own independent
7678
+ * versioned trees and symlinks. Per-platform bases:
7679
+ * - systemd / compose: /opt/kici/<name>/
7680
+ * - launchd: /usr/local/kici/<name>/
7681
+ * - windows: C:\Program Files\KiCI\<name>\
7253
7682
  */
7254
- var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorWizard: () => runOrchestratorWizard });
7683
+ function getInstallBase(platform, name) {
7684
+ const sep = platform === "windows" ? "\\" : "/";
7685
+ switch (platform) {
7686
+ case "systemd":
7687
+ case "compose": return `/opt/kici/${name}${sep}`;
7688
+ case "launchd": return `/usr/local/kici/${name}${sep}`;
7689
+ case "windows": return `C:\\Program Files\\KiCI\\${name}${sep}`;
7690
+ }
7691
+ }
7692
+ /** Check if the platform is Windows. */
7693
+ function isWindows(platform) {
7694
+ return platform === "windows";
7695
+ }
7696
+ /** Get the launcher script name for a component. */
7697
+ function getLauncherName(component, platform) {
7698
+ const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
7699
+ return isWindows(platform) ? `${baseName}.cmd` : baseName;
7700
+ }
7255
7701
  /**
7256
- * Run the interactive orchestrator setup wizard.
7257
- *
7258
- * Asks only essential questions per the user decision:
7259
- * 1. Mode (platform/hybrid/independent)
7260
- * 2. Database URL
7261
- * 3. Port
7262
- * 4. Secrets encryption key
7263
- * 5. Bootstrap admin token (for kici-admin authentication)
7264
- * 6. Platform URL + token (if platform/hybrid mode)
7265
- * 7. Webhook secret (if hybrid/independent mode)
7702
+ * Extract an archive (.tar.gz or .zip) to a destination directory.
7703
+ * Returns the name of the top-level directory inside the archive.
7266
7704
  */
7267
- async function runOrchestratorWizard() {
7268
- console.log("");
7269
- console.log("KiCI orchestrator setup wizard");
7270
- console.log("==============================");
7271
- console.log("");
7272
- const mode = await promptSelect("Operating mode:", [
7273
- {
7274
- name: "Platform relay (recommended)",
7275
- value: "platform",
7276
- description: "Connect to KiCI Platform for webhook routing"
7277
- },
7278
- {
7279
- name: "Hybrid",
7280
- value: "hybrid",
7281
- description: "Platform relay + direct webhooks"
7282
- },
7283
- {
7284
- name: "Independent",
7285
- value: "independent",
7286
- description: "Self-hosted, no Platform dependency"
7287
- }
7288
- ], "platform");
7289
- console.log("");
7290
- const databaseUrl = await promptDbUrl();
7291
- const port = await promptPort(4e3);
7292
- console.log("");
7293
- const generatedKey = randomBytes(32).toString("hex");
7294
- console.log(`Generated secrets key: ${generatedKey}`);
7295
- const useGenerated = await promptConfirm("Use this key?");
7296
- let secretsKey;
7297
- if (useGenerated) secretsKey = generatedKey;
7298
- else secretsKey = await promptSecret("Enter custom secrets encryption key (64 hex chars):");
7299
- console.log("");
7300
- const generatedAdminToken = randomBytes(32).toString("hex");
7301
- console.log(`Generated bootstrap admin token: ${generatedAdminToken}`);
7302
- const useGeneratedAdminToken = await promptConfirm("Use this token?");
7303
- let bootstrapAdminToken;
7304
- if (useGeneratedAdminToken) bootstrapAdminToken = generatedAdminToken;
7305
- else bootstrapAdminToken = await promptSecret("Enter custom bootstrap admin token:");
7306
- let platformUrl;
7307
- let platformToken;
7308
- if (mode === "platform" || mode === "hybrid") {
7309
- console.log("");
7310
- platformUrl = await promptUrl("Platform relay URL:", "wss://platform.kici.dev");
7311
- platformToken = await promptSecret("Platform authentication token:");
7312
- }
7313
- let source;
7314
- console.log("");
7315
- if (await promptConfirm("Add a GitHub App source?", false)) {
7316
- const sourceName = await input({ message: "Source name (e.g. main-org):" });
7317
- const appId = await input({
7318
- message: "GitHub App ID:",
7319
- validate: (v) => /^\d+$/.test(v.trim()) ? true : "Must be a numeric App ID"
7320
- });
7321
- const privateKeyPath = await input({
7322
- message: "Path to private key file (.pem):",
7323
- validate: (v) => v.trim() ? true : "Path is required"
7324
- });
7325
- const sourceWebhookSecret = await promptSecret("Webhook secret:");
7326
- let privateKey;
7327
- try {
7328
- privateKey = (await readFile(privateKeyPath.trim(), "utf-8")).trim();
7329
- } catch (err) {
7330
- throw new Error(`Failed to read private key from ${privateKeyPath}: ${err}`);
7331
- }
7332
- source = {
7333
- provider: "github",
7334
- name: sourceName.trim(),
7335
- appId: appId.trim(),
7336
- privateKey,
7337
- webhookSecret: sourceWebhookSecret
7338
- };
7339
- }
7340
- console.log("");
7341
- console.log("Configuration complete. Summary:");
7342
- console.log(` Mode: ${mode}`);
7343
- console.log(` Database: ${databaseUrl.replace(/:[^@]*@/, ":***@")}`);
7344
- console.log(` Port: ${port}`);
7345
- console.log(` Admin token: ${bootstrapAdminToken.slice(0, 8)}…`);
7346
- console.log(` Platform: ${platformUrl ?? "N/A"}`);
7347
- if (source) console.log(` Source: ${source.provider}:${source.appId} (${source.name})`);
7348
- console.log("");
7349
- return {
7350
- mode,
7351
- databaseUrl,
7352
- port,
7353
- secretsKey,
7354
- bootstrapAdminToken,
7355
- platformUrl,
7356
- platformToken,
7357
- source
7358
- };
7705
+ function extractArchive(archivePath, destDir) {
7706
+ fs.mkdirSync(destDir, { recursive: true });
7707
+ if (archivePath.endsWith(".zip")) if (os.platform() === "win32") execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: "inherit" });
7708
+ else execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: "inherit" });
7709
+ else execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "inherit" });
7710
+ const dirs = fs.readdirSync(destDir).filter((e) => fs.statSync(path.join(destDir, e)).isDirectory());
7711
+ if (dirs.length === 0) throw new Error("Archive does not contain a directory");
7712
+ return dirs[0];
7359
7713
  }
7360
- var init_orchestrator_wizard = __esmMin((() => {
7361
- init_prompts();
7362
- }));
7363
- //#endregion
7364
- //#region src/cli/commands/orchestrator-service/install.ts
7365
7714
  /**
7366
- * Spin up a dev PostgreSQL container using Docker or Podman.
7367
- * Returns the DATABASE_URL for the container.
7715
+ * List installed versions for a component by scanning the install base directory
7716
+ * for directories matching `{component}-{version}/`.
7368
7717
  */
7369
- function startDevPostgres(containerName) {
7370
- const password = crypto.randomBytes(16).toString("hex");
7371
- const port = 15432;
7372
- let runtime = "podman";
7373
- try {
7374
- execSync("podman --version", { stdio: "ignore" });
7375
- } catch {
7376
- runtime = "docker";
7718
+ function listInstalledVersions(installBase, component) {
7719
+ if (!fs.existsSync(installBase)) return [];
7720
+ const prefix = `${component}-`;
7721
+ return fs.readdirSync(installBase).filter((entry) => {
7722
+ if (!entry.startsWith(prefix)) return false;
7723
+ const fullPath = path.join(installBase, entry);
7724
+ return fs.statSync(fullPath).isDirectory();
7725
+ }).map((entry) => entry.slice(prefix.length)).sort();
7726
+ }
7727
+ /**
7728
+ * Read the current symlink target to determine the active version.
7729
+ * Returns null if no symlink exists or on Windows.
7730
+ */
7731
+ function getCurrentVersion(installBase, component, platform) {
7732
+ if (isWindows(platform)) {
7733
+ const versionFile = path.join(installBase, `${component}-current-version.txt`);
7377
7734
  try {
7378
- execSync("docker --version", { stdio: "ignore" });
7735
+ return fs.readFileSync(versionFile, "utf-8").trim();
7379
7736
  } catch {
7380
- throw new Error("Neither podman nor docker found. Install one to use --dev mode.");
7737
+ return null;
7381
7738
  }
7382
7739
  }
7740
+ const symlinkPath = path.join(installBase, component);
7383
7741
  try {
7384
- if (execSync(`${runtime} ps -a --filter name=${containerName} --format "{{.Names}}"`, { encoding: "utf-8" }).trim()) {
7385
- console.log(`Dev PostgreSQL container "${containerName}" already exists.`);
7386
- console.log(`Remove it with: ${runtime} rm -f ${containerName}`);
7387
- throw new Error(`Container "${containerName}" already exists`);
7388
- }
7742
+ const target = fs.readlinkSync(symlinkPath);
7743
+ const prefix = `${component}-`;
7744
+ if (target.startsWith(prefix)) return target.slice(prefix.length);
7745
+ const basename = path.basename(target);
7746
+ if (basename.startsWith(prefix)) return basename.slice(prefix.length);
7747
+ } catch {}
7748
+ return null;
7749
+ }
7750
+ /** Write the current version to a tracking file (used on Windows). */
7751
+ function writeCurrentVersion(installBase, component, version) {
7752
+ const versionFile = path.join(installBase, `${component}-current-version.txt`);
7753
+ fs.writeFileSync(versionFile, version, "utf-8");
7754
+ }
7755
+ /**
7756
+ * Update the symlink atomically on Unix.
7757
+ * Creates a temporary symlink then renames it over the existing one.
7758
+ */
7759
+ function updateSymlinkAtomic(installBase, component, version) {
7760
+ const symlinkPath = path.join(installBase, component);
7761
+ const tmpLink = `${symlinkPath}.tmp.${Date.now()}`;
7762
+ const target = `${component}-${version}`;
7763
+ try {
7764
+ fs.symlinkSync(target, tmpLink);
7765
+ fs.renameSync(tmpLink, symlinkPath);
7389
7766
  } catch (err) {
7390
- if (err instanceof Error && err.message.includes("already exists")) throw err;
7767
+ try {
7768
+ fs.unlinkSync(tmpLink);
7769
+ } catch {}
7770
+ throw err;
7391
7771
  }
7392
- console.log(`Starting dev PostgreSQL container "${containerName}" on port ${port}...`);
7393
- execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 -e POSTGRES_PASSWORD=${password} -e POSTGRES_DB=kici postgres:18-trixie`, { stdio: "inherit" });
7394
- return `postgresql://postgres:${password}@localhost:${port}/kici`;
7395
7772
  }
7396
- function registerOrchestratorInstall(orchestrator) {
7397
- orchestrator.command("install").description("Install the orchestrator as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to orchestrator binary (default: current executable)").option("--dev", "Dev mode: spin up PostgreSQL container on port 15432").option("--wizard", "Interactive wizard for guided setup").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--user <name>", "Run the service as the named user (system-level launchd only; sets UserName in plist so the daemon drops privileges)").action(async (opts) => {
7398
- try {
7399
- if (opts.wizard && opts.envFile) {
7400
- console.error("Error: Cannot use --wizard with --env-file");
7773
+ /**
7774
+ * Perform a versioned directory upgrade for a KiCI component.
7775
+ *
7776
+ * Flow:
7777
+ * 1. Parse upgrade source (--from archive or --url)
7778
+ * 2. Determine install base by platform
7779
+ * 3. Extract new versioned directory
7780
+ * 4. Stop service
7781
+ * 5. Update symlink (Unix) or service registration (Windows)
7782
+ * 6. Start service
7783
+ */
7784
+ async function performVersionedUpgrade(component, opts) {
7785
+ try {
7786
+ const platform = detectPlatform(opts.platform);
7787
+ const manager = await createServiceManager(platform);
7788
+ const userLevel = !isRoot();
7789
+ const kiciRoot = kiciConfigRoot(userLevel);
7790
+ const { config, installBase, resolvedInstance } = await resolveUpgradeTarget({
7791
+ component,
7792
+ opts: {
7793
+ instanceDir: opts.instanceDir,
7794
+ name: opts.name
7795
+ },
7796
+ manager,
7797
+ isUserLevel: userLevel,
7798
+ kiciRoot
7799
+ });
7800
+ if (!isWindows(platform) && !userLevel && !isRoot()) {
7801
+ console.error("Error: root privileges required to upgrade system-level services");
7802
+ process.exit(1);
7803
+ }
7804
+ if (opts.rollback) {
7805
+ await handleRollback(component, platform, installBase, config, manager, opts);
7806
+ return;
7807
+ }
7808
+ if (opts.cleanup) {
7809
+ await handleCleanup(component, platform, installBase);
7810
+ return;
7811
+ }
7812
+ if (!opts.from && !opts.url) {
7813
+ console.error("Error: provide --from <archive-path> or --url <url> for the upgrade package");
7814
+ process.exit(1);
7815
+ }
7816
+ if (!opts.version) {
7817
+ console.error("Error: --version is required to specify the target version");
7818
+ process.exit(1);
7819
+ }
7820
+ const version = opts.version;
7821
+ const versionedDirName = `${component}-${version}`;
7822
+ const versionedDirPath = path.join(installBase, versionedDirName);
7823
+ if (fs.existsSync(versionedDirPath)) if (opts.force) {
7824
+ console.log(`Removing existing directory ${versionedDirPath} (--force)`);
7825
+ fs.rmSync(versionedDirPath, {
7826
+ recursive: true,
7827
+ force: true
7828
+ });
7829
+ } else {
7830
+ console.error(`Error: version directory already exists: ${versionedDirPath}`);
7831
+ console.error("Use --force to overwrite.");
7832
+ process.exit(1);
7833
+ }
7834
+ if (!await manager.isInstalled(config)) {
7835
+ console.error(`Error: service "${config.name}" is not installed`);
7836
+ process.exit(1);
7837
+ }
7838
+ let archivePath;
7839
+ const tmpDir = path.join(os.tmpdir(), `kici-upgrade-${Date.now()}`);
7840
+ fs.mkdirSync(tmpDir, { recursive: true });
7841
+ if (opts.from) {
7842
+ archivePath = path.resolve(opts.from);
7843
+ if (!fs.existsSync(archivePath)) {
7844
+ console.error(`Error: archive not found at ${archivePath}`);
7401
7845
  process.exit(1);
7402
7846
  }
7403
- const platform = detectPlatform(opts.platform);
7404
- const userLevel = resolveUserLevel(opts);
7405
- const serviceName = opts.name;
7406
- console.log(`Platform: ${platform}`);
7407
- console.log(`Privilege: ${userLevel ? "user" : "system"}`);
7408
- console.log(`Service name: ${serviceName}`);
7409
- const configDir = getConfigDir(serviceName, userLevel);
7410
- const logDir = getLogDir(serviceName, userLevel);
7411
- fs.mkdirSync(configDir, { recursive: true });
7412
- fs.mkdirSync(logDir, { recursive: true });
7413
- const envFilePath = path.join(configDir, `${serviceName}.env`);
7414
- let devDbUrl;
7415
- if (opts.dev) {
7416
- devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
7417
- console.log(`Dev PostgreSQL URL: ${devDbUrl}`);
7418
- }
7419
- if (opts.wizard) {
7420
- const { runOrchestratorWizard } = await Promise.resolve().then(() => (init_orchestrator_wizard(), orchestrator_wizard_exports));
7421
- const wizardConfig = await runOrchestratorWizard();
7422
- let envContent = "# KiCI orchestrator configuration (generated by setup wizard)\n";
7423
- envContent += `KICI_MODE=${wizardConfig.mode}\n`;
7424
- envContent += `KICI_DATABASE_URL=${wizardConfig.databaseUrl}\n`;
7425
- envContent += `KICI_PORT=${wizardConfig.port}\n`;
7426
- envContent += `KICI_SECRET_KEY=${wizardConfig.secretsKey}\n`;
7427
- envContent += `KICI_BOOTSTRAP_ADMIN_TOKEN=${wizardConfig.bootstrapAdminToken}\n`;
7428
- if (wizardConfig.platformUrl) envContent += `KICI_PLATFORM_URL=${wizardConfig.platformUrl}\n`;
7429
- if (wizardConfig.platformToken) envContent += `KICI_PLATFORM_TOKEN=${wizardConfig.platformToken}\n`;
7430
- fs.writeFileSync(envFilePath, envContent, "utf-8");
7431
- console.log(`Wrote wizard configuration to ${envFilePath}`);
7432
- } else if (opts.envFile) {
7433
- const source = path.resolve(opts.envFile);
7434
- if (!fs.existsSync(source)) {
7435
- console.error(`Error: env file not found: ${source}`);
7436
- process.exit(1);
7437
- }
7438
- fs.copyFileSync(source, envFilePath);
7439
- console.log(`Copied env file to ${envFilePath}`);
7440
- } else if (!fs.existsSync(envFilePath)) {
7441
- let envContent = `# KiCI orchestrator configuration\n# See docs for all available options\n`;
7442
- if (devDbUrl) envContent += `KICI_DATABASE_URL=${devDbUrl}\n`;
7443
- fs.writeFileSync(envFilePath, envContent, "utf-8");
7444
- console.log(`Created env file at ${envFilePath}`);
7445
- } else if (devDbUrl) {
7446
- fs.appendFileSync(envFilePath, `\nKICI_DATABASE_URL=${devDbUrl}\n`);
7447
- console.log(`Appended KICI_DATABASE_URL to ${envFilePath}`);
7847
+ } else {
7848
+ const ext = opts.url.endsWith(".zip") ? ".zip" : ".tar.gz";
7849
+ archivePath = path.join(tmpDir, `${component}-${version}${ext}`);
7850
+ await downloadArchive(opts.url, archivePath);
7851
+ }
7852
+ const currentVersion = getCurrentVersion(installBase, component, platform);
7853
+ if (!opts.yes) {
7854
+ console.log(`This will upgrade "${config.name}" to version ${version}:`);
7855
+ if (currentVersion) console.log(` Current version: ${currentVersion}`);
7856
+ console.log(` New version: ${version}`);
7857
+ console.log(` Install path: ${versionedDirPath}`);
7858
+ console.log(" The service will be stopped during upgrade.");
7859
+ console.log("");
7860
+ if (!await confirm$2("Proceed with upgrade?")) {
7861
+ console.log("Upgrade cancelled.");
7862
+ return;
7448
7863
  }
7449
- const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`));
7450
- const { executablePath, args } = resolveServiceExecutable({
7451
- binary: opts.binary ? path.resolve(opts.binary) : void 0,
7452
- nodePath: process.execPath,
7453
- entryScript
7454
- });
7455
- if (userLevel) try {
7456
- const envContent = fs.readFileSync(envFilePath, "utf-8");
7457
- if (envContent.includes("firecracker") || envContent.includes("FIRECRACKER")) {
7458
- console.warn("\nWARNING: Firecracker scaler requires root privileges.");
7459
- console.warn("The service is being installed at user level. Firecracker will not work.");
7460
- console.warn("Re-run as root (sudo) to install a system-level service.\n");
7461
- }
7462
- } catch {}
7463
- const config = {
7464
- name: serviceName,
7465
- displayName: "KiCI Orchestrator",
7466
- description: "KiCI CI/CD workflow orchestrator service",
7467
- executablePath,
7468
- args,
7469
- nodeBinDir: path.dirname(process.execPath),
7470
- envFilePath,
7471
- workingDirectory: configDir,
7472
- isUserLevel: userLevel,
7473
- user: opts.user,
7474
- restartPolicy: DEFAULT_RESTART_POLICY
7475
- };
7476
- await (await createServiceManager(platform)).install(config);
7477
- console.log(`\nOrchestrator service "${serviceName}" installed successfully.`);
7478
- console.log(` Config: ${envFilePath}`);
7479
- console.log(` Logs: ${logDir}`);
7480
- console.log(`\nNext steps:`);
7481
- console.log(` 1. Edit ${envFilePath} with your configuration`);
7482
- console.log(` 2. Run \`kici-admin orchestrator start\` to start the service`);
7483
- } catch (err) {
7484
- console.error(`Error: ${toErrorMessage(err)}`);
7485
- process.exit(1);
7486
7864
  }
7487
- });
7865
+ console.log("Extracting archive...");
7866
+ const extractDir = path.join(tmpDir, "extract");
7867
+ const extractedDirName = extractArchive(archivePath, extractDir);
7868
+ fs.mkdirSync(installBase, { recursive: true });
7869
+ const srcDir = path.join(extractDir, extractedDirName);
7870
+ if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
7871
+ else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
7872
+ console.log(`Extracted to ${versionedDirPath}`);
7873
+ console.log("Stopping service...");
7874
+ if ((await manager.status(config)).state === "running") {
7875
+ await manager.stop(config);
7876
+ console.log("Service stopped.");
7877
+ }
7878
+ if (isWindows(platform)) {
7879
+ const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
7880
+ config.executablePath = launcherPath;
7881
+ await manager.uninstall(config);
7882
+ await new Promise((r) => setTimeout(r, 2e3));
7883
+ await manager.install(config);
7884
+ writeCurrentVersion(installBase, component, version);
7885
+ console.log(`Service registration updated to ${launcherPath}`);
7886
+ } else {
7887
+ updateSymlinkAtomic(installBase, component, version);
7888
+ const symlinkPath = path.join(installBase, component);
7889
+ console.log(`Symlink updated: ${symlinkPath} -> ${versionedDirName}`);
7890
+ const launcherPath = path.join(symlinkPath, getLauncherName(component, platform));
7891
+ if (fs.existsSync(launcherPath)) fs.chmodSync(launcherPath, 493);
7892
+ config.executablePath = path.join(installBase, component, getLauncherName(component, platform));
7893
+ }
7894
+ console.log("Starting service...");
7895
+ await manager.start(config);
7896
+ console.log("Service started.");
7897
+ writeManifest(resolvedInstance.instanceDir, {
7898
+ ...resolvedInstance.manifest,
7899
+ kiciVersion: version
7900
+ });
7901
+ fs.rmSync(tmpDir, {
7902
+ recursive: true,
7903
+ force: true
7904
+ });
7905
+ console.log("");
7906
+ if (currentVersion) {
7907
+ console.log(`Upgrade complete: ${currentVersion} -> ${version}`);
7908
+ console.log(`Previous version preserved at: ${path.join(installBase, `${component}-${currentVersion}`)}`);
7909
+ } else console.log(`Upgrade to ${version} complete.`);
7910
+ } catch (err) {
7911
+ console.error(`Error: ${toErrorMessage(err)}`);
7912
+ process.exit(1);
7913
+ }
7914
+ }
7915
+ /**
7916
+ * Handle --rollback: switch symlink to the previous version and restart.
7917
+ */
7918
+ async function handleRollback(component, platform, installBase, config, manager, opts) {
7919
+ const versions = listInstalledVersions(installBase, component);
7920
+ if (versions.length < 2) {
7921
+ console.error("Error: no previous version available for rollback");
7922
+ if (versions.length === 1) console.error(`Only version installed: ${versions[0]}`);
7923
+ process.exit(1);
7924
+ }
7925
+ const currentVersion = getCurrentVersion(installBase, component, platform);
7926
+ if (!currentVersion) {
7927
+ console.error("Error: cannot determine current version (no symlink found)");
7928
+ console.log("Available versions:");
7929
+ for (const v of versions) console.log(` ${component}-${v}/`);
7930
+ process.exit(1);
7931
+ }
7932
+ const currentIdx = versions.indexOf(currentVersion);
7933
+ let previousVersion;
7934
+ if (currentIdx > 0) previousVersion = versions[currentIdx - 1];
7935
+ else if (versions.length >= 2) previousVersion = versions[1];
7936
+ else {
7937
+ console.error("Error: no alternative version available for rollback");
7938
+ process.exit(1);
7939
+ return;
7940
+ }
7941
+ if (!opts.yes) {
7942
+ console.log(`Rolling back "${config.name}":`);
7943
+ console.log(` Current version: ${currentVersion}`);
7944
+ console.log(` Rollback to: ${previousVersion}`);
7945
+ console.log("");
7946
+ if (!await confirm$2("Proceed with rollback?")) {
7947
+ console.log("Rollback cancelled.");
7948
+ return;
7949
+ }
7950
+ }
7951
+ console.log("Stopping service...");
7952
+ if ((await manager.status(config)).state === "running") {
7953
+ await manager.stop(config);
7954
+ console.log("Service stopped.");
7955
+ }
7956
+ if (isWindows(platform)) {
7957
+ const launcherPath = path.join(installBase, `${component}-${previousVersion}`, getLauncherName(component, platform));
7958
+ config.executablePath = launcherPath;
7959
+ await manager.uninstall(config);
7960
+ await manager.install(config);
7961
+ writeCurrentVersion(installBase, component, previousVersion);
7962
+ console.log(`Service registration updated to ${launcherPath}`);
7963
+ } else {
7964
+ updateSymlinkAtomic(installBase, component, previousVersion);
7965
+ console.log(`Symlink updated: ${path.join(installBase, component)} -> ${component}-${previousVersion}`);
7966
+ }
7967
+ console.log("Starting service...");
7968
+ await manager.start(config);
7969
+ console.log("Service started.");
7970
+ console.log("");
7971
+ console.log(`Rollback complete: ${currentVersion} -> ${previousVersion}`);
7972
+ }
7973
+ /**
7974
+ * Handle --cleanup: remove all versioned directories except the current
7975
+ * and previous versions.
7976
+ */
7977
+ async function handleCleanup(component, platform, installBase) {
7978
+ const versions = listInstalledVersions(installBase, component);
7979
+ if (versions.length <= 2) {
7980
+ console.log("Nothing to clean up (2 or fewer versions installed).");
7981
+ return;
7982
+ }
7983
+ const currentVersion = getCurrentVersion(installBase, component, platform);
7984
+ const currentIdx = currentVersion ? versions.indexOf(currentVersion) : versions.length - 1;
7985
+ const previousIdx = currentIdx > 0 ? currentIdx - 1 : -1;
7986
+ const toRemove = versions.filter((_, i) => i !== currentIdx && i !== previousIdx);
7987
+ if (toRemove.length === 0) {
7988
+ console.log("Nothing to clean up.");
7989
+ return;
7990
+ }
7991
+ console.log("The following versions will be removed:");
7992
+ for (const v of toRemove) console.log(` ${component}-${v}/`);
7993
+ if (currentVersion) console.log(`\nKeeping: ${component}-${currentVersion}/ (current)`);
7994
+ if (previousIdx >= 0) console.log(`Keeping: ${component}-${versions[previousIdx]}/ (previous)`);
7995
+ for (const v of toRemove) {
7996
+ const dirPath = path.join(installBase, `${component}-${v}`);
7997
+ fs.rmSync(dirPath, {
7998
+ recursive: true,
7999
+ force: true
8000
+ });
8001
+ console.log(`Removed ${dirPath}`);
8002
+ }
8003
+ console.log(`\nCleanup complete. Removed ${toRemove.length} old version(s).`);
7488
8004
  }
7489
8005
  //#endregion
7490
- //#region src/cli/commands/orchestrator-service/uninstall.ts
7491
- function registerOrchestratorUninstall(orchestrator) {
7492
- orchestrator.command("uninstall").description("Remove the orchestrator service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
7493
- try {
7494
- const platform = detectPlatform(opts.platform);
7495
- const userLevel = resolveUserLevel(opts);
7496
- const serviceName = opts.name;
7497
- const configDir = getConfigDir(serviceName, userLevel);
7498
- const logDir = getLogDir(serviceName, userLevel);
7499
- const config = {
7500
- name: serviceName,
7501
- displayName: "KiCI Orchestrator",
7502
- description: "KiCI CI/CD workflow orchestrator service",
7503
- executablePath: "",
7504
- envFilePath: `${configDir}${serviceName}.env`,
7505
- workingDirectory: configDir,
7506
- isUserLevel: userLevel,
7507
- restartPolicy: DEFAULT_RESTART_POLICY
7508
- };
7509
- const manager = await createServiceManager(platform);
7510
- if (!await manager.isInstalled(config)) {
7511
- console.log(`Service "${serviceName}" is not installed.`);
7512
- process.exit(0);
7513
- }
7514
- try {
7515
- if ((await manager.status(config)).state === "running") {
7516
- console.log(`Stopping service "${serviceName}"...`);
7517
- await manager.stop(config);
7518
- }
7519
- } catch {}
7520
- await manager.uninstall(config);
7521
- console.log(`\nOrchestrator service "${serviceName}" uninstalled.`);
7522
- console.log(`\nThe following files were preserved for manual cleanup:`);
7523
- console.log(` Config: ${configDir}`);
7524
- console.log(` Logs: ${logDir}`);
7525
- console.log(` Database: check your DATABASE_URL in the env file`);
7526
- } catch (err) {
7527
- console.error(`Error: ${toErrorMessage(err)}`);
7528
- process.exit(1);
8006
+ //#region src/cli/wizard/prompts.ts
8007
+ /**
8008
+ * Shared prompt utilities for the setup wizards.
8009
+ *
8010
+ * Wraps @inquirer/prompts with consistent formatting and
8011
+ * validation for common input types (DB URLs, ports, etc.).
8012
+ */
8013
+ /** Prompt for a PostgreSQL database URL with validation. */
8014
+ async function promptDbUrl() {
8015
+ return input({
8016
+ message: "PostgreSQL database URL:",
8017
+ validate: (value) => {
8018
+ const v = value.trim();
8019
+ if (!v.startsWith("postgresql://") && !v.startsWith("postgres://")) return "Must start with postgresql:// or postgres://";
8020
+ return true;
8021
+ }
8022
+ });
8023
+ }
8024
+ /** Prompt for a port number with validation. */
8025
+ async function promptPort(defaultPort) {
8026
+ const value = await input({
8027
+ message: "Port:",
8028
+ default: String(defaultPort),
8029
+ validate: (value) => {
8030
+ const n = parseInt(value.trim(), 10);
8031
+ if (isNaN(n) || n < 1 || n > 65535) return "Must be a number between 1 and 65535";
8032
+ return true;
7529
8033
  }
7530
8034
  });
8035
+ return parseInt(value.trim(), 10);
7531
8036
  }
7532
- //#endregion
7533
- //#region src/cli/commands/orchestrator-service/start.ts
7534
- function registerOrchestratorStart(orchestrator) {
7535
- orchestrator.command("start").description("Start the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
7536
- try {
7537
- const platform = detectPlatform(opts.platform);
7538
- const userLevel = resolveUserLevel(opts);
7539
- const serviceName = opts.name;
7540
- const configDir = getConfigDir(serviceName, userLevel);
7541
- const config = {
7542
- name: serviceName,
7543
- displayName: "KiCI Orchestrator",
7544
- description: "KiCI CI/CD workflow orchestrator service",
7545
- executablePath: "",
7546
- envFilePath: path.join(configDir, `${serviceName}.env`),
7547
- workingDirectory: configDir,
7548
- isUserLevel: userLevel,
7549
- restartPolicy: DEFAULT_RESTART_POLICY
7550
- };
7551
- const manager = await createServiceManager(platform);
7552
- if (!await manager.isInstalled(config)) {
7553
- console.error(`Error: service "${serviceName}" is not installed.`);
7554
- console.error(`Run \`kici-admin orchestrator install\` first.`);
7555
- process.exit(1);
7556
- }
7557
- await manager.start(config);
7558
- console.log(`Orchestrator service "${serviceName}" started.`);
7559
- } catch (err) {
7560
- console.error(`Error: ${toErrorMessage(err)}`);
7561
- process.exit(1);
7562
- }
8037
+ /** Prompt for a yes/no confirmation. */
8038
+ async function promptConfirm(message, defaultValue = true) {
8039
+ return confirm({
8040
+ message,
8041
+ default: defaultValue
7563
8042
  });
7564
8043
  }
7565
- //#endregion
7566
- //#region src/cli/commands/orchestrator-service/stop.ts
7567
- function registerOrchestratorStop(orchestrator) {
7568
- orchestrator.command("stop").description("Stop the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
7569
- try {
7570
- const platform = detectPlatform(opts.platform);
7571
- const userLevel = resolveUserLevel(opts);
7572
- const serviceName = opts.name;
7573
- const configDir = getConfigDir(serviceName, userLevel);
7574
- const config = {
7575
- name: serviceName,
7576
- displayName: "KiCI Orchestrator",
7577
- description: "KiCI CI/CD workflow orchestrator service",
7578
- executablePath: "",
7579
- envFilePath: `${configDir}${serviceName}.env`,
7580
- workingDirectory: configDir,
7581
- isUserLevel: userLevel,
7582
- restartPolicy: DEFAULT_RESTART_POLICY
7583
- };
7584
- await (await createServiceManager(platform)).stop(config);
7585
- console.log(`Orchestrator service "${serviceName}" stopped.`);
7586
- } catch (err) {
7587
- console.error(`Error: ${toErrorMessage(err)}`);
7588
- process.exit(1);
8044
+ /** Prompt for a URL with http(s):// validation. */
8045
+ async function promptUrl(message, defaultValue) {
8046
+ return input({
8047
+ message,
8048
+ default: defaultValue,
8049
+ validate: (value) => {
8050
+ const v = value.trim();
8051
+ if (!v.startsWith("http://") && !v.startsWith("https://") && !v.startsWith("wss://") && !v.startsWith("ws://")) return "Must start with http://, https://, ws://, or wss://";
8052
+ return true;
7589
8053
  }
7590
8054
  });
7591
8055
  }
7592
- //#endregion
7593
- //#region src/cli/commands/orchestrator-service/restart.ts
7594
- function registerOrchestratorRestart(orchestrator) {
7595
- orchestrator.command("restart").description("Restart the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
7596
- try {
7597
- const platform = detectPlatform(opts.platform);
7598
- const userLevel = resolveUserLevel(opts);
7599
- const serviceName = opts.name;
7600
- const configDir = getConfigDir(serviceName, userLevel);
7601
- const config = {
7602
- name: serviceName,
7603
- displayName: "KiCI Orchestrator",
7604
- description: "KiCI CI/CD workflow orchestrator service",
7605
- executablePath: "",
7606
- envFilePath: `${configDir}${serviceName}.env`,
7607
- workingDirectory: configDir,
7608
- isUserLevel: userLevel,
7609
- restartPolicy: DEFAULT_RESTART_POLICY
7610
- };
7611
- const manager = await createServiceManager(platform);
7612
- if (!await manager.isInstalled(config)) {
7613
- console.error(`Error: service "${serviceName}" is not installed.`);
7614
- console.error(`Run \`kici-admin orchestrator install\` first.`);
7615
- process.exit(1);
7616
- }
7617
- await manager.restart(config);
7618
- console.log(`Orchestrator service "${serviceName}" restarted.`);
7619
- } catch (err) {
7620
- console.error(`Error: ${toErrorMessage(err)}`);
7621
- process.exit(1);
8056
+ /** Prompt for a secret/password (masked input). */
8057
+ async function promptSecret(message) {
8058
+ return password({
8059
+ message,
8060
+ mask: "*",
8061
+ validate: (value) => {
8062
+ if (!value.trim()) return "This field is required";
8063
+ return true;
7622
8064
  }
7623
8065
  });
7624
8066
  }
8067
+ /** Prompt for a selection from a list of options. */
8068
+ async function promptSelect(message, choices, defaultValue) {
8069
+ return select({
8070
+ message,
8071
+ choices,
8072
+ default: defaultValue
8073
+ });
8074
+ }
8075
+ var init_prompts = __esmMin((() => {}));
7625
8076
  //#endregion
7626
- //#region src/cli/commands/orchestrator-service/status.ts
7627
- /** Read the port from the env file in the config directory. */
7628
- function readPortFromEnv$1(configDir, serviceName) {
7629
- const envPath = path.join(configDir, `${serviceName}.env`);
7630
- if (!fs.existsSync(envPath)) return 4e3;
7631
- try {
7632
- const content = fs.readFileSync(envPath, "utf-8");
7633
- for (const line of content.split("\n")) {
7634
- const trimmed = line.trim();
7635
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
7636
- const [key, ...rest] = trimmed.split("=");
7637
- if (key?.trim() === "KICI_PORT") {
7638
- const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
7639
- const parsed = parseInt(val, 10);
7640
- if (!isNaN(parsed)) return parsed;
7641
- }
8077
+ //#region src/cli/wizard/orchestrator-wizard.ts
8078
+ /**
8079
+ * Interactive wizard for orchestrator service setup.
8080
+ *
8081
+ * Walks the user through essential configuration (mode, DB URL,
8082
+ * port, secrets key) with sensible defaults. Returns a config
8083
+ * object that the install command uses to write the env file.
8084
+ */
8085
+ var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorWizard: () => runOrchestratorWizard });
8086
+ /**
8087
+ * Run the interactive orchestrator setup wizard.
8088
+ *
8089
+ * Asks only essential questions per the user decision:
8090
+ * 1. Mode (platform/hybrid/independent)
8091
+ * 2. Database URL
8092
+ * 3. Port
8093
+ * 4. Secrets encryption key
8094
+ * 5. Bootstrap admin token (for kici-admin authentication)
8095
+ * 6. Platform URL + token (if platform/hybrid mode)
8096
+ * 7. Webhook secret (if hybrid/independent mode)
8097
+ */
8098
+ async function runOrchestratorWizard() {
8099
+ console.log("");
8100
+ console.log("KiCI orchestrator setup wizard");
8101
+ console.log("==============================");
8102
+ console.log("");
8103
+ const mode = await promptSelect("Operating mode:", [
8104
+ {
8105
+ name: "Platform relay (recommended)",
8106
+ value: "platform",
8107
+ description: "Connect to KiCI Platform for webhook routing"
8108
+ },
8109
+ {
8110
+ name: "Hybrid",
8111
+ value: "hybrid",
8112
+ description: "Platform relay + direct webhooks"
8113
+ },
8114
+ {
8115
+ name: "Independent",
8116
+ value: "independent",
8117
+ description: "Self-hosted, no Platform dependency"
7642
8118
  }
7643
- } catch {}
7644
- return 4e3;
7645
- }
7646
- /** Query orchestrator health API. */
7647
- async function queryHealth$1(port) {
7648
- try {
7649
- const controller = new AbortController();
7650
- const timeout = setTimeout(() => controller.abort(), 3e3);
7651
- const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
7652
- clearTimeout(timeout);
7653
- if (!res.ok) return null;
7654
- return await res.json();
7655
- } catch {
7656
- return null;
8119
+ ], "platform");
8120
+ console.log("");
8121
+ const databaseUrl = await promptDbUrl();
8122
+ const port = await promptPort(4e3);
8123
+ console.log("");
8124
+ const generatedKey = randomBytes(32).toString("hex");
8125
+ console.log(`Generated secrets key: ${generatedKey}`);
8126
+ const useGenerated = await promptConfirm("Use this key?");
8127
+ let secretsKey;
8128
+ if (useGenerated) secretsKey = generatedKey;
8129
+ else secretsKey = await promptSecret("Enter custom secrets encryption key (64 hex chars):");
8130
+ console.log("");
8131
+ const generatedAdminToken = randomBytes(32).toString("hex");
8132
+ console.log(`Generated bootstrap admin token: ${generatedAdminToken}`);
8133
+ const useGeneratedAdminToken = await promptConfirm("Use this token?");
8134
+ let bootstrapAdminToken;
8135
+ if (useGeneratedAdminToken) bootstrapAdminToken = generatedAdminToken;
8136
+ else bootstrapAdminToken = await promptSecret("Enter custom bootstrap admin token:");
8137
+ let platformUrl;
8138
+ let platformToken;
8139
+ if (mode === "platform" || mode === "hybrid") {
8140
+ console.log("");
8141
+ platformUrl = await promptUrl("Platform relay URL:", "wss://platform.kici.dev");
8142
+ platformToken = await promptSecret("Platform authentication token:");
7657
8143
  }
7658
- }
7659
- /** Format the status output as a readable table. */
7660
- function formatStatus$1(serviceStatus, health, serviceName) {
7661
- const lines = [];
7662
- lines.push(`Service: ${serviceName}`);
7663
- lines.push(`State: ${serviceStatus.state}`);
7664
- if (serviceStatus.pid) lines.push(`PID: ${serviceStatus.pid}`);
7665
- if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
7666
- if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
7667
- if (health) {
7668
- lines.push("");
7669
- lines.push("--- KiCI orchestrator ---");
7670
- if (health.mode) lines.push(`Mode: ${health.mode}`);
7671
- if (health.port) lines.push(`Port: ${health.port}`);
7672
- if (health.database) lines.push(`Database: ${health.database}`);
7673
- if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
7674
- if (health.agents != null) lines.push(`Agents: ${health.agents}`);
7675
- if (health.scaler) {
7676
- const s = health.scaler;
7677
- lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
8144
+ let source;
8145
+ console.log("");
8146
+ if (await promptConfirm("Add a GitHub App source?", false)) {
8147
+ const sourceName = await input({ message: "Source name (e.g. main-org):" });
8148
+ const appId = await input({
8149
+ message: "GitHub App ID:",
8150
+ validate: (v) => /^\d+$/.test(v.trim()) ? true : "Must be a numeric App ID"
8151
+ });
8152
+ const privateKeyPath = await input({
8153
+ message: "Path to private key file (.pem):",
8154
+ validate: (v) => v.trim() ? true : "Path is required"
8155
+ });
8156
+ const sourceWebhookSecret = await promptSecret("Webhook secret:");
8157
+ let privateKey;
8158
+ try {
8159
+ privateKey = (await readFile(privateKeyPath.trim(), "utf-8")).trim();
8160
+ } catch (err) {
8161
+ throw new Error(`Failed to read private key from ${privateKeyPath}: ${err}`);
7678
8162
  }
7679
- if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
7680
- } else if (serviceStatus.state === "running") {
7681
- lines.push("");
7682
- lines.push("(Could not reach health API)");
8163
+ source = {
8164
+ provider: "github",
8165
+ name: sourceName.trim(),
8166
+ appId: appId.trim(),
8167
+ privateKey,
8168
+ webhookSecret: sourceWebhookSecret
8169
+ };
7683
8170
  }
7684
- return lines.join("\n");
7685
- }
7686
- /** Build JSON output combining service + health data. */
7687
- function buildJsonOutput$1(serviceStatus, health, serviceName) {
8171
+ console.log("");
8172
+ console.log("Configuration complete. Summary:");
8173
+ console.log(` Mode: ${mode}`);
8174
+ console.log(` Database: ${databaseUrl.replace(/:[^@]*@/, ":***@")}`);
8175
+ console.log(` Port: ${port}`);
8176
+ console.log(` Admin token: ${bootstrapAdminToken.slice(0, 8)}…`);
8177
+ console.log(` Platform: ${platformUrl ?? "N/A"}`);
8178
+ if (source) console.log(` Source: ${source.provider}:${source.appId} (${source.name})`);
8179
+ console.log("");
7688
8180
  return {
7689
- service: serviceName,
7690
- ...serviceStatus,
7691
- health: health ?? void 0
8181
+ mode,
8182
+ databaseUrl,
8183
+ port,
8184
+ secretsKey,
8185
+ bootstrapAdminToken,
8186
+ platformUrl,
8187
+ platformToken,
8188
+ source
7692
8189
  };
7693
8190
  }
7694
- function registerStatusCommand(parent) {
7695
- parent.command("status").description("Show orchestrator service status and health information").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--json", "Output as JSON").action(async (opts) => {
8191
+ var init_orchestrator_wizard = __esmMin((() => {
8192
+ init_prompts();
8193
+ }));
8194
+ //#endregion
8195
+ //#region src/cli/commands/orchestrator-service/install.ts
8196
+ /**
8197
+ * Spin up a dev PostgreSQL container using Docker or Podman.
8198
+ * Returns the DATABASE_URL for the container.
8199
+ */
8200
+ function startDevPostgres(containerName) {
8201
+ const password = crypto.randomBytes(16).toString("hex");
8202
+ const port = 15432;
8203
+ let runtime = "podman";
8204
+ try {
8205
+ execSync("podman --version", { stdio: "ignore" });
8206
+ } catch {
8207
+ runtime = "docker";
7696
8208
  try {
7697
- const manager = await createServiceManager(detectPlatform(opts.platform));
7698
- const userLevel = !isRoot();
7699
- const configDir = getConfigDir(opts.name, userLevel);
7700
- const config = {
7701
- name: opts.name,
7702
- displayName: "KiCI orchestrator",
7703
- description: "KiCI orchestrator service",
7704
- executablePath: "",
7705
- envFilePath: path.join(configDir, `${opts.name}.env`),
7706
- workingDirectory: configDir,
7707
- isUserLevel: userLevel,
7708
- restartPolicy: {
7709
- enabled: true,
7710
- delays: [
7711
- 1,
7712
- 5,
7713
- 15,
7714
- 30
7715
- ],
7716
- maxRetries: 5,
7717
- windowSeconds: 300
7718
- }
7719
- };
7720
- const serviceStatus = await manager.status(config);
7721
- let health = null;
7722
- if (serviceStatus.state === "running") health = await queryHealth$1(readPortFromEnv$1(configDir, opts.name));
7723
- if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, opts.name), null, 2));
7724
- else console.log(formatStatus$1(serviceStatus, health, opts.name));
7725
- } catch (err) {
7726
- console.error(`Error: ${toErrorMessage(err)}`);
7727
- process.exit(1);
8209
+ execSync("docker --version", { stdio: "ignore" });
8210
+ } catch {
8211
+ throw new Error("Neither podman nor docker found. Install one to use --dev mode.");
7728
8212
  }
7729
- });
8213
+ }
8214
+ try {
8215
+ if (execSync(`${runtime} ps -a --filter name=${containerName} --format "{{.Names}}"`, { encoding: "utf-8" }).trim()) {
8216
+ console.log(`Dev PostgreSQL container "${containerName}" already exists.`);
8217
+ console.log(`Remove it with: ${runtime} rm -f ${containerName}`);
8218
+ throw new Error(`Container "${containerName}" already exists`);
8219
+ }
8220
+ } catch (err) {
8221
+ if (err instanceof Error && err.message.includes("already exists")) throw err;
8222
+ }
8223
+ console.log(`Starting dev PostgreSQL container "${containerName}" on port ${port}...`);
8224
+ execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 -e POSTGRES_PASSWORD=${password} -e POSTGRES_DB=kici postgres:18-trixie`, { stdio: "inherit" });
8225
+ return `postgresql://postgres:${password}@localhost:${port}/kici`;
7730
8226
  }
7731
- //#endregion
7732
- //#region src/cli/commands/orchestrator-service/logs.ts
7733
- function registerLogsCommand(parent) {
7734
- parent.command("logs").description("Tail and follow orchestrator service logs").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--since <duration>", "Show logs since duration (e.g. 1h, 30m)").option("--level <level>", "Filter by log level (error|warn|info)").option("--json", "Output as structured JSON").option("--no-follow", "Snapshot mode (do not tail)").action(async (opts) => {
8227
+ function registerOrchestratorInstall(orchestrator) {
8228
+ orchestrator.command("install").description("Install the orchestrator as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to orchestrator binary (default: current executable)").option("--dev", "Dev mode: spin up PostgreSQL container on port 15432").option("--wizard", "Interactive wizard for guided setup").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--user <name>", "Run the service as the named user (system-level launchd only; sets UserName in plist so the daemon drops privileges)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
7735
8229
  try {
7736
- const manager = await createServiceManager(detectPlatform(opts.platform));
7737
- const userLevel = !isRoot();
7738
- const configDir = getConfigDir(opts.name, userLevel);
8230
+ if (opts.wizard && opts.envFile) {
8231
+ console.error("Error: Cannot use --wizard with --env-file");
8232
+ process.exit(1);
8233
+ }
8234
+ const platform = detectPlatform(opts.platform);
8235
+ const userLevel = resolveUserLevel(opts);
8236
+ const serviceName = opts.name;
8237
+ const instanceDir = path.resolve(opts.instanceDir ?? process.cwd());
8238
+ const kiciRoot = kiciConfigRoot(userLevel);
8239
+ console.log(`Platform: ${platform}`);
8240
+ console.log(`Privilege: ${userLevel ? "user" : "system"}`);
8241
+ console.log(`Service name: ${serviceName}`);
8242
+ console.log(`Instance dir: ${instanceDir}`);
8243
+ const manager = await createServiceManager(platform);
8244
+ const existing = (await listInstances({
8245
+ component: "orchestrator",
8246
+ isUserLevel: userLevel,
8247
+ kiciRoot,
8248
+ manager
8249
+ })).find((c) => c.name === serviceName);
8250
+ if (existing && existing.instanceDir !== instanceDir && !opts.force) {
8251
+ const at = existing.instanceDir ?? "(no manifest)";
8252
+ console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}. Pass a different --name, a different --instance-dir, or --force to overwrite.`);
8253
+ process.exit(1);
8254
+ }
8255
+ const configDir = getConfigDir(serviceName, userLevel);
8256
+ const logDir = getLogDir(serviceName, userLevel);
8257
+ fs.mkdirSync(configDir, { recursive: true });
8258
+ fs.mkdirSync(logDir, { recursive: true });
8259
+ const envFilePath = path.join(configDir, `${serviceName}.env`);
8260
+ let devDbUrl;
8261
+ if (opts.dev) {
8262
+ devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
8263
+ console.log(`Dev PostgreSQL URL: ${devDbUrl}`);
8264
+ }
8265
+ if (opts.wizard) {
8266
+ const { runOrchestratorWizard } = await Promise.resolve().then(() => (init_orchestrator_wizard(), orchestrator_wizard_exports));
8267
+ const wizardConfig = await runOrchestratorWizard();
8268
+ let envContent = "# KiCI orchestrator configuration (generated by setup wizard)\n";
8269
+ envContent += `KICI_MODE=${wizardConfig.mode}\n`;
8270
+ envContent += `KICI_DATABASE_URL=${wizardConfig.databaseUrl}\n`;
8271
+ envContent += `KICI_PORT=${wizardConfig.port}\n`;
8272
+ envContent += `KICI_SECRET_KEY=${wizardConfig.secretsKey}\n`;
8273
+ envContent += `KICI_BOOTSTRAP_ADMIN_TOKEN=${wizardConfig.bootstrapAdminToken}\n`;
8274
+ if (wizardConfig.platformUrl) envContent += `KICI_PLATFORM_URL=${wizardConfig.platformUrl}\n`;
8275
+ if (wizardConfig.platformToken) envContent += `KICI_PLATFORM_TOKEN=${wizardConfig.platformToken}\n`;
8276
+ fs.writeFileSync(envFilePath, envContent, "utf-8");
8277
+ console.log(`Wrote wizard configuration to ${envFilePath}`);
8278
+ } else if (opts.envFile) {
8279
+ const source = path.resolve(opts.envFile);
8280
+ if (!fs.existsSync(source)) {
8281
+ console.error(`Error: env file not found: ${source}`);
8282
+ process.exit(1);
8283
+ }
8284
+ fs.copyFileSync(source, envFilePath);
8285
+ console.log(`Copied env file to ${envFilePath}`);
8286
+ } else if (!fs.existsSync(envFilePath)) {
8287
+ let envContent = `# KiCI orchestrator configuration\n# See docs for all available options\n`;
8288
+ if (devDbUrl) envContent += `KICI_DATABASE_URL=${devDbUrl}\n`;
8289
+ fs.writeFileSync(envFilePath, envContent, "utf-8");
8290
+ console.log(`Created env file at ${envFilePath}`);
8291
+ } else if (devDbUrl) {
8292
+ fs.appendFileSync(envFilePath, `\nKICI_DATABASE_URL=${devDbUrl}\n`);
8293
+ console.log(`Appended KICI_DATABASE_URL to ${envFilePath}`);
8294
+ }
8295
+ const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`));
8296
+ const { executablePath, args } = resolveServiceExecutable({
8297
+ binary: opts.binary ? path.resolve(opts.binary) : void 0,
8298
+ nodePath: process.execPath,
8299
+ entryScript
8300
+ });
8301
+ if (userLevel) try {
8302
+ const envContent = fs.readFileSync(envFilePath, "utf-8");
8303
+ if (envContent.includes("firecracker") || envContent.includes("FIRECRACKER")) {
8304
+ console.warn("\nWARNING: Firecracker scaler requires root privileges.");
8305
+ console.warn("The service is being installed at user level. Firecracker will not work.");
8306
+ console.warn("Re-run as root (sudo) to install a system-level service.\n");
8307
+ }
8308
+ } catch {}
7739
8309
  const config = {
7740
- name: opts.name,
7741
- displayName: "KiCI orchestrator",
7742
- description: "KiCI orchestrator service",
7743
- executablePath: "",
7744
- envFilePath: path.join(configDir, `${opts.name}.env`),
8310
+ name: serviceName,
8311
+ displayName: "KiCI Orchestrator",
8312
+ description: "KiCI CI/CD workflow orchestrator service",
8313
+ executablePath,
8314
+ args,
8315
+ nodeBinDir: path.dirname(process.execPath),
8316
+ envFilePath,
7745
8317
  workingDirectory: configDir,
7746
8318
  isUserLevel: userLevel,
7747
- restartPolicy: {
7748
- enabled: true,
7749
- delays: [
7750
- 1,
7751
- 5,
7752
- 15,
7753
- 30
7754
- ],
7755
- maxRetries: 5,
7756
- windowSeconds: 300
7757
- }
7758
- };
7759
- const logOptions = {
7760
- since: opts.since,
7761
- level: opts.level,
7762
- json: opts.json,
7763
- follow: opts.follow
8319
+ user: opts.user,
8320
+ component: "orchestrator",
8321
+ restartPolicy: DEFAULT_RESTART_POLICY
7764
8322
  };
7765
- await manager.logs(config, logOptions);
8323
+ await manager.install(config);
8324
+ const manifestFile = writeManifest(instanceDir, {
8325
+ component: "orchestrator",
8326
+ name: serviceName,
8327
+ platform,
8328
+ isUserLevel: userLevel,
8329
+ envFilePath,
8330
+ configDir,
8331
+ logDir,
8332
+ installBase: getInstallBase(platform, serviceName),
8333
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8334
+ kiciVersion: process.env.npm_package_version ?? "unknown"
8335
+ });
8336
+ try {
8337
+ appendIndexEntry(kiciRoot, {
8338
+ component: "orchestrator",
8339
+ name: serviceName,
8340
+ platform,
8341
+ isUserLevel: userLevel,
8342
+ instanceDir
8343
+ });
8344
+ } catch (err) {
8345
+ console.warn(`Warning: instance index append failed: ${err.message}`);
8346
+ }
8347
+ console.log(`\nOrchestrator service "${serviceName}" installed successfully.`);
8348
+ console.log(` Config: ${envFilePath}`);
8349
+ console.log(` Logs: ${logDir}`);
8350
+ console.log(` Manifest: ${manifestFile}`);
8351
+ console.log(`\nNext steps:`);
8352
+ console.log(` 1. Edit ${envFilePath} with your configuration`);
8353
+ console.log(` 2. Run \`kici-admin orchestrator start\` to start the service`);
7766
8354
  } catch (err) {
7767
8355
  console.error(`Error: ${toErrorMessage(err)}`);
7768
8356
  process.exit(1);
7769
8357
  }
7770
- });
7771
- }
7772
- //#endregion
7773
- //#region src/cli/commands/shared/versioned-upgrade.ts
7774
- /**
7775
- * Shared versioned directory upgrade logic for kici-admin upgrade commands.
7776
- *
7777
- * Implements the versioned directory layout:
7778
- * - Extract new version alongside old versions
7779
- * - Update symlink (Unix) or service registration (Windows) atomically
7780
- * - Preserve old versions for rollback
7781
- * - Optional cleanup of old versions
7782
- */
7783
- /** Prompt the user for confirmation (returns true if yes). */
7784
- async function confirm$2(message) {
7785
- const rl = createInterface({
7786
- input: process.stdin,
7787
- output: process.stdout
7788
- });
7789
- return new Promise((resolve) => {
7790
- rl.question(`${message} [y/N] `, (answer) => {
7791
- rl.close();
7792
- resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
7793
- });
7794
- });
7795
- }
7796
- /** Download a file from a URL to a local path. */
7797
- async function downloadArchive(url, destPath) {
7798
- console.log(`Downloading from ${url}...`);
7799
- const res = await fetch(url);
7800
- if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
7801
- const fileStream = createWriteStream(destPath);
7802
- await pipeline(res.body, fileStream);
7803
- console.log(`Downloaded to ${destPath}`);
7804
- }
7805
- /**
7806
- * Get the install base directory for the current platform.
7807
- *
7808
- * - Linux (systemd): /opt/kici/
7809
- * - macOS (launchd): /usr/local/kici/
7810
- * - Windows: C:\Program Files\KiCI\
7811
- * - Compose: /opt/kici/ (default)
7812
- */
7813
- function getInstallBase(platform) {
7814
- switch (platform) {
7815
- case "systemd":
7816
- case "compose": return "/opt/kici/";
7817
- case "launchd": return "/usr/local/kici/";
7818
- case "windows": return "C:\\Program Files\\KiCI\\";
7819
- }
7820
- }
7821
- /** Check if the platform is Windows. */
7822
- function isWindows(platform) {
7823
- return platform === "windows";
7824
- }
7825
- /** Get the launcher script name for a component. */
7826
- function getLauncherName(component, platform) {
7827
- const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
7828
- return isWindows(platform) ? `${baseName}.cmd` : baseName;
7829
- }
7830
- /**
7831
- * Extract an archive (.tar.gz or .zip) to a destination directory.
7832
- * Returns the name of the top-level directory inside the archive.
7833
- */
7834
- function extractArchive(archivePath, destDir) {
7835
- fs.mkdirSync(destDir, { recursive: true });
7836
- if (archivePath.endsWith(".zip")) if (os.platform() === "win32") execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: "inherit" });
7837
- else execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: "inherit" });
7838
- else execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "inherit" });
7839
- const dirs = fs.readdirSync(destDir).filter((e) => fs.statSync(path.join(destDir, e)).isDirectory());
7840
- if (dirs.length === 0) throw new Error("Archive does not contain a directory");
7841
- return dirs[0];
7842
- }
7843
- /**
7844
- * List installed versions for a component by scanning the install base directory
7845
- * for directories matching `{component}-{version}/`.
7846
- */
7847
- function listInstalledVersions(installBase, component) {
7848
- if (!fs.existsSync(installBase)) return [];
7849
- const prefix = `${component}-`;
7850
- return fs.readdirSync(installBase).filter((entry) => {
7851
- if (!entry.startsWith(prefix)) return false;
7852
- const fullPath = path.join(installBase, entry);
7853
- return fs.statSync(fullPath).isDirectory();
7854
- }).map((entry) => entry.slice(prefix.length)).sort();
7855
- }
7856
- /**
7857
- * Read the current symlink target to determine the active version.
7858
- * Returns null if no symlink exists or on Windows.
7859
- */
7860
- function getCurrentVersion(installBase, component, platform) {
7861
- if (isWindows(platform)) {
7862
- const versionFile = path.join(installBase, `${component}-current-version.txt`);
7863
- try {
7864
- return fs.readFileSync(versionFile, "utf-8").trim();
7865
- } catch {
7866
- return null;
7867
- }
7868
- }
7869
- const symlinkPath = path.join(installBase, component);
7870
- try {
7871
- const target = fs.readlinkSync(symlinkPath);
7872
- const prefix = `${component}-`;
7873
- if (target.startsWith(prefix)) return target.slice(prefix.length);
7874
- const basename = path.basename(target);
7875
- if (basename.startsWith(prefix)) return basename.slice(prefix.length);
7876
- } catch {}
7877
- return null;
7878
- }
7879
- /** Write the current version to a tracking file (used on Windows). */
7880
- function writeCurrentVersion(installBase, component, version) {
7881
- const versionFile = path.join(installBase, `${component}-current-version.txt`);
7882
- fs.writeFileSync(versionFile, version, "utf-8");
8358
+ });
7883
8359
  }
7884
- /**
7885
- * Update the symlink atomically on Unix.
7886
- * Creates a temporary symlink then renames it over the existing one.
7887
- */
7888
- function updateSymlinkAtomic(installBase, component, version) {
7889
- const symlinkPath = path.join(installBase, component);
7890
- const tmpLink = `${symlinkPath}.tmp.${Date.now()}`;
7891
- const target = `${component}-${version}`;
7892
- try {
7893
- fs.symlinkSync(target, tmpLink);
7894
- fs.renameSync(tmpLink, symlinkPath);
7895
- } catch (err) {
8360
+ //#endregion
8361
+ //#region src/cli/commands/orchestrator-service/uninstall.ts
8362
+ function registerOrchestratorUninstall(orchestrator) {
8363
+ orchestrator.command("uninstall").description("Remove the orchestrator service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to uninstall").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
7896
8364
  try {
7897
- fs.unlinkSync(tmpLink);
7898
- } catch {}
7899
- throw err;
7900
- }
7901
- }
7902
- /**
7903
- * Perform a versioned directory upgrade for a KiCI component.
7904
- *
7905
- * Flow:
7906
- * 1. Parse upgrade source (--from archive or --url)
7907
- * 2. Determine install base by platform
7908
- * 3. Extract new versioned directory
7909
- * 4. Stop service
7910
- * 5. Update symlink (Unix) or service registration (Windows)
7911
- * 6. Start service
7912
- */
7913
- async function performVersionedUpgrade(component, opts) {
7914
- try {
7915
- const platform = detectPlatform(opts.platform);
7916
- const manager = await createServiceManager(platform);
7917
- const userLevel = !isRoot();
7918
- const configDir = getConfigDir(opts.name, userLevel);
7919
- const installBase = getInstallBase(platform);
7920
- const config = {
7921
- name: opts.name,
7922
- displayName: `KiCI ${component}`,
7923
- description: `KiCI ${component} service`,
7924
- executablePath: "",
7925
- envFilePath: path.join(configDir, `${opts.name}.env`),
7926
- workingDirectory: configDir,
7927
- isUserLevel: userLevel,
7928
- restartPolicy: {
7929
- enabled: true,
7930
- delays: [
7931
- 1,
7932
- 5,
7933
- 15,
7934
- 30
7935
- ],
7936
- maxRetries: 5,
7937
- windowSeconds: 300
8365
+ const platform = detectPlatform(opts.platform);
8366
+ const userLevel = resolveUserLevel(opts);
8367
+ const manager = await createServiceManager(platform);
8368
+ const kiciRoot = kiciConfigRoot(userLevel);
8369
+ const resolved = await resolveInstance({
8370
+ component: "orchestrator",
8371
+ opts: {
8372
+ instanceDir: opts.instanceDir,
8373
+ name: opts.name
8374
+ },
8375
+ cwd: process.cwd(),
8376
+ kiciRoot,
8377
+ manager,
8378
+ isUserLevel: userLevel
8379
+ });
8380
+ const config = {
8381
+ name: resolved.manifest.name,
8382
+ displayName: "KiCI Orchestrator",
8383
+ description: "KiCI CI/CD workflow orchestrator service",
8384
+ executablePath: "",
8385
+ envFilePath: resolved.manifest.envFilePath,
8386
+ workingDirectory: resolved.manifest.configDir,
8387
+ isUserLevel: resolved.manifest.isUserLevel,
8388
+ restartPolicy: DEFAULT_RESTART_POLICY,
8389
+ component: "orchestrator"
8390
+ };
8391
+ if (!await manager.isInstalled(config)) console.log(`Service "${config.name}" is not installed.`);
8392
+ else {
8393
+ try {
8394
+ if ((await manager.status(config)).state === "running") {
8395
+ console.log(`Stopping service "${config.name}"...`);
8396
+ await manager.stop(config);
8397
+ }
8398
+ } catch {}
8399
+ await manager.uninstall(config);
7938
8400
  }
7939
- };
7940
- if (!isWindows(platform) && !userLevel && !isRoot()) {
7941
- console.error("Error: root privileges required to upgrade system-level services");
7942
- process.exit(1);
7943
- }
7944
- if (opts.rollback) {
7945
- await handleRollback(component, platform, installBase, config, manager, opts);
7946
- return;
7947
- }
7948
- if (opts.cleanup) {
7949
- await handleCleanup(component, platform, installBase);
7950
- return;
7951
- }
7952
- if (!opts.from && !opts.url) {
7953
- console.error("Error: provide --from <archive-path> or --url <url> for the upgrade package");
7954
- process.exit(1);
7955
- }
7956
- if (!opts.version) {
7957
- console.error("Error: --version is required to specify the target version");
8401
+ removeIndexEntry(kiciRoot, {
8402
+ component: "orchestrator",
8403
+ name: config.name
8404
+ });
8405
+ console.log(`\nOrchestrator service "${config.name}" uninstalled.`);
8406
+ console.log(`Manifest preserved at ${resolved.manifestPath} — delete manually if no longer needed.`);
8407
+ } catch (err) {
8408
+ console.error(`Error: ${toErrorMessage(err)}`);
7958
8409
  process.exit(1);
7959
8410
  }
7960
- const version = opts.version;
7961
- const versionedDirName = `${component}-${version}`;
7962
- const versionedDirPath = path.join(installBase, versionedDirName);
7963
- if (fs.existsSync(versionedDirPath)) if (opts.force) {
7964
- console.log(`Removing existing directory ${versionedDirPath} (--force)`);
7965
- fs.rmSync(versionedDirPath, {
7966
- recursive: true,
7967
- force: true
8411
+ });
8412
+ }
8413
+ //#endregion
8414
+ //#region src/cli/commands/orchestrator-service/start.ts
8415
+ function registerOrchestratorStart(orchestrator) {
8416
+ orchestrator.command("start").description("Start the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to start").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8417
+ try {
8418
+ const platform = detectPlatform(opts.platform);
8419
+ const userLevel = resolveUserLevel(opts);
8420
+ const manager = await createServiceManager(platform);
8421
+ const kiciRoot = kiciConfigRoot(userLevel);
8422
+ const resolved = await resolveInstance({
8423
+ component: "orchestrator",
8424
+ opts: {
8425
+ instanceDir: opts.instanceDir,
8426
+ name: opts.name
8427
+ },
8428
+ cwd: process.cwd(),
8429
+ kiciRoot,
8430
+ manager,
8431
+ isUserLevel: userLevel
7968
8432
  });
7969
- } else {
7970
- console.error(`Error: version directory already exists: ${versionedDirPath}`);
7971
- console.error("Use --force to overwrite.");
8433
+ const config = {
8434
+ name: resolved.manifest.name,
8435
+ displayName: "KiCI Orchestrator",
8436
+ description: "KiCI CI/CD workflow orchestrator service",
8437
+ executablePath: "",
8438
+ envFilePath: resolved.manifest.envFilePath,
8439
+ workingDirectory: resolved.manifest.configDir,
8440
+ isUserLevel: resolved.manifest.isUserLevel,
8441
+ restartPolicy: DEFAULT_RESTART_POLICY,
8442
+ component: "orchestrator"
8443
+ };
8444
+ if (!await manager.isInstalled(config)) {
8445
+ console.error(`Error: service "${config.name}" is not installed.`);
8446
+ console.error(`Run \`kici-admin orchestrator install\` first.`);
8447
+ process.exit(1);
8448
+ }
8449
+ await manager.start(config);
8450
+ console.log(`Orchestrator service "${config.name}" started.`);
8451
+ } catch (err) {
8452
+ console.error(`Error: ${toErrorMessage(err)}`);
7972
8453
  process.exit(1);
7973
8454
  }
7974
- if (!await manager.isInstalled(config)) {
7975
- console.error(`Error: service "${opts.name}" is not installed`);
8455
+ });
8456
+ }
8457
+ //#endregion
8458
+ //#region src/cli/commands/orchestrator-service/stop.ts
8459
+ function registerOrchestratorStop(orchestrator) {
8460
+ orchestrator.command("stop").description("Stop the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to stop").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8461
+ try {
8462
+ const platform = detectPlatform(opts.platform);
8463
+ const userLevel = resolveUserLevel(opts);
8464
+ const manager = await createServiceManager(platform);
8465
+ const kiciRoot = kiciConfigRoot(userLevel);
8466
+ const resolved = await resolveInstance({
8467
+ component: "orchestrator",
8468
+ opts: {
8469
+ instanceDir: opts.instanceDir,
8470
+ name: opts.name
8471
+ },
8472
+ cwd: process.cwd(),
8473
+ kiciRoot,
8474
+ manager,
8475
+ isUserLevel: userLevel
8476
+ });
8477
+ const config = {
8478
+ name: resolved.manifest.name,
8479
+ displayName: "KiCI Orchestrator",
8480
+ description: "KiCI CI/CD workflow orchestrator service",
8481
+ executablePath: "",
8482
+ envFilePath: resolved.manifest.envFilePath,
8483
+ workingDirectory: resolved.manifest.configDir,
8484
+ isUserLevel: resolved.manifest.isUserLevel,
8485
+ restartPolicy: DEFAULT_RESTART_POLICY,
8486
+ component: "orchestrator"
8487
+ };
8488
+ await manager.stop(config);
8489
+ console.log(`Orchestrator service "${config.name}" stopped.`);
8490
+ } catch (err) {
8491
+ console.error(`Error: ${toErrorMessage(err)}`);
7976
8492
  process.exit(1);
7977
8493
  }
7978
- let archivePath;
7979
- const tmpDir = path.join(os.tmpdir(), `kici-upgrade-${Date.now()}`);
7980
- fs.mkdirSync(tmpDir, { recursive: true });
7981
- if (opts.from) {
7982
- archivePath = path.resolve(opts.from);
7983
- if (!fs.existsSync(archivePath)) {
7984
- console.error(`Error: archive not found at ${archivePath}`);
8494
+ });
8495
+ }
8496
+ //#endregion
8497
+ //#region src/cli/commands/orchestrator-service/restart.ts
8498
+ function registerOrchestratorRestart(orchestrator) {
8499
+ orchestrator.command("restart").description("Restart the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to restart").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8500
+ try {
8501
+ const platform = detectPlatform(opts.platform);
8502
+ const userLevel = resolveUserLevel(opts);
8503
+ const manager = await createServiceManager(platform);
8504
+ const kiciRoot = kiciConfigRoot(userLevel);
8505
+ const resolved = await resolveInstance({
8506
+ component: "orchestrator",
8507
+ opts: {
8508
+ instanceDir: opts.instanceDir,
8509
+ name: opts.name
8510
+ },
8511
+ cwd: process.cwd(),
8512
+ kiciRoot,
8513
+ manager,
8514
+ isUserLevel: userLevel
8515
+ });
8516
+ const config = {
8517
+ name: resolved.manifest.name,
8518
+ displayName: "KiCI Orchestrator",
8519
+ description: "KiCI CI/CD workflow orchestrator service",
8520
+ executablePath: "",
8521
+ envFilePath: resolved.manifest.envFilePath,
8522
+ workingDirectory: resolved.manifest.configDir,
8523
+ isUserLevel: resolved.manifest.isUserLevel,
8524
+ restartPolicy: DEFAULT_RESTART_POLICY,
8525
+ component: "orchestrator"
8526
+ };
8527
+ if (!await manager.isInstalled(config)) {
8528
+ console.error(`Error: service "${config.name}" is not installed.`);
8529
+ console.error(`Run \`kici-admin orchestrator install\` first.`);
7985
8530
  process.exit(1);
7986
8531
  }
7987
- } else {
7988
- const ext = opts.url.endsWith(".zip") ? ".zip" : ".tar.gz";
7989
- archivePath = path.join(tmpDir, `${component}-${version}${ext}`);
7990
- await downloadArchive(opts.url, archivePath);
8532
+ await manager.restart(config);
8533
+ console.log(`Orchestrator service "${config.name}" restarted.`);
8534
+ } catch (err) {
8535
+ console.error(`Error: ${toErrorMessage(err)}`);
8536
+ process.exit(1);
7991
8537
  }
7992
- const currentVersion = getCurrentVersion(installBase, component, platform);
7993
- if (!opts.yes) {
7994
- console.log(`This will upgrade "${opts.name}" to version ${version}:`);
7995
- if (currentVersion) console.log(` Current version: ${currentVersion}`);
7996
- console.log(` New version: ${version}`);
7997
- console.log(` Install path: ${versionedDirPath}`);
7998
- console.log(" The service will be stopped during upgrade.");
7999
- console.log("");
8000
- if (!await confirm$2("Proceed with upgrade?")) {
8001
- console.log("Upgrade cancelled.");
8002
- return;
8538
+ });
8539
+ }
8540
+ //#endregion
8541
+ //#region src/cli/commands/orchestrator-service/status.ts
8542
+ /** Read the port from the service's env file (path from the manifest). */
8543
+ function readPortFromEnvFile$1(envFilePath) {
8544
+ if (!fs.existsSync(envFilePath)) return 4e3;
8545
+ try {
8546
+ const content = fs.readFileSync(envFilePath, "utf-8");
8547
+ for (const line of content.split("\n")) {
8548
+ const trimmed = line.trim();
8549
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
8550
+ const [key, ...rest] = trimmed.split("=");
8551
+ if (key?.trim() === "KICI_PORT") {
8552
+ const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
8553
+ const parsed = parseInt(val, 10);
8554
+ if (!isNaN(parsed)) return parsed;
8003
8555
  }
8004
8556
  }
8005
- console.log("Extracting archive...");
8006
- const extractDir = path.join(tmpDir, "extract");
8007
- const extractedDirName = extractArchive(archivePath, extractDir);
8008
- fs.mkdirSync(installBase, { recursive: true });
8009
- const srcDir = path.join(extractDir, extractedDirName);
8010
- if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
8011
- else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
8012
- console.log(`Extracted to ${versionedDirPath}`);
8013
- console.log("Stopping service...");
8014
- if ((await manager.status(config)).state === "running") {
8015
- await manager.stop(config);
8016
- console.log("Service stopped.");
8017
- }
8018
- if (isWindows(platform)) {
8019
- const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
8020
- config.executablePath = launcherPath;
8021
- await manager.uninstall(config);
8022
- await new Promise((r) => setTimeout(r, 2e3));
8023
- await manager.install(config);
8024
- writeCurrentVersion(installBase, component, version);
8025
- console.log(`Service registration updated to ${launcherPath}`);
8026
- } else {
8027
- updateSymlinkAtomic(installBase, component, version);
8028
- const symlinkPath = path.join(installBase, component);
8029
- console.log(`Symlink updated: ${symlinkPath} -> ${versionedDirName}`);
8030
- const launcherPath = path.join(symlinkPath, getLauncherName(component, platform));
8031
- if (fs.existsSync(launcherPath)) fs.chmodSync(launcherPath, 493);
8032
- config.executablePath = path.join(installBase, component, getLauncherName(component, platform));
8033
- }
8034
- console.log("Starting service...");
8035
- await manager.start(config);
8036
- console.log("Service started.");
8037
- fs.rmSync(tmpDir, {
8038
- recursive: true,
8039
- force: true
8040
- });
8041
- console.log("");
8042
- if (currentVersion) {
8043
- console.log(`Upgrade complete: ${currentVersion} -> ${version}`);
8044
- console.log(`Previous version preserved at: ${path.join(installBase, `${component}-${currentVersion}`)}`);
8045
- } else console.log(`Upgrade to ${version} complete.`);
8046
- } catch (err) {
8047
- console.error(`Error: ${toErrorMessage(err)}`);
8048
- process.exit(1);
8557
+ } catch {}
8558
+ return 4e3;
8559
+ }
8560
+ /** Query orchestrator health API. */
8561
+ async function queryHealth$1(port) {
8562
+ try {
8563
+ const controller = new AbortController();
8564
+ const timeout = setTimeout(() => controller.abort(), 3e3);
8565
+ const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
8566
+ clearTimeout(timeout);
8567
+ if (!res.ok) return null;
8568
+ return await res.json();
8569
+ } catch {
8570
+ return null;
8049
8571
  }
8050
8572
  }
8051
- /**
8052
- * Handle --rollback: switch symlink to the previous version and restart.
8053
- */
8054
- async function handleRollback(component, platform, installBase, config, manager, opts) {
8055
- const versions = listInstalledVersions(installBase, component);
8056
- if (versions.length < 2) {
8057
- console.error("Error: no previous version available for rollback");
8058
- if (versions.length === 1) console.error(`Only version installed: ${versions[0]}`);
8059
- process.exit(1);
8060
- }
8061
- const currentVersion = getCurrentVersion(installBase, component, platform);
8062
- if (!currentVersion) {
8063
- console.error("Error: cannot determine current version (no symlink found)");
8064
- console.log("Available versions:");
8065
- for (const v of versions) console.log(` ${component}-${v}/`);
8066
- process.exit(1);
8067
- }
8068
- const currentIdx = versions.indexOf(currentVersion);
8069
- let previousVersion;
8070
- if (currentIdx > 0) previousVersion = versions[currentIdx - 1];
8071
- else if (versions.length >= 2) previousVersion = versions[1];
8072
- else {
8073
- console.error("Error: no alternative version available for rollback");
8074
- process.exit(1);
8075
- return;
8076
- }
8077
- if (!opts.yes) {
8078
- console.log(`Rolling back "${opts.name}":`);
8079
- console.log(` Current version: ${currentVersion}`);
8080
- console.log(` Rollback to: ${previousVersion}`);
8081
- console.log("");
8082
- if (!await confirm$2("Proceed with rollback?")) {
8083
- console.log("Rollback cancelled.");
8084
- return;
8573
+ /** Format the status output as a readable table. */
8574
+ function formatStatus$1(serviceStatus, health, serviceName) {
8575
+ const lines = [];
8576
+ lines.push(`Service: ${serviceName}`);
8577
+ lines.push(`State: ${serviceStatus.state}`);
8578
+ if (serviceStatus.pid) lines.push(`PID: ${serviceStatus.pid}`);
8579
+ if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
8580
+ if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
8581
+ if (health) {
8582
+ lines.push("");
8583
+ lines.push("--- KiCI orchestrator ---");
8584
+ if (health.mode) lines.push(`Mode: ${health.mode}`);
8585
+ if (health.port) lines.push(`Port: ${health.port}`);
8586
+ if (health.database) lines.push(`Database: ${health.database}`);
8587
+ if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
8588
+ if (health.agents != null) lines.push(`Agents: ${health.agents}`);
8589
+ if (health.scaler) {
8590
+ const s = health.scaler;
8591
+ lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
8085
8592
  }
8593
+ if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
8594
+ } else if (serviceStatus.state === "running") {
8595
+ lines.push("");
8596
+ lines.push("(Could not reach health API)");
8086
8597
  }
8087
- console.log("Stopping service...");
8088
- if ((await manager.status(config)).state === "running") {
8089
- await manager.stop(config);
8090
- console.log("Service stopped.");
8091
- }
8092
- if (isWindows(platform)) {
8093
- const launcherPath = path.join(installBase, `${component}-${previousVersion}`, getLauncherName(component, platform));
8094
- config.executablePath = launcherPath;
8095
- await manager.uninstall(config);
8096
- await manager.install(config);
8097
- writeCurrentVersion(installBase, component, previousVersion);
8098
- console.log(`Service registration updated to ${launcherPath}`);
8099
- } else {
8100
- updateSymlinkAtomic(installBase, component, previousVersion);
8101
- console.log(`Symlink updated: ${path.join(installBase, component)} -> ${component}-${previousVersion}`);
8102
- }
8103
- console.log("Starting service...");
8104
- await manager.start(config);
8105
- console.log("Service started.");
8106
- console.log("");
8107
- console.log(`Rollback complete: ${currentVersion} -> ${previousVersion}`);
8598
+ return lines.join("\n");
8108
8599
  }
8109
- /**
8110
- * Handle --cleanup: remove all versioned directories except the current
8111
- * and previous versions.
8112
- */
8113
- async function handleCleanup(component, platform, installBase) {
8114
- const versions = listInstalledVersions(installBase, component);
8115
- if (versions.length <= 2) {
8116
- console.log("Nothing to clean up (2 or fewer versions installed).");
8117
- return;
8118
- }
8119
- const currentVersion = getCurrentVersion(installBase, component, platform);
8120
- const currentIdx = currentVersion ? versions.indexOf(currentVersion) : versions.length - 1;
8121
- const previousIdx = currentIdx > 0 ? currentIdx - 1 : -1;
8122
- const toRemove = versions.filter((_, i) => i !== currentIdx && i !== previousIdx);
8123
- if (toRemove.length === 0) {
8124
- console.log("Nothing to clean up.");
8125
- return;
8126
- }
8127
- console.log("The following versions will be removed:");
8128
- for (const v of toRemove) console.log(` ${component}-${v}/`);
8129
- if (currentVersion) console.log(`\nKeeping: ${component}-${currentVersion}/ (current)`);
8130
- if (previousIdx >= 0) console.log(`Keeping: ${component}-${versions[previousIdx]}/ (previous)`);
8131
- for (const v of toRemove) {
8132
- const dirPath = path.join(installBase, `${component}-${v}`);
8133
- fs.rmSync(dirPath, {
8134
- recursive: true,
8135
- force: true
8136
- });
8137
- console.log(`Removed ${dirPath}`);
8138
- }
8139
- console.log(`\nCleanup complete. Removed ${toRemove.length} old version(s).`);
8600
+ /** Build JSON output combining service + health data. */
8601
+ function buildJsonOutput$1(serviceStatus, health, serviceName) {
8602
+ return {
8603
+ service: serviceName,
8604
+ ...serviceStatus,
8605
+ health: health ?? void 0
8606
+ };
8607
+ }
8608
+ function registerStatusCommand(parent) {
8609
+ parent.command("status").description("Show orchestrator service status and health information").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance to inspect").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").option("--json", "Output as JSON").action(async (opts) => {
8610
+ try {
8611
+ const platform = detectPlatform(opts.platform);
8612
+ const userLevel = resolveUserLevel(opts);
8613
+ const manager = await createServiceManager(platform);
8614
+ const kiciRoot = kiciConfigRoot(userLevel);
8615
+ const resolved = await resolveInstance({
8616
+ component: "orchestrator",
8617
+ opts: {
8618
+ instanceDir: opts.instanceDir,
8619
+ name: opts.name
8620
+ },
8621
+ cwd: process.cwd(),
8622
+ kiciRoot,
8623
+ manager,
8624
+ isUserLevel: userLevel
8625
+ });
8626
+ const config = {
8627
+ name: resolved.manifest.name,
8628
+ displayName: "KiCI Orchestrator",
8629
+ description: "KiCI CI/CD workflow orchestrator service",
8630
+ executablePath: "",
8631
+ envFilePath: resolved.manifest.envFilePath,
8632
+ workingDirectory: resolved.manifest.configDir,
8633
+ isUserLevel: resolved.manifest.isUserLevel,
8634
+ restartPolicy: DEFAULT_RESTART_POLICY,
8635
+ component: "orchestrator"
8636
+ };
8637
+ const serviceStatus = await manager.status(config);
8638
+ let health = null;
8639
+ if (serviceStatus.state === "running") health = await queryHealth$1(readPortFromEnvFile$1(config.envFilePath));
8640
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, config.name), null, 2));
8641
+ else console.log(formatStatus$1(serviceStatus, health, config.name));
8642
+ } catch (err) {
8643
+ console.error(`Error: ${toErrorMessage(err)}`);
8644
+ process.exit(1);
8645
+ }
8646
+ });
8647
+ }
8648
+ //#endregion
8649
+ //#region src/cli/commands/orchestrator-service/logs.ts
8650
+ function registerLogsCommand(parent) {
8651
+ parent.command("logs").description("Tail and follow orchestrator service logs").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance whose logs to read").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").option("--since <duration>", "Show logs since duration (e.g. 1h, 30m)").option("--level <level>", "Filter by log level (error|warn|info)").option("--json", "Output as structured JSON").option("--no-follow", "Snapshot mode (do not tail)").action(async (opts) => {
8652
+ try {
8653
+ const platform = detectPlatform(opts.platform);
8654
+ const userLevel = resolveUserLevel(opts);
8655
+ const manager = await createServiceManager(platform);
8656
+ const kiciRoot = kiciConfigRoot(userLevel);
8657
+ const resolved = await resolveInstance({
8658
+ component: "orchestrator",
8659
+ opts: {
8660
+ instanceDir: opts.instanceDir,
8661
+ name: opts.name
8662
+ },
8663
+ cwd: process.cwd(),
8664
+ kiciRoot,
8665
+ manager,
8666
+ isUserLevel: userLevel
8667
+ });
8668
+ const config = {
8669
+ name: resolved.manifest.name,
8670
+ displayName: "KiCI Orchestrator",
8671
+ description: "KiCI CI/CD workflow orchestrator service",
8672
+ executablePath: "",
8673
+ envFilePath: resolved.manifest.envFilePath,
8674
+ workingDirectory: resolved.manifest.configDir,
8675
+ isUserLevel: resolved.manifest.isUserLevel,
8676
+ restartPolicy: DEFAULT_RESTART_POLICY,
8677
+ component: "orchestrator"
8678
+ };
8679
+ const logOptions = {
8680
+ since: opts.since,
8681
+ level: opts.level,
8682
+ json: opts.json,
8683
+ follow: opts.follow
8684
+ };
8685
+ await manager.logs(config, logOptions);
8686
+ } catch (err) {
8687
+ console.error(`Error: ${toErrorMessage(err)}`);
8688
+ process.exit(1);
8689
+ }
8690
+ });
8140
8691
  }
8141
8692
  //#endregion
8142
8693
  //#region src/cli/commands/orchestrator-service/upgrade.ts
8143
8694
  function registerUpgradeCommand(parent) {
8144
- parent.command("upgrade").description("Upgrade orchestrator to a new version using versioned directory layout").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--from <path>", "Path to package archive (.tar.gz or .zip)").option("--url <url>", "URL to download package archive from").option("--version <version>", "Target version string (e.g., 0.3.0)").option("--yes", "Skip confirmation prompt").option("--force", "Overwrite existing versioned directory").option("--cleanup", "Remove old versions (keeps current and previous)").option("--rollback", "Roll back to the previous version").action(async (opts) => {
8695
+ parent.command("upgrade").description("Upgrade orchestrator to a new version using versioned directory layout").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance to upgrade").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--from <path>", "Path to package archive (.tar.gz or .zip)").option("--url <url>", "URL to download package archive from").option("--version <version>", "Target version string (e.g., 0.3.0)").option("--yes", "Skip confirmation prompt").option("--force", "Overwrite existing versioned directory").option("--cleanup", "Remove old versions (keeps current and previous)").option("--rollback", "Roll back to the previous version").action(async (opts) => {
8145
8696
  await performVersionedUpgrade("orchestrator", opts);
8146
8697
  });
8147
8698
  }
@@ -8199,18 +8750,33 @@ var init_agent_wizard = __esmMin((() => {
8199
8750
  //#endregion
8200
8751
  //#region src/cli/commands/agent-service/install.ts
8201
8752
  function registerAgentInstall(agent) {
8202
- agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--wizard", "Interactive wizard for guided setup").action(async (opts) => {
8753
+ agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--wizard", "Interactive wizard for guided setup").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
8203
8754
  try {
8204
8755
  if (opts.wizard && opts.envFile) {
8205
8756
  console.error("Error: Cannot use --wizard with --env-file");
8206
8757
  process.exit(1);
8207
8758
  }
8208
8759
  const platform = detectPlatform(opts.platform);
8209
- const userLevel = !isRoot();
8760
+ const userLevel = resolveUserLevel(opts);
8210
8761
  const serviceName = opts.name;
8762
+ const instanceDir = path.resolve(opts.instanceDir ?? process.cwd());
8763
+ const kiciRoot = kiciConfigRoot(userLevel);
8211
8764
  console.log(`Platform: ${platform}`);
8212
8765
  console.log(`Privilege: ${userLevel ? "user" : "system"}`);
8213
8766
  console.log(`Service name: ${serviceName}`);
8767
+ console.log(`Instance dir: ${instanceDir}`);
8768
+ const manager = await createServiceManager(platform);
8769
+ const existing = (await listInstances({
8770
+ component: "agent",
8771
+ isUserLevel: userLevel,
8772
+ kiciRoot,
8773
+ manager
8774
+ })).find((c) => c.name === serviceName);
8775
+ if (existing && existing.instanceDir !== instanceDir && !opts.force) {
8776
+ const at = existing.instanceDir ?? "(no manifest)";
8777
+ console.error(`Error: an agent instance "${serviceName}" is already installed at ${at}. Pass a different --name, a different --instance-dir, or --force to overwrite.`);
8778
+ process.exit(1);
8779
+ }
8214
8780
  const configDir = getConfigDir(serviceName, userLevel);
8215
8781
  const logDir = getLogDir(serviceName, userLevel);
8216
8782
  fs.mkdirSync(configDir, { recursive: true });
@@ -8257,12 +8823,37 @@ function registerAgentInstall(agent) {
8257
8823
  envFilePath,
8258
8824
  workingDirectory: configDir,
8259
8825
  isUserLevel: userLevel,
8826
+ component: "agent",
8260
8827
  restartPolicy: DEFAULT_RESTART_POLICY
8261
8828
  };
8262
- await (await createServiceManager(platform)).install(config);
8829
+ await manager.install(config);
8830
+ const manifestFile = writeManifest(instanceDir, {
8831
+ component: "agent",
8832
+ name: serviceName,
8833
+ platform,
8834
+ isUserLevel: userLevel,
8835
+ envFilePath,
8836
+ configDir,
8837
+ logDir,
8838
+ installBase: getInstallBase(platform, serviceName),
8839
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8840
+ kiciVersion: process.env.npm_package_version ?? "unknown"
8841
+ });
8842
+ try {
8843
+ appendIndexEntry(kiciRoot, {
8844
+ component: "agent",
8845
+ name: serviceName,
8846
+ platform,
8847
+ isUserLevel: userLevel,
8848
+ instanceDir
8849
+ });
8850
+ } catch (err) {
8851
+ console.warn(`Warning: instance index append failed: ${err.message}`);
8852
+ }
8263
8853
  console.log(`\nAgent service "${serviceName}" installed successfully.`);
8264
- console.log(` Config: ${envFilePath}`);
8265
- console.log(` Logs: ${logDir}`);
8854
+ console.log(` Config: ${envFilePath}`);
8855
+ console.log(` Logs: ${logDir}`);
8856
+ console.log(` Manifest: ${manifestFile}`);
8266
8857
  console.log(`\nNext steps:`);
8267
8858
  console.log(` 1. Edit ${envFilePath} with your configuration`);
8268
8859
  console.log(` 2. Run \`kici-admin agent start\` to start the service`);
@@ -8275,39 +8866,50 @@ function registerAgentInstall(agent) {
8275
8866
  //#endregion
8276
8867
  //#region src/cli/commands/agent-service/uninstall.ts
8277
8868
  function registerAgentUninstall(agent) {
8278
- agent.command("uninstall").description("Remove the agent service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-agent").action(async (opts) => {
8869
+ agent.command("uninstall").description("Remove the agent service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to uninstall").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8279
8870
  try {
8280
8871
  const platform = detectPlatform(opts.platform);
8281
- const userLevel = !isRoot();
8282
- const serviceName = opts.name;
8283
- const configDir = getConfigDir(serviceName, userLevel);
8284
- const logDir = getLogDir(serviceName, userLevel);
8872
+ const userLevel = resolveUserLevel(opts);
8873
+ const manager = await createServiceManager(platform);
8874
+ const kiciRoot = kiciConfigRoot(userLevel);
8875
+ const resolved = await resolveInstance({
8876
+ component: "agent",
8877
+ opts: {
8878
+ instanceDir: opts.instanceDir,
8879
+ name: opts.name
8880
+ },
8881
+ cwd: process.cwd(),
8882
+ kiciRoot,
8883
+ manager,
8884
+ isUserLevel: userLevel
8885
+ });
8285
8886
  const config = {
8286
- name: serviceName,
8887
+ name: resolved.manifest.name,
8287
8888
  displayName: "KiCI Agent",
8288
8889
  description: "KiCI CI/CD workflow execution agent service",
8289
8890
  executablePath: "",
8290
- envFilePath: `${configDir}${serviceName}.env`,
8291
- workingDirectory: configDir,
8292
- isUserLevel: userLevel,
8293
- restartPolicy: DEFAULT_RESTART_POLICY
8891
+ envFilePath: resolved.manifest.envFilePath,
8892
+ workingDirectory: resolved.manifest.configDir,
8893
+ isUserLevel: resolved.manifest.isUserLevel,
8894
+ restartPolicy: DEFAULT_RESTART_POLICY,
8895
+ component: "agent"
8294
8896
  };
8295
- const manager = await createServiceManager(platform);
8296
- if (!await manager.isInstalled(config)) {
8297
- console.log(`Service "${serviceName}" is not installed.`);
8298
- process.exit(0);
8897
+ if (!await manager.isInstalled(config)) console.log(`Service "${config.name}" is not installed.`);
8898
+ else {
8899
+ try {
8900
+ if ((await manager.status(config)).state === "running") {
8901
+ console.log(`Stopping service "${config.name}"...`);
8902
+ await manager.stop(config);
8903
+ }
8904
+ } catch {}
8905
+ await manager.uninstall(config);
8299
8906
  }
8300
- try {
8301
- if ((await manager.status(config)).state === "running") {
8302
- console.log(`Stopping service "${serviceName}"...`);
8303
- await manager.stop(config);
8304
- }
8305
- } catch {}
8306
- await manager.uninstall(config);
8307
- console.log(`\nAgent service "${serviceName}" uninstalled.`);
8308
- console.log(`\nThe following files were preserved for manual cleanup:`);
8309
- console.log(` Config: ${configDir}`);
8310
- console.log(` Logs: ${logDir}`);
8907
+ removeIndexEntry(kiciRoot, {
8908
+ component: "agent",
8909
+ name: config.name
8910
+ });
8911
+ console.log(`\nAgent service "${config.name}" uninstalled.`);
8912
+ console.log(`Manifest preserved at ${resolved.manifestPath} — delete manually if no longer needed.`);
8311
8913
  } catch (err) {
8312
8914
  console.error(`Error: ${toErrorMessage(err)}`);
8313
8915
  process.exit(1);
@@ -8317,30 +8919,41 @@ function registerAgentUninstall(agent) {
8317
8919
  //#endregion
8318
8920
  //#region src/cli/commands/agent-service/start.ts
8319
8921
  function registerAgentStart(agent) {
8320
- agent.command("start").description("Start the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-agent").action(async (opts) => {
8922
+ agent.command("start").description("Start the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to start").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8321
8923
  try {
8322
8924
  const platform = detectPlatform(opts.platform);
8323
- const userLevel = !isRoot();
8324
- const serviceName = opts.name;
8325
- const configDir = getConfigDir(serviceName, userLevel);
8925
+ const userLevel = resolveUserLevel(opts);
8926
+ const manager = await createServiceManager(platform);
8927
+ const kiciRoot = kiciConfigRoot(userLevel);
8928
+ const resolved = await resolveInstance({
8929
+ component: "agent",
8930
+ opts: {
8931
+ instanceDir: opts.instanceDir,
8932
+ name: opts.name
8933
+ },
8934
+ cwd: process.cwd(),
8935
+ kiciRoot,
8936
+ manager,
8937
+ isUserLevel: userLevel
8938
+ });
8326
8939
  const config = {
8327
- name: serviceName,
8940
+ name: resolved.manifest.name,
8328
8941
  displayName: "KiCI Agent",
8329
8942
  description: "KiCI CI/CD workflow execution agent service",
8330
8943
  executablePath: "",
8331
- envFilePath: path.join(configDir, `${serviceName}.env`),
8332
- workingDirectory: configDir,
8333
- isUserLevel: userLevel,
8334
- restartPolicy: DEFAULT_RESTART_POLICY
8944
+ envFilePath: resolved.manifest.envFilePath,
8945
+ workingDirectory: resolved.manifest.configDir,
8946
+ isUserLevel: resolved.manifest.isUserLevel,
8947
+ restartPolicy: DEFAULT_RESTART_POLICY,
8948
+ component: "agent"
8335
8949
  };
8336
- const manager = await createServiceManager(platform);
8337
8950
  if (!await manager.isInstalled(config)) {
8338
- console.error(`Error: service "${serviceName}" is not installed.`);
8951
+ console.error(`Error: service "${config.name}" is not installed.`);
8339
8952
  console.error(`Run \`kici-admin agent install\` first.`);
8340
8953
  process.exit(1);
8341
8954
  }
8342
8955
  await manager.start(config);
8343
- console.log(`Agent service "${serviceName}" started.`);
8956
+ console.log(`Agent service "${config.name}" started.`);
8344
8957
  } catch (err) {
8345
8958
  console.error(`Error: ${toErrorMessage(err)}`);
8346
8959
  process.exit(1);
@@ -8350,24 +8963,36 @@ function registerAgentStart(agent) {
8350
8963
  //#endregion
8351
8964
  //#region src/cli/commands/agent-service/stop.ts
8352
8965
  function registerAgentStop(agent) {
8353
- agent.command("stop").description("Stop the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-agent").action(async (opts) => {
8966
+ agent.command("stop").description("Stop the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to stop").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8354
8967
  try {
8355
8968
  const platform = detectPlatform(opts.platform);
8356
- const userLevel = !isRoot();
8357
- const serviceName = opts.name;
8358
- const configDir = getConfigDir(serviceName, userLevel);
8969
+ const userLevel = resolveUserLevel(opts);
8970
+ const manager = await createServiceManager(platform);
8971
+ const kiciRoot = kiciConfigRoot(userLevel);
8972
+ const resolved = await resolveInstance({
8973
+ component: "agent",
8974
+ opts: {
8975
+ instanceDir: opts.instanceDir,
8976
+ name: opts.name
8977
+ },
8978
+ cwd: process.cwd(),
8979
+ kiciRoot,
8980
+ manager,
8981
+ isUserLevel: userLevel
8982
+ });
8359
8983
  const config = {
8360
- name: serviceName,
8984
+ name: resolved.manifest.name,
8361
8985
  displayName: "KiCI Agent",
8362
8986
  description: "KiCI CI/CD workflow execution agent service",
8363
8987
  executablePath: "",
8364
- envFilePath: `${configDir}${serviceName}.env`,
8365
- workingDirectory: configDir,
8366
- isUserLevel: userLevel,
8367
- restartPolicy: DEFAULT_RESTART_POLICY
8988
+ envFilePath: resolved.manifest.envFilePath,
8989
+ workingDirectory: resolved.manifest.configDir,
8990
+ isUserLevel: resolved.manifest.isUserLevel,
8991
+ restartPolicy: DEFAULT_RESTART_POLICY,
8992
+ component: "agent"
8368
8993
  };
8369
- await (await createServiceManager(platform)).stop(config);
8370
- console.log(`Agent service "${serviceName}" stopped.`);
8994
+ await manager.stop(config);
8995
+ console.log(`Agent service "${config.name}" stopped.`);
8371
8996
  } catch (err) {
8372
8997
  console.error(`Error: ${toErrorMessage(err)}`);
8373
8998
  process.exit(1);
@@ -8377,30 +9002,41 @@ function registerAgentStop(agent) {
8377
9002
  //#endregion
8378
9003
  //#region src/cli/commands/agent-service/restart.ts
8379
9004
  function registerAgentRestart(agent) {
8380
- agent.command("restart").description("Restart the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-agent").action(async (opts) => {
9005
+ agent.command("restart").description("Restart the agent service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--instance-dir <path>", "Deploy folder of the instance to restart").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
8381
9006
  try {
8382
9007
  const platform = detectPlatform(opts.platform);
8383
- const userLevel = !isRoot();
8384
- const serviceName = opts.name;
8385
- const configDir = getConfigDir(serviceName, userLevel);
9008
+ const userLevel = resolveUserLevel(opts);
9009
+ const manager = await createServiceManager(platform);
9010
+ const kiciRoot = kiciConfigRoot(userLevel);
9011
+ const resolved = await resolveInstance({
9012
+ component: "agent",
9013
+ opts: {
9014
+ instanceDir: opts.instanceDir,
9015
+ name: opts.name
9016
+ },
9017
+ cwd: process.cwd(),
9018
+ kiciRoot,
9019
+ manager,
9020
+ isUserLevel: userLevel
9021
+ });
8386
9022
  const config = {
8387
- name: serviceName,
9023
+ name: resolved.manifest.name,
8388
9024
  displayName: "KiCI Agent",
8389
9025
  description: "KiCI CI/CD workflow execution agent service",
8390
9026
  executablePath: "",
8391
- envFilePath: `${configDir}${serviceName}.env`,
8392
- workingDirectory: configDir,
8393
- isUserLevel: userLevel,
8394
- restartPolicy: DEFAULT_RESTART_POLICY
9027
+ envFilePath: resolved.manifest.envFilePath,
9028
+ workingDirectory: resolved.manifest.configDir,
9029
+ isUserLevel: resolved.manifest.isUserLevel,
9030
+ restartPolicy: DEFAULT_RESTART_POLICY,
9031
+ component: "agent"
8395
9032
  };
8396
- const manager = await createServiceManager(platform);
8397
9033
  if (!await manager.isInstalled(config)) {
8398
- console.error(`Error: service "${serviceName}" is not installed.`);
9034
+ console.error(`Error: service "${config.name}" is not installed.`);
8399
9035
  console.error(`Run \`kici-admin agent install\` first.`);
8400
9036
  process.exit(1);
8401
9037
  }
8402
9038
  await manager.restart(config);
8403
- console.log(`Agent service "${serviceName}" restarted.`);
9039
+ console.log(`Agent service "${config.name}" restarted.`);
8404
9040
  } catch (err) {
8405
9041
  console.error(`Error: ${toErrorMessage(err)}`);
8406
9042
  process.exit(1);
@@ -8409,12 +9045,11 @@ function registerAgentRestart(agent) {
8409
9045
  }
8410
9046
  //#endregion
8411
9047
  //#region src/cli/commands/agent-service/status.ts
8412
- /** Read port from agent env file. */
8413
- function readPortFromEnv(configDir, serviceName) {
8414
- const envPath = path.join(configDir, `${serviceName}.env`);
8415
- if (!fs.existsSync(envPath)) return 4001;
9048
+ /** Read the port from the agent's env file (path from the manifest). */
9049
+ function readPortFromEnvFile(envFilePath) {
9050
+ if (!fs.existsSync(envFilePath)) return 4001;
8416
9051
  try {
8417
- const content = fs.readFileSync(envPath, "utf-8");
9052
+ const content = fs.readFileSync(envFilePath, "utf-8");
8418
9053
  for (const line of content.split("\n")) {
8419
9054
  const trimmed = line.trim();
8420
9055
  if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
@@ -8470,36 +9105,39 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
8470
9105
  };
8471
9106
  }
8472
9107
  function registerAgentStatusCommand(parent) {
8473
- parent.command("status").description("Show agent service status and health information").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-agent").option("--json", "Output as JSON").action(async (opts) => {
9108
+ parent.command("status").description("Show agent service status and health information").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance to inspect").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").option("--json", "Output as JSON").action(async (opts) => {
8474
9109
  try {
8475
- const manager = await createServiceManager(detectPlatform(opts.platform));
8476
- const userLevel = !isRoot();
8477
- const configDir = getConfigDir(opts.name, userLevel);
9110
+ const platform = detectPlatform(opts.platform);
9111
+ const userLevel = resolveUserLevel(opts);
9112
+ const manager = await createServiceManager(platform);
9113
+ const kiciRoot = kiciConfigRoot(userLevel);
9114
+ const resolved = await resolveInstance({
9115
+ component: "agent",
9116
+ opts: {
9117
+ instanceDir: opts.instanceDir,
9118
+ name: opts.name
9119
+ },
9120
+ cwd: process.cwd(),
9121
+ kiciRoot,
9122
+ manager,
9123
+ isUserLevel: userLevel
9124
+ });
8478
9125
  const config = {
8479
- name: opts.name,
8480
- displayName: "KiCI agent",
8481
- description: "KiCI agent service",
9126
+ name: resolved.manifest.name,
9127
+ displayName: "KiCI Agent",
9128
+ description: "KiCI CI/CD workflow execution agent service",
8482
9129
  executablePath: "",
8483
- envFilePath: path.join(configDir, `${opts.name}.env`),
8484
- workingDirectory: configDir,
8485
- isUserLevel: userLevel,
8486
- restartPolicy: {
8487
- enabled: true,
8488
- delays: [
8489
- 1,
8490
- 5,
8491
- 15,
8492
- 30
8493
- ],
8494
- maxRetries: 5,
8495
- windowSeconds: 300
8496
- }
9130
+ envFilePath: resolved.manifest.envFilePath,
9131
+ workingDirectory: resolved.manifest.configDir,
9132
+ isUserLevel: resolved.manifest.isUserLevel,
9133
+ restartPolicy: DEFAULT_RESTART_POLICY,
9134
+ component: "agent"
8497
9135
  };
8498
9136
  const serviceStatus = await manager.status(config);
8499
9137
  let health = null;
8500
- if (serviceStatus.state === "running") health = await queryHealth(readPortFromEnv(configDir, opts.name));
8501
- if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, opts.name), null, 2));
8502
- else console.log(formatStatus(serviceStatus, health, opts.name));
9138
+ if (serviceStatus.state === "running") health = await queryHealth(readPortFromEnvFile(config.envFilePath));
9139
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
9140
+ else console.log(formatStatus(serviceStatus, health, config.name));
8503
9141
  } catch (err) {
8504
9142
  console.error(`Error: ${toErrorMessage(err)}`);
8505
9143
  process.exit(1);
@@ -8509,30 +9147,33 @@ function registerAgentStatusCommand(parent) {
8509
9147
  //#endregion
8510
9148
  //#region src/cli/commands/agent-service/logs.ts
8511
9149
  function registerAgentLogsCommand(parent) {
8512
- parent.command("logs").description("Tail and follow agent service logs").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-agent").option("--since <duration>", "Show logs since duration (e.g. 1h, 30m)").option("--level <level>", "Filter by log level (error|warn|info)").option("--json", "Output as structured JSON").option("--no-follow", "Snapshot mode (do not tail)").action(async (opts) => {
9150
+ parent.command("logs").description("Tail and follow agent service logs").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance whose logs to read").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").option("--since <duration>", "Show logs since duration (e.g. 1h, 30m)").option("--level <level>", "Filter by log level (error|warn|info)").option("--json", "Output as structured JSON").option("--no-follow", "Snapshot mode (do not tail)").action(async (opts) => {
8513
9151
  try {
8514
- const manager = await createServiceManager(detectPlatform(opts.platform));
8515
- const userLevel = !isRoot();
8516
- const configDir = getConfigDir(opts.name, userLevel);
9152
+ const platform = detectPlatform(opts.platform);
9153
+ const userLevel = resolveUserLevel(opts);
9154
+ const manager = await createServiceManager(platform);
9155
+ const kiciRoot = kiciConfigRoot(userLevel);
9156
+ const resolved = await resolveInstance({
9157
+ component: "agent",
9158
+ opts: {
9159
+ instanceDir: opts.instanceDir,
9160
+ name: opts.name
9161
+ },
9162
+ cwd: process.cwd(),
9163
+ kiciRoot,
9164
+ manager,
9165
+ isUserLevel: userLevel
9166
+ });
8517
9167
  const config = {
8518
- name: opts.name,
8519
- displayName: "KiCI agent",
8520
- description: "KiCI agent service",
9168
+ name: resolved.manifest.name,
9169
+ displayName: "KiCI Agent",
9170
+ description: "KiCI CI/CD workflow execution agent service",
8521
9171
  executablePath: "",
8522
- envFilePath: path.join(configDir, `${opts.name}.env`),
8523
- workingDirectory: configDir,
8524
- isUserLevel: userLevel,
8525
- restartPolicy: {
8526
- enabled: true,
8527
- delays: [
8528
- 1,
8529
- 5,
8530
- 15,
8531
- 30
8532
- ],
8533
- maxRetries: 5,
8534
- windowSeconds: 300
8535
- }
9172
+ envFilePath: resolved.manifest.envFilePath,
9173
+ workingDirectory: resolved.manifest.configDir,
9174
+ isUserLevel: resolved.manifest.isUserLevel,
9175
+ restartPolicy: DEFAULT_RESTART_POLICY,
9176
+ component: "agent"
8536
9177
  };
8537
9178
  const logOptions = {
8538
9179
  since: opts.since,
@@ -8550,7 +9191,7 @@ function registerAgentLogsCommand(parent) {
8550
9191
  //#endregion
8551
9192
  //#region src/cli/commands/agent-service/upgrade.ts
8552
9193
  function registerAgentUpgradeCommand(parent) {
8553
- parent.command("upgrade").description("Upgrade agent to a new version using versioned directory layout").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--name <name>", "Service name", "kici-agent").option("--from <path>", "Path to package archive (.tar.gz or .zip)").option("--url <url>", "URL to download package archive from").option("--version <version>", "Target version string (e.g., 0.3.0)").option("--yes", "Skip confirmation prompt").option("--force", "Overwrite existing versioned directory").option("--cleanup", "Remove old versions (keeps current and previous)").option("--rollback", "Roll back to the previous version").action(async (opts) => {
9194
+ parent.command("upgrade").description("Upgrade agent to a new version using versioned directory layout").option("--platform <type>", "Service platform (systemd|launchd|windows|compose)").option("--instance-dir <path>", "Deploy folder of the instance to upgrade").option("--name <name>", "Service name (no default — must resolve via flag/CWD)").option("--from <path>", "Path to package archive (.tar.gz or .zip)").option("--url <url>", "URL to download package archive from").option("--version <version>", "Target version string (e.g., 0.3.0)").option("--yes", "Skip confirmation prompt").option("--force", "Overwrite existing versioned directory").option("--cleanup", "Remove old versions (keeps current and previous)").option("--rollback", "Roll back to the previous version").action(async (opts) => {
8554
9195
  await performVersionedUpgrade("agent", opts);
8555
9196
  });
8556
9197
  }