sverka 0.0.0 → 0.2.2
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/bin.d.mts +1 -0
- package/dist/bin.mjs +23 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/errors-DHioC6LB.mjs +16 -0
- package/dist/errors-DHioC6LB.mjs.map +1 -0
- package/dist/index.d.mts +84 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2040 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/config-hooks.d.mts +11 -0
- package/dist/internal/config-hooks.d.mts.map +1 -0
- package/dist/internal/config-hooks.mjs +30 -0
- package/dist/internal/config-hooks.mjs.map +1 -0
- package/package.json +51 -10
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2040 @@
|
|
|
1
|
+
import { t as missingBuildHint } from "./errors-DHioC6LB.mjs";
|
|
2
|
+
import * as nodeModule from "node:module";
|
|
3
|
+
import { register } from "node:module";
|
|
4
|
+
import process$1 from "node:process";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { mkdir, open, readFile, realpath, writeFile } from "node:fs/promises";
|
|
10
|
+
import { collectConstructWarnings, synthesize, validateGraph } from "@sverka/workflow";
|
|
11
|
+
import { bindRunPlan, createPlanner } from "@sverka/sdk";
|
|
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";
|
|
15
|
+
import { compileGithub, compileGitlab } from "@sverka/compiler";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
//#region src/types.ts
|
|
18
|
+
/** Exit codes used by the CLI. */
|
|
19
|
+
const ExitCode = {
|
|
20
|
+
Success: 0,
|
|
21
|
+
PolicyFail: 1,
|
|
22
|
+
UsageError: 2,
|
|
23
|
+
RuntimeError: 3
|
|
24
|
+
};
|
|
25
|
+
/** Error thrown when CLI argument parsing or command execution fails. */
|
|
26
|
+
var CliError = class extends Error {
|
|
27
|
+
code;
|
|
28
|
+
exitCode;
|
|
29
|
+
cause;
|
|
30
|
+
constructor(message, code, exitCode, cause) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CliError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.exitCode = exitCode;
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/output.ts
|
|
40
|
+
/**
|
|
41
|
+
* OutputWriter that writes to caller-provided sinks (stdout/stderr).
|
|
42
|
+
* In production, the sinks write to process.stdout/process.stderr.
|
|
43
|
+
* In tests, the sinks capture into arrays.
|
|
44
|
+
*/
|
|
45
|
+
var ConsoleOutputWriter = class {
|
|
46
|
+
#out;
|
|
47
|
+
#err;
|
|
48
|
+
#quiet;
|
|
49
|
+
#verbose;
|
|
50
|
+
constructor(out, err, quiet = false, verbose = false) {
|
|
51
|
+
this.#out = out;
|
|
52
|
+
this.#err = err;
|
|
53
|
+
this.#quiet = quiet;
|
|
54
|
+
this.#verbose = verbose;
|
|
55
|
+
}
|
|
56
|
+
write(text) {
|
|
57
|
+
if (this.#quiet) return;
|
|
58
|
+
this.#out(text);
|
|
59
|
+
}
|
|
60
|
+
writeLine(text) {
|
|
61
|
+
if (this.#quiet) return;
|
|
62
|
+
this.#out(text + "\n");
|
|
63
|
+
}
|
|
64
|
+
error(text) {
|
|
65
|
+
this.#err(text);
|
|
66
|
+
}
|
|
67
|
+
errorLine(text) {
|
|
68
|
+
this.#err(text + "\n");
|
|
69
|
+
}
|
|
70
|
+
/** Write a debug line to stderr, only when --verbose is set. */
|
|
71
|
+
debug(text) {
|
|
72
|
+
if (this.#verbose) this.#err(text + "\n");
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Wrapper that applies --quiet / --verbose / --format semantics to any
|
|
77
|
+
* underlying OutputWriter. Used to make injected test writers respect the
|
|
78
|
+
* same suppression rules as the production writer.
|
|
79
|
+
*
|
|
80
|
+
* - `--quiet` (text format) suppresses stdout writes; JSON stdout is kept.
|
|
81
|
+
* - `--verbose` routes `debug()` to the underlying `errorLine()`.
|
|
82
|
+
* - stderr is never suppressed.
|
|
83
|
+
*/
|
|
84
|
+
var FlagAwareWriter = class {
|
|
85
|
+
#base;
|
|
86
|
+
#quiet;
|
|
87
|
+
#verbose;
|
|
88
|
+
constructor(base, quiet, verbose) {
|
|
89
|
+
this.#base = base;
|
|
90
|
+
this.#quiet = quiet;
|
|
91
|
+
this.#verbose = verbose;
|
|
92
|
+
}
|
|
93
|
+
write(text) {
|
|
94
|
+
if (this.#quiet) return;
|
|
95
|
+
this.#base.write(text);
|
|
96
|
+
}
|
|
97
|
+
writeLine(text) {
|
|
98
|
+
if (this.#quiet) return;
|
|
99
|
+
this.#base.writeLine(text);
|
|
100
|
+
}
|
|
101
|
+
error(text) {
|
|
102
|
+
this.#base.error(text);
|
|
103
|
+
}
|
|
104
|
+
errorLine(text) {
|
|
105
|
+
this.#base.errorLine(text);
|
|
106
|
+
}
|
|
107
|
+
debug(text) {
|
|
108
|
+
if (this.#verbose) this.#base.errorLine(text);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Create an OutputWriter from global flags. In JSON format, stdout is NOT
|
|
113
|
+
* suppressed by --quiet (the JSON result is the data and must be emitted).
|
|
114
|
+
*/
|
|
115
|
+
function createOutputWriter(global, out, err) {
|
|
116
|
+
return new ConsoleOutputWriter(out, err, global.quiet && global.format === "text", global.verbose);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Wrap any OutputWriter with --quiet / --verbose semantics from global flags.
|
|
120
|
+
* Use for injected test writers so they respect the same suppression rules.
|
|
121
|
+
*/
|
|
122
|
+
function wrapOutputWriter(global, base) {
|
|
123
|
+
return new FlagAwareWriter(base, global.quiet && global.format === "text", global.verbose);
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/internal/version.ts
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the sverka CLI version from its own package.json.
|
|
129
|
+
* Walks up from this module so it works from src/ (dev) and bundled
|
|
130
|
+
* dist/ alike, and never picks up the consumer's cwd package.json
|
|
131
|
+
* (which is what yargs' default --version resolution does).
|
|
132
|
+
*/
|
|
133
|
+
function cliVersion() {
|
|
134
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
135
|
+
while (true) {
|
|
136
|
+
try {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
138
|
+
if (pkg.name === "sverka" && pkg.version) return pkg.version;
|
|
139
|
+
} catch {}
|
|
140
|
+
const parent = dirname(dir);
|
|
141
|
+
if (parent === dir) return "0.0.0";
|
|
142
|
+
dir = parent;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/internal/paths.ts
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a path against a root directory. Absolute paths are returned
|
|
149
|
+
* unchanged; relative paths are joined under `root` so that `--root` governs
|
|
150
|
+
* path resolution regardless of the process working directory.
|
|
151
|
+
*/
|
|
152
|
+
function resolveUnderRoot(root, path) {
|
|
153
|
+
return isAbsolute(path) ? path : join(root, path);
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/internal/config.ts
|
|
157
|
+
let hooksRegistered = false;
|
|
158
|
+
function findHooksFile() {
|
|
159
|
+
for (const rel of ["./config-hooks.mjs", "./internal/config-hooks.mjs"]) try {
|
|
160
|
+
const candidate = fileURLToPath(new URL(rel, import.meta.url));
|
|
161
|
+
if (existsSync(candidate)) return candidate;
|
|
162
|
+
} catch {}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Register ESM resolve hooks so a config file that cannot resolve @sverka/*
|
|
167
|
+
* packages from its own location falls back to the copies bundled with the
|
|
168
|
+
* CLI. This makes a globally-installed `sverka` self-contained.
|
|
169
|
+
*/
|
|
170
|
+
async function registerConfigHooks() {
|
|
171
|
+
if (hooksRegistered) return;
|
|
172
|
+
const hooksFile = findHooksFile();
|
|
173
|
+
if (hooksFile === null) return;
|
|
174
|
+
const href = pathToFileURL(hooksFile).href;
|
|
175
|
+
try {
|
|
176
|
+
const registerHooks = nodeModule.registerHooks;
|
|
177
|
+
if (typeof registerHooks === "function") {
|
|
178
|
+
const hooks = await import(href);
|
|
179
|
+
if (typeof hooks.resolve === "function") {
|
|
180
|
+
registerHooks.call(nodeModule, { resolve: hooks.resolve });
|
|
181
|
+
hooksRegistered = true;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
register(href);
|
|
186
|
+
hooksRegistered = true;
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Find a sverka.config.ts or sverka.config.js file in the given root.
|
|
191
|
+
* Returns the absolute path or null if not found.
|
|
192
|
+
*/
|
|
193
|
+
async function findConfig(root) {
|
|
194
|
+
for (const candidate of [
|
|
195
|
+
"sverka.config.ts",
|
|
196
|
+
"sverka.config.js",
|
|
197
|
+
"sverka.config.mjs"
|
|
198
|
+
]) {
|
|
199
|
+
const path = join(root, candidate);
|
|
200
|
+
if (existsSync(path)) return path;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Resolve the effective config path from global flags.
|
|
206
|
+
* Throws a usage error when no config can be found.
|
|
207
|
+
*/
|
|
208
|
+
async function resolveConfigPath$1(global) {
|
|
209
|
+
let configPath = global.config ? resolveUnderRoot(global.root, global.config) : null;
|
|
210
|
+
if (!configPath) configPath = await findConfig(global.root);
|
|
211
|
+
if (!configPath) throw new CliError("no config found (use --config to specify a path)", "MISSING_ARG", ExitCode.UsageError);
|
|
212
|
+
return configPath;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Structural check that avoids `instanceof` across package boundaries.
|
|
216
|
+
* A construct-like object (Project or bare Pipeline — single-pipeline
|
|
217
|
+
* configs may skip the Project) has a `node` with an `id` string and a
|
|
218
|
+
* `children` array.
|
|
219
|
+
*/
|
|
220
|
+
function notConstructError() {
|
|
221
|
+
return new CliError("config must export a Project or Pipeline instance (default or named 'project')", "SDK_ERROR", ExitCode.RuntimeError);
|
|
222
|
+
}
|
|
223
|
+
function assertConstructLike(value) {
|
|
224
|
+
if (value === null || typeof value !== "object") throw notConstructError();
|
|
225
|
+
const node = value.node;
|
|
226
|
+
if (typeof node !== "object" || node === null || typeof node.id !== "string" || !Array.isArray(node.children) || typeof node.findAll !== "function") throw notConstructError();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Load a sverka config file and return the Project construct.
|
|
230
|
+
* The config file must export a `default` Project-like instance or a named
|
|
231
|
+
* `project` Project-like instance.
|
|
232
|
+
*
|
|
233
|
+
* @throws CliError if the config cannot be loaded or doesn't export a Project.
|
|
234
|
+
*/
|
|
235
|
+
async function loadConfig(configPath) {
|
|
236
|
+
const absPath = isAbsolute(configPath) ? configPath : resolve(process.cwd(), configPath);
|
|
237
|
+
if (!existsSync(absPath)) throw new CliError(`config not found: ${absPath}`, "MISSING_ARG", ExitCode.UsageError);
|
|
238
|
+
let mod;
|
|
239
|
+
try {
|
|
240
|
+
await registerConfigHooks();
|
|
241
|
+
mod = await import(pathToFileURL(absPath).href);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
throw new CliError(`failed to load config: ${e instanceof Error ? e.message : String(e)}`, "SDK_ERROR", ExitCode.RuntimeError, e);
|
|
244
|
+
}
|
|
245
|
+
const construct = mod.project ?? mod.default;
|
|
246
|
+
assertConstructLike(construct);
|
|
247
|
+
return construct;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Resolve, load, and synthesize the config for commands that need a graph.
|
|
251
|
+
*/
|
|
252
|
+
async function loadProjectGraph(global) {
|
|
253
|
+
const configPath = await resolveConfigPath$1(global);
|
|
254
|
+
const project = await loadConfig(configPath);
|
|
255
|
+
const warnings = collectConstructWarnings(project);
|
|
256
|
+
return {
|
|
257
|
+
configPath,
|
|
258
|
+
project,
|
|
259
|
+
graph: synthesize(project),
|
|
260
|
+
warnings
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Detect the package manager used by the project at `root`.
|
|
265
|
+
* Falls back to `npm` when no lockfile or packageManager field is found.
|
|
266
|
+
*/
|
|
267
|
+
function detectPackageManager$1(root) {
|
|
268
|
+
const fromLockfile = detectFromLockfile(root);
|
|
269
|
+
if (fromLockfile) return fromLockfile;
|
|
270
|
+
const fromPkg = detectFromPackageJson(root);
|
|
271
|
+
if (fromPkg) return fromPkg;
|
|
272
|
+
return "npm";
|
|
273
|
+
}
|
|
274
|
+
function detectFromLockfile(root) {
|
|
275
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
|
|
276
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
277
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
278
|
+
if (existsSync(join(root, "package-lock.json"))) return "npm";
|
|
279
|
+
}
|
|
280
|
+
function detectFromPackageJson(root) {
|
|
281
|
+
const pkgPath = join(root, "package.json");
|
|
282
|
+
if (!existsSync(pkgPath)) return void 0;
|
|
283
|
+
try {
|
|
284
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
285
|
+
if (typeof pkg.packageManager === "string") return pmFromPackageManagerField(pkg.packageManager);
|
|
286
|
+
} catch {}
|
|
287
|
+
}
|
|
288
|
+
function pmFromPackageManagerField(packageManager) {
|
|
289
|
+
if (packageManager.startsWith("npm")) return "npm";
|
|
290
|
+
if (packageManager.startsWith("pnpm")) return "pnpm";
|
|
291
|
+
if (packageManager.startsWith("yarn")) return "yarn";
|
|
292
|
+
if (packageManager.startsWith("bun")) return "bun";
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Ensure `@sverka/workflow` is declared as a devDependency of the target
|
|
296
|
+
* project. Creates a minimal package.json when one does not exist.
|
|
297
|
+
*/
|
|
298
|
+
async function ensureConstructsDependency(root) {
|
|
299
|
+
const pkgPath = join(root, "package.json");
|
|
300
|
+
const base = await loadPackageBase(pkgPath);
|
|
301
|
+
const pkg = {
|
|
302
|
+
name: base.name ?? "sverka-project",
|
|
303
|
+
version: base.version ?? "0.0.0",
|
|
304
|
+
...base
|
|
305
|
+
};
|
|
306
|
+
const declared = ensureConstructsDeclared(pkg, root);
|
|
307
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
308
|
+
return declared;
|
|
309
|
+
}
|
|
310
|
+
async function loadPackageBase(pkgPath) {
|
|
311
|
+
try {
|
|
312
|
+
return JSON.parse(await readFile(pkgPath, "utf8"));
|
|
313
|
+
} catch (e) {
|
|
314
|
+
if (e.code === "ENOENT") return {
|
|
315
|
+
name: "sverka-project",
|
|
316
|
+
version: "0.0.0"
|
|
317
|
+
};
|
|
318
|
+
throw new CliError(`failed to read package.json: ${e instanceof Error ? e.message : String(e)}`, "PACKAGE_ERROR", ExitCode.RuntimeError, e);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Dependency names that were renamed to @sverka/workflow. */
|
|
322
|
+
const LEGACY_DEP_NAMES = ["@sverka/constructs", "@sverka/cdk"];
|
|
323
|
+
/** Drop renamed legacy package entries from dependencies/devDependencies. */
|
|
324
|
+
function removeLegacyDeps(pkg) {
|
|
325
|
+
for (const key of ["dependencies", "devDependencies"]) {
|
|
326
|
+
const deps = pkg[key];
|
|
327
|
+
if (deps === void 0) continue;
|
|
328
|
+
for (const name of LEGACY_DEP_NAMES) delete deps[name];
|
|
329
|
+
if (Object.keys(deps).length === 0) delete pkg[key];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function ensureConstructsDeclared(pkg, root) {
|
|
333
|
+
removeLegacyDeps(pkg);
|
|
334
|
+
const deps = pkg.dependencies ?? {};
|
|
335
|
+
const devDeps = pkg.devDependencies ?? {};
|
|
336
|
+
const existing = deps["@sverka/workflow"] ?? devDeps["@sverka/workflow"];
|
|
337
|
+
if (typeof existing === "string") return existing;
|
|
338
|
+
if (existing !== void 0) {
|
|
339
|
+
delete deps["@sverka/workflow"];
|
|
340
|
+
delete devDeps["@sverka/workflow"];
|
|
341
|
+
}
|
|
342
|
+
const spec = constructsDepSpec(root);
|
|
343
|
+
if (spec === null) return null;
|
|
344
|
+
pkg.devDependencies = {
|
|
345
|
+
...devDeps,
|
|
346
|
+
"@sverka/workflow": spec
|
|
347
|
+
};
|
|
348
|
+
return spec;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Dependency spec for @sverka/workflow in the target project.
|
|
352
|
+
*
|
|
353
|
+
* - `workspace:*` inside the sverka monorepo itself.
|
|
354
|
+
* - `^<version>` when the package resolves from a node_modules tree (a real
|
|
355
|
+
* registry install).
|
|
356
|
+
* - `link:<dir>` (or `file:<dir>` for npm, which rejects the `link:`
|
|
357
|
+
* protocol) when it resolves to a bare checkout — a semver spec would
|
|
358
|
+
* fail to install when the version was never published.
|
|
359
|
+
* - null when the package cannot be resolved at all — nothing is declared,
|
|
360
|
+
* the caller warns instead of writing an uninstallable spec.
|
|
361
|
+
*/
|
|
362
|
+
function constructsDepSpec(root) {
|
|
363
|
+
if (isLocalWorkspace(root)) return "workspace:*";
|
|
364
|
+
const resolved = resolveConstructsPackage();
|
|
365
|
+
if (resolved === null) return null;
|
|
366
|
+
if (resolved.dir.includes(`${sep}node_modules${sep}`)) return `^${resolved.version}`;
|
|
367
|
+
return `${detectPackageManager$1(root) === "npm" ? "file:" : "link:"}${resolved.dir}`;
|
|
368
|
+
}
|
|
369
|
+
/** Resolve the @sverka/workflow package directory and version from the CLI's context. */
|
|
370
|
+
function resolveConstructsPackage() {
|
|
371
|
+
let entry;
|
|
372
|
+
try {
|
|
373
|
+
entry = fileURLToPath(import.meta.resolve("@sverka/workflow"));
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
let dir = dirname(entry);
|
|
378
|
+
while (true) {
|
|
379
|
+
const pkgPath = join(dir, "package.json");
|
|
380
|
+
if (existsSync(pkgPath)) try {
|
|
381
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
382
|
+
if (pkg.name === "@sverka/workflow") return {
|
|
383
|
+
dir,
|
|
384
|
+
version: pkg.version ?? "0.0.0"
|
|
385
|
+
};
|
|
386
|
+
} catch {}
|
|
387
|
+
const parent = dirname(dir);
|
|
388
|
+
if (parent === dir) return null;
|
|
389
|
+
dir = parent;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function isLocalWorkspace(root) {
|
|
393
|
+
const rootPkg = join(root, "package.json");
|
|
394
|
+
const constructsPkg = join(root, "packages", "workflow", "package.json");
|
|
395
|
+
try {
|
|
396
|
+
const rootData = JSON.parse(readFileSync(rootPkg, "utf8"));
|
|
397
|
+
if (JSON.parse(readFileSync(constructsPkg, "utf8")).name !== "@sverka/workflow") return false;
|
|
398
|
+
return (Array.isArray(rootData.workspaces) ? rootData.workspaces : rootData.workspaces?.packages ?? []).some((pattern) => pattern.startsWith("packages"));
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/internal/detect.ts
|
|
405
|
+
/** package.json script names that map to pipeline steps. */
|
|
406
|
+
const DETECT_SCRIPT_CHECKS = [
|
|
407
|
+
"lint",
|
|
408
|
+
"typecheck",
|
|
409
|
+
"test",
|
|
410
|
+
"build",
|
|
411
|
+
"format",
|
|
412
|
+
"check"
|
|
413
|
+
];
|
|
414
|
+
/**
|
|
415
|
+
* Read package.json scripts under root. Returns an empty map when absent or
|
|
416
|
+
* malformed. Ground truth for JS/TS projects.
|
|
417
|
+
*/
|
|
418
|
+
function readPackageScripts(root) {
|
|
419
|
+
try {
|
|
420
|
+
const scripts = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts ?? {};
|
|
421
|
+
const out = {};
|
|
422
|
+
for (const [name, value] of Object.entries(scripts)) if (typeof value === "string" && value.length > 0) out[name] = value;
|
|
423
|
+
return out;
|
|
424
|
+
} catch {
|
|
425
|
+
return {};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* True when a command is a package-manager script invocation
|
|
430
|
+
* (`<pm> run <script>`) whose script does not exist — a phantom step that
|
|
431
|
+
* would fail with "Missing script" on first run.
|
|
432
|
+
*/
|
|
433
|
+
function isPhantomScriptStep(command, scripts) {
|
|
434
|
+
const m = /^(?:bun|npm|pnpm|yarn|deno)\s+run\s+([A-Za-z0-9:_-]+)/.exec(command.trim());
|
|
435
|
+
if (m === null) return false;
|
|
436
|
+
return !(m[1] in scripts);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Detect checks for the project at `root`: planner proposals (filtered to
|
|
440
|
+
* real scripts) plus any known check scripts the planner did not cover.
|
|
441
|
+
*/
|
|
442
|
+
async function detectProjectChecks(root) {
|
|
443
|
+
const pm = detectPackageManager$1(root);
|
|
444
|
+
const scripts = readPackageScripts(root);
|
|
445
|
+
const checks = [];
|
|
446
|
+
try {
|
|
447
|
+
const planner = createPlanner();
|
|
448
|
+
const ctx = await planner.discover({
|
|
449
|
+
root,
|
|
450
|
+
scopeToRoot: true
|
|
451
|
+
});
|
|
452
|
+
const proposal = await planner.plan(ctx);
|
|
453
|
+
if (proposal.checks.length > 0) {
|
|
454
|
+
const resolver = createBuiltinResolver();
|
|
455
|
+
const resolved = synthesizeCheckSteps(proposal.checks, ctx, resolver);
|
|
456
|
+
for (const r of resolved) {
|
|
457
|
+
const shellOp = r.step.operations.find((o) => o.kind === "shell");
|
|
458
|
+
const command = shellOp?.kind === "shell" ? shellOp.command : "";
|
|
459
|
+
if (isPhantomScriptStep(command, scripts)) continue;
|
|
460
|
+
const sarifOut = r.outputs.find((o) => o.format === "sarif");
|
|
461
|
+
checks.push({
|
|
462
|
+
checkId: r.checkId,
|
|
463
|
+
command,
|
|
464
|
+
...sarifOut !== void 0 ? { sarifOutput: sarifOut.path } : {},
|
|
465
|
+
source: "planner"
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch {}
|
|
470
|
+
for (const name of DETECT_SCRIPT_CHECKS) {
|
|
471
|
+
if (!(name in scripts)) continue;
|
|
472
|
+
if (checks.some((c) => c.checkId === name)) continue;
|
|
473
|
+
checks.push({
|
|
474
|
+
checkId: name,
|
|
475
|
+
command: `${pm} run ${name}`,
|
|
476
|
+
source: "script"
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return checks;
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/commands/init.ts
|
|
483
|
+
function buildMinimalTemplate(pm) {
|
|
484
|
+
return [
|
|
485
|
+
"import { Project, Pipeline, ShellStep, Entry } from \"@sverka/workflow\";",
|
|
486
|
+
"",
|
|
487
|
+
"const proj = new Project(\"verify\");",
|
|
488
|
+
"const ci = new Pipeline(proj, \"ci\");",
|
|
489
|
+
`new ShellStep(ci, "lint", { command: "${pm} run lint" });`,
|
|
490
|
+
`new ShellStep(ci, "typecheck", { command: "${pm} run typecheck" });`,
|
|
491
|
+
`new ShellStep(ci, "test", { command: "${pm} run test" });`,
|
|
492
|
+
"new Entry(ci, \"on-push\", { trigger: { kind: \"push\" }, roots: [\"lint\", \"typecheck\", \"test\"] });",
|
|
493
|
+
"",
|
|
494
|
+
"export default proj;",
|
|
495
|
+
""
|
|
496
|
+
].join("\n");
|
|
497
|
+
}
|
|
498
|
+
function buildFullTemplate(pm) {
|
|
499
|
+
return [
|
|
500
|
+
"import { Project, Pipeline, ShellStep, Entry } from \"@sverka/workflow\";",
|
|
501
|
+
"",
|
|
502
|
+
"const proj = new Project(\"verify\");",
|
|
503
|
+
"const ci = new Pipeline(proj, \"ci\");",
|
|
504
|
+
`new ShellStep(ci, "lint", { command: "${pm} run lint" });`,
|
|
505
|
+
`new ShellStep(ci, "typecheck", { command: "${pm} run typecheck" });`,
|
|
506
|
+
`new ShellStep(ci, "test", { command: "${pm} run test" });`,
|
|
507
|
+
`new ShellStep(ci, "build", { command: "${pm} run build" });`,
|
|
508
|
+
"new Entry(ci, \"on-push\", { trigger: { kind: \"push\" }, roots: [\"lint\", \"typecheck\", \"test\", \"build\"] });",
|
|
509
|
+
"",
|
|
510
|
+
"export default proj;",
|
|
511
|
+
""
|
|
512
|
+
].join("\n");
|
|
513
|
+
}
|
|
514
|
+
/** Build a config from detected checks (planner + package.json scripts). */
|
|
515
|
+
async function buildDetectedTemplate(root) {
|
|
516
|
+
const checks = await detectProjectChecks(root);
|
|
517
|
+
if (checks.length === 0) return null;
|
|
518
|
+
const stepLines = checks.map((c) => {
|
|
519
|
+
const id = JSON.stringify(c.checkId);
|
|
520
|
+
const cmd = JSON.stringify(c.command);
|
|
521
|
+
if (c.sarifOutput !== void 0) return `new ShellStep(ci, ${id}, { command: ${cmd}, outputs: ${"{ " + JSON.stringify(c.sarifOutput) + ": { type: \"artifact\", fromStdout: true } }"} });`;
|
|
522
|
+
return `new ShellStep(ci, ${id}, { command: ${cmd} });`;
|
|
523
|
+
});
|
|
524
|
+
const rootList = checks.map((c) => JSON.stringify(c.checkId)).join(", ");
|
|
525
|
+
return [
|
|
526
|
+
"import { Project, Pipeline, ShellStep, Entry, push } from \"@sverka/workflow\";",
|
|
527
|
+
"",
|
|
528
|
+
"const proj = new Project(\"verify\");",
|
|
529
|
+
"const ci = new Pipeline(proj, \"ci\");",
|
|
530
|
+
...stepLines,
|
|
531
|
+
`new Entry(ci, "on-push", { trigger: push(), roots: [${rootList}] });`,
|
|
532
|
+
"",
|
|
533
|
+
"export default proj;",
|
|
534
|
+
""
|
|
535
|
+
].join("\n");
|
|
536
|
+
}
|
|
537
|
+
/** Resolve and validate the template name, returning its file content. */
|
|
538
|
+
function resolveTemplateContent(template, pm) {
|
|
539
|
+
if (template !== "minimal" && template !== "full") throw new CliError(`invalid template: ${template} (expected minimal|full)`, "INVALID_FLAG", ExitCode.UsageError);
|
|
540
|
+
return template === "full" ? buildFullTemplate(pm) : buildMinimalTemplate(pm);
|
|
541
|
+
}
|
|
542
|
+
/** Write the config file, creating parent directories and using exclusive create when not forcing. */
|
|
543
|
+
async function writeConfig(configPath, content, force) {
|
|
544
|
+
if (existsSync(configPath) && !force) throw new CliError(`config already exists: ${configPath} (use --force to overwrite)`, "CONFIG_EXISTS", ExitCode.UsageError);
|
|
545
|
+
const flags = force ? "utf8" : {
|
|
546
|
+
encoding: "utf8",
|
|
547
|
+
flag: "wx"
|
|
548
|
+
};
|
|
549
|
+
try {
|
|
550
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
551
|
+
await writeFile(configPath, content, flags);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
if (e instanceof Error && "code" in e && e.code === "EEXIST") throw new CliError(`config already exists: ${configPath} (use --force to overwrite)`, "CONFIG_EXISTS", ExitCode.UsageError, e);
|
|
554
|
+
throw e;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Emit the init result in the requested output format. */
|
|
558
|
+
function emitInitResult(output, format, configPath, template, start) {
|
|
559
|
+
if (format === "json") output.writeLine(JSON.stringify({
|
|
560
|
+
command: "init",
|
|
561
|
+
data: {
|
|
562
|
+
path: configPath,
|
|
563
|
+
template
|
|
564
|
+
},
|
|
565
|
+
durationMs: Date.now() - start
|
|
566
|
+
}));
|
|
567
|
+
else output.writeLine(`Created ${configPath} (template: ${template})`);
|
|
568
|
+
}
|
|
569
|
+
/** Resolve the config path relative to root, honoring an explicit --config. */
|
|
570
|
+
function resolveConfigPath(root, config) {
|
|
571
|
+
const selected = config ?? "sverka.config.ts";
|
|
572
|
+
return isAbsolute(selected) ? selected : join(root, selected);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Create a sverka.config.ts in the root directory.
|
|
576
|
+
*/
|
|
577
|
+
async function initCommand(args, global, output, start) {
|
|
578
|
+
const configPath = resolveConfigPath(global.root, global.config);
|
|
579
|
+
if (existsSync(configPath) && !args.force) throw new CliError(`config already exists: ${configPath} (use --force to overwrite)`, "CONFIG_EXISTS", ExitCode.UsageError);
|
|
580
|
+
let content;
|
|
581
|
+
let template;
|
|
582
|
+
if (args.detect) {
|
|
583
|
+
await mkdir(global.root, { recursive: true });
|
|
584
|
+
output.debug(`init: root=${global.root} detect=true force=${Boolean(args.force)}`);
|
|
585
|
+
const detected = await buildDetectedTemplate(global.root);
|
|
586
|
+
if (detected !== null) {
|
|
587
|
+
content = detected;
|
|
588
|
+
template = "detect";
|
|
589
|
+
} else {
|
|
590
|
+
output.debug("init: detection found no checks, falling back to minimal template");
|
|
591
|
+
content = buildMinimalTemplate(detectPackageManager$1(global.root));
|
|
592
|
+
template = "minimal";
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
template = args.template ?? "minimal";
|
|
596
|
+
const pm = detectPackageManager$1(global.root);
|
|
597
|
+
output.debug(`init: root=${global.root} template=${template} pm=${pm} force=${Boolean(args.force)}`);
|
|
598
|
+
content = resolveTemplateContent(template, pm);
|
|
599
|
+
await mkdir(global.root, { recursive: true });
|
|
600
|
+
}
|
|
601
|
+
if (await ensureConstructsDependency(global.root) === null) output.errorLine("note: could not resolve @sverka/workflow — the generated config imports it; install it (e.g. bun add -d @sverka/workflow) before running sverka");
|
|
602
|
+
await writeConfig(configPath, content, Boolean(args.force));
|
|
603
|
+
emitInitResult(output, global.format, configPath, template, start);
|
|
604
|
+
return ExitCode.Success;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/commands/validate.ts
|
|
608
|
+
/**
|
|
609
|
+
* Validate a sverka config: synthesize the Definition Graph and run validators.
|
|
610
|
+
* Exit 0 if valid, 2 if missing config, 3 if load/synthesis fails.
|
|
611
|
+
*/
|
|
612
|
+
async function validateCommand(global, output, start) {
|
|
613
|
+
output.debug(`validate: root=${global.root} config=${global.config ?? "(auto)"}`);
|
|
614
|
+
const { configPath, graph, warnings } = await loadProjectGraph(global);
|
|
615
|
+
validateGraph(graph);
|
|
616
|
+
const durationMs = Date.now() - start;
|
|
617
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
618
|
+
command: "validate",
|
|
619
|
+
data: {
|
|
620
|
+
path: configPath,
|
|
621
|
+
valid: true,
|
|
622
|
+
pipelines: graph.project.pipelines.length,
|
|
623
|
+
warnings
|
|
624
|
+
},
|
|
625
|
+
durationMs
|
|
626
|
+
}));
|
|
627
|
+
else {
|
|
628
|
+
for (const warning of warnings) output.errorLine(`warning: ${warning}`);
|
|
629
|
+
output.writeLine(`Config valid: ${configPath}`);
|
|
630
|
+
output.writeLine(` pipelines: ${graph.project.pipelines.length}`);
|
|
631
|
+
for (const pipeline of graph.project.pipelines) output.writeLine(` ${pipeline.id}: ${pipeline.steps.length} steps, ${pipeline.entries.length} entries`);
|
|
632
|
+
}
|
|
633
|
+
return ExitCode.Success;
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/internal/graph.ts
|
|
637
|
+
/**
|
|
638
|
+
* Return the id of the first entry found across all pipelines.
|
|
639
|
+
* Returns `undefined` when the graph contains no entries.
|
|
640
|
+
*/
|
|
641
|
+
function resolveDefaultEntryId(graph) {
|
|
642
|
+
for (const pipeline of graph.project.pipelines) {
|
|
643
|
+
const entry = pipeline.entries[0];
|
|
644
|
+
if (entry) return entry.id;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Return whether an entry with the given id exists in the graph.
|
|
649
|
+
*/
|
|
650
|
+
function entryExists(graph, entryId) {
|
|
651
|
+
for (const pipeline of graph.project.pipelines) for (const entry of pipeline.entries) if (entry.id === entryId) return true;
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/commands/plan.ts
|
|
656
|
+
/**
|
|
657
|
+
* Bind an Entry and Inputs into a Run Plan and display it.
|
|
658
|
+
*/
|
|
659
|
+
async function planCommand(args, global, output, start) {
|
|
660
|
+
output.debug(`plan: root=${global.root} entry=${args.entryId ?? "(first)"}`);
|
|
661
|
+
const { graph } = await loadProjectGraph(global);
|
|
662
|
+
const entryId = args.entryId ?? resolveDefaultEntryId(graph);
|
|
663
|
+
if (!entryId) throw new CliError("no entries in graph", "MISSING_ARG", ExitCode.UsageError);
|
|
664
|
+
if (!entryExists(graph, entryId)) throw new CliError(`entry "${entryId}" not found in graph`, "MISSING_ARG", ExitCode.UsageError);
|
|
665
|
+
const plan = bindRunPlan({
|
|
666
|
+
graph,
|
|
667
|
+
entryId
|
|
668
|
+
});
|
|
669
|
+
const durationMs = Date.now() - start;
|
|
670
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
671
|
+
command: "plan",
|
|
672
|
+
data: {
|
|
673
|
+
id: plan.id,
|
|
674
|
+
graphId: plan.graphId,
|
|
675
|
+
entry: plan.entry,
|
|
676
|
+
steps: plan.steps.map((s) => s.id),
|
|
677
|
+
inputs: plan.inputs
|
|
678
|
+
},
|
|
679
|
+
durationMs
|
|
680
|
+
}));
|
|
681
|
+
else {
|
|
682
|
+
output.writeLine(`Run Plan: ${plan.id}`);
|
|
683
|
+
output.writeLine(` entry: ${plan.entry.id}`);
|
|
684
|
+
output.writeLine(` steps: ${plan.steps.length}`);
|
|
685
|
+
for (const step of plan.steps) {
|
|
686
|
+
const deps = step.dependencies.length > 0 ? ` (depends: ${step.dependencies.map((d) => d.producer).join(", ")})` : "";
|
|
687
|
+
output.writeLine(` - ${step.id}${deps}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return ExitCode.Success;
|
|
691
|
+
}
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region src/commands/graph.ts
|
|
694
|
+
/**
|
|
695
|
+
* Display the Definition Graph: pipelines, entries, steps, and dependencies.
|
|
696
|
+
*/
|
|
697
|
+
async function graphCommand(global, output, start) {
|
|
698
|
+
output.debug(`graph: root=${global.root}`);
|
|
699
|
+
const { graph } = await loadProjectGraph(global);
|
|
700
|
+
const durationMs = Date.now() - start;
|
|
701
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
702
|
+
command: "graph",
|
|
703
|
+
data: graph,
|
|
704
|
+
durationMs
|
|
705
|
+
}));
|
|
706
|
+
else {
|
|
707
|
+
output.writeLine(`Definition Graph: ${graph.project.id}`);
|
|
708
|
+
for (const pipeline of graph.project.pipelines) {
|
|
709
|
+
output.writeLine(` Pipeline: ${pipeline.id}`);
|
|
710
|
+
output.writeLine(` entries:`);
|
|
711
|
+
for (const entry of pipeline.entries) output.writeLine(` ${entry.id} (trigger: ${entry.trigger.kind}, roots: ${entry.roots.join(", ")})`);
|
|
712
|
+
output.writeLine(` steps:`);
|
|
713
|
+
for (const step of pipeline.steps) {
|
|
714
|
+
const deps = step.dependencies.length > 0 ? ` → [${step.dependencies.map((d) => `${d.kind}:${d.producer}`).join(", ")}]` : "";
|
|
715
|
+
output.writeLine(` ${step.id}${deps}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return ExitCode.Success;
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/internal/runtime-check.ts
|
|
723
|
+
/**
|
|
724
|
+
* Check if a binary is available on PATH by spawning `<binary> --version`.
|
|
725
|
+
* Returns true if the binary exists and exits 0, false otherwise.
|
|
726
|
+
*/
|
|
727
|
+
function isBinaryAvailable(binary) {
|
|
728
|
+
const result = spawnSync(binary, ["--version"], {
|
|
729
|
+
stdio: [
|
|
730
|
+
"ignore",
|
|
731
|
+
"pipe",
|
|
732
|
+
"ignore"
|
|
733
|
+
],
|
|
734
|
+
encoding: "utf8",
|
|
735
|
+
timeout: 5e3
|
|
736
|
+
});
|
|
737
|
+
return result.status === 0 && result.error === void 0;
|
|
738
|
+
}
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/internal/report-context.ts
|
|
741
|
+
function git(root, ...args) {
|
|
742
|
+
const res = spawnSync("git", args, {
|
|
743
|
+
cwd: root,
|
|
744
|
+
encoding: "utf-8",
|
|
745
|
+
timeout: 2e3
|
|
746
|
+
});
|
|
747
|
+
return (res.status === 0 ? res.stdout?.trim() : void 0) || void 0;
|
|
748
|
+
}
|
|
749
|
+
/** Normalize a git remote URL (ssh or https) to a clickable web URL.
|
|
750
|
+
* Credentials embedded in https remotes (user:token@host) are stripped. */
|
|
751
|
+
function repoWebUrl(remote) {
|
|
752
|
+
if (!remote) return void 0;
|
|
753
|
+
const ssh = /^git@([^:]+):(.+?)(\.git)?$/.exec(remote);
|
|
754
|
+
if (ssh) return `https://${ssh[1]}/${ssh[2]}`;
|
|
755
|
+
try {
|
|
756
|
+
const url = new URL(remote);
|
|
757
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
758
|
+
url.username = "";
|
|
759
|
+
url.password = "";
|
|
760
|
+
url.pathname = url.pathname.replace(/\.git$/, "");
|
|
761
|
+
return url.toString().replace(/\/$/, "");
|
|
762
|
+
} catch {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function collectLinks(repo, commit) {
|
|
767
|
+
const links = [];
|
|
768
|
+
if (repo) links.push({
|
|
769
|
+
label: "Repo",
|
|
770
|
+
url: repo
|
|
771
|
+
});
|
|
772
|
+
if (repo && commit) links.push({
|
|
773
|
+
label: "Commit",
|
|
774
|
+
url: `${repo}/commit/${commit}`
|
|
775
|
+
});
|
|
776
|
+
if (repo && process$1.env.GITHUB_RUN_ID) links.push({
|
|
777
|
+
label: "CI run",
|
|
778
|
+
url: `${repo}/actions/runs/${process$1.env.GITHUB_RUN_ID}`
|
|
779
|
+
});
|
|
780
|
+
return links;
|
|
781
|
+
}
|
|
782
|
+
function collectReportContext(root, command) {
|
|
783
|
+
const env = process$1.env;
|
|
784
|
+
const repo = (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}` : void 0) ?? repoWebUrl(git(root, "remote", "get-url", "origin"));
|
|
785
|
+
const commit = env.GITHUB_SHA ?? git(root, "rev-parse", "HEAD");
|
|
786
|
+
const branch = env.GITHUB_REF_NAME ?? git(root, "rev-parse", "--abbrev-ref", "HEAD");
|
|
787
|
+
const meta = [...branch ? [{
|
|
788
|
+
label: "Branch",
|
|
789
|
+
value: branch
|
|
790
|
+
}] : [], {
|
|
791
|
+
label: "Directory",
|
|
792
|
+
value: root
|
|
793
|
+
}];
|
|
794
|
+
return {
|
|
795
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
796
|
+
command,
|
|
797
|
+
meta,
|
|
798
|
+
links: collectLinks(repo, commit)
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/commands/run.ts
|
|
803
|
+
/**
|
|
804
|
+
* Execute a Run Plan through the native engine and print events.
|
|
805
|
+
*/
|
|
806
|
+
async function runCommand(args, global, output, start) {
|
|
807
|
+
const executor = args.executor ?? "host";
|
|
808
|
+
const isSarif = global.format === "sarif";
|
|
809
|
+
const isWeb = global.format === "web";
|
|
810
|
+
const isHtml = global.format === "html" || args.output !== void 0 && !isSarif && !isWeb;
|
|
811
|
+
const evaluate = args.evaluate || isHtml || isSarif || isWeb;
|
|
812
|
+
output.debug(`run: root=${global.root} executor=${executor} entry=${args.entryId ?? "(first)"} format=${global.format}`);
|
|
813
|
+
assertExecutorAvailable(executor);
|
|
814
|
+
const { graph, warnings } = await loadProjectGraph(global);
|
|
815
|
+
for (const warning of warnings) output.errorLine(`warning: ${warning}`);
|
|
816
|
+
const entryId = resolveEntryId(graph, args.entryId);
|
|
817
|
+
const plan = bindRunPlan({
|
|
818
|
+
graph,
|
|
819
|
+
entryId
|
|
820
|
+
});
|
|
821
|
+
const workspace = global.root;
|
|
822
|
+
const artifactDir = join(global.root, ".sverka", "artifacts");
|
|
823
|
+
if (args.jobs !== void 0 && (!Number.isInteger(args.jobs) || args.jobs < 1 || args.jobs > 64)) throw new CliError("--jobs must be an integer in [1, 64]", "INVALID_FLAG", ExitCode.UsageError);
|
|
824
|
+
const { events, runStatus, renderer } = await consumeEvents(createEngine({
|
|
825
|
+
drivers: buildDrivers(executor),
|
|
826
|
+
maxConcurrent: args.jobs ?? 4
|
|
827
|
+
}), plan, {
|
|
828
|
+
workspace,
|
|
829
|
+
artifactDir
|
|
830
|
+
}, global, output, graph, args);
|
|
831
|
+
const durationMs = Date.now() - start;
|
|
832
|
+
let policyExitCode = 0;
|
|
833
|
+
let evalResult = null;
|
|
834
|
+
let collectionFailed = false;
|
|
835
|
+
if (evaluate) {
|
|
836
|
+
const result = await runEvaluation(artifactDir, global, output, events, renderer, args);
|
|
837
|
+
policyExitCode = result.exitCode;
|
|
838
|
+
evalResult = result.summary;
|
|
839
|
+
collectionFailed = result.summary === null && result.exitCode === ExitCode.RuntimeError;
|
|
840
|
+
}
|
|
841
|
+
renderer?.flush();
|
|
842
|
+
if (isHtml && renderer) {
|
|
843
|
+
const reportPath = args.output ?? join(global.root, ".sverka", "report.html");
|
|
844
|
+
output.writeLine(`Wrote HTML report to ${reportPath}`);
|
|
845
|
+
}
|
|
846
|
+
if (renderer && "waitUntilExit" in renderer) await renderer.waitUntilExit();
|
|
847
|
+
if (collectionFailed) return policyExitCode;
|
|
848
|
+
writeRunOutput(plan.id, runStatus, events, durationMs, global, output, evalResult);
|
|
849
|
+
if (evaluate && policyExitCode !== 0) return policyExitCode;
|
|
850
|
+
return exitCodeForStatus(runStatus);
|
|
851
|
+
}
|
|
852
|
+
function assertExecutorAvailable(executor) {
|
|
853
|
+
if (executor === "docker" && !isBinaryAvailable("docker")) throw new CliError("docker executor not available (docker not found on PATH)", "RUNTIME_NOT_AVAILABLE", ExitCode.RuntimeError);
|
|
854
|
+
}
|
|
855
|
+
function resolveEntryId(graph, entryId) {
|
|
856
|
+
const resolved = entryId ?? resolveDefaultEntryId(graph);
|
|
857
|
+
if (!resolved) throw new CliError("no entries in graph", "MISSING_ARG", ExitCode.UsageError);
|
|
858
|
+
if (!entryExists(graph, resolved)) throw new CliError(`entry "${resolved}" not found in graph`, "MISSING_ARG", ExitCode.UsageError);
|
|
859
|
+
return resolved;
|
|
860
|
+
}
|
|
861
|
+
function buildDrivers(executor) {
|
|
862
|
+
const drivers = [createHostDriver({
|
|
863
|
+
enabled: true,
|
|
864
|
+
allowlist: {
|
|
865
|
+
entries: [],
|
|
866
|
+
isAllowed: () => true
|
|
867
|
+
},
|
|
868
|
+
envAllowlist: [
|
|
869
|
+
"PATH",
|
|
870
|
+
"HOME",
|
|
871
|
+
"USER",
|
|
872
|
+
"SHELL",
|
|
873
|
+
"TMPDIR",
|
|
874
|
+
"XDG_CONFIG_HOME",
|
|
875
|
+
"XDG_CACHE_HOME",
|
|
876
|
+
"XDG_DATA_HOME",
|
|
877
|
+
"XDG_STATE_HOME",
|
|
878
|
+
"XDG_RUNTIME_DIR"
|
|
879
|
+
]
|
|
880
|
+
})];
|
|
881
|
+
if (executor === "docker") drivers.push(createDockerDriver({}));
|
|
882
|
+
return drivers;
|
|
883
|
+
}
|
|
884
|
+
async function consumeEvents(engine, plan, ctx, global, output, graph, args) {
|
|
885
|
+
const events = [];
|
|
886
|
+
let runStatus = "failure";
|
|
887
|
+
const { createTextRenderer, createHtmlRenderer, createInkRenderer } = await import("@sverka/reporter");
|
|
888
|
+
let renderer = null;
|
|
889
|
+
const isSarif = global.format === "sarif";
|
|
890
|
+
const isWeb = global.format === "web";
|
|
891
|
+
const isHtml = global.format === "html" || args.output !== void 0 && !isSarif && !isWeb;
|
|
892
|
+
const tuiDenied = args.tui === false || args.formatExplicit === true;
|
|
893
|
+
if (!isHtml && !isSarif && !isWeb && global.format === "text" && !tuiDenied && (args.tui === true || process$1.stdout.isTTY === true)) try {
|
|
894
|
+
renderer = createInkRenderer({ graph });
|
|
895
|
+
} catch {
|
|
896
|
+
renderer = createTextRenderer({ writer: output });
|
|
897
|
+
}
|
|
898
|
+
else if (global.format === "text" && !isHtml) renderer = createTextRenderer({ writer: output });
|
|
899
|
+
else if (isHtml) renderer = createHtmlRenderer({
|
|
900
|
+
outputPath: args.output ?? join(global.root, ".sverka", "report.html"),
|
|
901
|
+
graph,
|
|
902
|
+
context: collectReportContext(global.root, `sverka ${process$1.argv.slice(2).join(" ")}`)
|
|
903
|
+
});
|
|
904
|
+
for await (const event of engine.run({
|
|
905
|
+
plan,
|
|
906
|
+
workspace: ctx.workspace,
|
|
907
|
+
artifactDir: ctx.artifactDir
|
|
908
|
+
})) {
|
|
909
|
+
events.push(event);
|
|
910
|
+
renderer?.onEvent(event);
|
|
911
|
+
if (event.type === "run-completed") runStatus = event.status;
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
events,
|
|
915
|
+
runStatus,
|
|
916
|
+
renderer
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
async function runEvaluation(artifactDir, global, output, _events, renderer, args) {
|
|
920
|
+
const { collectFindings, evaluateGate, ReporterError } = await import("@sverka/reporter");
|
|
921
|
+
let rows;
|
|
922
|
+
try {
|
|
923
|
+
rows = await collectFindings({ artifactDir });
|
|
924
|
+
} catch (e) {
|
|
925
|
+
if (e instanceof ReporterError) {
|
|
926
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
927
|
+
command: "run",
|
|
928
|
+
error: "COLLECTION_FAILED",
|
|
929
|
+
message: e.message
|
|
930
|
+
}));
|
|
931
|
+
else output.writeLine(`Collection failed: ${e.message}`);
|
|
932
|
+
return {
|
|
933
|
+
exitCode: ExitCode.RuntimeError,
|
|
934
|
+
summary: null
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
throw e;
|
|
938
|
+
}
|
|
939
|
+
const findings = rows.map((r) => r.finding);
|
|
940
|
+
const { result, exitCode } = evaluateGate({ findings });
|
|
941
|
+
if (renderer) {
|
|
942
|
+
renderer.onFindings(findings);
|
|
943
|
+
renderer.onVerdict(result);
|
|
944
|
+
renderer.flush();
|
|
945
|
+
}
|
|
946
|
+
if (global.format === "sarif") {
|
|
947
|
+
const sarifPath = args.output ?? join(global.root, ".sverka", "findings.sarif");
|
|
948
|
+
const sarifLog = serializeSarif(findings);
|
|
949
|
+
mkdirSync(dirname(sarifPath), { recursive: true });
|
|
950
|
+
writeFileSync(sarifPath, JSON.stringify(sarifLog, null, 2), "utf-8");
|
|
951
|
+
output.writeLine(`Wrote SARIF to ${sarifPath}`);
|
|
952
|
+
}
|
|
953
|
+
if (global.format === "web") {
|
|
954
|
+
const webPath = args.output ?? join(global.root, ".sverka", "report.html");
|
|
955
|
+
try {
|
|
956
|
+
const { generateSarifHtml } = await import("@sverka/sarif-viewer-web");
|
|
957
|
+
const html = generateSarifHtml(findings);
|
|
958
|
+
mkdirSync(dirname(webPath), { recursive: true });
|
|
959
|
+
writeFileSync(webPath, html, "utf-8");
|
|
960
|
+
output.writeLine(`Wrote HTML report to ${webPath}`);
|
|
961
|
+
} catch (e) {
|
|
962
|
+
output.errorLine(`sverka run: failed to generate web report: ${e instanceof Error ? e.message : String(e)}`);
|
|
963
|
+
return {
|
|
964
|
+
exitCode: 1,
|
|
965
|
+
summary: {
|
|
966
|
+
findings,
|
|
967
|
+
verdict: result.verdict,
|
|
968
|
+
summary: result.summary
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
exitCode,
|
|
975
|
+
summary: {
|
|
976
|
+
findings,
|
|
977
|
+
verdict: result.verdict,
|
|
978
|
+
summary: result.summary
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const SIMPLE_STEP_STATUSES = {
|
|
983
|
+
"step-skipped": "skipped",
|
|
984
|
+
"step-cancelled": "cancelled",
|
|
985
|
+
"step-suspended": "suspended"
|
|
986
|
+
};
|
|
987
|
+
/** Extract per-step results from the event stream for JSON output. */
|
|
988
|
+
function summarizeSteps(events) {
|
|
989
|
+
const steps = [];
|
|
990
|
+
for (const event of events) {
|
|
991
|
+
const step = summarizeStep(event);
|
|
992
|
+
if (step !== void 0) steps.push(step);
|
|
993
|
+
}
|
|
994
|
+
return steps;
|
|
995
|
+
}
|
|
996
|
+
function summarizeStep(event) {
|
|
997
|
+
if (event.type === "step-succeeded" || event.type === "step-failed") return {
|
|
998
|
+
stepId: event.stepId,
|
|
999
|
+
status: event.type === "step-succeeded" ? "succeeded" : "failed",
|
|
1000
|
+
durationMs: event.durationMs,
|
|
1001
|
+
...event.type === "step-failed" ? { error: event.error } : {},
|
|
1002
|
+
...capturedOutputFields(event)
|
|
1003
|
+
};
|
|
1004
|
+
if (event.type === "step-cache-hit") return {
|
|
1005
|
+
stepId: event.stepId,
|
|
1006
|
+
status: "cache-hit",
|
|
1007
|
+
cacheKey: event.key
|
|
1008
|
+
};
|
|
1009
|
+
const status = SIMPLE_STEP_STATUSES[event.type];
|
|
1010
|
+
if (status === void 0 || !("stepId" in event)) return void 0;
|
|
1011
|
+
return {
|
|
1012
|
+
stepId: event.stepId,
|
|
1013
|
+
status
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function capturedOutputFields(event) {
|
|
1017
|
+
return {
|
|
1018
|
+
...event.stdout !== void 0 ? { stdout: event.stdout } : {},
|
|
1019
|
+
...event.stderr !== void 0 ? { stderr: event.stderr } : {},
|
|
1020
|
+
...event.exitCode !== void 0 ? { exitCode: event.exitCode } : {}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function writeRunOutput(planId, runStatus, events, durationMs, global, output, evalResult) {
|
|
1024
|
+
if (global.format === "json") {
|
|
1025
|
+
const steps = summarizeSteps(events);
|
|
1026
|
+
output.writeLine(JSON.stringify({
|
|
1027
|
+
command: "run",
|
|
1028
|
+
data: {
|
|
1029
|
+
planId,
|
|
1030
|
+
status: runStatus,
|
|
1031
|
+
steps,
|
|
1032
|
+
...evalResult ? {
|
|
1033
|
+
findings: evalResult.findings.length,
|
|
1034
|
+
verdict: evalResult.verdict,
|
|
1035
|
+
summary: evalResult.summary
|
|
1036
|
+
} : {}
|
|
1037
|
+
},
|
|
1038
|
+
durationMs
|
|
1039
|
+
}));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function exitCodeForStatus(runStatus) {
|
|
1043
|
+
if (runStatus === "success") return ExitCode.Success;
|
|
1044
|
+
if (runStatus === "failure") return ExitCode.PolicyFail;
|
|
1045
|
+
return ExitCode.RuntimeError;
|
|
1046
|
+
}
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/commands/discover.ts
|
|
1049
|
+
/**
|
|
1050
|
+
* Discover project context and display it.
|
|
1051
|
+
*/
|
|
1052
|
+
async function discoverCommand(global, output, start) {
|
|
1053
|
+
output.debug(`discover: root=${global.root}`);
|
|
1054
|
+
const ctx = await createPlanner().discover({ root: global.root });
|
|
1055
|
+
const durationMs = Date.now() - start;
|
|
1056
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1057
|
+
command: "discover",
|
|
1058
|
+
data: {
|
|
1059
|
+
root: ctx.root,
|
|
1060
|
+
commit: ctx.commit,
|
|
1061
|
+
dirty: ctx.dirty,
|
|
1062
|
+
languages: ctx.languages,
|
|
1063
|
+
packageManagers: ctx.packageManagers,
|
|
1064
|
+
hasContainerBuild: ctx.hasContainerBuild,
|
|
1065
|
+
hasCiDefinition: ctx.hasCiDefinition,
|
|
1066
|
+
monorepo: ctx.monorepo,
|
|
1067
|
+
changedFiles: ctx.changedFiles.length
|
|
1068
|
+
},
|
|
1069
|
+
durationMs
|
|
1070
|
+
}));
|
|
1071
|
+
else {
|
|
1072
|
+
output.writeLine(`Project: ${ctx.root}`);
|
|
1073
|
+
output.writeLine(` commit: ${ctx.commit || "(none)"}`);
|
|
1074
|
+
output.writeLine(` dirty: ${ctx.dirty}`);
|
|
1075
|
+
output.writeLine(` languages: ${ctx.languages.map((l) => l.name).join(", ") || "(none)"}`);
|
|
1076
|
+
output.writeLine(` package managers: ${ctx.packageManagers.map((p) => p.name).join(", ") || "(none)"}`);
|
|
1077
|
+
output.writeLine(` container build: ${ctx.hasContainerBuild}`);
|
|
1078
|
+
output.writeLine(` CI definition: ${ctx.hasCiDefinition}`);
|
|
1079
|
+
if (ctx.monorepo) output.writeLine(` monorepo: ${ctx.monorepo.tool} (${ctx.monorepo.workspaces.length} workspaces)`);
|
|
1080
|
+
output.writeLine(` changed files: ${ctx.changedFiles.length}`);
|
|
1081
|
+
}
|
|
1082
|
+
return ExitCode.Success;
|
|
1083
|
+
}
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/commands/check.ts
|
|
1086
|
+
/**
|
|
1087
|
+
* Discover project checks (planner + package.json scripts) and display the
|
|
1088
|
+
* steps they would become — the same detection `init --detect` uses.
|
|
1089
|
+
*/
|
|
1090
|
+
async function checkCommand(global, output, start) {
|
|
1091
|
+
output.debug(`check: root=${global.root}`);
|
|
1092
|
+
const checks = await detectProjectChecks(global.root);
|
|
1093
|
+
const durationMs = Date.now() - start;
|
|
1094
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1095
|
+
command: "check",
|
|
1096
|
+
data: {
|
|
1097
|
+
proposed: checks.map((c) => c.checkId),
|
|
1098
|
+
resolved: checks.map((c) => ({
|
|
1099
|
+
id: c.checkId,
|
|
1100
|
+
command: c.command
|
|
1101
|
+
}))
|
|
1102
|
+
},
|
|
1103
|
+
durationMs
|
|
1104
|
+
}));
|
|
1105
|
+
else {
|
|
1106
|
+
output.writeLine(`Proposed checks: ${checks.length}`);
|
|
1107
|
+
for (const c of checks) output.writeLine(` - ${c.checkId} (${c.source}): ${c.command}`);
|
|
1108
|
+
}
|
|
1109
|
+
return ExitCode.Success;
|
|
1110
|
+
}
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/commands/policy.ts
|
|
1113
|
+
/**
|
|
1114
|
+
* Evaluate findings against the default policy.
|
|
1115
|
+
* In v0, this is a placeholder — findings come from a baseline file
|
|
1116
|
+
* or are empty (no execution output to extract from yet).
|
|
1117
|
+
*/
|
|
1118
|
+
async function policyCommand(args, global, output, start) {
|
|
1119
|
+
output.debug(`policy: root=${global.root} findings=${args.findings} baseline=${args.baseline ?? "(none)"}`);
|
|
1120
|
+
const findings = normalizeFindings(await loadFindingsFile(resolveUnderRoot(global.root, args.findings)), {
|
|
1121
|
+
root: global.root,
|
|
1122
|
+
checkIdPrefix: "",
|
|
1123
|
+
defaultConfidence: .5
|
|
1124
|
+
});
|
|
1125
|
+
const baselineFingerprints = await loadBaselineFingerprints(global.root, args.baseline);
|
|
1126
|
+
const result = evaluatePolicy(findings, DEFAULT_POLICY, [...baselineFingerprints]);
|
|
1127
|
+
writePolicyOutput(result, global, output, Date.now() - start);
|
|
1128
|
+
return result.verdict === "pass" ? ExitCode.Success : ExitCode.PolicyFail;
|
|
1129
|
+
}
|
|
1130
|
+
async function loadFindingsFile(findingsPath) {
|
|
1131
|
+
if (!existsSync(findingsPath)) throw new CliError(`findings file not found: ${findingsPath}`, "MISSING_ARG", ExitCode.UsageError);
|
|
1132
|
+
try {
|
|
1133
|
+
return JSON.parse(await readFile(findingsPath, "utf8"));
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
throw new CliError(`failed to parse findings file: ${e instanceof Error ? e.message : String(e)}`, "SDK_ERROR", ExitCode.RuntimeError, e);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function normalizeFindings(raw, ctx) {
|
|
1139
|
+
try {
|
|
1140
|
+
return normalizeSarif(raw, ctx);
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
throw new CliError(`failed to normalize findings: ${e instanceof Error ? e.message : String(e)}`, "SDK_ERROR", ExitCode.RuntimeError, e);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async function loadBaselineFingerprints(root, baseline) {
|
|
1146
|
+
if (!baseline) return [];
|
|
1147
|
+
const baselinePath = resolveUnderRoot(root, baseline);
|
|
1148
|
+
try {
|
|
1149
|
+
return (await loadBaseline(baselinePath)).fingerprints;
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
throw new CliError(`failed to load baseline: ${e instanceof Error ? e.message : String(e)}`, "SDK_ERROR", ExitCode.RuntimeError, e);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function writePolicyOutput(result, global, output, durationMs) {
|
|
1155
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1156
|
+
command: "policy",
|
|
1157
|
+
data: {
|
|
1158
|
+
verdict: result.verdict,
|
|
1159
|
+
summary: result.summary,
|
|
1160
|
+
triggered: result.triggered.length
|
|
1161
|
+
},
|
|
1162
|
+
durationMs
|
|
1163
|
+
}));
|
|
1164
|
+
else {
|
|
1165
|
+
output.writeLine(`Policy: ${result.verdict}`);
|
|
1166
|
+
output.writeLine(` ${result.summary}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/internal/ci-setup.ts
|
|
1171
|
+
/**
|
|
1172
|
+
* Detect the toolchain setup a generated GitHub workflow needs on a clean
|
|
1173
|
+
* runner: the package-manager action + dependency install, and recursive
|
|
1174
|
+
* submodule checkout when the repo has .gitmodules.
|
|
1175
|
+
*
|
|
1176
|
+
* The declared `packageManager` version is honored where the toolchain
|
|
1177
|
+
* supports it: `bun-version` for bun, Corepack for pnpm/yarn (both read the
|
|
1178
|
+
* `packageManager` field), and an explicit global install for npm.
|
|
1179
|
+
*
|
|
1180
|
+
* Always emits `checkoutWith` with `persist-credentials: false` —
|
|
1181
|
+
* generated YAML never persists the GITHUB_TOKEN, even when nothing else
|
|
1182
|
+
* was detected.
|
|
1183
|
+
*/
|
|
1184
|
+
function detectCiSetup(root) {
|
|
1185
|
+
const setup = detectPackageManagerSetup(root);
|
|
1186
|
+
const checkoutWith = existsSync(join(root, ".gitmodules")) ? {
|
|
1187
|
+
"persist-credentials": false,
|
|
1188
|
+
submodules: "recursive"
|
|
1189
|
+
} : { "persist-credentials": false };
|
|
1190
|
+
return {
|
|
1191
|
+
...setup.length > 0 ? { setup } : {},
|
|
1192
|
+
checkoutWith
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
function detectPackageManagerSetup(root) {
|
|
1196
|
+
const { name, version } = detectPackageManager(root);
|
|
1197
|
+
if (name === "bun") return bunSteps(version);
|
|
1198
|
+
if (name === void 0) return [];
|
|
1199
|
+
const node = setupNodeStep(root);
|
|
1200
|
+
switch (name) {
|
|
1201
|
+
case "pnpm": return pnpmSteps(version, node);
|
|
1202
|
+
case "yarn": return yarnSteps(version, node);
|
|
1203
|
+
case "npm": return npmSteps(root, version, node);
|
|
1204
|
+
default: return [];
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/** setup-node step — pins Node from engines.node (a semver range), else lts. */
|
|
1208
|
+
function setupNodeStep(root) {
|
|
1209
|
+
return {
|
|
1210
|
+
name: "Setup Node",
|
|
1211
|
+
uses: "actions/setup-node@v7",
|
|
1212
|
+
with: { "node-version": nodeVersion(root) }
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function nodeVersion(root) {
|
|
1216
|
+
try {
|
|
1217
|
+
const raw = readFileSync(join(root, "package.json"), "utf8");
|
|
1218
|
+
const engines = JSON.parse(raw).engines?.node;
|
|
1219
|
+
if (typeof engines === "string" && engines.trim() !== "") return engines;
|
|
1220
|
+
} catch {}
|
|
1221
|
+
return "lts/*";
|
|
1222
|
+
}
|
|
1223
|
+
function bunSteps(version) {
|
|
1224
|
+
return [{
|
|
1225
|
+
name: "Setup Bun",
|
|
1226
|
+
uses: "oven-sh/setup-bun@v2",
|
|
1227
|
+
...version ? { with: { "bun-version": version } } : {}
|
|
1228
|
+
}, {
|
|
1229
|
+
name: "Install dependencies",
|
|
1230
|
+
run: "bun install --frozen-lockfile --ignore-scripts"
|
|
1231
|
+
}];
|
|
1232
|
+
}
|
|
1233
|
+
function pnpmSteps(version, node) {
|
|
1234
|
+
return [
|
|
1235
|
+
{
|
|
1236
|
+
name: "Setup pnpm",
|
|
1237
|
+
uses: "pnpm/action-setup@v4",
|
|
1238
|
+
...version ? {} : { with: { version: "latest" } }
|
|
1239
|
+
},
|
|
1240
|
+
node,
|
|
1241
|
+
{
|
|
1242
|
+
name: "Install dependencies",
|
|
1243
|
+
run: "pnpm install --frozen-lockfile --ignore-scripts"
|
|
1244
|
+
}
|
|
1245
|
+
];
|
|
1246
|
+
}
|
|
1247
|
+
function yarnSteps(version, node) {
|
|
1248
|
+
return [
|
|
1249
|
+
node,
|
|
1250
|
+
...version ? [{
|
|
1251
|
+
name: "Enable Corepack",
|
|
1252
|
+
run: "corepack enable"
|
|
1253
|
+
}] : [],
|
|
1254
|
+
{
|
|
1255
|
+
name: "Install dependencies",
|
|
1256
|
+
run: "yarn install"
|
|
1257
|
+
}
|
|
1258
|
+
];
|
|
1259
|
+
}
|
|
1260
|
+
function npmSteps(root, version, node) {
|
|
1261
|
+
return [
|
|
1262
|
+
node,
|
|
1263
|
+
...version ? [{
|
|
1264
|
+
name: "Pin npm",
|
|
1265
|
+
run: `npm install -g "npm@${version}"`
|
|
1266
|
+
}] : [],
|
|
1267
|
+
{
|
|
1268
|
+
name: "Install dependencies",
|
|
1269
|
+
run: existsSync(join(root, "package-lock.json")) ? "npm ci --ignore-scripts" : "npm install --ignore-scripts"
|
|
1270
|
+
}
|
|
1271
|
+
];
|
|
1272
|
+
}
|
|
1273
|
+
function detectPackageManager(root) {
|
|
1274
|
+
const declared = packageManagerField(root);
|
|
1275
|
+
if (declared) return declared;
|
|
1276
|
+
if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return { name: "bun" };
|
|
1277
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return { name: "pnpm" };
|
|
1278
|
+
if (existsSync(join(root, "yarn.lock"))) return { name: "yarn" };
|
|
1279
|
+
if (existsSync(join(root, "package-lock.json"))) return { name: "npm" };
|
|
1280
|
+
if (existsSync(join(root, "package.json"))) return { name: "npm" };
|
|
1281
|
+
return {};
|
|
1282
|
+
}
|
|
1283
|
+
function packageManagerField(root) {
|
|
1284
|
+
try {
|
|
1285
|
+
const raw = readFileSync(join(root, "package.json"), "utf8");
|
|
1286
|
+
const pm = JSON.parse(raw).packageManager;
|
|
1287
|
+
if (typeof pm !== "string") return void 0;
|
|
1288
|
+
const [name, version] = pm.split("@");
|
|
1289
|
+
if (name === "bun" || name === "pnpm" || name === "yarn" || name === "npm") return version !== void 0 ? {
|
|
1290
|
+
name,
|
|
1291
|
+
version
|
|
1292
|
+
} : { name };
|
|
1293
|
+
} catch {}
|
|
1294
|
+
}
|
|
1295
|
+
//#endregion
|
|
1296
|
+
//#region src/commands/compile.ts
|
|
1297
|
+
/** Compile the Definition Graph to a target CI YAML. */
|
|
1298
|
+
async function compileCommand(args, global, output, start) {
|
|
1299
|
+
const target = args.target;
|
|
1300
|
+
if (target !== "github" && target !== "gitlab") throw new CliError(`invalid compile target: ${target} (expected github or gitlab)`, "INVALID_FLAG", ExitCode.UsageError);
|
|
1301
|
+
if (args.output && args.outputDir) throw new CliError("--output and --output-dir are mutually exclusive", "INVALID_FLAG", ExitCode.UsageError);
|
|
1302
|
+
output.debug(`compile: root=${global.root} target=${target} output=${args.output ?? "stdout"}`);
|
|
1303
|
+
const { graph } = await loadProjectGraph(global);
|
|
1304
|
+
const result = target === "github" ? compileGithub(graph, {
|
|
1305
|
+
...detectCiSetup(global.root),
|
|
1306
|
+
...args.pin ? { pinning: { mode: "strict" } } : {}
|
|
1307
|
+
}) : compileGitlab(graph);
|
|
1308
|
+
let hasError = false;
|
|
1309
|
+
for (const d of result.diagnostics) {
|
|
1310
|
+
output.errorLine(`[${d.severity}] ${d.capability}: ${d.message}`);
|
|
1311
|
+
if (d.severity === "error") hasError = true;
|
|
1312
|
+
}
|
|
1313
|
+
const yaml = result.artifacts.map((a) => a.content).join("\n---\n");
|
|
1314
|
+
if (result.artifacts.length > 1 && !args.outputDir) {
|
|
1315
|
+
if (args.output) throw new CliError(`--output cannot represent ${result.artifacts.length} artifacts; use --output-dir`, "INVALID_FLAG", ExitCode.UsageError);
|
|
1316
|
+
output.errorLine(`[warning] ${result.artifacts.length} artifacts glued into one stream; use --output-dir to write separate files`);
|
|
1317
|
+
}
|
|
1318
|
+
if (args.outputDir) {
|
|
1319
|
+
const outDir = resolve(global.root, args.outputDir);
|
|
1320
|
+
await mkdir(outDir, { recursive: true });
|
|
1321
|
+
const realOutDir = await realpath(outDir);
|
|
1322
|
+
const contained = (base, p) => {
|
|
1323
|
+
const rel = relative(base, p);
|
|
1324
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
1325
|
+
};
|
|
1326
|
+
const written = [];
|
|
1327
|
+
for (const a of result.artifacts) {
|
|
1328
|
+
const outPath = resolve(outDir, a.path);
|
|
1329
|
+
const escapes = () => new CliError(`artifact path escapes output dir: ${a.path}`, "INVALID_FLAG", ExitCode.UsageError);
|
|
1330
|
+
if (!contained(outDir, outPath)) throw escapes();
|
|
1331
|
+
for (let ancestor = dirname(outPath);;) try {
|
|
1332
|
+
const realAncestor = await realpath(ancestor);
|
|
1333
|
+
if (!contained(realOutDir, join(realAncestor, relative(ancestor, outPath)))) throw escapes();
|
|
1334
|
+
break;
|
|
1335
|
+
} catch (e) {
|
|
1336
|
+
if (e instanceof CliError) throw e;
|
|
1337
|
+
if (e.code !== "ENOENT") throw e;
|
|
1338
|
+
const up = dirname(ancestor);
|
|
1339
|
+
if (up === ancestor) break;
|
|
1340
|
+
ancestor = up;
|
|
1341
|
+
}
|
|
1342
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
1343
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;
|
|
1344
|
+
let fh;
|
|
1345
|
+
try {
|
|
1346
|
+
fh = await open(outPath, flags);
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
if (e.code === "ELOOP") throw escapes();
|
|
1349
|
+
throw e;
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
await fh.writeFile(a.content);
|
|
1353
|
+
} finally {
|
|
1354
|
+
await fh.close();
|
|
1355
|
+
}
|
|
1356
|
+
written.push(outPath);
|
|
1357
|
+
}
|
|
1358
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1359
|
+
command: "compile",
|
|
1360
|
+
data: {
|
|
1361
|
+
target,
|
|
1362
|
+
paths: written
|
|
1363
|
+
},
|
|
1364
|
+
durationMs: Date.now() - start
|
|
1365
|
+
}));
|
|
1366
|
+
else for (const p of written) output.writeLine(`Compiled ${target} workflow to ${p}`);
|
|
1367
|
+
} else if (args.output) {
|
|
1368
|
+
const outPath = resolve(global.root, args.output);
|
|
1369
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
1370
|
+
await writeFile(outPath, yaml, "utf8");
|
|
1371
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1372
|
+
command: "compile",
|
|
1373
|
+
data: {
|
|
1374
|
+
target,
|
|
1375
|
+
path: outPath
|
|
1376
|
+
},
|
|
1377
|
+
durationMs: Date.now() - start
|
|
1378
|
+
}));
|
|
1379
|
+
else output.writeLine(`Compiled ${target} workflow to ${outPath}`);
|
|
1380
|
+
} else if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1381
|
+
command: "compile",
|
|
1382
|
+
data: {
|
|
1383
|
+
target,
|
|
1384
|
+
yaml
|
|
1385
|
+
},
|
|
1386
|
+
durationMs: Date.now() - start
|
|
1387
|
+
}));
|
|
1388
|
+
else output.write(yaml);
|
|
1389
|
+
return hasError ? ExitCode.RuntimeError : ExitCode.Success;
|
|
1390
|
+
}
|
|
1391
|
+
//#endregion
|
|
1392
|
+
//#region src/commands/synth.ts
|
|
1393
|
+
/**
|
|
1394
|
+
* Alias for `compile`. Delegates to compileCommand.
|
|
1395
|
+
*/
|
|
1396
|
+
async function synthCommand(args, global, output, start) {
|
|
1397
|
+
return compileCommand(args, global, output, start);
|
|
1398
|
+
}
|
|
1399
|
+
//#endregion
|
|
1400
|
+
//#region src/internal/buffering-writer.ts
|
|
1401
|
+
/**
|
|
1402
|
+
* OutputWriter for MCP tool execution: captures structured stdout output
|
|
1403
|
+
* (command handlers emit JSON when format="json") and routes all error/debug
|
|
1404
|
+
* output to stderr. stdout is reserved for MCP JSON-RPC transport.
|
|
1405
|
+
*/
|
|
1406
|
+
var BufferingOutputWriter = class {
|
|
1407
|
+
#buffer = "";
|
|
1408
|
+
write(text) {
|
|
1409
|
+
this.#buffer += text;
|
|
1410
|
+
}
|
|
1411
|
+
writeLine(text) {
|
|
1412
|
+
this.#buffer += text + "\n";
|
|
1413
|
+
}
|
|
1414
|
+
error(text) {
|
|
1415
|
+
process$1.stderr.write(text);
|
|
1416
|
+
}
|
|
1417
|
+
errorLine(text) {
|
|
1418
|
+
process$1.stderr.write(text + "\n");
|
|
1419
|
+
}
|
|
1420
|
+
debug(text) {
|
|
1421
|
+
process$1.stderr.write(text + "\n");
|
|
1422
|
+
}
|
|
1423
|
+
/** The captured stdout content (JSON string from the command handler). */
|
|
1424
|
+
get captured() {
|
|
1425
|
+
return this.#buffer;
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
//#endregion
|
|
1429
|
+
//#region src/internal/mcp-tools.ts
|
|
1430
|
+
/** Build GlobalFlags for JSON-format command execution. */
|
|
1431
|
+
function jsonFlags(root) {
|
|
1432
|
+
return {
|
|
1433
|
+
format: "json",
|
|
1434
|
+
config: null,
|
|
1435
|
+
root,
|
|
1436
|
+
quiet: false,
|
|
1437
|
+
verbose: false
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/** Safely parse JSON, returning null on failure. */
|
|
1441
|
+
function safeJsonParse(text) {
|
|
1442
|
+
try {
|
|
1443
|
+
return JSON.parse(text);
|
|
1444
|
+
} catch {
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Run a CLI command handler with a buffering writer and return an MCP
|
|
1450
|
+
* CallToolResult. On success (exit 0), the captured JSON `data` field is
|
|
1451
|
+
* returned as text content. On failure, isError is set with the error message.
|
|
1452
|
+
*/
|
|
1453
|
+
async function runCommandAsTool(fn, root) {
|
|
1454
|
+
const writer = new BufferingOutputWriter();
|
|
1455
|
+
const global = jsonFlags(root);
|
|
1456
|
+
const start = Date.now();
|
|
1457
|
+
try {
|
|
1458
|
+
const exitCode = await fn(writer, global, start);
|
|
1459
|
+
const text = writer.captured.trim();
|
|
1460
|
+
if (exitCode === ExitCode.Success) {
|
|
1461
|
+
const parsed = text ? safeJsonParse(text) : null;
|
|
1462
|
+
const data = parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed ?? {};
|
|
1463
|
+
return { content: [{
|
|
1464
|
+
type: "text",
|
|
1465
|
+
text: JSON.stringify(data)
|
|
1466
|
+
}] };
|
|
1467
|
+
}
|
|
1468
|
+
const parsed = text ? safeJsonParse(text) : null;
|
|
1469
|
+
return {
|
|
1470
|
+
content: [{
|
|
1471
|
+
type: "text",
|
|
1472
|
+
text: parsed ? JSON.stringify(parsed) : text || `exit code ${exitCode}`
|
|
1473
|
+
}],
|
|
1474
|
+
isError: true
|
|
1475
|
+
};
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
return {
|
|
1478
|
+
content: [{
|
|
1479
|
+
type: "text",
|
|
1480
|
+
text: e instanceof Error ? e.message : String(e)
|
|
1481
|
+
}],
|
|
1482
|
+
isError: true
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
z.string().optional(), z.string().optional(), z.string().optional(), z.string().optional(), z.string().optional(), z.string().optional(), z.enum(["host", "docker"]).optional(), z.string().optional(), z.enum(["github", "gitlab"]);
|
|
1487
|
+
/**
|
|
1488
|
+
* Register all 5 Sverka MCP tools on the given McpServer.
|
|
1489
|
+
* Each tool maps to the corresponding CLI command handler.
|
|
1490
|
+
*/
|
|
1491
|
+
function registerSverkaTools(server, defaultRoot) {
|
|
1492
|
+
server.tool("sverka.validate", "Validate a sverka config: synthesize the Definition Graph and run validators.", { root: z.string().optional() }, async (args) => {
|
|
1493
|
+
return runCommandAsTool((w, g, s) => validateCommand(g, w, s), args.root ?? defaultRoot);
|
|
1494
|
+
});
|
|
1495
|
+
server.tool("sverka.plan", "Bind an Entry and inputs into a Run Plan and return the step list.", {
|
|
1496
|
+
root: z.string().optional(),
|
|
1497
|
+
entryId: z.string().optional()
|
|
1498
|
+
}, async (args) => {
|
|
1499
|
+
return runCommandAsTool((w, g, s) => planCommand({ ...args.entryId !== void 0 && { entryId: args.entryId } }, g, w, s), args.root ?? defaultRoot);
|
|
1500
|
+
});
|
|
1501
|
+
server.tool("sverka.graph", "Display the Definition Graph: pipelines, entries, steps, and dependencies.", { root: z.string().optional() }, async (args) => {
|
|
1502
|
+
return runCommandAsTool((w, g, s) => graphCommand(g, w, s), args.root ?? defaultRoot);
|
|
1503
|
+
});
|
|
1504
|
+
server.tool("sverka.run", "Execute the workflow locally through the native engine and return the run status.", {
|
|
1505
|
+
root: z.string().optional(),
|
|
1506
|
+
entryId: z.string().optional(),
|
|
1507
|
+
executor: z.enum(["host", "docker"]).optional()
|
|
1508
|
+
}, async (args) => {
|
|
1509
|
+
return runCommandAsTool((w, g, s) => runCommand({
|
|
1510
|
+
...args.entryId !== void 0 && { entryId: args.entryId },
|
|
1511
|
+
...args.executor !== void 0 && { executor: args.executor }
|
|
1512
|
+
}, g, w, s), args.root ?? defaultRoot);
|
|
1513
|
+
});
|
|
1514
|
+
server.tool("sverka.synth", "Compile the workflow to a target CI YAML (github or gitlab).", {
|
|
1515
|
+
root: z.string().optional(),
|
|
1516
|
+
target: z.enum(["github", "gitlab"])
|
|
1517
|
+
}, async (args) => {
|
|
1518
|
+
return synthTool(args.root ?? defaultRoot, args.target);
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
/** Run the synth tool: load graph, compile to target, return artifacts. */
|
|
1522
|
+
async function synthTool(root, target) {
|
|
1523
|
+
try {
|
|
1524
|
+
const { graph } = await loadProjectGraph({
|
|
1525
|
+
root,
|
|
1526
|
+
config: null
|
|
1527
|
+
});
|
|
1528
|
+
const result = target === "github" ? compileGithub(graph) : compileGitlab(graph);
|
|
1529
|
+
const data = {
|
|
1530
|
+
target,
|
|
1531
|
+
artifacts: result.artifacts.map((a) => ({
|
|
1532
|
+
path: a.path,
|
|
1533
|
+
content: a.content
|
|
1534
|
+
})),
|
|
1535
|
+
diagnostics: result.diagnostics
|
|
1536
|
+
};
|
|
1537
|
+
return { content: [{
|
|
1538
|
+
type: "text",
|
|
1539
|
+
text: JSON.stringify(data)
|
|
1540
|
+
}] };
|
|
1541
|
+
} catch (e) {
|
|
1542
|
+
return {
|
|
1543
|
+
content: [{
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: e instanceof Error ? e.message : String(e)
|
|
1546
|
+
}],
|
|
1547
|
+
isError: true
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
//#endregion
|
|
1552
|
+
//#region src/commands/mcp-server.ts
|
|
1553
|
+
/**
|
|
1554
|
+
* Start an MCP server over stdio that exposes Sverka operations as tools.
|
|
1555
|
+
* External AI agents can invoke sverka.validate, sverka.plan, sverka.graph,
|
|
1556
|
+
* sverka.run, and sverka.synth via the Model Context Protocol.
|
|
1557
|
+
*
|
|
1558
|
+
* The server runs until stdin closes or SIGTERM is received. stdout is
|
|
1559
|
+
* reserved for MCP JSON-RPC transport — all logs go to stderr.
|
|
1560
|
+
*
|
|
1561
|
+
* @param shutdownSignal Optional promise that resolves to signal shutdown
|
|
1562
|
+
* (for testing). In production, the server waits for stdin close / SIGTERM.
|
|
1563
|
+
*/
|
|
1564
|
+
async function mcpServerCommand(_args, global, output, _start, shutdownSignal) {
|
|
1565
|
+
output.debug(`mcp-server: root=${global.root}`);
|
|
1566
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
1567
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
1568
|
+
const server = new McpServer({
|
|
1569
|
+
name: "sverka",
|
|
1570
|
+
version: cliVersion()
|
|
1571
|
+
});
|
|
1572
|
+
registerSverkaTools(server, global.root);
|
|
1573
|
+
const transport = new StdioServerTransport();
|
|
1574
|
+
try {
|
|
1575
|
+
await server.connect(transport);
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1578
|
+
output.errorLine(`mcp-server: failed to start: ${msg}`);
|
|
1579
|
+
return ExitCode.RuntimeError;
|
|
1580
|
+
}
|
|
1581
|
+
await (shutdownSignal ?? waitForStdinClose());
|
|
1582
|
+
await server.close();
|
|
1583
|
+
return ExitCode.Success;
|
|
1584
|
+
}
|
|
1585
|
+
/** Wait for stdin close or SIGTERM. */
|
|
1586
|
+
function waitForStdinClose() {
|
|
1587
|
+
return new Promise((resolve) => {
|
|
1588
|
+
const cleanup = () => {
|
|
1589
|
+
process$1.stdin.off("end", cleanup);
|
|
1590
|
+
process$1.stdin.off("close", cleanup);
|
|
1591
|
+
process$1.off("SIGTERM", cleanup);
|
|
1592
|
+
resolve();
|
|
1593
|
+
};
|
|
1594
|
+
process$1.stdin.on("end", cleanup);
|
|
1595
|
+
process$1.stdin.on("close", cleanup);
|
|
1596
|
+
process$1.on("SIGTERM", cleanup);
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
//#endregion
|
|
1600
|
+
//#region src/commands/doctor.ts
|
|
1601
|
+
/**
|
|
1602
|
+
* Check the environment: Node.js, Bun, git. Reports status of each.
|
|
1603
|
+
* Exit 0 when all pass, 3 when a required tool is missing.
|
|
1604
|
+
*/
|
|
1605
|
+
async function doctorCommand(global, output, start) {
|
|
1606
|
+
output.debug(`doctor: root=${global.root}`);
|
|
1607
|
+
const checks = [
|
|
1608
|
+
runCheck("node", "node", true),
|
|
1609
|
+
runCheck("bun", "bun", false),
|
|
1610
|
+
runCheck("git", "git", true)
|
|
1611
|
+
];
|
|
1612
|
+
const allOk = checks.every((c) => !c.required || c.status === "ok");
|
|
1613
|
+
const exitCode = allOk ? ExitCode.Success : ExitCode.RuntimeError;
|
|
1614
|
+
const durationMs = Date.now() - start;
|
|
1615
|
+
if (global.format === "json") output.writeLine(JSON.stringify({
|
|
1616
|
+
command: "doctor",
|
|
1617
|
+
data: {
|
|
1618
|
+
checks,
|
|
1619
|
+
allOk
|
|
1620
|
+
},
|
|
1621
|
+
durationMs
|
|
1622
|
+
}));
|
|
1623
|
+
else {
|
|
1624
|
+
output.writeLine("Environment diagnostics:");
|
|
1625
|
+
for (const check of checks) {
|
|
1626
|
+
const version = check.version ? `v${check.version}` : "NOT FOUND";
|
|
1627
|
+
output.writeLine(` ${check.name}: ${version}`);
|
|
1628
|
+
}
|
|
1629
|
+
output.writeLine(allOk ? "All checks passed." : "Some checks failed.");
|
|
1630
|
+
}
|
|
1631
|
+
return exitCode;
|
|
1632
|
+
}
|
|
1633
|
+
function runCheck(name, binary, required) {
|
|
1634
|
+
try {
|
|
1635
|
+
const result = spawnSync(binary, ["--version"], {
|
|
1636
|
+
stdio: [
|
|
1637
|
+
"ignore",
|
|
1638
|
+
"pipe",
|
|
1639
|
+
"ignore"
|
|
1640
|
+
],
|
|
1641
|
+
encoding: "utf8",
|
|
1642
|
+
timeout: 5e3
|
|
1643
|
+
});
|
|
1644
|
+
if (result.status !== 0 || result.error) return {
|
|
1645
|
+
name,
|
|
1646
|
+
status: "missing",
|
|
1647
|
+
version: null,
|
|
1648
|
+
required
|
|
1649
|
+
};
|
|
1650
|
+
const out = result.stdout.trim();
|
|
1651
|
+
return {
|
|
1652
|
+
name,
|
|
1653
|
+
status: "ok",
|
|
1654
|
+
version: out.match(/(\d+\.\d+\.\d+[^\s]*)/)?.[1] ?? out,
|
|
1655
|
+
required
|
|
1656
|
+
};
|
|
1657
|
+
} catch {
|
|
1658
|
+
return {
|
|
1659
|
+
name,
|
|
1660
|
+
status: "missing",
|
|
1661
|
+
version: null,
|
|
1662
|
+
required
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/commands/view.ts
|
|
1668
|
+
/**
|
|
1669
|
+
* View SARIF findings in the terminal TUI or generate an HTML report.
|
|
1670
|
+
*/
|
|
1671
|
+
async function viewCommand(args, global, output, start) {
|
|
1672
|
+
let sarifJson;
|
|
1673
|
+
if (args.file) try {
|
|
1674
|
+
sarifJson = readFileSync(args.file, "utf8");
|
|
1675
|
+
} catch (e) {
|
|
1676
|
+
throw new CliError(`failed to read SARIF file: ${e instanceof Error ? e.message : String(e)}`, "MISSING_ARG", ExitCode.RuntimeError);
|
|
1677
|
+
}
|
|
1678
|
+
else if (!process$1.stdin.isTTY) {
|
|
1679
|
+
sarifJson = readFileSync(0, "utf8");
|
|
1680
|
+
if (sarifJson.trim() === "") {
|
|
1681
|
+
output.errorLine("sverka view: stdin is empty.");
|
|
1682
|
+
return ExitCode.RuntimeError;
|
|
1683
|
+
}
|
|
1684
|
+
} else {
|
|
1685
|
+
output.errorLine("sverka view: no input. Provide a SARIF file argument or pipe SARIF via stdin.");
|
|
1686
|
+
return ExitCode.UsageError;
|
|
1687
|
+
}
|
|
1688
|
+
let sarif;
|
|
1689
|
+
try {
|
|
1690
|
+
sarif = JSON.parse(sarifJson);
|
|
1691
|
+
} catch (e) {
|
|
1692
|
+
output.errorLine(`sverka view: invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
1693
|
+
return ExitCode.RuntimeError;
|
|
1694
|
+
}
|
|
1695
|
+
if (args.format === "web") return renderWeb(sarif, args, output);
|
|
1696
|
+
return renderTui(sarif, output);
|
|
1697
|
+
}
|
|
1698
|
+
/** Launch the TUI viewer using @sverka/sarif-viewer-tui. */
|
|
1699
|
+
async function renderTui(sarif, output) {
|
|
1700
|
+
try {
|
|
1701
|
+
const { renderSarifTui } = await import("@sverka/sarif-viewer-tui");
|
|
1702
|
+
await renderSarifTui({ sarif });
|
|
1703
|
+
return ExitCode.Success;
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
output.errorLine(`sverka view: ${e instanceof Error ? e.message : String(e)}`);
|
|
1706
|
+
return ExitCode.RuntimeError;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/** Generate an HTML report using @sverka/sarif-viewer-web. */
|
|
1710
|
+
async function renderWeb(sarif, args, output) {
|
|
1711
|
+
try {
|
|
1712
|
+
const { renderSarifWeb } = await import("@sverka/sarif-viewer-web");
|
|
1713
|
+
const outputPath = args.output ?? "sarif-report.html";
|
|
1714
|
+
renderSarifWeb({
|
|
1715
|
+
sarif,
|
|
1716
|
+
outputPath
|
|
1717
|
+
});
|
|
1718
|
+
output.writeLine(`Wrote ${outputPath}`);
|
|
1719
|
+
return ExitCode.Success;
|
|
1720
|
+
} catch (e) {
|
|
1721
|
+
output.errorLine(`sverka view: ${e instanceof Error ? e.message : String(e)}`);
|
|
1722
|
+
return ExitCode.RuntimeError;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
//#endregion
|
|
1726
|
+
//#region src/commands/ui.ts
|
|
1727
|
+
/**
|
|
1728
|
+
* Start a local web dashboard server that lists SARIF files
|
|
1729
|
+
* and renders individual findings reports.
|
|
1730
|
+
*/
|
|
1731
|
+
async function uiCommand(args, global, output, _start) {
|
|
1732
|
+
const artifactsDir = join(global.root, ".sverka", "artifacts");
|
|
1733
|
+
const port = args.port ?? 3e3;
|
|
1734
|
+
const host = args.host ?? "localhost";
|
|
1735
|
+
try {
|
|
1736
|
+
const { startUiServer } = await import("@sverka/ui");
|
|
1737
|
+
const server = await startUiServer({
|
|
1738
|
+
artifactsDir,
|
|
1739
|
+
port,
|
|
1740
|
+
host
|
|
1741
|
+
});
|
|
1742
|
+
output.writeLine(`Sverka UI running at ${server.url}`);
|
|
1743
|
+
output.writeLine(`Artifacts: ${artifactsDir}`);
|
|
1744
|
+
output.writeLine("Press Ctrl+C to stop.");
|
|
1745
|
+
process$1.once("SIGINT", () => {
|
|
1746
|
+
server.close();
|
|
1747
|
+
process$1.exit(ExitCode.Success);
|
|
1748
|
+
});
|
|
1749
|
+
return new Promise(() => {});
|
|
1750
|
+
} catch (e) {
|
|
1751
|
+
output.errorLine(`sverka ui: ${e instanceof Error ? e.message : String(e)}`);
|
|
1752
|
+
return ExitCode.RuntimeError;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region src/main.ts
|
|
1757
|
+
/** Build GlobalFlags from parsed yargs options. */
|
|
1758
|
+
function buildGlobalFlags(parsed) {
|
|
1759
|
+
return {
|
|
1760
|
+
format: resolveFormat(parsed.format),
|
|
1761
|
+
config: typeof parsed.config === "string" ? parsed.config : null,
|
|
1762
|
+
root: typeof parsed.root === "string" ? parsed.root : process$1.cwd(),
|
|
1763
|
+
quiet: Boolean(parsed.quiet),
|
|
1764
|
+
verbose: Boolean(parsed.verbose)
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
/** Resolve the format string from parsed yargs. */
|
|
1768
|
+
function resolveFormat(format) {
|
|
1769
|
+
if (format === "json") return "json";
|
|
1770
|
+
if (format === "html") return "html";
|
|
1771
|
+
if (format === "sarif") return "sarif";
|
|
1772
|
+
if (format === "web") return "web";
|
|
1773
|
+
return "text";
|
|
1774
|
+
}
|
|
1775
|
+
/** Resolve the real output writer, wrapping injected writers with flag semantics. */
|
|
1776
|
+
function resolveOutputWriter(global, deps) {
|
|
1777
|
+
if (deps?.output) return wrapOutputWriter(global, deps.output);
|
|
1778
|
+
return createOutputWriter(global, (s) => process$1.stdout.write(s), (s) => process$1.stderr.write(s));
|
|
1779
|
+
}
|
|
1780
|
+
/** Configure the init subcommand options. */
|
|
1781
|
+
function addInitCommand(y) {
|
|
1782
|
+
return y.option("template", {
|
|
1783
|
+
type: "string",
|
|
1784
|
+
default: "minimal",
|
|
1785
|
+
choices: ["minimal", "full"]
|
|
1786
|
+
}).option("force", {
|
|
1787
|
+
type: "boolean",
|
|
1788
|
+
default: false
|
|
1789
|
+
}).option("detect", {
|
|
1790
|
+
type: "boolean",
|
|
1791
|
+
default: false,
|
|
1792
|
+
describe: "Generate config from detected project checks (overrides --template)"
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
/** Configure the run subcommand options. */
|
|
1796
|
+
function addRunCommand(y) {
|
|
1797
|
+
return y.option("entry", { type: "string" }).option("executor", {
|
|
1798
|
+
type: "string",
|
|
1799
|
+
default: "host",
|
|
1800
|
+
choices: ["host", "docker"]
|
|
1801
|
+
}).option("evaluate", {
|
|
1802
|
+
type: "boolean",
|
|
1803
|
+
default: false
|
|
1804
|
+
}).option("output", {
|
|
1805
|
+
type: "string",
|
|
1806
|
+
alias: "o",
|
|
1807
|
+
describe: "Output file path for HTML report (implies --format html)"
|
|
1808
|
+
}).option("tui", {
|
|
1809
|
+
type: "boolean",
|
|
1810
|
+
describe: "Interactive terminal UI (default: on when stdout is a TTY and no --format)"
|
|
1811
|
+
}).option("jobs", {
|
|
1812
|
+
type: "number",
|
|
1813
|
+
alias: "j",
|
|
1814
|
+
describe: "Max steps running concurrently (default: 4)"
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
/** Configure the plan subcommand options. */
|
|
1818
|
+
function addPlanCommand(y) {
|
|
1819
|
+
return y.option("entry", { type: "string" });
|
|
1820
|
+
}
|
|
1821
|
+
/** Configure the policy subcommand options. */
|
|
1822
|
+
function addPolicyCommand(y) {
|
|
1823
|
+
return y.option("findings", {
|
|
1824
|
+
type: "string",
|
|
1825
|
+
demandOption: true
|
|
1826
|
+
}).option("baseline", { type: "string" });
|
|
1827
|
+
}
|
|
1828
|
+
/** Configure the synth subcommand options. */
|
|
1829
|
+
function addSynthCommand(y) {
|
|
1830
|
+
return y.option("target", {
|
|
1831
|
+
type: "string",
|
|
1832
|
+
demandOption: true,
|
|
1833
|
+
choices: ["github", "gitlab"]
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
/** Configure the compile subcommand options. */
|
|
1837
|
+
function addCompileCommand(y) {
|
|
1838
|
+
return y.option("target", {
|
|
1839
|
+
type: "string",
|
|
1840
|
+
demandOption: true,
|
|
1841
|
+
choices: ["github", "gitlab"]
|
|
1842
|
+
}).option("output", {
|
|
1843
|
+
type: "string",
|
|
1844
|
+
alias: "o",
|
|
1845
|
+
describe: "Write YAML to a file instead of stdout"
|
|
1846
|
+
}).option("output-dir", {
|
|
1847
|
+
type: "string",
|
|
1848
|
+
describe: "Write each artifact to <dir>/<artifact path> (multi-pipeline)"
|
|
1849
|
+
}).option("pin", {
|
|
1850
|
+
type: "boolean",
|
|
1851
|
+
default: false,
|
|
1852
|
+
describe: "Pin GitHub action refs to commit SHAs (github target)"
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
/** Configure the view subcommand options. */
|
|
1856
|
+
function addViewCommand(y) {
|
|
1857
|
+
return y.positional("file", {
|
|
1858
|
+
type: "string",
|
|
1859
|
+
describe: "Path to a .sarif file (reads from stdin if omitted)"
|
|
1860
|
+
}).option("format", {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
alias: "f",
|
|
1863
|
+
choices: [
|
|
1864
|
+
"tui",
|
|
1865
|
+
"web",
|
|
1866
|
+
"html"
|
|
1867
|
+
],
|
|
1868
|
+
default: "tui",
|
|
1869
|
+
describe: "View format: tui (terminal), web/html (HTML report)"
|
|
1870
|
+
}).option("output", {
|
|
1871
|
+
type: "string",
|
|
1872
|
+
alias: "o",
|
|
1873
|
+
describe: "Output HTML file path (for --format web, default: sarif-report.html)"
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
/** Configure the ui subcommand options. */
|
|
1877
|
+
function addUiCommand(y) {
|
|
1878
|
+
return y.option("port", {
|
|
1879
|
+
type: "number",
|
|
1880
|
+
default: 3e3,
|
|
1881
|
+
describe: "Port to listen on"
|
|
1882
|
+
}).option("host", {
|
|
1883
|
+
type: "string",
|
|
1884
|
+
default: "localhost",
|
|
1885
|
+
describe: "Host to bind"
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
function buildParser() {
|
|
1889
|
+
return yargs([]).scriptName("sverka").version(cliVersion()).option("format", {
|
|
1890
|
+
type: "string",
|
|
1891
|
+
alias: "f",
|
|
1892
|
+
choices: [
|
|
1893
|
+
"text",
|
|
1894
|
+
"json",
|
|
1895
|
+
"html",
|
|
1896
|
+
"sarif",
|
|
1897
|
+
"web"
|
|
1898
|
+
]
|
|
1899
|
+
}).option("config", {
|
|
1900
|
+
type: "string",
|
|
1901
|
+
alias: "c"
|
|
1902
|
+
}).option("root", {
|
|
1903
|
+
type: "string",
|
|
1904
|
+
alias: "r",
|
|
1905
|
+
default: process$1.cwd()
|
|
1906
|
+
}).option("quiet", {
|
|
1907
|
+
type: "boolean",
|
|
1908
|
+
alias: "q",
|
|
1909
|
+
default: false
|
|
1910
|
+
}).option("verbose", {
|
|
1911
|
+
type: "boolean",
|
|
1912
|
+
alias: "v",
|
|
1913
|
+
default: false
|
|
1914
|
+
}).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]", "View SARIF findings in TUI or generate HTML report", addViewCommand).command("ui", "Start local web dashboard server", addUiCommand).demandCommand(1, "No command given").strict().fail((msg, err) => {
|
|
1915
|
+
if (err) throw err;
|
|
1916
|
+
throw new CliError(msg, "UNKNOWN_COMMAND", ExitCode.UsageError);
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
/** Dispatch a single command to its handler. */
|
|
1920
|
+
async function dispatch(command, parsed, global, output, start) {
|
|
1921
|
+
switch (command) {
|
|
1922
|
+
case "init": return dispatchInit(parsed, global, output, start);
|
|
1923
|
+
case "validate": return validateCommand(global, output, start);
|
|
1924
|
+
case "plan": return dispatchPlan(parsed, global, output, start);
|
|
1925
|
+
case "graph": return graphCommand(global, output, start);
|
|
1926
|
+
case "run": return dispatchRun(parsed, global, output, start);
|
|
1927
|
+
case "discover": return discoverCommand(global, output, start);
|
|
1928
|
+
case "check": return checkCommand(global, output, start);
|
|
1929
|
+
case "policy": return dispatchPolicy(parsed, global, output, start);
|
|
1930
|
+
case "synth": return dispatchSynth(parsed, global, output, start);
|
|
1931
|
+
case "compile": return dispatchCompile(parsed, global, output, start);
|
|
1932
|
+
case "mcp-server": return mcpServerCommand({}, global, output, start);
|
|
1933
|
+
case "doctor": return doctorCommand(global, output, start);
|
|
1934
|
+
case "view": return dispatchView(parsed, global, output, start);
|
|
1935
|
+
case "ui": return dispatchUi(parsed, global, output, start);
|
|
1936
|
+
default: throw new CliError(`unknown command: ${command}`, "UNKNOWN_COMMAND", ExitCode.UsageError);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
function dispatchInit(parsed, global, output, start) {
|
|
1940
|
+
return initCommand({
|
|
1941
|
+
template: typeof parsed.template === "string" ? parsed.template : void 0,
|
|
1942
|
+
force: Boolean(parsed.force),
|
|
1943
|
+
detect: Boolean(parsed.detect)
|
|
1944
|
+
}, global, output, start);
|
|
1945
|
+
}
|
|
1946
|
+
function dispatchPlan(parsed, global, output, start) {
|
|
1947
|
+
const args = {};
|
|
1948
|
+
if (typeof parsed.entry === "string") args.entryId = parsed.entry;
|
|
1949
|
+
return planCommand(args, global, output, start);
|
|
1950
|
+
}
|
|
1951
|
+
function dispatchRun(parsed, global, output, start) {
|
|
1952
|
+
const args = {
|
|
1953
|
+
executor: parsed.executor === "docker" ? "docker" : "host",
|
|
1954
|
+
evaluate: Boolean(parsed.evaluate)
|
|
1955
|
+
};
|
|
1956
|
+
if (parsed.jobs !== void 0) {
|
|
1957
|
+
const jobs = Number(parsed.jobs);
|
|
1958
|
+
if (!Number.isInteger(jobs) || jobs < 1 || jobs > 64) throw new CliError("--jobs must be an integer in [1, 64]", "INVALID_FLAG", ExitCode.UsageError);
|
|
1959
|
+
args.jobs = jobs;
|
|
1960
|
+
}
|
|
1961
|
+
if (typeof parsed.entry === "string") args.entryId = parsed.entry;
|
|
1962
|
+
if (typeof parsed.output === "string") args.output = parsed.output;
|
|
1963
|
+
if (parsed.tui === true) args.tui = true;
|
|
1964
|
+
if (parsed.tui === false) args.tui = false;
|
|
1965
|
+
args.formatExplicit = parsed.format !== void 0;
|
|
1966
|
+
return runCommand(args, global, output, start);
|
|
1967
|
+
}
|
|
1968
|
+
function dispatchPolicy(parsed, global, output, start) {
|
|
1969
|
+
const args = { findings: String(parsed.findings) };
|
|
1970
|
+
if (typeof parsed.baseline === "string") args.baseline = parsed.baseline;
|
|
1971
|
+
return policyCommand(args, global, output, start);
|
|
1972
|
+
}
|
|
1973
|
+
function dispatchSynth(parsed, global, output, start) {
|
|
1974
|
+
return synthCommand({ target: parsed.target === "gitlab" ? "gitlab" : "github" }, global, output, start);
|
|
1975
|
+
}
|
|
1976
|
+
function dispatchCompile(parsed, global, output, start) {
|
|
1977
|
+
const args = { target: parsed.target === "gitlab" ? "gitlab" : "github" };
|
|
1978
|
+
if (typeof parsed.output === "string") args.output = parsed.output;
|
|
1979
|
+
if (typeof parsed.outputDir === "string") args.outputDir = parsed.outputDir;
|
|
1980
|
+
if (parsed.pin === true) args.pin = true;
|
|
1981
|
+
return compileCommand(args, global, output, start);
|
|
1982
|
+
}
|
|
1983
|
+
function dispatchView(parsed, global, output, start) {
|
|
1984
|
+
const args = { format: parsed.format === "web" || parsed.format === "html" ? "web" : "tui" };
|
|
1985
|
+
if (typeof parsed.file === "string") args.file = parsed.file;
|
|
1986
|
+
if (typeof parsed.output === "string") args.output = parsed.output;
|
|
1987
|
+
return viewCommand(args, global, output, start);
|
|
1988
|
+
}
|
|
1989
|
+
function dispatchUi(parsed, global, output, start) {
|
|
1990
|
+
const args = {};
|
|
1991
|
+
if (typeof parsed.port === "number") args.port = parsed.port;
|
|
1992
|
+
if (typeof parsed.host === "string") args.host = parsed.host;
|
|
1993
|
+
return uiCommand(args, global, output, start);
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* The main CLI entry point.
|
|
1997
|
+
* @param argv Command-line arguments (excluding node and script path).
|
|
1998
|
+
* @returns Exit code (0 = success, 1 = policy fail, 2 = usage error, 3 = runtime error).
|
|
1999
|
+
*/
|
|
2000
|
+
async function main(argv, deps) {
|
|
2001
|
+
const start = Date.now();
|
|
2002
|
+
const output = deps?.output ?? createOutputWriter({
|
|
2003
|
+
format: "text",
|
|
2004
|
+
config: null,
|
|
2005
|
+
root: process$1.cwd(),
|
|
2006
|
+
quiet: false,
|
|
2007
|
+
verbose: false
|
|
2008
|
+
}, (s) => process$1.stdout.write(s), (s) => process$1.stderr.write(s));
|
|
2009
|
+
let parsed;
|
|
2010
|
+
try {
|
|
2011
|
+
parsed = await buildParser().parseAsync(argv);
|
|
2012
|
+
} catch (e) {
|
|
2013
|
+
return handleError(e, output, start);
|
|
2014
|
+
}
|
|
2015
|
+
const global = buildGlobalFlags(parsed);
|
|
2016
|
+
const realOutput = resolveOutputWriter(global, deps);
|
|
2017
|
+
const command = String(parsed._[0] ?? "");
|
|
2018
|
+
realOutput.debug(`sverka: command=${command} root=${global.root} format=${global.format}`);
|
|
2019
|
+
try {
|
|
2020
|
+
return await dispatch(command, parsed, global, realOutput, start);
|
|
2021
|
+
} catch (e) {
|
|
2022
|
+
return handleError(e, realOutput, start);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
function handleError(e, output, _start) {
|
|
2026
|
+
const hint = missingBuildHint(e);
|
|
2027
|
+
if (e instanceof CliError) {
|
|
2028
|
+
output.errorLine(`error: ${e.message}`);
|
|
2029
|
+
if (hint !== null) output.errorLine(`hint: ${hint}`);
|
|
2030
|
+
return e.exitCode;
|
|
2031
|
+
}
|
|
2032
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2033
|
+
output.errorLine(`error: ${msg}`);
|
|
2034
|
+
if (hint !== null) output.errorLine(`hint: ${hint}`);
|
|
2035
|
+
return ExitCode.RuntimeError;
|
|
2036
|
+
}
|
|
2037
|
+
//#endregion
|
|
2038
|
+
export { CliError, ConsoleOutputWriter, ExitCode, createOutputWriter, main, wrapOutputWriter };
|
|
2039
|
+
|
|
2040
|
+
//# sourceMappingURL=index.mjs.map
|