@kici-dev/compiler 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -8,4 +8,18 @@ import { Command } from 'commander';
8
8
  export declare function buildProgram(): Command;
9
9
  /** Build the program and parse argv — the bin-shim entry point. */
10
10
  export declare function runCli(argv?: string[]): void;
11
+ /**
12
+ * Decide whether this module is the process entry point, tolerating a
13
+ * symlinked `argv[1]`. A `node_modules/.bin/kici` entry is a symlink, and when
14
+ * it points at this compiled `cli.js` (the compiler package declares a `kici`
15
+ * bin), `process.argv[1]` is the symlink path while `import.meta.url` is the
16
+ * real file. A plain `resolve()` comparison sees two different paths and never
17
+ * matches, silently skipping `runCli()` — so `kici compile` (and every other
18
+ * subcommand) becomes a no-op when invoked through the bin symlink.
19
+ * Dereference both sides with `realpathSync` so a symlinked invocation is
20
+ * correctly recognised as the entry point. Falls back to a plain `resolve()`
21
+ * comparison when `argv[1]` doesn't resolve to a real file (e.g. a virtual
22
+ * entry point), preserving the previous behaviour for that edge case.
23
+ */
24
+ export declare function isMainEntryPoint(argv1: string | undefined, importMetaUrl: string): boolean;
11
25
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -3,10 +3,11 @@ import "./chunk-BTugEXQM.js";
3
3
  import { shouldSuppressBanner } from "./cli-banner.js";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { resolve } from "node:path";
6
+ import { realpathSync } from "node:fs";
6
7
  import { Argument, Command, Option } from "commander";
7
8
  import pc from "picocolors";
8
9
  //#region src/cli.ts
