@kici-dev/orchestrator 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/__test-helpers__/mock-db-query.d.ts +4 -5
  2. package/dist/agent/agent-version.d.ts +5 -5
  3. package/dist/agent/dispatcher.d.ts +26 -0
  4. package/dist/agent/registry.d.ts +7 -9
  5. package/dist/agent-packaging/node-runtime-members.d.ts +14 -0
  6. package/dist/app.d.ts +3 -3
  7. package/dist/approvals/apply-decision.d.ts +1 -1
  8. package/dist/audit/access-log.d.ts +2 -3
  9. package/dist/cli/api-client.d.ts +20 -1
  10. package/dist/cli/commands/access-log.d.ts +3 -3
  11. package/dist/cli/commands/agent-service/agent-env.d.ts +20 -0
  12. package/dist/cli/commands/agent-service/status.d.ts +3 -3
  13. package/dist/cli/commands/attestations.d.ts +10 -0
  14. package/dist/cli/commands/cold-store-impl.d.ts +3 -3
  15. package/dist/cli/commands/cold-store.d.ts +6 -3
  16. package/dist/cli/commands/context.d.ts +5 -3
  17. package/dist/cli/commands/dashboard-encryption-key.d.ts +18 -0
  18. package/dist/cli/commands/event-dlq.d.ts +5 -6
  19. package/dist/cli/commands/event-log.d.ts +2 -3
  20. package/dist/cli/commands/execution.d.ts +1 -3
  21. package/dist/cli/commands/orchestrator-service/status.d.ts +10 -3
  22. package/dist/cli/commands/queue.d.ts +1 -2
  23. package/dist/cli/commands/registration.d.ts +8 -4
  24. package/dist/cli/commands/runs.d.ts +2 -3
  25. package/dist/cli/commands/secret.d.ts +1 -0
  26. package/dist/cli/commands/service-health.d.ts +68 -0
  27. package/dist/cli/commands/shared/confirm.d.ts +19 -0
  28. package/dist/cli/commands/shared/env-file-mode.d.ts +3 -2
  29. package/dist/cli/commands/shared/upgrade-safety.d.ts +11 -5
  30. package/dist/cli/commands/shared/versioned-upgrade.d.ts +119 -7
  31. package/dist/cli/commands/workflow.d.ts +3 -4
  32. package/dist/cli/open-browser.d.ts +1 -1
  33. package/dist/cli/service/entrypoint.d.ts +18 -0
  34. package/dist/cli/service/image-digests.d.ts +10 -0
  35. package/dist/cli/service/index.d.ts +4 -2
  36. package/dist/cli/service/instance/manifest.d.ts +39 -0
  37. package/dist/cli/service/launchd.d.ts +45 -10
  38. package/dist/cli/service/shutdown-grace.d.ts +35 -0
  39. package/dist/cli/service/types.d.ts +9 -0
  40. package/dist/cli/service/windows-acl.d.ts +40 -0
  41. package/dist/cli/service/windows-env-file.d.ts +45 -0
  42. package/dist/cli/service/windows.d.ts +13 -0
  43. package/dist/cli.js +1869 -707
  44. package/dist/cluster/cluster-identity.d.ts +4 -4
  45. package/dist/cluster/join-token.d.ts +2 -2
  46. package/dist/cluster/leader-gated-scheduler.d.ts +7 -1
  47. package/dist/cluster/peer-credentials.d.ts +1 -1
  48. package/dist/cluster/raft-state.d.ts +6 -0
  49. package/dist/cluster/raft.d.ts +16 -2
  50. package/dist/cold-store/load-access-log-range.d.ts +3 -3
  51. package/dist/cold-store/load-event-log-range.d.ts +1 -1
  52. package/dist/cold-store/load-secret-audit-log-range.d.ts +2 -2
  53. package/dist/cold-store/orchestrator-cold-store.d.ts +9 -11
  54. package/dist/cold-store/tables/access-log.d.ts +2 -2
  55. package/dist/cold-store/tables/event-log.d.ts +4 -10
  56. package/dist/cold-store/tables/execution-jobs.d.ts +1 -1
  57. package/dist/cold-store/tables/execution-runs.d.ts +4 -4
  58. package/dist/cold-store/tables/execution-steps.d.ts +1 -1
  59. package/dist/cold-store/tables/secret-audit-log.d.ts +3 -3
  60. package/dist/config.d.ts +3 -1
  61. package/dist/contexts/held-runs.d.ts +2 -2
  62. package/dist/contexts/variable-store.d.ts +2 -0
  63. package/dist/dashboard/attestation-filters.d.ts +1 -1
  64. package/dist/dashboard/handler.d.ts +8 -8
  65. package/dist/db/deferred-indexes.d.ts +2 -2
  66. package/dist/db/migrations/005_cold_store_chunk_counter.d.ts +2 -4
  67. package/dist/db/migrations/006_runs_jobs_steps_archived_at.d.ts +4 -4
  68. package/dist/db/migrations/007_audit_logs_archived_at.d.ts +6 -9
  69. package/dist/db/migrations/008_event_log_archived_at.d.ts +4 -6
  70. package/dist/db/migrations/010_cold_store_chunks.d.ts +2 -2
  71. package/dist/db/migrations/012_peer_credentials_active_uniq.d.ts +2 -4
  72. package/dist/db/migrations/016_org_settings_allow_http_npm.d.ts +2 -2
  73. package/dist/db/migrations/045_host_properties.d.ts +1 -1
  74. package/dist/db/migrations/046_join_token_consumed_by_instance.d.ts +1 -1
  75. package/dist/db/migrations/048_host_reboot_pending.d.ts +1 -1
  76. package/dist/db/migrations/049_held_runs_payload.d.ts +1 -1
  77. package/dist/db/migrations/050_sources_slug.d.ts +1 -1
  78. package/dist/db/migrations/051_binding_host_pattern.d.ts +1 -1
  79. package/dist/db/migrations/052_host_reach_metadata.d.ts +1 -1
  80. package/dist/db/migrations/053_agent_token_single_use.d.ts +1 -1
  81. package/dist/db/migrations/054_local_working_tree.d.ts +1 -1
  82. package/dist/db/migrations/055_agent_token_mandatory_labels.d.ts +1 -1
  83. package/dist/db/migrations/056_execution_jobs_environments.d.ts +1 -1
  84. package/dist/db/migrations/064_execution_jobs_skipped_environments.d.ts +1 -1
  85. package/dist/db/types.d.ts +12 -13
  86. package/dist/events/types.d.ts +1 -1
  87. package/dist/fault-injection-types.d.ts +2 -2
  88. package/dist/firecracker/host-network.d.ts +21 -3
  89. package/dist/firecracker/net-interfaces.d.ts +1 -1
  90. package/dist/helpers/run-detached.d.ts +12 -0
  91. package/dist/index.js +76 -14
  92. package/dist/oidc/db-signer.d.ts +1 -1
  93. package/dist/pipeline/global-eval-round.d.ts +2 -2
  94. package/dist/pipeline/processor.d.ts +2 -2
  95. package/dist/provenance/retrier-mint.d.ts +1 -1
  96. package/dist/provenance/trust-root.d.ts +3 -3
  97. package/dist/providers/universal-git/clone-token.d.ts +9 -13
  98. package/dist/providers/universal-git/index.d.ts +1 -2
  99. package/dist/providers/universal-git/normalizer.d.ts +2 -3
  100. package/dist/providers/universal-git/ssh-auth.d.ts +4 -4
  101. package/dist/queue/cleanup.d.ts +2 -2
  102. package/dist/queue/job-queue.d.ts +6 -6
  103. package/dist/queue/scheduled-job.d.ts +5 -7
  104. package/dist/registration/extractor.d.ts +1 -1
  105. package/dist/registration/registration-index.d.ts +1 -1
  106. package/dist/reporting/execution-tracker.d.ts +3 -3
  107. package/dist/routes/admin-access-log.d.ts +1 -1
  108. package/dist/routes/admin-auth.d.ts +2 -2
  109. package/dist/routes/admin-contexts.d.ts +6 -0
  110. package/dist/routes/admin-event-log.d.ts +2 -2
  111. package/dist/routes/admin-registrations.d.ts +2 -2
  112. package/dist/routes/admin-runs.d.ts +3 -3
  113. package/dist/routes/admin-scheduled-jobs.d.ts +2 -3
  114. package/dist/routes/health.d.ts +9 -0
  115. package/dist/scaler/container-backend.d.ts +1 -1
  116. package/dist/scaler/firecracker-backend.d.ts +15 -10
  117. package/dist/scaler/firecracker-extra-hosts.d.ts +20 -0
  118. package/dist/scaler/machine-ledger.d.ts +3 -4
  119. package/dist/scaler/scaler-state-store.d.ts +1 -1
  120. package/dist/scaler/types.d.ts +4 -1
  121. package/dist/secrets/audit-logger.d.ts +3 -3
  122. package/dist/security/reduced-privilege-note.d.ts +2 -3
  123. package/dist/security/trust-policy-store.d.ts +3 -3
  124. package/dist/server.d.ts +2 -1
  125. package/dist/server.js +2885 -2211
  126. package/dist/stale-detector/stale-run-detector.d.ts +1 -1
  127. package/dist/standalone.d.ts +1 -1
  128. package/dist/standalone.js +2254 -1629
  129. package/dist/storage/filesystem.d.ts +1 -1
  130. package/dist/storage/index.d.ts +1 -1
  131. package/dist/storage/sign-url.d.ts +2 -1
  132. package/dist/storage/types.d.ts +2 -2
  133. package/dist/ws/agent-handler.d.ts +34 -3
  134. package/dist/ws/agent-heartbeat.d.ts +2 -3
  135. package/dist/ws/dashboard-context-handler.d.ts +1 -1
  136. package/dist/ws/platform-client.d.ts +44 -32
  137. package/dist/ws/server-options.d.ts +1 -1
  138. package/dist/ws/test-relay-handlers.d.ts +1 -1
  139. package/installer-image-digests.json +3 -3
  140. package/package.json +4 -4
  141. package/sbom.spdx.json +53 -53
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import path, { dirname, join, resolve } from "node:path";
6
6
  import * as fs$1 from "node:fs";
