verifika 0.0.2 → 0.0.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). The CLI flags, exit codes, configuration format (`version: 1`) and JSON output (`schemaVersion: 1`) are the public API.
4
4
 
5
+ ## [0.0.3] - 2026-10-13
6
+
7
+ ### Added
8
+
9
+ - Live status block in `human` mode on an interactive terminal: progress and elapsed time, each running check with a spinner, its elapsed time and latest output line, and what queued checks wait on. Finished checks print above it as in line mode. Line output remains the fallback in CI, when piped, with `TERM=dumb`, and with the new `--no-live` flag or `VERIFIKA_LIVE=0`.
10
+ - `human` output: a `verifika · <selection> · <n> checks · <j> parallel` header, aligned durations, the command and a rerun hint (`vfk -c <check>`) on failure lines, a `○ not run: build failed` marker for checks blocked by a failed dependency, and an estimate of the time saved by skipped checks from their last recorded durations.
11
+ - In `human` mode, the first interrupt prints `stopping… (Ctrl-C again to force)`.
12
+ - `vfk logs` on a terminal: local and relative start time (`today 01:12, 3m ago`), clickable OSC 8 log paths (opt out with `VERIFIKA_HYPERLINKS=0`), and paging through `$PAGER` or `less -R -F -X` (`--no-pager` to opt out; never with `--tail`, when piped, or in `agent` mode).
13
+ - `vfk plan` in `human` mode colours `run` and dims skipped checks.
14
+ - `vfk init` adds `"verify": "vfk"` and `"verify:ci": "vfk --all"` to `package.json` when they are absent, keeping its indentation, line endings, key order and trailing newline; existing scripts are never overwritten.
15
+ - JSON (additive, `schemaVersion: 1`): `checks[].blockedBy` on blocked checks in run results, naming the dependency that did not pass; `checks[].requiredBy` in plan output on checks selected only as dependencies.
16
+
17
+ ### Changed
18
+
19
+ - **`npm run verify` is now selective.** The recommended scripts are `"verify": "vfk"` for local work and `"verify:ci": "vfk --all"` for the full gate; use `npm run verify:ci` (or `vfk --all`) in CI and before delivery. The README setup prompt, docs and agent skill say so, and the prompt only replaces an existing `verify` script when the configured checks cover what it ran. If you followed the 0.0.2 setup, your `"verify": "vfk --all"` script still runs the full gate; to adopt the split, rename it to `verify:ci` and add `"verify": "vfk"` by hand (`vfk init` only adds scripts when it creates the config).
20
+ - A check pulled in by several dependents now names all of them (`required by test, pack`), in plan output and reasons.
21
+ - Failure previews leave out package-manager script banners (`> app@1.0.0 lint`, `> eslint .`, yarn's `$ …`).
22
+
23
+ ### Fixed
24
+
25
+ - ESLint failure previews showed only the `✖ N problems` summary; they now keep each `line:col error` line under its file heading, plus the summary.
26
+ - A failed check whose preview was hidden by the three-preview limit looked as if nothing was found; its line now says `(preview omitted: vfk logs <check>)`, in `human` and `agent` output.
27
+
5
28
  ## [0.0.2] - 2026-10-12
6
29
 
7
30
  ### Changed
@@ -37,5 +60,6 @@ First public release.
37
60
  - A process that deliberately detaches from the check (on Linux and macOS: leaves the process group, for example via `setsid`; on Windows: breaks away from the job with an exited intermediate parent) is not tracked unless it keeps the check's output open, in which case the check is reported as `error`.
38
61
  - On Windows, confirming cleanup queries the process table with PowerShell after each check, which costs roughly 0.5–3 s per check.
39
62
 
63
+ [0.0.3]: https://github.com/alexportelli12/verifika/compare/v0.0.2...v0.0.3
40
64
  [0.0.2]: https://github.com/alexportelli12/verifika/releases/tag/v0.0.2
41
65
  [0.0.1]: https://github.com/alexportelli12/verifika/releases/tag/v0.0.1
package/README.md CHANGED
@@ -62,7 +62,14 @@ Set up Verifika (https://github.com/alexportelli12/verifika) in this repository.
62
62
  runs on every change.
63
63
  - Add `dependsOn` where one check needs another's output (for example e2e after build).
64
64
  - Keep concurrency at 1 unless the checks clearly share no build outputs.
65
- - If package.json has no "verify" script, add "verify": "vfk --all".
65
+ - Make sure package.json has two scripts: "verify": "vfk" (selective, for local
66
+ work) and "verify:ci": "vfk --all" (the full gate). `vfk init` adds them when
67
+ they are missing. If a "verify" or "verify:ci" script already exists, replace
68
+ it only when the configured checks cover everything it ran; otherwise leave it
69
+ and report the conflict. This is the one exception to the rule below about
70
+ existing scripts.
71
+ - If the CI workflow runs the gate some other way, report that switching it to
72
+ `npm run verify:ci` is recommended. Do not edit the workflow.
66
73
  Reference: https://github.com/alexportelli12/verifika/blob/main/docs/configuration.md
67
74
  3. Validate the config:
68
75
  - Run `npx vfk --all`. If a check fails, decide whether the config is wrong (fix it)
@@ -74,9 +81,11 @@ Set up Verifika (https://github.com/alexportelli12/verifika) in this repository.
74
81
  agent's skills directory (see
75
82
  https://github.com/alexportelli12/verifika/blob/main/docs/agents.md).
76
83
 
77
- Do not change source code or existing scripts to make checks pass, and do not commit.
84
+ Do not change source code or existing scripts to make checks pass (except "verify"
85
+ and "verify:ci", as step 2 describes), and do not commit.
78
86
  Finish with: the checks you configured and why, the --all result, the expected
79
- selection for those two edits, and any existing failures you found.
87
+ selection for those two edits, the scripts you added, replaced or left (with any
88
+ conflicts), any CI recommendation, and any existing failures you found.
80
89
  ```
81
90
 
82
91
  Review the config it writes before committing it: the commands in it run on every verification.
@@ -85,7 +94,8 @@ Review the config it writes before committing it: the commands in it run on ever
85
94
 
86
95
  ```sh
87
96
  npm install --save-dev verifika
88
- npx vfk init # creates verifika.config.json from your package.json scripts
97
+ npx vfk init # creates verifika.config.json from your package.json scripts,
98
+ # and adds "verify" / "verify:ci" scripts if they are missing
89
99
  ```
90
100
 
91
101
  `init` only maps conventional script names (`lint`, `test`, `build`, ...) and leaves every check unfiltered, so open the file and give each check `paths`:
@@ -115,12 +125,14 @@ npx vfk # run them
115
125
  npx vfk --all # run everything (do this before you push)
116
126
  ```
117
127
 
118
- Add a script so everyone runs the same full gate:
128
+ `init` adds two scripts unless package.json already has scripts with those names (it never overwrites them):
119
129
 
120
130
  ```json
121
- { "scripts": { "verify": "vfk --all" } }
131
+ { "scripts": { "verify": "vfk", "verify:ci": "vfk --all" } }
122
132
  ```
123
133
 
134
+ `npm run verify` runs the checks your changes affect, for quick feedback while you work. `npm run verify:ci` runs every check: it is the full gate, so use it in CI and before you push or open a pull request.
135
+
124
136
  Run logs go to `.verifika/`, which ignores itself in git, so you do not need to edit `.gitignore`. To install globally instead, run `npm install --global verifika`.
125
137
 
126
138
  ## How checks are selected
@@ -141,12 +153,14 @@ A check without `paths` runs whenever any file changed; `"always": true` runs it
141
153
 
142
154
  The default is `human` on an interactive terminal and `agent` otherwise. Override with `--mode` or `VERIFIKA_MODE`.
143
155
 
144
- | Mode | Success | Failure |
145
- | ------- | ----------------------------- | --------------------------------------------------- |
146
- | `human` | Live progress, summary | Failing checks, previews, logs hint |
147
- | `agent` | One line: `PASS 4/4 (4.3s) …` | One line per problem, bounded preview, logs command |
148
- | `json` | Versioned result document | Same document, `"status": "failed"` |
149
- | `quiet` | Nothing | One line on stderr |
156
+ | Mode | Success | Failure |
157
+ | ------- | ----------------------------- | --------------------------------------------------------------- |
158
+ | `human` | Live status block, summary | Failing checks with command and rerun hint, previews, logs hint |
159
+ | `agent` | One line: `PASS 4/4 (4.3s) …` | One line per problem, bounded preview, logs command |
160
+ | `json` | Versioned result document | Same document, `"status": "failed"` |
161
+ | `quiet` | Nothing | One line on stderr |
162
+
163
+ In `human` mode on an interactive terminal, a live status block shows progress, each running check with its latest output line, and what the queued checks wait on; finished checks print above it. In CI, when piped, with `TERM=dumb`, or with `--no-live` / `VERIFIKA_LIVE=0`, `human` mode prints plain lines instead (one per check start and end, plus a note every 30 seconds for long checks), which read well in logs. `NO_COLOR` turns colour off in both.
150
164
 
151
165
  Exit codes: `0` passed (or nothing to verify), `1` verification failed, `2` usage or configuration error, `128+n` interrupted by a signal (`130` SIGINT, `143` SIGTERM).
152
166
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { ExitCode } from "./errors.js";
3
3
  import { main } from "./main.js";
4
+ import { systemTerminal } from "./reporters/live.js";
4
5
  const write = (stream) => (text) => {
5
6
  stream.write(text);
6
7
  };
@@ -17,6 +18,9 @@ main(process.argv.slice(2), {
17
18
  isTTY: Boolean(process.stdout.isTTY),
18
19
  out: write(process.stdout),
19
20
  err: write(process.stderr),
21
+ ...(process.stdout.isTTY
22
+ ? { terminal: systemTerminal(write(process.stdout), write(process.stderr), process.stdout) }
23
+ : {}),
20
24
  }).then((code) => {
21
25
  process.exitCode = code;
22
26
  }, (error) => {
package/dist/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { CONFIG_FILE } from "./config.js";
5
5
  import { VerifikaError } from "./errors.js";
@@ -14,6 +14,44 @@ const CANDIDATES = [
14
14
  ["build", ["build"]],
15
15
  ];
16
16
  const NPM_PLACEHOLDER_TEST = /no test specified/;
17
+ /** npm scripts `vfk init` adds: selective verification while working, the full gate for CI. */
18
+ export const VERIFY_SCRIPTS = {
19
+ verify: "vfk",
20
+ "verify:ci": "vfk --all",
21
+ };
22
+ /**
23
+ * Adds the `verify` and `verify:ci` scripts to package.json where they are
24
+ * absent; existing scripts are never overwritten. Keeps the file's indentation,
25
+ * line endings, key order and trailing newline.
26
+ */
27
+ export async function addVerifyScripts(root) {
28
+ const file = path.join(root, "package.json");
29
+ const raw = await readFile(file, "utf8");
30
+ const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
31
+ const text = raw.slice(bom.length);
32
+ const manifest = JSON.parse(text);
33
+ const scripts = (manifest.scripts ??= {});
34
+ const result = { added: [], kept: [] };
35
+ for (const [name, command] of Object.entries(VERIFY_SCRIPTS)) {
36
+ const existing = scripts[name];
37
+ if (existing === undefined) {
38
+ scripts[name] = command;
39
+ result.added.push(name);
40
+ }
41
+ else {
42
+ result.kept.push({ name, command: existing });
43
+ }
44
+ }
45
+ if (result.added.length === 0)
46
+ return result;
47
+ // A one-line manifest stays on one line.
48
+ const indent = text.trim().includes("\n") ? (/^[ \t]+(?=")/m.exec(text)?.[0] ?? " ") : "";
49
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
50
+ const trailing = /\r?\n$/.test(text) ? newline : "";
51
+ const json = JSON.stringify(manifest, null, indent).replace(/\n/g, newline);
52
+ await writeFile(file, `${bom}${json}${trailing}`);
53
+ return result;
54
+ }
17
55
  /**
18
56
  * Writes a starter verifika.config.json from the package.json scripts in `root`.
19
57
  * Checks are created without path filters (they run on any change); narrowing
package/dist/logs.js CHANGED
@@ -2,7 +2,9 @@ import { createReadStream } from "node:fs";
2
2
  import { createInterface } from "node:readline";
3
3
  import { open } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
5
6
  import { VerifikaError } from "./errors.js";
7
+ import { withPager } from "./pager.js";
6
8
  import { duration, isFailureStatus, selectionLabel } from "./reporters/reporter.js";
7
9
  import { plain, symbol } from "./reporters/human.js";
8
10
  import { stripAnsi } from "./preview.js";
@@ -15,7 +17,7 @@ import { readRun } from "./runs.js";
15
17
  export async function showLogs(request, output) {
16
18
  const run = await readRun(request.root, request.runId);
17
19
  if (request.checks.length === 0 && !request.failed) {
18
- output.out(renderRunSummary(run));
20
+ output.out(renderRunSummary(run, request.display));
19
21
  return 0;
20
22
  }
21
23
  let targets;
@@ -35,20 +37,29 @@ export async function showLogs(request, output) {
35
37
  targets = request.checks.map((name) => byName.get(name));
36
38
  }
37
39
  const headers = request.failed || targets.length > 1;
38
- for (const check of targets) {
39
- if (headers)
40
- output.out(`── ${check.name}: ${check.status} (${check.reason}) ──\n`);
41
- if (!check.log) {
42
- output.out(`${check.name} has no log: ${check.status} (${check.reason})\n`);
43
- continue;
40
+ const print = async (write) => {
41
+ for (const check of targets) {
42
+ if (headers)
43
+ await write(`── ${check.name}: ${check.status} (${check.reason}) ──\n`);
44
+ if (!check.log) {
45
+ await write(`${check.name} has no log: ${check.status} (${check.reason})\n`);
46
+ continue;
47
+ }
48
+ await printLog(path.join(request.root, check.log), request, write);
44
49
  }
45
- await printLog(path.join(request.root, check.log), request, output);
50
+ };
51
+ if (request.pager !== undefined && request.tail === undefined) {
52
+ await withPager(request.pager, request.env ?? process.env, print);
53
+ }
54
+ else {
55
+ await print((text) => output.out(text));
46
56
  }
47
57
  return 0;
48
58
  }
49
- export function renderRunSummary(run) {
59
+ export function renderRunSummary(run, display) {
60
+ const started = display ? describeTime(new Date(run.startedAt), display.now) : run.startedAt;
50
61
  const lines = [
51
- `Run ${run.runId} · ${run.status} · ${duration(run.durationMs)} · ${run.startedAt}`,
62
+ `Run ${run.runId} · ${run.status} · ${duration(run.durationMs)} · ${started}`,
52
63
  selectionLabel(run.selection, run.summary.skipped),
53
64
  ];
54
65
  if (run.status === "running")
@@ -60,8 +71,9 @@ export function renderRunSummary(run) {
60
71
  parts.push(duration(check.durationMs).padStart(6));
61
72
  if (check.status !== "passed")
62
73
  parts.push(check.reason);
63
- if (check.log)
64
- parts.push(check.log);
74
+ if (check.log) {
75
+ parts.push(display?.hyperlinks ? hyperlink(check.log, path.join(run.root, check.log)) : check.log);
76
+ }
65
77
  lines.push(parts.join(" "));
66
78
  }
67
79
  // One line for everything not selected; large configs would otherwise list dozens.
@@ -72,12 +84,41 @@ export function renderRunSummary(run) {
72
84
  }
73
85
  return `${lines.join("\n")}\n`;
74
86
  }
75
- async function printLog(file, { tail, stripAnsi: strip = false }, output) {
87
+ /**
88
+ * `today 01:12, 3m ago`; `yesterday 23:40, 2h ago`; otherwise the local date.
89
+ * Local time, because that is what the user's clock shows.
90
+ */
91
+ export function describeTime(time, now) {
92
+ const pad = (value) => String(value).padStart(2, "0");
93
+ const clock = `${pad(time.getHours())}:${pad(time.getMinutes())}`;
94
+ const day = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
95
+ const days = Math.round((day(now) - day(time)) / 86_400_000);
96
+ const date = days === 0
97
+ ? "today"
98
+ : days === 1
99
+ ? "yesterday"
100
+ : `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
101
+ return `${date} ${clock}, ${ago(now.getTime() - time.getTime())}`;
102
+ }
103
+ function ago(ms) {
104
+ if (ms < 60_000)
105
+ return ms < 1_000 ? "just now" : `${Math.floor(ms / 1000)}s ago`;
106
+ if (ms < 3_600_000)
107
+ return `${Math.floor(ms / 60_000)}m ago`;
108
+ if (ms < 86_400_000)
109
+ return `${Math.floor(ms / 3_600_000)}h ago`;
110
+ return `${Math.floor(ms / 86_400_000)}d ago`;
111
+ }
112
+ /** An OSC 8 terminal hyperlink to a local file; terminals without support show just the text. */
113
+ export function hyperlink(text, file) {
114
+ return `\u001b]8;;${pathToFileURL(file).href}\u001b\\${text}\u001b]8;;\u001b\\`;
115
+ }
116
+ async function printLog(file, { tail, stripAnsi: strip = false }, write) {
76
117
  try {
77
118
  if (tail !== undefined) {
78
119
  const text = await readTail(file, tail);
79
120
  const shown = strip ? stripAnsi(text) : text;
80
- output.out(shown.endsWith("\n") || shown === "" ? shown : `${shown}\n`);
121
+ await write(shown.endsWith("\n") || shown === "" ? shown : `${shown}\n`);
81
122
  return;
82
123
  }
83
124
  if (strip) {
@@ -87,16 +128,16 @@ async function printLog(file, { tail, stripAnsi: strip = false }, output) {
87
128
  crlfDelay: Infinity,
88
129
  });
89
130
  for await (const line of lines)
90
- output.out(`${stripAnsi(line)}\n`);
131
+ await write(`${stripAnsi(line)}\n`);
91
132
  return;
92
133
  }
93
134
  let last = "";
94
135
  for await (const chunk of createReadStream(file, { encoding: "utf8" })) {
95
- output.out(chunk);
136
+ await write(chunk);
96
137
  last = chunk;
97
138
  }
98
139
  if (last !== "" && !last.endsWith("\n"))
99
- output.out("\n");
140
+ await write("\n");
100
141
  }
101
142
  catch (error) {
102
143
  throw new VerifikaError(`cannot read log ${file}: ${error.message}`);
package/dist/main.js CHANGED
@@ -3,17 +3,21 @@ import path from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { findConfigFile, loadConfig } from "./config.js";
5
5
  import { ExitCode, VerifikaError } from "./errors.js";
6
- import { initConfig } from "./init.js";
6
+ import { addVerifyScripts, initConfig, VERIFY_SCRIPTS } from "./init.js";
7
7
  import { showLogs } from "./logs.js";
8
+ import { pagerAvailable, pagerCommand } from "./pager.js";
8
9
  import { plan } from "./planner.js";
9
10
  import { killAllNow } from "./process.js";
10
11
  import { agentReporter } from "./reporters/agent.js";
12
+ import { detectCi } from "./ci.js";
11
13
  import { colors, humanReporter, plain } from "./reporters/human.js";
12
14
  import { jsonReporter } from "./reporters/json.js";
15
+ import { liveReporter } from "./reporters/live.js";
13
16
  import { planJson, renderPlan } from "./reporters/plan.js";
14
17
  import { quietReporter } from "./reporters/quiet.js";
15
18
  import { OUTPUT_MODES } from "./reporters/reporter.js";
16
19
  import { runVerification } from "./runner.js";
20
+ import { estimateSkipped } from "./runs.js";
17
21
  const COMMANDS = ["run", "plan", "logs", "init", "help"];
18
22
  const DEFAULT_PREVIEW_LINES = 10;
19
23
  export const HELP = `vfk - run the checks your changes need, with minimal output
@@ -24,6 +28,7 @@ Usage:
24
28
  vfk plan [options] Show which checks would run and why
25
29
  vfk logs [check...] Show logs from the latest run
26
30
  vfk init [--force] Create verifika.config.json from package.json scripts
31
+ and add "verify"/"verify:ci" scripts if absent
27
32
 
28
33
  Selection (run, plan):
29
34
  --all Run every check
@@ -38,11 +43,15 @@ Execution (run):
38
43
  --fail-fast Stop everything after the first failure
39
44
  --no-fail-fast Run every selected check even after a failure
40
45
  --preview <lines> Failure preview lines per check (default ${DEFAULT_PREVIEW_LINES}, 0 = off)
46
+ --no-live Print progress line by line instead of a live
47
+ status block (or set VERIFIKA_LIVE=0)
41
48
 
42
49
  Logs:
43
50
  --failed Print logs of every failed check
44
51
  --tail <lines> Print only the last lines of each log
45
52
  --run <id> Read a specific run instead of the latest
53
+ --no-pager Print logs directly instead of through $PAGER
54
+ (paging only happens on a terminal)
46
55
 
47
56
  Output:
48
57
  --mode <mode> human | agent | json | quiet
@@ -77,6 +86,7 @@ const OPTIONS = {
77
86
  "fail-fast": { type: "boolean" },
78
87
  "no-fail-fast": { type: "boolean" },
79
88
  preview: { type: "string" },
89
+ "no-live": { type: "boolean" },
80
90
  },
81
91
  plan: { ...SHARED, ...SELECTION },
82
92
  logs: {
@@ -84,6 +94,7 @@ const OPTIONS = {
84
94
  failed: { type: "boolean" },
85
95
  tail: { type: "string" },
86
96
  run: { type: "string" },
97
+ "no-pager": { type: "boolean" },
87
98
  },
88
99
  init: { ...SHARED, force: { type: "boolean" } },
89
100
  help: SHARED,
@@ -123,6 +134,9 @@ export async function main(argv, io) {
123
134
  ...(values.run !== undefined ? { runId: values.run } : {}),
124
135
  ...(values.tail !== undefined ? { tail: integerOption(values.tail, "--tail", 1) } : {}),
125
136
  stripAnsi: !io.isTTY,
137
+ env: io.env,
138
+ // Agents in a pseudo-terminal must never get a pager waiting for a keypress.
139
+ ...(mode === "human" ? logsDisplay(io, values["no-pager"] === true) : {}),
126
140
  }, io);
127
141
  }
128
142
  const request = {
@@ -146,8 +160,10 @@ export async function main(argv, io) {
146
160
  if (command === "plan") {
147
161
  if (mode === "json")
148
162
  io.out(planJson(selection, config));
149
- else if (mode !== "quiet")
150
- io.out(renderPlan(selection, mode === "human" ? 50 : 20));
163
+ else if (mode === "human")
164
+ io.out(renderPlan(selection, 50, humanStyle(io)));
165
+ else if (mode === "agent")
166
+ io.out(renderPlan(selection, 20));
151
167
  return ExitCode.Passed;
152
168
  }
153
169
  if (values["fail-fast"] && values["no-fail-fast"]) {
@@ -160,18 +176,31 @@ export async function main(argv, io) {
160
176
  const previewLines = values.preview !== undefined
161
177
  ? integerOption(values.preview, "--preview", 0)
162
178
  : DEFAULT_PREVIEW_LINES;
163
- const result = await withInterruptHandling((signal) => runVerification({
164
- config,
165
- selection,
166
- concurrency,
167
- failFast,
168
- previewLines,
169
- signal,
170
- reporter: reporterFor(mode, io),
171
- version: version(),
172
- env: io.env,
173
- }));
174
- return result.exitCode;
179
+ const skipped = selection.checks.filter((check) => !check.selected).map((check) => check.name);
180
+ const reporter = reporterFor(mode, io, {
181
+ live: values["no-live"] !== true,
182
+ // Only people see the estimate; other modes skip reading earlier runs.
183
+ ...(mode === "human" && skipped.length > 0
184
+ ? { skipEstimate: await estimateSkipped(config.root, skipped) }
185
+ : {}),
186
+ });
187
+ try {
188
+ const result = await withInterruptHandling(() => reporter.onInterrupt?.(), (signal) => runVerification({
189
+ config,
190
+ selection,
191
+ concurrency,
192
+ failFast,
193
+ previewLines,
194
+ signal,
195
+ reporter,
196
+ version: version(),
197
+ env: io.env,
198
+ }));
199
+ return result.exitCode;
200
+ }
201
+ finally {
202
+ reporter.dispose?.();
203
+ }
175
204
  }
176
205
  catch (error) {
177
206
  if (error instanceof VerifikaError) {
@@ -191,6 +220,18 @@ async function runInit(io, values) {
191
220
  const root = values.config ? path.dirname(path.resolve(io.cwd, values.config)) : io.cwd;
192
221
  const { file, checks } = await initConfig(root, Boolean(values.force));
193
222
  io.out(`Created ${path.relative(io.cwd, file) || file} with checks: ${checks.join(", ")}\n`);
223
+ const scripts = await addVerifyScripts(root);
224
+ if (scripts.added.length > 0) {
225
+ const added = scripts.added.map((name) => `"${name}": "${VERIFY_SCRIPTS[name]}"`);
226
+ io.out(`Added package.json scripts: ${added.join(", ")}\n`);
227
+ }
228
+ for (const { name, command } of scripts.kept) {
229
+ if (command === VERIFY_SCRIPTS[name]) {
230
+ io.out(`package.json already has "${name}": "${command}"\n`);
231
+ continue;
232
+ }
233
+ io.out(`Kept the existing "${name}" script (${command}); to use Verifika, change it to "${VERIFY_SCRIPTS[name]}" once the checks cover everything it runs\n`);
234
+ }
194
235
  io.out('Review the commands, add "paths" to checks that only some files affect, format the file if your repository checks formatting, then run: vfk plan\n');
195
236
  return ExitCode.Passed;
196
237
  }
@@ -240,13 +281,22 @@ function guessMode(argv, io) {
240
281
  ? "human"
241
282
  : "agent";
242
283
  }
243
- function reporterFor(mode, io) {
284
+ function reporterFor(mode, io, options) {
244
285
  switch (mode) {
245
286
  case "human": {
246
- const color = io.isTTY && !io.env.NO_COLOR
247
- ? true
248
- : Boolean(io.env.FORCE_COLOR && io.env.FORCE_COLOR !== "0");
249
- return humanReporter(io, color ? colors : plain, { heartbeat: io.isTTY });
287
+ const style = humanStyle(io);
288
+ // The live block redraws in place, so it needs a real, capable terminal that
289
+ // nobody is logging; everywhere else the line reporter is the fallback.
290
+ const live = options.live &&
291
+ io.terminal !== undefined &&
292
+ io.isTTY &&
293
+ !detectCi(io.env).ci &&
294
+ io.env.TERM !== "dumb" &&
295
+ !isOff(io.env.VERIFIKA_LIVE);
296
+ const estimate = options.skipEstimate ? { skipEstimate: options.skipEstimate } : {};
297
+ if (live && io.terminal)
298
+ return liveReporter(io.terminal, style, estimate);
299
+ return humanReporter(io, style, { heartbeat: io.isTTY, ...estimate });
250
300
  }
251
301
  case "agent":
252
302
  return agentReporter(io);
@@ -256,6 +306,28 @@ function reporterFor(mode, io) {
256
306
  return quietReporter(io);
257
307
  }
258
308
  }
309
+ /**
310
+ * On a terminal, `vfk logs` speaks to a person: local relative times, clickable
311
+ * log paths (not in CI, whose log viewers show escape codes), and a pager.
312
+ */
313
+ function logsDisplay(io, noPager) {
314
+ if (!io.isTTY)
315
+ return {};
316
+ const capable = !detectCi(io.env).ci && io.env.TERM !== "dumb";
317
+ const pager = pagerCommand(io.env);
318
+ return {
319
+ display: { now: new Date(), hyperlinks: capable && !isOff(io.env.VERIFIKA_HYPERLINKS) },
320
+ ...(noPager || !pagerAvailable(pager, io.env) ? {} : { pager }),
321
+ };
322
+ }
323
+ /** Colour on a terminal unless NO_COLOR is set; FORCE_COLOR enables it elsewhere. */
324
+ function humanStyle(io) {
325
+ const color = io.isTTY && !io.env.NO_COLOR ? true : Boolean(io.env.FORCE_COLOR && io.env.FORCE_COLOR !== "0");
326
+ return color ? colors : plain;
327
+ }
328
+ function isOff(value) {
329
+ return value !== undefined && ["0", "false", "no", "off"].includes(value.trim().toLowerCase());
330
+ }
259
331
  function reportError(error, mode, io) {
260
332
  if (mode === "json") {
261
333
  const document = {
@@ -273,14 +345,16 @@ function reportError(error, mode, io) {
273
345
  * First SIGINT/SIGTERM/SIGHUP stops scheduling and terminates running checks
274
346
  * gracefully; a second one kills them immediately.
275
347
  */
276
- async function withInterruptHandling(operation) {
348
+ async function withInterruptHandling(onFirstSignal, operation) {
277
349
  const controller = new AbortController();
278
350
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
279
351
  const onSignal = (signal) => {
280
352
  if (controller.signal.aborted)
281
353
  killAllNow();
282
- else
354
+ else {
355
+ onFirstSignal();
283
356
  controller.abort(signal);
357
+ }
284
358
  };
285
359
  for (const signal of signals)
286
360
  process.on(signal, onSignal);
package/dist/pager.js ADDED
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+ import path from "node:path";
4
+ /** Used when `PAGER` is unset: quit if one screen, keep colours, leave output on screen. */
5
+ export const DEFAULT_PAGER = "less -R -F -X";
6
+ /** The pager command line from `PAGER`, or the default. */
7
+ export function pagerCommand(env) {
8
+ const configured = env.PAGER?.trim();
9
+ return configured ? configured : DEFAULT_PAGER;
10
+ }
11
+ /**
12
+ * Whether the pager's program exists, so a missing one (e.g. `less` on Windows)
13
+ * falls back to plain output instead of a shell error. Checks the first word of
14
+ * the command (which may be quoted): a path, or a name looked up on PATH (with
15
+ * PATHEXT on Windows).
16
+ */
17
+ export function pagerAvailable(command, env) {
18
+ const match = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command);
19
+ const program = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
20
+ if (program === "")
21
+ return false;
22
+ const isWindows = process.platform === "win32";
23
+ const extensions = isWindows
24
+ ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)]
25
+ : [""];
26
+ const candidates = program.includes("/") || program.includes("\\")
27
+ ? [program]
28
+ : (env.PATH ?? env.Path ?? "")
29
+ .split(path.delimiter)
30
+ .filter(Boolean)
31
+ .map((directory) => path.join(directory, program));
32
+ return candidates.some((candidate) => extensions.some((extension) => {
33
+ try {
34
+ accessSync(candidate + extension, isWindows ? constants.F_OK : constants.X_OK);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }));
41
+ }
42
+ /**
43
+ * Pipes everything `produce` writes into the pager, which takes over the
44
+ * terminal until the user quits it. Quitting early is not an error: further
45
+ * output is dropped. Ctrl-C belongs to the pager meanwhile, so Verifika ignores
46
+ * SIGINT until the pager exits (as git does).
47
+ */
48
+ export async function withPager(command, env, produce) {
49
+ // Like git: colours and quit-if-one-screen for a plain `less`, unless the user configured LESS.
50
+ const child = spawn(command, {
51
+ shell: true,
52
+ stdio: ["pipe", "inherit", "inherit"],
53
+ env: { LESS: "FRX", ...env },
54
+ });
55
+ const ignoreInterrupt = () => undefined;
56
+ process.on("SIGINT", ignoreInterrupt);
57
+ const exited = new Promise((resolve) => {
58
+ child.once("close", () => resolve());
59
+ child.once("error", () => resolve());
60
+ });
61
+ let closed = false;
62
+ const stdin = child.stdin;
63
+ stdin.on("error", () => {
64
+ // EPIPE: the user quit the pager before reading everything.
65
+ closed = true;
66
+ });
67
+ child.once("close", () => {
68
+ closed = true;
69
+ });
70
+ try {
71
+ await produce((text) => {
72
+ if (closed)
73
+ return;
74
+ if (!stdin.write(text)) {
75
+ return new Promise((resolve) => {
76
+ const done = () => {
77
+ stdin.off("drain", done);
78
+ child.off("close", done);
79
+ resolve();
80
+ };
81
+ stdin.once("drain", done);
82
+ child.once("close", done);
83
+ });
84
+ }
85
+ });
86
+ }
87
+ finally {
88
+ stdin.end();
89
+ await exited;
90
+ process.off("SIGINT", ignoreInterrupt);
91
+ }
92
+ }