9
- const version = "0.1.20";
10
+ const version = "0.1.22";
10
11
  /**
11
12
  * Build the kici Commander program with every command registered. Exported so
12
13
  * the surface registry can walk the real command tree without parsing argv (no
@@ -43,7 +44,7 @@ function buildProgram() {
43
44
  await fixtureCommand(event, options);
44
45
  });
45
46
  const runCommand = program.command("run").description("Execute workflows locally or remotely");
46
- runCommand.command("local").argument("[event]", "Event type (e.g., push, pr:open, schedule) — optional with --pick").description("Execute workflows locally without orchestrator infrastructure").option("-p, --pick", "Interactively pick a workflow and trigger to simulate", false).option("--workflow <name>", "Run only the specified workflow").option("--job <name>", "Run only the specified job (and its dependencies)").option("--branch <name>", "Override detected git branch").option("--sha <hash>", "Override detected git SHA").option("--payload <path>", "Path to explicit event payload JSON file").option("--concurrency <n>", "Max parallel jobs (default: CPU cores)", parseInt).option("--keep-going", "Continue after job failure", false).option("--container", "Use Podman container isolation", false).option("--env <KEY=VALUE>", "Environment variable override (repeatable)", (val, prev) => [...prev, val], []).option("--quiet", "Suppress streaming output", false).option("--json", "Output structured JSON result", false).option("--junit <path>", "Output JUnit XML result").option("--files <path>", "Override changed file paths (repeatable, default: git diff)", (val, prev) => [...prev, val], []).option("--debug", "Verbose internals", false).option("--kici-dir <path>", "Path to .kici directory", ".kici").option("--in-place", "Run against the real working directory instead of an isolated tmp checkout", false).option("--keep", "Always retain the isolated tmp checkout (default: keep only on failure)", false).action(async (event, options) => {
47
+ runCommand.command("local").argument("[event]", "Event type (e.g., push, pr:open, schedule) — optional with --pick").description("Execute workflows locally without orchestrator infrastructure").option("-p, --pick", "Interactively pick a workflow and trigger to simulate", false).option("--workflow <name>", "Run only the specified workflow").option("--job <name>", "Run only the specified job (and its dependencies)").option("--branch <name>", "Override detected git branch").option("--sha <hash>", "Override detected git SHA").option("--payload <path>", "Path to explicit event payload JSON file").option("--concurrency <n>", "Max parallel jobs (default: CPU cores)", parseInt).option("--keep-going", "Continue after job failure", false).option("--container", "Use Podman container isolation", false).option("--env <KEY=VALUE>", "Environment variable override (repeatable)", (val, prev) => [...prev, val], []).option("--quiet", "Suppress streaming output", false).option("--json", "Output structured JSON result", false).option("--junit <path>", "Output JUnit XML result").option("--files <path>", "Override changed file paths (repeatable, default: git diff)", (val, prev) => [...prev, val], []).option("--debug", "Verbose internals", false).option("--kici-dir <path>", "Path to .kici directory", ".kici").option("--in-place", "Run against the real working directory instead of an isolated tmp checkout", false).option("--keep", "Always retain the isolated tmp checkout (default: keep only on failure)", false).option("--check", "Run in check mode: report drift, change nothing", false).option("--fail-on-drift", "In check mode, exit non-zero if any step reports drift", false).action(async (event, options) => {
47
48
  if (options.pick && options.workflow) {
48
49
  console.error("Error: --pick is mutually exclusive with --workflow.");
49
50
  process.exit(2);
@@ -53,8 +54,20 @@ function buildProgram() {
53
54
  process.exit(2);
54
55
  }
55
56
  const { runLocalCommand } = await import("./commands/index.js");
57
+ const { resolveCheckMode } = await import("./commands/check-mode.js");
58
+ let checkMode;
59
+ try {
60
+ checkMode = resolveCheckMode({
61
+ check: options.check,
62
+ failOnDrift: options.failOnDrift
63
+ });
64
+ } catch (err) {
65
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
66
+ process.exit(2);
67
+ }
56
68
  const success = await runLocalCommand({
57
69
  event,
70
+ checkMode,
58
71
  pick: options.pick,
59
72
  workflow: options.workflow,
60
73
  job: options.job,
@@ -76,11 +89,26 @@ function buildProgram() {
76
89
  });
77
90
  process.exit(success ? 0 : 1);
78
91
  });
79
- runCommand.command("remote").argument("[fixture]", "Fixture name or glob pattern (omit to list available)").description("Execute fixtures remotely via orchestrator").option("--workflow <name>", "Run a specific workflow directly (bypass triggers)").option("--all", "Run all available fixtures", false).option("--parallel", "Run matching fixtures concurrently", false).option("--no-wait", "Fire and forget (print runIds, don't stream)").option("--quiet", "Suppress output except final result", false).option("--json", "Output structured JSON result", false).option("--junit <path>", "Output JUnit XML result").option("--history", "Show recent run history", false).option("--routing-key <key>", "Override routing key for this run").option("--org <id>", "Target organization (overrides the active org)").option("--orchestrator <name>", "Target orchestrator cluster (overrides the per-org default)").option("--debug", "Verbose internals", false).option("--kici-dir <path>", "Path to .kici directory", ".kici").option("--context <ctx.key=value>", "Inject a namespaced context secret, uploaded encrypted to the orchestrator (repeatable)", (val, prev) => [...prev, val], []).option("--env <KEY=VALUE>", "Provide a per-run secret (repeatable); uploaded encrypted to the orchestrator", (val, prev) => [...prev, val], []).action(async (fixture, options) => {
92
+ runCommand.command("remote").argument("[fixture]", "Fixture name or glob pattern (omit to list available)").description("Execute fixtures remotely via orchestrator").option("--workflow <name>", "Run a specific workflow directly (bypass triggers)").option("--all", "Run all available fixtures", false).option("--parallel", "Run matching fixtures concurrently", false).option("--no-wait", "Fire and forget (print runIds, don't stream)").option("--quiet", "Suppress output except final result", false).option("--json", "Output structured JSON result", false).option("--junit <path>", "Output JUnit XML result").option("--history", "Show recent run history", false).option("--routing-key <key>", "Override routing key for this run").option("--org <id>", "Target organization (overrides the active org)").option("--orchestrator <name>", "Target orchestrator cluster (overrides the per-org default)").option("--debug", "Verbose internals", false).option("--kici-dir <path>", "Path to .kici directory", ".kici").option("--context <ctx.key=value>", "Inject a namespaced context secret, uploaded encrypted to the orchestrator (repeatable)", (val, prev) => [...prev, val], []).option("--env <KEY=VALUE>", "Provide a per-run secret (repeatable); uploaded encrypted to the orchestrator", (val, prev) => [...prev, val], []).option("--check", "Run in check mode: report drift, change nothing", false).option("--fail-on-drift", "In check mode, exit non-zero if any step reports drift", false).option("--target <selector>", "Narrow runsOnAll jobs to hosts matching this label selector (repeatable, AND-combined)", (val, prev) => [...prev, val], []).option("--target-allow-empty", "A --target that narrows a runsOnAll job to zero hosts skips it instead of failing", false).option("--approve-all, --yes", "Auto-approve every approval gate this run holds on (run-scoped; eligibility still enforced)", false).action(async (fixture, options) => {
80
93
  const { runRemoteCommand } = await import("./commands/index.js");
94
+ const { resolveCheckMode } = await import("./commands/check-mode.js");
95
+ let checkMode;
96
+ try {
97
+ checkMode = resolveCheckMode({
98
+ check: options.check,
99
+ failOnDrift: options.failOnDrift
100
+ });
101
+ } catch (err) {
102
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
103
+ process.exit(2);
104
+ }
81
105
  const success = await runRemoteCommand(fixture, {
82
106
  ...options,
83
- envFlags: options.env
107
+ checkMode,
108
+ envFlags: options.env,
109
+ targets: options.target,
110
+ targetAllowEmpty: options.targetAllowEmpty,
111
+ approveAll: options.approveAll
84
112
  });
85
113
  process.exit(success ? 0 : 1);
86
114
  });
@@ -102,7 +130,7 @@ function buildProgram() {
102
130
  const success = await hookInstallCommand({ git: options.git });
103
131
  process.exit(success ? 0 : 1);
104
132
  });
105
- program.command("login").description("Authenticate with KiCI via browser OAuth (default) or API key (--token)").option("--token <key>", "API key for direct authentication (legacy)").option("--device", "Force device authorization flow (for headless/SSH environments)").option("--platform-endpoint <url>", "Platform relay URL").option("--routing-key <key>", "Routing key for webhook source identification").addHelpText("after", `
133
+ program.command("login").description("Authenticate with KiCI via browser OAuth (default) or API key (--token)").option("--token <key>", "API key for direct authentication (legacy)").option("--device", "Force device authorization flow (for headless/SSH environments)").option("--platform-endpoint <url>", "Platform relay URL").option("--oidc-issuer <url>", "OIDC issuer URL (defaults to the hosted KiCI IdP unless a flag/env selects another)").option("--routing-key <key>", "Routing key for webhook source identification").addHelpText("after", `
106
134
  Environment variables:
107
135
  KICI_BROWSER_CMD Custom browser command (use {url} placeholder, or 'none' to suppress)
108
136
  KICI_CALLBACK_PORT Fixed port for OAuth PKCE callback server (default: random)
@@ -115,6 +143,7 @@ Environment variables:
115
143
  token: options.token,
116
144
  device: options.device,
117
145
  platformEndpoint: options.platformEndpoint,
146
+ oidcIssuer: options.oidcIssuer,
118
147
  routingKey: options.routingKey
119
148
  });
120
149
  process.exit(success ? 0 : 1);
@@ -263,8 +292,30 @@ Environment variables:
263
292
  function runCli(argv = process.argv) {
264
293
  buildProgram().parse(argv);
265
294
  }
266
- if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) runCli();
295
+ /**
296
+ * Decide whether this module is the process entry point, tolerating a
297
+ * symlinked `argv[1]`. A `node_modules/.bin/kici` entry is a symlink, and when
298
+ * it points at this compiled `cli.js` (the compiler package declares a `kici`
299
+ * bin), `process.argv[1]` is the symlink path while `import.meta.url` is the
300
+ * real file. A plain `resolve()` comparison sees two different paths and never
301
+ * matches, silently skipping `runCli()` — so `kici compile` (and every other
302
+ * subcommand) becomes a no-op when invoked through the bin symlink.
303
+ * Dereference both sides with `realpathSync` so a symlinked invocation is
304
+ * correctly recognised as the entry point. Falls back to a plain `resolve()`
305
+ * comparison when `argv[1]` doesn't resolve to a real file (e.g. a virtual
306
+ * entry point), preserving the previous behaviour for that edge case.
307
+ */
308
+ function isMainEntryPoint(argv1, importMetaUrl) {
309
+ if (!argv1) return false;
310
+ const modulePath = fileURLToPath(importMetaUrl);
311
+ try {
312
+ return realpathSync(argv1) === realpathSync(modulePath);
313
+ } catch {
314
+ return resolve(argv1) === resolve(modulePath);
315
+ }
316
+ }
317
+ if (isMainEntryPoint(process.argv[1], import.meta.url)) runCli();
267
318
  //#endregion