7
7
  import fs, { chmodSync, closeSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, promises, readFileSync, realpathSync, rmSync, statSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
8
8
  import { Command, Option } from "commander";
9
- import { AgentPlatform, BaseColdStore, ChunkLru, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
9
+ import { AgentPlatform, BaseColdStore, ChunkLru, ReadinessStatus, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
10
10
  import { AccessLogSource, ApprovalDecision, CiTrustLevel, ContextType, ExecutionJobStatus, ForkPolicy, HoldType, KICI_AGENT_ENV_PREFIX, KNOWN_ROLES, MIN_APPROVAL_EXPIRY_SECONDS, MinimumTrustSchema, OrchestratorMode, PLATFORM_CONNECTED_MODES, PRIVILEGED_ROOT_LABEL, RuntimeFact, SECONDS_PER_HOUR, ScalerBackendType, ScalerEventType, TERMINAL_JOB_STATES, TERMINAL_RUN_STATES, WS_MAX_PAYLOAD_BYTES, accessLogWarmSqlCase, agentLabelOf, assertValidSecretKey, attestationVerifyStatusSchema, canonicalizeLabel, canonicalizeLabels, canonicalizeMatcher, derivePlatformTaints, flattenActor, getAccessLogColdDays, getSecretAuditLogColdDays, matcherSatisfiedBy, minAccessLogWarmDays, minSecretAuditLogWarmDays, parseHostPropertyAssignments, platformToTaints, resolveHeldRunId, resolveRoleLabels, runtimeLabel, scalerAgentLabels, scalerPlatformSchema, secretAuditLogWarmSqlCase, shouldRecordAccess, shouldRecordSecretResolve } from "@kici-dev/engine";
11
11
  import crypto$1, { createDecipheriv, createHash, createHmac, createPublicKey, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
12
12
  import { createInterface } from "node:readline";
@@ -14,17 +14,18 @@ import { sql } from "kysely";
14
14
  import { access, chmod, constants as constants$1, copyFile, link, mkdir, open, readFile, readdir, readlink, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
15
15
  import { parse } from "yaml";
16
16
  import { z } from "zod";
17
+ import { AGENT_DEFAULT_PORT, LOGGER_ENV_VARS, ORCHESTRATOR_DEFAULT_PORT, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
17
18
  import os, { hostname, platform, tmpdir, uptime, default as os$1 } from "node:os";
18
19
  import { getDatabaseCollationDrift, refreshDatabaseCollationVersion, reindexDatabaseConcurrently } from "@kici-dev/shared/db-collation";
19
20
  import { Migrator } from "kysely/migration";
20
21
  import { exec, execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
21
22
  import { promisify } from "node:util";
23
+ import { SERVICE_ENV_FILE_VAR } from "@kici-dev/shared/service-env-file";
22
24
  import "jsonpath-plus";
23
25
  import { makeTempDir, withTempDir } from "@kici-dev/shared/tmp";
24
26
  import { Octokit } from "@octokit/rest";
25
27
  import { createAppAuth } from "@octokit/auth-app";
26
28
  import http, { createServer } from "node:http";
27
- import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
28
29
  import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
29
30
  import { Upload } from "@aws-sdk/lib-storage";
30
31
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -43,6 +44,7 @@ import { CLI_INDEPENDENT_ONLY_OPERATIONS, DASHBOARD_WRITE_OPERATIONS, DASHBOARD_
43
44
  import { CLUSTER_NAME_FORMAT_MESSAGE } from "@kici-dev/engine/protocol/cluster-name";
44
45
  import { BASELINE_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, deleteForwardRules, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, removeIsolationRules, validateNftablesAvailability } from "@kici-dev/shared/net";
45
46
  import { ImagePullPolicy, ensureRuntimeVolume, injectedAgentCommand, pullImageIfMissing, runtimeInjectBind } from "@kici-dev/shared/container-runtime";
47
+ import { isIP } from "node:net";
46
48
  import { StringDecoder } from "node:string_decoder";
47
49
  import { PassThrough } from "node:stream";
48
50
  import Docker from "dockerode";
@@ -254,6 +256,32 @@ var AdminApiClient = class {
254
256
  return this.request("DELETE", `/api/v1/admin/secrets/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`);
255
257
  }
256
258
  /**
259
+ * Create an empty secret scope. A `<backend>:` qualifier selects the
260
+ * backend; an unqualified scope targets the PG backend. Creating a scope
261
+ * that already exists succeeds and changes nothing.
262
+ */
263
+ async createScope(orgId, scope) {
264
+ return this.request("POST", "/api/v1/admin/secrets/scopes", {
265
+ orgId,
266
+ scope
267
+ });
268
+ }
269
+ /**
270
+ * Rename a secret scope inside its backend. The route refuses a move between
271
+ * backends and a rename onto a scope that already exists.
272
+ */
273
+ async renameScope(orgId, oldScope, newScope) {
274
+ return this.request("PUT", "/api/v1/admin/secrets/scopes/rename", {
275
+ orgId,
276
+ oldScope,
277
+ newScope
278
+ });
279
+ }
280
+ /** Delete a secret scope and every secret in it. */
281
+ async deleteScope(orgId, scope) {
282
+ return this.request("DELETE", `/api/v1/admin/secrets/scopes/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}`);
283
+ }
284
+ /**
257
285
  * Create (or upsert) a deployment context. `allowLocalExecution` lets
258
286
  * CLI-initiated / local runs resolve secrets through this context.
259
287
  */
@@ -999,13 +1027,13 @@ var PgSecretStore = class PgSecretStore {
999
1027
  //#endregion
1000
1028
  //#region src/cold-store/load-access-log-range.ts
1001
1029
  /**
1002
- * Read-through helper for `access_log` (Orchestrator) — Phase D.
1030
+ * Read-through helper for `access_log` (Orchestrator).
1003
1031
  *
1004
1032
  * Unlike `audit_log` and `secret_audit_log`, the `access_log` callers
1005
1033
  * (dashboard "Data access" tab, admin HTTP route, `kici-admin
1006
1034
  * access-log list` CLI) all use the same cursor-based pagination
1007
- * already implemented by `AccessLogWriter.query()`. Phase D's design
1008
- * §7 row 11 classifies access_log as "paginated/transparent" — there
1035
+ * already implemented by `AccessLogWriter.query()`. access_log is
1036
+ * "paginated/transparent" — there
1009
1037
  * is NO `--include-archived` flag. The helper merges cold rows
1010
1038
  * automatically when pagination crosses the warm cutoff or the caller
1011
1039
  * supplies a `from` timestamp older than warm.
@@ -1185,9 +1213,9 @@ var init_load_access_log_range = __esmMin((() => {
1185
1213
  //#endregion
1186
1214
  //#region src/cold-store/load-secret-audit-log-range.ts
1187
1215
  /**
1188
- * Read-through helper for `secret_audit_log` (Orchestrator) — Phase D.
1216
+ * Read-through helper for `secret_audit_log` (Orchestrator).
1189
1217
  *
1190
- * Mirrors the Phase B/C reader pattern. Wraps `AuditLogger.query()`'s
1218
+ * Mirrors the execution-table reader pattern. Wraps `AuditLogger.query()`'s
1191
1219
  * existing offset/limit shape; cold-store rows are fetched only when
1192
1220
  * `includeArchived === true`.
1193
1221
  *
@@ -1284,7 +1312,7 @@ var AuditLogger = class {
1284
1312
  * engine's `shouldRecordSecretResolve` helper) since they're written
1285
1313
  * once per JOB-step secret resolution and dwarf every other action's
1286
1314
  * volume. Denied resolves and every other action (`setSecret`,
1287
- * `deleteSecret`, `rotateKey`, `secret-outputs.reveal`) bypass the
1315
+ * `deleteSecret`, the scope writes, `rotateKey`, `secret-outputs.reveal`) bypass the
1288
1316
  * sampler — they are transactional with the mutation and must always
1289
1317
  * land.
1290
1318
  *
@@ -1494,6 +1522,48 @@ async function warnIfContextUnbound(args) {
1494
1522
  if (found && type?.success && found.bindings.length === 0) (args.warn ?? console.error)(unboundContextWarning(args.orgId, args.name, type.data));
1495
1523
  }
1496
1524
  //#endregion
1525
+ //#region src/cli/commands/shared/confirm.ts
1526
+ /**
1527
+ * Yes/no confirmation prompt for the `kici-admin` verbs that change or destroy
1528
+ * state unless the operator passes `--yes`.
1529
+ */
1530
+ /** Message of the error raised when stdin ends before the operator answers. */
1531
+ const NO_CONFIRMATION_ANSWER = "no answer to the confirmation prompt: stdin closed before a line arrived. Pass --yes to run without the prompt.";
1532
+ /**
1533
+ * Write `prompt` and read one line of answer.
1534
+ *
1535
+ * Resolves true when the line is `y` or `yes` (any case, surrounding spaces
1536
+ * ignored) and false for any other line. Rejects with
1537
+ * {@link NO_CONFIRMATION_ANSWER} when stdin ends before a line arrives, as it
1538
+ * does for a script, a cron job or a `< /dev/null` redirect. Resolving false
1539
+ * there would let a verb run without `--yes` exit 0 having done nothing, and
1540
+ * the script that ran it would read that as success.
1541
+ */
1542
+ function confirmPrompt(prompt, io = {
1543
+ input: process.stdin,
1544
+ output: process.stderr
1545
+ }) {
1546
+ const rl = createInterface({
1547
+ input: io.input,
1548
+ output: io.output
1549
+ });
1550
+ return new Promise((resolve, reject) => {
1551
+ let answered = false;
1552
+ const settle = (answer) => {
1553
+ if (answered) return;
1554
+ answered = true;
1555
+ rl.close();
1556
+ const normalized = answer.trim().toLowerCase();
1557
+ resolve(normalized === "y" || normalized === "yes");
1558
+ };
1559
+ rl.once("close", () => {
1560
+ if (!answered) reject(/* @__PURE__ */ new Error(NO_CONFIRMATION_ANSWER));
1561
+ });
1562
+ rl.once("line", settle);
1563
+ rl.question(prompt, settle);
1564
+ });
1565
+ }
1566
+ //#endregion
1497
1567
  //#region src/cli/commands/secret.ts
1498
1568
  function resolveDirectDbUrl$10(explicit) {
1499
1569
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -1658,7 +1728,7 @@ function registerSecretCommands(program, getClient) {
1658
1728
  sec.command("delete <orgId> <scope> <key>").description("Delete a secret").option("--yes", "Skip confirmation prompt").action(async (orgId, scope, key, opts) => {
1659
1729
  try {
1660
1730
  if (!opts.yes) {
1661
- if (!await confirm$4(`Are you sure you want to delete secret '${key}' from scope '${scope}'?`)) {
1731
+ if (!await confirmPrompt(`Are you sure you want to delete secret '${key}' from scope '${scope}'? [y/N] `)) {
1662
1732
  console.log("Aborted.");
1663
1733
  return;
1664
1734
  }
@@ -1670,6 +1740,7 @@ function registerSecretCommands(program, getClient) {
1670
1740
  process.exit(1);
1671
1741
  }
1672
1742
  });
1743
+ registerSecretScopeCommands(sec, getClient);
1673
1744
  sec.command("fix-prefixed-scopes <orgId>").description("Repair PG-backend secret scopes stored with a stale pg: qualifier (direct-DB). Each affected scope is renamed to its bare path, re-encrypting every secret under the corrected AAD. Exits 2 when any scope needs manual repair.").option("--dry-run", "Print the plan and exit without writing").option("--database-url <url>", "Orchestrator DB URL (or KICI_DATABASE_URL)").action(async (orgId, opts) => {
1674
1745
  const dbUrl = resolveDirectDbUrl$10(opts.databaseUrl);
1675
1746
  if (!dbUrl) {
@@ -1720,17 +1791,48 @@ function registerSecretCommands(program, getClient) {
1720
1791
  if (skipped > 0) process.exit(2);
1721
1792
  });
1722
1793
  }
1723
- async function confirm$4(message) {
1724
- const { createInterface } = await import("node:readline");
1725
- const rl = createInterface({
1726
- input: process.stdin,
1727
- output: process.stderr
1794
+ /**
1795
+ * `secret scope create|rename|delete` — the operator path for the dashboard's
1796
+ * scope writes (`secrets.scope.*` in the dashboard-write policy). HTTP only,
1797
+ * like `secret scopes`: each verb calls the orchestrator admin API.
1798
+ */
1799
+ function registerSecretScopeCommands(sec, getClient) {
1800
+ const scope = sec.command("scope").description("Create, rename or delete a secret scope");
1801
+ scope.command("create <orgId> <scope>").description("Create an empty secret scope. A <backend>: qualifier selects the backend; an unqualified scope targets the PG backend. An existing scope stays unchanged.").option("--json", "Emit JSON output").action(async (orgId, scopeName, opts) => {
1802
+ try {
1803
+ const result = await getClient().createScope(orgId, scopeName);
1804
+ if (opts.json) console.log(JSON.stringify(result));
1805
+ else console.log(`Secret scope '${scopeName}' created for org ${orgId}.`);
1806
+ } catch (err) {
1807
+ console.error(`Error: ${toErrorMessage(err)}`);
1808
+ process.exit(1);
1809
+ }
1728
1810
  });
1729
- return new Promise((resolve) => {
1730
- rl.question(`${message} [y/N] `, (answer) => {
1731
- rl.close();
1732
- resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
1733
- });
1811
+ scope.command("rename <orgId> <oldScope> <newScope>").description("Rename a secret scope inside its backend. Refuses a move between backends and a rename onto a scope that already exists.").option("--json", "Emit JSON output").action(async (orgId, oldScope, newScope, opts) => {
1812
+ try {
1813
+ const result = await getClient().renameScope(orgId, oldScope, newScope);
1814
+ if (opts.json) console.log(JSON.stringify(result));
1815
+ else console.log(`Secret scope '${oldScope}' renamed to '${newScope}' for org ${orgId}.`);
1816
+ } catch (err) {
1817
+ console.error(`Error: ${toErrorMessage(err)}`);
1818
+ process.exit(1);
1819
+ }
1820
+ });
1821
+ scope.command("delete <orgId> <scope>").description("Delete a secret scope and every secret in it").option("--yes", "Skip confirmation prompt").option("--json", "Emit JSON output").action(async (orgId, scopeName, opts) => {
1822
+ try {
1823
+ if (!opts.yes) {
1824
+ if (!await confirmPrompt(`Are you sure you want to delete scope '${scopeName}' and every secret in it? [y/N] `)) {
1825
+ console.log("Aborted.");
1826
+ return;
1827
+ }
1828
+ }
1829
+ const result = await getClient().deleteScope(orgId, scopeName);
1830
+ if (opts.json) console.log(JSON.stringify(result));
1831
+ else console.log(`Secret scope '${scopeName}' deleted for org ${orgId}.`);
1832
+ } catch (err) {
1833
+ console.error(`Error: ${toErrorMessage(err)}`);
1834
+ process.exit(1);
1835
+ }
1734
1836
  });
1735
1837
  }
1736
1838
  //#endregion
@@ -1808,7 +1910,7 @@ function formatAuditTable(entries) {
1808
1910
  ].join("\n");
1809
1911
  }
1810
1912
  function registerAuditCommands(program, getClient) {
1811
- program.command("audit").description("Query the secrets audit log").option("--context <name>", "Filter by context name").option("--routing-key <rk>", "Filter by routing key (required for cold-store scan)").option("--action <action>", "Filter by action type").option("--from <date>", "From date (ISO 8601)").option("--to <date>", "To date (ISO 8601)").option("--limit <n>", "Max entries to return", "100").option("--offset <n>", "Offset for pagination").option("--include-archived", "Include rows from cold storage (Phase D)", false).action(async (opts) => {
1913
+ program.command("audit").description("Query the secrets audit log").option("--context <name>", "Filter by context name").option("--routing-key <rk>", "Filter by routing key (required for cold-store scan)").option("--action <action>", "Filter by action type").option("--from <date>", "From date (ISO 8601)").option("--to <date>", "To date (ISO 8601)").option("--limit <n>", "Max entries to return", "100").option("--offset <n>", "Offset for pagination").option("--include-archived", "Include rows from cold storage", false).action(async (opts) => {
1812
1914
  try {
1813
1915
  const entries = await getClient().queryAudit({
1814
1916
  contextName: opts.context,
@@ -2206,7 +2308,7 @@ var init_schema = __esmMin((() => {
2206
2308
  instanceId: z.string().default(() => randomUUID()),
2207
2309
  mode: OrchestratorMode.default("platform"),
2208
2310
  databaseUrl: z.string().default(""),
2209
- port: z.coerce.number().default(4e3),
2311
+ port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
2210
2312
  basePath: z.string().default("/"),
2211
2313
  tlsCertPath: z.string().optional(),
2212
2314
  scalerConfigPath: z.string().optional(),
@@ -3643,12 +3745,10 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
3643
3745
  * `kici-admin cold-store list-chunks` CLI can report totals without
3644
3746
  * issuing S3 LIST calls (expensive + rate-limited).
3645
3747
  *
3646
- * Phase A creates the table empty; the cold-store archiver
3647
- * (Phase B+) updates it transactionally with every successful chunk
3748
+ * The table starts empty; the cold-store archiver
3749
+ * updates it transactionally with every successful chunk
3648
3750
  * write via `INSERT ... ON CONFLICT (db, table_name, tenant_id) DO
3649
3751
  * UPDATE SET chunk_count = chunk_count + EXCLUDED.chunk_count, ...`.
3650
- *
3651
- * sections 5 and 8.
3652
3752
  */
3653
3753
  async function up$146(db) {
3654
3754
  await sql`
@@ -3681,12 +3781,12 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
3681
3781
  * `execution_runs` / `execution_jobs` / `execution_steps` cold-store
3682
3782
  * schema additions.
3683
3783
  *
3684
- * Phase C of the cold-storage archival system. See
3784
+ * Part of the cold-storage archival system.
3685
3785
  *
3686
3786
  * Each table gets `archived_at TIMESTAMPTZ NULL` and
3687
3787
  * `archive_object_key TEXT NULL` (set inside the archive tx before the
3688
- * DELETE; survivors carry NULL — same pattern as Phase B's migration
3689
- * 004 for `run_events`).
3788
+ * DELETE; survivors carry NULL — the same pattern `run_events` carries
3789
+ * in the Platform's schema).
3690
3790
  *
3691
3791
  * Tenant column denormalization: the orchestrator side partitions by
3692
3792
  * `routing_key`, but only `execution_runs` carries the column today.
@@ -3695,7 +3795,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
3695
3795
  * denormalize `routing_key` onto both tables and backfill from the
3696
3796
  * existing run rows. Backfill is one-shot inside this migration. New
3697
3797
  * inserts must populate the column going forward (enforced at the
3698
- * application layer in the same phase).
3798
+ * application layer).
3699
3799
  *
3700
3800
  * The denormalized column is left NULLable for safety: if an insert
3701
3801
  * site is missed in the rollout, the row still lands in PG; the
@@ -3788,13 +3888,12 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3788
3888
  * `secret_audit_log` and `access_log` cold-store schema additions, plus
3789
3889
  * the removal of `access_log`'s `expires_at`-based hard delete.
3790
3890
  *
3791
- * Phase D of the cold-storage archival system. See
3891
+ * Part of the cold-storage archival system.
3792
3892
  *
3793
3893
  * Both tables get the same `archived_at TIMESTAMPTZ NULL` /
3794
- * `archive_object_key TEXT NULL` pair that Phase B added to `run_events`
3795
- * (`packages/platform/src/db/migrations/004_run_events_archived_at.ts`)
3796
- * and Phase C added to `execution_runs` / `execution_jobs` /
3797
- * `execution_steps` (`006_runs_jobs_steps_archived_at.ts`). Set inside
3894
+ * `archive_object_key TEXT NULL` pair that `run_events` carries
3895
+ * (in the Platform's schema) and that `execution_runs` / `execution_jobs` /
3896
+ * `execution_steps` got in `006_runs_jobs_steps_archived_at.ts`. Set inside
3798
3897
  * the archive transaction before the DELETE; survivors carry NULL.
3799
3898
  *
3800
3899
  * `secret_audit_log` gets a composite
@@ -3808,7 +3907,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3808
3907
  * (range scans use either direction).
3809
3908
  *
3810
3909
  * Drops `access_log.expires_at` column and `access_log_expires_idx`.
3811
- * Until Phase D, `expires_at` powered a 90-day hard-delete sweep in
3910
+ * Before this migration, `expires_at` powered a 90-day hard-delete sweep in
3812
3911
  * `packages/orchestrator/src/audit/access-log.ts:cleanup` (called from
3813
3912
  * `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 5). With
3814
3913
  * cold-store, `access_log` rows older than 30 days are archived rather
@@ -3816,11 +3915,9 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3816
3915
  * column would mean either (a) double-evicting rows or (b) silently
3817
3916
  * mismatching the new contract. Drop it.
3818
3917
  *
3819
- * Per the project's "no backward compatibility (pre-release)" rule,
3820
3918
  * `down()` recreates `expires_at` with a default of `now() + 90 days`
3821
3919
  * — best effort; rows inserted between `up()` and a hypothetical
3822
- * `down()` would not have meaningful retention bounds. Acceptable for
3823
- * staging.
3920
+ * `down()` would not have meaningful retention bounds.
3824
3921
  */
3825
3922
  async function up$144(db) {
3826
3923
  await sql`
@@ -3878,13 +3975,11 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
3878
3975
  * `event_log` cold-store schema additions plus removal of the
3879
3976
  * `expires_at`-based 30-day hard delete.
3880
3977
  *
3881
- * Phase E of the cold-storage archival system. See
3882
- * §10
3883
- * Phase E.
3978
+ * Part of the cold-storage archival system.
3884
3979
  *
3885
3980
  * Adds the same `archived_at TIMESTAMPTZ NULL` /
3886
- * `archive_object_key TEXT NULL` pair Phase D added on this side
3887
- * (`007_audit_logs_archived_at.ts`). Set inside the archive
3981
+ * `archive_object_key TEXT NULL` pair
3982
+ * `007_audit_logs_archived_at.ts` added on this side. Set inside the archive
3888
3983
  * transaction before the DELETE; survivors carry NULL.
3889
3984
  *
3890
3985
  * Adds `idx_event_log_routing_received` on `(routing_key, received_at)`
@@ -3895,7 +3990,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
3895
3990
  * archiver scan.
3896
3991
  *
3897
3992
  * Drops `event_log.expires_at` and the matching
3898
- * `event_log_expires_at_idx` index. Until Phase E, `expires_at`
3993
+ * `event_log_expires_at_idx` index. Before this migration, `expires_at`
3899
3994
  * powered the 30-day hard-delete sweep in
3900
3995
  * `packages/orchestrator/src/webhook/event-log.ts:cleanup`, called
3901
3996
  * from `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4
@@ -3978,7 +4073,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
3978
4073
  up: () => up$141
3979
4074
  });
3980
4075
  /**
3981
- * Cold-store chunk index — Phase 2 (cold-store purge).
4076
+ * Cold-store chunk index (cold-store purge).
3982
4077
  *
3983
4078
  * One row per archived chunk that the GC sweep can later purge from S3.
3984
4079
  * Inserted inside the same transaction as `markArchivedAndDelete` so a
@@ -4003,7 +4098,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
4003
4098
  * uses this to issue `DeleteObject` directly without recomputing the
4004
4099
  * key from the prefix.
4005
4100
  *
4006
- * Pre-Phase-2 chunks (v1 manifest) are NOT in this table — they live
4101
+ * V1-manifest chunks are NOT in this table — they live
4007
4102
  * forever. Adapters that don't opt into per-bucket archival via
4008
4103
  * `coldTtlDays` don't insert here either.
4009
4104
  */
@@ -4119,13 +4214,11 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
4119
4214
  * nothing and both `INSERT`s succeeded, leaving 2+ ACTIVE rows for the same
4120
4215
  * `instance_id`. The peer's local credential file then ended up holding a
4121
4216
  * hash that a later concurrent `save()` had revoked, causing an endless
4122
- * "Peer HMAC proof invalid" loop. See
4123
- * for the
4124
- * full incident write-up (DB rows + log evidence from real staging deploys).
4217
+ * "Peer HMAC proof invalid" loop.
4125
4218
  *
4126
4219
  * The `up()` first dedupes any pre-existing duplicates (revoke all but the
4127
4220
  * newest active row per `instance_id`) — required because earlier migrations
4128
- * predate the constraint and existing staging DBs already have the dupes.
4221
+ * predate the constraint and existing databases may already hold the dupes.
4129
4222
  *
4130
4223
  * `down()` only drops the index; it does NOT undo the dedupe (there's no
4131
4224
  * safe way to recreate revoked rows, and the dedupe is monotonic).
@@ -4463,8 +4556,8 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
4463
4556
  * Operators flip this on a per-org basis to permit `http://` (non-HTTPS)
4464
4557
  * registry URLs in workflow `registries:` declarations. By default only
4465
4558
  * `https://` registries are accepted, with an automatic exemption for
4466
- * loopback / `*.local` / private link-local hosts so dev fixtures
4467
- * (Verdaccio at `http://verdaccio.local:4873`) keep working.
4559
+ * loopback / `*.local` / private link-local hosts so a local registry
4560
+ * (for example `http://registry.local:4873`) keeps working.
4468
4561
  *
4469
4562
  * Idempotent: a re-run on a DB that already has the column is a no-op.
4470
4563
  */
@@ -4509,8 +4602,7 @@ var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
4509
4602
  * They are orphans by design — once Platform's migration runs, the
4510
4603
  * dashboard queries the orchestrator API with the new prefixed IDs
4511
4604
  * and the orchestrator's `WHERE org_id = $1` filter naturally hides
4512
- * the old rows. Staging E2E regenerates fresh rows during the next
4513
- * test run.
4605
+ * the old rows. New activity writes fresh rows under the prefixed IDs.
4514
4606
  *
4515
4607
  * Tables widened: `access_log`, `environment_bindings`,
4516
4608
  * `environment_source_overrides`, `environment_variables`,
@@ -4552,7 +4644,7 @@ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
4552
4644
  * orphaned and regenerated on the next deploy. That works for
4553
4645
  * historical artefacts (execution_runs, event_log, access_log) but
4554
4646
  * NOT for tenant-state tables the orchestrator needs to recognise the
4555
- * staging org at routing time (`sources`, `generic_webhook_sources`,
4647
+ * tenant's org at routing time (`sources`, `generic_webhook_sources`,
4556
4648
  * `workflow_registrations`, `org_settings`) and the env-config tables
4557
4649
  * referenced from runtime requests (`environments`,
4558
4650
  * `environment_bindings`, `environment_source_overrides`,
@@ -5807,7 +5899,7 @@ var _045_host_properties_exports = /* @__PURE__ */ __exportAll({
5807
5899
  * the separate queryable host-vars dimension.
5808
5900
  *
5809
5901
  * Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
5810
- * the column is a no-op. Staging data is preserved (additive column).
5902
+ * the column is a no-op. Existing data is preserved (additive column).
5811
5903
  */
5812
5904
  async function up$106(db) {
5813
5905
  await sql`ALTER TABLE public.host_roster
@@ -5833,7 +5925,7 @@ var _046_join_token_consumed_by_instance_exports = /* @__PURE__ */ __exportAll({
5833
5925
  * join token already in its env — no operator action, no cluster redeploy.
5834
5926
  *
5835
5927
  * Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
5836
- * the column is a no-op. Staging data is preserved (additive nullable column).
5928
+ * the column is a no-op. Existing data is preserved (additive nullable column).
5837
5929
  */
5838
5930
  async function up$105(db) {
5839
5931
  await sql`ALTER TABLE public.join_tokens
@@ -5859,8 +5951,7 @@ var _047_needs_run_on_exports = /* @__PURE__ */ __exportAll({
5859
5951
  * - Drop `if_failed`.
5860
5952
  *
5861
5953
  * Idempotent: column-exists guarded so re-running on a DB that already has
5862
- * `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved
5863
- * (staging data is not dropped).
5954
+ * `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved.
5864
5955
  */
5865
5956
  const ALL_TERMINAL_JSON = JSON.stringify([
5866
5957
  "success",
@@ -5932,7 +6023,7 @@ var _048_host_reboot_pending_exports = /* @__PURE__ */ __exportAll({
5932
6023
  * (3) clears on the next reconnect (down-then-up), releasing the held job. NULL
5933
6024
  * = no reboot pending.
5934
6025
  *
5935
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6026
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
5936
6027
  * preserved.
5937
6028
  */
5938
6029
  async function up$103(db) {
@@ -5955,7 +6046,7 @@ var _049_held_runs_payload_exports = /* @__PURE__ */ __exportAll({
5955
6046
  * the dashboard approval queue and the CLI render the computed diff the
5956
6047
  * operator approves. NULL for every non-drift hold.
5957
6048
  *
5958
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6049
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
5959
6050
  * preserved.
5960
6051
  */
5961
6052
  async function up$102(db) {
@@ -5981,7 +6072,7 @@ var _050_sources_slug_exports = /* @__PURE__ */ __exportAll({
5981
6072
  * hasn't populated it yet (manual `--app-id` flow whose initial fetch failed,
5982
6073
  * or a row created before the rollout).
5983
6074
  *
5984
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6075
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
5985
6076
  * preserved.
5986
6077
  */
5987
6078
  async function up$101(db) {
@@ -6009,7 +6100,7 @@ var _051_binding_host_pattern_exports = /* @__PURE__ */ __exportAll({
6009
6100
  * distinct per-host selectors.
6010
6101
  *
6011
6102
  * Idempotent: re-running on a DB that already has the column / index is a no-op.
6012
- * Additive — staging data is preserved.
6103
+ * Additive — existing data is preserved.
6013
6104
  */
6014
6105
  async function up$100(db) {
6015
6106
  await sql`ALTER TABLE public.environment_bindings
@@ -6045,7 +6136,7 @@ var _052_host_reach_metadata_exports = /* @__PURE__ */ __exportAll({
6045
6136
  *
6046
6137
  * All nullable: a host with no reach metadata simply cannot be bootstrapped and
6047
6138
  * behaves exactly as before. Idempotent (`ADD COLUMN IF NOT EXISTS`); additive,
6048
- * so staging data is preserved.
6139
+ * so existing data is preserved.
6049
6140
  */
6050
6141
  async function up$99(db) {
6051
6142
  await sql`ALTER TABLE public.host_roster
@@ -6077,7 +6168,7 @@ var _053_agent_token_single_use_exports = /* @__PURE__ */ __exportAll({
6077
6168
  * default for every existing static / ephemeral token, which stay reusable
6078
6169
  * until expiry).
6079
6170
  *
6080
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6171
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6081
6172
  * preserved.
6082
6173
  */
6083
6174
  async function up$98(db) {
@@ -6102,7 +6193,7 @@ var _054_local_working_tree_exports = /* @__PURE__ */ __exportAll({
6102
6193
  * lock file). The dashboard renders a "Local machine" badge for these runs
6103
6194
  * and avoids building an external repository link from the repo identifier.
6104
6195
  *
6105
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so staging
6196
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so existing
6106
6197
  * data is preserved.
6107
6198
  */
6108
6199
  async function up$97(db) {
@@ -6130,7 +6221,7 @@ var _055_agent_token_mandatory_labels_exports = /* @__PURE__ */ __exportAll({
6130
6221
  * agent accepts any job its advertised labels match), so every existing token
6131
6222
  * stays unconfined until re-minted.
6132
6223
  *
6133
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6224
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6134
6225
  * preserved.
6135
6226
  */
6136
6227
  async function up$96(db) {
@@ -6157,7 +6248,7 @@ var _056_execution_jobs_environments_exports = /* @__PURE__ */ __exportAll({
6157
6248
  * fully-resolved list when a deferred-init agent eval resolves dynamic
6158
6249
  * elements. NULL = the job binds no environment.
6159
6250
  *
6160
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6251
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6161
6252
  * preserved.
6162
6253
  */
6163
6254
  async function up$95(db) {
@@ -6468,7 +6559,7 @@ var _064_execution_jobs_skipped_environments_exports = /* @__PURE__ */ __exportA
6468
6559
  * - `execution_jobs.env_warning text NULL` — the user-visible warning naming the
6469
6560
  * skipped environments, surfaced on the dashboard run view. NULL = no warning.
6470
6561
  *
6471
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6562
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6472
6563
  * preserved.
6473
6564
  */
6474
6565
  async function up$87(db) {
@@ -10851,11 +10942,10 @@ var AccessLogWriter = class {
10851
10942
  }).execute();
10852
10943
  }
10853
10944
  /**
10854
- * Phase D: delegates to `loadAccessLogRange` so cold-store rows are
10945
+ * Delegates to `loadAccessLogRange` so cold-store rows are
10855
10946
  * merged transparently when pagination crosses the warm cutoff. The
10856
10947
  * caller-visible `{items, nextCursor}` shape is preserved. NO
10857
- * --include-archived flag — access_log is "paginated/transparent"
10858
- * per design §7 row 11.
10948
+ * --include-archived flag — access_log is "paginated/transparent".
10859
10949
  */
10860
10950
  async query(filter) {
10861
10951
  const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
@@ -11658,7 +11748,7 @@ function uninstallBackupTimer(platform, config, io = defaultTimerIo) {
11658
11748
  }
11659
11749
  //#endregion
11660
11750
  //#region src/cli/service/types.ts
11661
- var DEFAULT_RESTART_POLICY;
11751
+ var DEFAULT_RESTART_POLICY, SERVICE_TEXT;
11662
11752
  var init_types$1 = __esmMin((() => {
11663
11753
  DEFAULT_RESTART_POLICY = {
11664
11754
  enabled: true,
@@ -11671,6 +11761,16 @@ var init_types$1 = __esmMin((() => {
11671
11761
  maxRetries: 5,
11672
11762
  windowSeconds: 300
11673
11763
  };
11764
+ SERVICE_TEXT = {
11765
+ orchestrator: {
11766
+ displayName: "KiCI Orchestrator",
11767
+ description: "KiCI CI/CD workflow orchestrator service"
11768
+ },
11769
+ agent: {
11770
+ displayName: "KiCI Agent",
11771
+ description: "KiCI CI/CD workflow execution agent service"
11772
+ }
11773
+ };
11674
11774
  }));
11675
11775
  //#endregion
11676
11776
  //#region src/cli/service/privilege.ts
@@ -11747,6 +11847,148 @@ var init_privilege = __esmMin((() => {
11747
11847
  init_platform_detect();
11748
11848
  }));
11749
11849
  //#endregion
11850
+ //#region src/cli/service/windows-acl.ts
11851
+ /**
11852
+ * Restricting a Windows service's env file to the service account and to
11853
+ * Administrators.
11854
+ *
11855
+ * Node maps a file mode onto Windows by toggling the read-only attribute and
11856
+ * changes no ACL, so ENV_FILE_MODE protects nothing there. The instance folder
11857
+ * under C:\ProgramData\kici\ inherits read access for BUILTIN\Users from
11858
+ * C:\ProgramData. Its ACL is replaced with full control for LocalSystem (the
11859
+ * account the shawl service runs as) and Administrators, inherited by
11860
+ * everything created in it: the env file, the temporary file a secure write
11861
+ * stages it through, and the service logs are unreadable to other accounts
11862
+ * from the moment they exist.
11863
+ *
11864
+ * Every call passes `/L` and none recurses, so icacls acts on the folder and on
11865
+ * the env file themselves and never on what a link there points at. A
11866
+ * recursive `/reset` or `/setowner` descends into the target of a junction or
11867
+ * of a symbolic link, even with `/L`, and rewrites the ACL of the files there.
11868
+ * The new folder ACL still reaches every entry that inherits from the folder.
11869
+ */
11870
+ /**
11871
+ * The icacls in the Windows system folder, which also holds the cmd.exe that
11872
+ * COMSPEC names. A bare `icacls` would also be looked up in the current
11873
+ * folder, and the install runs elevated.
11874
+ */
11875
+ function icaclsPath() {
11876
+ const comspec = process.env.COMSPEC;
11877
+ const systemDir = comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? path.win32.dirname(comspec) : "C:\\Windows\\System32";
11878
+ return path.win32.join(systemDir, "icacls.exe");
11879
+ }
11880
+ /**
11881
+ * The icacls arguments that remove every inherited ACE from `dir` and grant
11882
+ * full control, inherited by files and folders, to LocalSystem and
11883
+ * Administrators. SIDs rather than names, so a localized Windows resolves them.
11884
+ */
11885
+ function restrictDirArgs(dir) {
11886
+ return [
11887
+ dir,
11888
+ "/inheritance:r",
11889
+ "/grant:r",
11890
+ `*${LOCAL_SYSTEM_SID}:(OI)(CI)F`,
11891
+ `*${ADMINISTRATORS_SID}:(OI)(CI)F`,
11892
+ "/L",
11893
+ "/Q"
11894
+ ];
11895
+ }
11896
+ /** The icacls arguments that reset `file` to inherit only from its folder. */
11897
+ function resetFileArgs(file) {
11898
+ return [
11899
+ file,
11900
+ "/reset",
11901
+ "/L",
11902
+ "/Q"
11903
+ ];
11904
+ }
11905
+ /**
11906
+ * Restrict the folder that holds `envFilePath`, then reset an existing env
11907
+ * file to inherit only from it, which also drops any explicit ACE on the file.
11908
+ * Run it before the first secret is written into the folder.
11909
+ */
11910
+ function restrictEnvFileAccess(envFilePath, run = runIcacls, exists = fs.existsSync) {
11911
+ const dir = path.win32.dirname(envFilePath);
11912
+ try {
11913
+ run(restrictDirArgs(dir));
11914
+ if (exists(envFilePath)) run(resetFileArgs(envFilePath));
11915
+ } catch (err) {
11916
+ throw new Error(`could not restrict access to ${dir}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
11917
+ }
11918
+ }
11919
+ var LOCAL_SYSTEM_SID, ADMINISTRATORS_SID, runIcacls;
11920
+ var init_windows_acl = __esmMin((() => {
11921
+ LOCAL_SYSTEM_SID = "S-1-5-18";
11922
+ ADMINISTRATORS_SID = "S-1-5-32-544";
11923
+ runIcacls = (args) => {
11924
+ execFileSync(icaclsPath(), args, { stdio: "pipe" });
11925
+ };
11926
+ }));
11927
+ //#endregion
11928
+ //#region src/cli/service/windows-env-file.ts
11929
+ /**
11930
+ * Whether a Windows service launch runs a release that reads KICI_ENV_FILE.
11931
+ *
11932
+ * A Windows service is registered with the path of its env file in
11933
+ * KICI_ENV_FILE, never with the values in the file: any local account reads the
11934
+ * command line of a service with `sc.exe qc`. A release from before
11935
+ * KICI_ENV_FILE ignores the path and starts without its configuration, and the
11936
+ * only registration that would run it puts the values back on the command line.
11937
+ * So every command that registers a Windows service, or switches one to another
11938
+ * release, refuses such a release before it changes anything.
11939
+ */
11940
+ /** Whether `file` holds `marker`, or undefined when it cannot be read. */
11941
+ function fileHolds(file, marker) {
11942
+ let content;
11943
+ try {
11944
+ content = fs.readFileSync(file);
11945
+ } catch {
11946
+ return;
11947
+ }
11948
+ return content.includes(marker);
11949
+ }
11950
+ /**
11951
+ * The file that names a launch's release: the npm entry among its arguments,
11952
+ * or else the executable itself.
11953
+ */
11954
+ function launchedRelease(launch) {
11955
+ return launch.args?.find((arg) => PACKAGE_ENTRY.test(arg)) ?? launch.executablePath;
11956
+ }
11957
+ /**
11958
+ * Whether the process a Windows service launch starts reads KICI_ENV_FILE.
11959
+ *
11960
+ * A KiCI package runs as `<dir>\<name>.cmd` beside its bundle
11961
+ * `<dir>\lib\<name>.cjs`, which names KICI_ENV_FILE when the release reads it.
11962
+ * An npm install runs node with the `@kici-dev/<component>/dist/<entry>.js`
11963
+ * entry, which then imports the env-file loader. Any other launch, such as a
11964
+ * batch file with no KiCI bundle beside it or a custom executable, is taken to
11965
+ * read it.
11966
+ */
11967
+ function launchReadsEnvFile(launch) {
11968
+ const { executablePath } = launch;
11969
+ if (/\.(cmd|bat)$/i.test(executablePath)) {
11970
+ const { dir, name } = path.win32.parse(executablePath);
11971
+ const sep = executablePath.includes("\\") ? "\\" : "/";
11972
+ return fileHolds(`${dir}${sep}lib${sep}${name}.cjs`, BUNDLE_MARKER) ?? true;
11973
+ }
11974
+ const entry = launchedRelease(launch);
11975
+ return PACKAGE_ENTRY.test(entry) ? fileHolds(entry, ENTRY_MARKER) ?? true : true;
11976
+ }
11977
+ /**
11978
+ * Why a Windows service may not run `release`, a release from before
11979
+ * KICI_ENV_FILE, and what the operator can do instead.
11980
+ */
11981
+ function envFileRefusal(release, envFilePath) {
11982
+ return `${release} predates KICI_ENV_FILE, so a Windows service can run it only with the values of ${envFilePath} on its command line, where any local account can read them with sc.exe qc. Use a release that reads KICI_ENV_FILE. To run that release anyway, uninstall the service and install it with the kici-admin of that release.`;
11983
+ }
11984
+ var ENV_FILE_LOADER_EXPORT, BUNDLE_MARKER, ENTRY_MARKER, PACKAGE_ENTRY;
11985
+ var init_windows_env_file = __esmMin((() => {
11986
+ ENV_FILE_LOADER_EXPORT = "./load-service-env-file";
11987
+ BUNDLE_MARKER = SERVICE_ENV_FILE_VAR;
11988
+ ENTRY_MARKER = "@kici-dev/shared/load-service-env-file";
11989
+ PACKAGE_ENTRY = /[/\\]@kici-dev[/\\](?:orchestrator|agent)[/\\]dist[/\\](?:server|standalone)\.js$/;
11990
+ }));
11991
+ //#endregion
11750
11992
  //#region src/cli/service/instance/types.ts
11751
11993
  /** Narrow an arbitrary string to a Component. */
11752
11994
  function isComponent(value) {
@@ -11757,6 +11999,81 @@ var init_types = __esmMin((() => {
11757
11999
  COMPONENTS = ["orchestrator", "agent"];
11758
12000
  }));
11759
12001
  //#endregion
12002
+ //#region src/cli/service/image-digests.ts
12003
+ /**
12004
+ * Resolves the customer installer's image reference from the digest record
12005
+ * shipped with this package (installer-image-digests.json at the package root).
12006
+ *
12007
+ * Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
12008
+ * the image hash — a registry that serves substituted bits fails the pull. When
12009
+ * the record is missing (a dev tree that never ran a release) or lacks the
12010
+ * image, falls back to the moving `:latest` tag and warns.
12011
+ *
12012
+ * The record resolves relative to this module's directory and works for both
12013
+ * layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
12014
+ * the package root) and the single-file bundle (`dist/cli.js` → one dir up).
12015
+ */
12016
+ /**
12017
+ * Climb from a starting directory looking for installer-image-digests.json.
12018
+ * Works for the source layout (src/cli/service → 3 up) and the single-file
12019
+ * bundle layout (dist → 1 up).
12020
+ */
12021
+ function findRecordFile(startDir) {
12022
+ let dir = startDir;
12023
+ for (let i = 0; i < 6; i++) {
12024
+ const candidate = path.join(dir, RECORD_FILENAME);
12025
+ if (existsSync(candidate)) return candidate;
12026
+ const parent = path.dirname(dir);
12027
+ if (parent === dir) break;
12028
+ dir = parent;
12029
+ }
12030
+ return null;
12031
+ }
12032
+ /** The published image a component runs, whatever the service is named. */
12033
+ function installerImageName(component) {
12034
+ return `kici-${component}`;
12035
+ }
12036
+ /**
12037
+ * The recorded release and digest of an installer image, or null when the
12038
+ * record is missing, unreadable, or does not hold the image.
12039
+ */
12040
+ function recordedImage(name, opts) {
12041
+ const file = opts.filePath ?? findRecordFile(import.meta.dirname);
12042
+ if (!file || !existsSync(file)) return null;
12043
+ try {
12044
+ const rec = JSON.parse(readFileSync(file, "utf8"));
12045
+ const digest = rec.images?.[name];
12046
+ return digest && rec.version ? {
12047
+ version: rec.version,
12048
+ digest
12049
+ } : null;
12050
+ } catch {
12051
+ return null;
12052
+ }
12053
+ }
12054
+ /**
12055
+ * The release {@link resolveImageRef} pins `name` at, or null when it falls
12056
+ * back to the moving `:latest` tag.
12057
+ */
12058
+ function pinnedImageVersion(name, opts = {}) {
12059
+ return recordedImage(name, opts)?.version ?? null;
12060
+ }
12061
+ /**
12062
+ * Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
12063
+ * image. Falls back to `:latest` (with a warning) when no recorded digest exists.
12064
+ */
12065
+ function resolveImageRef(name, opts = {}) {
12066
+ const pinned = recordedImage(name, opts);
12067
+ if (pinned) return `${QUAY_PREFIX}/${name}:${pinned.version}@${pinned.digest}`;
12068
+ console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
12069
+ return `${QUAY_PREFIX}/${name}:latest`;
12070
+ }
12071
+ var QUAY_PREFIX, RECORD_FILENAME;
12072
+ var init_image_digests = __esmMin((() => {
12073
+ QUAY_PREFIX = "quay.io/kici-dev";
12074
+ RECORD_FILENAME = "installer-image-digests.json";
12075
+ }));
12076
+ //#endregion
11760
12077
  //#region src/cli/service/instance/manifest.ts
11761
12078
  /**
11762
12079
  * Instance manifest — the single source of truth for a folder-anchored
@@ -11847,6 +12164,90 @@ function resolveVersionFromLaunchSpec(spec, component) {
11847
12164
  }
11848
12165
  return null;
11849
12166
  }
12167
+ /**
12168
+ * The version of the launcher a service definition starts, or null when the
12169
+ * launch command does not show one.
12170
+ *
12171
+ * An npm-installed entry script names its version in its package.json
12172
+ * ({@link resolveVersionFromLaunchSpec}). A launcher under the instance's
12173
+ * install base resolves into the `<component>-<version>` folder an archive
12174
+ * upgrade extracted: through the `<component>` symlink on Linux and macOS, or
12175
+ * directly where Windows registers it. A `--binary` anywhere else shows none.
12176
+ */
12177
+ function resolveLauncherVersion(spec, component, installBase) {
12178
+ const fromPackage = resolveVersionFromLaunchSpec(spec, component);
12179
+ if (fromPackage) return fromPackage;
12180
+ let base;
12181
+ try {
12182
+ base = fs.realpathSync(installBase);
12183
+ } catch {
12184
+ return null;
12185
+ }
12186
+ const prefix = `${component}-`;
12187
+ for (const token of [spec.execPath, ...spec.args]) {
12188
+ if (!path.isAbsolute(token)) continue;
12189
+ let real;
12190
+ try {
12191
+ real = fs.realpathSync(token);
12192
+ } catch {
12193
+ continue;
12194
+ }
12195
+ const [folder = "", ...rest] = path.relative(base, real).split(path.sep);
12196
+ if (rest.length > 0 && folder.startsWith(prefix) && folder.length > prefix.length) return folder.slice(prefix.length);
12197
+ }
12198
+ return null;
12199
+ }
12200
+ /**
12201
+ * Write the manifest `install` built into `instanceDir`.
12202
+ *
12203
+ * A first install writes `fresh` as it is. A re-install of the same instance
12204
+ * (the folder holds a readable manifest of this component and name) updates
12205
+ * that manifest: it keeps what the instance recorded over time — `createdAt`,
12206
+ * the `migrationHeads` the rollback schema guard reads, and any field a newer
12207
+ * CLI wrote — and takes every other field from `fresh`. Its `kiciVersion` is
12208
+ * the version of the launcher `launch` starts; when the launcher shows none,
12209
+ * the recorded version stands, because the CLI that ran `install` need not
12210
+ * be the version the service runs. A compose service runs the image its
12211
+ * compose file pins, whatever `launch` names, so a compose re-install records
12212
+ * the release of that image (`opts.digestRecordPath` overrides the digest
12213
+ * record it reads, for tests).
12214
+ *
12215
+ * A manifest that cannot be read, or that names another instance, is
12216
+ * replaced, and the result carries a warning.
12217
+ */
12218
+ function writeInstallManifest(instanceDir, fresh, launch, opts = {}) {
12219
+ let existing;
12220
+ try {
12221
+ existing = readManifest(instanceDir, fresh.component);
12222
+ } catch (err) {
12223
+ return {
12224
+ file: writeManifest(instanceDir, fresh),
12225
+ manifest: fresh,
12226
+ warning: `${err.message}. install replaced it, so the migration heads it recorded, if any, are lost.`
12227
+ };
12228
+ }
12229
+ if (!existing) return {
12230
+ file: writeManifest(instanceDir, fresh),
12231
+ manifest: fresh
12232
+ };
12233
+ if (existing.name !== fresh.name) return {
12234
+ file: writeManifest(instanceDir, fresh),
12235
+ manifest: fresh,
12236
+ warning: `${manifestPath(instanceDir, fresh.component)} described the instance "${existing.name}". It now describes "${fresh.name}".`
12237
+ };
12238
+ const launcherVersion = fresh.platform === "compose" ? pinnedImageVersion(installerImageName(fresh.component), { filePath: opts.digestRecordPath }) : resolveLauncherVersion(launch, fresh.component, fresh.installBase);
12239
+ const manifest = {
12240
+ ...existing,
12241
+ ...fresh,
12242
+ createdAt: existing.createdAt,
12243
+ kiciVersion: launcherVersion ?? existing.kiciVersion
12244
+ };
12245
+ if (existing.migrationHeads) manifest.migrationHeads = existing.migrationHeads;
12246
+ return {
12247
+ file: writeManifest(instanceDir, manifest),
12248
+ manifest
12249
+ };
12250
+ }
11850
12251
  function resolveNpmInstallTarget(spec, component, opts) {
11851
12252
  const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
11852
12253
  const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
@@ -11882,6 +12283,7 @@ function firstGlobalPackage(entry) {
11882
12283
  }
11883
12284
  var REQUIRED_FIELDS;
11884
12285
  var init_manifest = __esmMin((() => {
12286
+ init_image_digests();
11885
12287
  REQUIRED_FIELDS = [
11886
12288
  "component",
11887
12289
  "name",
@@ -12449,6 +12851,43 @@ var init_systemd = __esmMin((() => {
12449
12851
  };
12450
12852
  }));
12451
12853
  //#endregion
12854
+ //#region src/cli/service/shutdown-grace.ts
12855
+ /**
12856
+ * Seconds an init system must wait after SIGTERM before it SIGKILLs the service.
12857
+ *
12858
+ * The runtime defaults are shorter than either component's own graceful-shutdown
12859
+ * budget: Compose waits 10s, launchd's default ExitTimeOut is 5s, and shawl's
12860
+ * default `--stop-timeout` on Windows is 3s. A teardown that
12861
+ * takes longer is killed partway through: the orchestrator never broadcasts
12862
+ * `peer.leaving` and never closes its agent sockets, and the agent never
12863
+ * finishes draining. That window is reached exactly when it matters most — the
12864
+ * orchestrator's scaler stops each managed agent with its own grace, so an
12865
+ * orchestrator with agents in flight routinely needs more than 10s.
12866
+ *
12867
+ * Each value is its component's force-exit budget plus headroom, so the
12868
+ * process always reaches its own timer first and the runtime's kill stays the
12869
+ * last resort it is meant to be:
12870
+ * - orchestrator: 30s (`setupGracefulShutdown` default) plus up to 2s of log
12871
+ * file flush -> 45s
12872
+ * - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
12873
+ *
12874
+ * An unrecognised component gets the larger value: over-waiting costs a few
12875
+ * seconds on shutdown, under-waiting corrupts it.
12876
+ */
12877
+ function shutdownGraceSeconds(config) {
12878
+ return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
12879
+ }
12880
+ /**
12881
+ * Seconds a `stop` waits for the init system to report the service stopped.
12882
+ *
12883
+ * The init system kills the process once its grace elapses, so a service still
12884
+ * running after the grace plus this margin is hung, not slow.
12885
+ */
12886
+ function stopWaitSeconds(config) {
12887
+ return shutdownGraceSeconds(config) + 15;
12888
+ }
12889
+ var init_shutdown_grace = __esmMin((() => {}));
12890
+ //#endregion
12452
12891
  //#region src/cli/service/launchd.ts
12453
12892
  /**
12454
12893
  * launchd service manager implementation.
@@ -12471,14 +12910,19 @@ function stripLabelPrefix(label) {
12471
12910
  return label;
12472
12911
  }
12473
12912
  /** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
12474
- function sleep(ms) {
12913
+ function sleep$1(ms) {
12475
12914
  return new Promise((resolve) => setTimeout(resolve, ms));
12476
12915
  }
12477
- var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, LaunchdServiceManager;
12916
+ var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, KICI_COMPONENT_MARKER, PRINT_NO_SUCH_SERVICE, PRINT_NO_SUCH_DOMAIN, FAILED_BOOTOUT_SETTLE_MS, LaunchdServiceManager;
12478
12917
  var init_launchd = __esmMin((() => {
12918
+ init_shutdown_grace();
12479
12919
  LABEL_PREFIX = "dev.kici";
12480
12920
  KNOWN_LABEL_PREFIXES = ["dev.kici.", "com.kici."];
12481
12921
  SYSTEM_LOG_DIR = "/var/log/kici";
12922
+ KICI_COMPONENT_MARKER = /<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/;
12923
+ PRINT_NO_SUCH_SERVICE = 113;
12924
+ PRINT_NO_SUCH_DOMAIN = 112;
12925
+ FAILED_BOOTOUT_SETTLE_MS = 5e3;
12482
12926
  LaunchdServiceManager = class {
12483
12927
  platform = "launchd";
12484
12928
  /** Build the launchd label for a service. */
@@ -12490,11 +12934,51 @@ var init_launchd = __esmMin((() => {
12490
12934
  if (config.isUserLevel) return path.join(os.homedir(), "Library", "Logs", "kici");
12491
12935
  return SYSTEM_LOG_DIR;
12492
12936
  }
12493
- /** Resolve the plist file path based on service level. */
12494
- plistPath(config) {
12495
- const filename = `${this.label(config)}.plist`;
12496
- if (config.isUserLevel) return path.join(os.homedir(), "Library", "LaunchAgents", filename);
12497
- return path.join("/Library", "LaunchDaemons", filename);
12937
+ /** The directory launchd loads this service level's plists from. */
12938
+ plistDir(isUserLevel) {
12939
+ return isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : path.join("/Library", "LaunchDaemons");
12940
+ }
12941
+ /** The job `install` writes: `dev.kici.<name>`. */
12942
+ installJob(config) {
12943
+ const label = this.label(config);
12944
+ return {
12945
+ label,
12946
+ plistPath: path.join(this.plistDir(config.isUserLevel), `${label}.plist`)
12947
+ };
12948
+ }
12949
+ /**
12950
+ * An installed job for this name under another label: a `com.kici.<name>`
12951
+ * plist, or a plist named after the service itself — each one `list()`
12952
+ * reports as this instance. Only a plist that carries the KiCI component
12953
+ * marker counts, and the job is addressed by the Label the plist declares.
12954
+ */
12955
+ otherLabelJob(config) {
12956
+ const prefixes = [...KNOWN_LABEL_PREFIXES.filter((p) => p !== `${LABEL_PREFIX}.`), ""];
12957
+ for (const prefix of prefixes) {
12958
+ const stem = `${prefix}${config.name}`;
12959
+ if (stripLabelPrefix(stem) !== config.name) continue;
12960
+ const plistPath = path.join(this.plistDir(config.isUserLevel), `${stem}.plist`);
12961
+ if (!fs.existsSync(plistPath)) continue;
12962
+ const content = fs.readFileSync(plistPath, "utf-8");
12963
+ if (!KICI_COMPONENT_MARKER.test(content)) continue;
12964
+ const label = content.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/);
12965
+ return {
12966
+ label: label ? this.unescapeXml(label[1]) : stem,
12967
+ plistPath
12968
+ };
12969
+ }
12970
+ return null;
12971
+ }
12972
+ /**
12973
+ * The job a lifecycle command acts on: the `dev.kici.<name>` plist when it
12974
+ * is installed, otherwise a job this name was installed under with another
12975
+ * label, so every instance `list()` reports can be stopped, started and
12976
+ * removed. With neither installed, the `dev.kici.<name>` job.
12977
+ */
12978
+ resolveJob(config) {
12979
+ const job = this.installJob(config);
12980
+ if (fs.existsSync(job.plistPath)) return job;
12981
+ return this.otherLabelJob(config) ?? job;
12498
12982
  }
12499
12983
  /** Parse a .env file into key-value pairs. */
12500
12984
  parseEnvFile(envFilePath) {
@@ -12550,11 +13034,18 @@ var init_launchd = __esmMin((() => {
12550
13034
  lines.push(` <string>${this.escapeXml(config.workingDirectory)}</string>`);
12551
13035
  lines.push(" <key>RunAtLoad</key>");
12552
13036
  lines.push(" <true/>");
12553
- lines.push(" <key>KeepAlive</key>");
12554
- lines.push(" <true/>");
12555
- if (config.restartPolicy.enabled && config.restartPolicy.delays.length > 0) {
12556
- lines.push(" <key>ThrottleInterval</key>");
12557
- lines.push(` <integer>${config.restartPolicy.delays[0]}</integer>`);
13037
+ lines.push(" <key>ExitTimeOut</key>");
13038
+ lines.push(` <integer>${shutdownGraceSeconds(config)}</integer>`);
13039
+ if (config.restartPolicy.enabled) {
13040
+ lines.push(" <key>KeepAlive</key>");
13041
+ lines.push(" <dict>");
13042
+ lines.push(" <key>SuccessfulExit</key>");
13043
+ lines.push(" <false/>");
13044
+ lines.push(" </dict>");
13045
+ if (config.restartPolicy.delays.length > 0) {
13046
+ lines.push(" <key>ThrottleInterval</key>");
13047
+ lines.push(` <integer>${config.restartPolicy.delays[0]}</integer>`);
13048
+ }
12558
13049
  }
12559
13050
  lines.push(" <key>StandardOutPath</key>");
12560
13051
  lines.push(` <string>${this.escapeXml(path.join(logDirectory, `${config.name}.out.log`))}</string>`);
@@ -12590,30 +13081,48 @@ var init_launchd = __esmMin((() => {
12590
13081
  * domain from the plist's filesystem location, but they fail silently on
12591
13082
  * headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
12592
13083
  * is not available without a console login). The modern `bootstrap` /
12593
- * `bootout` / `kickstart` / `kill` verbs take the domain explicitly, so a
13084
+ * `bootout` / `kickstart` / `print` verbs take the domain explicitly, so a
12594
13085
  * system-level LaunchDaemon loads regardless of whether anyone is logged
12595
13086
  * in at the console — exactly what we need for headless deploy targets.
12596
13087
  */
12597
13088
  domain(config) {
12598
13089
  return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
12599
13090
  }
12600
- /** `<domain>/<label>` — the target string for kickstart/kill/print/enable. */
12601
- domainTarget(config) {
12602
- return `${this.domain(config)}/${this.label(config)}`;
13091
+ /** `<domain>/<label>` — the target string for bootout/kickstart/print. */
13092
+ domainTarget(config, job) {
13093
+ return `${this.domain(config)}/${job.label}`;
12603
13094
  }
12604
- /** Is the service currently loaded in its target domain? */
12605
- isLoaded(config) {
13095
+ /**
13096
+ * `launchctl print <domain>/<label>`: the job's description while it is
13097
+ * loaded, or null when launchd reports it not loaded. Unlike `launchctl
13098
+ * list`, which lists only the caller's own domain, this reads a system
13099
+ * daemon without root. Any other failure throws, so a job that could not be
13100
+ * inspected is never mistaken for a stopped one.
13101
+ */
13102
+ printJob(config, job) {
12606
13103
  try {
12607
- execFileSync("launchctl", ["print", this.domainTarget(config)], { stdio: "ignore" });
12608
- return true;
12609
- } catch {
12610
- return false;
13104
+ return execFileSync("launchctl", ["print", this.domainTarget(config, job)], {
13105
+ encoding: "utf-8",
13106
+ stdio: [
13107
+ "ignore",
13108
+ "pipe",
13109
+ "pipe"
13110
+ ]
13111
+ });
13112
+ } catch (err) {
13113
+ const status = err.status;
13114
+ if (status === PRINT_NO_SUCH_SERVICE || status === PRINT_NO_SUCH_DOMAIN) return null;
13115
+ throw err;
12611
13116
  }
12612
13117
  }
13118
+ /** Is the job currently loaded in its target domain? */
13119
+ isLoaded(config, job) {
13120
+ return this.printJob(config, job) !== null;
13121
+ }
12613
13122
  async install(config) {
12614
- const plistFile = this.plistPath(config);
13123
+ const job = this.installJob(config);
12615
13124
  const plistContent = this.generatePlist(config);
12616
- fs.mkdirSync(path.dirname(plistFile), { recursive: true });
13125
+ fs.mkdirSync(path.dirname(job.plistPath), { recursive: true });
12617
13126
  const logDirectory = this.logDir(config);
12618
13127
  fs.mkdirSync(logDirectory, { recursive: true });
12619
13128
  if (!config.isUserLevel && config.user) execFileSync("chown", [
@@ -12621,44 +13130,51 @@ var init_launchd = __esmMin((() => {
12621
13130
  `${config.user}:staff`,
12622
13131
  logDirectory
12623
13132
  ], { stdio: "inherit" });
12624
- fs.writeFileSync(plistFile, plistContent, "utf-8");
12625
- if (this.isLoaded(config)) {
12626
- try {
12627
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
12628
- } catch {}
12629
- await this.waitUntilUnloaded(config);
13133
+ const otherJob = this.otherLabelJob(config);
13134
+ if (otherJob) {
13135
+ await this.unload(config, otherJob);
13136
+ fs.unlinkSync(otherJob.plistPath);
12630
13137
  }
12631
- await this.bootstrapWithRetry(config, plistFile);
13138
+ fs.writeFileSync(job.plistPath, plistContent, "utf-8");
13139
+ await this.unload(config, job);
13140
+ await this.bootstrapWithRetry(config, job, { replaceLoaded: true });
12632
13141
  }
12633
13142
  /**
12634
- * Poll until the service is no longer loaded in its target domain, or a
12635
- * short deadline elapses. `launchctl bootout` is asynchronous — it returns
12636
- * before launchd has finished releasing the service — so a bootstrap issued
13143
+ * Poll until the job is no longer loaded in its target domain, or the
13144
+ * deadline elapses. Resolves true once the job has left the domain,
13145
+ * false on timeout. `launchctl bootout` is asynchronous — it returns before
13146
+ * launchd has finished releasing the job — so a bootstrap issued
12637
13147
  * immediately afterward races the teardown and fails with EIO. Waiting for
12638
13148
  * the unload to complete closes that race for the common case; the residual
12639
13149
  * window is covered by bootstrapWithRetry.
12640
13150
  */
12641
- async waitUntilUnloaded(config) {
12642
- const deadline = Date.now() + 15e3;
12643
- while (this.isLoaded(config)) {
12644
- if (Date.now() >= deadline) return;
12645
- await sleep(500);
13151
+ async waitUntilUnloaded(config, job, timeoutMs) {
13152
+ const deadline = Date.now() + timeoutMs;
13153
+ while (this.isLoaded(config, job)) {
13154
+ if (Date.now() >= deadline) return false;
13155
+ await sleep$1(500);
12646
13156
  }
13157
+ return true;
12647
13158
  }
12648
13159
  /**
12649
13160
  * Bootstrap into the target domain, retrying on the transient EIO
12650
13161
  * ("5: Input/output error") launchd returns when a just-removed service has
12651
13162
  * not finished tearing down. A genuine, non-transient failure (bad plist,
12652
- * permission denied) is re-thrown on the first attempt. If a stale instance
12653
- * reappears between attempts, it is booted out before the next try.
13163
+ * permission denied) is re-thrown on the first attempt.
13164
+ *
13165
+ * launchd answers a bootstrap of a job that is already loaded with the same
13166
+ * EIO. With `replaceLoaded` (install, which just wrote a new plist) such a
13167
+ * job is booted out before the next try. Without it (start), a job that
13168
+ * another caller loaded meanwhile is the outcome start wanted: RunAtLoad has
13169
+ * started it, so the call succeeds and leaves it running.
12654
13170
  */
12655
- async bootstrapWithRetry(config, plistFile) {
13171
+ async bootstrapWithRetry(config, job, opts) {
12656
13172
  const attempts = 5;
12657
13173
  for (let attempt = 1; attempt <= attempts; attempt++) try {
12658
13174
  execFileSync("launchctl", [
12659
13175
  "bootstrap",
12660
13176
  this.domain(config),
12661
- plistFile
13177
+ job.plistPath
12662
13178
  ], { stdio: [
12663
13179
  "inherit",
12664
13180
  "inherit",
@@ -12666,66 +13182,92 @@ var init_launchd = __esmMin((() => {
12666
13182
  ] });
12667
13183
  return;
12668
13184
  } catch (err) {
13185
+ if (!opts.replaceLoaded && this.isLoaded(config, job)) return;
12669
13186
  const e = err;
12670
13187
  const stderr = (e.stderr ?? "").toString();
12671
13188
  if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
12672
13189
  if (stderr) process.stderr.write(stderr);
12673
13190
  throw err;
12674
13191
  }
12675
- if (this.isLoaded(config)) try {
12676
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
13192
+ if (opts.replaceLoaded && this.isLoaded(config, job)) try {
13193
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: "inherit" });
12677
13194
  } catch {}
12678
- await sleep(1e3 * attempt);
13195
+ await sleep$1(1e3 * attempt);
12679
13196
  }
12680
13197
  }
12681
13198
  async uninstall(config) {
12682
- const plistFile = this.plistPath(config);
13199
+ const job = this.resolveJob(config);
12683
13200
  try {
12684
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
13201
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
13202
+ "inherit",
13203
+ "inherit",
13204
+ "pipe"
13205
+ ] });
12685
13206
  } catch {}
12686
13207
  try {
12687
- fs.unlinkSync(plistFile);
13208
+ fs.unlinkSync(job.plistPath);
12688
13209
  } catch {}
12689
13210
  }
12690
13211
  async start(config) {
12691
- execFileSync("launchctl", [
12692
- "kickstart",
12693
- "-k",
12694
- this.domainTarget(config)
12695
- ], { stdio: "inherit" });
13212
+ const job = this.resolveJob(config);
13213
+ if (!this.isLoaded(config, job)) {
13214
+ await this.bootstrapWithRetry(config, job, { replaceLoaded: false });
13215
+ return;
13216
+ }
13217
+ execFileSync("launchctl", ["kickstart", this.domainTarget(config, job)], { stdio: "inherit" });
12696
13218
  }
12697
13219
  async stop(config) {
12698
- execFileSync("launchctl", [
12699
- "kill",
12700
- "TERM",
12701
- this.domainTarget(config)
12702
- ], { stdio: "inherit" });
13220
+ const job = this.resolveJob(config);
13221
+ if (!this.isLoaded(config, job) && !fs.existsSync(job.plistPath)) throw new Error(`launchd job ${job.label} is not installed: no job is loaded and ${job.plistPath} does not exist.`);
13222
+ await this.unload(config, job);
13223
+ }
13224
+ /**
13225
+ * Boot the job out of its domain and wait until launchd has finished the
13226
+ * teardown. Does nothing when no job is loaded.
13227
+ */
13228
+ async unload(config, job) {
13229
+ if (!this.isLoaded(config, job)) return;
13230
+ let bootoutError;
13231
+ try {
13232
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
13233
+ "inherit",
13234
+ "inherit",
13235
+ "pipe"
13236
+ ] });
13237
+ } catch (err) {
13238
+ bootoutError = err;
13239
+ }
13240
+ if (bootoutError !== void 0) {
13241
+ if (await this.waitUntilUnloaded(config, job, FAILED_BOOTOUT_SETTLE_MS)) return;
13242
+ const stderr = String(bootoutError.stderr ?? "");
13243
+ if (stderr) process.stderr.write(stderr);
13244
+ throw bootoutError;
13245
+ }
13246
+ const timeoutSeconds = stopWaitSeconds(config);
13247
+ if (!await this.waitUntilUnloaded(config, job, timeoutSeconds * 1e3)) throw new Error(`launchd job ${job.label} is still loaded ${timeoutSeconds}s after launchctl bootout. Its process may be hung — inspect it with \`launchctl print ${this.domainTarget(config, job)}\`.`);
12703
13248
  }
12704
13249
  async restart(config) {
12705
13250
  await this.stop(config);
12706
13251
  await this.start(config);
12707
13252
  }
12708
13253
  async status(config) {
12709
- const label = this.label(config);
13254
+ const job = this.resolveJob(config);
13255
+ let description;
12710
13256
  try {
12711
- const output = execFileSync("launchctl", ["list"], { encoding: "utf-8" });
12712
- for (const line of output.split("\n")) {
12713
- if (!line.includes(label)) continue;
12714
- const parts = line.trim().split(" ");
12715
- if (parts.length >= 3) {
12716
- const pidStr = parts[0];
12717
- const exitCode = parseInt(parts[1], 10);
12718
- const pid = pidStr !== "-" ? parseInt(pidStr, 10) : void 0;
12719
- if (pid && pid > 0) return {
12720
- state: "running",
12721
- pid
12722
- };
12723
- if (exitCode !== 0) return { state: "failed" };
12724
- return { state: "stopped" };
12725
- }
12726
- }
12727
- } catch {}
12728
- return { state: "unknown" };
13257
+ description = this.printJob(config, job);
13258
+ } catch {
13259
+ return { state: "unknown" };
13260
+ }
13261
+ if (description === null) return fs.existsSync(job.plistPath) ? { state: "stopped" } : { state: "unknown" };
13262
+ const pid = description.match(/^\tpid = (\d+)$/m);
13263
+ if (pid) return {
13264
+ state: "running",
13265
+ pid: parseInt(pid[1], 10)
13266
+ };
13267
+ const lastExit = description.match(/^\tlast exit code = (-?\d+)/m);
13268
+ if (lastExit && parseInt(lastExit[1], 10) !== 0) return { state: "failed" };
13269
+ if (/^\tlast terminating signal = /m.test(description)) return { state: "failed" };
13270
+ return { state: "stopped" };
12729
13271
  }
12730
13272
  async logs(config, options) {
12731
13273
  const logDirectory = this.logDir(config);
@@ -12743,10 +13285,10 @@ var init_launchd = __esmMin((() => {
12743
13285
  });
12744
13286
  }
12745
13287
  async isInstalled(config) {
12746
- return fs.existsSync(this.plistPath(config));
13288
+ return fs.existsSync(this.resolveJob(config).plistPath);
12747
13289
  }
12748
13290
  async list(isUserLevel) {
12749
- const baseDir = isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : "/Library/LaunchDaemons";
13291
+ const baseDir = this.plistDir(isUserLevel);
12750
13292
  if (!fs.existsSync(baseDir)) return [];
12751
13293
  const out = [];
12752
13294
  for (const entry of fs.readdirSync(baseDir)) {
@@ -12759,7 +13301,7 @@ var init_launchd = __esmMin((() => {
12759
13301
  if (err?.code === "ENOENT") continue;
12760
13302
  throw new Error(`could not read the launchd job at ${path.join(baseDir, entry)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
12761
13303
  }
12762
- const match = content.match(/<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/);
13304
+ const match = content.match(KICI_COMPONENT_MARKER);
12763
13305
  if (!match) continue;
12764
13306
  const dirMatch = content.match(/<key>KiCIInstanceDir<\/key>\s*<string>([^<]+)<\/string>/);
12765
13307
  out.push({
@@ -12775,7 +13317,7 @@ var init_launchd = __esmMin((() => {
12775
13317
  async readLaunchSpec(config) {
12776
13318
  let content;
12777
13319
  try {
12778
- content = fs.readFileSync(this.plistPath(config), "utf-8");
13320
+ content = fs.readFileSync(this.resolveJob(config).plistPath, "utf-8");
12779
13321
  } catch {
12780
13322
  return null;
12781
13323
  }
@@ -13044,12 +13586,75 @@ function parseStateCode(output) {
13044
13586
  const match = output.match(/STATE\s*:\s*(\d+)/);
13045
13587
  return match ? parseInt(match[1], 10) : void 0;
13046
13588
  }
13047
- var SERVICE_REGISTRY_PROBE_TIMEOUT_MS, WindowsServiceManager;
13589
+ /** Async sleep used to pace the `sc.exe query` polls. */
13590
+ function sleep(ms) {
13591
+ return new Promise((resolve) => setTimeout(resolve, ms));
13592
+ }
13593
+ /** A `.cmd` or `.bat` file, which Windows runs through cmd.exe. */
13594
+ function isBatchFile(executablePath) {
13595
+ return /\.(cmd|bat)$/i.test(executablePath);
13596
+ }
13597
+ /**
13598
+ * The cmd.exe that COMSPEC names, as Node itself uses to run a shell on Windows.
13599
+ * A COMSPEC that names another shell is not used: the wrapper arguments are
13600
+ * cmd.exe's.
13601
+ */
13602
+ function cmdExePath() {
13603
+ const comspec = process.env.COMSPEC;
13604
+ return comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? comspec : "C:\\Windows\\System32\\cmd.exe";
13605
+ }
13606
+ /**
13607
+ * Throw when cmd.exe would read part of `token` as syntax.
13608
+ *
13609
+ * shawl quotes a token on cmd.exe's command line only when it contains a space
13610
+ * or a tab. Inside quotes cmd.exe reads `&`, `|`, `<`, `>`, `(`, `)` and the
13611
+ * `,` `;` `=` delimiters as text; outside quotes it reads them as syntax. `%`
13612
+ * and `^` change even inside quotes (`call` expands `%` again and doubles `^`),
13613
+ * and a `"` ends the quoting.
13614
+ */
13615
+ function assertCmdSafe(token) {
13616
+ const found = (/[ \t]/.test(token) ? /["%^]/ : /["%^&|<>(),;=]/).exec(token);
13617
+ if (found) throw new Error(`cannot run "${token}" as a Windows service: it contains "${found[0]}", which cmd.exe reads as syntax when it runs the batch file. Install from a path without it.`);
13618
+ }
13619
+ /**
13620
+ * The command shawl registers for the service: the executable and its
13621
+ * arguments, with a batch file run through cmd.exe ({@link BATCH_WRAPPER_ARGS}).
13622
+ */
13623
+ function serviceCommand(config) {
13624
+ const command = [config.executablePath, ...config.args ?? []];
13625
+ if (!isBatchFile(config.executablePath)) return command;
13626
+ command.forEach(assertCmdSafe);
13627
+ return [
13628
+ cmdExePath(),
13629
+ ...BATCH_WRAPPER_ARGS,
13630
+ ...command,
13631
+ BATCH_STDIN
13632
+ ];
13633
+ }
13634
+ /** The launch command inside a {@link serviceCommand}: a batch file's cmd.exe wrapper removed. */
13635
+ function unwrapServiceCommand(tokens) {
13636
+ const [exe, ...rest] = tokens;
13637
+ return exe !== void 0 && /(^|\\)cmd\.exe$/i.test(exe) && BATCH_WRAPPER_ARGS.every((arg, i) => rest[i]?.toLowerCase() === arg) && rest.at(-1) === BATCH_STDIN && rest.length > BATCH_WRAPPER_ARGS.length + 1 ? rest.slice(BATCH_WRAPPER_ARGS.length, -1) : tokens;
13638
+ }
13639
+ var SERVICE_REGISTRY_PROBE_TIMEOUT_MS, ERROR_SERVICE_NOT_ACTIVE, ERROR_SERVICE_CANNOT_ACCEPT_CTRL, BATCH_WRAPPER_ARGS, BATCH_STDIN, WindowsServiceManager;
13048
13640
  var init_windows = __esmMin((() => {
13049
13641
  init_downloader();
13050
13642
  init_registry();
13051
13643
  init_platform_detect();
13644
+ init_shutdown_grace();
13645
+ init_windows_acl();
13646
+ init_windows_env_file();
13052
13647
  SERVICE_REGISTRY_PROBE_TIMEOUT_MS = 1e4;
13648
+ ERROR_SERVICE_NOT_ACTIVE = 1062;
13649
+ ERROR_SERVICE_CANNOT_ACCEPT_CTRL = 1061;
13650
+ BATCH_WRAPPER_ARGS = [
13651
+ "/d",
13652
+ "/e:on",
13653
+ "/v:off",
13654
+ "/c",
13655
+ "call"
13656
+ ];
13657
+ BATCH_STDIN = "<NUL";
13053
13658
  WindowsServiceManager = class {
13054
13659
  platform = "windows";
13055
13660
  /**
@@ -13091,13 +13696,24 @@ var init_windows = __esmMin((() => {
13091
13696
  return false;
13092
13697
  }
13093
13698
  }
13699
+ /** The service's STATE code from `sc.exe query`, or undefined when the query fails. */
13700
+ queryStateCode(name) {
13701
+ try {
13702
+ return parseStateCode(execSync(`sc.exe query ${name}`, { stdio: "pipe" }).toString());
13703
+ } catch {
13704
+ return;
13705
+ }
13706
+ }
13094
13707
  async install(config) {
13708
+ const command = serviceCommand(config);
13709
+ if (!launchReadsEnvFile(config)) throw new Error(`cannot register ${config.name}: ` + envFileRefusal(launchedRelease(config), config.envFilePath));
13710
+ const envDir = path.dirname(config.envFilePath);
13711
+ fs.mkdirSync(envDir, { recursive: true });
13712
+ if (!config.isUserLevel) restrictEnvFileAccess(config.envFilePath);
13095
13713
  if (this.serviceExists(config.name)) await this.uninstall(config);
13096
13714
  const depMeta = getDepMetadata("shawl");
13097
13715
  const depPath = await ensureDep(depMeta, getCacheDir());
13098
13716
  const shawlExe = path.join(depPath, depMeta.extractPath);
13099
- const envDir = path.dirname(config.envFilePath);
13100
- fs.mkdirSync(envDir, { recursive: true });
13101
13717
  const cwd = config.workingDirectory.replace(/\\+$/, "");
13102
13718
  const logDir = path.join(envDir, "logs");
13103
13719
  fs.mkdirSync(logDir, { recursive: true });
@@ -13109,23 +13725,12 @@ var init_windows = __esmMin((() => {
13109
13725
  "--cwd",
13110
13726
  `"${cwd}"`,
13111
13727
  "--log-dir",
13112
- `"${logDir.replace(/\\+$/, "")}"`
13728
+ `"${logDir.replace(/\\+$/, "")}"`,
13729
+ "--stop-timeout",
13730
+ String(shutdownGraceSeconds(config) * 1e3)
13113
13731
  ];
13114
- if (fs.existsSync(config.envFilePath)) {
13115
- const envContent = fs.readFileSync(config.envFilePath, "utf-8");
13116
- for (const line of envContent.split("\n")) {
13117
- const trimmed = line.trim();
13118
- if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
13119
- const eqIdx = trimmed.indexOf("=");
13120
- if (trimmed.slice(0, eqIdx) === "PATH") {
13121
- const dirs = trimmed.slice(eqIdx + 1).split(";").filter(Boolean);
13122
- for (const dir of dirs) cmdParts.push("--path-prepend", `"${dir.replace(/\\+$/, "")}"`);
13123
- } else cmdParts.push("--env", `"${trimmed}"`);
13124
- }
13125
- }
13126
- }
13127
- cmdParts.push("--", `"${config.executablePath}"`);
13128
- for (const arg of config.args ?? []) cmdParts.push(`"${arg}"`);
13732
+ cmdParts.push("--env", `"${SERVICE_ENV_FILE_VAR}=${config.envFilePath}"`);
13733
+ cmdParts.push("--", ...command.map((token) => `"${token}"`));
13129
13734
  const cmd = cmdParts.join(" ");
13130
13735
  execSync(cmd, { stdio: "pipe" });
13131
13736
  const dirSuffix = config.component && config.instanceDir ? ` [KiCI-DIR:${config.instanceDir}]` : "";
@@ -13137,60 +13742,55 @@ var init_windows = __esmMin((() => {
13137
13742
  execSync(`sc.exe failure ${config.name} reset= ${resetSeconds} actions= ${actions}`, { stdio: "pipe" });
13138
13743
  }
13139
13744
  async uninstall(config) {
13745
+ const waitSeconds = stopWaitSeconds(config);
13140
13746
  try {
13141
13747
  execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13142
- await new Promise((r) => setTimeout(r, 3e3));
13143
13748
  } catch {}
13749
+ const stopDeadline = Date.now() + waitSeconds * 1e3;
13750
+ while (Date.now() < stopDeadline) {
13751
+ const stateCode = this.queryStateCode(config.name);
13752
+ if (stateCode === 1 || stateCode === void 0) break;
13753
+ await sleep(500);
13754
+ }
13144
13755
  execSync(`sc.exe delete ${config.name}`, { stdio: "pipe" });
13145
- const deadline = Date.now() + 3e4;
13146
- while (Date.now() < deadline) try {
13147
- execSync(`sc.exe query ${config.name}`, { stdio: "pipe" });
13148
- await new Promise((r) => setTimeout(r, 1e3));
13149
- } catch {
13150
- return;
13756
+ const deadline = Date.now() + waitSeconds * 1e3;
13757
+ while (Date.now() < deadline) {
13758
+ if (!this.serviceExists(config.name)) return;
13759
+ await sleep(1e3);
13151
13760
  }
13152
- throw new Error(`Service ${config.name} still exists after 30s. The process may be hung — check Task Manager and kill manually if needed.`);
13761
+ throw new Error(`Service ${config.name} still exists ${waitSeconds}s after sc.exe delete. The process may be hung — check Task Manager and kill manually if needed.`);
13153
13762
  }
13154
13763
  async start(config) {
13155
- const deadline = Date.now() + 3e4;
13764
+ const deadline = Date.now() + stopWaitSeconds(config) * 1e3;
13156
13765
  while (Date.now() < deadline) {
13157
- let stateCode;
13158
- try {
13159
- stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
13160
- } catch {
13161
- stateCode = void 0;
13162
- }
13766
+ const stateCode = this.queryStateCode(config.name);
13163
13767
  if (stateCode === 4) return;
13164
13768
  if (stateCode === 1) break;
13165
- await new Promise((r) => setTimeout(r, 500));
13769
+ await sleep(500);
13166
13770
  }
13167
13771
  execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
13168
13772
  }
13169
13773
  async stop(config) {
13170
- execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13171
- const deadline = Date.now() + 3e4;
13172
- while (Date.now() < deadline) {
13173
- let stateCode;
13174
- try {
13175
- stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
13176
- } catch {
13177
- stateCode = void 0;
13178
- }
13179
- if (stateCode === 1) return;
13180
- await new Promise((r) => setTimeout(r, 500));
13181
- }
13182
- throw new Error(`Service ${config.name} did not reach STOPPED state within 30s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
13183
- }
13184
- async restart(config) {
13185
13774
  try {
13186
13775
  execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13187
- } catch {}
13188
- const deadline = Date.now() + 3e4;
13776
+ } catch (err) {
13777
+ const status = err?.status;
13778
+ if (status !== ERROR_SERVICE_NOT_ACTIVE && status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL) throw err;
13779
+ const stateCode = this.queryStateCode(config.name);
13780
+ if (stateCode === 1) return;
13781
+ if (status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL || stateCode !== 3) throw err;
13782
+ }
13783
+ const waitSeconds = stopWaitSeconds(config);
13784
+ const deadline = Date.now() + waitSeconds * 1e3;
13189
13785
  while (Date.now() < deadline) {
13190
- if (parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString()) === 1) break;
13191
- await new Promise((r) => setTimeout(r, 500));
13786
+ if (this.queryStateCode(config.name) === 1) return;
13787
+ await sleep(500);
13192
13788
  }
13193
- execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
13789
+ throw new Error(`Service ${config.name} did not reach STOPPED state within ${waitSeconds}s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
13790
+ }
13791
+ async restart(config) {
13792
+ await this.stop(config);
13793
+ await this.start(config);
13194
13794
  }
13195
13795
  async status(config) {
13196
13796
  try {
@@ -13259,18 +13859,38 @@ var init_windows = __esmMin((() => {
13259
13859
  }
13260
13860
  return out;
13261
13861
  }
13262
- async readLaunchSpec(config) {
13263
- let raw;
13862
+ /**
13863
+ * The command line a service is registered with.
13864
+ *
13865
+ * `sc.exe qc` answers most registrations, but it fails (error 1734) once the
13866
+ * line is longer than about 4000 characters, as an older CLI's registration
13867
+ * with every env-file value on it can be. It also prints in the console code
13868
+ * page, so a path outside ASCII, such as one under the profile of a user
13869
+ * named Jürgen, decodes to U+FFFD. In either case the registry value it reads
13870
+ * (`ImagePath`) is read through PowerShell, printed as UTF-8.
13871
+ */
13872
+ registeredCommandLine(name) {
13873
+ try {
13874
+ const line = execSync(`sc.exe qc ${name}`, { stdio: "pipe" }).toString().match(/BINARY_PATH_NAME\s*:\s*(.+)/)?.[1]?.trim();
13875
+ if (line && !line.includes("�")) return line;
13876
+ } catch {}
13877
+ const key = `HKLM:\\SYSTEM\\CurrentControlSet\\Services\\${name.replace(/'/g, "''")}`;
13264
13878
  try {
13265
- raw = execSync(`sc.exe qc ${config.name}`, { stdio: "pipe" }).toString();
13879
+ return execSync(`powershell -NoProfile -NonInteractive -Command "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding \$false; (Get-ItemProperty -LiteralPath '${key}').ImagePath"`, { stdio: "pipe" }).toString("utf-8").replace(/^\uFEFF/, "").trim() || null;
13266
13880
  } catch {
13267
13881
  return null;
13268
13882
  }
13269
- const m = raw.match(/BINARY_PATH_NAME\s*:\s*(.+)/);
13270
- if (!m) return null;
13271
- const sep = m[1].indexOf(" -- ");
13883
+ }
13884
+ async readLaunchSpec(config) {
13885
+ const line = this.registeredCommandLine(config.name);
13886
+ if (line === null) return null;
13887
+ const parts = [...line.matchAll(/"([^"]*)"|(\S+)/g)].map((t) => ({
13888
+ text: t[1] ?? t[2],
13889
+ quoted: t[1] !== void 0
13890
+ }));
13891
+ const sep = parts.findIndex((p) => !p.quoted && p.text === "--");
13272
13892
  if (sep === -1) return null;
13273
- const tokens = [...m[1].slice(sep + 4).matchAll(/"([^"]*)"|(\S+)/g)].map((t) => t[1] ?? t[2]);
13893
+ const tokens = unwrapServiceCommand(parts.slice(sep + 1).map((p) => p.text));
13274
13894
  if (tokens.length === 0 || !tokens[0]) return null;
13275
13895
  return {
13276
13896
  execPath: tokens[0],
@@ -13295,56 +13915,6 @@ function composeFilePath(envFilePath, serviceName) {
13295
13915
  }
13296
13916
  var init_compose_path = __esmMin((() => {}));
13297
13917
  //#endregion
13298
- //#region src/cli/service/image-digests.ts
13299
- /**
13300
- * Resolves the customer installer's image reference from the digest record
13301
- * shipped with this package (installer-image-digests.json at the package root).
13302
- *
13303
- * Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
13304
- * the image hash — a registry that serves substituted bits fails the pull. When
13305
- * the record is missing (a dev tree that never ran a release) or lacks the
13306
- * image, falls back to the moving `:latest` tag and warns.
13307
- *
13308
- * The record resolves relative to this module's directory and works for both
13309
- * layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
13310
- * the package root) and the single-file bundle (`dist/cli.js` → one dir up).
13311
- */
13312
- /**
13313
- * Climb from a starting directory looking for installer-image-digests.json.
13314
- * Works for the source layout (src/cli/service → 3 up) and the single-file
13315
- * bundle layout (dist → 1 up).
13316
- */
13317
- function findRecordFile(startDir) {
13318
- let dir = startDir;
13319
- for (let i = 0; i < 6; i++) {
13320
- const candidate = path.join(dir, RECORD_FILENAME);
13321
- if (existsSync(candidate)) return candidate;
13322
- const parent = path.dirname(dir);
13323
- if (parent === dir) break;
13324
- dir = parent;
13325
- }
13326
- return null;
13327
- }
13328
- /**
13329
- * Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
13330
- * image. Falls back to `:latest` (with a warning) when no recorded digest exists.
13331
- */
13332
- function resolveImageRef(name, opts = {}) {
13333
- const file = opts.filePath ?? findRecordFile(import.meta.dirname);
13334
- if (file && existsSync(file)) try {
13335
- const rec = JSON.parse(readFileSync(file, "utf8"));
13336
- const digest = rec.images?.[name];
13337
- if (digest && rec.version) return `${QUAY_PREFIX}/${name}:${rec.version}@${digest}`;
13338
- } catch {}
13339
- console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
13340
- return `${QUAY_PREFIX}/${name}:latest`;
13341
- }
13342
- var QUAY_PREFIX, RECORD_FILENAME;
13343
- var init_image_digests = __esmMin((() => {
13344
- QUAY_PREFIX = "quay.io/kici-dev";
13345
- RECORD_FILENAME = "installer-image-digests.json";
13346
- }));
13347
- //#endregion
13348
13918
  //#region src/cli/service/compose.ts
13349
13919
  /**
13350
13920
  * Docker/Podman Compose service manager.
@@ -13453,29 +14023,6 @@ function getRestartMode(config) {
13453
14023
  return "unless-stopped";
13454
14024
  }
13455
14025
  /**
13456
- * Seconds a runtime must wait after SIGTERM before it SIGKILLs the container.
13457
- *
13458
- * Compose defaults this to 10s, which is shorter than either component's own
13459
- * graceful-shutdown budget, so a teardown that takes longer than 10s is killed
13460
- * partway through: the orchestrator never broadcasts `peer.leaving` and never
13461
- * closes its agent sockets, and the agent never finishes draining. That window
13462
- * is reached exactly when it matters most — the orchestrator's scaler stops
13463
- * each managed agent container with its own grace, so an orchestrator with
13464
- * agents in flight routinely needs more than 10s.
13465
- *
13466
- * Each value is its component's force-exit budget plus headroom, so the
13467
- * process always reaches its own timer first and the runtime's kill stays the
13468
- * last resort it is meant to be:
13469
- * - orchestrator: 30s (`setupGracefulShutdown` default) -> 45s
13470
- * - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
13471
- *
13472
- * An unrecognised component gets the larger value: over-waiting costs a few
13473
- * seconds on shutdown, under-waiting corrupts it.
13474
- */
13475
- function stopGracePeriodSeconds(config) {
13476
- return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
13477
- }
13478
- /**
13479
14026
  * Generate compose YAML content for a service.
13480
14027
  *
13481
14028
  * Uses a manual YAML builder to avoid adding a YAML library dependency.
@@ -13483,25 +14030,24 @@ function stopGracePeriodSeconds(config) {
13483
14030
  * restart policy, and volume mounts.
13484
14031
  *
13485
14032
  * The `image:` line is pinned by manifest-list digest via `resolveImageRef`
13486
- * (`quay.io/kici-dev/<service-name>:<version>@sha256:<digest>`), falling back
13487
- * to the mutable `:latest` tag only when no recorded digest ships with this
13488
- * package. Source of truth for the publish targets:
13489
- * packages/ci/src/publish-images.ts. See
13490
- * .claude/rules/container-publish-registry.md — Quay is the sole registry for
13491
- * public KiCI images we publish.
14033
+ * (`quay.io/kici-dev/kici-<component>:<version>@sha256:<digest>`, whatever the
14034
+ * service is named), falling back to the mutable `:latest` tag only when no
14035
+ * recorded digest ships with this package. Quay is the only registry the KiCI
14036
+ * images are published to.
13492
14037
  */
13493
14038
  function generateComposeYaml(config) {
13494
14039
  const restartMode = getRestartMode(config);
14040
+ const image = config.component ? installerImageName(config.component) : config.name;
13495
14041
  const lines = [
13496
14042
  "# Generated by KiCI service installer",
13497
14043
  `# Service: ${config.displayName}`,
13498
14044
  "",
13499
14045
  "services:",
13500
14046
  ` ${config.name}:`,
13501
- ` image: ${resolveImageRef(config.name)}`,
14047
+ ` image: ${resolveImageRef(image)}`,
13502
14048
  ` container_name: ${config.name}`,
13503
14049
  ` restart: ${restartMode}`,
13504
- ` stop_grace_period: ${stopGracePeriodSeconds(config)}s`,
14050
+ ` stop_grace_period: ${shutdownGraceSeconds(config)}s`,
13505
14051
  " env_file:",
13506
14052
  ` - ${config.envFilePath}`,
13507
14053
  " volumes:",
@@ -13519,6 +14065,7 @@ var RUNTIME_PROBE_TIMEOUT_MS, ComposeServiceManager;
13519
14065
  var init_compose = __esmMin((() => {
13520
14066
  init_compose_path();
13521
14067
  init_image_digests();
14068
+ init_shutdown_grace();
13522
14069
  RUNTIME_PROBE_TIMEOUT_MS = 1e4;
13523
14070
  ComposeServiceManager = class {
13524
14071
  platform = "compose";
@@ -13673,11 +14220,14 @@ var init_compose = __esmMin((() => {
13673
14220
  var service_exports = /* @__PURE__ */ __exportAll({
13674
14221
  COMPONENTS: () => COMPONENTS,
13675
14222
  DEFAULT_RESTART_POLICY: () => DEFAULT_RESTART_POLICY,
14223
+ ENV_FILE_LOADER_EXPORT: () => ENV_FILE_LOADER_EXPORT,
13676
14224
  InstanceNotFoundError: () => InstanceNotFoundError,
14225
+ SERVICE_TEXT: () => SERVICE_TEXT,
13677
14226
  appendIndexEntry: () => appendIndexEntry,
13678
14227
  candidatePlatforms: () => candidatePlatforms,
13679
14228
  createServiceManager: () => createServiceManager,
13680
14229
  detectPlatform: () => detectPlatform,
14230
+ envFileRefusal: () => envFileRefusal,
13681
14231
  formatRefusal: () => formatRefusal,
13682
14232
  getCacheDir: () => getCacheDir,
13683
14233
  getConfigDir: () => getConfigDir,
@@ -13686,6 +14236,8 @@ var service_exports = /* @__PURE__ */ __exportAll({
13686
14236
  isComponent: () => isComponent,
13687
14237
  isRoot: () => isRoot,
13688
14238
  kiciConfigRoot: () => kiciConfigRoot,
14239
+ launchReadsEnvFile: () => launchReadsEnvFile,
14240
+ launchedRelease: () => launchedRelease,
13689
14241
  listInstances: () => listInstances,
13690
14242
  manifestFilename: () => manifestFilename,
13691
14243
  manifestPath: () => manifestPath,
@@ -13695,10 +14247,13 @@ var service_exports = /* @__PURE__ */ __exportAll({
13695
14247
  removeIndexEntry: () => removeIndexEntry,
13696
14248
  resolveInstance: () => resolveInstance,
13697
14249
  resolveInstanceTarget: () => resolveInstanceTarget,
14250
+ resolveLauncherVersion: () => resolveLauncherVersion,
13698
14251
  resolveNpmInstallTarget: () => resolveNpmInstallTarget,
13699
14252
  resolveUserLevel: () => resolveUserLevel,
13700
14253
  resolveVersionFromLaunchSpec: () => resolveVersionFromLaunchSpec,
14254
+ restrictEnvFileAccess: () => restrictEnvFileAccess,
13701
14255
  writeIndex: () => writeIndex,
14256
+ writeInstallManifest: () => writeInstallManifest,
13702
14257
  writeManifest: () => writeManifest
13703
14258
  });
13704
14259
  /**
@@ -13736,6 +14291,8 @@ var init_service = __esmMin((() => {
13736
14291
  init_types$1();
13737
14292
  init_platform_detect();
13738
14293
  init_privilege();
14294
+ init_windows_acl();
14295
+ init_windows_env_file();
13739
14296
  init_types();
13740
14297
  init_manifest();
13741
14298
  init_index_file();
@@ -13768,7 +14325,7 @@ function resolveDatabaseUrl$7(explicit) {
13768
14325
  if (!url) throw new Error("Database URL required. Pass --database-url or set KICI_DATABASE_URL.");
13769
14326
  return url;
13770
14327
  }
13771
- async function confirmInteractive$4(prompt, expected) {
14328
+ async function confirmInteractive$1(prompt, expected) {
13772
14329
  if (!process.stdin.isTTY) return false;
13773
14330
  process.stderr.write(prompt);
13774
14331
  const rl = createInterface({
@@ -13896,7 +14453,7 @@ function registerDbCommands(program, getClient) {
13896
14453
  const url = resolveDatabaseUrl$7(opts.databaseUrl);
13897
14454
  const { dbName } = parseDatabaseUrl(url);
13898
14455
  if (!opts.yes) {
13899
- if (!await confirmInteractive$4(`About to DROP + RECREATE database "${dbName}". Type "${dbName}" to confirm: `, dbName)) {
14456
+ if (!await confirmInteractive$1(`About to DROP + RECREATE database "${dbName}". Type "${dbName}" to confirm: `, dbName)) {
13900
14457
  console.error("Aborted.");
13901
14458
  process.exit(1);
13902
14459
  }
@@ -13948,7 +14505,7 @@ function registerDbCommands(program, getClient) {
13948
14505
  const url = resolveDatabaseUrl$7(opts.databaseUrl);
13949
14506
  const { dbName } = parseDatabaseUrl(url);
13950
14507
  if (!opts.yes) {
13951
- if (!await confirmInteractive$4(`About to RESTORE (--clean) into database "${dbName}", overwriting its contents. Type "${dbName}" to confirm: `, dbName)) {
14508
+ if (!await confirmInteractive$1(`About to RESTORE (--clean) into database "${dbName}", overwriting its contents. Type "${dbName}" to confirm: `, dbName)) {
13952
14509
  console.error("Aborted.");
13953
14510
  process.exit(1);
13954
14511
  }
@@ -15087,7 +15644,7 @@ function startManifestLoopback(opts) {
15087
15644
  * blocked launcher) just falls back to copy-paste.
15088
15645
  *
15089
15646
  * `KICI_BROWSER_CMD` overrides the launcher: `none` suppresses it entirely
15090
- * (E2E / headless capture), any other value is run with `{url}` substituted —
15647
+ * (automated tests / headless capture), any other value is run with `{url}` substituted —
15091
15648
  * mirroring the `kici login` convention.
15092
15649
  */
15093
15650
  function openBrowserBestEffort(url) {
@@ -16007,19 +16564,9 @@ function registerSourceCommands(program, getClient) {
16007
16564
  });
16008
16565
  src.command("remove <routingKey>").description("Remove a source (GitHub, or generic/local with --generic / --local)").option("--yes", "Skip confirmation").option("--generic", "Remove a generic source (routingKey is treated as source ID)").option("--local", "Remove a local (file://) source (routingKey is treated as source ID)").option("--hard", "Permanently delete a generic/local source (requires --generic or --local)").option("--database-url <url>", "Use direct DB access instead of HTTP (offline mode)").action(async (routingKey, opts) => {
16009
16566
  try {
16010
- if (!opts.yes) {
16011
- const rl = createInterface({
16012
- input: process.stdin,
16013
- output: process.stdout
16014
- });
16015
- const answer = await new Promise((resolve) => {
16016
- rl.question(`Remove source "${routingKey}"? (y/N) `, resolve);
16017
- });
16018
- rl.close();
16019
- if (answer.toLowerCase() !== "y") {
16020
- console.log("Cancelled.");
16021
- return;
16022
- }
16567
+ if (!opts.yes && !await confirmPrompt(`Remove source "${routingKey}"? (y/N) `)) {
16568
+ console.log("Cancelled.");
16569
+ return;
16023
16570
  }
16024
16571
  const dbUrl = resolveDirectDbUrl$9(opts.databaseUrl);
16025
16572
  if (dbUrl) {
@@ -16177,10 +16724,9 @@ function registerRemoteSourceCommands(program) {
16177
16724
  * Workflow registration commands for kici-admin.
16178
16725
  *
16179
16726
  * Provides operator-facing read access to the workflow_registrations table
16180
- * via the orchestrator's admin HTTP API. This is the dogfooded replacement
16181
- * for "let me just psql the registration table" — every CLI subcommand here
16182
- * goes through AdminApiClient and the existing /api/v1/admin/registrations
16183
- * route.
16727
+ * via the orchestrator's admin HTTP API. Use it instead of querying the
16728
+ * registration table with psql: every CLI subcommand here goes through
16729
+ * AdminApiClient and the existing /api/v1/admin/registrations route.
16184
16730
  *
16185
16731
  * workflow list List registered workflows (table or JSON)
16186
16732
  *
@@ -16263,7 +16809,7 @@ function renderTable$3(headers, rows) {
16263
16809
  */
16264
16810
  function registerWorkflowCommands(program, getClient) {
16265
16811
  const wf = program.command("workflow").description("Inspect workflow registrations");
16266
- wf.command("list").description("List workflow registrations (dogfooded via /api/v1/admin/registrations)").option("--org <orgId>", "Filter by customer/org id (server param: customerId)").option("--routing-key <key>", "Filter by routing key, e.g. github:42").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--trigger-type <type>", "Filter by trigger type, e.g. webhook, push, schedule").option("--event <eventName>", "Filter by webhook event name (scans lock_entry.triggers)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16812
+ wf.command("list").description("List workflow registrations (admin API: /api/v1/admin/registrations)").option("--org <orgId>", "Filter by customer/org id (server param: customerId)").option("--routing-key <key>", "Filter by routing key, e.g. github:42").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--trigger-type <type>", "Filter by trigger type, e.g. webhook, push, schedule").option("--event <eventName>", "Filter by webhook event name (scans lock_entry.triggers)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16267
16813
  try {
16268
16814
  const path = `/api/v1/admin/registrations${buildQueryString({
16269
16815
  org: opts.org,
@@ -16387,7 +16933,7 @@ function emptyStepLogNote(stepIndex, logs) {
16387
16933
  */
16388
16934
  function registerRunsCommands(program, getClient) {
16389
16935
  const runs = program.command("runs").description("Inspect execution runs, jobs, and steps");
16390
- runs.command("list").description("List execution runs (dogfooded via /api/v1/admin/runs)").option("--status <statuses>", "Filter by run status. Accepts a single value or a comma-separated list (e.g. success,failed)").option("--workflow-name <name>", "Filter by workflow name").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--since <iso8601>", "Only include runs with created_at strictly later than this ISO-8601 timestamp").option("--count", "Return only the count of matching runs, skipping the row listing").option("--limit <n>", "Max results (default 20, max 100)", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16936
+ runs.command("list").description("List execution runs (admin API: /api/v1/admin/runs)").option("--status <statuses>", "Filter by run status. Accepts a single value or a comma-separated list (e.g. success,failed)").option("--workflow-name <name>", "Filter by workflow name").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--since <iso8601>", "Only include runs with created_at strictly later than this ISO-8601 timestamp").option("--count", "Return only the count of matching runs, skipping the row listing").option("--limit <n>", "Max results (default 20, max 100)", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16391
16937
  try {
16392
16938
  if (opts.count) {
16393
16939
  const data = await getClient().countRuns({
@@ -16590,7 +17136,7 @@ function registerRunsCommands(program, getClient) {
16590
17136
  process.exit(1);
16591
17137
  }
16592
17138
  });
16593
- runs.command("jobs <runId>").description("List jobs for a run (dogfooded via /api/v1/admin/runs/:runId/jobs)").option("--include-steps", "Embed step list inside each job (default false)").option("--json", "Emit raw JSON instead of a table").action(async (runId, opts) => {
17139
+ runs.command("jobs <runId>").description("List jobs for a run (admin API: /api/v1/admin/runs/:runId/jobs)").option("--include-steps", "Embed step list inside each job (default false)").option("--json", "Emit raw JSON instead of a table").action(async (runId, opts) => {
16594
17140
  try {
16595
17141
  const response = await getClient().getRunJobs(runId, { includeSteps: Boolean(opts.includeSteps) });
16596
17142
  if (opts.json) {
@@ -16644,7 +17190,7 @@ function registerRunsCommands(program, getClient) {
16644
17190
  process.exit(1);
16645
17191
  }
16646
17192
  });
16647
- runs.command("logs <runId>").description("Print a page of a step log (dogfooded via /api/v1/admin/runs/:runId/jobs/:jobId/steps/:i/logs)").requiredOption("--job <jobId>", "Job id (the dispatch_queue row id for an eval round)").option("--step <n>", "Step index (default 0; -1 is the job setup log)", "0").option("--limit <n>", "Max lines to return (default 500, server caps at 2000)", "500").option("--cursor <c>", "Line-offset cursor from a previous page").option("--json", "Emit raw JSON instead of plain lines").action(async (runId, opts) => {
17193
+ runs.command("logs <runId>").description("Print a page of a step log (admin API: /api/v1/admin/runs/:runId/jobs/:jobId/steps/:i/logs)").requiredOption("--job <jobId>", "Job id (the dispatch_queue row id for an eval round)").option("--step <n>", "Step index (default 0; -1 is the job setup log)", "0").option("--limit <n>", "Max lines to return (default 500, server caps at 2000)", "500").option("--cursor <c>", "Line-offset cursor from a previous page").option("--json", "Emit raw JSON instead of plain lines").action(async (runId, opts) => {
16648
17194
  try {
16649
17195
  const params = new URLSearchParams({ limit: String(opts.limit) });
16650
17196
  if (opts.cursor) params.set("cursor", String(opts.cursor));
@@ -16754,6 +17300,8 @@ function clusterSentinelKey(prefix) {
16754
17300
  * Provider configuration (GitHub Apps, etc.) is managed via the `sources` table
16755
17301
  * and PgSecretStore, not through config. See SourceStore and SourceManager.
16756
17302
  */
17303
+ /** How long the database pool waits for a connection when `KICI_DB_POOL_ACQUIRE_TIMEOUT_MS` is unset. */
17304
+ const DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS = 5e3;
16757
17305
  /**
16758
17306
  * Default for `sealed_secrets_retry_backoff_ms`: how long a coordinator stops
16759
17307
  * offering a queued job it put back because it could not open its seal. The
@@ -16764,7 +17312,7 @@ const DEFAULT_SEALED_SECRETS_RETRY_BACKOFF_MS = 6e4;
16764
17312
  const PLATFORM_CONNECTED_MODES_TEXT = `${PLATFORM_CONNECTED_MODES.slice(0, -1).join(", ")}, or ${PLATFORM_CONNECTED_MODES[PLATFORM_CONNECTED_MODES.length - 1]}`;
16765
17313
  const baseSchema = z.object({
16766
17314
  mode: OrchestratorMode.default("platform"),
16767
- port: z.coerce.number().default(4e3),
17315
+ port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
16768
17316
  /**
16769
17317
  * Address the HTTP + WebSocket listener binds. Defaults to every interface,
16770
17318
  * matching the Platform's own `KICI_HOST`, because a real orchestrator
@@ -16841,7 +17389,7 @@ const baseSchema = z.object({
16841
17389
  orchestratorUrl: z.string().optional(),
16842
17390
  databaseUrl: z.string().default(""),
16843
17391
  dbPoolMax: z.coerce.number().int().positive().default(20),
16844
- dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(5e3),
17392
+ dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS),
16845
17393
  dbStatementTimeoutMs: z.coerce.number().int().nonnegative().default(3e4),
16846
17394
  lockfileCacheMax: z.coerce.number().default(500),
16847
17395
  lockfileCacheTtlMs: z.coerce.number().default(36e5),
@@ -17130,7 +17678,7 @@ const configSchema = baseSchema.superRefine((data, ctx) => {
17130
17678
  const packagingConfigSchema = baseSchema.superRefine(refineStorageShape);
17131
17679
  /**
17132
17680
  * Env-var definition for the orchestrator. Exported so the docs generator and
17133
- * the deploy-stg pre-validator can re-parse without going through process.env.
17681
+ * config pre-validation can re-parse without going through process.env.
17134
17682
  *
17135
17683
  * Note: passes the inner `baseSchema` (a ZodObject) so describe() can walk
17136
17684
  * `.shape`, but uses the outer `configSchema` (with .superRefine) as the parser
@@ -17378,8 +17926,8 @@ const COLD_STORE_ENV_VARS = [
17378
17926
  "KICI_COLD_STORE_EVENT_LOG_ENABLED"
17379
17927
  ];
17380
17928
  /**
17381
- * Deployment-identity env vars injected by the installer (and the staging
17382
- * deploy) so the orchestrator can report its own deployment shape in
17929
+ * Deployment-identity env vars injected by the installer (or other deploy
17930
+ * tooling) so the orchestrator can report its own deployment shape in
17383
17931
  * `source.register`. Read directly from `process.env` by the deployment reader,
17384
17932
  * not threaded through the AppConfig schema — registered here so the
17385
17933
  * unknown-KICI_* validator at boot doesn't reject them.
@@ -17757,7 +18305,8 @@ var S3CacheStorage = class {
17757
18305
  * Signature input: `${method}:${key}:${expiresUnixMs}` with HMAC-SHA256 keyed
17758
18306
  * by a per-orchestrator-process secret (random 32-byte hex at boot, persists
17759
18307
  * for the lifetime of the process). Tokens become invalid on orchestrator
17760
- * restart — fine for the filesystem backend's E2E / single-host use case.
18308
+ * restart — fine for the filesystem backend's single-host / test-sandbox use
18309
+ * case.
17761
18310
  */
17762
18311
  /** Default URL lifetime: 1 hour (matches S3 backend's PUT-URL lifetime). */
17763
18312
  const DEFAULT_SIGN_URL_TTL_MS = 36e5;
@@ -17793,7 +18342,7 @@ function signToken(secret, method, key, ttlMs = DEFAULT_SIGN_URL_TTL_MS) {
17793
18342
  * are replaced by HMAC-signed `http://<orchUrl>/api/v1/cache/blob/...` URLs
17794
18343
  * — see `sign-url.ts` for the token mechanics and `app.ts` for the route.
17795
18344
  *
17796
- * Intended for single-host deployments and E2E sandboxes where standing up
18345
+ * Intended for single-host deployments and test sandboxes where standing up
17797
18346
  * an S3-compatible service is overkill. Production should still use S3.
17798
18347
  *
17799
18348
  * Metadata is co-located as a sibling JSON file (`<key>.meta.json`) so the
@@ -18063,7 +18612,7 @@ function createCacheStorage(config) {
18063
18612
  //#region src/provenance/trust-root.ts
18064
18613
  /**
18065
18614
  * Derive the JWKS URI for a provenance OIDC issuer. Identical to the Platform's
18066
- * own derivation (`packages/platform/src/dashboard/routes/runs.ts`) so a bundle
18615
+ * own derivation so a bundle
18067
18616
  * verified client-side and at ingest resolve the same key set.
18068
18617
  */
18069
18618
  function deriveJwksUri(issuer) {
@@ -18292,16 +18841,6 @@ function readAttestations(db) {
18292
18841
  }
18293
18842
  //#endregion
18294
18843
  //#region src/cli/commands/attestations.ts
18295
- /**
18296
- * `kici-admin attestations` — orchestrator-DB-direct provenance maintenance.
18297
- *
18298
- * attestations reverify [--all] Recompute stored verdicts (verify-at-ingest
18299
- * backfill). Default scope: pending /
18300
- * unverifiable rows; `--all` re-evaluates
18301
- * every row. Direct DB + object storage.
18302
- *
18303
- * Classified `kici-admin` (orchestrator DB plane) per .claude/rules/platform-admin.md.
18304
- */
18305
18844
  init_client();
18306
18845
  const logger$15 = createLogger({ prefix: "kici-admin-attestations" });
18307
18846
  function resolveDatabaseUrl$5(explicit) {
@@ -18330,18 +18869,6 @@ function buildStorage(config) {
18330
18869
  signingSecret: "kici-admin-reverify"
18331
18870
  });
18332
18871
  }
18333
- async function confirmInteractive$3(prompt) {
18334
- const rl = createInterface({
18335
- input: process.stdin,
18336
- output: process.stderr
18337
- });
18338
- try {
18339
- const answer = await new Promise((resolve) => rl.question(prompt, resolve));
18340
- return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
18341
- } finally {
18342
- rl.close();
18343
- }
18344
- }
18345
18872
  /** Render attestation rows as a compact human table (stderr note when empty). */
18346
18873
  function printAttestationTable(rows) {
18347
18874
  if (rows.length === 0) {
@@ -18371,7 +18898,7 @@ function registerAttestationsCommands(program, getClient) {
18371
18898
  const config = loadConfig();
18372
18899
  const url = resolveDatabaseUrl$5(opts.databaseUrl ?? (config.databaseUrl || void 0));
18373
18900
  if (opts.all && !opts.yes) {
18374
- if (!await confirmInteractive$3("Re-evaluate the verdict of EVERY attestation (including already-verified)? [y/N] ")) {
18901
+ if (!await confirmPrompt("Re-evaluate the verdict of EVERY attestation (including already-verified)? [y/N] ")) {
18375
18902
  console.error("Aborted.");
18376
18903
  process.exit(1);
18377
18904
  }
@@ -18594,7 +19121,7 @@ var OrchestratorSigningKeyRepo = class {
18594
19121
  * The in-memory private key is imported NON-EXTRACTABLE (`extractable: false`),
18595
19122
  * so once loaded the raw private material can never be read back out of the
18596
19123
  * process — the only export path is the public JWK. This is what makes the
18597
- * private key "non-exportable by design" (design spec § A / § H).
19124
+ * private key "non-exportable by design".
18598
19125
  */
18599
19126
  /** AAD binding the wrapped private JWK to its purpose (AES-GCM authenticated data). */
18600
19127
  const SIGNING_KEY_AAD = "orchestrator-signing-key";
@@ -18707,8 +19234,7 @@ var DbSigner = class DbSigner {
18707
19234
  //#region src/cli/commands/signing-key.ts
18708
19235
  /**
18709
19236
  * `kici-admin signing-key` — orchestrator-owned provenance signing key
18710
- * management (orchestrator DB plane; classified `kici-admin` per
18711
- * .claude/rules/platform-admin.md).
19237
+ * management (orchestrator DB plane, so it belongs to `kici-admin`).
18712
19238
  *
18713
19239
  * signing-key list List keys (kid / status / created_at).
18714
19240
  * Reads the database when a database URL
@@ -18725,7 +19251,7 @@ var DbSigner = class DbSigner {
18725
19251
  * halves ONLY — never private material.
18726
19252
  *
18727
19253
  * The PRIVATE key is non-exportable by design: there is no export of private
18728
- * material and no import. Loss recovery is a routine rotation (§ H of the design).
19254
+ * material and no import. Loss recovery is a routine rotation.
18729
19255
  */
18730
19256
  init_client();
18731
19257
  const logger$14 = createLogger({ prefix: "kici-admin-signing-key" });
@@ -18766,18 +19292,6 @@ function resolveSecretKey$1() {
18766
19292
  if (!config.secretKey) throw new Error("KICI_SECRET_KEY is required to generate a db-custody signing key (it wraps the private JWK).");
18767
19293
  return config.secretKey;
18768
19294
  }
18769
- async function confirmInteractive$2(prompt) {
18770
- const rl = createInterface({
18771
- input: process.stdin,
18772
- output: process.stderr
18773
- });
18774
- try {
18775
- const a = (await new Promise((resolve) => rl.question(prompt, resolve))).trim().toLowerCase();
18776
- return a === "y" || a === "yes";
18777
- } finally {
18778
- rl.close();
18779
- }
18780
- }
18781
19295
  async function withRepo$1(databaseUrl, fn) {
18782
19296
  const pool = createPool(resolveDatabaseUrl$4(databaseUrl));
18783
19297
  const db = createDb$1(pool);
@@ -18869,7 +19383,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
18869
19383
  process.stderr.write(`Generated active signing key ${outcome.kid}.\n`);
18870
19384
  }
18871
19385
  }, {
18872
- confirm: confirmInteractive$2,
19386
+ confirm: confirmPrompt,
18873
19387
  yes: opts.yes,
18874
19388
  dryRun: opts.dryRun
18875
19389
  });
@@ -18893,7 +19407,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
18893
19407
  process.stderr.write(`Rotated: new active signing key ${kid}.\n`);
18894
19408
  }
18895
19409
  }, {
18896
- confirm: confirmInteractive$2,
19410
+ confirm: confirmPrompt,
18897
19411
  yes: opts.yes,
18898
19412
  dryRun: opts.dryRun
18899
19413
  });
@@ -18915,7 +19429,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
18915
19429
  signingKey.command("revoke <kid>").description("Distrust a compromised key (REMOVED from the JWKS; everything it signed is distrusted)").requiredOption("--reason <reason>", "Why the key is being revoked (audit)").option("--database-url <url>", "Orchestrator DB URL (else KICI_DATABASE_URL)").option("--yes", "Skip the confirmation prompt").action(async (kid, opts) => {
18916
19430
  try {
18917
19431
  if (!opts.yes) {
18918
- if (!await confirmInteractive$2(`Revoke signing key ${kid}? Everything it ever signed becomes UNVERIFIABLE. [y/N] `)) {
19432
+ if (!await confirmPrompt(`Revoke signing key ${kid}? Everything it ever signed becomes UNVERIFIABLE. [y/N] `)) {
18919
19433
  console.error("Aborted.");
18920
19434
  process.exit(1);
18921
19435
  }
@@ -19261,6 +19775,41 @@ function encryptPrivateKey(privateKey, secretKey) {
19261
19775
  }
19262
19776
  createLogger({ prefix: "job-secret-seal" });
19263
19777
  runtimeLabel(RuntimeFact.enum.docker), runtimeLabel(RuntimeFact.enum.podman);
19778
+ //#endregion
19779
+ //#region src/helpers/run-detached.ts
19780
+ /**
19781
+ * Running work that nothing awaits.
19782
+ *
19783
+ * A promise that nobody awaits and nobody catches becomes an unhandled
19784
+ * rejection when it fails, and the orchestrator's shutdown hook treats an
19785
+ * unhandled rejection as fatal: one failed query in a timer tick or a message
19786
+ * handler would stop the whole process. `runDetached` runs such work and logs
19787
+ * its failure instead. ESLint's `no-floating-promises` (with `ignoreVoid:
19788
+ * false`) refuses a bare or `void`-ed promise anywhere in this package, so
19789
+ * detached work runs through here or carries its own `.catch`.
19790
+ */
19791
+ /**
19792
+ * Run `work` without waiting for it. A throw, or a rejection of the promise it
19793
+ * returns, is logged at error level as `<task> failed` with `context` and the
19794
+ * error message, and goes no further. `context` is read when the failure is
19795
+ * logged, so the work may fill it in while it runs.
19796
+ */
19797
+ function runDetached(logger, task, work, context = {}) {
19798
+ const fail = (err) => {
19799
+ logger.error(`${task} failed`, {
19800
+ ...context,
19801
+ error: toErrorMessage(err)
19802
+ });
19803
+ };
19804
+ let outcome;
19805
+ try {
19806
+ outcome = work();
19807
+ } catch (err) {
19808
+ fail(err);
19809
+ return;
19810
+ }
19811
+ Promise.resolve(outcome).catch(fail);
19812
+ }
19264
19813
  createLogger({ prefix: "instance-heartbeat" });
19265
19814
  createLogger({ prefix: "job-queue" });
19266
19815
  /**
@@ -19287,24 +19836,6 @@ async function generateDashboardEncryptionKey(secretKey) {
19287
19836
  }
19288
19837
  //#endregion
19289
19838
  //#region src/cli/commands/dashboard-encryption-key.ts
19290
- /**
19291
- * `kici-admin dashboard-encryption-key` — the orchestrator's X25519
19292
- * dashboard-encryption key: the trust root a browser seals a secret / variable
19293
- * value to under the `encrypted` dashboard-write posture, so the hosted control
19294
- * plane relays only opaque ciphertext.
19295
- *
19296
- * dashboard-encryption-key show Print the active kid, its public JWK, where
19297
- * the JWKS is published, and the Verified-tier
19298
- * URL (when that tier is opted into).
19299
- * dashboard-encryption-key list List every key on record (kid / status / created_at).
19300
- * dashboard-encryption-key rotate Mint a new active key; the prior active key
19301
- * is retired from the published JWKS, but its
19302
- * private half stays on record so envelopes
19303
- * already sealed to it still decrypt.
19304
- *
19305
- * The PRIVATE half is master-key wrapped (`KICI_SECRET_KEY`) in the orchestrator
19306
- * DB and is never exportable — recovery from a lost key is a routine rotation.
19307
- */
19308
19839
  init_client();
19309
19840
  const logger$9 = createLogger({ prefix: "kici-admin-dashboard-encryption-key" });
19310
19841
  function resolveDatabaseUrl$3(explicit) {
@@ -19317,18 +19848,6 @@ function resolveSecretKey() {
19317
19848
  if (!config.secretKey) throw new Error("KICI_SECRET_KEY is required for the dashboard encryption key (it wraps the private key).");
19318
19849
  return config.secretKey;
19319
19850
  }
19320
- async function confirmInteractive$1(prompt) {
19321
- const rl = createInterface({
19322
- input: process.stdin,
19323
- output: process.stderr
19324
- });
19325
- try {
19326
- const a = (await new Promise((resolve) => rl.question(prompt, resolve))).trim().toLowerCase();
19327
- return a === "y" || a === "yes";
19328
- } finally {
19329
- rl.close();
19330
- }
19331
- }
19332
19851
  async function withRepo(databaseUrl, fn) {
19333
19852
  const pool = createPool(resolveDatabaseUrl$3(databaseUrl));
19334
19853
  const db = createDb$1(pool);
@@ -19435,7 +19954,7 @@ function registerDashboardEncryptionKeyCommands(program) {
19435
19954
  process.stderr.write(`Rotated: new active dashboard-encryption key ${kid}.\n`);
19436
19955
  }
19437
19956
  }, {
19438
- confirm: confirmInteractive$1,
19957
+ confirm: confirmPrompt,
19439
19958
  yes: opts.yes,
19440
19959
  dryRun: opts.dryRun
19441
19960
  });
@@ -19459,7 +19978,7 @@ function renderTable$1(headers, rows) {
19459
19978
  }
19460
19979
  function registerEventLogCommands(program, getClient) {
19461
19980
  const eventLog = program.command("event-log").description("Inspect the inbound webhook delivery log");
19462
- eventLog.command("list").description("List inbound webhook deliveries (dogfooded via /api/v1/admin/event-log)").option("--org <orgId>", "Filter by org/tenant ID").option("--routing-key <key>", "Filter by routing key (e.g. github:42)").option("--event <type>", "Filter by event type (e.g. push, pull_request)").option("--action <action>", "Filter by event action (e.g. opened, closed, synchronize for pull_request)").option("--status <s>", "Filter by outcome status (received|shed|processed|duplicate|lockfile_missing|lockfile_corrupt|failed)").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--delivery-id <substr>", "Substring filter on delivery_id").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--offset <n>", "Skip first N results", "0").option("--include-archived", "Merge cold-store archived rows into the result (requires --routing-key for cold scoping)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19981
+ eventLog.command("list").description("List inbound webhook deliveries (admin API: /api/v1/admin/event-log)").option("--org <orgId>", "Filter by org/tenant ID").option("--routing-key <key>", "Filter by routing key (e.g. github:42)").option("--event <type>", "Filter by event type (e.g. push, pull_request)").option("--action <action>", "Filter by event action (e.g. opened, closed, synchronize for pull_request)").option("--status <s>", "Filter by outcome status (received|shed|processed|duplicate|lockfile_missing|lockfile_corrupt|failed)").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--delivery-id <substr>", "Substring filter on delivery_id").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--offset <n>", "Skip first N results", "0").option("--include-archived", "Merge cold-store archived rows into the result (requires --routing-key for cold scoping)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19463
19982
  try {
19464
19983
  const response = await getClient().listEventLog({
19465
19984
  orgId: opts.org,
@@ -19573,7 +20092,7 @@ function renderTable(headers, rows) {
19573
20092
  }
19574
20093
  function registerAccessLogCommands(program, getClient) {
19575
20094
  const accessLog = program.command("access-log").description("Inspect the read / admin-mutation access log");
19576
- accessLog.command("list").description("List access-log rows (dogfooded via /api/v1/admin/access-log)").option("--org-id <orgId>", "Filter by org/tenant ID").option("--actor-type <t>", "Filter by actor type (user|api_key|service_account|platform_operator|system)").option("--actor-id <id>", "Filter by actor id (zsub, keyId, service_account id, ...)").option("--action <action>", "Filter by dotted action (e.g. run.detail.read, run.cancel)").option("--source <s>", "Filter by source (platform_proxy|admin_http|admin_cli)").option("--outcome <o>", "Filter by outcome (allowed|denied|error)").option("--target-type <t>", "Filter by target type (run|step|event_log|secret_scope|...)").option("--target-id <id>", "Filter by target id").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--q <text>", "Filter by substring of error_message (trigram-indexed full-text search)").option("--agent-label <label>", "Filter by exact agent label").option("--agent-only", "Only agent-attributed rows").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--cursor <c>", "Opaque cursor from a previous nextCursor").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
20095
+ accessLog.command("list").description("List access-log rows (admin API: /api/v1/admin/access-log)").option("--org-id <orgId>", "Filter by org/tenant ID").option("--actor-type <t>", "Filter by actor type (user|api_key|service_account|platform_operator|system)").option("--actor-id <id>", "Filter by actor id (zsub, keyId, service_account id, ...)").option("--action <action>", "Filter by dotted action (e.g. run.detail.read, run.cancel)").option("--source <s>", "Filter by source (platform_proxy|admin_http|admin_cli)").option("--outcome <o>", "Filter by outcome (allowed|denied|error)").option("--target-type <t>", "Filter by target type (run|step|event_log|secret_scope|...)").option("--target-id <id>", "Filter by target id").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--q <text>", "Filter by substring of error_message (trigram-indexed full-text search)").option("--agent-label <label>", "Filter by exact agent label").option("--agent-only", "Only agent-attributed rows").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--cursor <c>", "Opaque cursor from a previous nextCursor").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19577
20096
  try {
19578
20097
  const response = await getClient().listAccessLog({
19579
20098
  orgId: opts.orgId,
@@ -19705,7 +20224,7 @@ function registerBackendCommands(program, getClient) {
19705
20224
  try {
19706
20225
  scopeInfo = ` has ${(await getClient().getBackend(name)).scopeCount ?? 0} discovered scopes -- they will become unavailable.`;
19707
20226
  } catch {}
19708
- if (!await confirm$3(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
20227
+ if (!await confirmPrompt(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
19709
20228
  console.log("Aborted.");
19710
20229
  return;
19711
20230
  }
@@ -19892,21 +20411,6 @@ function printBackendSummary(b) {
19892
20411
  console.log(` Sync interval: ${Math.round(Number(b.syncIntervalMs) / 6e4)}m`);
19893
20412
  console.log(` Enabled: ${b.enabled}`);
19894
20413
  }
19895
- /**
19896
- * Simple confirmation prompt.
19897
- */
19898
- async function confirm$3(message) {
19899
- const rl = createInterface({
19900
- input: process.stdin,
19901
- output: process.stderr
19902
- });
19903
- return new Promise((resolve) => {
19904
- rl.question(message, (answer) => {
19905
- rl.close();
19906
- resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
19907
- });
19908
- });
19909
- }
19910
20414
  //#endregion
19911
20415
  //#region src/cli/commands/diagnose.ts
19912
20416
  /** ANSI color codes for terminal output. */
@@ -20257,6 +20761,16 @@ function registerInspectBundleCommand(program) {
20257
20761
  //#endregion
20258
20762
  //#region src/cli/service/entrypoint.ts
20259
20763
  /**
20764
+ * Resolves the command a service unit should run.
20765
+ *
20766
+ * A `npm install -g kici-admin` install exposes only the `kici-admin` /
20767
+ * `kici-agent` CLI bins — the long-running orchestrator and agent servers are
20768
+ * separate module entry points (`dist/server.js` / `dist/standalone.js`) that
20769
+ * must be launched with `node <script>`. These helpers turn an install's
20770
+ * options into the `{ executablePath, args }` pair the platform service
20771
+ * managers write into the unit's run command.
20772
+ */
20773
+ /**
20260
20774
  * Pick the orchestrator server entry from an env file's `KICI_MODE`.
20261
20775
  * `independent` runs the standalone server; `platform` / `hybrid` / unset run
20262
20776
  * the Platform-connected server.
@@ -20281,6 +20795,34 @@ function resolveServiceExecutable(opts) {
20281
20795
  args: [opts.entryScript]
20282
20796
  };
20283
20797
  }
20798
+ /**
20799
+ * The path of a server entry script installed beside this CLI, for a service
20800
+ * that runs `node <script>`. `resolve` is the calling module's
20801
+ * `import.meta.resolve`, or undefined where the CLI has none: a kici-admin from a
20802
+ * standalone package is a single CommonJS bundle that carries no orchestrator
20803
+ * or agent server. Either way the error names `--binary`, the launcher the
20804
+ * service runs instead.
20805
+ */
20806
+ function resolveServerScript(specifier, component, resolve) {
20807
+ let cause = NO_RESOLVER;
20808
+ if (resolve) try {
20809
+ return fileURLToPath(resolve(specifier));
20810
+ } catch (err) {
20811
+ cause = err instanceof Error ? err.message : String(err);
20812
+ }
20813
+ throw new Error(missingServerMessage(component, specifier, cause));
20814
+ }
20815
+ /**
20816
+ * Refuse an install without `--binary` on a kici-admin that cannot resolve
20817
+ * installed packages, before the wizard runs or any file is written.
20818
+ */
20819
+ function assertServerResolvable(component, binary, resolve) {
20820
+ if (binary === void 0 && resolve === void 0) throw new Error(missingServerMessage(component, `@kici-dev/${component}`, NO_RESOLVER));
20821
+ }
20822
+ const NO_RESOLVER = "this kici-admin cannot resolve installed packages";
20823
+ function missingServerMessage(component, specifier, cause) {
20824
+ return `kici-admin cannot find the ${component} server (${specifier}): ${cause}. Pass --binary with the path of the ${component} launcher, such as the one in a standalone ${component} package.`;
20825
+ }
20284
20826
  //#endregion
20285
20827
  //#region src/cli/service/deploy-env.ts
20286
20828
  init_compose_path();
@@ -20359,6 +20901,11 @@ async function waitForQuiesce(poll, opts) {
20359
20901
  }
20360
20902
  }
20361
20903
  /**
20904
+ * What a version switch prints when it cannot read the live migration ledger,
20905
+ * so it could not compare the schema to the recorded head.
20906
+ */
20907
+ const SCHEMA_NOT_CHECKED = "The running service could not be reached, so the schema was not checked.";
20908
+ /**
20362
20909
  * The last applied migration reported by a running service.
20363
20910
  *
20364
20911
  * Returns null when nothing has been applied — a fresh database — which the
@@ -20378,17 +20925,18 @@ function migrationsAfter(rows, head) {
20378
20925
  * Decide whether the version pointer may move.
20379
20926
  *
20380
20927
  * Three outcomes, and the middle one is the compatibility shim: an instance
20381
- * installed before `migrationHeads` existed has no recorded head, and refusing
20382
- * there would break the documented `--rollback` on exactly the installs that
20383
- * predate the guard. So an unknown head warns loudly and falls through to the
20384
- * existing confirmation prompt; the map is populated at the next version
20385
- * change, arming the guard one upgrade later.
20928
+ * installed before `migrationHeads` existed, or one whose upgrade away from the
20929
+ * target ran without admin access, has no recorded head, and refusing there
20930
+ * would break the documented `--rollback` on exactly those installs. So an
20931
+ * unknown head warns loudly and falls through to the existing confirmation
20932
+ * prompt; the map is populated at the next version change with admin access,
20933
+ * arming the guard one upgrade later.
20386
20934
  */
20387
20935
  function schemaGuardVerdict(input) {
20388
20936
  const { targetVersion, recordedHead, liveRows } = input;
20389
20937
  if (!recordedHead) return {
20390
20938
  kind: "unknown-head",
20391
- message: `WARNING: no migration head is recorded for ${targetVersion}.\n This instance was installed before the schema guard existed, so it cannot be\n checked whether the database is ahead of that version. If it is, the older\n binary will refuse to boot with "corrupted migrations" and restart in a loop.\n Take a dump first ("kici-admin db backup"). If the switch does loop, start the\n older version with KICI_AUTO_MIGRATE=false — it boots only if every newer\n migration was purely additive.\n The head is recorded from this version change on, so the guard is armed for\n the next one.`
20939
+ message: `WARNING: no migration head is recorded for ${targetVersion}.\n Either this instance was installed before the schema guard existed, or the\n upgrade away from ${targetVersion} ran without admin access (KICI_ADMIN_TOKEN).\n So it cannot be checked whether the database is ahead of that version. If it\n is, the older binary will refuse to boot with "corrupted migrations" and\n restart in a loop.\n Take a dump first ("kici-admin db backup"). If the switch does loop, start the\n older version with KICI_AUTO_MIGRATE=false — it boots only if every newer\n migration was purely additive.\n A version change with admin access records the head, which arms the guard\n for the next one.`
20392
20940
  };
20393
20941
  const live = appliedHead(liveRows);
20394
20942
  if (live === null || live === recordedHead) return { kind: "ok" };
@@ -20475,8 +21023,7 @@ async function resolveUpgradeTarget(args) {
20475
21023
  return {
20476
21024
  config: {
20477
21025
  name: resolved.manifest.name,
20478
- displayName: `KiCI ${component}`,
20479
- description: `KiCI ${component} service`,
21026
+ ...SERVICE_TEXT[component],
20480
21027
  executablePath: "",
20481
21028
  envFilePath: resolved.manifest.envFilePath,
20482
21029
  workingDirectory: resolved.manifest.configDir,
@@ -20491,18 +21038,12 @@ async function resolveUpgradeTarget(args) {
20491
21038
  platform
20492
21039
  };
20493
21040
  }
20494
- /** Prompt the user for confirmation (returns true if yes). */
20495
- async function confirm$2(message) {
20496
- const rl = createInterface({
21041
+ /** Prompt the user for confirmation on stdout, beside the upgrade plan it follows. */
21042
+ function confirm$1(message) {
21043
+ return confirmPrompt(`${message} [y/N] `, {
20497
21044
  input: process.stdin,
20498
21045
  output: process.stdout
20499
21046
  });
20500
- return new Promise((resolve) => {
20501
- rl.question(`${message} [y/N] `, (answer) => {
20502
- rl.close();
20503
- resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
20504
- });
20505
- });
20506
21047
  }
20507
21048
  /** Download a file from a URL to a local path. */
20508
21049
  async function downloadArchive(url, destPath) {
@@ -20517,8 +21058,8 @@ async function downloadArchive(url, destPath) {
20517
21058
  * Install base for a KiCI component instance.
20518
21059
  *
20519
21060
  * Name-scoped so that two instances of the same component (e.g. an org's
20520
- * dogfood orchestrator and an E2E test orchestrator) own independent
20521
- * versioned trees and symlinks. Per-platform bases:
21061
+ * production orchestrator and a test orchestrator on the same host) own
21062
+ * independent versioned trees and symlinks. Per-platform bases:
20522
21063
  * - systemd / compose: /opt/kici/<name>/
20523
21064
  * - launchd: /usr/local/kici/<name>/
20524
21065
  * - windows: C:\Program Files\KiCI\<name>\
@@ -20570,10 +21111,73 @@ function checkPickFlagConflicts(opts) {
20570
21111
  if (conflicts.length === 0) return null;
20571
21112
  return `--pick cannot be combined with ${conflicts.join(", ")}. --pick activates an already-installed version; it never downloads or takes an explicit version.`;
20572
21113
  }
21114
+ /**
21115
+ * Point the Windows service at `launcherPath`. sc.exe has no "update binary
21116
+ * path", so `install` replaces the registration: it checks the new launch
21117
+ * command first, then removes the old service and waits until it is gone
21118
+ * before it creates the new one. A launch command it refuses leaves the old
21119
+ * registration in place.
21120
+ */
21121
+ async function reregisterWindowsService(manager, config, launcherPath) {
21122
+ config.executablePath = launcherPath;
21123
+ await manager.install(config);
21124
+ }
20573
21125
  /** Check if the platform is Windows. */
20574
21126
  function isWindows(platform) {
20575
21127
  return platform === "windows";
20576
21128
  }
21129
+ /**
21130
+ * Refuse to point a Windows service at a release from before KICI_ENV_FILE.
21131
+ * Every caller runs it before it stops the service, so a refused switch leaves
21132
+ * the service registered and running as it was.
21133
+ */
21134
+ function assertWindowsLaunchReadsEnvFile(args) {
21135
+ if (launchReadsEnvFile(args.launch)) return;
21136
+ throw new Error(`refusing to ${args.action}: ${envFileRefusal(args.release, args.config.envFilePath)} The service was not changed.`);
21137
+ }
21138
+ /**
21139
+ * Stop the service, register it again on Windows, then start it: the restart
21140
+ * every npm-source upgrade ends with.
21141
+ *
21142
+ * `npm install -g` replaces the package in place, so the launch command the
21143
+ * service runs (`spec`, read back from its registration) stays valid. On
21144
+ * Windows the registration around that command (the service wrapper's
21145
+ * arguments, the description, the recovery settings) is whatever the CLI that
21146
+ * installed it wrote, so `install` writes the current one, as the archive
21147
+ * upgrade does. A launch command that names a file that does not exist was
21148
+ * read back wrong, so it is not registered: the service restarts as it was,
21149
+ * with a warning. systemd and launchd keep their unit as it is.
21150
+ */
21151
+ async function restartOntoInstalledPackage(args) {
21152
+ const { manager, config, platform, spec } = args;
21153
+ const component = config.component ?? "orchestrator";
21154
+ const read = spec ? {
21155
+ executablePath: spec.execPath,
21156
+ args: spec.args
21157
+ } : null;
21158
+ const launch = read && fs.existsSync(read.executablePath) && fs.existsSync(launchedRelease(read)) ? read : null;
21159
+ if (isWindows(platform) && launch) assertWindowsLaunchReadsEnvFile({
21160
+ launch,
21161
+ release: launchedRelease(launch),
21162
+ action: `restart "${config.name}" onto the installed package`,
21163
+ config
21164
+ });
21165
+ if ((await manager.status(config)).state === "running") await manager.stop(config);
21166
+ if (isWindows(platform)) {
21167
+ if (launch) {
21168
+ try {
21169
+ await manager.install({
21170
+ ...config,
21171
+ ...launch
21172
+ });
21173
+ } catch (err) {
21174
+ throw new Error(`could not register the service again: ${toErrorMessage(err)}. Run \`kici-admin ${component} install\` for it.`, { cause: err });
21175
+ }
21176
+ console.log("Service registration rewritten from the current installer.");
21177
+ } else console.warn(`Warning: the service was not registered again, because its launch command could not be read, or names a file that does not exist. Run \`kici-admin ${component} install\` for it to apply the current registration.`);
21178
+ }
21179
+ await manager.start(config);
21180
+ }
20577
21181
  /** Get the launcher script name for a component. */
20578
21182
  function getLauncherName(component, platform) {
20579
21183
  const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
@@ -20594,6 +21198,45 @@ function extractArchive(archivePath, destDir) {
20594
21198
  return dirs[0];
20595
21199
  }
20596
21200
  /**
21201
+ * Extract a release archive into `tmpDir` and return the release folder in it.
21202
+ * On Windows a release from before KICI_ENV_FILE is refused here, before
21203
+ * anything is copied into the install base or the service is stopped.
21204
+ */
21205
+ function extractRelease(args) {
21206
+ const { archivePath, tmpDir, component, platform, version, config } = args;
21207
+ const extractDir = path.join(tmpDir, "extract");
21208
+ const srcDir = path.join(extractDir, extractArchive(archivePath, extractDir));
21209
+ if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
21210
+ launch: { executablePath: path.join(srcDir, getLauncherName(component, platform)) },
21211
+ release: `version ${version}`,
21212
+ action: `upgrade "${config.name}" to ${version}`,
21213
+ config
21214
+ });
21215
+ return srcDir;
21216
+ }
21217
+ /**
21218
+ * Put the release in `archivePath` at `versionedDirPath`. The release is
21219
+ * extracted and checked first ({@link extractRelease}); only then is a folder
21220
+ * already at `versionedDirPath` removed, which the caller allows with
21221
+ * `--force`, and the release copied in. A refused release leaves the install
21222
+ * base as it was.
21223
+ */
21224
+ function installRelease(args) {
21225
+ const { installBase, versionedDirPath, platform } = args;
21226
+ const srcDir = extractRelease(args);
21227
+ if (fs.existsSync(versionedDirPath)) {
21228
+ console.log(`Removing existing directory ${versionedDirPath} (--force)`);
21229
+ fs.rmSync(versionedDirPath, {
21230
+ recursive: true,
21231
+ force: true
21232
+ });
21233
+ }
21234
+ fs.mkdirSync(installBase, { recursive: true });
21235
+ if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
21236
+ else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
21237
+ console.log(`Extracted to ${versionedDirPath}`);
21238
+ }
21239
+ /**
20597
21240
  * List installed versions for a component by scanning the install base directory
20598
21241
  * for directories matching `{component}-{version}/`.
20599
21242
  */
@@ -20742,18 +21385,87 @@ function planNpmSourceUpgrade(opts) {
20742
21385
  version: requestedVersion
20743
21386
  };
20744
21387
  }
21388
+ /**
21389
+ * The cmd.exe command line for npm on Windows. npm is a batch file there, which
21390
+ * Node.js starts only through a shell, and Node.js joins the parts of a shell
21391
+ * command with spaces and quotes none of them. The standard Node.js install is
21392
+ * under `C:\Program Files`, so the npm path is quoted here. An argument with a
21393
+ * character outside those of a package name, a version or a flag is refused
21394
+ * rather than handed to cmd.exe.
21395
+ */
21396
+ function windowsShellCommand(cmd, args) {
21397
+ if (cmd.includes("\"")) throw new Error(`cannot run "${cmd}" through cmd.exe: the path holds a double quote`);
21398
+ for (const arg of args) if (!/^[A-Za-z0-9@/._+-]+$/.test(arg)) throw new Error(`cannot pass "${arg}" to npm: it holds a character cmd.exe reads as syntax`);
21399
+ return [`"${cmd}"`, ...args].join(" ");
21400
+ }
20745
21401
  const defaultSpawnRunner = (cmd, args, opts) => {
20746
- const r = spawnSync(cmd, args, {
21402
+ const env = opts?.env ?? process.env;
21403
+ const r = process.platform === "win32" ? spawnSync(windowsShellCommand(cmd, args), {
21404
+ encoding: "utf-8",
21405
+ shell: true,
21406
+ env
21407
+ }) : spawnSync(cmd, args, {
20747
21408
  encoding: "utf-8",
20748
- shell: process.platform === "win32",
20749
- env: opts?.env ?? process.env
21409
+ env
20750
21410
  });
20751
21411
  return {
20752
21412
  status: r.status,
21413
+ stdout: r.stdout ?? "",
20753
21414
  stderr: (r.stderr ?? "") + (r.error ? String(r.error) : "")
20754
21415
  };
20755
21416
  };
20756
21417
  /**
21418
+ * The environment npm runs in: the unit's pinned node first on PATH, because
21419
+ * npm derives its global prefix from the node that runs it.
21420
+ */
21421
+ function npmEnv(target) {
21422
+ const pinnedBinDir = path.dirname(target.nodeExecPath);
21423
+ return {
21424
+ ...process.env,
21425
+ PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
21426
+ };
21427
+ }
21428
+ /**
21429
+ * Whether `version` of the KiCI packages reads KICI_ENV_FILE, asked of the
21430
+ * registry the unit's npm installs from. A release that reads it publishes the
21431
+ * env-file loader in `@kici-dev/shared`, which every KiCI package of the same
21432
+ * version depends on.
21433
+ */
21434
+ function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
21435
+ const res = run(target.npmPath, [
21436
+ "view",
21437
+ `@kici-dev/shared@${version}`,
21438
+ "exports",
21439
+ "--json"
21440
+ ], { env: npmEnv(target) });
21441
+ if (res.status !== 0) return {
21442
+ ok: false,
21443
+ error: res.stderr.trim() || `npm view exited with ${res.status}`
21444
+ };
21445
+ const out = res.stdout.trim();
21446
+ if (!out) return {
21447
+ ok: true,
21448
+ reads: false
21449
+ };
21450
+ let exportsMap;
21451
+ try {
21452
+ exportsMap = JSON.parse(out);
21453
+ } catch {
21454
+ return {
21455
+ ok: false,
21456
+ error: `npm view printed no JSON: ${out.slice(0, 200)}`
21457
+ };
21458
+ }
21459
+ if (Array.isArray(exportsMap)) return {
21460
+ ok: false,
21461
+ error: `npm matched more than one version of @kici-dev/shared for ${version}; pass an exact --version`
21462
+ };
21463
+ return {
21464
+ ok: true,
21465
+ reads: typeof exportsMap === "object" && exportsMap !== null && "./load-service-env-file" in exportsMap
21466
+ };
21467
+ }
21468
+ /**
20757
21469
  * Install `<owningPackage>@<version>` globally under the unit's own pinned node,
20758
21470
  * so it lands in exactly the global prefix the unit's ExecStart resolves from.
20759
21471
  *
@@ -20768,16 +21480,11 @@ const defaultSpawnRunner = (cmd, args, opts) => {
20768
21480
  * `run` seam is injected in tests.
20769
21481
  */
20770
21482
  function installGlobalPackage(target, version, run = defaultSpawnRunner) {
20771
- const pinnedBinDir = path.dirname(target.nodeExecPath);
20772
- const env = {
20773
- ...process.env,
20774
- PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
20775
- };
20776
21483
  const res = run(target.npmPath, [
20777
21484
  "install",
20778
21485
  "-g",
20779
21486
  `${target.owningPackage}@${version}`
20780
- ], { env });
21487
+ ], { env: npmEnv(target) });
20781
21488
  return {
20782
21489
  ok: res.status === 0,
20783
21490
  stderr: res.stderr
@@ -20886,18 +21593,10 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20886
21593
  const version = opts.version;
20887
21594
  const versionedDirName = `${component}-${version}`;
20888
21595
  const versionedDirPath = path.join(installBase, versionedDirName);
20889
- if (fs.existsSync(versionedDirPath)) {
20890
- if (opts.force) {
20891
- console.log(`Removing existing directory ${versionedDirPath} (--force)`);
20892
- fs.rmSync(versionedDirPath, {
20893
- recursive: true,
20894
- force: true
20895
- });
20896
- } else {
20897
- console.error(`Error: version directory already exists: ${versionedDirPath}`);
20898
- console.error("Use --force to overwrite.");
20899
- process.exit(1);
20900
- }
21596
+ if (fs.existsSync(versionedDirPath) && !opts.force) {
21597
+ console.error(`Error: version directory already exists: ${versionedDirPath}`);
21598
+ console.error("Use --force to overwrite.");
21599
+ process.exit(1);
20901
21600
  }
20902
21601
  if (!await manager.isInstalled(config)) {
20903
21602
  console.error(`Error: service "${config.name}" is not installed`);
@@ -20924,19 +21623,27 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20924
21623
  console.log(` Install path: ${versionedDirPath}`);
20925
21624
  console.log(" The service will be stopped during upgrade.");
20926
21625
  console.log("");
20927
- if (!await confirm$2("Proceed with upgrade?")) {
21626
+ if (!await confirm$1("Proceed with upgrade?")) {
20928
21627
  console.log("Upgrade cancelled.");
20929
21628
  return;
20930
21629
  }
20931
21630
  }
20932
21631
  console.log("Extracting archive...");
20933
- const extractDir = path.join(tmpDir, "extract");
20934
- const extractedDirName = extractArchive(archivePath, extractDir);
20935
- fs.mkdirSync(installBase, { recursive: true });
20936
- const srcDir = path.join(extractDir, extractedDirName);
20937
- if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
20938
- else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
20939
- console.log(`Extracted to ${versionedDirPath}`);
21632
+ try {
21633
+ installRelease({
21634
+ archivePath,
21635
+ tmpDir,
21636
+ installBase,
21637
+ versionedDirPath,
21638
+ component,
21639
+ platform,
21640
+ version,
21641
+ config
21642
+ });
21643
+ } catch (err) {
21644
+ await cleanupTmpDir();
21645
+ throw err;
21646
+ }
20940
21647
  const preStopHeads = await runPreStopSafety({
20941
21648
  component,
20942
21649
  opts,
@@ -20950,10 +21657,7 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20950
21657
  }
20951
21658
  if (isWindows(platform)) {
20952
21659
  const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
20953
- config.executablePath = launcherPath;
20954
- await manager.uninstall(config);
20955
- await new Promise((r) => setTimeout(r, 2e3));
20956
- await manager.install(config);
21660
+ await reregisterWindowsService(manager, config, launcherPath);
20957
21661
  writeCurrentVersion(installBase, component, version);
20958
21662
  console.log(`Service registration updated to ${launcherPath}`);
20959
21663
  } else {
@@ -21018,17 +21722,36 @@ async function performNpmSourceUpgrade(component, config, resolvedInstance, mana
21018
21722
  process.exit(1);
21019
21723
  }
21020
21724
  if (action.kind === "restart-only") {
21021
- await restartOnlyUpgrade(component, config, resolvedInstance, manager, action.verdict, opts);
21725
+ await restartOnlyUpgrade({
21726
+ component,
21727
+ config,
21728
+ resolvedInstance,
21729
+ manager,
21730
+ verdict: action.verdict,
21731
+ opts,
21732
+ platform,
21733
+ spec
21734
+ });
21022
21735
  return;
21023
21736
  }
21024
- await performSelfDrivingInstall(component, config, resolvedInstance, manager, action, opts);
21737
+ await performSelfDrivingInstall({
21738
+ component,
21739
+ config,
21740
+ resolvedInstance,
21741
+ manager,
21742
+ action,
21743
+ opts,
21744
+ platform,
21745
+ spec
21746
+ });
21025
21747
  }
21026
21748
  /**
21027
21749
  * Restart-only path: the operator pre-installed `@kici-dev/<pkg>@<version>` (or
21028
21750
  * kici-admin) themselves. Verify the unit will launch the invoking CLI's version,
21029
21751
  * restart onto it, and persist the manifest version.
21030
21752
  */
21031
- async function restartOnlyUpgrade(component, config, resolvedInstance, manager, verdict, opts) {
21753
+ async function restartOnlyUpgrade(args) {
21754
+ const { config, resolvedInstance, manager, verdict, opts, platform, spec } = args;
21032
21755
  if (!verdict.ok) {
21033
21756
  console.error(`Error: ${verdict.reason}`);
21034
21757
  process.exit(1);
@@ -21037,14 +21760,18 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
21037
21760
  console.log(`This will restart "${config.name}" onto the npm-installed version ${verdict.version}.`);
21038
21761
  console.log(" The service will be stopped briefly during the restart.");
21039
21762
  console.log("");
21040
- if (!await confirm$2("Proceed with upgrade?")) {
21763
+ if (!await confirm$1("Proceed with upgrade?")) {
21041
21764
  console.log("Upgrade cancelled.");
21042
21765
  return;
21043
21766
  }
21044
21767
  }
21045
21768
  console.log("Restarting service onto the npm-installed package...");
21046
- if ((await manager.status(config)).state === "running") await manager.stop(config);
21047
- await manager.start(config);
21769
+ await restartOntoInstalledPackage({
21770
+ manager,
21771
+ config,
21772
+ platform,
21773
+ spec
21774
+ });
21048
21775
  if (verdict.manifestVersion !== null) writeManifest(resolvedInstance.instanceDir, {
21049
21776
  ...resolvedInstance.manifest,
21050
21777
  kiciVersion: verdict.manifestVersion
@@ -21057,19 +21784,26 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
21057
21784
  * pinned node/npm, restart, and verify the unit now launches the requested
21058
21785
  * version.
21059
21786
  */
21060
- async function performSelfDrivingInstall(component, config, resolvedInstance, manager, action, opts) {
21787
+ async function performSelfDrivingInstall(args) {
21788
+ const { component, config, resolvedInstance, manager, action, opts, platform, spec } = args;
21061
21789
  const { target, version } = action;
21790
+ if (isWindows(platform)) {
21791
+ const release = `${target.owningPackage}@${version}`;
21792
+ const verdict = npmReleaseReadsEnvFile(target, version, args.run);
21793
+ if (!verdict.ok) throw new Error(`could not check whether ${release} reads KICI_ENV_FILE: ${verdict.error}. The service and the installed package were not changed.`);
21794
+ if (!verdict.reads) throw new Error(`refusing to install ${release} for "${config.name}": ${envFileRefusal(release, config.envFilePath)} The service and the installed package were not changed.`);
21795
+ }
21062
21796
  if (!opts.yes) {
21063
21797
  console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
21064
21798
  console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
21065
21799
  console.log("");
21066
- if (!await confirm$2("Proceed with upgrade?")) {
21800
+ if (!await confirm$1("Proceed with upgrade?")) {
21067
21801
  console.log("Upgrade cancelled.");
21068
21802
  return;
21069
21803
  }
21070
21804
  }
21071
21805
  console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
21072
- const result = installGlobalPackage(target, version);
21806
+ const result = installGlobalPackage(target, version, args.run);
21073
21807
  if (!result.ok) {
21074
21808
  const hint = /EACCES|permission denied|EPERM/i.test(result.stderr) ? " The unit's global prefix is not writable — re-run with privileges matching the unit's runtime." : "";
21075
21809
  console.error(`Error: npm install failed for ${target.owningPackage}@${version}.${hint}`);
@@ -21077,8 +21811,12 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
21077
21811
  process.exit(1);
21078
21812
  }
21079
21813
  console.log("Restarting service onto the freshly-installed package...");
21080
- if ((await manager.status(config)).state === "running") await manager.stop(config);
21081
- await manager.start(config);
21814
+ await restartOntoInstalledPackage({
21815
+ manager,
21816
+ config,
21817
+ platform,
21818
+ spec
21819
+ });
21082
21820
  const newSpec = await manager.readLaunchSpec(config);
21083
21821
  const launched = newSpec ? resolveVersionFromLaunchSpec(newSpec, component) : null;
21084
21822
  if (launched !== version) {
@@ -21120,7 +21858,7 @@ async function handlePick(component, platform, installBase, config, manager, res
21120
21858
  console.log(` Install path: ${path.join(installBase, `${component}-${target}`)}`);
21121
21859
  console.log(" The service will be stopped during the switch.");
21122
21860
  console.log("");
21123
- if (!await confirm$2("Proceed with switch?")) {
21861
+ if (!await confirm$1("Proceed with switch?")) {
21124
21862
  console.log("Pick cancelled.");
21125
21863
  return;
21126
21864
  }
@@ -21141,14 +21879,6 @@ async function handlePick(component, platform, installBase, config, manager, res
21141
21879
  console.log(`Switched: ${current ?? "(unknown)"} -> ${target}`);
21142
21880
  }
21143
21881
  /**
21144
- * Stop the service, switch the active version pointer to an already-installed
21145
- * versioned directory (atomic symlink on Unix; uninstall→install + version file
21146
- * on Windows), restart, and persist the new `kiciVersion` into the manifest.
21147
- *
21148
- * Shared by `--rollback` (switch to the previous version) and `--pick` (switch
21149
- * to any chosen installed version).
21150
- */
21151
- /**
21152
21882
  * Refuse a version switch whose target cannot boot against the current schema.
21153
21883
  *
21154
21884
  * `--rollback` and `--pick` both flip a version pointer, and the previous
@@ -21166,9 +21896,9 @@ async function assertSchemaAllowsSwitch(args) {
21166
21896
  const rows = await readLiveMigrationRows(hooks.migrationStatus);
21167
21897
  if (rows === null) {
21168
21898
  console.log("");
21169
- console.log(" !! The running service could not be reached, so the schema was not checked.");
21899
+ console.log(` !! ${SCHEMA_NOT_CHECKED}`);
21170
21900
  console.log("");
21171
- return yes ? true : confirm$2("Proceed with the switch anyway?");
21901
+ return yes ? true : confirm$1("Proceed with the switch anyway?");
21172
21902
  }
21173
21903
  const recordedHead = resolvedInstance.manifest.migrationHeads?.[targetVersion];
21174
21904
  const verdict = schemaGuardVerdict({
@@ -21181,7 +21911,7 @@ async function assertSchemaAllowsSwitch(args) {
21181
21911
  console.log("");
21182
21912
  console.log(verdict.message);
21183
21913
  console.log("");
21184
- return yes ? true : confirm$2("Proceed with the switch anyway?");
21914
+ return yes ? true : confirm$1("Proceed with the switch anyway?");
21185
21915
  }
21186
21916
  if (!migrateDown) {
21187
21917
  console.error("");
@@ -21208,8 +21938,23 @@ async function assertSchemaAllowsSwitch(args) {
21208
21938
  }
21209
21939
  return true;
21210
21940
  }
21941
+ /**
21942
+ * Stop the service, switch the active version pointer to an already-installed
21943
+ * versioned directory (atomic symlink on Unix; re-registration + version file
21944
+ * on Windows), restart, and persist the new `kiciVersion` into the manifest.
21945
+ *
21946
+ * Shared by `--rollback` (switch to the previous version) and `--pick` (switch
21947
+ * to any chosen installed version).
21948
+ */
21211
21949
  async function switchToInstalledVersion(args) {
21212
21950
  const { component, platform, installBase, config, manager, resolvedInstance, targetVersion } = args;
21951
+ const launcherPath = path.join(installBase, `${component}-${targetVersion}`, getLauncherName(component, platform));
21952
+ if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
21953
+ launch: { executablePath: launcherPath },
21954
+ release: `version ${targetVersion}`,
21955
+ action: `switch "${config.name}" to ${targetVersion}`,
21956
+ config
21957
+ });
21213
21958
  if (!await assertSchemaAllowsSwitch({
21214
21959
  component,
21215
21960
  targetVersion,
@@ -21227,10 +21972,7 @@ async function switchToInstalledVersion(args) {
21227
21972
  console.log("Service stopped.");
21228
21973
  }
21229
21974
  if (isWindows(platform)) {
21230
- const launcherPath = path.join(installBase, `${component}-${targetVersion}`, getLauncherName(component, platform));
21231
- config.executablePath = launcherPath;
21232
- await manager.uninstall(config);
21233
- await manager.install(config);
21975
+ await reregisterWindowsService(manager, config, launcherPath);
21234
21976
  writeCurrentVersion(installBase, component, targetVersion);
21235
21977
  console.log(`Service registration updated to ${launcherPath}`);
21236
21978
  } else {
@@ -21276,7 +22018,7 @@ async function handleRollback(component, platform, installBase, config, manager,
21276
22018
  console.log(` Current version: ${currentVersion}`);
21277
22019
  console.log(` Rollback to: ${previousVersion}`);
21278
22020
  console.log("");
21279
- if (!await confirm$2("Proceed with rollback?")) {
22021
+ if (!await confirm$1("Proceed with rollback?")) {
21280
22022
  console.log("Rollback cancelled.");
21281
22023
  return;
21282
22024
  }
@@ -21893,6 +22635,8 @@ Token vocabulary:
21893
22635
  \`kici-admin join\` to add another orchestrator as a peer to an existing cluster.
21894
22636
  `).option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--mode <mode>", `Operating mode written to the env file (${OrchestratorMode.options.join(", ")}). Naming it with --env-file overwrites the copied file's own KICI_MODE; omitting it leaves that value alone`, DEFAULT_INSTALL_MODE).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", "Run the interactive setup wizard (default on an interactive terminal)").option("--no-wizard", "Skip the wizard and write a stub env file to edit by hand").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, command) => {
21895
22637
  try {
22638
+ const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
22639
+ assertServerResolvable("orchestrator", opts.binary, resolveModule);
21896
22640
  if (opts.wizard === true && opts.envFile) {
21897
22641
  console.error("Error: Cannot use --wizard with --env-file");
21898
22642
  process.exit(1);
@@ -21925,7 +22669,7 @@ Token vocabulary:
21925
22669
  })).find((c) => c.name === serviceName);
21926
22670
  if (existing && existing.instanceDir !== instanceDir && !opts.force) {
21927
22671
  const at = existing.instanceDir ?? "(no manifest)";
21928
- console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run \`npm install -g kici-admin@latest\`\nthen \`kici-admin orchestrator restart\`; for a versioned-directory install run\n\`kici-admin orchestrator upgrade\`.\n\nInstalling a second, separate instance? Pass a different --name or --instance-dir,\nor --force to take over this one.`);
22672
+ console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run\n\`kici-admin orchestrator upgrade --version <version>\`; for a versioned-directory install\nrun \`kici-admin orchestrator upgrade --from <archive> --version <version>\`.\n\nInstalling a second, separate instance? Pass a different --name or --instance-dir,\nor --force to take over this one.`);
21929
22673
  process.exit(1);
21930
22674
  }
21931
22675
  const configDir = getConfigDir(serviceName, userLevel);
@@ -21933,6 +22677,7 @@ Token vocabulary:
21933
22677
  fs.mkdirSync(configDir, { recursive: true });
21934
22678
  fs.mkdirSync(logDir, { recursive: true });
21935
22679
  const envFilePath = path.join(configDir, `${serviceName}.env`);
22680
+ if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
21936
22681
  let devDbUrl;
21937
22682
  if (opts.dev) {
21938
22683
  devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
@@ -21966,7 +22711,7 @@ Token vocabulary:
21966
22711
  envFilePath
21967
22712
  });
21968
22713
  fs.writeFileSync(envFilePath, upsertDeployEnvLines(fs.readFileSync(envFilePath, "utf-8"), deployLines), "utf-8");
21969
- const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`));
22714
+ const entryScript = opts.binary ? void 0 : resolveServerScript(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`, "orchestrator", resolveModule);
21970
22715
  const { executablePath, args } = resolveServiceExecutable({
21971
22716
  binary: opts.binary ? path.resolve(opts.binary) : void 0,
21972
22717
  nodePath: process.execPath,
@@ -21982,8 +22727,7 @@ Token vocabulary:
21982
22727
  } catch {}
21983
22728
  const config = {
21984
22729
  name: serviceName,
21985
- displayName: "KiCI Orchestrator",
21986
- description: "KiCI CI/CD workflow orchestrator service",
22730
+ ...SERVICE_TEXT.orchestrator,
21987
22731
  executablePath,
21988
22732
  args,
21989
22733
  nodeBinDir: path.dirname(process.execPath),
@@ -21996,7 +22740,7 @@ Token vocabulary:
21996
22740
  restartPolicy: DEFAULT_RESTART_POLICY
21997
22741
  };
21998
22742
  await manager.install(config);
21999
- const manifestFile = writeManifest(instanceDir, {
22743
+ const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
22000
22744
  component: "orchestrator",
22001
22745
  name: serviceName,
22002
22746
  platform,
@@ -22007,7 +22751,11 @@ Token vocabulary:
22007
22751
  installBase: getInstallBase(platform, serviceName),
22008
22752
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
22009
22753
  kiciVersion: readKiciVersion()
22754
+ }, {
22755
+ execPath: executablePath,
22756
+ args
22010
22757
  });
22758
+ if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
22011
22759
  try {
22012
22760
  appendIndexEntry(kiciRoot, {
22013
22761
  component: "orchestrator",
@@ -22224,23 +22972,135 @@ function registerOrchestratorRestart(orchestrator) {
22224
22972
  });
22225
22973
  }
22226
22974
  //#endregion
22227
- //#region src/cli/commands/orchestrator-service/status.ts
22228
- init_service();
22229
- init_compose_path();
22230
- /** Read the env file, or empty content when it is missing or unreadable. */
22231
- function readEnvContent(envFilePath) {
22975
+ //#region src/cli/commands/service-health.ts
22976
+ /**
22977
+ * Reads a locally installed KiCI service's health endpoints and formats what
22978
+ * they report. `kici-admin orchestrator status` and `kici-admin agent status`
22979
+ * both render through here, so the two sections agree on layout and on how a
22980
+ * missing field is handled.
22981
+ */
22982
+ /** How long a health request waits unless the caller passes its own timeout. */
22983
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e3;
22984
+ /** Hex digits of a bundle hash to show: the length the build prints in its `deps:` line. */
22985
+ const SHORT_HASH_LENGTH = 12;
22986
+ /** Bind addresses that mean "every interface", reached on `localhost`. */
22987
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
22988
+ "",
22989
+ "0.0.0.0",
22990
+ "::",
22991
+ "[::]"
22992
+ ]);
22993
+ /**
22994
+ * Read the env file, or empty content when it is missing or unreadable. A file
22995
+ * this account may not read is reported: the port and address the caller then
22996
+ * falls back to may not be the ones the service uses. On Windows only
22997
+ * LocalSystem and Administrators can read a service's env file.
22998
+ */
22999
+ function readEnvContent(envFilePath, warn = console.warn) {
22232
23000
  try {
22233
- return fs.existsSync(envFilePath) ? fs.readFileSync(envFilePath, "utf-8") : "";
22234
- } catch {
23001
+ return fs.readFileSync(envFilePath, "utf-8");
23002
+ } catch (err) {
23003
+ const code = err.code;
23004
+ if (code === "EACCES" || code === "EPERM") warn(`Warning: cannot read ${envFilePath} (${code}), so the default port and address are used. Run the command as an administrator, or as an account that can read the file.`);
22235
23005
  return "";
22236
23006
  }
22237
23007
  }
22238
- /** Read the port from the service's env file (path from the manifest). */
22239
- function readPortFromEnvFile$1(envFilePath) {
22240
- const value = readEnvValue(readEnvContent(envFilePath), "KICI_PORT");
22241
- const parsed = value === void 0 ? NaN : parseInt(value, 10);
22242
- return isNaN(parsed) ? 4e3 : parsed;
23008
+ /**
23009
+ * Where the service answers, read from its env file: `KICI_PORT` (or
23010
+ * `defaultPort`), `KICI_HOST` and `KICI_BASE_PATH`. A service bound to every
23011
+ * interface is reached on `localhost`; one bound to a single address is reached
23012
+ * there. The agent reads none of the last two, so its env file never sets them.
23013
+ */
23014
+ function readLocalEndpoint(envContent, defaultPort) {
23015
+ const portValue = readEnvValue(envContent, "KICI_PORT");
23016
+ const port = portValue === void 0 ? NaN : parseInt(portValue, 10);
23017
+ const bindHost = readEnvValue(envContent, "KICI_HOST")?.trim() ?? "";
23018
+ const trimmedPath = (readEnvValue(envContent, "KICI_BASE_PATH")?.trim() ?? "").replace(/^\/+|\/+$/g, "");
23019
+ return {
23020
+ host: WILDCARD_HOSTS.has(bindHost) ? "localhost" : bindHost,
23021
+ port: isNaN(port) ? defaultPort : port,
23022
+ pathPrefix: trimmedPath === "" ? "" : `/${trimmedPath}`
23023
+ };
23024
+ }
23025
+ /** The URL of `pathname` on `endpoint`, with an IPv6 host bracketed. */
23026
+ function localUrl(endpoint, pathname) {
23027
+ return `http://${endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}${endpoint.pathPrefix}${pathname}`;
22243
23028
  }
23029
+ /**
23030
+ * GET a JSON body from a service on this host. `acceptStatuses` names the
23031
+ * non-2xx codes whose body is still an answer: `/ready` returns its checks
23032
+ * with a 503 when one fails. Returns null when nothing answers in time.
23033
+ */
23034
+ async function fetchLocalJson(endpoint, pathname, opts = {}) {
23035
+ const controller = new AbortController();
23036
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
23037
+ try {
23038
+ const res = await fetch(localUrl(endpoint, pathname), { signal: controller.signal });
23039
+ if (!res.ok && !(opts.acceptStatuses ?? []).includes(res.status)) return null;
23040
+ return await res.json();
23041
+ } catch {
23042
+ return null;
23043
+ } finally {
23044
+ clearTimeout(timeout);
23045
+ }
23046
+ }
23047
+ /**
23048
+ * A `/health` body with its deprecated `buildCommit` replaced by its version.
23049
+ * A service older than this CLI reports a commit ID from KiCI's private
23050
+ * repository there, which a reader cannot resolve; `--json` keeps the key, a
23051
+ * string as before, and carries the release version in it. A body that reports
23052
+ * no `buildCommit` comes back unchanged.
23053
+ */
23054
+ function hideBuildCommit(health) {
23055
+ if (!("buildCommit" in health)) return health;
23056
+ return {
23057
+ ...health,
23058
+ buildCommit: health.version ?? "unknown"
23059
+ };
23060
+ }
23061
+ /**
23062
+ * Rows for the build fingerprint and process uptime of a `/health` body. A
23063
+ * field the body lacks is skipped, so a service older than this CLI renders
23064
+ * the fields it does report. The version line never shows `buildCommit`
23065
+ * (see {@link hideBuildCommit}).
23066
+ */
23067
+ function buildInfoRows(health) {
23068
+ const rows = [];
23069
+ if (health.version) rows.push(["Version", health.buildDate ? `${health.version} (built ${health.buildDate})` : health.version]);
23070
+ if (health.sdkVersion) rows.push(["SDK", health.sdkBundleHash ? `${health.sdkVersion} (bundle ${health.sdkBundleHash.slice(0, SHORT_HASH_LENGTH)})` : health.sdkVersion]);
23071
+ if (typeof health.uptime === "number") rows.push(["Uptime", formatUptime(Math.floor(health.uptime))]);
23072
+ return rows;
23073
+ }
23074
+ /**
23075
+ * A status section: a blank separator line, the heading, then one line per
23076
+ * row with each value starting at `valueColumn`. A body with no field this CLI
23077
+ * recognises still gets a line saying so, so the heading never stands alone.
23078
+ */
23079
+ function formatHealthSection(heading, rows, valueColumn) {
23080
+ return [
23081
+ "",
23082
+ heading,
23083
+ ...(rows.length > 0 ? rows : [["Health", "answered, with no fields this CLI recognises"]]).map(([label, value]) => `${label}:`.padEnd(valueColumn) + value)
23084
+ ];
23085
+ }
23086
+ //#endregion
23087
+ //#region src/cli/commands/orchestrator-service/status.ts
23088
+ init_service();
23089
+ init_compose_path();
23090
+ /** Time `/ready` gets beyond the database-pool acquire timeout, for its query and its response. */
23091
+ const READINESS_MARGIN_MS = 3e3;
23092
+ /**
23093
+ * How long to wait for `/ready`. Its database check waits up to the pool's
23094
+ * acquire timeout before it reports `database: false`, so the wait covers that
23095
+ * timeout plus a margin. An acquire timeout of 0 lets the pool wait without
23096
+ * limit; the default then bounds the wait.
23097
+ */
23098
+ function readinessTimeoutMs(envContent) {
23099
+ const configured = Number(readEnvValue(envContent, "KICI_DB_POOL_ACQUIRE_TIMEOUT_MS"));
23100
+ return (Number.isFinite(configured) && configured > 0 ? configured : DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS) + READINESS_MARGIN_MS;
23101
+ }
23102
+ /** Column the values of the orchestrator section start at. */
23103
+ const VALUE_COLUMN$1 = 12;
22244
23104
  /** The scaler config the env file names, by path first and directory second. */
22245
23105
  function readScalerConfig(envContent) {
22246
23106
  return readEnvValue(envContent, "KICI_SCALER_CONFIG_PATH") ?? readEnvValue(envContent, "KICI_SCALER_CONFIG_DIR");
@@ -22261,21 +23121,14 @@ function formatConfigPaths(args) {
22261
23121
  if (args.platform === "compose") lines.push(`Compose file: ${composeFilePath(args.envFilePath, args.serviceName)}`);
22262
23122
  return lines;
22263
23123
  }
22264
- /** Query orchestrator health API. */
22265
- async function queryHealth$1(port) {
22266
- try {
22267
- const controller = new AbortController();
22268
- const timeout = setTimeout(() => controller.abort(), 3e3);
22269
- const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
22270
- clearTimeout(timeout);
22271
- if (!res.ok) return null;
22272
- return await res.json();
22273
- } catch {
22274
- return null;
22275
- }
23124
+ /** `yes` when every readiness check passed, otherwise `no` and the failing checks. */
23125
+ function formatReadiness(readiness) {
23126
+ if (readiness.status === ReadinessStatus.Ready) return "yes";
23127
+ const failing = Object.entries(readiness.checks ?? {}).filter(([, passed]) => !passed).map(([name]) => name);
23128
+ return failing.length > 0 ? `no (failing: ${failing.join(", ")})` : "no";
22276
23129
  }
22277
23130
  /** Format the status output as a readable table. */
22278
- function formatStatus$1(serviceStatus, health, serviceName) {
23131
+ function formatStatus$1(serviceStatus, health, readiness, serviceName) {
22279
23132
  const lines = [];
22280
23133
  lines.push(`Service: ${serviceName}`);
22281
23134
  lines.push(`State: ${serviceStatus.state}`);
@@ -22283,18 +23136,11 @@ function formatStatus$1(serviceStatus, health, serviceName) {
22283
23136
  if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
22284
23137
  if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
22285
23138
  if (health) {
22286
- lines.push("");
22287
- lines.push("--- KiCI orchestrator ---");
22288
- if (health.mode) lines.push(`Mode: ${health.mode}`);
22289
- if (health.port) lines.push(`Port: ${health.port}`);
22290
- if (health.database) lines.push(`Database: ${health.database}`);
22291
- if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
22292
- if (health.agents != null) lines.push(`Agents: ${health.agents}`);
22293
- if (health.scaler) {
22294
- const s = health.scaler;
22295
- lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
22296
- }
22297
- if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
23139
+ const rows = [];
23140
+ if (health.status) rows.push(["Health", health.status]);
23141
+ rows.push(["Ready", readiness ? formatReadiness(readiness) : "unknown (/ready did not answer)"]);
23142
+ rows.push(...buildInfoRows(health));
23143
+ lines.push(...formatHealthSection("--- KiCI orchestrator ---", rows, VALUE_COLUMN$1));
22298
23144
  } else if (serviceStatus.state === "running") {
22299
23145
  lines.push("");
22300
23146
  lines.push("(Could not reach health API)");
@@ -22302,12 +23148,13 @@ function formatStatus$1(serviceStatus, health, serviceName) {
22302
23148
  return lines.join("\n");
22303
23149
  }
22304
23150
  /** Build JSON output combining service + health data. */
22305
- function buildJsonOutput$1(serviceStatus, health, serviceName, configPaths) {
23151
+ function buildJsonOutput$1(serviceStatus, health, readiness, serviceName, configPaths) {
22306
23152
  return {
22307
23153
  service: serviceName,
22308
23154
  ...serviceStatus,
22309
23155
  configPaths,
22310
- health: health ?? void 0
23156
+ health: health ? hideBuildCommit(health) : void 0,
23157
+ readiness: readiness ?? void 0
22311
23158
  };
22312
23159
  }
22313
23160
  function registerStatusCommand(parent) {
@@ -22346,14 +23193,21 @@ function registerStatusCommand(parent) {
22346
23193
  envContent
22347
23194
  });
22348
23195
  let health = null;
22349
- if (serviceStatus.state === "running") health = await queryHealth$1(readPortFromEnvFile$1(config.envFilePath));
23196
+ let readiness = null;
23197
+ if (serviceStatus.state === "running") {
23198
+ const endpoint = readLocalEndpoint(envContent, ORCHESTRATOR_DEFAULT_PORT);
23199
+ [health, readiness] = await Promise.all([fetchLocalJson(endpoint, "/health"), fetchLocalJson(endpoint, "/ready", {
23200
+ acceptStatuses: [503],
23201
+ timeoutMs: readinessTimeoutMs(envContent)
23202
+ })]);
23203
+ }
22350
23204
  const jsonConfigPaths = { envFile: config.envFilePath };
22351
23205
  const scalerPath = readScalerConfig(envContent);
22352
23206
  if (scalerPath) jsonConfigPaths.scalerConfig = scalerPath;
22353
23207
  if (installPlatform === "compose") jsonConfigPaths.composeFile = composeFilePath(config.envFilePath, config.name);
22354
- if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, config.name, jsonConfigPaths), null, 2));
23208
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, readiness, config.name, jsonConfigPaths), null, 2));
22355
23209
  else {
22356
- console.log(formatStatus$1(serviceStatus, health, config.name));
23210
+ console.log(formatStatus$1(serviceStatus, health, readiness, config.name));
22357
23211
  console.log("");
22358
23212
  console.log(configPathLines.join("\n"));
22359
23213
  }
@@ -22520,6 +23374,26 @@ function resolveDatabaseUrl$2(envFilePath, readFile) {
22520
23374
  return url;
22521
23375
  }
22522
23376
  //#endregion
23377
+ //#region src/agent-packaging/node-runtime-members.ts
23378
+ /**
23379
+ * The members of an official Node.js Linux or macOS archive that a KiCI payload
23380
+ * vendors, relative to the archive's `node-v<version>-<os>-<arch>/` directory:
23381
+ * the `node` binary, the `npm` and `npx` launchers, and the bundled npm package.
23382
+ * With them in place, the agent's `resolveNpm()` finds
23383
+ * `<nodeDir>/../lib/node_modules/npm/bin/npm-cli.js` beside the vendored `node`,
23384
+ * so the builder role needs no npm on the host.
23385
+ *
23386
+ * This module has no imports so that repository tooling can read the list:
23387
+ * `scripts/lib/node-dist.mjs` (the standalone package builder) keeps its own
23388
+ * copy, and its test pins the two lists equal.
23389
+ */
23390
+ const NODE_RUNTIME_MEMBERS = [
23391
+ "bin/node",
23392
+ "bin/npm",
23393
+ "bin/npx",
23394
+ "lib/node_modules/npm"
23395
+ ];
23396
+ //#endregion
22523
23397
  //#region src/agent-packaging/node-binary.ts
22524
23398
  /**
22525
23399
  * Fetch a Node dist URL, retrying transient failures. The runtime download is a
@@ -22578,12 +23452,7 @@ async function downloadNodeBinary(opts) {
22578
23452
  const archivePath = path.join(prefixDir, filename);
22579
23453
  writeFileSync(archivePath, buf);
22580
23454
  const inner = `node-v${opts.version}-${nodeOs}-${nodeArch}`;
22581
- const members = [
22582
- `${inner}/bin/node`,
22583
- `${inner}/bin/npm`,
22584
- `${inner}/bin/npx`,
22585
- `${inner}/lib/node_modules/npm`
22586
- ];
23455
+ const members = NODE_RUNTIME_MEMBERS.map((member) => `${inner}/${member}`);
22587
23456
  const out = $.sync({
22588
23457
  nothrow: true,
22589
23458
  stdio: "pipe"
@@ -22802,7 +23671,7 @@ init_manifest();
22802
23671
  const ALL_PLATFORMS = AgentPlatform.options;
22803
23672
  /** The orchestrator's own version (single-version invariant) = the packaged agent version. */
22804
23673
  function resolveKiciVersion() {
22805
- return "0.10.0";
23674
+ return "0.12.0";
22806
23675
  }
22807
23676
  /** Parse the --platform value: default set | single | CSV | `all`. */
22808
23677
  function parsePlatforms(raw) {
@@ -22986,6 +23855,17 @@ function registerOrchestratorServiceCommands(program, getClient, tryGetClient) {
22986
23855
  registerOrchestratorDrain(orchestrator, getClient);
22987
23856
  }
22988
23857
  //#endregion
23858
+ //#region src/cli/commands/agent-service/agent-env.ts
23859
+ /** Render the env file, one `KEY=value` line per field that was given. */
23860
+ function renderAgentEnvFile(fields) {
23861
+ const lines = [fields.header];
23862
+ if (fields.orchestratorUrl) lines.push(`KICI_ORCHESTRATOR_URL=${fields.orchestratorUrl}`);
23863
+ if (fields.token) lines.push(`KICI_AGENT_TOKEN=${fields.token}`);
23864
+ if (fields.labels && fields.labels.length > 0) lines.push(`KICI_LABELS=${fields.labels.join(",")}`);
23865
+ if (fields.port !== void 0) lines.push(`KICI_PORT=${fields.port}`);
23866
+ return `${lines.join("\n")}\n`;
23867
+ }
23868
+ //#endregion
22989
23869
  //#region src/cli/wizard/agent-wizard.ts
22990
23870
  var agent_wizard_exports = /* @__PURE__ */ __exportAll({ runAgentWizard: () => runAgentWizard });
22991
23871
  /**
@@ -23006,13 +23886,13 @@ async function runAgentWizard() {
23006
23886
  const agentToken = await promptSecret("Agent authentication token:");
23007
23887
  console.log("");
23008
23888
  const labels = (await input({
23009
- message: "Agent labels (comma-separated, empty = accept all jobs):",
23889
+ message: "Agent labels (comma-separated; empty = only the kici:* labels every agent reports):",
23010
23890
  default: ""
23011
23891
  })).split(",").map((l) => l.trim()).filter(Boolean);
23012
23892
  console.log("");
23013
23893
  console.log("Configuration complete. Summary:");
23014
23894
  console.log(` Orchestrator: ${orchestratorUrl}`);
23015
- console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none - accepts all jobs)"}`);
23895
+ console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none; only the kici:* labels every agent reports)"}`);
23016
23896
  console.log("");
23017
23897
  return {
23018
23898
  orchestratorUrl,
@@ -23026,13 +23906,26 @@ var init_agent_wizard = __esmMin((() => {
23026
23906
  //#endregion
23027
23907
  //#region src/cli/commands/agent-service/install.ts
23028
23908
  init_service();
23909
+ /** `--port` as a TCP port number; exits with an error for anything else. */
23910
+ function parsePort(value) {
23911
+ if (value === void 0) return void 0;
23912
+ const port = /^\d+$/.test(value) ? Number(value) : NaN;
23913
+ if (!(port >= 1 && port <= 65535)) {
23914
+ console.error(`Error: --port must be a TCP port between 1 and 65535 (got "${value}")`);
23915
+ process.exit(1);
23916
+ }
23917
+ return port;
23918
+ }
23029
23919
  function registerAgentInstall(agent) {
23030
- agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").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) => {
23920
+ agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").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("--port <port>", `HTTP port for the agent's health and metrics endpoints; each agent on one host needs its own (default: ${AGENT_DEFAULT_PORT})`).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) => {
23031
23921
  try {
23922
+ const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
23923
+ assertServerResolvable("agent", opts.binary, resolveModule);
23032
23924
  if (opts.wizard && opts.envFile) {
23033
23925
  console.error("Error: Cannot use --wizard with --env-file");
23034
23926
  process.exit(1);
23035
23927
  }
23928
+ const port = parsePort(opts.port);
23036
23929
  const platform = detectPlatform(opts.platform);
23037
23930
  const userLevel = resolveUserLevel(opts);
23038
23931
  const serviceName = opts.name;
@@ -23058,14 +23951,17 @@ function registerAgentInstall(agent) {
23058
23951
  fs.mkdirSync(configDir, { recursive: true });
23059
23952
  fs.mkdirSync(logDir, { recursive: true });
23060
23953
  const envFilePath = path.join(configDir, `${serviceName}.env`);
23954
+ if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
23061
23955
  if (opts.wizard) {
23062
23956
  const { runAgentWizard } = await Promise.resolve().then(() => (init_agent_wizard(), agent_wizard_exports));
23063
23957
  const wizardConfig = await runAgentWizard();
23064
- let envContent = "# KiCI agent configuration (generated by setup wizard)\n";
23065
- envContent += `KICI_ORCHESTRATOR_URL=${wizardConfig.orchestratorUrl}\n`;
23066
- envContent += `KICI_AGENT_TOKEN=${wizardConfig.agentToken}\n`;
23067
- if (wizardConfig.labels.length > 0) envContent += `KICI_AGENT_LABELS=${wizardConfig.labels.join(",")}\n`;
23068
- writeFileSecurelySync(envFilePath, envContent, 384);
23958
+ writeFileSecurelySync(envFilePath, renderAgentEnvFile({
23959
+ header: "# KiCI agent configuration (generated by setup wizard)",
23960
+ orchestratorUrl: wizardConfig.orchestratorUrl,
23961
+ token: wizardConfig.agentToken,
23962
+ labels: wizardConfig.labels,
23963
+ port
23964
+ }), 384);
23069
23965
  console.log(`Wrote wizard configuration to ${envFilePath}`);
23070
23966
  } else if (opts.envFile) {
23071
23967
  const source = path.resolve(opts.envFile);
@@ -23076,15 +23972,17 @@ function registerAgentInstall(agent) {
23076
23972
  copyFileSecurelySync(source, envFilePath, 384);
23077
23973
  console.log(`Copied env file to ${envFilePath}`);
23078
23974
  } else if (!fs.existsSync(envFilePath)) {
23079
- let envContent = `# KiCI agent configuration\n`;
23080
- if (opts.orchestratorUrl) envContent += `KICI_ORCHESTRATOR_URL=${opts.orchestratorUrl}\n`;
23081
- if (opts.token) envContent += `KICI_AGENT_TOKEN=${opts.token}\n`;
23082
- if (opts.labels) envContent += `KICI_AGENT_LABELS=${opts.labels}\n`;
23083
- writeFileSecurelySync(envFilePath, envContent, 384);
23975
+ writeFileSecurelySync(envFilePath, renderAgentEnvFile({
23976
+ header: "# KiCI agent configuration",
23977
+ orchestratorUrl: opts.orchestratorUrl,
23978
+ token: opts.token,
23979
+ labels: opts.labels?.split(",").map((l) => l.trim()).filter(Boolean),
23980
+ port
23981
+ }), 384);
23084
23982
  console.log(`Created env file at ${envFilePath}`);
23085
23983
  }
23086
23984
  if (fs.existsSync(envFilePath)) fs.chmodSync(envFilePath, 384);
23087
- const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve("@kici-dev/agent/server"));
23985
+ const entryScript = opts.binary ? void 0 : resolveServerScript("@kici-dev/agent/server", "agent", resolveModule);
23088
23986
  const { executablePath, args } = resolveServiceExecutable({
23089
23987
  binary: opts.binary ? path.resolve(opts.binary) : void 0,
23090
23988
  nodePath: process.execPath,
@@ -23092,8 +23990,7 @@ function registerAgentInstall(agent) {
23092
23990
  });
23093
23991
  const config = {
23094
23992
  name: serviceName,
23095
- displayName: "KiCI Agent",
23096
- description: "KiCI CI/CD workflow execution agent service",
23993
+ ...SERVICE_TEXT.agent,
23097
23994
  executablePath,
23098
23995
  args,
23099
23996
  nodeBinDir: path.dirname(process.execPath),
@@ -23105,7 +24002,7 @@ function registerAgentInstall(agent) {
23105
24002
  restartPolicy: DEFAULT_RESTART_POLICY
23106
24003
  };
23107
24004
  await manager.install(config);
23108
- const manifestFile = writeManifest(instanceDir, {
24005
+ const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
23109
24006
  component: "agent",
23110
24007
  name: serviceName,
23111
24008
  platform,
@@ -23116,7 +24013,11 @@ function registerAgentInstall(agent) {
23116
24013
  installBase: getInstallBase(platform, serviceName),
23117
24014
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
23118
24015
  kiciVersion: readKiciVersion()
24016
+ }, {
24017
+ execPath: executablePath,
24018
+ args
23119
24019
  });
24020
+ if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
23120
24021
  try {
23121
24022
  appendIndexEntry(kiciRoot, {
23122
24023
  component: "agent",
@@ -23328,37 +24229,8 @@ function registerAgentRestart(agent) {
23328
24229
  //#endregion
23329
24230
  //#region src/cli/commands/agent-service/status.ts
23330
24231
  init_service();
23331
- /** Read the port from the agent's env file (path from the manifest). */
23332
- function readPortFromEnvFile(envFilePath) {
23333
- if (!fs.existsSync(envFilePath)) return 4001;
23334
- try {
23335
- const content = fs.readFileSync(envFilePath, "utf-8");
23336
- for (const line of content.split("\n")) {
23337
- const trimmed = line.trim();
23338
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
23339
- const [key, ...rest] = trimmed.split("=");
23340
- if (key?.trim() === "KICI_AGENT_PORT" || key?.trim() === "KICI_PORT") {
23341
- const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
23342
- const parsed = parseInt(val, 10);
23343
- if (!isNaN(parsed)) return parsed;
23344
- }
23345
- }
23346
- } catch {}
23347
- return 4001;
23348
- }
23349
- /** Query agent health API. */
23350
- async function queryHealth(port) {
23351
- try {
23352
- const controller = new AbortController();
23353
- const timeout = setTimeout(() => controller.abort(), 3e3);
23354
- const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
23355
- clearTimeout(timeout);
23356
- if (!res.ok) return null;
23357
- return await res.json();
23358
- } catch {
23359
- return null;
23360
- }
23361
- }
24232
+ /** Column the values of the agent section start at. */
24233
+ const VALUE_COLUMN = 14;
23362
24234
  function formatStatus(serviceStatus, health, serviceName) {
23363
24235
  const lines = [];
23364
24236
  lines.push(`Service: ${serviceName}`);
@@ -23367,13 +24239,12 @@ function formatStatus(serviceStatus, health, serviceName) {
23367
24239
  if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
23368
24240
  if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
23369
24241
  if (health) {
23370
- lines.push("");
23371
- lines.push("--- KiCI agent ---");
23372
- if (health.labels && health.labels.length > 0) lines.push(`Labels: ${health.labels.join(", ")}`);
23373
- else lines.push("Labels: (none - accepts all jobs)");
23374
- if (health.orchestratorConnection) lines.push(`Orchestrator: ${health.orchestratorConnection}`);
23375
- if (health.currentJob) lines.push(`Current job: ${health.currentJob.id ?? "unknown"} (${health.currentJob.workflow ?? ""})`);
23376
- else lines.push("Current job: idle");
24242
+ const rows = [];
24243
+ if (health.agentId) rows.push(["Agent ID", health.agentId]);
24244
+ if (typeof health.connected === "boolean") rows.push(["Orchestrator", health.connected ? "connected" : "disconnected"]);
24245
+ if (typeof health.activeJobs === "number") rows.push(["Active jobs", String(health.activeJobs)]);
24246
+ rows.push(...buildInfoRows(health));
24247
+ lines.push(...formatHealthSection("--- KiCI agent ---", rows, VALUE_COLUMN));
23377
24248
  } else if (serviceStatus.state === "running") {
23378
24249
  lines.push("");
23379
24250
  lines.push("(Could not reach agent health API)");
@@ -23384,7 +24255,7 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
23384
24255
  return {
23385
24256
  service: serviceName,
23386
24257
  ...serviceStatus,
23387
- health: health ?? void 0
24258
+ health: health ? hideBuildCommit(health) : void 0
23388
24259
  };
23389
24260
  }
23390
24261
  function registerAgentStatusCommand(parent) {
@@ -23416,7 +24287,7 @@ function registerAgentStatusCommand(parent) {
23416
24287
  };
23417
24288
  const serviceStatus = await manager.status(config);
23418
24289
  let health = null;
23419
- if (serviceStatus.state === "running") health = await queryHealth(readPortFromEnvFile(config.envFilePath));
24290
+ if (serviceStatus.state === "running") health = await fetchLocalJson(readLocalEndpoint(readEnvContent(config.envFilePath), AGENT_DEFAULT_PORT), "/health");
23420
24291
  if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
23421
24292
  else console.log(formatStatus(serviceStatus, health, config.name));
23422
24293
  } catch (err) {
@@ -26253,7 +27124,7 @@ function registerClusterNameCommands(program, getClient) {
26253
27124
  * (`cluster_meta.cluster_id`) and the durable S3 sentinel
26254
27125
  * (`<prefix>/.kici-cluster-id`).
26255
27126
  *
26256
- * Used by `kici-admin cluster reconcile-identity` and the staging deploy's
27127
+ * Used by `kici-admin cluster reconcile-identity`, which a deploy can run as a
26257
27128
  * pre-orchestrator-start self-heal step. Talks DB + S3 directly (never the
26258
27129
  * orchestrator HTTP admin API) so it works while the orchestrator process is
26259
27130
  * down — which is exactly when a "Cluster identity mismatch" boot failure needs
@@ -26450,9 +27321,8 @@ function registerClusterCommands(program) {
26450
27321
  *
26451
27322
  * Direct-DB mode: activated when `--database-url` is passed (or
26452
27323
  * KICI_DATABASE_URL / DATABASE_URL is set). Opens its own pool and runs the
26453
- * SQL directly. Used by e2e/helpers/deploy.ts during warm-start cleanup,
26454
- * when the orchestrator is deliberately stopped so a stale scoped-secret
26455
- * can't crash it on boot.
27324
+ * SQL directly. Use it for cleanup while the orchestrator is deliberately
27325
+ * stopped, so a stale scoped-secret can't crash it on boot.
26456
27326
  */
26457
27327
  function resolveDirectDbUrl$5(explicit) {
26458
27328
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -26832,6 +27702,72 @@ function registerContextCommands(program, getClient) {
26832
27702
  process.exit(1);
26833
27703
  }
26834
27704
  });
27705
+ registerSourceOverrideCommands(env, getClient);
27706
+ }
27707
+ function printSourceOverrides(rows) {
27708
+ if (rows.length === 0) {
27709
+ console.log("No source overrides found.");
27710
+ return;
27711
+ }
27712
+ const header = [
27713
+ "ROUTING KEY",
27714
+ "KEY",
27715
+ "VALUE"
27716
+ ];
27717
+ const data = rows.map((r) => [
27718
+ r.routing_key,
27719
+ r.key,
27720
+ r.value
27721
+ ]);
27722
+ const widths = header.map((h, i) => Math.max(h.length, ...data.map((row) => row[i].length)));
27723
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
27724
+ console.log(header.map((h, i) => pad(h, widths[i])).join(" "));
27725
+ for (const row of data) console.log(row.map((c, i) => pad(c, widths[i])).join(" "));
27726
+ }
27727
+ /** Admin API path of one source override, every segment URI-encoded. */
27728
+ function sourceOverridePath(opts) {
27729
+ const params = new URLSearchParams({ orgId: opts.org });
27730
+ return `/api/v1/admin/contexts/${encodeURIComponent(opts.env)}/source-overrides/${encodeURIComponent(opts.routingKey)}/${encodeURIComponent(opts.key)}?${params}`;
27731
+ }
27732
+ /**
27733
+ * `context source-override list|set|delete` — the operator path for the
27734
+ * dashboard's per-source override writes (`contexts.source_overrides.*` in the
27735
+ * dashboard-write policy). HTTP only: each verb calls the orchestrator admin API.
27736
+ */
27737
+ function registerSourceOverrideCommands(context, getClient) {
27738
+ const so = context.command("source-override").description("Per-source variable overrides in a context (HTTP only)");
27739
+ so.command("list").description("List the source overrides in a context, for every source unless --routing-key").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").option("--routing-key <key>", "Only list the overrides for this source routing key").option("--json", "Emit JSON output").action(async (opts) => {
27740
+ try {
27741
+ const params = new URLSearchParams({ orgId: opts.org });
27742
+ if (opts.routingKey) params.set("routingKey", opts.routingKey);
27743
+ const result = await getClient().get(`/api/v1/admin/contexts/${encodeURIComponent(opts.env)}/source-overrides?${params}`);
27744
+ if (opts.json) console.log(JSON.stringify(result));
27745
+ else printSourceOverrides(result.overrides);
27746
+ } catch (err) {
27747
+ console.error(`Error: ${toErrorMessage(err)}`);
27748
+ process.exit(1);
27749
+ }
27750
+ });
27751
+ so.command("set").description("Set the value one source sees for a context variable key. A locked context variable keeps its own value.").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").requiredOption("--routing-key <key>", "Routing key of the source the override applies to").requiredOption("--key <key>", "Variable key").requiredOption("--value <value>", "Override value").option("--json", "Emit JSON output").action(async (opts) => {
27752
+ try {
27753
+ const result = await getClient().put(sourceOverridePath(opts), { value: opts.value });
27754
+ if (opts.json) console.log(JSON.stringify(result));
27755
+ else console.log(`context source-override set: key=${opts.key} routing-key=${opts.routingKey} (env=${opts.env})`);
27756
+ } catch (err) {
27757
+ console.error(`Error: ${toErrorMessage(err)}`);
27758
+ process.exit(1);
27759
+ }
27760
+ });
27761
+ so.command("delete").description("Delete one source override, so the source resolves the key from the context variables").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").requiredOption("--routing-key <key>", "Routing key of the source the override applies to").requiredOption("--key <key>", "Variable key").option("--json", "Emit JSON output").action(async (opts) => {
27762
+ try {
27763
+ const result = await getClient().delete(sourceOverridePath(opts));
27764
+ if (opts.json) console.log(JSON.stringify(result));
27765
+ else console.log(`context source-override delete: key=${opts.key} routing-key=${opts.routingKey} (env=${opts.env})`);
27766
+ } catch (err) {
27767
+ console.error(`Error: ${toErrorMessage(err)}`);
27768
+ process.exit(1);
27769
+ }
27770
+ });
26835
27771
  }
26836
27772
  //#endregion
26837
27773
  //#region src/cli/commands/variable.ts
@@ -26886,7 +27822,7 @@ function registerVariableCommands(program, getClient) {
26886
27822
  vr.command("delete <orgId> <context> <key>").description("Delete a context variable").option("--yes", "Skip confirmation prompt").action(async (orgId, context, key, opts) => {
26887
27823
  try {
26888
27824
  if (!opts.yes) {
26889
- if (!await confirm$1(`Are you sure you want to delete variable '${key}' from context '${context}'?`)) {
27825
+ if (!await confirmPrompt(`Are you sure you want to delete variable '${key}' from context '${context}'? [y/N] `)) {
26890
27826
  console.log("Aborted.");
26891
27827
  return;
26892
27828
  }
@@ -26899,19 +27835,6 @@ function registerVariableCommands(program, getClient) {
26899
27835
  }
26900
27836
  });
26901
27837
  }
26902
- async function confirm$1(message) {
26903
- const { createInterface } = await import("node:readline");
26904
- const rl = createInterface({
26905
- input: process.stdin,
26906
- output: process.stderr
26907
- });
26908
- return new Promise((resolve) => {
26909
- rl.question(`${message} [y/N] `, (answer) => {
26910
- rl.close();
26911
- resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
26912
- });
26913
- });
26914
- }
26915
27838
  //#endregion
26916
27839
  //#region src/cli/commands/queue.ts
26917
27840
  function resolveDirectDbUrl$3(explicit) {
@@ -27214,7 +28137,7 @@ function printRegistrationShow(res) {
27214
28137
  console.log(`updated_at: ${r.updated_at}`);
27215
28138
  }
27216
28139
  function registerRegistrationCommands(program, getClient) {
27217
- const reg = program.command("registration").description("Registered workflow instance read (workflow_registrations table)");
28140
+ const reg = program.command("registration").description("Registered workflow instances (workflow_registrations table)");
27218
28141
  reg.command("list").description("List workflow_registrations rows (also returns registry_version)").option("--org <id>", "Filter by customer_id").option("--routing-key <k>", "Filter by routing_key").option("--repo <ident>", "Filter by repo_identifier").option("--trigger-type <type>", "Filter by trigger type (in trigger_types[])").option("--limit <n>", "Max rows (default 100, max 1000)").option("--database-url <url>", "Use direct DB access instead of HTTP (offline mode)").option("--json", "Emit JSON output").action(async (opts) => {
27219
28142
  try {
27220
28143
  const limit = parseIntOption(opts.limit, "--limit");
@@ -27257,6 +28180,42 @@ function registerRegistrationCommands(program, getClient) {
27257
28180
  process.exit(1);
27258
28181
  }
27259
28182
  });
28183
+ registerRegistrationWriteCommands(reg, getClient);
28184
+ }
28185
+ /**
28186
+ * `registration disable|enable|delete` — the operator path for the dashboard's
28187
+ * `registration.disable` / `registration.delete` writes. `enable` is the same
28188
+ * admin route as `disable`, with the flag cleared.
28189
+ */
28190
+ function registerRegistrationWriteCommands(reg, getClient) {
28191
+ const setDisabled = async (id, disabled, json) => {
28192
+ try {
28193
+ const result = await getClient().patch(`/api/v1/admin/registrations/${encodeURIComponent(id)}/disable`, { disabled });
28194
+ if (json) console.log(JSON.stringify(result));
28195
+ else console.log(`registration ${disabled ? "disable" : "enable"}: id=${id} disabled=${result.disabled} registry_version=${result.registryVersion}`);
28196
+ } catch (err) {
28197
+ console.error(`Error: ${toErrorMessage(err)}`);
28198
+ process.exit(1);
28199
+ }
28200
+ };
28201
+ reg.command("disable <id>").description("Disable a workflow registration: it stays registered, but its triggers stop dispatching").option("--json", "Emit JSON output").action(async (id, opts) => setDisabled(id, true, opts.json));
28202
+ reg.command("enable <id>").description("Re-enable a disabled workflow registration").option("--json", "Emit JSON output").action(async (id, opts) => setDisabled(id, false, opts.json));
28203
+ reg.command("delete <id>").description("Delete a workflow registration by id").option("--yes", "Skip confirmation prompt").option("--json", "Emit JSON output").action(async (id, opts) => {
28204
+ try {
28205
+ if (!opts.yes) {
28206
+ if (!await confirmPrompt(`Are you sure you want to delete registration '${id}'? [y/N] `)) {
28207
+ console.log("Aborted.");
28208
+ return;
28209
+ }
28210
+ }
28211
+ const result = await getClient().delete(`/api/v1/admin/registrations/${encodeURIComponent(id)}`);
28212
+ if (opts.json) console.log(JSON.stringify(result));
28213
+ else console.log(`registration delete: id=${id} deleted=${result.deleted} registry_version=${result.registryVersion}`);
28214
+ } catch (err) {
28215
+ console.error(`Error: ${toErrorMessage(err)}`);
28216
+ process.exit(1);
28217
+ }
28218
+ });
27260
28219
  }
27261
28220
  //#endregion
27262
28221
  //#region src/cli/commands/event.ts
@@ -27395,7 +28354,7 @@ function registerEventDlqCommands(program, getClient) {
27395
28354
  * Contract:
27396
28355
  * - tenant column: `routing_key` (denormalized in migration 006)
27397
28356
  * - partition column: `created_at`
27398
- * - warm TTL: 30 days (design §5 matrix row 9)
28357
+ * - warm TTL: 30 days
27399
28358
  * - eligibility: created_at < cutoff (steps are append-only and have no
27400
28359
  * terminal-status predicate — there is no UPDATE statement against
27401
28360
  * `execution_steps` in the orchestrator codebase, so age alone is
@@ -27553,7 +28512,7 @@ var init_execution_steps = __esmMin((() => {
27553
28512
  * Contract:
27554
28513
  * - tenant column: `routing_key` (denormalized in migration 006)
27555
28514
  * - partition column: `created_at`
27556
- * - warm TTL: 30 days (design §5 matrix row 8)
28515
+ * - warm TTL: 30 days
27557
28516
  * - eligibility: terminal job status AND no live steps remain for this
27558
28517
  * (run, job)
27559
28518
  *
@@ -27728,7 +28687,7 @@ var init_execution_jobs = __esmMin((() => {
27728
28687
  * Contract:
27729
28688
  * - tenant column: `routing_key`
27730
28689
  * - partition column: `created_at`
27731
- * - warm TTL: 30 days (design §5 matrix row 7)
28690
+ * - warm TTL: 30 days
27732
28691
  * - eligibility: terminal run status AND no live jobs reference this run
27733
28692
  *
27734
28693
  * Order: registered LAST in the orchestrator cold-store. Within one
@@ -27846,7 +28805,7 @@ var init_execution_runs = __esmMin((() => {
27846
28805
  return row.created_at;
27847
28806
  }
27848
28807
  /**
27849
- * Phase F — natural-key extractor used by `replayRow` so callers
28808
+ * Natural-key extractor used by `replayRow` so callers
27850
28809
  * (the rerun pipeline) can locate the chunk holding a given UUID
27851
28810
  * `run_id` without knowing the internal SERIAL `id` returned by
27852
28811
  * `rowId()`.
@@ -27855,10 +28814,10 @@ var init_execution_runs = __esmMin((() => {
27855
28814
  return row.run_id;
27856
28815
  }
27857
28816
  /**
27858
- * Phase F — replay a chunk's `execution_runs` rows back into
28817
+ * Replay a chunk's `execution_runs` rows back into
27859
28818
  * Orchestrator PG. Mirrors the Platform-side replayInsert; the
27860
28819
  * audit row goes to `access_log` (not `audit_log`) because the
27861
- * orchestrator's audit surface is access_log per design §8.
28820
+ * orchestrator's audit surface is access_log.
27862
28821
  *
27863
28822
  * Idempotent on duplicate replays via `ON CONFLICT (run_id) DO
27864
28823
  * NOTHING`. Decrements `cold_store_chunk_counts` (floored at 0).
@@ -27957,8 +28916,8 @@ var init_execution_runs = __esmMin((() => {
27957
28916
  * Contract:
27958
28917
  * - tenant column: `routing_key` (NULL → synthetic `__orchestrator__`)
27959
28918
  * - partition column: `timestamp`
27960
- * - warm TTL: 90 days (longer window than other audit tables — design
27961
- * §5 matrix row 10. Volume is low, forensic value is high)
28919
+ * - warm TTL: 90 days (longer window than other audit tables — volume is
28920
+ * low, forensic value is high)
27962
28921
  *
27963
28922
  * Synthetic tenant: rows without `routing_key` (e.g. orchestrator-level
27964
28923
  * key rotations done before any source is bound) collapse to a single
@@ -28091,7 +29050,7 @@ var init_secret_audit_log = __esmMin((() => {
28091
29050
  return row.timestamp;
28092
29051
  }
28093
29052
  /**
28094
- * Phase 2 — per-row cold-retention TTL. Sampled `resolve` /
29053
+ * Per-row cold-retention TTL. Sampled `resolve` /
28095
29054
  * `resolve_named` rows get 180d cold; mutations get `'forever'`. The
28096
29055
  * denied-outcome override promotes to 730d (forensic).
28097
29056
  *
@@ -28205,7 +29164,7 @@ var init_secret_audit_log = __esmMin((() => {
28205
29164
  * Contract:
28206
29165
  * - tenant column: `org_id` (NULL → synthetic `__orchestrator__`)
28207
29166
  * - partition column: `created_at`
28208
- * - warm TTL: 30 days (design §5 matrix row 11)
29167
+ * - warm TTL: 30 days
28209
29168
  *
28210
29169
  * Replaces the previous 90-day `expires_at`-based hard delete (removed
28211
29170
  * by migration 007). Rows older than 30 days now live in S3
@@ -28346,7 +29305,7 @@ var init_access_log = __esmMin((() => {
28346
29305
  return row.created_at;
28347
29306
  }
28348
29307
  /**
28349
- * Phase 2 — per-row cold-retention TTL. Mirrors the warm-side override
29308
+ * Per-row cold-retention TTL. Mirrors the warm-side override
28350
29309
  * layering: outcome=denied/error → 730d (forensic), platform_operator →
28351
29310
  * `'forever'` (compliance), otherwise per-action map. The framework
28352
29311
  * groups rows by `coldDaysToBucket(coldTtlDays(row))` at archive time.
@@ -28451,27 +29410,21 @@ var init_access_log = __esmMin((() => {
28451
29410
  //#endregion
28452
29411
  //#region src/cold-store/tables/event-log.ts
28453
29412
  /**
28454
- * `event_log` cold-store adapter (Orchestrator side) —.
28455
- *
28456
- * §2 row 6
28457
- * and §10.
29413
+ * `event_log` cold-store adapter (Orchestrator side).
28458
29414
  *
28459
29415
  * Contract:
28460
29416
  * - tenant column: `routing_key` (NOT NULL on this side — no
28461
29417
  * synthetic-tenant fallback like access_log / secret_audit_log)
28462
29418
  * - partition column: `received_at`
28463
- * - warm TTL: 30 days (replaces the previous 30-day hard delete in
28464
- * `packages/orchestrator/src/webhook/event-log.ts:cleanup` +
28465
- * `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4)
29419
+ * - warm TTL: 30 days (rows are archived, not hard-deleted)
28466
29420
  *
28467
29421
  * Per-row payload retention: rows on this table carry a `payload_key`
28468
29422
  * pointing at a gzipped webhook body in object storage (LogStorage).
28469
29423
  * Cold-store packages the row metadata — including `payload_key` —
28470
29424
  * but does NOT touch the body the key points to. The body remains in
28471
29425
  * S3 indefinitely so the dashboard delivery-detail page resolves
28472
- * payload reads identically for hot and cold rows. The pre-Phase-E
28473
- * cleanup that deleted both the row and the payload blob in
28474
- * lock-step is retired.
29426
+ * payload reads identically for hot and cold rows. Nothing deletes the
29427
+ * row and the payload blob in lock-step.
28475
29428
  *
28476
29429
  * Recursive write: archiving event_log rows writes one access_log
28477
29430
  * audit row per chunk (the orchestrator's audit surface). The new
@@ -28613,17 +29566,15 @@ var init_event_log = __esmMin((() => {
28613
29566
  /**
28614
29567
  * Orchestrator-side cold-store.
28615
29568
  *
28616
- * Extends BaseColdStore with orchestrator-specific table adapters.
28617
- * Phase A registered no adapters; Phase C added execution_runs /
28618
- * execution_jobs / execution_steps. Phase D adds secret_audit_log
28619
- * and access_log.
29569
+ * Extends BaseColdStore with orchestrator-specific table adapters:
29570
+ * execution_runs / execution_jobs / execution_steps, secret_audit_log,
29571
+ * access_log and event_log.
28620
29572
  */
28621
29573
  /**
28622
29574
  * Build the orchestrator cold-store config from env vars. Reads
28623
- * `KICI_COLD_STORE_*` vars mirrored with the Platform side. YAML
28624
- * integration (per the design doc) lands in a follow-up commit that
28625
- * wires the `coldStore` section of the orchestrator config schema;
28626
- * Phase A ships the env-var path only.
29575
+ * `KICI_COLD_STORE_*` vars mirrored with the Platform side. The
29576
+ * orchestrator config schema has no `coldStore` section, so env vars are
29577
+ * the only input.
28627
29578
  */
28628
29579
  function readOrchestratorColdStoreConfig(env = process.env) {
28629
29580
  const enabled = env.KICI_COLD_STORE_ENABLED === "true";
@@ -28716,7 +29667,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
28716
29667
  this.registerAdapter(new EventLogAdapter(deps.kdb, deps.instanceId, { overrides: deps.config.tables.event_log ?? deps.config.tables["event_log"] }));
28717
29668
  }
28718
29669
  /**
28719
- * Phase 2 — query the orchestrator's `cold_store_chunks` table for
29670
+ * Query the orchestrator's `cold_store_chunks` table for
28720
29671
  * chunks past their per-row cold-retention horizon. See the matching
28721
29672
  * Platform-side method for the full rationale.
28722
29673
  */
@@ -28749,7 +29700,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
28749
29700
  }));
28750
29701
  }
28751
29702
  /**
28752
- * Phase 2 — per-chunk advisory lock keyed on
29703
+ * Per-chunk advisory lock keyed on
28753
29704
  * `hashtext('cold-store-purge|orchestrator|<table>|<chunkId>')`.
28754
29705
  */
28755
29706
  async withPurgeLock(args, fn) {
@@ -28770,10 +29721,9 @@ var init_orchestrator_cold_store = __esmMin((() => {
28770
29721
  //#endregion
28771
29722
  //#region src/cli/commands/cold-store-impl.ts
28772
29723
  /**
28773
- * Phase C implementations for `kici-admin cold-store` subcommands.
29724
+ * Implementations for `kici-admin cold-store` subcommands.
28774
29725
  *
28775
- * Mirrors the Platform-side implementation in
28776
- * `packages/platform/src/admin/cold-store-commands.ts` — same shape,
29726
+ * Mirrors the Platform-side implementation — same shape,
28777
29727
  * just builds an `OrchestratorColdStore` against the orchestrator
28778
29728
  * Postgres + S3 instead of a `PlatformColdStore`.
28779
29729
  *
@@ -28785,9 +29735,8 @@ var init_orchestrator_cold_store = __esmMin((() => {
28785
29735
  * - prints results to stdout (JSON Lines for list-chunks, JSONL for
28786
29736
  * peek-chunk, free-form for the others)
28787
29737
  *
28788
- * Audit emission lives one level up in the command registration so we
28789
- * can write one `access_log` breadcrumb row per CLI invocation in the
28790
- * same shape the Platform admin CLI uses for `audit_log`.
29738
+ * No per-invocation breadcrumb row is written: the Platform admin CLI's
29739
+ * `audit_log` breadcrumb has no `access_log` counterpart here.
28791
29740
  */
28792
29741
  var cold_store_impl_exports = /* @__PURE__ */ __exportAll({
28793
29742
  archiveNow: () => archiveNow,
@@ -29050,7 +29999,7 @@ async function replayChunk(opts) {
29050
29999
  }
29051
30000
  }
29052
30001
  /**
29053
- * Phase F — promote a chunk's rows BACK into the orchestrator PG.
30002
+ * Promote a chunk's rows back into the orchestrator PG.
29054
30003
  * Mirrors the Platform-side `replayIntoPg`. Adapter must implement
29055
30004
  * `replayInsert` — currently only `execution_runs`.
29056
30005
  */
@@ -29182,7 +30131,7 @@ async function reconcile(opts) {
29182
30131
  }
29183
30132
  }
29184
30133
  /**
29185
- * Phase 2 — list chunks past their cold-retention horizon. Read-only;
30134
+ * List chunks past their cold-retention horizon. Read-only;
29186
30135
  * no S3 or PG mutation. Emits one JSON line per row so the output is
29187
30136
  * easy to pipe through `jq` / `column -t` / etc.
29188
30137
  */
@@ -29206,7 +30155,7 @@ async function listPurgeable(opts) {
29206
30155
  }
29207
30156
  }
29208
30157
  /**
29209
- * Phase 2 — delete S3 objects + clean up PG bookkeeping for chunks
30158
+ * Delete S3 objects + clean up PG bookkeeping for chunks
29210
30159
  * past their cold-retention horizon. Defaults to dry-run; pass
29211
30160
  * `--apply` to actually delete.
29212
30161
  */
@@ -29358,7 +30307,7 @@ function registerColdStoreCommands(program, _getClient) {
29358
30307
  process.exit(2);
29359
30308
  }
29360
30309
  });
29361
- coldStore.command("replay-into-pg <chunkId>").description("Phase F: promote every row in a chunk BACK into orchestrator PG (clear archived_at, write replay audit)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").requiredOption("--table <table>", "Adapter table name (currently: execution_runs)").requiredOption("--tenant <rk>", "Routing key for the chunk").requiredOption("--partition-date <YYYY-MM-DD>", "Partition date for the chunk").action(async (chunkId, opts) => {
30310
+ coldStore.command("replay-into-pg <chunkId>").description("Promote every row in a chunk back into orchestrator PG (clear archived_at, write a replay audit row)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").requiredOption("--table <table>", "Adapter table name (currently: execution_runs)").requiredOption("--tenant <rk>", "Routing key for the chunk").requiredOption("--partition-date <YYYY-MM-DD>", "Partition date for the chunk").action(async (chunkId, opts) => {
29362
30311
  try {
29363
30312
  const databaseUrl = resolveDatabaseUrl(opts);
29364
30313
  const { replayIntoPg } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
@@ -29389,7 +30338,7 @@ function registerColdStoreCommands(program, _getClient) {
29389
30338
  process.exit(2);
29390
30339
  }
29391
30340
  });
29392
- coldStore.command("list-purgeable").description("Phase 2: list chunks past their cold-retention horizon (read-only)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to inspect", "1000").action(async (opts) => {
30341
+ coldStore.command("list-purgeable").description("List chunks past their cold-retention horizon (read-only)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to inspect", "1000").action(async (opts) => {
29393
30342
  try {
29394
30343
  const databaseUrl = resolveDatabaseUrl(opts);
29395
30344
  const { listPurgeable } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
@@ -29404,7 +30353,7 @@ function registerColdStoreCommands(program, _getClient) {
29404
30353
  process.exit(2);
29405
30354
  }
29406
30355
  });
29407
- coldStore.command("purge-now").description("Phase 2: purge expired chunks from S3 + PG bookkeeping. DRY-RUN by default — pass --apply to actually delete.").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to process", "1000").option("--apply", "Actually delete (default is dry-run)").action(async (opts) => {
30356
+ coldStore.command("purge-now").description("Purge expired chunks from S3 + PG bookkeeping. DRY-RUN by default — pass --apply to actually delete.").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to process", "1000").option("--apply", "Actually delete (default is dry-run)").action(async (opts) => {
29408
30357
  try {
29409
30358
  const databaseUrl = resolveDatabaseUrl(opts);
29410
30359
  const { purgeNow } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
@@ -29644,7 +30593,7 @@ function registerCacheCommands(program, deps = {
29644
30593
  *
29645
30594
  * A leaf module on purpose: it imports nothing, so an out-of-package consumer
29646
30595
  * can read the constant without pulling the orchestrator runtime graph in with
29647
- * it. The NetworkManager drift watchdog (`hack/lib/nm-watchdog.ts`) derives its
30596
+ * it. A NetworkManager drift watchdog derives its
29648
30597
  * expected-pattern set from here rather than re-typing the patterns — a second
29649
30598
  * copy desyncs silently the moment a pattern is added. `host-network.ts`
29650
30599
  * re-exports it and renders `NM_CONF_CONTENT` from it, so the drop-in and the
@@ -29712,9 +30661,9 @@ function bridgePrefix(bridgeName) {
29712
30661
  * serializes them.
29713
30662
  *
29714
30663
  * The command list NEVER deletes the table. It is a self-heal — `ensureHostReady`
29715
- * runs it whenever `verifyBridge` reports unhealthy, which happens on any of
29716
- * four conditions none of which knows how many VMs are live — so dropping the
29717
- * table would leave every running VM fail-open, with full RFC1918 and
30664
+ * runs it whenever `verifyBridge` reports unhealthy, and none of the
30665
+ * conditions it checks says how many VMs are live — so dropping the table
30666
+ * would leave every running VM fail-open, with full RFC1918 and
29718
30667
  * cloud-metadata reach, until it was destroyed. Instead the baseline lives in
29719
30668
  * its own regular chain (`baseline`, see {@link BASELINE_CHAIN}) that is flushed
29720
30669
  * and refilled, and the hooked `forward` chain keeps its per-VM rules
@@ -29844,10 +30793,9 @@ const NM_CONF_CONTENT = [
29844
30793
  "#",
29845
30794
  "# The `+=` append operator is mandatory: NM merges conf.d drop-ins last-wins",
29846
30795
  "# per key, so a bare `unmanaged-devices=` here would be silently clobbered by",
29847
- "# (or would silently clobber) any other drop-in's unmanaged-devices line — e.g.",
29848
- "# the wifi drop-in from @kici-dev/util-linux-management. `+=` accumulates",
29849
- "# across files so every drop-in survives. See",
29850
- "# .claude/rules/networkmanager-unmanaged.md.",
30796
+ "# (or would silently clobber) any other drop-in's unmanaged-devices line, such",
30797
+ "# as a wifi drop-in another tool installs. `+=` accumulates across files so",
30798
+ "# every drop-in survives.",
29851
30799
  "[keyfile]",
29852
30800
  ...FIRECRACKER_NET_INTERFACES.map((p) => `unmanaged-devices+=interface-name:${p}`),
29853
30801
  ""
@@ -30035,6 +30983,7 @@ async function verifyBridge(cfg, opts = {}) {
30035
30983
  } catch {
30036
30984
  misses.push(`could not read chain ip ${cfg.table} forward`);
30037
30985
  }
30986
+ const { natPresent, baselineRulesPresent } = await checkSubnetRules(cfg, runner, misses);
30038
30987
  try {
30039
30988
  const { stdout } = await runner({
30040
30989
  bin: "ip",
@@ -30053,7 +31002,7 @@ async function verifyBridge(cfg, opts = {}) {
30053
31002
  } catch {
30054
31003
  misses.push(`could not read enslaved links on ${cfg.bridgeName}`);
30055
31004
  }
30056
- const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && tapIsolationPresent;
31005
+ const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && natPresent && baselineRulesPresent && tapIsolationPresent;
30057
31006
  return {
30058
31007
  bridgeName: cfg.bridgeName,
30059
31008
  bridgeExists,
@@ -30061,6 +31010,8 @@ async function verifyBridge(cfg, opts = {}) {
30061
31010
  addrPresent,
30062
31011
  tablePresent,
30063
31012
  baselineChainPresent,
31013
+ natPresent,
31014
+ baselineRulesPresent,
30064
31015
  tapIsolationPresent,
30065
31016
  healthy,
30066
31017
  detail: healthy ? "healthy" : misses.join("; ")
@@ -30090,6 +31041,146 @@ function forwardEndsInBaselineJump(nftJson) {
30090
31041
  });
30091
31042
  }
30092
31043
  /**
31044
+ * Check the two chains that carry this bridge's subnet: the `postrouting` NAT
31045
+ * that gives its guests internet egress, and the `baseline` rules that isolate
31046
+ * them.
31047
+ *
31048
+ * The table, its chains and the tail jump can all be in place while neither
31049
+ * rule set serves this subnet: a second bridge provisioned into the same table
31050
+ * flushes both chains and refills them with its own subnet. Every guest on this
31051
+ * bridge then loses internet access — no name resolves — and a check that stops
31052
+ * at the chain layout still reads the host as healthy, so the self-heal never
31053
+ * runs.
31054
+ */
31055
+ async function checkSubnetRules(cfg, runner, misses) {
31056
+ let subnet;
31057
+ try {
31058
+ subnet = cidrToNetwork(cfg.bridgeCidr);
31059
+ } catch (err) {
31060
+ misses.push(`invalid bridge CIDR ${cfg.bridgeCidr}: ${toErrorMessage(err)}`);
31061
+ return {
31062
+ natPresent: false,
31063
+ baselineRulesPresent: false
31064
+ };
31065
+ }
31066
+ const listChain = async (chain) => {
31067
+ try {
31068
+ const { stdout } = await runner({
31069
+ bin: "nft",
31070
+ args: [
31071
+ "-j",
31072
+ "list",
31073
+ "chain",
31074
+ "ip",
31075
+ cfg.table,
31076
+ chain
31077
+ ]
31078
+ });
31079
+ return stdout;
31080
+ } catch {
31081
+ misses.push(`could not read chain ip ${cfg.table} ${chain}`);
31082
+ return null;
31083
+ }
31084
+ };
31085
+ const postrouting = await listChain("postrouting");
31086
+ const natPresent = postrouting !== null && postroutingMasqueradesSubnet(postrouting, subnet);
31087
+ if (postrouting !== null && !natPresent) misses.push(`ip ${cfg.table} postrouting does not masquerade ${subnet}`);
31088
+ const baseline = await listChain(BASELINE_CHAIN);
31089
+ const baselineRulesPresent = baseline !== null && baselineHoldsBridgeRules(baseline, cfg);
31090
+ if (baseline !== null && !baselineRulesPresent) misses.push(`ip ${cfg.table} ${BASELINE_CHAIN} does not hold the ${subnet} rules`);
31091
+ return {
31092
+ natPresent,
31093
+ baselineRulesPresent
31094
+ };
31095
+ }
31096
+ const RULE_VERDICTS = [
31097
+ "accept",
31098
+ "drop",
31099
+ "masquerade"
31100
+ ];
31101
+ /** An nft JSON address operand as `a.b.c.d` or `a.b.c.d/len`; null for any other operand. */
31102
+ function nftAddress(operand) {
31103
+ if (typeof operand === "string") return operand;
31104
+ const prefix = operand?.prefix;
31105
+ return typeof prefix?.addr === "string" && typeof prefix.len === "number" ? `${prefix.addr}/${prefix.len}` : null;
31106
+ }
31107
+ /** The rules of a `nft -j list chain` listing, in chain order; null when it does not parse. */
31108
+ function listedRuleShapes(nftJson) {
31109
+ let parsed;
31110
+ try {
31111
+ parsed = JSON.parse(nftJson);
31112
+ } catch {
31113
+ return null;
31114
+ }
31115
+ const entries = parsed?.nftables;
31116
+ if (!Array.isArray(entries)) return null;
31117
+ const shapes = [];
31118
+ for (const entry of entries) {
31119
+ const expr = entry.rule?.expr;
31120
+ if (!Array.isArray(expr)) continue;
31121
+ const shape = {
31122
+ saddr: null,
31123
+ daddr: null,
31124
+ verdict: null
31125
+ };
31126
+ for (const node of expr) {
31127
+ const match = node.match;
31128
+ const positive = match?.op === void 0 || match.op === "==";
31129
+ const payload = match && positive ? match.left?.payload : void 0;
31130
+ if (payload?.protocol === "ip" && payload.field === "saddr") shape.saddr = nftAddress(match.right);
31131
+ else if (payload?.protocol === "ip" && payload.field === "daddr") shape.daddr = nftAddress(match.right);
31132
+ shape.verdict = RULE_VERDICTS.find((v) => v in node) ?? shape.verdict;
31133
+ }
31134
+ shapes.push(shape);
31135
+ }
31136
+ return shapes;
31137
+ }
31138
+ /** The shape of one `nft add rule` token list the provisioner builds. */
31139
+ function commandRuleShape(tokens) {
31140
+ const shape = {
31141
+ saddr: null,
31142
+ daddr: null,
31143
+ verdict: null
31144
+ };
31145
+ for (let i = 0; i + 2 < tokens.length; i++) {
31146
+ if (tokens[i] !== "ip") continue;
31147
+ if (tokens[i + 1] === "saddr") shape.saddr = tokens[i + 2];
31148
+ if (tokens[i + 1] === "daddr") shape.daddr = tokens[i + 2];
31149
+ }
31150
+ shape.verdict = RULE_VERDICTS.find((v) => v === tokens.at(-1)) ?? null;
31151
+ return shape;
31152
+ }
31153
+ /** `<daddr> <verdict>` for every subnet-scoped accept or drop, in chain order (`*`: any daddr). */
31154
+ function subnetVerdicts(rules, subnet) {
31155
+ return rules.filter((r) => r.saddr === subnet && (r.verdict === "accept" || r.verdict === "drop")).map((r) => `${r.daddr ?? "*"} ${r.verdict}`);
31156
+ }
31157
+ /**
31158
+ * True when a `nft -j list chain … postrouting` listing masquerades traffic
31159
+ * whose source is `subnet` (the network CIDR, e.g. `10.0.0.0/24`).
31160
+ */
31161
+ function postroutingMasqueradesSubnet(nftJson, subnet) {
31162
+ return (listedRuleShapes(nftJson) ?? []).some((r) => r.saddr === subnet && r.verdict === "masquerade");
31163
+ }
31164
+ /**
31165
+ * True when a `nft -j list chain … baseline` listing holds this bridge's
31166
+ * subnet-scoped accepts and drops exactly as {@link buildBridgeCommands} writes
31167
+ * them, in the same order.
31168
+ *
31169
+ * Order is part of the check: the internet accept is terminal, so ahead of the
31170
+ * RFC1918 and metadata drops it would let the guests reach every private range.
31171
+ */
31172
+ function baselineHoldsBridgeRules(nftJson, cfg) {
31173
+ const listed = listedRuleShapes(nftJson);
31174
+ if (listed === null) return false;
31175
+ const subnet = cidrToNetwork(cfg.bridgeCidr);
31176
+ const want = subnetVerdicts(buildBridgeCommands({
31177
+ ...cfg,
31178
+ hostIface: cfg.hostIface ?? "egress"
31179
+ }).filter((c) => c.bin === "nft" && c.args[0] === "add" && c.args[1] === "rule" && c.args[3] === BASELINE_CHAIN).map((c) => commandRuleShape(c.args.slice(4))), subnet);
31180
+ const got = subnetVerdicts(listed, subnet);
31181
+ return want.length === got.length && want.every((v, i) => v === got[i]);
31182
+ }
31183
+ /**
30093
31184
  * Names of the kici-* TAPs enslaved to the bridge that are NOT port-isolated,
30094
31185
  * read from `ip -d -j link show master <bridge>`.
30095
31186
  *
@@ -30255,7 +31346,7 @@ function registerProvision(group) {
30255
31346
  //#endregion
30256
31347
  //#region src/cli/commands/firecracker/verify.ts
30257
31348
  function registerVerify(group) {
30258
- group.command("verify").description("Check a Firecracker host bridge is up with its addr + nft table").requiredOption("--bridge <name>", "bridge interface name").requiredOption("--cidr <cidr>", "gateway IP + prefix").option("--table <name>", "nft table name", "kici").option("--sudo", "wrap privileged commands with sudo -n").action(async (opts) => {
31349
+ group.command("verify").description("Check a Firecracker host bridge: its addr, nft table, and subnet NAT + isolation rules").requiredOption("--bridge <name>", "bridge interface name").requiredOption("--cidr <cidr>", "gateway IP + prefix").option("--table <name>", "nft table name", "kici").option("--sudo", "wrap privileged commands with sudo -n").action(async (opts) => {
30259
31350
  const cfg = optionsToConfig(opts);
30260
31351
  const h = await verifyBridge(cfg, { requireSudo: opts.sudo });
30261
31352
  if (h.healthy) {
@@ -30334,6 +31425,57 @@ function isNodeError(err) {
30334
31425
  return err instanceof Error && "code" in err;
30335
31426
  }
30336
31427
  z.enum(["hook", "sweep"]);
31428
+ //#endregion
31429
+ //#region src/scaler/firecracker-extra-hosts.ts
31430
+ /**
31431
+ * A Firecracker scaler's `extraHosts`: the `host:address` mappings the rootfs
31432
+ * `/init` appends to each guest's `/etc/hosts`.
31433
+ *
31434
+ * An entry is `host:address`, the form the container backend documents for the
31435
+ * same field; the container runtimes accept more (`host=address`, bracketed
31436
+ * IPv6), a Firecracker scaler does not. `host-gateway` stands for the bridge
31437
+ * gateway, the address a microVM reaches its host at. A guest carries no
31438
+ * mapping unless the scaler configures one.
31439
+ */
31440
+ /** The address keyword for the bridge gateway, as the container runtimes spell it. */
31441
+ const HOST_GATEWAY = "host-gateway";
31442
+ /**
31443
+ * An RFC 1123 hostname: dot-separated labels of letters, digits and inner
31444
+ * hyphens. Nothing else can reach the guest's `/etc/hosts`, so an entry can
31445
+ * never add a second line or a second name there.
31446
+ */
31447
+ const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
31448
+ /**
31449
+ * Parse one `host:address` entry. The host ends at the first colon, so an
31450
+ * IPv6 address keeps its own colons. Throws on a malformed entry.
31451
+ */
31452
+ function parseExtraHost(entry) {
31453
+ const sep = entry.indexOf(":");
31454
+ const host = sep === -1 ? "" : entry.slice(0, sep);
31455
+ const address = sep === -1 ? "" : entry.slice(sep + 1);
31456
+ if (host === "" || address === "") throw new Error(`extraHosts entry "${entry}" must be host:address`);
31457
+ if (!HOSTNAME.test(host)) throw new Error(`extraHosts entry "${entry}": "${host}" is not a valid hostname`);
31458
+ if (address !== "host-gateway" && isIP(address) === 0) throw new Error(`extraHosts entry "${entry}": the address must be an IP address or ${HOST_GATEWAY}`);
31459
+ return {
31460
+ host,
31461
+ address
31462
+ };
31463
+ }
31464
+ /**
31465
+ * The MMDS value the rootfs `/init` reads: `host:address` pairs joined by
31466
+ * commas, with `host-gateway` replaced by `gateway`. `undefined` when the
31467
+ * scaler configures no mapping, so the guest gets none. Throws on a malformed
31468
+ * entry, and on `host-gateway` when `gateway` is not an IP address.
31469
+ */
31470
+ function renderGuestExtraHosts(entries, gateway) {
31471
+ if (!entries || entries.length === 0) return void 0;
31472
+ return entries.map((entry) => {
31473
+ const { host, address } = parseExtraHost(entry);
31474
+ if (address !== "host-gateway") return `${host}:${address}`;
31475
+ if (isIP(gateway) === 0) throw new Error(`extraHosts entry "${entry}": the bridge gateway "${gateway}" is not an IP address`);
31476
+ return `${host}:${gateway}`;
31477
+ }).join(",");
31478
+ }
30337
31479
  /**
30338
31480
  * Zod schema for a single resource spec (cpus + memory).
30339
31481
  *
@@ -30476,6 +31618,12 @@ const scalerEntrySchema = z.object({
30476
31618
  "auto"
30477
31619
  ]).default("auto"),
30478
31620
  orchestratorUrl: z.string().optional(),
31621
+ /**
31622
+ * Extra `host:address` name mappings for spawned agents. A container scaler
31623
+ * passes them to the runtime; a Firecracker scaler writes them into each
31624
+ * guest's `/etc/hosts`, with `host-gateway` resolved to the bridge gateway.
31625
+ * None by default.
31626
+ */
30479
31627
  extraHosts: z.array(z.string()).optional(),
30480
31628
  networkIsolation: z.boolean().default(true),
30481
31629
  warmPool: warmPoolSchema,
@@ -30621,6 +31769,17 @@ const scalerEntrySchema = z.object({
30621
31769
  message: `Firecracker scaler "${data.name}" requires a 'gid' field`,
30622
31770
  path: ["gid"]
30623
31771
  });
31772
+ (data.extraHosts ?? []).forEach((entry, i) => {
31773
+ try {
31774
+ parseExtraHost(entry);
31775
+ } catch (err) {
31776
+ ctx.addIssue({
31777
+ code: z.ZodIssueCode.custom,
31778
+ message: `Firecracker scaler "${data.name}" extraHosts[${i}]: ${toErrorMessage(err)}`,
31779
+ path: ["extraHosts", i]
31780
+ });
31781
+ }
31782
+ });
30624
31783
  data.labelSets.forEach((ls, i) => {
30625
31784
  if (!ls.rootfsPath) ctx.addIssue({
30626
31785
  code: z.ZodIssueCode.custom,
@@ -31486,19 +32645,12 @@ const POSIX_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31486
32645
  * Interface-name pattern for per-VM TAP devices.
31487
32646
  * Matches what {@link generateTapName} produces — `kici-` plus the LAST 8
31488
32647
  * characters of the VM id, which for a scaler-minted id is 8 lowercase hex.
32648
+ * The orphan sweep considers only names of this shape, so the default bridge
32649
+ * (`kici-br0`) and any other operator interface outside it are never
32650
+ * candidates. The scaler's configured bridge is skipped by name as well, in
32651
+ * case an operator named it in this shape: deleting it kills every running VM.
31489
32652
  */
31490
32653
  const VM_TAP_PATTERN = /^kici-[0-9a-f]{8}$/;
31491
- /**
31492
- * Infrastructure interfaces that share the `kici-` prefix but must NEVER be
31493
- * deleted by the orphan sweep. Bridges (kici-br0/kici-br1) carry all VM
31494
- * traffic; kici-m01 is the staging metadata interface. Losing any of these
31495
- * kills all running VMs.
31496
- */
31497
- const PROTECTED_INTERFACES = [
31498
- "kici-br0",
31499
- "kici-br1",
31500
- "kici-m01"
31501
- ];
31502
32654
  /** Convert a dotted IPv4 netmask ('255.255.255.0') to a CIDR prefix length (24). */
31503
32655
  function netmaskToPrefix(netmask) {
31504
32656
  return netmask.split(".").map((o) => Number(o).toString(2).padStart(8, "0")).join("").split("1").length - 1;
@@ -31533,6 +32685,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31533
32685
  bridgeName;
31534
32686
  cidr;
31535
32687
  gateway;
32688
+ /** The MMDS `kici-extra-hosts` value, or undefined when the scaler maps no host. */
32689
+ guestExtraHosts;
31536
32690
  /** Host services the orchestrator directed agents at, as `hostAccess` entries. */
31537
32691
  hostServices;
31538
32692
  netmask;
@@ -31567,6 +32721,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31567
32721
  this.bridgeName = options.bridgeName;
31568
32722
  this.cidr = options.cidr;
31569
32723
  this.gateway = options.gateway;
32724
+ this.guestExtraHosts = renderGuestExtraHosts(options.extraHosts, options.gateway);
31570
32725
  this.hostServices = options.hostServices;
31571
32726
  this.netmask = options.netmask;
31572
32727
  this.table = options.table ?? "kici";
@@ -31661,7 +32816,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31661
32816
  });
31662
32817
  let alloc;
31663
32818
  const onSpawnAbort = () => {
31664
- this.abandonSpawn(agentId, alloc, "spawn aborted (deadline or shutdown)");
32819
+ this.abandonSpawnDetached(agentId, alloc, "spawn aborted (deadline or shutdown)");
31665
32820
  };
31666
32821
  signal?.addEventListener("abort", onSpawnAbort, { once: true });
31667
32822
  try {
@@ -31800,8 +32955,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31800
32955
  "kici-agent-id": agentId,
31801
32956
  "kici-labels": fullLabels.join(","),
31802
32957
  "kici-scaler-managed": "1",
31803
- "kici-gateway-ip": this.gateway,
31804
32958
  ...agentToken ? { "kici-agent-token": agentToken } : {},
32959
+ ...this.guestExtraHosts ? { "kici-extra-hosts": this.guestExtraHosts } : {},
31805
32960
  ...matchedLabelSet.backpressureMode ? { "kici-backpressure-mode": matchedLabelSet.backpressureMode } : {},
31806
32961
  ...Object.keys(acceptedEnv).length > 0 ? { "kici-env": acceptedEnv } : {}
31807
32962
  } } });
@@ -31837,6 +32992,13 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31837
32992
  logger$2.warn(`Cleanup after abandoned spawn ${agentId} failed: ${toErrorMessage(err)}`);
31838
32993
  }
31839
32994
  }
32995
+ /**
32996
+ * {@link abandonSpawn} from a path that cannot await it (an abort listener, a
32997
+ * process exit handler). A failure is logged.
32998
+ */
32999
+ abandonSpawnDetached(agentId, alloc, reason) {
33000
+ runDetached(logger$2, "Abandoned spawn teardown", () => this.abandonSpawn(agentId, alloc, reason), { agentId });
33001
+ }
31840
33002
  getScalerContext(agentId) {
31841
33003
  const managed = this.agents.get(agentId);
31842
33004
  if (!managed) return void 0;
@@ -32214,7 +33376,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32214
33376
  }
32215
33377
  for (const name of ifaceNames) {
32216
33378
  if (!VM_TAP_PATTERN.test(name)) continue;
32217
- if (PROTECTED_INTERFACES.includes(name)) continue;
33379
+ if (name === this.bridgeName) continue;
32218
33380
  if (allocatedTapsBefore.has(name)) continue;
32219
33381
  if (allocatedTapsAfter.has(name)) continue;
32220
33382
  if (trackedTapDevices.has(name)) continue;
@@ -32273,9 +33435,9 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32273
33435
  /**
32274
33436
  * Default interval for periodic orphan sweeps (15 minutes).
32275
33437
  *
32276
- * Long-running orchestrators need in-process orphan sweeps because the
32277
- * external kici-leak-sweep timer skips TAP cleanup while a staging
32278
- * orchestrator is active (to avoid racing spawns). Without this, leaked
33438
+ * Long-running orchestrators need in-process orphan sweeps because a
33439
+ * host-level cleanup job skips TAP cleanup while an orchestrator is active
33440
+ * (to avoid racing spawns). Without this, leaked
32279
33441
  * TAPs accumulate until the orchestrator restarts — which can be weeks.
32280
33442
  */
32281
33443
  static ORPHAN_SWEEP_INTERVAL_MS = 9e5;
@@ -32672,8 +33834,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32672
33834
  this.tailAbortControllers.set(agentId, abortController);
32673
33835
  const signal = abortController.signal;
32674
33836
  const output = process.stdout;
32675
- this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output);
32676
- this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output);
33837
+ runDetached(logger$2, "Serial console tail", () => this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output), { agentId });
33838
+ runDetached(logger$2, "VMM log tail", () => this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output), { agentId });
32677
33839
  child.on("exit", (code, sig) => {
32678
33840
  setTimeout(() => {
32679
33841
  abortController.abort();
@@ -32687,7 +33849,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32687
33849
  signal: sig,
32688
33850
  failureType
32689
33851
  }), agentId, output, void 0, "firecracker-serial");
32690
- this.abandonSpawn(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
33852
+ this.abandonSpawnDetached(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
32691
33853
  }
32692
33854
  }, 500);
32693
33855
  });
@@ -33601,7 +34763,7 @@ function registerScalerCommands(program) {
33601
34763
  process.exit(1);
33602
34764
  }
33603
34765
  const scalerConfig = await loadScalerConfig(scalerConfigPath, scalerConfigDir);
33604
- const port = local.server?.port ?? Number(process.env.KICI_PORT ?? "4000");
34766
+ const port = local.server?.port ?? Number(process.env.KICI_PORT ?? ORCHESTRATOR_DEFAULT_PORT);
33605
34767
  const basePath = local.server?.basePath ?? process.env.KICI_BASE_PATH ?? "/";
33606
34768
  if ((opts.force ? false : await isOrchestratorHealthy(port, basePath)) && !opts.force) {
33607
34769
  const msg = "orchestrator is up and reaps orphans itself; pass --force to reap anyway";
@@ -33902,7 +35064,7 @@ Token vocabulary:
33902
35064
  * where no bundler substituted the constant — the same shape `server.ts` and
33903
35065
  * `standalone.ts` use for `ORCHESTRATOR_VERSION`.
33904
35066
  */
33905
- const CLI_VERSION = "0.10.0";
35067
+ const CLI_VERSION = "0.12.0";
33906
35068
  /**
33907
35069
  * Build the kici-admin Commander program with every command group registered.
33908
35070
  * Exported so the surface registry can walk the real command tree without