@kici-dev/orchestrator 0.1.12 → 0.1.14

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 (41) 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 +30 -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 +1719 -1002
  29. package/dist/db/migrations/025_init_failure.d.ts +16 -0
  30. package/dist/db/types.d.ts +15 -0
  31. package/dist/diagnostics/checks/index.d.ts +2 -1
  32. package/dist/diagnostics/checks/scaler.d.ts +13 -0
  33. package/dist/diagnostics/types.d.ts +5 -2
  34. package/dist/metrics/prometheus.d.ts +32 -4
  35. package/dist/reporting/execution-tracker.d.ts +77 -6
  36. package/dist/scaler/failure-tracker.d.ts +46 -0
  37. package/dist/scaler/manager.d.ts +8 -0
  38. package/dist/server.js +570 -168
  39. package/dist/standalone.js +547 -156
  40. package/package.json +4 -4
  41. package/sbom.spdx.json +91 -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";
@@ -1517,8 +1517,8 @@ var init_client = __esmMin((() => {
1517
1517
  //#region src/db/migrations/001_initial.ts
1518
1518
  init_client();
1519
1519
  var _001_initial_exports = /* @__PURE__ */ __exportAll({
1520
- down: () => down$23,
1521
- up: () => up$23
1520
+ down: () => down$24,
1521
+ up: () => up$24
1522
1522
  });
1523
1523
  /**
1524
1524
  * Squashed initial migration -- creates the complete Orchestrator database schema.
@@ -2210,7 +2210,7 @@ const DDL_STATEMENTS = [
2210
2210
  `ALTER TABLE ONLY public.held_runs
2211
2211
  ADD CONSTRAINT held_runs_environment_id_fkey FOREIGN KEY (environment_id) REFERENCES public.environments(id);`
2212
2212
  ];
2213
- async function up$23(db) {
2213
+ async function up$24(db) {
2214
2214
  for (const stmt of DDL_STATEMENTS) await sql.raw(stmt).execute(db);
2215
2215
  await sql`
2216
2216
  INSERT INTO cluster_meta (key, value)
@@ -2232,7 +2232,7 @@ async function up$23(db) {
2232
2232
  * Rollback drops everything created above. Uses CASCADE on table drops to cut
2233
2233
  * through the FK graph without relying on exact topological order.
2234
2234
  */
2235
- async function down$23(db) {
2235
+ async function down$24(db) {
2236
2236
  for (const [trig, tbl] of [["source_secrets_change_trigger", "scoped_secrets"], ["sources_change_trigger", "sources"]]) await sql.raw(`DROP TRIGGER IF EXISTS ${trig} ON public.${tbl}`).execute(db);
2237
2237
  for (const table of [
2238
2238
  "workflow_registrations",
@@ -2279,8 +2279,8 @@ async function down$23(db) {
2279
2279
  //#endregion
2280
2280
  //#region src/db/migrations/002_config_versions_key_version.ts
2281
2281
  var _002_config_versions_key_version_exports = /* @__PURE__ */ __exportAll({
2282
- down: () => down$22,
2283
- up: () => up$22
2282
+ down: () => down$23,
2283
+ up: () => up$23
2284
2284
  });
2285
2285
  /**
2286
2286
  * Add key_version column to config_versions so that sensitive-field encryption
@@ -2294,13 +2294,13 @@ var _002_config_versions_key_version_exports = /* @__PURE__ */ __exportAll({
2294
2294
  * No index is needed: rotation does a full-table scan; reads are by
2295
2295
  * `version` primary key and never filter on `key_version`.
2296
2296
  */
2297
- async function up$22(db) {
2297
+ async function up$23(db) {
2298
2298
  await sql`
2299
2299
  ALTER TABLE public.config_versions
2300
2300
  ADD COLUMN key_version integer NOT NULL DEFAULT 1
2301
2301
  `.execute(db);
2302
2302
  }
2303
- async function down$22(db) {
2303
+ async function down$23(db) {
2304
2304
  await sql`
2305
2305
  ALTER TABLE public.config_versions
2306
2306
  DROP COLUMN key_version
@@ -2309,8 +2309,8 @@ async function down$22(db) {
2309
2309
  //#endregion
2310
2310
  //#region src/db/migrations/003_access_log.ts
2311
2311
  var _003_access_log_exports = /* @__PURE__ */ __exportAll({
2312
- down: () => down$21,
2313
- up: () => up$21
2312
+ down: () => down$22,
2313
+ up: () => up$22
2314
2314
  });
2315
2315
  /**
2316
2316
  * Access log: one row per read or orchestrator-admin mutation attributable
@@ -2334,7 +2334,7 @@ var _003_access_log_exports = /* @__PURE__ */ __exportAll({
2334
2334
  * Retention is TTL-based via expires_at; packages/orchestrator/src/queue/
2335
2335
  * cleanup.ts picks up the prune pass.
2336
2336
  */
2337
- async function up$21(db) {
2337
+ async function up$22(db) {
2338
2338
  await sql`
2339
2339
  CREATE TABLE public.access_log (
2340
2340
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -2372,28 +2372,28 @@ async function up$21(db) {
2372
2372
  ON public.access_log (actor_type, actor_id, created_at DESC)
2373
2373
  `.execute(db);
2374
2374
  }
2375
- async function down$21(db) {
2375
+ async function down$22(db) {
2376
2376
  await sql`DROP TABLE IF EXISTS public.access_log`.execute(db);
2377
2377
  }
2378
2378
  //#endregion
2379
2379
  //#region src/db/migrations/004_rename_bundle_to_source.ts
2380
2380
  var _004_rename_bundle_to_source_exports = /* @__PURE__ */ __exportAll({
2381
- down: () => down$20,
2382
- up: () => up$20
2381
+ down: () => down$21,
2382
+ up: () => up$21
2383
2383
  });
2384
- async function up$20(db) {
2384
+ async function up$21(db) {
2385
2385
  await db.schema.alterTable("dispatch_queue").renameColumn("bundle_url", "source_tar_url").execute();
2386
2386
  await db.schema.alterTable("dispatch_queue").renameColumn("bundle_hash", "source_tar_hash").execute();
2387
2387
  }
2388
- async function down$20(db) {
2388
+ async function down$21(db) {
2389
2389
  await db.schema.alterTable("dispatch_queue").renameColumn("source_tar_url", "bundle_url").execute();
2390
2390
  await db.schema.alterTable("dispatch_queue").renameColumn("source_tar_hash", "bundle_hash").execute();
2391
2391
  }
2392
2392
  //#endregion
2393
2393
  //#region src/db/migrations/005_cold_store_chunk_counter.ts
2394
2394
  var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
2395
- down: () => down$19,
2396
- up: () => up$19
2395
+ down: () => down$20,
2396
+ up: () => up$20
2397
2397
  });
2398
2398
  /**
2399
2399
  * Cold-store chunk counter table.
@@ -2410,7 +2410,7 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
2410
2410
  *
2411
2411
  * sections 5 and 8.
2412
2412
  */
2413
- async function up$19(db) {
2413
+ async function up$20(db) {
2414
2414
  await sql`
2415
2415
  CREATE TABLE public.cold_store_chunk_counts (
2416
2416
  db TEXT NOT NULL,
@@ -2428,14 +2428,14 @@ async function up$19(db) {
2428
2428
  ON public.cold_store_chunk_counts (db, table_name)
2429
2429
  `.execute(db);
2430
2430
  }
2431
- async function down$19(db) {
2431
+ async function down$20(db) {
2432
2432
  await sql`DROP TABLE IF EXISTS public.cold_store_chunk_counts`.execute(db);
2433
2433
  }
2434
2434
  //#endregion
2435
2435
  //#region src/db/migrations/006_runs_jobs_steps_archived_at.ts
2436
2436
  var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
2437
- down: () => down$18,
2438
- up: () => up$18
2437
+ down: () => down$19,
2438
+ up: () => up$19
2439
2439
  });
2440
2440
  /**
2441
2441
  * `execution_runs` / `execution_jobs` / `execution_steps` cold-store
@@ -2471,7 +2471,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
2471
2471
  * - `idx_execution_jobs_routing_key_created (routing_key, created_at)`
2472
2472
  * - `idx_execution_steps_routing_key_created (routing_key, created_at)`
2473
2473
  */
2474
- async function up$18(db) {
2474
+ async function up$19(db) {
2475
2475
  await sql`
2476
2476
  ALTER TABLE public.execution_runs
2477
2477
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2516,7 +2516,7 @@ async function up$18(db) {
2516
2516
  ON public.execution_steps (routing_key, created_at)
2517
2517
  `.execute(db);
2518
2518
  }
2519
- async function down$18(db) {
2519
+ async function down$19(db) {
2520
2520
  await sql`DROP INDEX IF EXISTS public.idx_execution_steps_routing_key_created`.execute(db);
2521
2521
  await sql`
2522
2522
  ALTER TABLE public.execution_steps
@@ -2541,8 +2541,8 @@ async function down$18(db) {
2541
2541
  //#endregion
2542
2542
  //#region src/db/migrations/007_audit_logs_archived_at.ts
2543
2543
  var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
2544
- down: () => down$17,
2545
- up: () => up$17
2544
+ down: () => down$18,
2545
+ up: () => up$18
2546
2546
  });
2547
2547
  /**
2548
2548
  * `secret_audit_log` and `access_log` cold-store schema additions, plus
@@ -2582,7 +2582,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
2582
2582
  * `down()` would not have meaningful retention bounds. Acceptable for
2583
2583
  * staging.
2584
2584
  */
2585
- async function up$17(db) {
2585
+ async function up$18(db) {
2586
2586
  await sql`
2587
2587
  ALTER TABLE public.secret_audit_log
2588
2588
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2603,7 +2603,7 @@ async function up$17(db) {
2603
2603
  DROP COLUMN IF EXISTS expires_at
2604
2604
  `.execute(db);
2605
2605
  }
2606
- async function down$17(db) {
2606
+ async function down$18(db) {
2607
2607
  await sql`
2608
2608
  ALTER TABLE public.access_log
2609
2609
  ADD COLUMN expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '90 days')
@@ -2631,8 +2631,8 @@ async function down$17(db) {
2631
2631
  //#endregion
2632
2632
  //#region src/db/migrations/008_event_log_archived_at.ts
2633
2633
  var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
2634
- down: () => down$16,
2635
- up: () => up$16
2634
+ down: () => down$17,
2635
+ up: () => up$17
2636
2636
  });
2637
2637
  /**
2638
2638
  * `event_log` cold-store schema additions plus removal of the
@@ -2670,7 +2670,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
2670
2670
  * — best effort; rows inserted between `up()` and a hypothetical
2671
2671
  * `down()` would not have meaningful retention bounds.
2672
2672
  */
2673
- async function up$16(db) {
2673
+ async function up$17(db) {
2674
2674
  await sql`
2675
2675
  ALTER TABLE public.event_log
2676
2676
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2686,7 +2686,7 @@ async function up$16(db) {
2686
2686
  DROP COLUMN IF EXISTS expires_at
2687
2687
  `.execute(db);
2688
2688
  }
2689
- async function down$16(db) {
2689
+ async function down$17(db) {
2690
2690
  await sql`
2691
2691
  ALTER TABLE public.event_log
2692
2692
  ADD COLUMN expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '30 days')
@@ -2705,8 +2705,8 @@ async function down$16(db) {
2705
2705
  //#endregion
2706
2706
  //#region src/db/migrations/009_access_log_trigram.ts
2707
2707
  var _009_access_log_trigram_exports = /* @__PURE__ */ __exportAll({
2708
- down: () => down$15,
2709
- up: () => up$15
2708
+ down: () => down$16,
2709
+ up: () => up$16
2710
2710
  });
2711
2711
  /**
2712
2712
  * Trigram (pg_trgm) index on access_log.error_message for the federated
@@ -2719,7 +2719,7 @@ var _009_access_log_trigram_exports = /* @__PURE__ */ __exportAll({
2719
2719
  * EXISTS` are both safe to re-run. No CONCURRENTLY because Kysely runs
2720
2720
  * migrations inside a transaction; the lock is brief on a sampled table.
2721
2721
  */
2722
- async function up$15(db) {
2722
+ async function up$16(db) {
2723
2723
  await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.execute(db);
2724
2724
  await sql`
2725
2725
  CREATE INDEX IF NOT EXISTS access_log_error_message_trgm_idx
@@ -2728,14 +2728,14 @@ async function up$15(db) {
2728
2728
  WHERE error_message IS NOT NULL
2729
2729
  `.execute(db);
2730
2730
  }
2731
- async function down$15(db) {
2731
+ async function down$16(db) {
2732
2732
  await sql`DROP INDEX IF EXISTS public.access_log_error_message_trgm_idx`.execute(db);
2733
2733
  }
2734
2734
  //#endregion
2735
2735
  //#region src/db/migrations/010_cold_store_chunks.ts
2736
2736
  var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
2737
- down: () => down$14,
2738
- up: () => up$14
2737
+ down: () => down$15,
2738
+ up: () => up$15
2739
2739
  });
2740
2740
  /**
2741
2741
  * Cold-store chunk index — Phase 2 (cold-store purge).
@@ -2767,7 +2767,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
2767
2767
  * forever. Adapters that don't opt into per-bucket archival via
2768
2768
  * `coldTtlDays` don't insert here either.
2769
2769
  */
2770
- async function up$14(db) {
2770
+ async function up$15(db) {
2771
2771
  await sql`
2772
2772
  CREATE TABLE public.cold_store_chunks (
2773
2773
  db TEXT NOT NULL,
@@ -2794,14 +2794,14 @@ async function up$14(db) {
2794
2794
  ON public.cold_store_chunks (db, table_name, tenant_id, archived_at DESC)
2795
2795
  `.execute(db);
2796
2796
  }
2797
- async function down$14(db) {
2797
+ async function down$15(db) {
2798
2798
  await sql`DROP TABLE IF EXISTS public.cold_store_chunks`.execute(db);
2799
2799
  }
2800
2800
  //#endregion
2801
2801
  //#region src/db/migrations/011_drop_source_secrets_notify.ts
2802
2802
  var _011_drop_source_secrets_notify_exports = /* @__PURE__ */ __exportAll({
2803
- down: () => down$13,
2804
- up: () => up$13
2803
+ down: () => down$14,
2804
+ up: () => up$14
2805
2805
  });
2806
2806
  /**
2807
2807
  * Drop the `source_secrets_change_trigger` and the
@@ -2820,11 +2820,11 @@ var _011_drop_source_secrets_notify_exports = /* @__PURE__ */ __exportAll({
2820
2820
  * in `001_initial.ts`. They wake up no consumer until the `WebhookSecretManager`
2821
2821
  * is restored, so this migration is safe to roll back.
2822
2822
  */
2823
- async function up$13(db) {
2823
+ async function up$14(db) {
2824
2824
  await sql`DROP TRIGGER IF EXISTS source_secrets_change_trigger ON public.scoped_secrets`.execute(db);
2825
2825
  await sql`DROP FUNCTION IF EXISTS public.notify_source_secrets_change() CASCADE`.execute(db);
2826
2826
  }
2827
- async function down$13(db) {
2827
+ async function down$14(db) {
2828
2828
  await sql`
2829
2829
  CREATE OR REPLACE FUNCTION public.notify_source_secrets_change() RETURNS trigger
2830
2830
  LANGUAGE plpgsql
@@ -2863,8 +2863,8 @@ async function down$13(db) {
2863
2863
  //#endregion
2864
2864
  //#region src/db/migrations/012_peer_credentials_active_uniq.ts
2865
2865
  var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
2866
- down: () => down$12,
2867
- up: () => up$12
2866
+ down: () => down$13,
2867
+ up: () => up$13
2868
2868
  });
2869
2869
  /**
2870
2870
  * Add a partial unique index on `peer_credentials (instance_id) WHERE
@@ -2890,7 +2890,7 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
2890
2890
  * `down()` only drops the index; it does NOT undo the dedupe (there's no
2891
2891
  * safe way to recreate revoked rows, and the dedupe is monotonic).
2892
2892
  */
2893
- async function up$12(db) {
2893
+ async function up$13(db) {
2894
2894
  await sql`
2895
2895
  UPDATE public.peer_credentials
2896
2896
  SET revoked_at = NOW()
@@ -2908,14 +2908,14 @@ async function up$12(db) {
2908
2908
  WHERE revoked_at IS NULL
2909
2909
  `.execute(db);
2910
2910
  }
2911
- async function down$12(db) {
2911
+ async function down$13(db) {
2912
2912
  await sql`DROP INDEX IF EXISTS public.peer_credentials_active_uniq`.execute(db);
2913
2913
  }
2914
2914
  //#endregion
2915
2915
  //#region src/db/migrations/013_execution_log_bytes.ts
2916
2916
  var _013_execution_log_bytes_exports = /* @__PURE__ */ __exportAll({
2917
- down: () => down$11,
2918
- up: () => up$11
2917
+ down: () => down$12,
2918
+ up: () => up$12
2919
2919
  });
2920
2920
  /**
2921
2921
  * Add `log_bytes BIGINT NOT NULL DEFAULT 0` columns to `execution_runs` and
@@ -2934,7 +2934,7 @@ var _013_execution_log_bytes_exports = /* @__PURE__ */ __exportAll({
2934
2934
  *
2935
2935
  * Idempotent (`ADD COLUMN IF NOT EXISTS`).
2936
2936
  */
2937
- async function up$11(db) {
2937
+ async function up$12(db) {
2938
2938
  await sql`
2939
2939
  ALTER TABLE public.execution_runs
2940
2940
  ADD COLUMN IF NOT EXISTS log_bytes BIGINT NOT NULL DEFAULT 0
@@ -2944,15 +2944,15 @@ async function up$11(db) {
2944
2944
  ADD COLUMN IF NOT EXISTS log_bytes BIGINT NOT NULL DEFAULT 0
2945
2945
  `.execute(db);
2946
2946
  }
2947
- async function down$11(db) {
2947
+ async function down$12(db) {
2948
2948
  await sql`ALTER TABLE public.execution_runs DROP COLUMN IF EXISTS log_bytes`.execute(db);
2949
2949
  await sql`ALTER TABLE public.execution_jobs DROP COLUMN IF EXISTS log_bytes`.execute(db);
2950
2950
  }
2951
2951
  //#endregion
2952
2952
  //#region src/db/migrations/014_kici_events_lease_retry.ts
2953
2953
  var _014_kici_events_lease_retry_exports = /* @__PURE__ */ __exportAll({
2954
- down: () => down$10,
2955
- up: () => up$10
2954
+ down: () => down$11,
2955
+ up: () => up$11
2956
2956
  });
2957
2957
  /**
2958
2958
  * Add lease + retry + DLQ columns to `kici_events` so the EventRouter can
@@ -2986,7 +2986,7 @@ var _014_kici_events_lease_retry_exports = /* @__PURE__ */ __exportAll({
2986
2986
  *
2987
2987
  * Idempotent (`ADD COLUMN IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`).
2988
2988
  */
2989
- async function up$10(db) {
2989
+ async function up$11(db) {
2990
2990
  await sql`
2991
2991
  ALTER TABLE public.kici_events
2992
2992
  ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ,
@@ -3017,7 +3017,7 @@ async function up$10(db) {
3017
3017
  WHERE dlq_at IS NOT NULL
3018
3018
  `.execute(db);
3019
3019
  }
3020
- async function down$10(db) {
3020
+ async function down$11(db) {
3021
3021
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_dlq`.execute(db);
3022
3022
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_lease_expired`.execute(db);
3023
3023
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_retry_due`.execute(db);
@@ -3035,8 +3035,8 @@ async function down$10(db) {
3035
3035
  //#endregion
3036
3036
  //#region src/db/migrations/015_org_settings_customer_scoped.ts
3037
3037
  var _015_org_settings_customer_scoped_exports = /* @__PURE__ */ __exportAll({
3038
- down: () => down$9,
3039
- up: () => up$9
3038
+ down: () => down$10,
3039
+ up: () => up$10
3040
3040
  });
3041
3041
  /**
3042
3042
  * Org-scope `org_settings` and qualify each glob entry by source.
@@ -3064,7 +3064,7 @@ var _015_org_settings_customer_scoped_exports = /* @__PURE__ */ __exportAll({
3064
3064
  * Idempotent: a re-run on an already-migrated DB sees `customer_id` exists
3065
3065
  * and the list columns are already jsonb, so it is a no-op.
3066
3066
  */
3067
- async function up$9(db) {
3067
+ async function up$10(db) {
3068
3068
  if ((await sql`
3069
3069
  SELECT EXISTS (
3070
3070
  SELECT 1 FROM information_schema.columns
@@ -3189,7 +3189,7 @@ async function up$9(db) {
3189
3189
  await sql`DROP TABLE _org_settings_merged`.execute(db);
3190
3190
  await sql`DROP TABLE _org_settings_stage`.execute(db);
3191
3191
  }
3192
- async function down$9(db) {
3192
+ async function down$10(db) {
3193
3193
  if (!(await sql`
3194
3194
  SELECT EXISTS (
3195
3195
  SELECT 1 FROM information_schema.columns
@@ -3214,8 +3214,8 @@ async function down$9(db) {
3214
3214
  //#endregion
3215
3215
  //#region src/db/migrations/016_org_settings_allow_http_npm.ts
3216
3216
  var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
3217
- down: () => down$8,
3218
- up: () => up$8
3217
+ down: () => down$9,
3218
+ up: () => up$9
3219
3219
  });
3220
3220
  /**
3221
3221
  * Add `org_settings.allow_http_npm_registries boolean NOT NULL DEFAULT false`.
@@ -3228,7 +3228,7 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
3228
3228
  *
3229
3229
  * Idempotent: a re-run on a DB that already has the column is a no-op.
3230
3230
  */
3231
- async function up$8(db) {
3231
+ async function up$9(db) {
3232
3232
  if ((await sql`
3233
3233
  SELECT EXISTS (
3234
3234
  SELECT 1 FROM information_schema.columns
@@ -3242,7 +3242,7 @@ async function up$8(db) {
3242
3242
  ADD COLUMN allow_http_npm_registries boolean NOT NULL DEFAULT false
3243
3243
  `.execute(db);
3244
3244
  }
3245
- async function down$8(db) {
3245
+ async function down$9(db) {
3246
3246
  await sql`
3247
3247
  ALTER TABLE public.org_settings DROP COLUMN IF EXISTS allow_http_npm_registries
3248
3248
  `.execute(db);
@@ -3250,8 +3250,8 @@ async function down$8(db) {
3250
3250
  //#endregion
3251
3251
  //#region src/db/migrations/017_org_id_widen.ts
3252
3252
  var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
3253
- down: () => down$7,
3254
- up: () => up$7
3253
+ down: () => down$8,
3254
+ up: () => up$8
3255
3255
  });
3256
3256
  /**
3257
3257
  * Widen every orchestrator-side `org_id varchar(12)` column to
@@ -3291,17 +3291,17 @@ const ORG_ID_TABLES$1 = [
3291
3291
  "held_runs",
3292
3292
  "scoped_secrets"
3293
3293
  ];
3294
- async function up$7(db) {
3294
+ async function up$8(db) {
3295
3295
  for (const table of ORG_ID_TABLES$1) await sql.raw(`ALTER TABLE public.${table} ALTER COLUMN org_id TYPE varchar(16)`).execute(db);
3296
3296
  }
3297
- async function down$7(db) {
3297
+ async function down$8(db) {
3298
3298
  for (const table of ORG_ID_TABLES$1) await sql.raw(`ALTER TABLE public.${table} ALTER COLUMN org_id TYPE varchar(12)`).execute(db);
3299
3299
  }
3300
3300
  //#endregion
3301
3301
  //#region src/db/migrations/018_org_id_prefix_backfill.ts
3302
3302
  var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
3303
- down: () => down$6,
3304
- up: () => up$6
3303
+ down: () => down$7,
3304
+ up: () => up$7
3305
3305
  });
3306
3306
  /**
3307
3307
  * Prefix every orchestrator-side tenant string with `org_` to align
@@ -3346,19 +3346,19 @@ const CUSTOMER_ID_TABLES = [
3346
3346
  "workflow_registrations",
3347
3347
  "org_settings"
3348
3348
  ];
3349
- async function up$6(db) {
3349
+ async function up$7(db) {
3350
3350
  for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = 'org_' || org_id WHERE org_id <> 'kici-admin' AND org_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3351
3351
  for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = 'org_' || customer_id WHERE customer_id <> 'kici-admin' AND customer_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3352
3352
  }
3353
- async function down$6(db) {
3353
+ async function down$7(db) {
3354
3354
  for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = substring(customer_id from 5) WHERE customer_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3355
3355
  for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = substring(org_id from 5) WHERE org_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3356
3356
  }
3357
3357
  //#endregion
3358
3358
  //#region src/db/migrations/019_generic_sources_change_notify.ts
3359
3359
  var _019_generic_sources_change_notify_exports = /* @__PURE__ */ __exportAll({
3360
- down: () => down$5,
3361
- up: () => up$5
3360
+ down: () => down$6,
3361
+ up: () => up$6
3362
3362
  });
3363
3363
  /**
3364
3364
  * Add a Postgres trigger on `generic_webhook_sources` that emits
@@ -3381,7 +3381,7 @@ var _019_generic_sources_change_notify_exports = /* @__PURE__ */ __exportAll({
3381
3381
  * (`notify_sources_change()` + `sources_change_trigger`, defined in
3382
3382
  * `001_initial.ts`).
3383
3383
  */
3384
- async function up$5(db) {
3384
+ async function up$6(db) {
3385
3385
  await sql`
3386
3386
  CREATE FUNCTION public.notify_generic_sources_change() RETURNS trigger
3387
3387
  LANGUAGE plpgsql
@@ -3402,15 +3402,15 @@ async function up$5(db) {
3402
3402
  FOR EACH ROW EXECUTE FUNCTION public.notify_generic_sources_change()
3403
3403
  `.execute(db);
3404
3404
  }
3405
- async function down$5(db) {
3405
+ async function down$6(db) {
3406
3406
  await sql`DROP TRIGGER IF EXISTS generic_sources_change_trigger ON public.generic_webhook_sources`.execute(db);
3407
3407
  await sql`DROP FUNCTION IF EXISTS public.notify_generic_sources_change()`.execute(db);
3408
3408
  }
3409
3409
  //#endregion
3410
3410
  //#region src/db/migrations/020_org_settings_dashboard_write_policy.ts
3411
3411
  var _020_org_settings_dashboard_write_policy_exports = /* @__PURE__ */ __exportAll({
3412
- down: () => down$4,
3413
- up: () => up$4
3412
+ down: () => down$5,
3413
+ up: () => up$5
3414
3414
  });
3415
3415
  /**
3416
3416
  * Add `org_settings.dashboard_write_policy jsonb NOT NULL DEFAULT '{}'`.
@@ -3425,7 +3425,7 @@ var _020_org_settings_dashboard_write_policy_exports = /* @__PURE__ */ __exportA
3425
3425
  *
3426
3426
  * Idempotent: a re-run on a DB that already has the column is a no-op.
3427
3427
  */
3428
- async function up$4(db) {
3428
+ async function up$5(db) {
3429
3429
  if ((await sql`
3430
3430
  SELECT EXISTS (
3431
3431
  SELECT 1 FROM information_schema.columns
@@ -3439,7 +3439,7 @@ async function up$4(db) {
3439
3439
  ADD COLUMN dashboard_write_policy jsonb NOT NULL DEFAULT '{}'::jsonb
3440
3440
  `.execute(db);
3441
3441
  }
3442
- async function down$4(db) {
3442
+ async function down$5(db) {
3443
3443
  await sql`
3444
3444
  ALTER TABLE public.org_settings DROP COLUMN IF EXISTS dashboard_write_policy
3445
3445
  `.execute(db);
@@ -3447,8 +3447,8 @@ async function down$4(db) {
3447
3447
  //#endregion
3448
3448
  //#region src/db/migrations/021_check_run_tracking.ts
3449
3449
  var _021_check_run_tracking_exports = /* @__PURE__ */ __exportAll({
3450
- down: () => down$3,
3451
- up: () => up$3
3450
+ down: () => down$4,
3451
+ up: () => up$4
3452
3452
  });
3453
3453
  /**
3454
3454
  * Add `check_run_tracking` table for HA-safe check-run state persistence.
@@ -3472,7 +3472,7 @@ var _021_check_run_tracking_exports = /* @__PURE__ */ __exportAll({
3472
3472
  *
3473
3473
  * Idempotent: a re-run on a DB that already has the table is a no-op.
3474
3474
  */
3475
- async function up$3(db) {
3475
+ async function up$4(db) {
3476
3476
  if ((await sql`
3477
3477
  SELECT EXISTS (
3478
3478
  SELECT 1 FROM information_schema.tables
@@ -3503,14 +3503,14 @@ async function up$3(db) {
3503
3503
  WHERE run_id IS NOT NULL
3504
3504
  `.execute(db);
3505
3505
  }
3506
- async function down$3(db) {
3506
+ async function down$4(db) {
3507
3507
  await sql`DROP TABLE IF EXISTS public.check_run_tracking`.execute(db);
3508
3508
  }
3509
3509
  //#endregion
3510
3510
  //#region src/db/migrations/022_scaler_manager_state.ts
3511
3511
  var _022_scaler_manager_state_exports = /* @__PURE__ */ __exportAll({
3512
- down: () => down$2,
3513
- up: () => up$2
3512
+ down: () => down$3,
3513
+ up: () => up$3
3514
3514
  });
3515
3515
  /**
3516
3516
  * Add three tables persisting `ScalerManager` per-coord state:
@@ -3537,7 +3537,7 @@ var _022_scaler_manager_state_exports = /* @__PURE__ */ __exportAll({
3537
3537
  * Idempotent: a re-run on a DB that already has any of these tables
3538
3538
  * leaves the existing one alone.
3539
3539
  */
3540
- async function up$2(db) {
3540
+ async function up$3(db) {
3541
3541
  const tableExists = async (name) => {
3542
3542
  return (await sql`
3543
3543
  SELECT EXISTS (
@@ -3587,7 +3587,7 @@ async function up$2(db) {
3587
3587
  `.execute(db);
3588
3588
  }
3589
3589
  }
3590
- async function down$2(db) {
3590
+ async function down$3(db) {
3591
3591
  await sql`DROP TABLE IF EXISTS public.scaler_reservations`.execute(db);
3592
3592
  await sql`DROP TABLE IF EXISTS public.scaler_agent_jobs`.execute(db);
3593
3593
  await sql`DROP TABLE IF EXISTS public.scaler_spawning_agents`.execute(db);
@@ -3595,8 +3595,8 @@ async function down$2(db) {
3595
3595
  //#endregion
3596
3596
  //#region src/db/migrations/023_dispatch_queue_recovery_deadline.ts
3597
3597
  var _023_dispatch_queue_recovery_deadline_exports = /* @__PURE__ */ __exportAll({
3598
- down: () => down$1,
3599
- up: () => up$1
3598
+ down: () => down$2,
3599
+ up: () => up$2
3600
3600
  });
3601
3601
  /**
3602
3602
  * Add `dispatch_queue.recovery_deadline TIMESTAMPTZ` and
@@ -3620,7 +3620,7 @@ var _023_dispatch_queue_recovery_deadline_exports = /* @__PURE__ */ __exportAll(
3620
3620
  * Idempotent: re-running on a DB that already has either column is a
3621
3621
  * no-op.
3622
3622
  */
3623
- async function up$1(db) {
3623
+ async function up$2(db) {
3624
3624
  const colExists = async (name) => {
3625
3625
  return (await sql`
3626
3626
  SELECT EXISTS (
@@ -3645,7 +3645,7 @@ async function up$1(db) {
3645
3645
  WHERE recovery_deadline IS NOT NULL
3646
3646
  `.execute(db);
3647
3647
  }
3648
- async function down$1(db) {
3648
+ async function down$2(db) {
3649
3649
  await sql`DROP INDEX IF EXISTS public.idx_dispatch_queue_recovery_deadline`.execute(db);
3650
3650
  await sql`
3651
3651
  ALTER TABLE public.dispatch_queue DROP COLUMN IF EXISTS recovery_agent_id
@@ -3657,8 +3657,8 @@ async function down$1(db) {
3657
3657
  //#endregion
3658
3658
  //#region src/db/migrations/024_dispatch_queue_provisioning_error.ts
3659
3659
  var _024_dispatch_queue_provisioning_error_exports = /* @__PURE__ */ __exportAll({
3660
- down: () => down,
3661
- up: () => up
3660
+ down: () => down$1,
3661
+ up: () => up$1
3662
3662
  });
3663
3663
  /**
3664
3664
  * Add `dispatch_queue.last_provisioning_error TEXT` recording the most
@@ -3674,7 +3674,7 @@ var _024_dispatch_queue_provisioning_error_exports = /* @__PURE__ */ __exportAll
3674
3674
  *
3675
3675
  * Idempotent: re-running on a DB that already has the column is a no-op.
3676
3676
  */
3677
- async function up(db) {
3677
+ async function up$1(db) {
3678
3678
  const colExists = async (name) => {
3679
3679
  return (await sql`
3680
3680
  SELECT EXISTS (
@@ -3690,12 +3690,58 @@ async function up(db) {
3690
3690
  ADD COLUMN last_provisioning_error TEXT
3691
3691
  `.execute(db);
3692
3692
  }
3693
- async function down(db) {
3693
+ async function down$1(db) {
3694
3694
  await sql`
3695
3695
  ALTER TABLE public.dispatch_queue DROP COLUMN IF EXISTS last_provisioning_error
3696
3696
  `.execute(db);
3697
3697
  }
3698
3698
  //#endregion
3699
+ //#region src/db/migrations/025_init_failure.ts
3700
+ var _025_init_failure_exports = /* @__PURE__ */ __exportAll({
3701
+ down: () => down,
3702
+ up: () => up
3703
+ });
3704
+ /**
3705
+ * Add `init_failure jsonb` columns to `execution_runs` and `execution_jobs`.
3706
+ *
3707
+ * Presence of this column on a row means the run/job never executed a step
3708
+ * because of an init-phase failure; absence (NULL) means a normal run.
3709
+ * Shape on the wire is `InitFailure` from `@kici-dev/engine`. The dashboard
3710
+ * reads this column directly so it can render the right banner without
3711
+ * round-tripping to the orchestrator (which may be offline).
3712
+ *
3713
+ * Idempotent: re-running on a DB that already has either column is a no-op
3714
+ * for that column.
3715
+ */
3716
+ async function up(db) {
3717
+ const colExists = async (table, name) => {
3718
+ return (await sql`
3719
+ SELECT EXISTS (
3720
+ SELECT 1 FROM information_schema.columns
3721
+ WHERE table_schema = 'public'
3722
+ AND table_name = ${table}
3723
+ AND column_name = ${name}
3724
+ ) AS exists
3725
+ `.execute(db)).rows[0]?.exists ?? false;
3726
+ };
3727
+ if (!await colExists("execution_runs", "init_failure")) await sql`
3728
+ ALTER TABLE public.execution_runs
3729
+ ADD COLUMN init_failure JSONB DEFAULT NULL
3730
+ `.execute(db);
3731
+ if (!await colExists("execution_jobs", "init_failure")) await sql`
3732
+ ALTER TABLE public.execution_jobs
3733
+ ADD COLUMN init_failure JSONB DEFAULT NULL
3734
+ `.execute(db);
3735
+ }
3736
+ async function down(db) {
3737
+ await sql`
3738
+ ALTER TABLE public.execution_jobs DROP COLUMN IF EXISTS init_failure
3739
+ `.execute(db);
3740
+ await sql`
3741
+ ALTER TABLE public.execution_runs DROP COLUMN IF EXISTS init_failure
3742
+ `.execute(db);
3743
+ }
3744
+ //#endregion
3699
3745
  //#region src/db/migration-provider.ts
3700
3746
  function createMigrationProvider() {
3701
3747
  return { async getMigrations() {
@@ -3723,7 +3769,8 @@ function createMigrationProvider() {
3723
3769
  "021_check_run_tracking": _021_check_run_tracking_exports,
3724
3770
  "022_scaler_manager_state": _022_scaler_manager_state_exports,
3725
3771
  "023_dispatch_queue_recovery_deadline": _023_dispatch_queue_recovery_deadline_exports,
3726
- "024_dispatch_queue_provisioning_error": _024_dispatch_queue_provisioning_error_exports
3772
+ "024_dispatch_queue_provisioning_error": _024_dispatch_queue_provisioning_error_exports,
3773
+ "025_init_failure": _025_init_failure_exports
3727
3774
  };
3728
3775
  } };
3729
3776
  }
@@ -3865,7 +3912,7 @@ function registerDbCommands(program, getClient) {
3865
3912
  process.exit(1);
3866
3913
  }
3867
3914
  });
3868
- db.command("ensure <name>").description("CREATE DATABASE IF NOT EXISTS (idempotent)").option("--database-url <url>", "Admin DB URL (else KICI_DATABASE_URL / DATABASE_URL)").option("--owner <role>", "DB owner role (default: URL user). Pass when the admin connection is privileged but the new DB should be owned by a separate non-privileged role.").option("--revoke-connect-public", "After ensure, REVOKE CONNECT ON DATABASE \"<name>\" FROM PUBLIC (recommended on shared clusters).").action(async (name, opts) => {
3915
+ db.command("ensure <name>").description("CREATE DATABASE IF NOT EXISTS (idempotent)").option("--database-url <url>", "Admin DB URL (else KICI_DATABASE_URL / DATABASE_URL)").option("--owner <role>", "DB owner role (default: URL user). Pass when the admin connection is privileged but the new DB should be owned by a separate non-privileged role.").option("--revoke-connect-public", "After ensure, REVOKE CONNECT ON DATABASE \"<name>\" FROM PUBLIC (recommended on shared clusters).").option("--grant-connect-role <role>", "After ensure (and any --revoke-connect-public), GRANT CONNECT ON DATABASE \"<name>\" TO \"<role>\". Repeatable.", (val, acc) => acc.concat([val]), []).action(async (name, opts) => {
3869
3916
  try {
3870
3917
  const baseUrl = resolveDatabaseUrl$1(opts.databaseUrl);
3871
3918
  const url = new URL(baseUrl);
@@ -3874,9 +3921,10 @@ function registerDbCommands(program, getClient) {
3874
3921
  logInvocation(`ensure ${name}`, targetUrl);
3875
3922
  const outcome = await ensureDatabase(targetUrl, {
3876
3923
  owner: opts.owner,
3877
- revokeConnectFromPublic: !!opts.revokeConnectPublic
3924
+ revokeConnectFromPublic: !!opts.revokeConnectPublic,
3925
+ grantConnectToRoles: opts.grantConnectRole
3878
3926
  });
3879
- const suffix = (opts.owner ? ` (owner=${opts.owner})` : "") + (opts.revokeConnectPublic ? " [revoked CONNECT from PUBLIC]" : "");
3927
+ const suffix = (opts.owner ? ` (owner=${opts.owner})` : "") + (opts.revokeConnectPublic ? " [revoked CONNECT from PUBLIC]" : "") + (opts.grantConnectRole.length ? ` [granted CONNECT to: ${opts.grantConnectRole.join(", ")}]` : "");
3880
3928
  console.log(`db ensure: ${name} — ${outcome}${suffix}`);
3881
3929
  } catch (err) {
3882
3930
  console.error(`Error: ${toErrorMessage(err)}`);
@@ -6092,7 +6140,8 @@ function isRoot() {
6092
6140
  return typeof process.getuid === "function" && process.getuid() === 0;
6093
6141
  }
6094
6142
  /**
6095
- * Get the configuration directory for a KiCI service.
6143
+ * Name-agnostic KiCI config root — the directory that contains every
6144
+ * per-instance config subdir for this privilege level.
6096
6145
  *
6097
6146
  * Paths follow platform conventions:
6098
6147
  * - System Linux/macOS: /etc/kici/
@@ -6100,8 +6149,11 @@ function isRoot() {
6100
6149
  * - User macOS: ~/Library/Application Support/kici/
6101
6150
  * - System Windows: C:\ProgramData\kici\
6102
6151
  * - User Windows: %LOCALAPPDATA%\kici\
6152
+ *
6153
+ * Used by the instance index (`<kiciRoot>/instances.json`) which lives
6154
+ * outside any per-instance subdir, and as the base for {@link getConfigDir}.
6103
6155
  */
6104
- function getConfigDir(_serviceName, isUserLevel) {
6156
+ function kiciConfigRoot(isUserLevel) {
6105
6157
  const plat = os.platform();
6106
6158
  if (plat === "win32") {
6107
6159
  if (isUserLevel) return (process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local")) + "\\kici\\";
@@ -6112,20 +6164,40 @@ function getConfigDir(_serviceName, isUserLevel) {
6112
6164
  return path.join(os.homedir(), ".config", "kici") + "/";
6113
6165
  }
6114
6166
  /**
6115
- * Get the log directory for a KiCI service.
6167
+ * Get the configuration directory for a specific KiCI service instance.
6116
6168
  *
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\
6169
+ * Returns `<kiciConfigRoot>/<serviceName>/`. The name-scoped subdir is the
6170
+ * folder-anchored home for everything belonging to one installed instance
6171
+ * (env file, generated unit references, future per-instance state).
6172
+ *
6173
+ * Examples:
6174
+ * - System Linux: /etc/kici/<name>/
6175
+ * - User Linux: ~/.config/kici/<name>/
6176
+ * - User macOS: ~/Library/Application Support/kici/<name>/
6177
+ * - System Windows: C:\ProgramData\kici\<name>\
6178
+ * - User Windows: %LOCALAPPDATA%\kici\<name>\
6179
+ */
6180
+ function getConfigDir(serviceName, isUserLevel) {
6181
+ const root = kiciConfigRoot(isUserLevel);
6182
+ const sep = os.platform() === "win32" ? "\\" : "/";
6183
+ return root + serviceName + sep;
6184
+ }
6185
+ /**
6186
+ * Get the log directory for a specific KiCI service instance.
6187
+ *
6188
+ * The per-platform layout matches the existing matrix; the service name is
6189
+ * injected as the per-instance segment so each instance has its own log dir:
6190
+ * - System Linux/macOS: /var/log/kici/<name>/
6191
+ * - User Linux: ~/.local/share/kici/<name>/logs/
6192
+ * - User macOS: ~/Library/Logs/kici/<name>/
6193
+ * - Windows: C:\ProgramData\kici\<name>\logs\
6122
6194
  */
6123
- function getLogDir(_serviceName, isUserLevel) {
6195
+ function getLogDir(serviceName, isUserLevel) {
6124
6196
  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") + "/";
6197
+ if (plat === "win32") return "C:\\ProgramData\\kici\\" + serviceName + "\\logs\\";
6198
+ if (!isUserLevel) return "/var/log/kici/" + serviceName + "/";
6199
+ if (plat === "darwin") return path.join(os.homedir(), "Library", "Logs", "kici", serviceName) + "/";
6200
+ return path.join(os.homedir(), ".local", "share", "kici", serviceName, "logs") + "/";
6129
6201
  }
6130
6202
  /**
6131
6203
  * Get the cache directory for lazy dependency downloads.
@@ -6158,31 +6230,263 @@ function resolveUserLevel(opts) {
6158
6230
  return !isRoot();
6159
6231
  }
6160
6232
  //#endregion
6161
- //#region src/cli/service/systemd.ts
6233
+ //#region src/cli/service/instance/manifest.ts
6162
6234
  /**
6163
- * systemd service manager implementation.
6164
- *
6165
- * Generates unit files and manages service lifecycle via systemctl,
6166
- * journalctl, and loginctl commands. Supports both system-level
6167
- * (/etc/systemd/system/) and user-level (~/.config/systemd/user/) services.
6235
+ * Instance manifest the single source of truth for a folder-anchored
6236
+ * service install. Written by `install` into the deploy folder, read by
6237
+ * every lifecycle command to reconstruct the ServiceConfig.
6168
6238
  */
6169
- var systemd_exports = /* @__PURE__ */ __exportAll({
6170
- SystemdServiceManager: () => SystemdServiceManager,
6171
- isLingerEnabled: () => isLingerEnabled
6172
- });
6239
+ const REQUIRED_FIELDS = [
6240
+ "component",
6241
+ "name",
6242
+ "platform",
6243
+ "isUserLevel",
6244
+ "envFilePath",
6245
+ "configDir",
6246
+ "logDir",
6247
+ "installBase",
6248
+ "createdAt",
6249
+ "kiciVersion"
6250
+ ];
6251
+ /** Per-component manifest filename. */
6252
+ function manifestFilename(component) {
6253
+ return `.kici-${component}.json`;
6254
+ }
6255
+ /** Resolve the manifest path inside an instance directory. */
6256
+ function manifestPath(instanceDir, component) {
6257
+ return path.join(instanceDir, manifestFilename(component));
6258
+ }
6173
6259
  /**
6174
- * Check whether linger is already enabled for a user.
6175
- *
6176
- * Uses `loginctl show-user <user>` which is readable by any user. Returns
6177
- * true if the user's `Linger` property reads `yes`. Returns false on any
6178
- * parse failure, missing binary, or unknown user — the caller is expected
6179
- * to attempt `enable-linger` in that case.
6260
+ * Read the manifest for `component` from `instanceDir`.
6261
+ * Returns null when the file does not exist; throws on parse or schema errors.
6262
+ */
6263
+ function readManifest(instanceDir, component) {
6264
+ const file = manifestPath(instanceDir, component);
6265
+ if (!fs.existsSync(file)) return null;
6266
+ const raw = fs.readFileSync(file, "utf-8");
6267
+ let parsed;
6268
+ try {
6269
+ parsed = JSON.parse(raw);
6270
+ } catch (err) {
6271
+ throw new Error(`Malformed instance manifest at ${file}: ${err.message}`);
6272
+ }
6273
+ if (!parsed || typeof parsed !== "object") throw new Error(`Invalid instance manifest at ${file}: not an object`);
6274
+ for (const field of REQUIRED_FIELDS) if (!(field in parsed)) throw new Error(`Invalid instance manifest at ${file}: missing field "${field}"`);
6275
+ return parsed;
6276
+ }
6277
+ /**
6278
+ * Write the manifest for `manifest.component` into `instanceDir`.
6279
+ * Returns the full path written.
6280
+ */
6281
+ function writeManifest(instanceDir, manifest) {
6282
+ fs.mkdirSync(instanceDir, { recursive: true });
6283
+ const file = manifestPath(instanceDir, manifest.component);
6284
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
6285
+ return file;
6286
+ }
6287
+ /**
6288
+ * Read the running kici-admin's version from the orchestrator package.json.
6180
6289
  *
6181
- * Exported (package-private) for testing.
6290
+ * `process.env.npm_package_version` is only populated under `npm run` and is
6291
+ * undefined when kici-admin runs as a globally-installed binary, which is the
6292
+ * actual install path. Reading from the package.json on disk is the only
6293
+ * reliable source.
6182
6294
  */
6183
- function isLingerEnabled(user) {
6295
+ function readKiciVersion() {
6184
6296
  try {
6185
- const out = execFileSync("loginctl", [
6297
+ const pkgUrl = new URL("../../../../package.json", import.meta.url);
6298
+ const pkg = JSON.parse(fs.readFileSync(fileURLToPath(pkgUrl), "utf-8"));
6299
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
6300
+ } catch {
6301
+ return "unknown";
6302
+ }
6303
+ }
6304
+ //#endregion
6305
+ //#region src/cli/service/instance/index-file.ts
6306
+ /**
6307
+ * Instance index — a reconciled CACHE of all installed instances on the host.
6308
+ *
6309
+ * Lives at <kiciRoot>/instances.json, where kiciRoot is the name-agnostic
6310
+ * config root (~/.config/kici/ for user-level, /etc/kici/ for system). The
6311
+ * index is convenience: discovery is authoritative against the init system
6312
+ * (see resolve.ts#listInstances which reconciles this file against scans).
6313
+ *
6314
+ * Corrupt index → warn and treat as empty; the next install/uninstall
6315
+ * rewrites it cleanly.
6316
+ */
6317
+ const FILE = "instances.json";
6318
+ /** Resolve <kiciRoot>/instances.json. */
6319
+ function indexPath(kiciRoot) {
6320
+ return path.join(kiciRoot, FILE);
6321
+ }
6322
+ /** Read the index. Missing or corrupt → []. */
6323
+ function readIndex(kiciRoot) {
6324
+ const file = indexPath(kiciRoot);
6325
+ if (!fs.existsSync(file)) return [];
6326
+ try {
6327
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
6328
+ if (!Array.isArray(parsed)) {
6329
+ console.warn(`[kici] instance index at ${file} is invalid (not an array); ignoring`);
6330
+ return [];
6331
+ }
6332
+ return parsed;
6333
+ } catch (err) {
6334
+ console.warn(`[kici] instance index at ${file} is corrupt (${err.message}); ignoring`);
6335
+ return [];
6336
+ }
6337
+ }
6338
+ /** Overwrite the index. */
6339
+ function writeIndex(kiciRoot, entries) {
6340
+ fs.mkdirSync(kiciRoot, { recursive: true });
6341
+ fs.writeFileSync(indexPath(kiciRoot), JSON.stringify(entries, null, 2) + "\n", "utf-8");
6342
+ }
6343
+ /**
6344
+ * Append an entry. Idempotent for an exact (component,name,instanceDir) match.
6345
+ * Throws on a (component,name) collision with a different instanceDir — the
6346
+ * caller must use a different name or pass --force to overwrite.
6347
+ */
6348
+ function appendIndexEntry(kiciRoot, entry) {
6349
+ const current = readIndex(kiciRoot);
6350
+ const existing = current.find((e) => e.component === entry.component && e.name === entry.name);
6351
+ if (existing) {
6352
+ if (existing.instanceDir === entry.instanceDir) return;
6353
+ throw new Error(`Already an ${entry.component} instance "${entry.name}" registered at ${existing.instanceDir}`);
6354
+ }
6355
+ writeIndex(kiciRoot, [...current, entry]);
6356
+ }
6357
+ /** Remove the matching entry (no-op when absent). */
6358
+ function removeIndexEntry(kiciRoot, key) {
6359
+ const current = readIndex(kiciRoot);
6360
+ const next = current.filter((e) => !(e.component === key.component && e.name === key.name));
6361
+ if (next.length !== current.length) writeIndex(kiciRoot, next);
6362
+ }
6363
+ //#endregion
6364
+ //#region src/cli/service/instance/resolve.ts
6365
+ /**
6366
+ * resolveInstance — the single entry point every lifecycle command uses to
6367
+ * decide which installed service it's operating on.
6368
+ *
6369
+ * Priority:
6370
+ * 1. --instance-dir <path> → read manifest at <path>
6371
+ * 2. --name <name> → match against listInstances() result
6372
+ * 3. CWD manifest → read ./.kici-<component>.json
6373
+ * 4. otherwise → refuse with a candidate list (throws)
6374
+ *
6375
+ * listInstances reconciles the on-disk index (cache) with the driver's
6376
+ * native scan (source of truth). The reconciled result is rewritten back to
6377
+ * the index to self-heal stale entries.
6378
+ */
6379
+ /**
6380
+ * Reconcile <kiciRoot>/instances.json with the driver's native scan, then
6381
+ * rewrite the index dropping entries whose units no longer exist. Returns
6382
+ * the merged list filtered to the requested component + isUserLevel.
6383
+ */
6384
+ async function listInstances(args) {
6385
+ const { component, isUserLevel, kiciRoot, manager } = args;
6386
+ const scanForComponent = (await manager.list(isUserLevel)).filter((s) => s.component === component);
6387
+ const scanNames = new Set(scanForComponent.map((s) => s.name));
6388
+ const index = readIndex(kiciRoot);
6389
+ const relevantIndex = index.filter((e) => e.component === component && e.isUserLevel === isUserLevel);
6390
+ const survivors = relevantIndex.filter((e) => scanNames.has(e.name));
6391
+ if (survivors.length !== relevantIndex.length) writeIndex(kiciRoot, [...index.filter((e) => !(e.component === component && e.isUserLevel === isUserLevel)), ...survivors]);
6392
+ const indexByName = new Map(survivors.map((e) => [e.name, e]));
6393
+ return scanForComponent.map((s) => {
6394
+ const idx = indexByName.get(s.name);
6395
+ return {
6396
+ ...s,
6397
+ component,
6398
+ instanceDir: idx?.instanceDir,
6399
+ source: idx ? "index+scan" : "scan"
6400
+ };
6401
+ });
6402
+ }
6403
+ /**
6404
+ * Resolve the target instance for the current lifecycle invocation.
6405
+ * Throws with a refusal/candidate-listing error when ambiguous.
6406
+ */
6407
+ async function resolveInstance(args) {
6408
+ const { component, opts, cwd, kiciRoot, manager, isUserLevel } = args;
6409
+ if (opts.instanceDir) {
6410
+ const dir = path.resolve(opts.instanceDir);
6411
+ const m = readManifest(dir, component);
6412
+ if (!m) throw new Error(`No ${component} manifest at ${manifestPath(dir, component)}. Did you install with --instance-dir ${dir}?`);
6413
+ return {
6414
+ manifest: m,
6415
+ manifestPath: manifestPath(dir, component),
6416
+ instanceDir: dir
6417
+ };
6418
+ }
6419
+ if (opts.name) {
6420
+ const candidates = await listInstances({
6421
+ component,
6422
+ isUserLevel,
6423
+ kiciRoot,
6424
+ manager
6425
+ });
6426
+ const match = candidates.find((c) => c.name === opts.name);
6427
+ if (!match) throw new Error(formatNameNotFound(component, opts.name, candidates));
6428
+ 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.`);
6429
+ const manifest = readManifest(match.instanceDir, component);
6430
+ if (!manifest) throw new Error(`Manifest for ${component} instance "${opts.name}" missing at ${manifestPath(match.instanceDir, component)}.`);
6431
+ return {
6432
+ manifest,
6433
+ manifestPath: manifestPath(match.instanceDir, component),
6434
+ instanceDir: match.instanceDir
6435
+ };
6436
+ }
6437
+ const cwdManifest = readManifest(cwd, component);
6438
+ if (cwdManifest) return {
6439
+ manifest: cwdManifest,
6440
+ manifestPath: manifestPath(cwd, component),
6441
+ instanceDir: path.resolve(cwd)
6442
+ };
6443
+ const candidates = await listInstances({
6444
+ component,
6445
+ isUserLevel,
6446
+ kiciRoot,
6447
+ manager
6448
+ });
6449
+ throw new Error(formatRefusal(component, candidates));
6450
+ }
6451
+ /**
6452
+ * Format the refusal message and candidate table.
6453
+ *
6454
+ * When candidates is empty, returns the "no instances installed" guidance.
6455
+ * When candidates exist, lists them with their instanceDir (or "(no manifest)").
6456
+ */
6457
+ function formatRefusal(component, candidates) {
6458
+ if (candidates.length === 0) return `No ${component} instances installed on this host. Run \`kici-admin ${component} install --instance-dir <deploy folder>\` first.`;
6459
+ 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.`;
6460
+ }
6461
+ function formatNameNotFound(component, name, candidates) {
6462
+ return `${component} instance "${name}" not found. Installed:\n${candidates.length ? candidates.map((c) => ` - ${c.name} ${c.platform} ${c.instanceDir ?? "(no manifest)"}`).join("\n") : " (none)"}`;
6463
+ }
6464
+ //#endregion
6465
+ //#region src/cli/service/systemd.ts
6466
+ /**
6467
+ * systemd service manager implementation.
6468
+ *
6469
+ * Generates unit files and manages service lifecycle via systemctl,
6470
+ * journalctl, and loginctl commands. Supports both system-level
6471
+ * (/etc/systemd/system/) and user-level (~/.config/systemd/user/) services.
6472
+ */
6473
+ var systemd_exports = /* @__PURE__ */ __exportAll({
6474
+ SystemdServiceManager: () => SystemdServiceManager,
6475
+ isLingerEnabled: () => isLingerEnabled
6476
+ });
6477
+ /**
6478
+ * Check whether linger is already enabled for a user.
6479
+ *
6480
+ * Uses `loginctl show-user <user>` which is readable by any user. Returns
6481
+ * true if the user's `Linger` property reads `yes`. Returns false on any
6482
+ * parse failure, missing binary, or unknown user — the caller is expected
6483
+ * to attempt `enable-linger` in that case.
6484
+ *
6485
+ * Exported (package-private) for testing.
6486
+ */
6487
+ function isLingerEnabled(user) {
6488
+ try {
6489
+ const out = execFileSync("loginctl", [
6186
6490
  "show-user",
6187
6491
  user,
6188
6492
  "--property=Linger"
@@ -6215,6 +6519,7 @@ var init_systemd = __esmMin((() => {
6215
6519
  const lines = [];
6216
6520
  lines.push("[Unit]");
6217
6521
  lines.push(`Description=${config.description}`);
6522
+ if (config.component) lines.push(`X-KiCI-Component=${config.component}`);
6218
6523
  lines.push("After=network.target postgresql.service");
6219
6524
  lines.push("");
6220
6525
  lines.push("[Service]");
@@ -6351,6 +6656,24 @@ var init_systemd = __esmMin((() => {
6351
6656
  async isInstalled(config) {
6352
6657
  return fs.existsSync(this.unitFilePath(config));
6353
6658
  }
6659
+ async list(isUserLevel) {
6660
+ const unitDir = isUserLevel ? path.join(os.homedir(), ".config", "systemd", "user") : "/etc/systemd/system";
6661
+ if (!fs.existsSync(unitDir)) return [];
6662
+ const out = [];
6663
+ for (const entry of fs.readdirSync(unitDir)) {
6664
+ if (typeof entry !== "string") continue;
6665
+ if (!entry.startsWith("kici-") || !entry.endsWith(".service")) continue;
6666
+ const m = fs.readFileSync(path.join(unitDir, entry), "utf-8").match(/^X-KiCI-Component=(orchestrator|agent)\s*$/m);
6667
+ if (!m) continue;
6668
+ out.push({
6669
+ name: entry.replace(/\.service$/, ""),
6670
+ platform: "systemd",
6671
+ isUserLevel,
6672
+ component: m[1]
6673
+ });
6674
+ }
6675
+ return out;
6676
+ }
6354
6677
  };
6355
6678
  }));
6356
6679
  //#endregion
@@ -6363,6 +6686,10 @@ var init_systemd = __esmMin((() => {
6363
6686
  * user-level (~/Library/LaunchAgents/) agents.
6364
6687
  */
6365
6688
  var launchd_exports = /* @__PURE__ */ __exportAll({ LaunchdServiceManager: () => LaunchdServiceManager });
6689
+ /** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
6690
+ function sleep(ms) {
6691
+ return new Promise((resolve) => setTimeout(resolve, ms));
6692
+ }
6366
6693
  var LABEL_PREFIX, SYSTEM_LOG_DIR, LaunchdServiceManager;
6367
6694
  var init_launchd = __esmMin((() => {
6368
6695
  LABEL_PREFIX = "dev.kici";
@@ -6416,6 +6743,10 @@ var init_launchd = __esmMin((() => {
6416
6743
  lines.push("<dict>");
6417
6744
  lines.push(" <key>Label</key>");
6418
6745
  lines.push(` <string>${this.escapeXml(label)}</string>`);
6746
+ if (config.component) {
6747
+ lines.push(" <key>KiCIComponent</key>");
6748
+ lines.push(` <string>${config.component}</string>`);
6749
+ }
6419
6750
  lines.push(" <key>ProgramArguments</key>");
6420
6751
  lines.push(" <array>");
6421
6752
  lines.push(` <string>${this.escapeXml(config.executablePath)}</string>`);
@@ -6497,14 +6828,61 @@ var init_launchd = __esmMin((() => {
6497
6828
  logDirectory
6498
6829
  ], { stdio: "inherit" });
6499
6830
  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" });
6831
+ if (this.isLoaded(config)) {
6832
+ try {
6833
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6834
+ } catch {}
6835
+ await this.waitUntilUnloaded(config);
6836
+ }
6837
+ await this.bootstrapWithRetry(config, plistFile);
6838
+ }
6839
+ /**
6840
+ * Poll until the service is no longer loaded in its target domain, or a
6841
+ * short deadline elapses. `launchctl bootout` is asynchronous — it returns
6842
+ * before launchd has finished releasing the service — so a bootstrap issued
6843
+ * immediately afterward races the teardown and fails with EIO. Waiting for
6844
+ * the unload to complete closes that race for the common case; the residual
6845
+ * window is covered by bootstrapWithRetry.
6846
+ */
6847
+ async waitUntilUnloaded(config) {
6848
+ const deadline = Date.now() + 15e3;
6849
+ while (this.isLoaded(config)) {
6850
+ if (Date.now() >= deadline) return;
6851
+ await sleep(500);
6852
+ }
6853
+ }
6854
+ /**
6855
+ * Bootstrap into the target domain, retrying on the transient EIO
6856
+ * ("5: Input/output error") launchd returns when a just-removed service has
6857
+ * not finished tearing down. A genuine, non-transient failure (bad plist,
6858
+ * permission denied) is re-thrown on the first attempt. If a stale instance
6859
+ * reappears between attempts, it is booted out before the next try.
6860
+ */
6861
+ async bootstrapWithRetry(config, plistFile) {
6862
+ const attempts = 5;
6863
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
6864
+ execFileSync("launchctl", [
6865
+ "bootstrap",
6866
+ this.domain(config),
6867
+ plistFile
6868
+ ], { stdio: [
6869
+ "inherit",
6870
+ "inherit",
6871
+ "pipe"
6872
+ ] });
6873
+ return;
6874
+ } catch (err) {
6875
+ const e = err;
6876
+ const stderr = (e.stderr ?? "").toString();
6877
+ if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
6878
+ if (stderr) process.stderr.write(stderr);
6879
+ throw err;
6880
+ }
6881
+ if (this.isLoaded(config)) try {
6882
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6883
+ } catch {}
6884
+ await sleep(1e3 * attempt);
6885
+ }
6508
6886
  }
6509
6887
  async uninstall(config) {
6510
6888
  const plistFile = this.plistPath(config);
@@ -6573,6 +6951,30 @@ var init_launchd = __esmMin((() => {
6573
6951
  async isInstalled(config) {
6574
6952
  return fs.existsSync(this.plistPath(config));
6575
6953
  }
6954
+ async list(isUserLevel) {
6955
+ const baseDir = isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : "/Library/LaunchDaemons";
6956
+ if (!fs.existsSync(baseDir)) return [];
6957
+ const out = [];
6958
+ for (const entry of fs.readdirSync(baseDir)) {
6959
+ if (typeof entry !== "string") continue;
6960
+ if (!entry.endsWith(".plist")) continue;
6961
+ let content;
6962
+ try {
6963
+ content = fs.readFileSync(path.join(baseDir, entry), "utf-8");
6964
+ } catch {
6965
+ continue;
6966
+ }
6967
+ const match = content.match(/<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/);
6968
+ if (!match) continue;
6969
+ out.push({
6970
+ name: entry.replace(/\.plist$/, ""),
6971
+ platform: "launchd",
6972
+ isUserLevel,
6973
+ component: match[1]
6974
+ });
6975
+ }
6976
+ return out;
6977
+ }
6576
6978
  };
6577
6979
  }));
6578
6980
  //#endregion
@@ -6870,6 +7272,8 @@ var init_windows = __esmMin((() => {
6870
7272
  cmdParts.push("--", `"${config.executablePath}"`);
6871
7273
  for (const arg of config.args ?? []) cmdParts.push(`"${arg}"`);
6872
7274
  execSync(cmdParts.join(" "), { stdio: "pipe" });
7275
+ const descText = config.component ? `[KiCI:${config.component}] ${config.description}` : config.description;
7276
+ execSync(`sc.exe description ${config.name} "${descText.replace(/"/g, "\\\"")}"`, { stdio: "pipe" });
6873
7277
  execSync(`sc.exe config ${config.name} start= auto`, { stdio: "pipe" });
6874
7278
  const actions = config.restartPolicy.delays.map((d) => `restart/${d * 1e3}`).join("/");
6875
7279
  const resetSeconds = config.restartPolicy.windowSeconds;
@@ -6960,6 +7364,37 @@ var init_windows = __esmMin((() => {
6960
7364
  return false;
6961
7365
  }
6962
7366
  }
7367
+ async list(isUserLevel) {
7368
+ let raw;
7369
+ try {
7370
+ raw = execSync("powershell -Command \"Get-CimInstance Win32_Service -Filter \\\"Name LIKE 'kici-%'\\\" | Select-Object Name,Description | ConvertTo-Json\"", { stdio: "pipe" }).toString();
7371
+ } catch {
7372
+ return [];
7373
+ }
7374
+ if (!raw.trim()) return [];
7375
+ let parsed;
7376
+ try {
7377
+ parsed = JSON.parse(raw);
7378
+ } catch {
7379
+ return [];
7380
+ }
7381
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
7382
+ const out = [];
7383
+ for (const row of rows) {
7384
+ if (!row || typeof row !== "object") continue;
7385
+ const r = row;
7386
+ const match = (typeof r.Description === "string" ? r.Description : "").match(/^\[KiCI:(orchestrator|agent)\]/);
7387
+ if (!match) continue;
7388
+ if (typeof r.Name !== "string") continue;
7389
+ out.push({
7390
+ name: r.Name,
7391
+ platform: "windows",
7392
+ isUserLevel,
7393
+ component: match[1]
7394
+ });
7395
+ }
7396
+ return out;
7397
+ }
6963
7398
  };
6964
7399
  }));
6965
7400
  //#endregion
@@ -6982,11 +7417,11 @@ var compose_exports = /* @__PURE__ */ __exportAll({ ComposeServiceManager: () =>
6982
7417
  function detectRuntime() {
6983
7418
  try {
6984
7419
  execSync("podman compose version", { stdio: "pipe" });
6985
- return "podman compose";
7420
+ return "podman";
6986
7421
  } catch {}
6987
7422
  try {
6988
7423
  execSync("docker compose version", { stdio: "pipe" });
6989
- return "docker compose";
7424
+ return "docker";
6990
7425
  } catch {}
6991
7426
  throw new Error("No container runtime found. Install Docker or Podman with compose support.");
6992
7427
  }
@@ -7029,7 +7464,7 @@ function getRestartMode(config) {
7029
7464
  */
7030
7465
  function generateComposeYaml(config) {
7031
7466
  const restartMode = getRestartMode(config);
7032
- return [
7467
+ const lines = [
7033
7468
  "# Generated by KiCI service installer",
7034
7469
  `# Service: ${config.displayName}`,
7035
7470
  "",
@@ -7043,20 +7478,25 @@ function generateComposeYaml(config) {
7043
7478
  " volumes:",
7044
7479
  ` - ${config.workingDirectory}:${config.workingDirectory}`,
7045
7480
  " network_mode: host"
7046
- ].join("\n") + "\n";
7481
+ ];
7482
+ if (config.component) {
7483
+ lines.push(" labels:");
7484
+ lines.push(` dev.kici.component: ${config.component}`);
7485
+ }
7486
+ return lines.join("\n") + "\n";
7047
7487
  }
7048
7488
  var ComposeServiceManager;
7049
7489
  var init_compose = __esmMin((() => {
7050
7490
  ComposeServiceManager = class {
7051
7491
  runtime = null;
7052
- /** Get the runtime command, detecting it if not already done. */
7492
+ /** Get the runtime binary (`podman` or `docker`), detecting it if not already done. */
7053
7493
  getRuntime() {
7054
7494
  if (!this.runtime) this.runtime = detectRuntime();
7055
7495
  return this.runtime;
7056
7496
  }
7057
7497
  /** Run a compose command for the given service config. */
7058
7498
  runCompose(config, args) {
7059
- execSync(`${this.getRuntime()} -f "${getComposeFilePath(config)}" ${args}`, { stdio: "pipe" });
7499
+ execSync(`${this.getRuntime()} compose -f "${getComposeFilePath(config)}" ${args}`, { stdio: "pipe" });
7060
7500
  }
7061
7501
  async install(config) {
7062
7502
  this.getRuntime();
@@ -7084,7 +7524,7 @@ var init_compose = __esmMin((() => {
7084
7524
  }
7085
7525
  async status(config) {
7086
7526
  try {
7087
- const output = execSync(`${this.getRuntime()} -f "${getComposeFilePath(config)}" ps --format json`, { stdio: "pipe" }).toString();
7527
+ const output = execSync(`${this.getRuntime()} compose -f "${getComposeFilePath(config)}" ps --format json`, { stdio: "pipe" }).toString();
7088
7528
  const data = JSON.parse(output);
7089
7529
  const container = Array.isArray(data) ? data[0] : data;
7090
7530
  if (!container) return { state: "stopped" };
@@ -7108,6 +7548,44 @@ var init_compose = __esmMin((() => {
7108
7548
  const composeFile = getComposeFilePath(config);
7109
7549
  return fs.existsSync(composeFile);
7110
7550
  }
7551
+ async list(isUserLevel) {
7552
+ let runtime;
7553
+ try {
7554
+ runtime = this.getRuntime();
7555
+ } catch {
7556
+ return [];
7557
+ }
7558
+ let raw;
7559
+ try {
7560
+ raw = execSync(`${runtime} ps -a --filter label=dev.kici.component --format '{{json .}}'`, { encoding: "utf-8" }).toString();
7561
+ } catch {
7562
+ return [];
7563
+ }
7564
+ const out = [];
7565
+ for (const line of raw.split("\n")) {
7566
+ const trimmed = line.trim();
7567
+ if (!trimmed) continue;
7568
+ let row;
7569
+ try {
7570
+ row = JSON.parse(trimmed);
7571
+ } catch {
7572
+ continue;
7573
+ }
7574
+ if (!row || typeof row !== "object") continue;
7575
+ const r = row;
7576
+ const m = (typeof r.Labels === "string" ? r.Labels : "").match(/dev\.kici\.component=(orchestrator|agent)/);
7577
+ if (!m) continue;
7578
+ const name = typeof r.Names === "string" ? r.Names : String(r.Names ?? "");
7579
+ if (!name) continue;
7580
+ out.push({
7581
+ name,
7582
+ platform: "compose",
7583
+ isUserLevel,
7584
+ component: m[1]
7585
+ });
7586
+ }
7587
+ return out;
7588
+ }
7111
7589
  };
7112
7590
  }));
7113
7591
  //#endregion
@@ -7172,93 +7650,511 @@ function resolveServiceExecutable(opts) {
7172
7650
  };
7173
7651
  }
7174
7652
  //#endregion
7175
- //#region src/cli/wizard/prompts.ts
7653
+ //#region src/cli/commands/shared/versioned-upgrade.ts
7176
7654
  /**
7177
- * Shared prompt utilities for the setup wizards.
7655
+ * Shared versioned directory upgrade logic for kici-admin upgrade commands.
7178
7656
  *
7179
- * Wraps @inquirer/prompts with consistent formatting and
7180
- * validation for common input types (DB URLs, ports, etc.).
7657
+ * Implements the versioned directory layout:
7658
+ * - Extract new version alongside old versions
7659
+ * - Update symlink (Unix) or service registration (Windows) atomically
7660
+ * - Preserve old versions for rollback
7661
+ * - Optional cleanup of old versions
7181
7662
  */
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
7663
+ /**
7664
+ * Resolve the upgrade target via the folder-anchored model and build the
7665
+ * ServiceConfig + installBase the rest of the upgrade flow needs.
7666
+ *
7667
+ * Priority chain (delegated to {@link resolveInstance}):
7668
+ * 1. `opts.instanceDir` read manifest at that path.
7669
+ * 2. `opts.name` — match against listInstances() output.
7670
+ * 3. CWD manifest — read `./.kici-<component>.json`.
7671
+ * 4. otherwise — refuse with a candidate-list error.
7672
+ *
7673
+ * The returned `installBase` comes from the manifest, NEVER re-derived from
7674
+ * the service name. Instances installed with a non-default base must
7675
+ * continue to resolve to that base on upgrade.
7676
+ */
7677
+ async function resolveUpgradeTarget(args) {
7678
+ const { component, opts, manager, isUserLevel, kiciRoot } = args;
7679
+ const resolved = await resolveInstance({
7680
+ component,
7681
+ opts: {
7682
+ instanceDir: opts.instanceDir,
7683
+ name: opts.name
7684
+ },
7685
+ cwd: process.cwd(),
7686
+ kiciRoot,
7687
+ manager,
7688
+ isUserLevel
7211
7689
  });
7690
+ return {
7691
+ config: {
7692
+ name: resolved.manifest.name,
7693
+ displayName: `KiCI ${component}`,
7694
+ description: `KiCI ${component} service`,
7695
+ executablePath: "",
7696
+ envFilePath: resolved.manifest.envFilePath,
7697
+ workingDirectory: resolved.manifest.configDir,
7698
+ isUserLevel: resolved.manifest.isUserLevel,
7699
+ restartPolicy: {
7700
+ enabled: true,
7701
+ delays: [
7702
+ 1,
7703
+ 5,
7704
+ 15,
7705
+ 30
7706
+ ],
7707
+ maxRetries: 5,
7708
+ windowSeconds: 300
7709
+ },
7710
+ component
7711
+ },
7712
+ installBase: resolved.manifest.installBase,
7713
+ resolvedInstance: resolved
7714
+ };
7212
7715
  }
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
- }
7716
+ /** Prompt the user for confirmation (returns true if yes). */
7717
+ async function confirm$2(message) {
7718
+ const rl = createInterface({
7719
+ input: process.stdin,
7720
+ output: process.stdout
7223
7721
  });
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
- }
7722
+ return new Promise((resolve) => {
7723
+ rl.question(`${message} [y/N] `, (answer) => {
7724
+ rl.close();
7725
+ resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
7726
+ });
7234
7727
  });
7235
7728
  }
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
- });
7729
+ /** Download a file from a URL to a local path. */
7730
+ async function downloadArchive(url, destPath) {
7731
+ console.log(`Downloading from ${url}...`);
7732
+ const res = await fetch(url);
7733
+ if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
7734
+ const fileStream = createWriteStream(destPath);
7735
+ await pipeline(res.body, fileStream);
7736
+ console.log(`Downloaded to ${destPath}`);
7243
7737
  }
7244
- var init_prompts = __esmMin((() => {}));
7245
- //#endregion
7246
- //#region src/cli/wizard/orchestrator-wizard.ts
7247
7738
  /**
7248
- * Interactive wizard for orchestrator service setup.
7739
+ * Install base for a KiCI component instance.
7249
7740
  *
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.
7741
+ * Name-scoped so that two instances of the same component (e.g. an org's
7742
+ * dogfood orchestrator and an E2E test orchestrator) own independent
7743
+ * versioned trees and symlinks. Per-platform bases:
7744
+ * - systemd / compose: /opt/kici/<name>/
7745
+ * - launchd: /usr/local/kici/<name>/
7746
+ * - windows: C:\Program Files\KiCI\<name>\
7253
7747
  */
7254
- var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorWizard: () => runOrchestratorWizard });
7748
+ function getInstallBase(platform, name) {
7749
+ const sep = platform === "windows" ? "\\" : "/";
7750
+ switch (platform) {
7751
+ case "systemd":
7752
+ case "compose": return `/opt/kici/${name}${sep}`;
7753
+ case "launchd": return `/usr/local/kici/${name}${sep}`;
7754
+ case "windows": return `C:\\Program Files\\KiCI\\${name}${sep}`;
7755
+ }
7756
+ }
7757
+ /** Check if the platform is Windows. */
7758
+ function isWindows(platform) {
7759
+ return platform === "windows";
7760
+ }
7761
+ /** Get the launcher script name for a component. */
7762
+ function getLauncherName(component, platform) {
7763
+ const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
7764
+ return isWindows(platform) ? `${baseName}.cmd` : baseName;
7765
+ }
7255
7766
  /**
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
7767
+ * Extract an archive (.tar.gz or .zip) to a destination directory.
7768
+ * Returns the name of the top-level directory inside the archive.
7769
+ */
7770
+ function extractArchive(archivePath, destDir) {
7771
+ fs.mkdirSync(destDir, { recursive: true });
7772
+ if (archivePath.endsWith(".zip")) if (os.platform() === "win32") execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: "inherit" });
7773
+ else execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: "inherit" });
7774
+ else execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "inherit" });
7775
+ const dirs = fs.readdirSync(destDir).filter((e) => fs.statSync(path.join(destDir, e)).isDirectory());
7776
+ if (dirs.length === 0) throw new Error("Archive does not contain a directory");
7777
+ return dirs[0];
7778
+ }
7779
+ /**
7780
+ * List installed versions for a component by scanning the install base directory
7781
+ * for directories matching `{component}-{version}/`.
7782
+ */
7783
+ function listInstalledVersions(installBase, component) {
7784
+ if (!fs.existsSync(installBase)) return [];
7785
+ const prefix = `${component}-`;
7786
+ return fs.readdirSync(installBase).filter((entry) => {
7787
+ if (!entry.startsWith(prefix)) return false;
7788
+ const fullPath = path.join(installBase, entry);
7789
+ return fs.statSync(fullPath).isDirectory();
7790
+ }).map((entry) => entry.slice(prefix.length)).sort();
7791
+ }
7792
+ /**
7793
+ * Read the current symlink target to determine the active version.
7794
+ * Returns null if no symlink exists or on Windows.
7795
+ */
7796
+ function getCurrentVersion(installBase, component, platform) {
7797
+ if (isWindows(platform)) {
7798
+ const versionFile = path.join(installBase, `${component}-current-version.txt`);
7799
+ try {
7800
+ return fs.readFileSync(versionFile, "utf-8").trim();
7801
+ } catch {
7802
+ return null;
7803
+ }
7804
+ }
7805
+ const symlinkPath = path.join(installBase, component);
7806
+ try {
7807
+ const target = fs.readlinkSync(symlinkPath);
7808
+ const prefix = `${component}-`;
7809
+ if (target.startsWith(prefix)) return target.slice(prefix.length);
7810
+ const basename = path.basename(target);
7811
+ if (basename.startsWith(prefix)) return basename.slice(prefix.length);
7812
+ } catch {}
7813
+ return null;
7814
+ }
7815
+ /** Write the current version to a tracking file (used on Windows). */
7816
+ function writeCurrentVersion(installBase, component, version) {
7817
+ const versionFile = path.join(installBase, `${component}-current-version.txt`);
7818
+ fs.writeFileSync(versionFile, version, "utf-8");
7819
+ }
7820
+ /**
7821
+ * Update the symlink atomically on Unix.
7822
+ * Creates a temporary symlink then renames it over the existing one.
7823
+ */
7824
+ function updateSymlinkAtomic(installBase, component, version) {
7825
+ const symlinkPath = path.join(installBase, component);
7826
+ const tmpLink = `${symlinkPath}.tmp.${Date.now()}`;
7827
+ const target = `${component}-${version}`;
7828
+ try {
7829
+ fs.symlinkSync(target, tmpLink);
7830
+ fs.renameSync(tmpLink, symlinkPath);
7831
+ } catch (err) {
7832
+ try {
7833
+ fs.unlinkSync(tmpLink);
7834
+ } catch {}
7835
+ throw err;
7836
+ }
7837
+ }
7838
+ /**
7839
+ * Perform a versioned directory upgrade for a KiCI component.
7840
+ *
7841
+ * Flow:
7842
+ * 1. Parse upgrade source (--from archive or --url)
7843
+ * 2. Determine install base by platform
7844
+ * 3. Extract new versioned directory
7845
+ * 4. Stop service
7846
+ * 5. Update symlink (Unix) or service registration (Windows)
7847
+ * 6. Start service
7848
+ */
7849
+ async function performVersionedUpgrade(component, opts) {
7850
+ try {
7851
+ const platform = detectPlatform(opts.platform);
7852
+ const manager = await createServiceManager(platform);
7853
+ const userLevel = !isRoot();
7854
+ const kiciRoot = kiciConfigRoot(userLevel);
7855
+ const { config, installBase, resolvedInstance } = await resolveUpgradeTarget({
7856
+ component,
7857
+ opts: {
7858
+ instanceDir: opts.instanceDir,
7859
+ name: opts.name
7860
+ },
7861
+ manager,
7862
+ isUserLevel: userLevel,
7863
+ kiciRoot
7864
+ });
7865
+ if (!isWindows(platform) && !userLevel && !isRoot()) {
7866
+ console.error("Error: root privileges required to upgrade system-level services");
7867
+ process.exit(1);
7868
+ }
7869
+ if (opts.rollback) {
7870
+ await handleRollback(component, platform, installBase, config, manager, opts);
7871
+ return;
7872
+ }
7873
+ if (opts.cleanup) {
7874
+ await handleCleanup(component, platform, installBase);
7875
+ return;
7876
+ }
7877
+ if (!opts.from && !opts.url) {
7878
+ console.error("Error: provide --from <archive-path> or --url <url> for the upgrade package");
7879
+ process.exit(1);
7880
+ }
7881
+ if (!opts.version) {
7882
+ console.error("Error: --version is required to specify the target version");
7883
+ process.exit(1);
7884
+ }
7885
+ const version = opts.version;
7886
+ const versionedDirName = `${component}-${version}`;
7887
+ const versionedDirPath = path.join(installBase, versionedDirName);
7888
+ if (fs.existsSync(versionedDirPath)) if (opts.force) {
7889
+ console.log(`Removing existing directory ${versionedDirPath} (--force)`);
7890
+ fs.rmSync(versionedDirPath, {
7891
+ recursive: true,
7892
+ force: true
7893
+ });
7894
+ } else {
7895
+ console.error(`Error: version directory already exists: ${versionedDirPath}`);
7896
+ console.error("Use --force to overwrite.");
7897
+ process.exit(1);
7898
+ }
7899
+ if (!await manager.isInstalled(config)) {
7900
+ console.error(`Error: service "${config.name}" is not installed`);
7901
+ process.exit(1);
7902
+ }
7903
+ let archivePath;
7904
+ const tmpDir = path.join(os.tmpdir(), `kici-upgrade-${Date.now()}`);
7905
+ fs.mkdirSync(tmpDir, { recursive: true });
7906
+ if (opts.from) {
7907
+ archivePath = path.resolve(opts.from);
7908
+ if (!fs.existsSync(archivePath)) {
7909
+ console.error(`Error: archive not found at ${archivePath}`);
7910
+ process.exit(1);
7911
+ }
7912
+ } else {
7913
+ const ext = opts.url.endsWith(".zip") ? ".zip" : ".tar.gz";
7914
+ archivePath = path.join(tmpDir, `${component}-${version}${ext}`);
7915
+ await downloadArchive(opts.url, archivePath);
7916
+ }
7917
+ const currentVersion = getCurrentVersion(installBase, component, platform);
7918
+ if (!opts.yes) {
7919
+ console.log(`This will upgrade "${config.name}" to version ${version}:`);
7920
+ if (currentVersion) console.log(` Current version: ${currentVersion}`);
7921
+ console.log(` New version: ${version}`);
7922
+ console.log(` Install path: ${versionedDirPath}`);
7923
+ console.log(" The service will be stopped during upgrade.");
7924
+ console.log("");
7925
+ if (!await confirm$2("Proceed with upgrade?")) {
7926
+ console.log("Upgrade cancelled.");
7927
+ return;
7928
+ }
7929
+ }
7930
+ console.log("Extracting archive...");
7931
+ const extractDir = path.join(tmpDir, "extract");
7932
+ const extractedDirName = extractArchive(archivePath, extractDir);
7933
+ fs.mkdirSync(installBase, { recursive: true });
7934
+ const srcDir = path.join(extractDir, extractedDirName);
7935
+ if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
7936
+ else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
7937
+ console.log(`Extracted to ${versionedDirPath}`);
7938
+ console.log("Stopping service...");
7939
+ if ((await manager.status(config)).state === "running") {
7940
+ await manager.stop(config);
7941
+ console.log("Service stopped.");
7942
+ }
7943
+ if (isWindows(platform)) {
7944
+ const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
7945
+ config.executablePath = launcherPath;
7946
+ await manager.uninstall(config);
7947
+ await new Promise((r) => setTimeout(r, 2e3));
7948
+ await manager.install(config);
7949
+ writeCurrentVersion(installBase, component, version);
7950
+ console.log(`Service registration updated to ${launcherPath}`);
7951
+ } else {
7952
+ updateSymlinkAtomic(installBase, component, version);
7953
+ const symlinkPath = path.join(installBase, component);
7954
+ console.log(`Symlink updated: ${symlinkPath} -> ${versionedDirName}`);
7955
+ const launcherPath = path.join(symlinkPath, getLauncherName(component, platform));
7956
+ if (fs.existsSync(launcherPath)) fs.chmodSync(launcherPath, 493);
7957
+ config.executablePath = path.join(installBase, component, getLauncherName(component, platform));
7958
+ }
7959
+ console.log("Starting service...");
7960
+ await manager.start(config);
7961
+ console.log("Service started.");
7962
+ writeManifest(resolvedInstance.instanceDir, {
7963
+ ...resolvedInstance.manifest,
7964
+ kiciVersion: version
7965
+ });
7966
+ fs.rmSync(tmpDir, {
7967
+ recursive: true,
7968
+ force: true
7969
+ });
7970
+ console.log("");
7971
+ if (currentVersion) {
7972
+ console.log(`Upgrade complete: ${currentVersion} -> ${version}`);
7973
+ console.log(`Previous version preserved at: ${path.join(installBase, `${component}-${currentVersion}`)}`);
7974
+ } else console.log(`Upgrade to ${version} complete.`);
7975
+ } catch (err) {
7976
+ console.error(`Error: ${toErrorMessage(err)}`);
7977
+ process.exit(1);
7978
+ }
7979
+ }
7980
+ /**
7981
+ * Handle --rollback: switch symlink to the previous version and restart.
7982
+ */
7983
+ async function handleRollback(component, platform, installBase, config, manager, opts) {
7984
+ const versions = listInstalledVersions(installBase, component);
7985
+ if (versions.length < 2) {
7986
+ console.error("Error: no previous version available for rollback");
7987
+ if (versions.length === 1) console.error(`Only version installed: ${versions[0]}`);
7988
+ process.exit(1);
7989
+ }
7990
+ const currentVersion = getCurrentVersion(installBase, component, platform);
7991
+ if (!currentVersion) {
7992
+ console.error("Error: cannot determine current version (no symlink found)");
7993
+ console.log("Available versions:");
7994
+ for (const v of versions) console.log(` ${component}-${v}/`);
7995
+ process.exit(1);
7996
+ }
7997
+ const currentIdx = versions.indexOf(currentVersion);
7998
+ let previousVersion;
7999
+ if (currentIdx > 0) previousVersion = versions[currentIdx - 1];
8000
+ else if (versions.length >= 2) previousVersion = versions[1];
8001
+ else {
8002
+ console.error("Error: no alternative version available for rollback");
8003
+ process.exit(1);
8004
+ return;
8005
+ }
8006
+ if (!opts.yes) {
8007
+ console.log(`Rolling back "${config.name}":`);
8008
+ console.log(` Current version: ${currentVersion}`);
8009
+ console.log(` Rollback to: ${previousVersion}`);
8010
+ console.log("");
8011
+ if (!await confirm$2("Proceed with rollback?")) {
8012
+ console.log("Rollback cancelled.");
8013
+ return;
8014
+ }
8015
+ }
8016
+ console.log("Stopping service...");
8017
+ if ((await manager.status(config)).state === "running") {
8018
+ await manager.stop(config);
8019
+ console.log("Service stopped.");
8020
+ }
8021
+ if (isWindows(platform)) {
8022
+ const launcherPath = path.join(installBase, `${component}-${previousVersion}`, getLauncherName(component, platform));
8023
+ config.executablePath = launcherPath;
8024
+ await manager.uninstall(config);
8025
+ await manager.install(config);
8026
+ writeCurrentVersion(installBase, component, previousVersion);
8027
+ console.log(`Service registration updated to ${launcherPath}`);
8028
+ } else {
8029
+ updateSymlinkAtomic(installBase, component, previousVersion);
8030
+ console.log(`Symlink updated: ${path.join(installBase, component)} -> ${component}-${previousVersion}`);
8031
+ }
8032
+ console.log("Starting service...");
8033
+ await manager.start(config);
8034
+ console.log("Service started.");
8035
+ console.log("");
8036
+ console.log(`Rollback complete: ${currentVersion} -> ${previousVersion}`);
8037
+ }
8038
+ /**
8039
+ * Handle --cleanup: remove all versioned directories except the current
8040
+ * and previous versions.
8041
+ */
8042
+ async function handleCleanup(component, platform, installBase) {
8043
+ const versions = listInstalledVersions(installBase, component);
8044
+ if (versions.length <= 2) {
8045
+ console.log("Nothing to clean up (2 or fewer versions installed).");
8046
+ return;
8047
+ }
8048
+ const currentVersion = getCurrentVersion(installBase, component, platform);
8049
+ const currentIdx = currentVersion ? versions.indexOf(currentVersion) : versions.length - 1;
8050
+ const previousIdx = currentIdx > 0 ? currentIdx - 1 : -1;
8051
+ const toRemove = versions.filter((_, i) => i !== currentIdx && i !== previousIdx);
8052
+ if (toRemove.length === 0) {
8053
+ console.log("Nothing to clean up.");
8054
+ return;
8055
+ }
8056
+ console.log("The following versions will be removed:");
8057
+ for (const v of toRemove) console.log(` ${component}-${v}/`);
8058
+ if (currentVersion) console.log(`\nKeeping: ${component}-${currentVersion}/ (current)`);
8059
+ if (previousIdx >= 0) console.log(`Keeping: ${component}-${versions[previousIdx]}/ (previous)`);
8060
+ for (const v of toRemove) {
8061
+ const dirPath = path.join(installBase, `${component}-${v}`);
8062
+ fs.rmSync(dirPath, {
8063
+ recursive: true,
8064
+ force: true
8065
+ });
8066
+ console.log(`Removed ${dirPath}`);
8067
+ }
8068
+ console.log(`\nCleanup complete. Removed ${toRemove.length} old version(s).`);
8069
+ }
8070
+ //#endregion
8071
+ //#region src/cli/wizard/prompts.ts
8072
+ /**
8073
+ * Shared prompt utilities for the setup wizards.
8074
+ *
8075
+ * Wraps @inquirer/prompts with consistent formatting and
8076
+ * validation for common input types (DB URLs, ports, etc.).
8077
+ */
8078
+ /** Prompt for a PostgreSQL database URL with validation. */
8079
+ async function promptDbUrl() {
8080
+ return input({
8081
+ message: "PostgreSQL database URL:",
8082
+ validate: (value) => {
8083
+ const v = value.trim();
8084
+ if (!v.startsWith("postgresql://") && !v.startsWith("postgres://")) return "Must start with postgresql:// or postgres://";
8085
+ return true;
8086
+ }
8087
+ });
8088
+ }
8089
+ /** Prompt for a port number with validation. */
8090
+ async function promptPort(defaultPort) {
8091
+ const value = await input({
8092
+ message: "Port:",
8093
+ default: String(defaultPort),
8094
+ validate: (value) => {
8095
+ const n = parseInt(value.trim(), 10);
8096
+ if (isNaN(n) || n < 1 || n > 65535) return "Must be a number between 1 and 65535";
8097
+ return true;
8098
+ }
8099
+ });
8100
+ return parseInt(value.trim(), 10);
8101
+ }
8102
+ /** Prompt for a yes/no confirmation. */
8103
+ async function promptConfirm(message, defaultValue = true) {
8104
+ return confirm({
8105
+ message,
8106
+ default: defaultValue
8107
+ });
8108
+ }
8109
+ /** Prompt for a URL with http(s):// validation. */
8110
+ async function promptUrl(message, defaultValue) {
8111
+ return input({
8112
+ message,
8113
+ default: defaultValue,
8114
+ validate: (value) => {
8115
+ const v = value.trim();
8116
+ if (!v.startsWith("http://") && !v.startsWith("https://") && !v.startsWith("wss://") && !v.startsWith("ws://")) return "Must start with http://, https://, ws://, or wss://";
8117
+ return true;
8118
+ }
8119
+ });
8120
+ }
8121
+ /** Prompt for a secret/password (masked input). */
8122
+ async function promptSecret(message) {
8123
+ return password({
8124
+ message,
8125
+ mask: "*",
8126
+ validate: (value) => {
8127
+ if (!value.trim()) return "This field is required";
8128
+ return true;
8129
+ }
8130
+ });
8131
+ }
8132
+ /** Prompt for a selection from a list of options. */
8133
+ async function promptSelect(message, choices, defaultValue) {
8134
+ return select({
8135
+ message,
8136
+ choices,
8137
+ default: defaultValue
8138
+ });
8139
+ }
8140
+ var init_prompts = __esmMin((() => {}));
8141
+ //#endregion
8142
+ //#region src/cli/wizard/orchestrator-wizard.ts
8143
+ /**
8144
+ * Interactive wizard for orchestrator service setup.
8145
+ *
8146
+ * Walks the user through essential configuration (mode, DB URL,
8147
+ * port, secrets key) with sensible defaults. Returns a config
8148
+ * object that the install command uses to write the env file.
8149
+ */
8150
+ var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorWizard: () => runOrchestratorWizard });
8151
+ /**
8152
+ * Run the interactive orchestrator setup wizard.
8153
+ *
8154
+ * Asks only essential questions per the user decision:
8155
+ * 1. Mode (platform/hybrid/independent)
8156
+ * 2. Database URL
8157
+ * 3. Port
7262
8158
  * 4. Secrets encryption key
7263
8159
  * 5. Bootstrap admin token (for kici-admin authentication)
7264
8160
  * 6. Platform URL + token (if platform/hybrid mode)
@@ -7390,11 +8286,22 @@ function startDevPostgres(containerName) {
7390
8286
  if (err instanceof Error && err.message.includes("already exists")) throw err;
7391
8287
  }
7392
8288
  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" });
8289
+ const tmpEnvFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "kici-dev-pg-")), "postgres.env");
8290
+ try {
8291
+ fs.writeFileSync(tmpEnvFile, `POSTGRES_PASSWORD=${password}\n`, { mode: 384 });
8292
+ execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 --env-file ${tmpEnvFile} -e POSTGRES_DB=kici postgres:18-trixie`, { stdio: "inherit" });
8293
+ } finally {
8294
+ try {
8295
+ fs.rmSync(path.dirname(tmpEnvFile), {
8296
+ recursive: true,
8297
+ force: true
8298
+ });
8299
+ } catch {}
8300
+ }
7394
8301
  return `postgresql://postgres:${password}@localhost:${port}/kici`;
7395
8302
  }
7396
8303
  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) => {
8304
+ 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) => {
7398
8305
  try {
7399
8306
  if (opts.wizard && opts.envFile) {
7400
8307
  console.error("Error: Cannot use --wizard with --env-file");
@@ -7403,9 +8310,24 @@ function registerOrchestratorInstall(orchestrator) {
7403
8310
  const platform = detectPlatform(opts.platform);
7404
8311
  const userLevel = resolveUserLevel(opts);
7405
8312
  const serviceName = opts.name;
8313
+ const instanceDir = path.resolve(opts.instanceDir ?? process.cwd());
8314
+ const kiciRoot = kiciConfigRoot(userLevel);
7406
8315
  console.log(`Platform: ${platform}`);
7407
8316
  console.log(`Privilege: ${userLevel ? "user" : "system"}`);
7408
8317
  console.log(`Service name: ${serviceName}`);
8318
+ console.log(`Instance dir: ${instanceDir}`);
8319
+ const manager = await createServiceManager(platform);
8320
+ const existing = (await listInstances({
8321
+ component: "orchestrator",
8322
+ isUserLevel: userLevel,
8323
+ kiciRoot,
8324
+ manager
8325
+ })).find((c) => c.name === serviceName);
8326
+ if (existing && existing.instanceDir !== instanceDir && !opts.force) {
8327
+ const at = existing.instanceDir ?? "(no manifest)";
8328
+ console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}. Pass a different --name, a different --instance-dir, or --force to overwrite.`);
8329
+ process.exit(1);
8330
+ }
7409
8331
  const configDir = getConfigDir(serviceName, userLevel);
7410
8332
  const logDir = getLogDir(serviceName, userLevel);
7411
8333
  fs.mkdirSync(configDir, { recursive: true });
@@ -7441,707 +8363,412 @@ function registerOrchestratorInstall(orchestrator) {
7441
8363
  let envContent = `# KiCI orchestrator configuration\n# See docs for all available options\n`;
7442
8364
  if (devDbUrl) envContent += `KICI_DATABASE_URL=${devDbUrl}\n`;
7443
8365
  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}`);
7448
- }
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
- }
7487
- });
7488
- }
7489
- //#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);
7529
- }
7530
- });
7531
- }
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
- }
7563
- });
7564
- }
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);
8366
+ console.log(`Created env file at ${envFilePath}`);
8367
+ } else if (devDbUrl) {
8368
+ fs.appendFileSync(envFilePath, `\nKICI_DATABASE_URL=${devDbUrl}\n`);
8369
+ console.log(`Appended KICI_DATABASE_URL to ${envFilePath}`);
8370
+ }
8371
+ const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`));
8372
+ const { executablePath, args } = resolveServiceExecutable({
8373
+ binary: opts.binary ? path.resolve(opts.binary) : void 0,
8374
+ nodePath: process.execPath,
8375
+ entryScript
8376
+ });
8377
+ if (userLevel) try {
8378
+ const envContent = fs.readFileSync(envFilePath, "utf-8");
8379
+ if (envContent.includes("firecracker") || envContent.includes("FIRECRACKER")) {
8380
+ console.warn("\nWARNING: Firecracker scaler requires root privileges.");
8381
+ console.warn("The service is being installed at user level. Firecracker will not work.");
8382
+ console.warn("Re-run as root (sudo) to install a system-level service.\n");
8383
+ }
8384
+ } catch {}
7574
8385
  const config = {
7575
8386
  name: serviceName,
7576
8387
  displayName: "KiCI Orchestrator",
7577
8388
  description: "KiCI CI/CD workflow orchestrator service",
7578
- executablePath: "",
7579
- envFilePath: `${configDir}${serviceName}.env`,
8389
+ executablePath,
8390
+ args,
8391
+ nodeBinDir: path.dirname(process.execPath),
8392
+ envFilePath,
7580
8393
  workingDirectory: configDir,
7581
8394
  isUserLevel: userLevel,
8395
+ user: opts.user,
8396
+ component: "orchestrator",
7582
8397
  restartPolicy: DEFAULT_RESTART_POLICY
7583
8398
  };
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);
7589
- }
7590
- });
7591
- }
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 = {
8399
+ await manager.install(config);
8400
+ const manifestFile = writeManifest(instanceDir, {
8401
+ component: "orchestrator",
7602
8402
  name: serviceName,
7603
- displayName: "KiCI Orchestrator",
7604
- description: "KiCI CI/CD workflow orchestrator service",
7605
- executablePath: "",
7606
- envFilePath: `${configDir}${serviceName}.env`,
7607
- workingDirectory: configDir,
8403
+ platform,
7608
8404
  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);
7622
- }
7623
- });
7624
- }
7625
- //#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;
8405
+ envFilePath,
8406
+ configDir,
8407
+ logDir,
8408
+ installBase: getInstallBase(platform, serviceName),
8409
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8410
+ kiciVersion: readKiciVersion()
8411
+ });
8412
+ try {
8413
+ appendIndexEntry(kiciRoot, {
8414
+ component: "orchestrator",
8415
+ name: serviceName,
8416
+ platform,
8417
+ isUserLevel: userLevel,
8418
+ instanceDir
8419
+ });
8420
+ } catch (err) {
8421
+ console.warn(`Warning: instance index append failed: ${err.message}`);
7641
8422
  }
7642
- }
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;
7657
- }
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})`);
7678
- }
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)");
7683
- }
7684
- return lines.join("\n");
7685
- }
7686
- /** Build JSON output combining service + health data. */
7687
- function buildJsonOutput$1(serviceStatus, health, serviceName) {
7688
- return {
7689
- service: serviceName,
7690
- ...serviceStatus,
7691
- health: health ?? void 0
7692
- };
7693
- }
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) => {
7696
- 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);
7728
- }
7729
- });
7730
- }
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) => {
7735
- try {
7736
- const manager = await createServiceManager(detectPlatform(opts.platform));
7737
- const userLevel = !isRoot();
7738
- const configDir = getConfigDir(opts.name, userLevel);
7739
- 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`),
7745
- workingDirectory: configDir,
7746
- 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
7764
- };
7765
- await manager.logs(config, logOptions);
8423
+ console.log(`\nOrchestrator service "${serviceName}" installed successfully.`);
8424
+ console.log(` Config: ${envFilePath}`);
8425
+ console.log(` Logs: ${logDir}`);
8426
+ console.log(` Manifest: ${manifestFile}`);
8427
+ console.log(`\nNext steps:`);
8428
+ console.log(` 1. Edit ${envFilePath} with your configuration`);
8429
+ console.log(` 2. Run \`kici-admin orchestrator start\` to start the service`);
7766
8430
  } catch (err) {
7767
8431
  console.error(`Error: ${toErrorMessage(err)}`);
7768
8432
  process.exit(1);
7769
8433
  }
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");
8434
+ });
7883
8435
  }
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) {
8436
+ //#endregion
8437
+ //#region src/cli/commands/orchestrator-service/uninstall.ts
8438
+ function registerOrchestratorUninstall(orchestrator) {
8439
+ 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
8440
  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
8441
+ const platform = detectPlatform(opts.platform);
8442
+ const userLevel = resolveUserLevel(opts);
8443
+ const manager = await createServiceManager(platform);
8444
+ const kiciRoot = kiciConfigRoot(userLevel);
8445
+ const resolved = await resolveInstance({
8446
+ component: "orchestrator",
8447
+ opts: {
8448
+ instanceDir: opts.instanceDir,
8449
+ name: opts.name
8450
+ },
8451
+ cwd: process.cwd(),
8452
+ kiciRoot,
8453
+ manager,
8454
+ isUserLevel: userLevel
8455
+ });
8456
+ const config = {
8457
+ name: resolved.manifest.name,
8458
+ displayName: "KiCI Orchestrator",
8459
+ description: "KiCI CI/CD workflow orchestrator service",
8460
+ executablePath: "",
8461
+ envFilePath: resolved.manifest.envFilePath,
8462
+ workingDirectory: resolved.manifest.configDir,
8463
+ isUserLevel: resolved.manifest.isUserLevel,
8464
+ restartPolicy: DEFAULT_RESTART_POLICY,
8465
+ component: "orchestrator"
8466
+ };
8467
+ if (!await manager.isInstalled(config)) console.log(`Service "${config.name}" is not installed.`);
8468
+ else {
8469
+ try {
8470
+ if ((await manager.status(config)).state === "running") {
8471
+ console.log(`Stopping service "${config.name}"...`);
8472
+ await manager.stop(config);
8473
+ }
8474
+ } catch {}
8475
+ await manager.uninstall(config);
7938
8476
  }
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");
8477
+ removeIndexEntry(kiciRoot, {
8478
+ component: "orchestrator",
8479
+ name: config.name
8480
+ });
8481
+ console.log(`\nOrchestrator service "${config.name}" uninstalled.`);
8482
+ console.log(`Manifest preserved at ${resolved.manifestPath} — delete manually if no longer needed.`);
8483
+ } catch (err) {
8484
+ console.error(`Error: ${toErrorMessage(err)}`);
7958
8485
  process.exit(1);
7959
8486
  }
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
8487
+ });
8488
+ }
8489
+ //#endregion
8490
+ //#region src/cli/commands/orchestrator-service/start.ts
8491
+ function registerOrchestratorStart(orchestrator) {
8492
+ 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) => {
8493
+ try {
8494
+ const platform = detectPlatform(opts.platform);
8495
+ const userLevel = resolveUserLevel(opts);
8496
+ const manager = await createServiceManager(platform);
8497
+ const kiciRoot = kiciConfigRoot(userLevel);
8498
+ const resolved = await resolveInstance({
8499
+ component: "orchestrator",
8500
+ opts: {
8501
+ instanceDir: opts.instanceDir,
8502
+ name: opts.name
8503
+ },
8504
+ cwd: process.cwd(),
8505
+ kiciRoot,
8506
+ manager,
8507
+ isUserLevel: userLevel
7968
8508
  });
7969
- } else {
7970
- console.error(`Error: version directory already exists: ${versionedDirPath}`);
7971
- console.error("Use --force to overwrite.");
8509
+ const config = {
8510
+ name: resolved.manifest.name,
8511
+ displayName: "KiCI Orchestrator",
8512
+ description: "KiCI CI/CD workflow orchestrator service",
8513
+ executablePath: "",
8514
+ envFilePath: resolved.manifest.envFilePath,
8515
+ workingDirectory: resolved.manifest.configDir,
8516
+ isUserLevel: resolved.manifest.isUserLevel,
8517
+ restartPolicy: DEFAULT_RESTART_POLICY,
8518
+ component: "orchestrator"
8519
+ };
8520
+ if (!await manager.isInstalled(config)) {
8521
+ console.error(`Error: service "${config.name}" is not installed.`);
8522
+ console.error(`Run \`kici-admin orchestrator install\` first.`);
8523
+ process.exit(1);
8524
+ }
8525
+ await manager.start(config);
8526
+ console.log(`Orchestrator service "${config.name}" started.`);
8527
+ } catch (err) {
8528
+ console.error(`Error: ${toErrorMessage(err)}`);
7972
8529
  process.exit(1);
7973
8530
  }
7974
- if (!await manager.isInstalled(config)) {
7975
- console.error(`Error: service "${opts.name}" is not installed`);
8531
+ });
8532
+ }
8533
+ //#endregion
8534
+ //#region src/cli/commands/orchestrator-service/stop.ts
8535
+ function registerOrchestratorStop(orchestrator) {
8536
+ 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) => {
8537
+ try {
8538
+ const platform = detectPlatform(opts.platform);
8539
+ const userLevel = resolveUserLevel(opts);
8540
+ const manager = await createServiceManager(platform);
8541
+ const kiciRoot = kiciConfigRoot(userLevel);
8542
+ const resolved = await resolveInstance({
8543
+ component: "orchestrator",
8544
+ opts: {
8545
+ instanceDir: opts.instanceDir,
8546
+ name: opts.name
8547
+ },
8548
+ cwd: process.cwd(),
8549
+ kiciRoot,
8550
+ manager,
8551
+ isUserLevel: userLevel
8552
+ });
8553
+ const config = {
8554
+ name: resolved.manifest.name,
8555
+ displayName: "KiCI Orchestrator",
8556
+ description: "KiCI CI/CD workflow orchestrator service",
8557
+ executablePath: "",
8558
+ envFilePath: resolved.manifest.envFilePath,
8559
+ workingDirectory: resolved.manifest.configDir,
8560
+ isUserLevel: resolved.manifest.isUserLevel,
8561
+ restartPolicy: DEFAULT_RESTART_POLICY,
8562
+ component: "orchestrator"
8563
+ };
8564
+ await manager.stop(config);
8565
+ console.log(`Orchestrator service "${config.name}" stopped.`);
8566
+ } catch (err) {
8567
+ console.error(`Error: ${toErrorMessage(err)}`);
7976
8568
  process.exit(1);
7977
8569
  }
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}`);
8570
+ });
8571
+ }
8572
+ //#endregion
8573
+ //#region src/cli/commands/orchestrator-service/restart.ts
8574
+ function registerOrchestratorRestart(orchestrator) {
8575
+ 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) => {
8576
+ try {
8577
+ const platform = detectPlatform(opts.platform);
8578
+ const userLevel = resolveUserLevel(opts);
8579
+ const manager = await createServiceManager(platform);
8580
+ const kiciRoot = kiciConfigRoot(userLevel);
8581
+ const resolved = await resolveInstance({
8582
+ component: "orchestrator",
8583
+ opts: {
8584
+ instanceDir: opts.instanceDir,
8585
+ name: opts.name
8586
+ },
8587
+ cwd: process.cwd(),
8588
+ kiciRoot,
8589
+ manager,
8590
+ isUserLevel: userLevel
8591
+ });
8592
+ const config = {
8593
+ name: resolved.manifest.name,
8594
+ displayName: "KiCI Orchestrator",
8595
+ description: "KiCI CI/CD workflow orchestrator service",
8596
+ executablePath: "",
8597
+ envFilePath: resolved.manifest.envFilePath,
8598
+ workingDirectory: resolved.manifest.configDir,
8599
+ isUserLevel: resolved.manifest.isUserLevel,
8600
+ restartPolicy: DEFAULT_RESTART_POLICY,
8601
+ component: "orchestrator"
8602
+ };
8603
+ if (!await manager.isInstalled(config)) {
8604
+ console.error(`Error: service "${config.name}" is not installed.`);
8605
+ console.error(`Run \`kici-admin orchestrator install\` first.`);
7985
8606
  process.exit(1);
7986
8607
  }
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);
8608
+ await manager.restart(config);
8609
+ console.log(`Orchestrator service "${config.name}" restarted.`);
8610
+ } catch (err) {
8611
+ console.error(`Error: ${toErrorMessage(err)}`);
8612
+ process.exit(1);
7991
8613
  }
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;
8614
+ });
8615
+ }
8616
+ //#endregion
8617
+ //#region src/cli/commands/orchestrator-service/status.ts
8618
+ /** Read the port from the service's env file (path from the manifest). */
8619
+ function readPortFromEnvFile$1(envFilePath) {
8620
+ if (!fs.existsSync(envFilePath)) return 4e3;
8621
+ try {
8622
+ const content = fs.readFileSync(envFilePath, "utf-8");
8623
+ for (const line of content.split("\n")) {
8624
+ const trimmed = line.trim();
8625
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
8626
+ const [key, ...rest] = trimmed.split("=");
8627
+ if (key?.trim() === "KICI_PORT") {
8628
+ const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
8629
+ const parsed = parseInt(val, 10);
8630
+ if (!isNaN(parsed)) return parsed;
8003
8631
  }
8004
8632
  }
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);
8633
+ } catch {}
8634
+ return 4e3;
8635
+ }
8636
+ /** Query orchestrator health API. */
8637
+ async function queryHealth$1(port) {
8638
+ try {
8639
+ const controller = new AbortController();
8640
+ const timeout = setTimeout(() => controller.abort(), 3e3);
8641
+ const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
8642
+ clearTimeout(timeout);
8643
+ if (!res.ok) return null;
8644
+ return await res.json();
8645
+ } catch {
8646
+ return null;
8049
8647
  }
8050
8648
  }
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;
8649
+ /** Format the status output as a readable table. */
8650
+ function formatStatus$1(serviceStatus, health, serviceName) {
8651
+ const lines = [];
8652
+ lines.push(`Service: ${serviceName}`);
8653
+ lines.push(`State: ${serviceStatus.state}`);
8654
+ if (serviceStatus.pid) lines.push(`PID: ${serviceStatus.pid}`);
8655
+ if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
8656
+ if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
8657
+ if (health) {
8658
+ lines.push("");
8659
+ lines.push("--- KiCI orchestrator ---");
8660
+ if (health.mode) lines.push(`Mode: ${health.mode}`);
8661
+ if (health.port) lines.push(`Port: ${health.port}`);
8662
+ if (health.database) lines.push(`Database: ${health.database}`);
8663
+ if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
8664
+ if (health.agents != null) lines.push(`Agents: ${health.agents}`);
8665
+ if (health.scaler) {
8666
+ const s = health.scaler;
8667
+ lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
8085
8668
  }
8669
+ if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
8670
+ } else if (serviceStatus.state === "running") {
8671
+ lines.push("");
8672
+ lines.push("(Could not reach health API)");
8086
8673
  }
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}`);
8674
+ return lines.join("\n");
8108
8675
  }
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).`);
8676
+ /** Build JSON output combining service + health data. */
8677
+ function buildJsonOutput$1(serviceStatus, health, serviceName) {
8678
+ return {
8679
+ service: serviceName,
8680
+ ...serviceStatus,
8681
+ health: health ?? void 0
8682
+ };
8683
+ }
8684
+ function registerStatusCommand(parent) {
8685
+ 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) => {
8686
+ try {
8687
+ const platform = detectPlatform(opts.platform);
8688
+ const userLevel = resolveUserLevel(opts);
8689
+ const manager = await createServiceManager(platform);
8690
+ const kiciRoot = kiciConfigRoot(userLevel);
8691
+ const resolved = await resolveInstance({
8692
+ component: "orchestrator",
8693
+ opts: {
8694
+ instanceDir: opts.instanceDir,
8695
+ name: opts.name
8696
+ },
8697
+ cwd: process.cwd(),
8698
+ kiciRoot,
8699
+ manager,
8700
+ isUserLevel: userLevel
8701
+ });
8702
+ const config = {
8703
+ name: resolved.manifest.name,
8704
+ displayName: "KiCI Orchestrator",
8705
+ description: "KiCI CI/CD workflow orchestrator service",
8706
+ executablePath: "",
8707
+ envFilePath: resolved.manifest.envFilePath,
8708
+ workingDirectory: resolved.manifest.configDir,
8709
+ isUserLevel: resolved.manifest.isUserLevel,
8710
+ restartPolicy: DEFAULT_RESTART_POLICY,
8711
+ component: "orchestrator"
8712
+ };
8713
+ const serviceStatus = await manager.status(config);
8714
+ let health = null;
8715
+ if (serviceStatus.state === "running") health = await queryHealth$1(readPortFromEnvFile$1(config.envFilePath));
8716
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, config.name), null, 2));
8717
+ else console.log(formatStatus$1(serviceStatus, health, config.name));
8718
+ } catch (err) {
8719
+ console.error(`Error: ${toErrorMessage(err)}`);
8720
+ process.exit(1);
8721
+ }
8722
+ });
8723
+ }
8724
+ //#endregion
8725
+ //#region src/cli/commands/orchestrator-service/logs.ts
8726
+ function registerLogsCommand(parent) {
8727
+ 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) => {
8728
+ try {
8729
+ const platform = detectPlatform(opts.platform);
8730
+ const userLevel = resolveUserLevel(opts);
8731
+ const manager = await createServiceManager(platform);
8732
+ const kiciRoot = kiciConfigRoot(userLevel);
8733
+ const resolved = await resolveInstance({
8734
+ component: "orchestrator",
8735
+ opts: {
8736
+ instanceDir: opts.instanceDir,
8737
+ name: opts.name
8738
+ },
8739
+ cwd: process.cwd(),
8740
+ kiciRoot,
8741
+ manager,
8742
+ isUserLevel: userLevel
8743
+ });
8744
+ const config = {
8745
+ name: resolved.manifest.name,
8746
+ displayName: "KiCI Orchestrator",
8747
+ description: "KiCI CI/CD workflow orchestrator service",
8748
+ executablePath: "",
8749
+ envFilePath: resolved.manifest.envFilePath,
8750
+ workingDirectory: resolved.manifest.configDir,
8751
+ isUserLevel: resolved.manifest.isUserLevel,
8752
+ restartPolicy: DEFAULT_RESTART_POLICY,
8753
+ component: "orchestrator"
8754
+ };
8755
+ const logOptions = {
8756
+ since: opts.since,
8757
+ level: opts.level,
8758
+ json: opts.json,
8759
+ follow: opts.follow
8760
+ };
8761
+ await manager.logs(config, logOptions);
8762
+ } catch (err) {
8763
+ console.error(`Error: ${toErrorMessage(err)}`);
8764
+ process.exit(1);
8765
+ }
8766
+ });
8140
8767
  }
8141
8768
  //#endregion
8142
8769
  //#region src/cli/commands/orchestrator-service/upgrade.ts
8143
8770
  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) => {
8771
+ 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
8772
  await performVersionedUpgrade("orchestrator", opts);
8146
8773
  });
8147
8774
  }
@@ -8199,18 +8826,33 @@ var init_agent_wizard = __esmMin((() => {
8199
8826
  //#endregion
8200
8827
  //#region src/cli/commands/agent-service/install.ts
8201
8828
  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) => {
8829
+ 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
8830
  try {
8204
8831
  if (opts.wizard && opts.envFile) {
8205
8832
  console.error("Error: Cannot use --wizard with --env-file");
8206
8833
  process.exit(1);
8207
8834
  }
8208
8835
  const platform = detectPlatform(opts.platform);
8209
- const userLevel = !isRoot();
8836
+ const userLevel = resolveUserLevel(opts);
8210
8837
  const serviceName = opts.name;
8838
+ const instanceDir = path.resolve(opts.instanceDir ?? process.cwd());
8839
+ const kiciRoot = kiciConfigRoot(userLevel);
8211
8840
  console.log(`Platform: ${platform}`);
8212
8841
  console.log(`Privilege: ${userLevel ? "user" : "system"}`);
8213
8842
  console.log(`Service name: ${serviceName}`);
8843
+ console.log(`Instance dir: ${instanceDir}`);
8844
+ const manager = await createServiceManager(platform);
8845
+ const existing = (await listInstances({
8846
+ component: "agent",
8847
+ isUserLevel: userLevel,
8848
+ kiciRoot,
8849
+ manager
8850
+ })).find((c) => c.name === serviceName);
8851
+ if (existing && existing.instanceDir !== instanceDir && !opts.force) {
8852
+ const at = existing.instanceDir ?? "(no manifest)";
8853
+ console.error(`Error: an agent instance "${serviceName}" is already installed at ${at}. Pass a different --name, a different --instance-dir, or --force to overwrite.`);
8854
+ process.exit(1);
8855
+ }
8214
8856
  const configDir = getConfigDir(serviceName, userLevel);
8215
8857
  const logDir = getLogDir(serviceName, userLevel);
8216
8858
  fs.mkdirSync(configDir, { recursive: true });
@@ -8257,12 +8899,37 @@ function registerAgentInstall(agent) {
8257
8899
  envFilePath,
8258
8900
  workingDirectory: configDir,
8259
8901
  isUserLevel: userLevel,
8902
+ component: "agent",
8260
8903
  restartPolicy: DEFAULT_RESTART_POLICY
8261
8904
  };
8262
- await (await createServiceManager(platform)).install(config);
8905
+ await manager.install(config);
8906
+ const manifestFile = writeManifest(instanceDir, {
8907
+ component: "agent",
8908
+ name: serviceName,
8909
+ platform,
8910
+ isUserLevel: userLevel,
8911
+ envFilePath,
8912
+ configDir,
8913
+ logDir,
8914
+ installBase: getInstallBase(platform, serviceName),
8915
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8916
+ kiciVersion: readKiciVersion()
8917
+ });
8918
+ try {
8919
+ appendIndexEntry(kiciRoot, {
8920
+ component: "agent",
8921
+ name: serviceName,
8922
+ platform,
8923
+ isUserLevel: userLevel,
8924
+ instanceDir
8925
+ });
8926
+ } catch (err) {
8927
+ console.warn(`Warning: instance index append failed: ${err.message}`);
8928
+ }
8263
8929
  console.log(`\nAgent service "${serviceName}" installed successfully.`);
8264
- console.log(` Config: ${envFilePath}`);
8265
- console.log(` Logs: ${logDir}`);
8930
+ console.log(` Config: ${envFilePath}`);
8931
+ console.log(` Logs: ${logDir}`);
8932
+ console.log(` Manifest: ${manifestFile}`);
8266
8933
  console.log(`\nNext steps:`);
8267
8934
  console.log(` 1. Edit ${envFilePath} with your configuration`);
8268
8935
  console.log(` 2. Run \`kici-admin agent start\` to start the service`);
@@ -8275,39 +8942,50 @@ function registerAgentInstall(agent) {
8275
8942
  //#endregion
8276
8943
  //#region src/cli/commands/agent-service/uninstall.ts
8277
8944
  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) => {
8945
+ 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
8946
  try {
8280
8947
  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);
8948
+ const userLevel = resolveUserLevel(opts);
8949
+ const manager = await createServiceManager(platform);
8950
+ const kiciRoot = kiciConfigRoot(userLevel);
8951
+ const resolved = await resolveInstance({
8952
+ component: "agent",
8953
+ opts: {
8954
+ instanceDir: opts.instanceDir,
8955
+ name: opts.name
8956
+ },
8957
+ cwd: process.cwd(),
8958
+ kiciRoot,
8959
+ manager,
8960
+ isUserLevel: userLevel
8961
+ });
8285
8962
  const config = {
8286
- name: serviceName,
8963
+ name: resolved.manifest.name,
8287
8964
  displayName: "KiCI Agent",
8288
8965
  description: "KiCI CI/CD workflow execution agent service",
8289
8966
  executablePath: "",
8290
- envFilePath: `${configDir}${serviceName}.env`,
8291
- workingDirectory: configDir,
8292
- isUserLevel: userLevel,
8293
- restartPolicy: DEFAULT_RESTART_POLICY
8967
+ envFilePath: resolved.manifest.envFilePath,
8968
+ workingDirectory: resolved.manifest.configDir,
8969
+ isUserLevel: resolved.manifest.isUserLevel,
8970
+ restartPolicy: DEFAULT_RESTART_POLICY,
8971
+ component: "agent"
8294
8972
  };
8295
- const manager = await createServiceManager(platform);
8296
- if (!await manager.isInstalled(config)) {
8297
- console.log(`Service "${serviceName}" is not installed.`);
8298
- process.exit(0);
8973
+ if (!await manager.isInstalled(config)) console.log(`Service "${config.name}" is not installed.`);
8974
+ else {
8975
+ try {
8976
+ if ((await manager.status(config)).state === "running") {
8977
+ console.log(`Stopping service "${config.name}"...`);
8978
+ await manager.stop(config);
8979
+ }
8980
+ } catch {}
8981
+ await manager.uninstall(config);
8299
8982
  }
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}`);
8983
+ removeIndexEntry(kiciRoot, {
8984
+ component: "agent",
8985
+ name: config.name
8986
+ });
8987
+ console.log(`\nAgent service "${config.name}" uninstalled.`);
8988
+ console.log(`Manifest preserved at ${resolved.manifestPath} — delete manually if no longer needed.`);
8311
8989
  } catch (err) {
8312
8990
  console.error(`Error: ${toErrorMessage(err)}`);
8313
8991
  process.exit(1);
@@ -8317,30 +8995,41 @@ function registerAgentUninstall(agent) {
8317
8995
  //#endregion
8318
8996
  //#region src/cli/commands/agent-service/start.ts
8319
8997
  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) => {
8998
+ 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
8999
  try {
8322
9000
  const platform = detectPlatform(opts.platform);
8323
- const userLevel = !isRoot();
8324
- const serviceName = opts.name;
8325
- const configDir = getConfigDir(serviceName, userLevel);
9001
+ const userLevel = resolveUserLevel(opts);
9002
+ const manager = await createServiceManager(platform);
9003
+ const kiciRoot = kiciConfigRoot(userLevel);
9004
+ const resolved = await resolveInstance({
9005
+ component: "agent",
9006
+ opts: {
9007
+ instanceDir: opts.instanceDir,
9008
+ name: opts.name
9009
+ },
9010
+ cwd: process.cwd(),
9011
+ kiciRoot,
9012
+ manager,
9013
+ isUserLevel: userLevel
9014
+ });
8326
9015
  const config = {
8327
- name: serviceName,
9016
+ name: resolved.manifest.name,
8328
9017
  displayName: "KiCI Agent",
8329
9018
  description: "KiCI CI/CD workflow execution agent service",
8330
9019
  executablePath: "",
8331
- envFilePath: path.join(configDir, `${serviceName}.env`),
8332
- workingDirectory: configDir,
8333
- isUserLevel: userLevel,
8334
- restartPolicy: DEFAULT_RESTART_POLICY
9020
+ envFilePath: resolved.manifest.envFilePath,
9021
+ workingDirectory: resolved.manifest.configDir,
9022
+ isUserLevel: resolved.manifest.isUserLevel,
9023
+ restartPolicy: DEFAULT_RESTART_POLICY,
9024
+ component: "agent"
8335
9025
  };
8336
- const manager = await createServiceManager(platform);
8337
9026
  if (!await manager.isInstalled(config)) {
8338
- console.error(`Error: service "${serviceName}" is not installed.`);
9027
+ console.error(`Error: service "${config.name}" is not installed.`);
8339
9028
  console.error(`Run \`kici-admin agent install\` first.`);
8340
9029
  process.exit(1);
8341
9030
  }
8342
9031
  await manager.start(config);
8343
- console.log(`Agent service "${serviceName}" started.`);
9032
+ console.log(`Agent service "${config.name}" started.`);
8344
9033
  } catch (err) {
8345
9034
  console.error(`Error: ${toErrorMessage(err)}`);
8346
9035
  process.exit(1);
@@ -8350,24 +9039,36 @@ function registerAgentStart(agent) {
8350
9039
  //#endregion
8351
9040
  //#region src/cli/commands/agent-service/stop.ts
8352
9041
  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) => {
9042
+ 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
9043
  try {
8355
9044
  const platform = detectPlatform(opts.platform);
8356
- const userLevel = !isRoot();
8357
- const serviceName = opts.name;
8358
- const configDir = getConfigDir(serviceName, userLevel);
9045
+ const userLevel = resolveUserLevel(opts);
9046
+ const manager = await createServiceManager(platform);
9047
+ const kiciRoot = kiciConfigRoot(userLevel);
9048
+ const resolved = await resolveInstance({
9049
+ component: "agent",
9050
+ opts: {
9051
+ instanceDir: opts.instanceDir,
9052
+ name: opts.name
9053
+ },
9054
+ cwd: process.cwd(),
9055
+ kiciRoot,
9056
+ manager,
9057
+ isUserLevel: userLevel
9058
+ });
8359
9059
  const config = {
8360
- name: serviceName,
9060
+ name: resolved.manifest.name,
8361
9061
  displayName: "KiCI Agent",
8362
9062
  description: "KiCI CI/CD workflow execution agent service",
8363
9063
  executablePath: "",
8364
- envFilePath: `${configDir}${serviceName}.env`,
8365
- workingDirectory: configDir,
8366
- isUserLevel: userLevel,
8367
- restartPolicy: DEFAULT_RESTART_POLICY
9064
+ envFilePath: resolved.manifest.envFilePath,
9065
+ workingDirectory: resolved.manifest.configDir,
9066
+ isUserLevel: resolved.manifest.isUserLevel,
9067
+ restartPolicy: DEFAULT_RESTART_POLICY,
9068
+ component: "agent"
8368
9069
  };
8369
- await (await createServiceManager(platform)).stop(config);
8370
- console.log(`Agent service "${serviceName}" stopped.`);
9070
+ await manager.stop(config);
9071
+ console.log(`Agent service "${config.name}" stopped.`);
8371
9072
  } catch (err) {
8372
9073
  console.error(`Error: ${toErrorMessage(err)}`);
8373
9074
  process.exit(1);
@@ -8377,30 +9078,41 @@ function registerAgentStop(agent) {
8377
9078
  //#endregion
8378
9079
  //#region src/cli/commands/agent-service/restart.ts
8379
9080
  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) => {
9081
+ 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
9082
  try {
8382
9083
  const platform = detectPlatform(opts.platform);
8383
- const userLevel = !isRoot();
8384
- const serviceName = opts.name;
8385
- const configDir = getConfigDir(serviceName, userLevel);
9084
+ const userLevel = resolveUserLevel(opts);
9085
+ const manager = await createServiceManager(platform);
9086
+ const kiciRoot = kiciConfigRoot(userLevel);
9087
+ const resolved = await resolveInstance({
9088
+ component: "agent",
9089
+ opts: {
9090
+ instanceDir: opts.instanceDir,
9091
+ name: opts.name
9092
+ },
9093
+ cwd: process.cwd(),
9094
+ kiciRoot,
9095
+ manager,
9096
+ isUserLevel: userLevel
9097
+ });
8386
9098
  const config = {
8387
- name: serviceName,
9099
+ name: resolved.manifest.name,
8388
9100
  displayName: "KiCI Agent",
8389
9101
  description: "KiCI CI/CD workflow execution agent service",
8390
9102
  executablePath: "",
8391
- envFilePath: `${configDir}${serviceName}.env`,
8392
- workingDirectory: configDir,
8393
- isUserLevel: userLevel,
8394
- restartPolicy: DEFAULT_RESTART_POLICY
9103
+ envFilePath: resolved.manifest.envFilePath,
9104
+ workingDirectory: resolved.manifest.configDir,
9105
+ isUserLevel: resolved.manifest.isUserLevel,
9106
+ restartPolicy: DEFAULT_RESTART_POLICY,
9107
+ component: "agent"
8395
9108
  };
8396
- const manager = await createServiceManager(platform);
8397
9109
  if (!await manager.isInstalled(config)) {
8398
- console.error(`Error: service "${serviceName}" is not installed.`);
9110
+ console.error(`Error: service "${config.name}" is not installed.`);
8399
9111
  console.error(`Run \`kici-admin agent install\` first.`);
8400
9112
  process.exit(1);
8401
9113
  }
8402
9114
  await manager.restart(config);
8403
- console.log(`Agent service "${serviceName}" restarted.`);
9115
+ console.log(`Agent service "${config.name}" restarted.`);
8404
9116
  } catch (err) {
8405
9117
  console.error(`Error: ${toErrorMessage(err)}`);
8406
9118
  process.exit(1);
@@ -8409,12 +9121,11 @@ function registerAgentRestart(agent) {
8409
9121
  }
8410
9122
  //#endregion
8411
9123
  //#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;
9124
+ /** Read the port from the agent's env file (path from the manifest). */
9125
+ function readPortFromEnvFile(envFilePath) {
9126
+ if (!fs.existsSync(envFilePath)) return 4001;
8416
9127
  try {
8417
- const content = fs.readFileSync(envPath, "utf-8");
9128
+ const content = fs.readFileSync(envFilePath, "utf-8");
8418
9129
  for (const line of content.split("\n")) {
8419
9130
  const trimmed = line.trim();
8420
9131
  if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
@@ -8470,36 +9181,39 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
8470
9181
  };
8471
9182
  }
8472
9183
  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) => {
9184
+ 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
9185
  try {
8475
- const manager = await createServiceManager(detectPlatform(opts.platform));
8476
- const userLevel = !isRoot();
8477
- const configDir = getConfigDir(opts.name, userLevel);
9186
+ const platform = detectPlatform(opts.platform);
9187
+ const userLevel = resolveUserLevel(opts);
9188
+ const manager = await createServiceManager(platform);
9189
+ const kiciRoot = kiciConfigRoot(userLevel);
9190
+ const resolved = await resolveInstance({
9191
+ component: "agent",
9192
+ opts: {
9193
+ instanceDir: opts.instanceDir,
9194
+ name: opts.name
9195
+ },
9196
+ cwd: process.cwd(),
9197
+ kiciRoot,
9198
+ manager,
9199
+ isUserLevel: userLevel
9200
+ });
8478
9201
  const config = {
8479
- name: opts.name,
8480
- displayName: "KiCI agent",
8481
- description: "KiCI agent service",
9202
+ name: resolved.manifest.name,
9203
+ displayName: "KiCI Agent",
9204
+ description: "KiCI CI/CD workflow execution agent service",
8482
9205
  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
- }
9206
+ envFilePath: resolved.manifest.envFilePath,
9207
+ workingDirectory: resolved.manifest.configDir,
9208
+ isUserLevel: resolved.manifest.isUserLevel,
9209
+ restartPolicy: DEFAULT_RESTART_POLICY,
9210
+ component: "agent"
8497
9211
  };
8498
9212
  const serviceStatus = await manager.status(config);
8499
9213
  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));
9214
+ if (serviceStatus.state === "running") health = await queryHealth(readPortFromEnvFile(config.envFilePath));
9215
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
9216
+ else console.log(formatStatus(serviceStatus, health, config.name));
8503
9217
  } catch (err) {
8504
9218
  console.error(`Error: ${toErrorMessage(err)}`);
8505
9219
  process.exit(1);
@@ -8509,30 +9223,33 @@ function registerAgentStatusCommand(parent) {
8509
9223
  //#endregion
8510
9224
  //#region src/cli/commands/agent-service/logs.ts
8511
9225
  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) => {
9226
+ 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
9227
  try {
8514
- const manager = await createServiceManager(detectPlatform(opts.platform));
8515
- const userLevel = !isRoot();
8516
- const configDir = getConfigDir(opts.name, userLevel);
9228
+ const platform = detectPlatform(opts.platform);
9229
+ const userLevel = resolveUserLevel(opts);
9230
+ const manager = await createServiceManager(platform);
9231
+ const kiciRoot = kiciConfigRoot(userLevel);
9232
+ const resolved = await resolveInstance({
9233
+ component: "agent",
9234
+ opts: {
9235
+ instanceDir: opts.instanceDir,
9236
+ name: opts.name
9237
+ },
9238
+ cwd: process.cwd(),
9239
+ kiciRoot,
9240
+ manager,
9241
+ isUserLevel: userLevel
9242
+ });
8517
9243
  const config = {
8518
- name: opts.name,
8519
- displayName: "KiCI agent",
8520
- description: "KiCI agent service",
9244
+ name: resolved.manifest.name,
9245
+ displayName: "KiCI Agent",
9246
+ description: "KiCI CI/CD workflow execution agent service",
8521
9247
  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
- }
9248
+ envFilePath: resolved.manifest.envFilePath,
9249
+ workingDirectory: resolved.manifest.configDir,
9250
+ isUserLevel: resolved.manifest.isUserLevel,
9251
+ restartPolicy: DEFAULT_RESTART_POLICY,
9252
+ component: "agent"
8536
9253
  };
8537
9254
  const logOptions = {
8538
9255
  since: opts.since,
@@ -8550,7 +9267,7 @@ function registerAgentLogsCommand(parent) {
8550
9267
  //#endregion
8551
9268
  //#region src/cli/commands/agent-service/upgrade.ts
8552
9269
  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) => {
9270
+ 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
9271
  await performVersionedUpgrade("agent", opts);
8555
9272
  });
8556
9273
  }