268
- export { buildProgram, runCli };
319
+ export { buildProgram, isMainEntryPoint, runCli };
269
320
 
270
321
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,19 @@
1
+ import { CheckMode } from '@kici-dev/engine';
2
+ /** The two run-mode flags shared by `kici run local` and `kici run remote`. */
3
+ export interface CheckModeFlags {
4
+ /** --check: report drift, change nothing. */
5
+ check?: boolean;
6
+ /** --fail-on-drift: in check mode, exit non-zero if any step reports drift. */
7
+ failOnDrift?: boolean;
8
+ }
9
+ /**
10
+ * Resolve the run {@link CheckMode} from the `--check` / `--fail-on-drift` flags.
11
+ *
12
+ * - no flags -> `apply` (the unchanged default: converge).
13
+ * - `--check` -> `check` (report-only, changes nothing).
14
+ * - `--check --fail-on-drift` -> `check-fail-on-drift` (fails the run on drift).
15
+ *
16
+ * `--fail-on-drift` without `--check` is an error — it only modifies check mode.
17
+ */
18
+ export declare function resolveCheckMode(flags: CheckModeFlags): CheckMode;
19
+ //# sourceMappingURL=check-mode.d.ts.map
@@ -0,0 +1,21 @@
1
+ import "../chunk-BTugEXQM.js";
2
+ import { CheckMode } from "@kici-dev/engine";
3
+ //#region src/commands/check-mode.ts
4
+ /**
5
+ * Resolve the run {@link CheckMode} from the `--check` / `--fail-on-drift` flags.
6
+ *
7
+ * - no flags -> `apply` (the unchanged default: converge).
8
+ * - `--check` -> `check` (report-only, changes nothing).
9
+ * - `--check --fail-on-drift` -> `check-fail-on-drift` (fails the run on drift).
10
+ *
11
+ * `--fail-on-drift` without `--check` is an error — it only modifies check mode.
12
+ */
13
+ function resolveCheckMode(flags) {
14
+ if (flags.failOnDrift && !flags.check) throw new Error("--fail-on-drift requires --check");
15
+ if (flags.check) return flags.failOnDrift ? CheckMode.enum["check-fail-on-drift"] : CheckMode.enum.check;
16
+ return CheckMode.enum.apply;
17
+ }
18
+ //#endregion
19
+ export { resolveCheckMode };
20
+
21
+ //# sourceMappingURL=check-mode.js.map
@@ -8,8 +8,8 @@ import "../validation/index.js";
8
8
  import { computeLockfileHash, detectGitRoot, generateLockFile, serializeLockFile } from "../lockfile/generator.js";
