sverka 0.2.23 → 0.2.25

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/index.mjs CHANGED
@@ -3,18 +3,21 @@ import * as nodeModule from "node:module";
3
3
  import { register } from "node:module";
4
4
  import process$1 from "node:process";
5
5
  import yargs from "yargs";
6
- import { constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
7
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { mkdir, open, readFile, realpath, writeFile } from "node:fs/promises";
10
10
  import { Entry, Pipeline, Project, ShellStep, collectConstructWarnings, manual, synthesize, validateGraph } from "@sverka/workflow";
11
11
  import { bindRunPlan, createPlanner } from "@sverka/sdk";
12
12
  import { DEFAULT_POLICY, createBuiltinResolver, evaluatePolicy, loadBaseline, normalizeSarif, serializeSarif, synthesizeCheckSteps } from "@sverka/verification";
13
- import { createDockerDriver, createEngine, createHostDriver } from "@sverka/runtime";
14
- import { spawnSync } from "node:child_process";
13
+ import { AGENT_ENV, collectAgentWrites, createDockerDriver, createEngine, createFileCacheStore, createHostDriver, noAgentDriverError, resolveAgentDrivers, resolveAgentEngine } from "@sverka/runtime";
14
+ import { execFileSync, spawnSync } from "node:child_process";
15
15
  import { watch } from "chokidar";
16
+ import { homedir } from "node:os";
17
+ import { createRemoteCacheStore, createRemoteCircuit, createRemoteSnapshotStore, createTieredCacheStore } from "@sverka/storage";
16
18
  import { compileGithub, compileGitlab } from "@sverka/compiler";
17
19
  import { z } from "zod";
20
+ import { startHubServer } from "@sverka/hub";
18
21
  //#region src/types.ts
19
22
  /** Exit codes used by the CLI. */
20
23
  const ExitCode = {
@@ -190,17 +193,31 @@ async function registerConfigHooks() {
190
193
  /**
191
194
  * Find a sverka.config.ts or sverka.config.js file in the given root.
192
195
  * Returns the absolute path or null if not found.
196
+ * Spec 37/54: when no config exists, fall back to `.sverka/*.md` files
197
+ * (markdown authoring) — sorted, first file wins.
193
198
  */
194
199
  async function findConfig(root) {
195
200
  for (const candidate of [
196
201
  "sverka.config.ts",
202
+ "sverka.config.mts",
197
203
  "sverka.config.js",
198
204
  "sverka.config.mjs"
199
205
  ]) {
200
206
  const path = join(root, candidate);
201
207
  if (existsSync(path)) return path;
202
208
  }
203
- return null;
209
+ return findMarkdownConfig(root);
210
+ }
211
+ /** First `.sverka/*.md` file under root (sorted), or null. */
212
+ function findMarkdownConfig(root) {
213
+ const dir = join(root, ".sverka");
214
+ let names;
215
+ try {
216
+ names = readdirSync(dir).filter((n) => n.endsWith(".md")).sort();
217
+ } catch {
218
+ return null;
219
+ }
220
+ return names.length > 0 ? join(dir, names[0]) : null;
204
221
  }
205
222
  /**
206
223
  * Resolve the effective config path from global flags.
@@ -236,6 +253,12 @@ function assertConstructLike(value) {
236
253
  async function loadConfig(configPath) {
237
254
  const absPath = isAbsolute(configPath) ? configPath : resolve(process.cwd(), configPath);
238
255
  if (!existsSync(absPath)) throw new CliError(`config not found: ${absPath}`, "MISSING_ARG", ExitCode.UsageError);
256
+ if (absPath.endsWith(".md")) try {
257
+ const { loadMarkdownFile } = await import("@sverka/sdk");
258
+ return await loadMarkdownFile(absPath);
259
+ } catch (e) {
260
+ throw new CliError(`failed to load config: ${e instanceof Error ? e.message : String(e)}`, "SDK_ERROR", ExitCode.RuntimeError, e);
261
+ }
239
262
  let mod;
240
263
  try {
241
264
  await registerConfigHooks();
@@ -947,6 +970,119 @@ function watchLoop(opts) {
947
970
  };
948
971
  }
949
972
  //#endregion
973
+ //#region src/internal/hub.ts
974
+ function configHome() {
975
+ const xdg = process.env["XDG_CONFIG_HOME"];
976
+ return typeof xdg === "string" && xdg !== "" ? xdg : join(homedir(), ".config");
977
+ }
978
+ function credentialsPath() {
979
+ return join(configHome(), "sverka", "credentials");
980
+ }
981
+ function readCredentials() {
982
+ try {
983
+ const text = readFileSync(credentialsPath(), "utf8");
984
+ const parsed = JSON.parse(text);
985
+ if (typeof parsed === "object" && parsed !== null) return parsed;
986
+ } catch {}
987
+ return {};
988
+ }
989
+ /**
990
+ * Store a hub token at `~/.config/sverka/credentials` with mode 0600.
991
+ * The file is a JSON map keyed by hub URL so multiple hubs coexist.
992
+ */
993
+ function storeCredentials(url, token) {
994
+ const path = credentialsPath();
995
+ const hubs = {
996
+ ...readCredentials().hubs ?? {},
997
+ [url]: token
998
+ };
999
+ mkdirSync(dirname(path), {
1000
+ recursive: true,
1001
+ mode: 448
1002
+ });
1003
+ writeFileSync(path, `${JSON.stringify({ hubs }, null, 2)}\n`, { mode: 384 });
1004
+ chmodSync(path, 384);
1005
+ return path;
1006
+ }
1007
+ function storedTokenFor(url) {
1008
+ return readCredentials().hubs?.[url];
1009
+ }
1010
+ /** Read `.sverka/hub.json` when present. Malformed JSON is ignored. */
1011
+ function readHubFileConfig(root) {
1012
+ const path = join(root, ".sverka", "hub.json");
1013
+ if (!existsSync(path)) return {};
1014
+ try {
1015
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1016
+ if (typeof parsed !== "object" || parsed === null) return {};
1017
+ const outer = parsed;
1018
+ const inner = typeof outer.remote === "object" && outer.remote !== null ? outer.remote : outer;
1019
+ return {
1020
+ url: typeof inner.url === "string" ? inner.url : void 0,
1021
+ project: typeof inner.project === "string" ? inner.project : void 0,
1022
+ enabled: inner.enabled === true,
1023
+ cache: typeof inner.cache === "boolean" ? inner.cache : void 0,
1024
+ upload: typeof inner.upload === "boolean" ? inner.upload : void 0
1025
+ };
1026
+ } catch {
1027
+ return {};
1028
+ }
1029
+ }
1030
+ /** Derive "owner/repo" from `git remote.origin.url`; falls back to the
1031
+ * directory basename when there is no usable remote. */
1032
+ function projectSlug(root) {
1033
+ try {
1034
+ const url = execFileSync("git", [
1035
+ "config",
1036
+ "--get",
1037
+ "remote.origin.url"
1038
+ ], {
1039
+ cwd: root,
1040
+ encoding: "utf8",
1041
+ stdio: [
1042
+ "ignore",
1043
+ "pipe",
1044
+ "ignore"
1045
+ ]
1046
+ }).trim();
1047
+ const slug = (/^[^@]+@[^:]+:(.+)$/.exec(url)?.[1] ?? new URL(url).pathname).replace(/\.git$/, "").replace(/^\/+/, "");
1048
+ if (slug !== "") return slug;
1049
+ } catch {}
1050
+ return basename(resolve(root));
1051
+ }
1052
+ function envString(name) {
1053
+ const value = process.env[name];
1054
+ return typeof value === "string" && value !== "" ? value : void 0;
1055
+ }
1056
+ /** Canonical credential key — `sverka login` stores tokens under the URL
1057
+ * with trailing slashes stripped; lookups must normalize identically. */
1058
+ function normalizeHubUrl(url) {
1059
+ let end = url.length;
1060
+ while (end > 0 && url.charCodeAt(end - 1) === 47) end--;
1061
+ return url.slice(0, end);
1062
+ }
1063
+ /**
1064
+ * Resolve the effective hub connection for a root. Returns null when no
1065
+ * hub is configured at all (no url, no token anywhere) — the caller then
1066
+ * treats `--remote` as a usage error and a bare `run` stays local.
1067
+ */
1068
+ function resolveHub(root) {
1069
+ const file = readHubFileConfig(root);
1070
+ const rawUrl = envString("SVERKA_HUB_URL") ?? file.url;
1071
+ const url = rawUrl !== void 0 ? normalizeHubUrl(rawUrl) : void 0;
1072
+ const token = envString("SVERKA_HUB_TOKEN") ?? (url !== void 0 ? storedTokenFor(url) : void 0);
1073
+ if (url === void 0 || token === void 0) return null;
1074
+ return {
1075
+ config: {
1076
+ url,
1077
+ token,
1078
+ project: file.project ?? projectSlug(root)
1079
+ },
1080
+ cache: file.cache ?? true,
1081
+ upload: file.upload ?? true,
1082
+ enabled: file.enabled === true
1083
+ };
1084
+ }
1085
+ //#endregion
950
1086
  //#region src/commands/run.ts
951
1087
  /** Pure-argument guards — an invalid flag fails fast instead of surfacing
952
1088
  * as a recurring run failure inside the watch supervisor. */
@@ -968,6 +1104,7 @@ async function runCommand(args, global, output, start) {
968
1104
  if (detected !== void 0) output.errorLine(`no sverka.config — running ${detected.length} detected checks (${detected.join(", ")})`);
969
1105
  for (const warning of warnings) output.errorLine(`warning: ${warning}`);
970
1106
  const entryId = resolveEntryId(graph, args.entryId);
1107
+ const remote = resolveRemote(args, global, output);
971
1108
  const plan = bindRunPlan({
972
1109
  graph,
973
1110
  entryId
@@ -975,10 +1112,13 @@ async function runCommand(args, global, output, start) {
975
1112
  const artifactDir = join(global.root, ".sverka", "artifacts");
976
1113
  const { events, runStatus, renderer } = await consumeEvents(createEngine({
977
1114
  drivers: buildDrivers(executor),
1115
+ agentDrivers: resolveAgentDrivers(process$1.env),
978
1116
  maxConcurrent: args.jobs ?? 4
979
1117
  }), plan, {
980
1118
  workspace: global.root,
981
- artifactDir
1119
+ artifactDir,
1120
+ cache: remote?.stores.cache,
1121
+ snapshotStore: remote?.stores.snapshotStore
982
1122
  }, global, output, graph, args);
983
1123
  const durationMs = Date.now() - start;
984
1124
  const evaluation = await evaluateRun(fmt.evaluate, artifactDir, global, output, events, renderer, args, start);
@@ -994,6 +1134,7 @@ async function runCommand(args, global, output, start) {
994
1134
  ...detected !== void 0 ? { detected } : {},
995
1135
  ...args.report !== void 0 ? { report: args.report } : {}
996
1136
  }, events, output);
1137
+ if (remote !== void 0 && remote.upload && report !== void 0) await uploadReportSafe(remote, report, entryId, findRunId(events), output);
997
1138
  await finalizeRenderer(fmt, renderer, args, global, output);
998
1139
  if (evaluation.collectionFailed) return evaluation.exitCode;
999
1140
  writeRunOutput({
@@ -1011,6 +1152,52 @@ async function runCommand(args, global, output, start) {
1011
1152
  if (evaluation.exitCode !== 0) return evaluation.exitCode;
1012
1153
  return exitCodeForStatus(runStatus);
1013
1154
  }
1155
+ /** Build the remote wiring for a run. `--remote` with no hub configured
1156
+ * is a usage error naming `sverka login`; a hub.json-enabled run whose
1157
+ * credentials are missing warns and runs local-only (degrade, don't
1158
+ * fail). */
1159
+ function resolveRemote(args, global, output) {
1160
+ const hub = resolveHub(global.root);
1161
+ const requested = args.remote === true;
1162
+ if (hub === null) {
1163
+ if (requested) throw new CliError("--remote needs a hub: set SVERKA_HUB_URL + SVERKA_HUB_TOKEN, run `sverka login --hub <url> --token <t>`, or add .sverka/hub.json", "MISSING_ARG", ExitCode.UsageError);
1164
+ if (readHubFileConfig(global.root).enabled === true) output.errorLine("warning: .sverka/hub.json enables remote runs but no hub credentials resolved — running local-only (see `sverka login`)");
1165
+ return;
1166
+ }
1167
+ if (!requested && !hub.enabled) return void 0;
1168
+ const circuit = createRemoteCircuit();
1169
+ const remoteConfig = {
1170
+ ...hub.config,
1171
+ circuit
1172
+ };
1173
+ const cache = hub.cache ? createTieredCacheStore({
1174
+ local: createFileCacheStore({ cacheDir: join(global.root, ".sverka", "cache") }),
1175
+ remote: createRemoteCacheStore(remoteConfig),
1176
+ onWarn: (msg) => output.errorLine(`warning: ${msg}`)
1177
+ }) : void 0;
1178
+ return {
1179
+ config: remoteConfig,
1180
+ upload: hub.upload,
1181
+ stores: {
1182
+ ...cache !== void 0 ? { cache } : {},
1183
+ snapshotStore: createRemoteSnapshotStore(remoteConfig)
1184
+ }
1185
+ };
1186
+ }
1187
+ /** Upload the run report — warns `remote.upload-failed` and swallows the
1188
+ * error per Spec 55 ("remote failures degrade to local + warn"). */
1189
+ async function uploadReportSafe(remote, report, entryId, runId, output) {
1190
+ try {
1191
+ const { uploadRunReport } = await import("@sverka/storage");
1192
+ const result = await uploadRunReport(remote.config, report.payload, report.findingsList, {
1193
+ entry: entryId,
1194
+ ...runId !== void 0 ? { runId } : {}
1195
+ });
1196
+ output.debug(`remote: uploaded run report as ${result.runId}`);
1197
+ } catch (e) {
1198
+ output.errorLine(`warning: remote.upload-failed: ${e instanceof Error ? e.message : String(e)}`);
1199
+ }
1200
+ }
1014
1201
  /** Post-run: announce the HTML report path (sarif/web print their own),
1015
1202
  * then keep interactive renderers mounted until the user quits. */
1016
1203
  async function finalizeRenderer(fmt, renderer, args, global, output) {
@@ -1090,7 +1277,9 @@ async function writeReportSafe(ctx, events, output) {
1090
1277
  return {
1091
1278
  html: artifacts.htmlPath,
1092
1279
  json: join(artifacts.dir, "report.json"),
1093
- findings: artifacts.findingsCount
1280
+ findings: artifacts.findingsCount,
1281
+ payload: artifacts.payload,
1282
+ findingsList: artifacts.findings
1094
1283
  };
1095
1284
  } catch (err) {
1096
1285
  if (err instanceof CliError) throw err;
@@ -1198,7 +1387,9 @@ async function consumeEvents(engine, plan, ctx, global, output, graph, args) {
1198
1387
  for await (const event of engine.run({
1199
1388
  plan,
1200
1389
  workspace: ctx.workspace,
1201
- artifactDir: ctx.artifactDir
1390
+ artifactDir: ctx.artifactDir,
1391
+ ...ctx.cache !== void 0 ? { cache: ctx.cache } : {},
1392
+ ...ctx.snapshotStore !== void 0 ? { snapshotStore: ctx.snapshotStore } : {}
1202
1393
  })) {
1203
1394
  events.push(event);
1204
1395
  renderer?.onEvent(event);
@@ -1464,11 +1655,14 @@ async function writeRunArtifacts(opts) {
1464
1655
  const warnings = [];
1465
1656
  const findings = await collectRunFindings(opts, warnings);
1466
1657
  const htmlPath = await writeReportHtml(opts.reportPath !== void 0 ? resolveUnderRoot(opts.root, opts.reportPath) : join(dir, "report.html"), findings, warnings);
1467
- writeFileSync(join(dir, "report.json"), JSON.stringify(runReportPayload(opts, findings.length, warnings), null, 2), "utf-8");
1658
+ const payload = runReportPayload(opts, findings.length, warnings);
1659
+ writeFileSync(join(dir, "report.json"), JSON.stringify(payload, null, 2), "utf-8");
1468
1660
  return {
1469
1661
  dir,
1470
1662
  htmlPath,
1471
- findingsCount: findings.length
1663
+ findingsCount: findings.length,
1664
+ payload,
1665
+ findings
1472
1666
  };
1473
1667
  }
1474
1668
  function exitCodeForStatus(runStatus) {
@@ -2265,6 +2459,336 @@ async function uiCommand(args, global, output, _start) {
2265
2459
  }
2266
2460
  }
2267
2461
  //#endregion
2462
+ //#region src/commands/agent.ts
2463
+ const RESULT_FILE = "agent-result.json";
2464
+ const WRITES_FILE$1 = "sverka-writes.json";
2465
+ async function agentCommand(args, global, output, _start) {
2466
+ const env = process$1.env;
2467
+ const engine = resolveAgentEngine(env);
2468
+ const prompt = env[AGENT_ENV.prompt];
2469
+ if (prompt === void 0 || prompt === "") throw new CliError(`sverka agent: ${AGENT_ENV.prompt} is not set — the compiled agent job provides it`, "MISSING_ARG", ExitCode.UsageError);
2470
+ const outDir = args.outputDir ?? global.root;
2471
+ const mentions = parseMentionList(env[AGENT_ENV.mention]);
2472
+ const commentBody = env["COMMENT_BODY"];
2473
+ if (mentions.length > 0 && commentBody !== void 0 && !mentions.some((m) => commentBody.includes(m))) {
2474
+ output.writeLine(`sverka agent: comment does not contain a configured mention (${mentions.join(", ")}) — skipping`);
2475
+ await writeArtifacts(outDir, {
2476
+ text: "",
2477
+ finishReason: "stop"
2478
+ });
2479
+ return ExitCode.Success;
2480
+ }
2481
+ const driver = resolveAgentDrivers(env).find((d) => d.canExecute(engine));
2482
+ if (driver === void 0) throw noAgentDriverError(engine, `sverka agent, engine '${engine}'`);
2483
+ const model = env[AGENT_ENV.model];
2484
+ const maxTokens = env[AGENT_ENV.maxTokens];
2485
+ const request = {
2486
+ engine,
2487
+ prompt,
2488
+ ...model !== void 0 ? { model } : {},
2489
+ ...maxTokens !== void 0 ? { maxTokens: parseMaxTokens(maxTokens) } : {}
2490
+ };
2491
+ output.debug(`sverka agent: engine=${engine} driver=${driver.name}`);
2492
+ const result = await driver.executeAgent(request);
2493
+ if (result.finishReason === "error") {
2494
+ output.errorLine("sverka agent: agent finished with reason 'error'");
2495
+ return ExitCode.RuntimeError;
2496
+ }
2497
+ await writeArtifacts(outDir, result, collectAgentWrites(result));
2498
+ if (result.text !== "") output.writeLine(result.text);
2499
+ return ExitCode.Success;
2500
+ }
2501
+ /**
2502
+ * SVERKA_MENTION carries one mention string, or a JSON array when several
2503
+ * comment entries with different mentions reach the same job.
2504
+ */
2505
+ function parseMentionList(raw) {
2506
+ if (raw === void 0) return [];
2507
+ try {
2508
+ const parsed = JSON.parse(raw);
2509
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((m) => typeof m === "string")) return parsed;
2510
+ } catch {}
2511
+ return [raw];
2512
+ }
2513
+ function parseMaxTokens(raw) {
2514
+ const n = Number(raw);
2515
+ if (!Number.isInteger(n) || n <= 0) throw new CliError(`sverka agent: ${AGENT_ENV.maxTokens} must be a positive integer, got '${raw}'`, "INVALID_FLAG", ExitCode.UsageError);
2516
+ return n;
2517
+ }
2518
+ async function writeArtifacts(dir, result, writes = []) {
2519
+ await writeFile(join(dir, RESULT_FILE), JSON.stringify(result, null, 2), "utf-8");
2520
+ await writeFile(join(dir, WRITES_FILE$1), JSON.stringify({ writes }, null, 2), "utf-8");
2521
+ }
2522
+ //#endregion
2523
+ //#region src/commands/apply.ts
2524
+ const WRITES_FILE = "sverka-writes.json";
2525
+ const DECLARATIONS_ENV = "SVERKA_WRITE_DECLARATIONS";
2526
+ /** Write kinds `sverka apply` can execute. Everything else fails loudly. */
2527
+ const SUPPORTED_WRITE_KINDS = /* @__PURE__ */ new Set(["comment"]);
2528
+ async function applyCommand(args, global, output, _start) {
2529
+ const provider = args.provider ?? detectProvider();
2530
+ const writes = await loadWrites(args.file ?? join(global.root, WRITES_FILE));
2531
+ const declarations = loadDeclarations();
2532
+ const applied = [];
2533
+ for (const write of writes) {
2534
+ if (!declarations.some((d) => d.kind === write.kind)) throw new CliError(`sverka apply: write kind '${write.kind}' is not declared in permissions.write (declarations: ${declarations.map((d) => d.kind).join(", ") || "none"})`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2535
+ if (!SUPPORTED_WRITE_KINDS.has(write.kind)) throw new CliError(`sverka apply: write kind '${write.kind}' is declared but not supported by sverka apply (supported: ${[...SUPPORTED_WRITE_KINDS].join(", ")})`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2536
+ applied.push(await applyWrite(provider, write, declarations));
2537
+ }
2538
+ const summary = applied.length > 0 ? ` — ${applied.join("; ")}` : "";
2539
+ output.writeLine(`sverka apply: applied ${applied.length} write(s) via ${provider}${summary}`);
2540
+ return ExitCode.Success;
2541
+ }
2542
+ function detectProvider() {
2543
+ if (process$1.env["GITLAB_CI"] !== void 0 || process$1.env["CI_API_V4_URL"]) return "gitlab";
2544
+ if (process$1.env["GITHUB_ACTIONS"] !== void 0) return "github";
2545
+ throw new CliError("sverka apply: --provider is required when CI provider cannot be detected (gitlab|github)", "MISSING_ARG", ExitCode.UsageError);
2546
+ }
2547
+ async function loadWrites(file) {
2548
+ let raw;
2549
+ try {
2550
+ raw = await readFile(file, "utf-8");
2551
+ } catch {
2552
+ throw new CliError(`sverka apply: writes artifact not found: ${file} — the agent job must produce ${WRITES_FILE}`, "MISSING_ARG", ExitCode.RuntimeError);
2553
+ }
2554
+ let parsed;
2555
+ try {
2556
+ parsed = JSON.parse(raw);
2557
+ } catch (e) {
2558
+ throw new CliError(`sverka apply: ${WRITES_FILE} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2559
+ }
2560
+ const items = extractWritesItems(parsed);
2561
+ if (items === void 0) throw new CliError(`sverka apply: ${WRITES_FILE} must be an object with a 'writes' array`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2562
+ return items.map((item, i) => {
2563
+ if (typeof item !== "object" || item === null || typeof item.kind !== "string") throw new CliError(`sverka apply: writes[${i}] must be an object with a string 'kind'`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2564
+ return item;
2565
+ });
2566
+ }
2567
+ /** The artifact is either a bare array or an object with a 'writes' array. */
2568
+ function extractWritesItems(parsed) {
2569
+ if (Array.isArray(parsed)) return parsed;
2570
+ if (typeof parsed !== "object" || parsed === null) return void 0;
2571
+ const w = parsed.writes;
2572
+ return Array.isArray(w) ? w : void 0;
2573
+ }
2574
+ function loadDeclarations() {
2575
+ const raw = process$1.env[DECLARATIONS_ENV];
2576
+ if (raw === void 0 || raw === "") throw new CliError(`sverka apply: ${DECLARATIONS_ENV} is not set — the __apply job declares it from permissions.write`, "MISSING_ARG", ExitCode.RuntimeError);
2577
+ let parsed;
2578
+ try {
2579
+ parsed = JSON.parse(raw);
2580
+ } catch (e) {
2581
+ throw new CliError(`sverka apply: ${DECLARATIONS_ENV} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2582
+ }
2583
+ if (!Array.isArray(parsed)) throw new CliError(`sverka apply: ${DECLARATIONS_ENV} must be a JSON array of write declarations`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2584
+ return parsed;
2585
+ }
2586
+ async function applyWrite(provider, write, declarations) {
2587
+ if (write.kind === "comment") return provider === "gitlab" ? applyGitlabComment(write, declarations) : applyGithubComment(write);
2588
+ throw new CliError(`sverka apply: write kind '${write.kind}' is not supported`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2589
+ }
2590
+ function requireBody(write) {
2591
+ if (typeof write.body !== "string" || write.body === "") throw new CliError("sverka apply: comment write requires a non-empty 'body' string", "PACKAGE_ERROR", ExitCode.RuntimeError);
2592
+ return write.body;
2593
+ }
2594
+ /**
2595
+ * The object kind a comment write targets. `write.on` wins when present
2596
+ * (and must be a known kind — anything else fails loudly rather than
2597
+ * silently falling back); otherwise the triggering context decides: a
2598
+ * forwarded MR_IID means the comment fired on a merge request.
2599
+ */
2600
+ function resolveCommentTarget(write) {
2601
+ if (write.on === "issue" || write.on === "merge_request") return write.on;
2602
+ if (write.on !== void 0) throw new CliError(`sverka apply: comment write 'on' must be issue|merge_request, got '${JSON.stringify(write.on)}'`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2603
+ return process$1.env["MR_IID"] !== void 0 ? "merge_request" : "issue";
2604
+ }
2605
+ /**
2606
+ * Resolve the target iid for a comment write. When the triggering context
2607
+ * supplies an iid (the webhook forwarded MR_IID/ISSUE_IID), it wins — a
2608
+ * write.iid that disagrees is model output trying to redirect the comment
2609
+ * to another target, which fails loudly.
2610
+ */
2611
+ function requireIid(write, envKeys) {
2612
+ const contextRaw = envKeys.map((k) => process$1.env[k]).find((v) => v !== void 0 && v !== "");
2613
+ if (contextRaw !== void 0) {
2614
+ const ctx = Number(contextRaw);
2615
+ if (!Number.isInteger(ctx) || ctx <= 0) throw new CliError(`sverka apply: context iid '${contextRaw}' is not a positive integer`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2616
+ if (write.iid !== void 0 && Number(write.iid) !== ctx) throw new CliError(`sverka apply: write iid '${JSON.stringify(write.iid)}' does not match the triggering context iid ${ctx}`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2617
+ return ctx;
2618
+ }
2619
+ if (write.iid === void 0) throw new CliError(`sverka apply: comment write needs a target iid (${envKeys.join(" or ")} env var, or an 'iid' field)`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2620
+ const n = Number(write.iid);
2621
+ if (!Number.isInteger(n) || n <= 0) throw new CliError(`sverka apply: comment write iid must be a positive integer, got '${JSON.stringify(write.iid)}'`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2622
+ return n;
2623
+ }
2624
+ async function applyGitlabComment(write, declarations) {
2625
+ const { api, project, token } = gitlabApplyEnv();
2626
+ const body = requireBody(write);
2627
+ const on = resolveCommentTarget(write);
2628
+ if (!declarations.some((d) => d.kind === "comment" && d.target === on)) throw new CliError(`sverka apply: comment write targets '${on}' but no declaration declares it (declared comment targets: ${declarations.filter((d) => d.kind === "comment").map((d) => d.target).join(", ") || "none"})`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2629
+ const iid = requireIid(write, on === "merge_request" ? ["MR_IID"] : ["ISSUE_IID", "SVERKA_ISSUE_IID"]);
2630
+ await postJson(`${api}${on === "merge_request" ? `/projects/${encodeURIComponent(project)}/merge_requests/${iid}/notes` : `/projects/${encodeURIComponent(project)}/issues/${iid}/notes`}`, token, { body }, "PRIVATE-TOKEN");
2631
+ return `comment on ${on} !${iid}`;
2632
+ }
2633
+ /** Provider-supplied env for a GitLab apply call — all CI-controlled. */
2634
+ function gitlabApplyEnv() {
2635
+ const env = process$1.env;
2636
+ const api = env["CI_API_V4_URL"] ?? "https://gitlab.com/api/v4";
2637
+ const project = env["CI_PROJECT_ID"];
2638
+ if (project === void 0 || project === "") throw new CliError("sverka apply: CI_PROJECT_ID is not set — run inside a GitLab job or set it explicitly", "MISSING_ARG", ExitCode.RuntimeError);
2639
+ const token = env["SVERKA_APPLY_TOKEN"] || env["GITLAB_TOKEN"];
2640
+ if (token === void 0 || token === "") throw new CliError("sverka apply: SVERKA_APPLY_TOKEN is not set — scope it to the 'sverka-apply' environment (see engdocs/user/gitlab/agentic.md)", "MISSING_ARG", ExitCode.RuntimeError);
2641
+ return {
2642
+ api,
2643
+ project,
2644
+ token
2645
+ };
2646
+ }
2647
+ async function applyGithubComment(write) {
2648
+ const env = process$1.env;
2649
+ const api = env["GITHUB_API_URL"] ?? "https://api.github.com";
2650
+ const repo = env["GITHUB_REPOSITORY"];
2651
+ if (repo === void 0 || repo === "") throw new CliError("sverka apply: GITHUB_REPOSITORY is not set — run inside a GitHub Actions job or set it explicitly", "MISSING_ARG", ExitCode.RuntimeError);
2652
+ const token = env["SVERKA_APPLY_TOKEN"] || env["GH_TOKEN"] || env["GITHUB_TOKEN"];
2653
+ if (token === void 0 || token === "") throw new CliError("sverka apply: GH_TOKEN/GITHUB_TOKEN is not set", "MISSING_ARG", ExitCode.RuntimeError);
2654
+ const body = requireBody(write);
2655
+ const issue = requireIid(write, ["SVERKA_ISSUE_IID", "ISSUE_IID"]);
2656
+ await postJson(`${api}/repos/${repo}/issues/${issue}/comments`, token, { body }, "Bearer");
2657
+ return `comment on issue #${issue}`;
2658
+ }
2659
+ async function postJson(url, token, body, auth) {
2660
+ const headers = { "content-type": "application/json" };
2661
+ if (auth === "PRIVATE-TOKEN") headers["PRIVATE-TOKEN"] = token;
2662
+ else headers["authorization"] = `Bearer ${token}`;
2663
+ const res = await fetch(url, {
2664
+ method: "POST",
2665
+ headers,
2666
+ body: JSON.stringify(body)
2667
+ });
2668
+ if (!res.ok) {
2669
+ const text = await res.text().catch(() => "");
2670
+ throw new CliError(`sverka apply: provider request failed (${res.status} ${res.url}): ${text.slice(0, 300)}`, "PACKAGE_ERROR", ExitCode.RuntimeError);
2671
+ }
2672
+ }
2673
+ //#endregion
2674
+ //#region src/commands/login.ts
2675
+ async function loginCommand(args, _global, output, _start) {
2676
+ const url = args.hub ?? process.env["SVERKA_HUB_URL"];
2677
+ const token = args.token ?? process.env["SVERKA_HUB_TOKEN"];
2678
+ if (url === void 0) throw new CliError("missing --hub <url> (or set SVERKA_HUB_URL)", "MISSING_ARG", ExitCode.UsageError);
2679
+ if (!token) throw new CliError("missing --token <t> (or set SVERKA_HUB_TOKEN)", "MISSING_ARG", ExitCode.UsageError);
2680
+ let parsed;
2681
+ try {
2682
+ parsed = new URL(url);
2683
+ } catch {
2684
+ throw new CliError(`--hub must be a URL, got "${url}"`, "INVALID_FLAG", ExitCode.UsageError);
2685
+ }
2686
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new CliError(`--hub must be http(s), got "${parsed.protocol}"`, "INVALID_FLAG", ExitCode.UsageError);
2687
+ const normalized = normalizeHubUrl(url);
2688
+ const path = storeCredentials(normalized, token);
2689
+ output.writeLine(`stored credentials for ${normalized} in ${path}`);
2690
+ return ExitCode.Success;
2691
+ }
2692
+ //#endregion
2693
+ //#region src/commands/hub.ts
2694
+ async function hubCommand(args, global, output, _start) {
2695
+ if (args.action !== "serve") throw new CliError(`unknown hub action "${args.action ?? ""}" (supported: serve)`, "UNKNOWN_COMMAND", ExitCode.UsageError);
2696
+ if (args.port !== void 0 && (!Number.isInteger(args.port) || args.port < 0 || args.port > 65535)) throw new CliError("--port must be an integer in [0, 65535]", "INVALID_FLAG", ExitCode.UsageError);
2697
+ const dataDir = args.data ?? `${global.root}/.sverka-hub`;
2698
+ const server = await startHubServer({
2699
+ dataDir,
2700
+ ...args.port !== void 0 ? { port: args.port } : {},
2701
+ ...args.host !== void 0 ? { host: args.host } : {}
2702
+ });
2703
+ output.writeLine(`sverka hub listening on ${server.url}`);
2704
+ output.writeLine(` data: ${server.dataDir}`);
2705
+ output.writeLine(` dashboard: ${server.url}/`);
2706
+ output.writeLine(" (Ctrl+C to stop)");
2707
+ await new Promise((done) => {
2708
+ const stop = () => {
2709
+ process.removeListener("SIGINT", stop);
2710
+ process.removeListener("SIGTERM", stop);
2711
+ done();
2712
+ };
2713
+ process.on("SIGINT", stop);
2714
+ process.on("SIGTERM", stop);
2715
+ });
2716
+ await server.close();
2717
+ return ExitCode.Success;
2718
+ }
2719
+ //#endregion
2720
+ //#region src/commands/runs.ts
2721
+ function listLocalRuns(root) {
2722
+ const dir = join(root, ".sverka", "runs");
2723
+ if (!existsSync(dir)) return [];
2724
+ const rows = [];
2725
+ for (const entry of readdirSync(dir)) {
2726
+ const reportPath = join(dir, entry, "report.json");
2727
+ if (!existsSync(reportPath)) continue;
2728
+ let status = "unknown";
2729
+ let findings = null;
2730
+ try {
2731
+ const data = JSON.parse(readFileSync(reportPath, "utf8")).data ?? {};
2732
+ if (typeof data.status === "string") status = data.status;
2733
+ if (typeof data.findings === "number") findings = data.findings;
2734
+ } catch {}
2735
+ rows.push({
2736
+ runId: entry,
2737
+ status,
2738
+ findings,
2739
+ mtimeMs: statSync(reportPath).mtimeMs
2740
+ });
2741
+ }
2742
+ return rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
2743
+ }
2744
+ async function runsCommand(args, global, output, _start) {
2745
+ if (args.remote === true) return remoteRuns(args, global, output);
2746
+ return localRuns(args, global, output);
2747
+ }
2748
+ async function remoteRuns(args, global, output) {
2749
+ const hub = resolveHub(global.root);
2750
+ if (hub === null) throw new CliError("no hub configured — set SVERKA_HUB_URL + SVERKA_HUB_TOKEN, run `sverka login`, or add .sverka/hub.json", "MISSING_ARG", ExitCode.UsageError);
2751
+ const { listRuns } = await import("@sverka/storage");
2752
+ const runs = await listRuns(hub.config, { ...args.limit !== void 0 ? { limit: args.limit } : {} });
2753
+ if (global.format === "json") {
2754
+ output.writeLine(JSON.stringify({
2755
+ command: "runs",
2756
+ data: runs
2757
+ }));
2758
+ return ExitCode.Success;
2759
+ }
2760
+ if (runs.length === 0) {
2761
+ output.writeLine(`no remote runs for ${hub.config.project} — run \`sverka run --remote\` to upload one`);
2762
+ return ExitCode.Success;
2763
+ }
2764
+ output.writeLine(`remote runs for ${hub.config.project} (${hub.config.url})`);
2765
+ for (const run of runs) {
2766
+ const when = run.startedAt === null ? "—" : new Date(run.startedAt).toISOString().slice(0, 19) + "Z";
2767
+ const duration = run.durationMs === null ? "—" : `${(run.durationMs / 1e3).toFixed(1)}s`;
2768
+ output.writeLine(` ${run.runId} ${run.status.padEnd(9)} ${run.entry.padEnd(20)} ${when} ${duration} findings:${run.findingCounts.total}`);
2769
+ }
2770
+ return ExitCode.Success;
2771
+ }
2772
+ function localRuns(args, global, output) {
2773
+ const shown = listLocalRuns(global.root).slice(0, args.limit ?? 20);
2774
+ if (global.format === "json") {
2775
+ output.writeLine(JSON.stringify({
2776
+ command: "runs",
2777
+ data: shown
2778
+ }));
2779
+ return ExitCode.Success;
2780
+ }
2781
+ if (shown.length === 0) {
2782
+ output.writeLine("no runs recorded — run `sverka run` first");
2783
+ return ExitCode.Success;
2784
+ }
2785
+ for (const run of shown) {
2786
+ const when = new Date(run.mtimeMs).toISOString().slice(0, 19) + "Z";
2787
+ output.writeLine(` ${run.runId} ${run.status.padEnd(9)} ${when} findings:${run.findings ?? "—"}`);
2788
+ }
2789
+ return ExitCode.Success;
2790
+ }
2791
+ //#endregion
2268
2792
  //#region src/main.ts
2269
2793
  /** Build GlobalFlags from parsed yargs options. */
2270
2794
  function buildGlobalFlags(parsed) {
@@ -2334,6 +2858,10 @@ function addRunCommand(y) {
2334
2858
  type: "boolean",
2335
2859
  alias: "w",
2336
2860
  describe: "Re-run on file changes (debounced 300ms)"
2861
+ }).option("remote", {
2862
+ type: "boolean",
2863
+ default: false,
2864
+ describe: "Remote hub run: remote cache + snapshot store + report upload"
2337
2865
  });
2338
2866
  }
2339
2867
  /** Configure the plan subcommand options. */
@@ -2347,13 +2875,9 @@ function addPolicyCommand(y) {
2347
2875
  demandOption: true
2348
2876
  }).option("baseline", { type: "string" });
2349
2877
  }
2350
- /** Configure the synth subcommand options. */
2878
+ /** Configure the synth subcommand options. Alias for compile — same flags. */
2351
2879
  function addSynthCommand(y) {
2352
- return y.option("target", {
2353
- type: "string",
2354
- demandOption: true,
2355
- choices: ["github", "gitlab"]
2356
- });
2880
+ return addCompileCommand(y);
2357
2881
  }
2358
2882
  /** Configure the compile subcommand options. */
2359
2883
  function addCompileCommand(y) {
@@ -2407,6 +2931,45 @@ function addUiCommand(y) {
2407
2931
  describe: "Host to bind"
2408
2932
  });
2409
2933
  }
2934
+ /** Configure the login subcommand options. */
2935
+ function addLoginCommand(y) {
2936
+ return y.option("hub", {
2937
+ type: "string",
2938
+ describe: "Hub base URL (or SVERKA_HUB_URL)"
2939
+ }).option("token", {
2940
+ type: "string",
2941
+ describe: "Hub bearer token (or SVERKA_HUB_TOKEN)"
2942
+ });
2943
+ }
2944
+ /** Configure the hub subcommand options. */
2945
+ function addHubCommand(y) {
2946
+ return y.positional("action", {
2947
+ type: "string",
2948
+ choices: ["serve"],
2949
+ describe: "hub action (serve)"
2950
+ }).option("port", {
2951
+ type: "number",
2952
+ default: 7357,
2953
+ describe: "Port to listen on"
2954
+ }).option("host", {
2955
+ type: "string",
2956
+ describe: "Host to bind"
2957
+ }).option("data", {
2958
+ type: "string",
2959
+ describe: "Data directory (default: .sverka-hub under --root)"
2960
+ });
2961
+ }
2962
+ /** Configure the runs subcommand options. */
2963
+ function addRunsCommand(y) {
2964
+ return y.option("remote", {
2965
+ type: "boolean",
2966
+ default: false,
2967
+ describe: "List runs stored on the configured hub"
2968
+ }).option("limit", {
2969
+ type: "number",
2970
+ describe: "Max runs to list"
2971
+ });
2972
+ }
2410
2973
  function buildParser() {
2411
2974
  return yargs([]).scriptName("sverka").version(cliVersion()).option("format", {
2412
2975
  type: "string",
@@ -2433,7 +2996,17 @@ function buildParser() {
2433
2996
  type: "boolean",
2434
2997
  alias: "v",
2435
2998
  default: false
2436
- }).command("init", "Create a sverka.config.ts", addInitCommand).command("validate", "Validate a sverka config").command("plan", "Bind Entry + inputs → Run Plan", addPlanCommand).command("graph", "Display the Definition Graph").command("run", "Execute the workflow locally", addRunCommand).command("discover", "Discover and display project context").command("check", "Resolve proposed checks → StepDefinitions").command("policy", "Evaluate findings against policy", addPolicyCommand).command("synth", "Alias for compile — compile to a target CI YAML", addSynthCommand).command("compile", "Compile to a target CI YAML", addCompileCommand).command("mcp-server", "Expose Sverka as an MCP server (stdio)").command("doctor", "Diagnose environment and dependencies").command("view [file]", "Open the latest run report, or view SARIF findings (TUI/HTML)", addViewCommand).command("ui", "Start local web dashboard server", addUiCommand).demandCommand(1, "No command given").strict().fail((msg, err) => {
2999
+ }).command("init", "Create a sverka.config.ts", addInitCommand).command("validate", "Validate a sverka config").command("plan", "Bind Entry + inputs → Run Plan", addPlanCommand).command("graph", "Display the Definition Graph").command("run", "Execute the workflow locally", addRunCommand).command("discover", "Discover and display project context").command("check", "Resolve proposed checks → StepDefinitions").command("policy", "Evaluate findings against policy", addPolicyCommand).command("synth", "Alias for compile — compile to a target CI YAML", addSynthCommand).command("compile", "Compile to a target CI YAML", addCompileCommand).command("mcp-server", "Expose Sverka as an MCP server (stdio)").command("doctor", "Diagnose environment and dependencies").command("view [file]", "Open the latest run report, or view SARIF findings (TUI/HTML)", addViewCommand).command("ui", "Start local web dashboard server", addUiCommand).command("agent", "Run a single agent step from SVERKA_AGENT_* env (CI agent jobs)", (y) => y.option("output-dir", {
3000
+ type: "string",
3001
+ describe: "Directory for agent-result.json + sverka-writes.json (default: root)"
3002
+ })).command("apply", "Validate + apply agent writes artifact (CI __apply jobs)", (y) => y.option("provider", {
3003
+ type: "string",
3004
+ choices: ["gitlab", "github"],
3005
+ describe: "CI provider (auto-detected from CI env when omitted)"
3006
+ }).option("file", {
3007
+ type: "string",
3008
+ describe: "Path to sverka-writes.json (default: <root>/sverka-writes.json)"
3009
+ })).command("login", "Store hub credentials (~/.config/sverka/credentials)", addLoginCommand).command("hub <action>", "Self-hosted run hub (serve: cache + history + findings)", addHubCommand).command("runs", "List run history (local, or --remote)", addRunsCommand).demandCommand(1, "No command given").strict().fail((msg, err) => {
2437
3010
  if (err) throw err;
2438
3011
  throw new CliError(msg, "UNKNOWN_COMMAND", ExitCode.UsageError);
2439
3012
  });
@@ -2455,6 +3028,11 @@ async function dispatch(command, parsed, global, output, start) {
2455
3028
  case "doctor": return doctorCommand(global, output, start);
2456
3029
  case "view": return dispatchView(parsed, global, output, start);
2457
3030
  case "ui": return dispatchUi(parsed, global, output, start);
3031
+ case "agent": return dispatchAgent(parsed, global, output, start);
3032
+ case "apply": return dispatchApply(parsed, global, output, start);
3033
+ case "login": return dispatchLogin(parsed, global, output, start);
3034
+ case "hub": return dispatchHub(parsed, global, output, start);
3035
+ case "runs": return dispatchRuns(parsed, global, output, start);
2458
3036
  default: throw new CliError(`unknown command: ${command}`, "UNKNOWN_COMMAND", ExitCode.UsageError);
2459
3037
  }
2460
3038
  }
@@ -2491,6 +3069,7 @@ function dispatchRun(parsed, global, output, start) {
2491
3069
  if (parsed.tui === true) args.tui = true;
2492
3070
  if (parsed.tui === false) args.tui = false;
2493
3071
  if (parsed.watch === true) args.watch = true;
3072
+ if (parsed.remote === true) args.remote = true;
2494
3073
  return runCommand(args, global, output, start);
2495
3074
  }
2496
3075
  function dispatchPolicy(parsed, global, output, start) {
@@ -2499,7 +3078,11 @@ function dispatchPolicy(parsed, global, output, start) {
2499
3078
  return policyCommand(args, global, output, start);
2500
3079
  }
2501
3080
  function dispatchSynth(parsed, global, output, start) {
2502
- return synthCommand({ target: parsed.target === "gitlab" ? "gitlab" : "github" }, global, output, start);
3081
+ const args = { target: parsed.target === "gitlab" ? "gitlab" : "github" };
3082
+ if (typeof parsed.output === "string") args.output = parsed.output;
3083
+ if (typeof parsed.outputDir === "string") args.outputDir = parsed.outputDir;
3084
+ if (parsed.pin === true) args.pin = true;
3085
+ return synthCommand(args, global, output, start);
2503
3086
  }
2504
3087
  function dispatchCompile(parsed, global, output, start) {
2505
3088
  const args = { target: parsed.target === "gitlab" ? "gitlab" : "github" };
@@ -2520,6 +3103,36 @@ function dispatchUi(parsed, global, output, start) {
2520
3103
  if (typeof parsed.host === "string") args.host = parsed.host;
2521
3104
  return uiCommand(args, global, output, start);
2522
3105
  }
3106
+ function dispatchAgent(parsed, global, output, start) {
3107
+ const args = {};
3108
+ if (typeof parsed.outputDir === "string") args.outputDir = parsed.outputDir;
3109
+ return agentCommand(args, global, output, start);
3110
+ }
3111
+ function dispatchApply(parsed, global, output, start) {
3112
+ const args = {};
3113
+ if (parsed.provider === "gitlab" || parsed.provider === "github") args.provider = parsed.provider;
3114
+ if (typeof parsed.file === "string") args.file = parsed.file;
3115
+ return applyCommand(args, global, output, start);
3116
+ }
3117
+ function dispatchLogin(parsed, global, output, start) {
3118
+ const args = {};
3119
+ if (typeof parsed.hub === "string") args.hub = parsed.hub;
3120
+ if (typeof parsed.token === "string") args.token = parsed.token;
3121
+ return loginCommand(args, global, output, start);
3122
+ }
3123
+ function dispatchHub(parsed, global, output, start) {
3124
+ const args = {};
3125
+ if (typeof parsed.action === "string") args.action = parsed.action;
3126
+ if (typeof parsed.port === "number") args.port = parsed.port;
3127
+ if (typeof parsed.data === "string") args.data = parsed.data;
3128
+ if (typeof parsed.host === "string") args.host = parsed.host;
3129
+ return hubCommand(args, global, output, start);
3130
+ }
3131
+ function dispatchRuns(parsed, global, output, start) {
3132
+ const args = { remote: parsed.remote === true };
3133
+ if (typeof parsed.limit === "number") args.limit = parsed.limit;
3134
+ return runsCommand(args, global, output, start);
3135
+ }
2523
3136
  /**
2524
3137
  * The main CLI entry point.
2525
3138
  * @param argv Command-line arguments (excluding node and script path).