9
9
  import "../lockfile/index.js";
10
10
  import path from "node:path";
11
- import pc from "picocolors";
12
11
  import { existsSync } from "node:fs";
12
+ import pc from "picocolors";
13
13
  import fs from "node:fs/promises";
14
14
  import { logger, toErrorMessage } from "@kici-dev/core";
15
15
  import { PackageManager, detectPackageManagerSync } from "@kici-dev/core/package-manager";
@@ -19,8 +19,13 @@ export interface HeldRunContext {
19
19
  export declare function resolveHeldRunContext(): Promise<HeldRunContext | null>;
20
20
  /** List the held runs for a single run id. */
21
21
  export declare function listHeldRunsForRun(ctx: HeldRunContext, runId: string): Promise<HeldRunSummary[]>;
22
- /** POST an approve decision for a held run. Returns true on success. */
23
- export declare function postApprove(ctx: HeldRunContext, heldRunId: string): Promise<boolean>;
22
+ /**
23
+ * POST an approve decision for a held run. Returns true on success. When
24
+ * `autoApprove` is set, marks the approval as a `kici run --approve-all`
25
+ * breakglass so the orchestrator audits it as `held_run.auto_approve`
26
+ * (eligibility is still enforced server-side — never a bypass).
27
+ */
28
+ export declare function postApprove(ctx: HeldRunContext, heldRunId: string, autoApprove?: boolean): Promise<boolean>;
24
29
  /** POST a reject decision (with reason) for a held run. Returns true on success. */
25
30
  export declare function postReject(ctx: HeldRunContext, heldRunId: string, reason: string): Promise<boolean>;
26
31
  //# sourceMappingURL=held-run-client.d.ts.map
@@ -52,9 +52,15 @@ async function listHeldRunsForRun(ctx, runId) {
52
52
  if (!response.ok) throw new Error(await describeError(response));
53
53
  return (await response.json()).heldRuns ?? [];
54
54
  }
55
- /** POST an approve decision for a held run. Returns true on success. */
56
- async function postApprove(ctx, heldRunId) {
57
- const url = `${ctx.endpoint}/api/v1/orgs/${ctx.orgId}/held-runs/${heldRunId}/approve`;
55
+ /**
56
+ * POST an approve decision for a held run. Returns true on success. When
57
+ * `autoApprove` is set, marks the approval as a `kici run --approve-all`
58
+ * breakglass so the orchestrator audits it as `held_run.auto_approve`
59
+ * (eligibility is still enforced server-side — never a bypass).
60
+ */
61
+ async function postApprove(ctx, heldRunId, autoApprove = false) {
62
+ const query = autoApprove ? "?auto=1" : "";
63
+ const url = `${ctx.endpoint}/api/v1/orgs/${ctx.orgId}/held-runs/${heldRunId}/approve${query}`;
58
64
  const response = await fetch(url, {
59
65
  method: "POST",
60
66
  headers: authHeaders(ctx.token)
@@ -16,6 +16,11 @@ export interface HeldRunSummary {
16
16
  holdScope?: HeldRunScope;
17
17
  stepIndex?: number | null;
18
18
  status: string;
19
+ /** Computed drift payload for a `when: 'drift'` step hold; absent otherwise. */
20
+ payload?: {
21
+ summaryMarkdown: string;
22
+ drift?: unknown;
23
+ } | null;
19
24
  }
20
25
  /** Filters supplied on the command line. */
21
26
  export interface HeldRunFilter {
@@ -3,6 +3,8 @@ export interface LoginOptions {
3
3
  token?: string;
4
4
  /** Platform relay URL */
5
5
  platformEndpoint?: string;
6
+ /** OIDC issuer URL override */
7
+ oidcIssuer?: string;
6
8
  /** Routing key for webhook source identification */
7
9
  routingKey?: string;
8
10
  /** Force device authorization flow regardless of environment */
@@ -1,5 +1,5 @@
1
1
  import "../chunk-BTugEXQM.js";
2
- import { getConfigPath, mergeGlobalConfig } from "../remote/config.js";
2
+ import { getConfigPath, loadGlobalConfig, mergeGlobalConfig, saveGlobalConfig } from "../remote/config.js";
3
3
  import { deviceFlow, exchangeTokenForPat, pkceFlow } from "../remote/oauth.js";
4
4
  import "../remote/prod-defaults.js";
5
5
  import { isHeadless } from "../auth/headless-detect.js";
@@ -46,8 +46,9 @@ function checkPatExpiry(expiresAt) {
46
46
  * 4. Save PAT to global config
47
47
  */
48
48
  async function oauthLogin(options) {
49
+ const existing = await loadGlobalConfig();
49
50
  const platformUrl = options.platformEndpoint || process.env.KICI_PLATFORM_URL || "https://api.kici.dev";
50
- const issuer = process.env.KICI_OIDC_ISSUER || "https://auth.kici.dev/realms/kici-internal";
51
+ const issuer = options.oidcIssuer || process.env.KICI_OIDC_ISSUER || "https://auth.kici.dev/realms/kici-internal";
51
52
  const clientId = process.env.KICI_OIDC_CLIENT_ID || "kici-cli";
52
53
  console.log(pc.cyan("\n Step 1/4: Detecting environment..."));
53
54
  const browserCmdSet = !!process.env.KICI_BROWSER_CMD;
@@ -70,14 +71,21 @@ async function oauthLogin(options) {
70
71
  machineName
71
72
  });
72
73
  console.log(pc.cyan("\n Step 4/4: Saving credentials..."));
73
- const configUpdate = {
74
+ const endpointChanged = existing.platformEndpoint !== platformUrl;
75
+ const next = {
76
+ ...existing,
74
77
  pat: patResult.token,
75
78
  patId: patResult.id,
76
- patExpiresAt: patResult.expiresAt
79
+ patExpiresAt: patResult.expiresAt,
80
+ platformEndpoint: platformUrl,
81
+ oidcIssuer: issuer
77
82
  };
78
- if (options.platformEndpoint) configUpdate.platformEndpoint = options.platformEndpoint;
79
- if (options.routingKey) configUpdate.routingKey = options.routingKey;
80
- await mergeGlobalConfig(configUpdate);
83
+ if (options.routingKey) next.routingKey = options.routingKey;
84
+ if (endpointChanged) {
85
+ delete next.activeOrgId;
86
+ delete next.defaultClusters;
87
+ }
88
+ await saveGlobalConfig(next);
81
89
  const configPath = getConfigPath();
82
90
  const expiryDate = new Date(patResult.expiresAt).toLocaleDateString();
83
91
  console.log(pc.green(`\n Authenticated successfully!`));
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Hold-visibility + inline approve/reject for `kici run remote`.
3
+ *
4
+ * While a run is being watched, the orchestrator may pause it on an approval
5
+ * gate (a `when: 'always'` step/job/workflow gate, or a `when: 'drift'` step
6
+ * gate that carries a computed-drift payload). The poll loop calls
7
+ * `handleNewHolds` each tick with the run's pending holds. For each hold not
8
+ * seen before it prints the payload (if any) and, in a TTY, prompts the
9
+ * operator to approve/reject inline — reusing the same Platform held-run HTTP
10
+ * path as `kici approve` / `kici reject`. In a non-TTY it prints guidance and
11
+ * keeps polling (the run stays held until resolved out-of-band).
12
+ */
13
+ import type { HeldRunSummary } from './held-run-resolve.js';
14
+ import { type HeldRunContext } from './held-run-client.js';
15
+ /** A yes/no prompt; injected so the poll branch is unit-testable. */
16
+ export type ConfirmPrompt = (message: string) => Promise<boolean>;
17
+ /** What to do with a newly-observed hold. Pure — no IO. */
18
+ export type HoldAction = {
19
+ kind: 'prompt';
20
+ hold: HeldRunSummary;
21
+ } | {
22
+ kind: 'notify';
23
+ hold: HeldRunSummary;
24
+ };
25
+ /**
26
+ * Classify the holds observed this tick against the set already seen. Returns
27
+ * one action per genuinely-new pending hold (TTY ⇒ prompt, non-TTY ⇒ notify),
28
+ * and the updated seen-set. Pure: the caller performs the IO.
29
+ */
30
+ export declare function classifyNewHolds(holds: readonly HeldRunSummary[], seen: Set<string>, isTty: boolean): {
31
+ actions: HoldAction[];
32
+ seen: Set<string>;
33
+ };
34
+ /**
35
+ * Process the new holds observed this tick: print each, and in a TTY prompt the
36
+ * operator to approve/reject inline (posting the decision via the shared
37
+ * held-run HTTP path). Returns the updated seen-set. A resolved context is
38
+ * resolved lazily on first need and cached for the run.
39
+ */
40
+ export declare function handleNewHolds(args: {
41
+ holds: readonly HeldRunSummary[];
42
+ seen: Set<string>;
43
+ isTty: boolean;
44
+ confirm: ConfirmPrompt;
45
+ /**
46
+ * `--approve-all` breakglass: auto-approve each hold (run-scoped) instead of
47
+ * prompting, marking the approval as `auto_approve` so the orchestrator
48
+ * audits it distinctly. Eligibility is still enforced server-side.
49
+ */
50
+ approveAll?: boolean;
51
+ /** Override for tests; defaults to the shared Platform held-run context. */
52
+ resolveContext?: () => Promise<HeldRunContext | null>;
53
+ /** Override for tests. */
54
+ approve?: (ctx: HeldRunContext, heldRunId: string, autoApprove?: boolean) => Promise<boolean>;
55
+ reject?: (ctx: HeldRunContext, heldRunId: string, reason: string) => Promise<boolean>;
56
+ }): Promise<Set<string>>;
57
+ //# sourceMappingURL=run-hold-watch.d.ts.map
@@ -0,0 +1,87 @@
1
+ import "../chunk-BTugEXQM.js";
2
+ import { postApprove, postReject, resolveHeldRunContext } from "./held-run-client.js";
3
+ import pc from "picocolors";
4
+ import { logger } from "@kici-dev/core";
5
+ //#region src/commands/run-hold-watch.ts
6
+ /**
7
+ * Hold-visibility + inline approve/reject for `kici run remote`.
8
+ *
9
+ * While a run is being watched, the orchestrator may pause it on an approval
10
+ * gate (a `when: 'always'` step/job/workflow gate, or a `when: 'drift'` step
11
+ * gate that carries a computed-drift payload). The poll loop calls
12
+ * `handleNewHolds` each tick with the run's pending holds. For each hold not
13
+ * seen before it prints the payload (if any) and, in a TTY, prompts the
14
+ * operator to approve/reject inline — reusing the same Platform held-run HTTP
15
+ * path as `kici approve` / `kici reject`. In a non-TTY it prints guidance and
16
+ * keeps polling (the run stays held until resolved out-of-band).
17
+ */
18
+ /**
19
+ * Classify the holds observed this tick against the set already seen. Returns
20
+ * one action per genuinely-new pending hold (TTY ⇒ prompt, non-TTY ⇒ notify),
21
+ * and the updated seen-set. Pure: the caller performs the IO.
22
+ */
23
+ function classifyNewHolds(holds, seen, isTty) {
24
+ const next = new Set(seen);
25
+ const actions = [];
26
+ for (const hold of holds) {
27
+ if (hold.status !== "pending") continue;
28
+ if (next.has(hold.id)) continue;
29
+ next.add(hold.id);
30
+ actions.push({
31
+ kind: isTty ? "prompt" : "notify",
32
+ hold
33
+ });
34
+ }
35
+ return {
36
+ actions,
37
+ seen: next
38
+ };
39
+ }
40
+ /** Print a hold's identity + its drift payload (when present) to stdout. */
41
+ function printHold(hold) {
42
+ const scope = hold.holdScope ?? "job";
43
+ const where = scope === "step" && hold.stepIndex != null ? `step #${hold.stepIndex}${hold.jobId ? ` of job '${hold.jobId}'` : ""}` : hold.jobId ? `job '${hold.jobId}'` : scope;
44
+ logger.info(pc.yellow(`\n[kici] Run held for approval (${where}).`));
45
+ if (hold.payload?.summaryMarkdown) {
46
+ logger.info(pc.dim("Computed drift — review before approving:"));
47
+ for (const line of hold.payload.summaryMarkdown.split("\n")) logger.info(pc.dim(` ${line}`));
48
+ }
49
+ }
50
+ /**
51
+ * Process the new holds observed this tick: print each, and in a TTY prompt the
52
+ * operator to approve/reject inline (posting the decision via the shared
53
+ * held-run HTTP path). Returns the updated seen-set. A resolved context is
54
+ * resolved lazily on first need and cached for the run.
55
+ */
56
+ async function handleNewHolds(args) {
57
+ const { actions, seen } = classifyNewHolds(args.holds, args.seen, args.isTty);
58
+ if (actions.length === 0) return seen;
59
+ const resolveCtx = args.resolveContext ?? resolveHeldRunContext;
60
+ const doApprove = args.approve ?? postApprove;
61
+ const doReject = args.reject ?? postReject;
62
+ let ctx = null;
63
+ if (args.approveAll) logger.info(pc.yellow("[kici] --approve-all: auto-approving every gate for this run (eligibility enforced)."));
64
+ for (const action of actions) {
65
+ printHold(action.hold);
66
+ ctx = ctx ?? await resolveCtx();
67
+ if (!ctx) {
68
+ logger.info(pc.dim(`Run held; approve via \`kici approve ${action.hold.runId}\`.`));
69
+ continue;
70
+ }
71
+ if (args.approveAll) {
72
+ if (await doApprove(ctx, action.hold.id, true)) logger.info(pc.green("[kici] Auto-approved."));
73
+ continue;
74
+ }
75
+ if (action.kind === "notify") {
76
+ logger.info(pc.dim(`Run held; approve via the dashboard or \`kici approve ${action.hold.runId}\`.`));
77
+ continue;
78
+ }
79
+ const approved = await args.confirm("Approve this gate?");
80
+ if (approved ? await doApprove(ctx, action.hold.id) : await doReject(ctx, action.hold.id, "rejected via kici run")) logger.info(approved ? pc.green("[kici] Approved.") : pc.red("[kici] Rejected."));
81
+ }
82
+ return seen;
83
+ }
84
+ //#endregion
85
+ export { classifyNewHolds, handleNewHolds };
86
+
87
+ //# sourceMappingURL=run-hold-watch.js.map
@@ -1,5 +1,13 @@
1
+ import type { HostTargetSelector } from '@kici-dev/engine';
1
2
  import type { RunLocalOptions } from '../local-executor/types.js';
2
3
  import type { RemoteRunOptions } from './test.js';
4
+ /**
5
+ * Compile `--target` selector strings into a {@link HostTargetSelector}. Each
6
+ * string becomes one AND value (its own include set); repeated values
7
+ * AND-combine. Returns undefined when no `--target` is given. Throws when
8
+ * `--target-allow-empty` is set without at least one `--target`.
9
+ */
10
+ export declare function buildTargetSelector(targets: string[] | undefined, allowEmpty: boolean): HostTargetSelector | undefined;
3
11
  /**
4
12
  * Run a workflow locally using the local executor.
5
13
  * Thin wrapper that delegates to executeLocal from local-executor.
@@ -10,10 +10,14 @@ import { formatJsonResult } from "../remote/output/json.js";
10
10
  import { formatJunitResult } from "../remote/output/junit.js";
11
11
  import { formatErrorHighlight, formatMultiFixtureSummary, formatSummary } from "../remote/output/summary.js";
12
12
  import { compileFixtures, filterFixtures } from "../fixtures/compiler.js";
13
+ import { listHeldRunsForRun, resolveHeldRunContext } from "./held-run-client.js";
14
+ import { handleNewHolds } from "./run-hold-watch.js";
13
15
  import path from "node:path";
14
16
  import pc from "picocolors";
15
17
  import { readFile, writeFile } from "node:fs/promises";
16
18
  import { formatBytes, logger, toErrorMessage } from "@kici-dev/core";
19
+ import { normalizeRunsOnToMatchers } from "@kici-dev/engine/labels/compile";
20
+ import { confirm } from "@inquirer/prompts";
17
21
  //#region src/commands/run.ts
18
22
  /** Terminal run statuses returned by the Platform run-status snapshot. */
19
23
  const TERMINAL_STATUSES = new Set([
@@ -22,6 +26,22 @@ const TERMINAL_STATUSES = new Set([
22
26
  "cancelled",
23
27
  "error"
24
28
  ]);
29
+ /**
30
+ * Compile `--target` selector strings into a {@link HostTargetSelector}. Each
31
+ * string becomes one AND value (its own include set); repeated values
32
+ * AND-combine. Returns undefined when no `--target` is given. Throws when
33
+ * `--target-allow-empty` is set without at least one `--target`.
34
+ */
35
+ function buildTargetSelector(targets, allowEmpty) {
36
+ if (!targets || targets.length === 0) {
37
+ if (allowEmpty) throw new Error("--target-allow-empty requires at least one --target selector");
38
+ return;
39
+ }
40
+ return {
41
+ values: targets.map((t) => normalizeRunsOnToMatchers(t, "kici run --target")),
42
+ allowEmpty
43
+ };
44
+ }
25
45
  /** Interval between status/log polls while a run is active. */
26
46
  const POLL_INTERVAL_MS = 750;
27
47
  /**
@@ -58,6 +78,7 @@ async function runRemoteCommand(fixture, options) {
58
78
  if (!options.quiet) logger.info(pc.gray("Debug mode enabled"));
59
79
  }
60
80
  try {
81
+ buildTargetSelector(options.targets, options.targetAllowEmpty ?? false);
61
82
  if (options.workflow && !fixture && !options.all) return await runDirectWorkflow(options.workflow, options);
62
83
  const kiciDir = resolveKiciDir(options.kiciDir);
63
84
  if (options.history) {
@@ -294,7 +315,12 @@ async function runSingleFixture(fixture, ctx, options, config, history) {
294
315
  },
295
316
  workflowName: opts.workflowName,
296
317
  inlineLockFile: overlay.inlineLockFile,
297
- fullRepo: true
318
+ fullRepo: true,
319
+ ...options.checkMode && { checkMode: options.checkMode },
320
+ ...(() => {
321
+ const target = buildTargetSelector(options.targets, options.targetAllowEmpty ?? false);
322
+ return target ? { target } : {};
323
+ })()
298
324
  });
299
325
  if (triggerResult.status === "rejected") {
300
326
  if (!options.quiet) logger.info(pc.red(`Rejected: ${triggerResult.reason ?? "unknown reason"}`));
@@ -371,6 +397,8 @@ async function pollRunToCompletion(ctx, runId, fixtureId, options) {
371
397
  process.on("SIGINT", cancelHandler);
372
398
  try {
373
399
  let lastStatus = null;
400
+ const seenHolds = /* @__PURE__ */ new Set();
401
+ let heldCtx;
374
402
  while (!cancelled) {
375
403
  const logs = await ctx.client.runLogs(ctx.orgId, runId, cursor, ctx.target);
376
404
  for (const line of logs.lines) {
@@ -380,7 +408,9 @@ async function pollRunToCompletion(ctx, runId, fixtureId, options) {
380
408
  }
381
409
  cursor = logs.nextCursor;
382
410
  lastStatus = await ctx.client.runStatus(ctx.orgId, runId, ctx.target);
383
- if ((lastStatus.done || TERMINAL_STATUSES.has(lastStatus.status)) && logs.done) return finishRun(fixtureId, runId, lastStatus, startTime, tailLines, options);
411
+ const terminal = lastStatus.done || TERMINAL_STATUSES.has(lastStatus.status);
412
+ if (terminal && logs.done) return finishRun(fixtureId, runId, lastStatus, startTime, tailLines, options);
413
+ if (!terminal && !options.quiet) await watchRunHolds(runId, seenHolds, () => heldCtx, (c) => heldCtx = c, options);
384
414
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
385
415
  }
386
416
  return {
@@ -394,6 +424,34 @@ async function pollRunToCompletion(ctx, runId, fixtureId, options) {
394
424
  process.removeListener("SIGINT", cancelHandler);
395
425
  }
396
426
  }
427
+ /**
428
+ * Fetch this run's pending holds and surface them to the operator. The held-run
429
+ * context is resolved lazily (cached across ticks via the getter/setter). A
430
+ * resolution / fetch failure is swallowed — hold-visibility is best-effort and
431
+ * must never abort the run watch.
432
+ */
433
+ async function watchRunHolds(runId, seenHolds, getCtx, setCtx, options) {
434
+ try {
435
+ let ctx = getCtx();
436
+ if (ctx === void 0) {
437
+ ctx = await resolveHeldRunContext();
438
+ setCtx(ctx);
439
+ }
440
+ if (!ctx) return;
441
+ const holds = await listHeldRunsForRun(ctx, runId);
442
+ if (holds.length === 0) return;
443
+ await handleNewHolds({
444
+ holds,
445
+ seen: seenHolds,
446
+ isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
447
+ approveAll: Boolean(options.approveAll),
448
+ confirm: (message) => confirm({
449
+ message,
450
+ default: false
451
+ })
452
+ });
453
+ } catch {}
454
+ }
397
455
  /** Build the final result + render the summary table for a completed run. */
398
456
  function finishRun(fixtureId, runId, status, startTime, tailLines, options) {
399
457
  const durationMs = Date.now() - startTime;
@@ -481,7 +539,12 @@ async function runDirectWorkflow(workflowName, options) {
481
539
  },
482
540
  workflowName,
483
541
  inlineLockFile: overlay.inlineLockFile,
484
- fullRepo: true
542
+ fullRepo: true,
543
+ ...options.checkMode && { checkMode: options.checkMode },
544
+ ...(() => {
545
+ const target = buildTargetSelector(options.targets, options.targetAllowEmpty ?? false);
546
+ return target ? { target } : {};
547
+ })()
485
548
  });
486
549
  if (!options.quiet) logger.info(pc.green(`Run started: ${triggerResult.runId}`));
487
550
  if (options.wait === false) return triggerResult.status === "accepted";
@@ -509,6 +572,6 @@ function displayRemoteResults(results) {
509
572
  logger.info("");
510
573
  }
511
574
  //#endregion
512
- export { runLocalCommand, runRemoteCommand };
575
+ export { buildTargetSelector, runLocalCommand, runRemoteCommand };
513
576
 
514
577
  //# sourceMappingURL=run.js.map
@@ -1,4 +1,5 @@
1
1
  import { type PayloadOptions } from '../test-runner/payload-builder.js';
2
+ import { type CheckMode } from '@kici-dev/engine';
2
3
  /** Options for the kici test command (dry-run trigger preview) */
3
4
  export interface TestOptions extends PayloadOptions {
4
5
  /** Filter to specific workflow */
@@ -38,6 +39,22 @@ export interface RemoteRunOptions extends TestOptions {
38
39
  org?: string;
39
40
  /** Target orchestrator cluster name (overrides the per-org default). */
40
41
  orchestrator?: string;
42
+ /**
43
+ * Run mode resolved from --check / --fail-on-drift, threaded onto the dispatch
44
+ * payload so the orchestrator runs the agent step loop in the requested mode.
45
+ * Defaults to `apply`.
46
+ */
47
+ checkMode?: CheckMode;
48
+ /** `--target <selector>` values (repeatable), AND-combined into host narrowing. */
49
+ targets?: string[];
50
+ /** `--target-allow-empty`: a target that zeroes a runsOnAll job skips it instead of failing. */
51
+ targetAllowEmpty?: boolean;
52
+ /**
53
+ * `--approve-all` (alias `--yes`): auto-approve every approval gate this run
54
+ * holds on (run-scoped only — the run id this invocation dispatched). The
55
+ * operator must still be clause-eligible per hold; an ineligible hold blocks.
56
+ */
57
+ approveAll?: boolean;
41
58
  }
42
59
  /** Result of a single remote fixture run */
43
60
  export interface RemoteRunResult {