@tested/cli 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -25,7 +25,7 @@ CI uses the composite Action (`tested-hq/cli/action@main`). It installs `@tested
25
25
  ```yaml
26
26
  - uses: tested-hq/cli/action@main
27
27
  with:
28
- version: 0.1.5
28
+ version: 0.1.6
29
29
  token: ${{ secrets.TESTED_TOKEN }}
30
30
  ```
31
31
 
@@ -38,6 +38,8 @@ tested run # writes coverage even if tests fail
38
38
  tested diff # report (exit 0)
39
39
  tested check # gate (exit 1 if under threshold)
40
40
  tested push --pr <n> # needs TESTED_TOKEN
41
+ tested token # mint URL from git remote + env names
42
+ tested whoami # whether a token is set (never prints it)
41
43
  ```
42
44
 
43
45
  ## Security
@@ -68,7 +70,7 @@ tested push --pr <n> # share URL on app.tested.dev (needs token)
68
70
 
69
71
  Human output is monochrome editorial craft: restrained color via picocolors (green / yellow / red for status only). ASCII badges (`[PASS]` / `[FAIL]`) and metric bars. Honors `NO_COLOR` and non-TTY (no ornaments when color is unsupported).
70
72
 
71
- `--json` on every command keeps agent schemas stable.
73
+ `--json` is available on setup, doctor, init, run, diff, check, push, token, whoami, explain, and ignores. `tested run --json` is tested's own summary (command, exit, coverage path). It is not forwarded to the test runner.
72
74
 
73
75
  ## Dogfood
74
76
 
@@ -133,7 +135,7 @@ If `thresholds` is missing from `.tested.yaml`, `tested check` prints a notice o
133
135
  ```yaml
134
136
  - uses: tested-hq/cli/action@main
135
137
  with:
136
- version: 0.1.5
138
+ version: 0.1.6
137
139
  push: true
138
140
  pr-number: ${{ github.event.pull_request.number }}
139
141
  token: ${{ secrets.TESTED_TOKEN }}
@@ -176,8 +178,9 @@ $ tested push
176
178
 
177
179
  ### `tested run` extra args
178
180
 
179
- Extra args are forwarded to the runner via argv (no shell). In CI /
180
- non-interactive mode / when `TESTED_SAFE_RUN=1`:
181
+ `tested run` writes `coverage/coverage-final.json` even when the suite fails (Vitest `--coverage.reportOnFailure`). Extra args are forwarded to the runner via argv (no shell). `--json` is consumed by tested and is not forwarded.
182
+
183
+ In CI / non-interactive mode / when `TESTED_SAFE_RUN=1`:
181
184
 
182
185
  - `--watch`, `--watchAll`, `-w` are **rejected** (hang risk)
183
186
  - `--config` / `-c` paths that resolve **outside the repo root** are **rejected**
package/dist/td.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command10 } from "commander";
4
+ import { Command as Command11 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
8
8
  import { join } from "path";
9
- import "commander";
9
+ import { Option } from "commander";
10
10
  import { simpleGit } from "simple-git";
11
11
 
12
12
  // src/output/ui.ts
@@ -115,6 +115,15 @@ function formatCliError(message) {
115
115
  "or pass --token <token> (avoid on shared hosts: visible in ps)"
116
116
  ]);
117
117
  }
118
+ if (/git base ref .+ not found/i.test(m)) {
119
+ const refMatch = m.match(/git base ref "([^"]+)" not found/i);
120
+ const ref = refMatch?.[1] ?? "the requested ref";
121
+ return errorBlock(`git base ref "${ref}" not found`, [
122
+ "That ref is not in this repository.",
123
+ "Pass --base HEAD~1 (or a branch / commit that exists)",
124
+ "or set `base:` in .tested.yaml"
125
+ ]);
126
+ }
118
127
  if (/invalid PR number/i.test(m)) {
119
128
  return errorBlock("invalid PR number", [
120
129
  m.replace(/^invalid PR number\s*/i, "").replace(/^—\s*/, "") || m,
@@ -209,6 +218,16 @@ var PRE_PUSH_HOOK_BODY = `#!/usr/bin/env sh
209
218
  # Installed by \`tested init\`. Skip with \`git push --no-verify\`.
210
219
  tested diff
211
220
  `;
221
+ function resolveInitHooks(opts) {
222
+ if (!opts.hooks) return false;
223
+ const env = opts.env ?? process.env;
224
+ const ci = env.CI === "1" || env.CI === "true" || env.GITHUB_ACTIONS === "true";
225
+ const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
226
+ if ((!isTTY || ci) && !opts.force) {
227
+ return false;
228
+ }
229
+ return true;
230
+ }
212
231
  async function runInit(opts) {
213
232
  const { cwd, force, hooks } = opts;
214
233
  const pkgJsonPath = join(cwd, "package.json");
@@ -294,13 +313,13 @@ function formatInitResultHuman(result) {
294
313
  return lines.join("\n");
295
314
  }
296
315
  function registerInitCommand(program2) {
297
- program2.command("init").description("Initialize tested.dev in the current project (writes .tested.yaml)").option("--force", "Overwrite an existing .tested.yaml", false).option("--no-hooks", "Skip installing the husky pre-push hook").option("--json", "Emit JSON instead of human text", false).action(async (opts) => {
316
+ program2.command("init").description("Initialize tested.dev in the current project (writes .tested.yaml)").option("--force", "Overwrite an existing .tested.yaml", false).option("--hooks", "Install husky pre-push hook (skipped by default in CI / non-TTY)", false).addOption(new Option("--no-hooks").hideHelp()).option("--json", "Emit JSON instead of human text", false).action(async (opts) => {
298
317
  try {
299
- if (opts.hooks && !process.stdin.isTTY && !opts.force) {
318
+ if (opts.hooks && !resolveInitHooks({ hooks: true, force: opts.force })) {
300
319
  process.stderr.write(
301
320
  errorBlock(
302
321
  "--hooks in a non-TTY environment requires --force to confirm",
303
- ["Would install a git hook unattended."]
322
+ ["Would install a git hook unattended.", "Omit --hooks to init without a hook."]
304
323
  )
305
324
  );
306
325
  process.exit(1);
@@ -308,7 +327,7 @@ function registerInitCommand(program2) {
308
327
  const result = await runInit({
309
328
  cwd: process.cwd(),
310
329
  force: opts.force,
311
- hooks: opts.hooks
330
+ hooks: resolveInitHooks({ hooks: opts.hooks, force: opts.force })
312
331
  });
313
332
  if (opts.json) {
314
333
  process.stdout.write(JSON.stringify(buildInitJsonOutput(result), null, 2) + "\n");
@@ -617,8 +636,35 @@ async function openRepo(cwd) {
617
636
  const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
618
637
  return { git, repoRoot };
619
638
  }
620
- async function resolveBase(ctx, base) {
621
- return (await ctx.git.revparse([base])).trim();
639
+ async function tryRevparse(ctx, ref) {
640
+ try {
641
+ const sha = (await ctx.git.revparse([ref])).trim();
642
+ return sha || null;
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
647
+ function missingGitRefMessage(ref) {
648
+ return `git base ref "${ref}" not found`;
649
+ }
650
+ async function resolveEffectiveBase(ctx, requested) {
651
+ const requestedSha = await tryRevparse(ctx, requested);
652
+ if (!requestedSha) {
653
+ throw new Error(missingGitRefMessage(requested));
654
+ }
655
+ const head = await headSha(ctx);
656
+ if (requestedSha !== head) {
657
+ return { ref: requested, sha: requestedSha };
658
+ }
659
+ const upstream = await tryRevparse(ctx, "@{upstream}");
660
+ if (upstream && upstream !== head) {
661
+ return { ref: "@{upstream}", sha: upstream };
662
+ }
663
+ const parent = await tryRevparse(ctx, "HEAD~1");
664
+ if (parent) {
665
+ return { ref: "HEAD~1", sha: parent };
666
+ }
667
+ return { ref: requested, sha: requestedSha };
622
668
  }
623
669
  async function headSha(ctx) {
624
670
  return (await ctx.git.revparse(["HEAD"])).trim();
@@ -914,8 +960,8 @@ function buildDiffOutput(args) {
914
960
  async function computeDiff(opts) {
915
961
  const { cwd, config } = opts;
916
962
  const ctx = opts.ctx ?? await openRepo(cwd);
917
- const baseRef = assertSafeGitRef(opts.baseRef ?? config.base);
918
- const base = await resolveBase(ctx, baseRef);
963
+ const requested = assertSafeGitRef(opts.baseRef ?? config.base);
964
+ const { ref: baseRef, sha: base } = await resolveEffectiveBase(ctx, requested);
919
965
  const head = await headSha(ctx);
920
966
  const diffText = await unifiedDiff(ctx, base);
921
967
  const addedByFile = parseUnifiedDiff(diffText);
@@ -1915,7 +1961,7 @@ import pc3 from "picocolors";
1915
1961
  // package.json
1916
1962
  var package_default = {
1917
1963
  name: "@tested/cli",
1918
- version: "0.1.5",
1964
+ version: "0.1.6",
1919
1965
  description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
1920
1966
  license: "MIT",
1921
1967
  homepage: "https://tested.dev",
@@ -2160,6 +2206,26 @@ import { existsSync as existsSync5 } from "fs";
2160
2206
  import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
2161
2207
  import { spawn } from "child_process";
2162
2208
  import "commander";
2209
+ function splitRunArgs(extraArgs) {
2210
+ let json = false;
2211
+ const forwarded = [];
2212
+ let passthrough = false;
2213
+ for (const a of extraArgs) {
2214
+ if (!passthrough && a === "--") {
2215
+ passthrough = true;
2216
+ continue;
2217
+ }
2218
+ if (!passthrough && (a === "--json" || a === "--json=true")) {
2219
+ json = true;
2220
+ continue;
2221
+ }
2222
+ forwarded.push(a);
2223
+ }
2224
+ return { json, forwarded };
2225
+ }
2226
+ function buildRunJsonOutput(input) {
2227
+ return { schemaVersion: 1, ...input };
2228
+ }
2163
2229
  function resolveRunCommand(opts) {
2164
2230
  const runner = opts.runner ?? "vitest";
2165
2231
  switch (runner) {
@@ -2242,12 +2308,15 @@ function assertSafeRunArgs(extraArgs, repoRoot) {
2242
2308
  }
2243
2309
  function registerRunCommand(program2) {
2244
2310
  program2.command("run").description(
2245
- "Run the user's test suite with coverage enabled (runner read from .tested.yaml; defaults to vitest)"
2246
- ).allowUnknownOption(true).argument("[args...]", "Extra arguments forwarded to the runner").action(async (extraArgs) => {
2311
+ "Run the project test suite with coverage (writes coverage even if tests fail)"
2312
+ ).option("--json", "Emit tested JSON summary (not forwarded to the runner)", false).allowUnknownOption(true).argument("[args...]", "Extra arguments forwarded to the runner").action(async (extraArgs, opts) => {
2247
2313
  const cwd = process.cwd();
2314
+ const split = splitRunArgs(extraArgs ?? []);
2315
+ const json = Boolean(opts.json) || split.json;
2316
+ const forwarded = split.forwarded;
2248
2317
  try {
2249
2318
  if (shouldEnforceSafeRun()) {
2250
- assertSafeRunArgs(extraArgs, cwd);
2319
+ assertSafeRunArgs(forwarded, cwd);
2251
2320
  }
2252
2321
  } catch (err) {
2253
2322
  const message = err instanceof Error ? err.message : String(err);
@@ -2259,14 +2328,28 @@ function registerRunCommand(program2) {
2259
2328
  const coveragePath = resolve5(cwd, config.coverage.path);
2260
2329
  const { command, args } = resolveRunCommand({
2261
2330
  runner: config.testRunner,
2262
- extraArgs
2331
+ extraArgs: forwarded
2263
2332
  });
2264
- process.stderr.write(heading("tested.dev \u2014 running tests with coverage") + "\n");
2265
- process.stderr.write(dim(`${command} ${args.join(" ")}`) + "\n\n");
2333
+ if (!json) {
2334
+ process.stderr.write(heading("tested.dev \u2014 running tests with coverage") + "\n");
2335
+ process.stderr.write(dim(`${command} ${args.join(" ")}`) + "\n\n");
2336
+ }
2266
2337
  const child = spawn(command, args, { stdio: "inherit" });
2267
2338
  child.on("exit", (code) => {
2268
2339
  const exit = code ?? 1;
2269
2340
  const coverageWritten = existsSync5(coveragePath);
2341
+ if (json) {
2342
+ const payload = buildRunJsonOutput({
2343
+ command,
2344
+ args,
2345
+ exitCode: exit,
2346
+ coverageWritten,
2347
+ coveragePath: config.coverage.path
2348
+ });
2349
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2350
+ process.exit(exit);
2351
+ return;
2352
+ }
2270
2353
  if (exit === 0) {
2271
2354
  process.stderr.write("\n");
2272
2355
  process.stderr.write(tip("tested diff") + "\n");
@@ -2381,24 +2464,21 @@ function formatHuman(out, opts = {}) {
2381
2464
  );
2382
2465
  }
2383
2466
  }
2467
+ const patchFiles = out.files.filter((f) => f.patchCoverage !== null);
2384
2468
  if (isEmptyPatch(out.patch)) {
2385
2469
  lines.push("");
2386
2470
  lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
2387
- } else if (out.files.length > 0) {
2471
+ } else if (patchFiles.length > 0) {
2388
2472
  lines.push("");
2389
2473
  lines.push(heading("Files in diff:"));
2390
- const anyPatch = out.files.some((f) => f.patchCoverage !== null);
2391
- if (!anyPatch) {
2392
- lines.push(dim(` (project coverage \u2014 ${EMPTY_PATCH_REASON})`));
2393
- }
2394
- for (const f of out.files) {
2395
- const hasPatch = f.patchCoverage !== null;
2396
- const pctPart = hasPatch ? coloredPctCell(f.patchCoverage) : coloredPctCell(f.projectCoverage);
2397
- lines.push(` ${pctPart} ${f.path}`);
2474
+ for (const f of patchFiles) {
2475
+ const pct3 = f.patchCoverage;
2476
+ if (pct3 === null) continue;
2477
+ lines.push(` ${coloredPctCell(pct3)} ${f.path}`);
2398
2478
  if (f.uncoveredRanges.length > 0) {
2399
2479
  const ranges = formatRangeList(f.uncoveredRanges);
2400
2480
  lines.push(dim(` uncovered: ${ranges}`));
2401
- } else if (hasPatch) {
2481
+ } else {
2402
2482
  lines.push(dim(" fully covered in patch"));
2403
2483
  }
2404
2484
  }
@@ -2682,17 +2762,150 @@ function formatIgnoresList(patterns, asJson) {
2682
2762
  if (asJson) return JSON.stringify({ ignores: [...patterns] });
2683
2763
  return patterns.join("\n");
2684
2764
  }
2765
+ async function printIgnores(asJson) {
2766
+ const config = await loadConfig({ cwd: process.cwd() });
2767
+ process.stdout.write(formatIgnoresList(config.ignores, asJson) + "\n");
2768
+ }
2685
2769
  function registerIgnoresCommand(program2) {
2686
- const cmd = program2.command("ignores").description("Inspect the canonical ignore list");
2687
- cmd.command("list").description("Print all ignore patterns (defaults + user)").option("--json", "Emit JSON", false).action(async (opts) => {
2688
- const config = await loadConfig({ cwd: process.cwd() });
2689
- process.stdout.write(formatIgnoresList(config.ignores, opts.json) + "\n");
2770
+ program2.command("ignores").description("List ignore patterns (defaults + user). `list` is optional.").argument("[subcommand]", 'optional "list" (the default)').option("--json", "Emit JSON", false).action(async (subcommand, opts) => {
2771
+ if (subcommand !== void 0 && subcommand !== "list") {
2772
+ throw new Error(`unknown ignores subcommand "${subcommand}". Try: tested ignores list`);
2773
+ }
2774
+ await printIgnores(opts.json);
2775
+ });
2776
+ }
2777
+
2778
+ // src/commands/token.ts
2779
+ import "commander";
2780
+ async function resolveRepoIdentity(opts) {
2781
+ try {
2782
+ const open = opts.openRepoFn ?? openRepo;
2783
+ const getUrl = opts.remoteUrlFn ?? remoteUrl;
2784
+ const ctx = await open(opts.cwd);
2785
+ const url = await getUrl(ctx, "origin");
2786
+ const parsed = parseGitHubRemote(url);
2787
+ return parsed ?? { owner: null, name: null };
2788
+ } catch {
2789
+ return { owner: null, name: null };
2790
+ }
2791
+ }
2792
+ function tokenSourceFromEnv(env) {
2793
+ if (env.TESTED_TOKEN) return "TESTED_TOKEN";
2794
+ if (env.TESTED_INGEST_TOKEN) return "TESTED_INGEST_TOKEN";
2795
+ if (env.TESTED_TOKEN_FILE) return "TESTED_TOKEN_FILE";
2796
+ return "token";
2797
+ }
2798
+ function formatTokenHuman(identity) {
2799
+ const lines = [
2800
+ heading("tested.dev \u2014 token"),
2801
+ "",
2802
+ ...tokenMintGuidance(identity).map((line) => dim(` ${line}`)),
2803
+ ""
2804
+ ];
2805
+ return lines.join("\n");
2806
+ }
2807
+ function formatTokenJson(identity) {
2808
+ return JSON.stringify(
2809
+ {
2810
+ schemaVersion: 1,
2811
+ mintUrl: ingestTokenSettingsUrl(identity.owner, identity.name),
2812
+ envNames: [...INGEST_TOKEN_ENV_NAMES],
2813
+ owner: identity.owner,
2814
+ name: identity.name
2815
+ },
2816
+ null,
2817
+ 2
2818
+ ) + "\n";
2819
+ }
2820
+ function formatWhoamiHuman(result) {
2821
+ const lines = [heading("tested.dev \u2014 whoami"), ""];
2822
+ if (result.tokenSet) {
2823
+ lines.push(dim(` token: set via ${result.source} (value not shown)`));
2824
+ } else {
2825
+ lines.push(dim(" token: not set"));
2826
+ for (const line of tokenMintGuidance(result.identity)) {
2827
+ lines.push(dim(` ${line}`));
2828
+ }
2829
+ }
2830
+ lines.push("");
2831
+ return lines.join("\n");
2832
+ }
2833
+ function formatWhoamiJson(result) {
2834
+ return JSON.stringify(
2835
+ {
2836
+ schemaVersion: 1,
2837
+ tokenSet: result.tokenSet,
2838
+ source: result.source,
2839
+ mintUrl: ingestTokenSettingsUrl(result.identity.owner, result.identity.name),
2840
+ envNames: [...INGEST_TOKEN_ENV_NAMES]
2841
+ },
2842
+ null,
2843
+ 2
2844
+ ) + "\n";
2845
+ }
2846
+ async function runToken(opts) {
2847
+ const identity = await resolveRepoIdentity(opts);
2848
+ const stdout = opts.json ? formatTokenJson(identity) : formatTokenHuman(identity);
2849
+ return { stdout, exitCode: 0 };
2850
+ }
2851
+ async function runWhoami(opts) {
2852
+ const env = opts.env ?? process.env;
2853
+ const identity = await resolveRepoIdentity(opts);
2854
+ const resolve7 = opts.resolveTokenFn ?? resolveToken;
2855
+ let tokenSet = false;
2856
+ let source = null;
2857
+ try {
2858
+ const token = resolve7({ env, isTTY: false, warn: () => {
2859
+ } });
2860
+ tokenSet = Boolean(token);
2861
+ source = tokenSet ? tokenSourceFromEnv(env) : null;
2862
+ } catch (err) {
2863
+ const message = err instanceof Error ? err.message : String(err);
2864
+ return {
2865
+ stdout: "",
2866
+ stderr: formatCliError(message),
2867
+ exitCode: 1
2868
+ };
2869
+ }
2870
+ const result = { tokenSet, source, identity, exitCode: tokenSet ? 0 : 1 };
2871
+ const stdout = opts.json ? formatWhoamiJson(result) : formatWhoamiHuman(result);
2872
+ return { stdout, stderr: "", exitCode: result.exitCode };
2873
+ }
2874
+ function registerTokenCommand(program2) {
2875
+ program2.command("token").description("Print the ingest-token mint URL and accepted env names").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
2876
+ try {
2877
+ const result = await runToken({ cwd: process.cwd(), json: opts.json });
2878
+ process.stdout.write(result.stdout.endsWith("\n") ? result.stdout : result.stdout + "\n");
2879
+ process.exitCode = result.exitCode;
2880
+ } catch (err) {
2881
+ const message = err instanceof Error ? err.message : String(err);
2882
+ process.stderr.write(formatCliError(message));
2883
+ process.exitCode = 1;
2884
+ }
2885
+ });
2886
+ }
2887
+ function registerWhoamiCommand(program2) {
2888
+ program2.command("whoami").description("Report whether an ingest token is set (never prints the value)").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
2889
+ try {
2890
+ const result = await runWhoami({ cwd: process.cwd(), json: opts.json });
2891
+ if (result.stderr) process.stderr.write(result.stderr);
2892
+ if (result.stdout) {
2893
+ process.stdout.write(
2894
+ result.stdout.endsWith("\n") ? result.stdout : result.stdout + "\n"
2895
+ );
2896
+ }
2897
+ process.exitCode = result.exitCode;
2898
+ } catch (err) {
2899
+ const message = err instanceof Error ? err.message : String(err);
2900
+ process.stderr.write(errorBlock(message));
2901
+ process.exitCode = 1;
2902
+ }
2690
2903
  });
2691
2904
  }
2692
2905
 
2693
2906
  // src/cli.ts
2694
2907
  function createProgram() {
2695
- const program2 = new Command10();
2908
+ const program2 = new Command11();
2696
2909
  program2.name("tested").description(
2697
2910
  [
2698
2911
  "Coverage your agent can use.",
@@ -2708,6 +2921,8 @@ function createProgram() {
2708
2921
  registerDiffCommand(program2);
2709
2922
  registerCheckCommand(program2);
2710
2923
  registerPushCommand(program2);
2924
+ registerTokenCommand(program2);
2925
+ registerWhoamiCommand(program2);
2711
2926
  registerExplainCommand(program2);
2712
2927
  registerIgnoresCommand(program2);
2713
2928
  return program2;
package/dist/tested.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command10 } from "commander";
4
+ import { Command as Command11 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
8
8
  import { join } from "path";
9
- import "commander";
9
+ import { Option } from "commander";
10
10
  import { simpleGit } from "simple-git";
11
11
 
12
12
  // src/output/ui.ts
@@ -115,6 +115,15 @@ function formatCliError(message) {
115
115
  "or pass --token <token> (avoid on shared hosts: visible in ps)"
116
116
  ]);
117
117
  }
118
+ if (/git base ref .+ not found/i.test(m)) {
119
+ const refMatch = m.match(/git base ref "([^"]+)" not found/i);
120
+ const ref = refMatch?.[1] ?? "the requested ref";
121
+ return errorBlock(`git base ref "${ref}" not found`, [
122
+ "That ref is not in this repository.",
123
+ "Pass --base HEAD~1 (or a branch / commit that exists)",
124
+ "or set `base:` in .tested.yaml"
125
+ ]);
126
+ }
118
127
  if (/invalid PR number/i.test(m)) {
119
128
  return errorBlock("invalid PR number", [
120
129
  m.replace(/^invalid PR number\s*/i, "").replace(/^—\s*/, "") || m,
@@ -209,6 +218,16 @@ var PRE_PUSH_HOOK_BODY = `#!/usr/bin/env sh
209
218
  # Installed by \`tested init\`. Skip with \`git push --no-verify\`.
210
219
  tested diff
211
220
  `;
221
+ function resolveInitHooks(opts) {
222
+ if (!opts.hooks) return false;
223
+ const env = opts.env ?? process.env;
224
+ const ci = env.CI === "1" || env.CI === "true" || env.GITHUB_ACTIONS === "true";
225
+ const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
226
+ if ((!isTTY || ci) && !opts.force) {
227
+ return false;
228
+ }
229
+ return true;
230
+ }
212
231
  async function runInit(opts) {
213
232
  const { cwd, force, hooks } = opts;
214
233
  const pkgJsonPath = join(cwd, "package.json");
@@ -294,13 +313,13 @@ function formatInitResultHuman(result) {
294
313
  return lines.join("\n");
295
314
  }
296
315
  function registerInitCommand(program2) {
297
- program2.command("init").description("Initialize tested.dev in the current project (writes .tested.yaml)").option("--force", "Overwrite an existing .tested.yaml", false).option("--no-hooks", "Skip installing the husky pre-push hook").option("--json", "Emit JSON instead of human text", false).action(async (opts) => {
316
+ program2.command("init").description("Initialize tested.dev in the current project (writes .tested.yaml)").option("--force", "Overwrite an existing .tested.yaml", false).option("--hooks", "Install husky pre-push hook (skipped by default in CI / non-TTY)", false).addOption(new Option("--no-hooks").hideHelp()).option("--json", "Emit JSON instead of human text", false).action(async (opts) => {
298
317
  try {
299
- if (opts.hooks && !process.stdin.isTTY && !opts.force) {
318
+ if (opts.hooks && !resolveInitHooks({ hooks: true, force: opts.force })) {
300
319
  process.stderr.write(
301
320
  errorBlock(
302
321
  "--hooks in a non-TTY environment requires --force to confirm",
303
- ["Would install a git hook unattended."]
322
+ ["Would install a git hook unattended.", "Omit --hooks to init without a hook."]
304
323
  )
305
324
  );
306
325
  process.exit(1);
@@ -308,7 +327,7 @@ function registerInitCommand(program2) {
308
327
  const result = await runInit({
309
328
  cwd: process.cwd(),
310
329
  force: opts.force,
311
- hooks: opts.hooks
330
+ hooks: resolveInitHooks({ hooks: opts.hooks, force: opts.force })
312
331
  });
313
332
  if (opts.json) {
314
333
  process.stdout.write(JSON.stringify(buildInitJsonOutput(result), null, 2) + "\n");
@@ -617,8 +636,35 @@ async function openRepo(cwd) {
617
636
  const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
618
637
  return { git, repoRoot };
619
638
  }
620
- async function resolveBase(ctx, base) {
621
- return (await ctx.git.revparse([base])).trim();
639
+ async function tryRevparse(ctx, ref) {
640
+ try {
641
+ const sha = (await ctx.git.revparse([ref])).trim();
642
+ return sha || null;
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
647
+ function missingGitRefMessage(ref) {
648
+ return `git base ref "${ref}" not found`;
649
+ }
650
+ async function resolveEffectiveBase(ctx, requested) {
651
+ const requestedSha = await tryRevparse(ctx, requested);
652
+ if (!requestedSha) {
653
+ throw new Error(missingGitRefMessage(requested));
654
+ }
655
+ const head = await headSha(ctx);
656
+ if (requestedSha !== head) {
657
+ return { ref: requested, sha: requestedSha };
658
+ }
659
+ const upstream = await tryRevparse(ctx, "@{upstream}");
660
+ if (upstream && upstream !== head) {
661
+ return { ref: "@{upstream}", sha: upstream };
662
+ }
663
+ const parent = await tryRevparse(ctx, "HEAD~1");
664
+ if (parent) {
665
+ return { ref: "HEAD~1", sha: parent };
666
+ }
667
+ return { ref: requested, sha: requestedSha };
622
668
  }
623
669
  async function headSha(ctx) {
624
670
  return (await ctx.git.revparse(["HEAD"])).trim();
@@ -914,8 +960,8 @@ function buildDiffOutput(args) {
914
960
  async function computeDiff(opts) {
915
961
  const { cwd, config } = opts;
916
962
  const ctx = opts.ctx ?? await openRepo(cwd);
917
- const baseRef = assertSafeGitRef(opts.baseRef ?? config.base);
918
- const base = await resolveBase(ctx, baseRef);
963
+ const requested = assertSafeGitRef(opts.baseRef ?? config.base);
964
+ const { ref: baseRef, sha: base } = await resolveEffectiveBase(ctx, requested);
919
965
  const head = await headSha(ctx);
920
966
  const diffText = await unifiedDiff(ctx, base);
921
967
  const addedByFile = parseUnifiedDiff(diffText);
@@ -1915,7 +1961,7 @@ import pc3 from "picocolors";
1915
1961
  // package.json
1916
1962
  var package_default = {
1917
1963
  name: "@tested/cli",
1918
- version: "0.1.5",
1964
+ version: "0.1.6",
1919
1965
  description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
1920
1966
  license: "MIT",
1921
1967
  homepage: "https://tested.dev",
@@ -2160,6 +2206,26 @@ import { existsSync as existsSync5 } from "fs";
2160
2206
  import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
2161
2207
  import { spawn } from "child_process";
2162
2208
  import "commander";
2209
+ function splitRunArgs(extraArgs) {
2210
+ let json = false;
2211
+ const forwarded = [];
2212
+ let passthrough = false;
2213
+ for (const a of extraArgs) {
2214
+ if (!passthrough && a === "--") {
2215
+ passthrough = true;
2216
+ continue;
2217
+ }
2218
+ if (!passthrough && (a === "--json" || a === "--json=true")) {
2219
+ json = true;
2220
+ continue;
2221
+ }
2222
+ forwarded.push(a);
2223
+ }
2224
+ return { json, forwarded };
2225
+ }
2226
+ function buildRunJsonOutput(input) {
2227
+ return { schemaVersion: 1, ...input };
2228
+ }
2163
2229
  function resolveRunCommand(opts) {
2164
2230
  const runner = opts.runner ?? "vitest";
2165
2231
  switch (runner) {
@@ -2242,12 +2308,15 @@ function assertSafeRunArgs(extraArgs, repoRoot) {
2242
2308
  }
2243
2309
  function registerRunCommand(program2) {
2244
2310
  program2.command("run").description(
2245
- "Run the user's test suite with coverage enabled (runner read from .tested.yaml; defaults to vitest)"
2246
- ).allowUnknownOption(true).argument("[args...]", "Extra arguments forwarded to the runner").action(async (extraArgs) => {
2311
+ "Run the project test suite with coverage (writes coverage even if tests fail)"
2312
+ ).option("--json", "Emit tested JSON summary (not forwarded to the runner)", false).allowUnknownOption(true).argument("[args...]", "Extra arguments forwarded to the runner").action(async (extraArgs, opts) => {
2247
2313
  const cwd = process.cwd();
2314
+ const split = splitRunArgs(extraArgs ?? []);
2315
+ const json = Boolean(opts.json) || split.json;
2316
+ const forwarded = split.forwarded;
2248
2317
  try {
2249
2318
  if (shouldEnforceSafeRun()) {
2250
- assertSafeRunArgs(extraArgs, cwd);
2319
+ assertSafeRunArgs(forwarded, cwd);
2251
2320
  }
2252
2321
  } catch (err) {
2253
2322
  const message = err instanceof Error ? err.message : String(err);
@@ -2259,14 +2328,28 @@ function registerRunCommand(program2) {
2259
2328
  const coveragePath = resolve5(cwd, config.coverage.path);
2260
2329
  const { command, args } = resolveRunCommand({
2261
2330
  runner: config.testRunner,
2262
- extraArgs
2331
+ extraArgs: forwarded
2263
2332
  });
2264
- process.stderr.write(heading("tested.dev \u2014 running tests with coverage") + "\n");
2265
- process.stderr.write(dim(`${command} ${args.join(" ")}`) + "\n\n");
2333
+ if (!json) {
2334
+ process.stderr.write(heading("tested.dev \u2014 running tests with coverage") + "\n");
2335
+ process.stderr.write(dim(`${command} ${args.join(" ")}`) + "\n\n");
2336
+ }
2266
2337
  const child = spawn(command, args, { stdio: "inherit" });
2267
2338
  child.on("exit", (code) => {
2268
2339
  const exit = code ?? 1;
2269
2340
  const coverageWritten = existsSync5(coveragePath);
2341
+ if (json) {
2342
+ const payload = buildRunJsonOutput({
2343
+ command,
2344
+ args,
2345
+ exitCode: exit,
2346
+ coverageWritten,
2347
+ coveragePath: config.coverage.path
2348
+ });
2349
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2350
+ process.exit(exit);
2351
+ return;
2352
+ }
2270
2353
  if (exit === 0) {
2271
2354
  process.stderr.write("\n");
2272
2355
  process.stderr.write(tip("tested diff") + "\n");
@@ -2381,24 +2464,21 @@ function formatHuman(out, opts = {}) {
2381
2464
  );
2382
2465
  }
2383
2466
  }
2467
+ const patchFiles = out.files.filter((f) => f.patchCoverage !== null);
2384
2468
  if (isEmptyPatch(out.patch)) {
2385
2469
  lines.push("");
2386
2470
  lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
2387
- } else if (out.files.length > 0) {
2471
+ } else if (patchFiles.length > 0) {
2388
2472
  lines.push("");
2389
2473
  lines.push(heading("Files in diff:"));
2390
- const anyPatch = out.files.some((f) => f.patchCoverage !== null);
2391
- if (!anyPatch) {
2392
- lines.push(dim(` (project coverage \u2014 ${EMPTY_PATCH_REASON})`));
2393
- }
2394
- for (const f of out.files) {
2395
- const hasPatch = f.patchCoverage !== null;
2396
- const pctPart = hasPatch ? coloredPctCell(f.patchCoverage) : coloredPctCell(f.projectCoverage);
2397
- lines.push(` ${pctPart} ${f.path}`);
2474
+ for (const f of patchFiles) {
2475
+ const pct3 = f.patchCoverage;
2476
+ if (pct3 === null) continue;
2477
+ lines.push(` ${coloredPctCell(pct3)} ${f.path}`);
2398
2478
  if (f.uncoveredRanges.length > 0) {
2399
2479
  const ranges = formatRangeList(f.uncoveredRanges);
2400
2480
  lines.push(dim(` uncovered: ${ranges}`));
2401
- } else if (hasPatch) {
2481
+ } else {
2402
2482
  lines.push(dim(" fully covered in patch"));
2403
2483
  }
2404
2484
  }
@@ -2682,17 +2762,150 @@ function formatIgnoresList(patterns, asJson) {
2682
2762
  if (asJson) return JSON.stringify({ ignores: [...patterns] });
2683
2763
  return patterns.join("\n");
2684
2764
  }
2765
+ async function printIgnores(asJson) {
2766
+ const config = await loadConfig({ cwd: process.cwd() });
2767
+ process.stdout.write(formatIgnoresList(config.ignores, asJson) + "\n");
2768
+ }
2685
2769
  function registerIgnoresCommand(program2) {
2686
- const cmd = program2.command("ignores").description("Inspect the canonical ignore list");
2687
- cmd.command("list").description("Print all ignore patterns (defaults + user)").option("--json", "Emit JSON", false).action(async (opts) => {
2688
- const config = await loadConfig({ cwd: process.cwd() });
2689
- process.stdout.write(formatIgnoresList(config.ignores, opts.json) + "\n");
2770
+ program2.command("ignores").description("List ignore patterns (defaults + user). `list` is optional.").argument("[subcommand]", 'optional "list" (the default)').option("--json", "Emit JSON", false).action(async (subcommand, opts) => {
2771
+ if (subcommand !== void 0 && subcommand !== "list") {
2772
+ throw new Error(`unknown ignores subcommand "${subcommand}". Try: tested ignores list`);
2773
+ }
2774
+ await printIgnores(opts.json);
2775
+ });
2776
+ }
2777
+
2778
+ // src/commands/token.ts
2779
+ import "commander";
2780
+ async function resolveRepoIdentity(opts) {
2781
+ try {
2782
+ const open = opts.openRepoFn ?? openRepo;
2783
+ const getUrl = opts.remoteUrlFn ?? remoteUrl;
2784
+ const ctx = await open(opts.cwd);
2785
+ const url = await getUrl(ctx, "origin");
2786
+ const parsed = parseGitHubRemote(url);
2787
+ return parsed ?? { owner: null, name: null };
2788
+ } catch {
2789
+ return { owner: null, name: null };
2790
+ }
2791
+ }
2792
+ function tokenSourceFromEnv(env) {
2793
+ if (env.TESTED_TOKEN) return "TESTED_TOKEN";
2794
+ if (env.TESTED_INGEST_TOKEN) return "TESTED_INGEST_TOKEN";
2795
+ if (env.TESTED_TOKEN_FILE) return "TESTED_TOKEN_FILE";
2796
+ return "token";
2797
+ }
2798
+ function formatTokenHuman(identity) {
2799
+ const lines = [
2800
+ heading("tested.dev \u2014 token"),
2801
+ "",
2802
+ ...tokenMintGuidance(identity).map((line) => dim(` ${line}`)),
2803
+ ""
2804
+ ];
2805
+ return lines.join("\n");
2806
+ }
2807
+ function formatTokenJson(identity) {
2808
+ return JSON.stringify(
2809
+ {
2810
+ schemaVersion: 1,
2811
+ mintUrl: ingestTokenSettingsUrl(identity.owner, identity.name),
2812
+ envNames: [...INGEST_TOKEN_ENV_NAMES],
2813
+ owner: identity.owner,
2814
+ name: identity.name
2815
+ },
2816
+ null,
2817
+ 2
2818
+ ) + "\n";
2819
+ }
2820
+ function formatWhoamiHuman(result) {
2821
+ const lines = [heading("tested.dev \u2014 whoami"), ""];
2822
+ if (result.tokenSet) {
2823
+ lines.push(dim(` token: set via ${result.source} (value not shown)`));
2824
+ } else {
2825
+ lines.push(dim(" token: not set"));
2826
+ for (const line of tokenMintGuidance(result.identity)) {
2827
+ lines.push(dim(` ${line}`));
2828
+ }
2829
+ }
2830
+ lines.push("");
2831
+ return lines.join("\n");
2832
+ }
2833
+ function formatWhoamiJson(result) {
2834
+ return JSON.stringify(
2835
+ {
2836
+ schemaVersion: 1,
2837
+ tokenSet: result.tokenSet,
2838
+ source: result.source,
2839
+ mintUrl: ingestTokenSettingsUrl(result.identity.owner, result.identity.name),
2840
+ envNames: [...INGEST_TOKEN_ENV_NAMES]
2841
+ },
2842
+ null,
2843
+ 2
2844
+ ) + "\n";
2845
+ }
2846
+ async function runToken(opts) {
2847
+ const identity = await resolveRepoIdentity(opts);
2848
+ const stdout = opts.json ? formatTokenJson(identity) : formatTokenHuman(identity);
2849
+ return { stdout, exitCode: 0 };
2850
+ }
2851
+ async function runWhoami(opts) {
2852
+ const env = opts.env ?? process.env;
2853
+ const identity = await resolveRepoIdentity(opts);
2854
+ const resolve7 = opts.resolveTokenFn ?? resolveToken;
2855
+ let tokenSet = false;
2856
+ let source = null;
2857
+ try {
2858
+ const token = resolve7({ env, isTTY: false, warn: () => {
2859
+ } });
2860
+ tokenSet = Boolean(token);
2861
+ source = tokenSet ? tokenSourceFromEnv(env) : null;
2862
+ } catch (err) {
2863
+ const message = err instanceof Error ? err.message : String(err);
2864
+ return {
2865
+ stdout: "",
2866
+ stderr: formatCliError(message),
2867
+ exitCode: 1
2868
+ };
2869
+ }
2870
+ const result = { tokenSet, source, identity, exitCode: tokenSet ? 0 : 1 };
2871
+ const stdout = opts.json ? formatWhoamiJson(result) : formatWhoamiHuman(result);
2872
+ return { stdout, stderr: "", exitCode: result.exitCode };
2873
+ }
2874
+ function registerTokenCommand(program2) {
2875
+ program2.command("token").description("Print the ingest-token mint URL and accepted env names").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
2876
+ try {
2877
+ const result = await runToken({ cwd: process.cwd(), json: opts.json });
2878
+ process.stdout.write(result.stdout.endsWith("\n") ? result.stdout : result.stdout + "\n");
2879
+ process.exitCode = result.exitCode;
2880
+ } catch (err) {
2881
+ const message = err instanceof Error ? err.message : String(err);
2882
+ process.stderr.write(formatCliError(message));
2883
+ process.exitCode = 1;
2884
+ }
2885
+ });
2886
+ }
2887
+ function registerWhoamiCommand(program2) {
2888
+ program2.command("whoami").description("Report whether an ingest token is set (never prints the value)").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
2889
+ try {
2890
+ const result = await runWhoami({ cwd: process.cwd(), json: opts.json });
2891
+ if (result.stderr) process.stderr.write(result.stderr);
2892
+ if (result.stdout) {
2893
+ process.stdout.write(
2894
+ result.stdout.endsWith("\n") ? result.stdout : result.stdout + "\n"
2895
+ );
2896
+ }
2897
+ process.exitCode = result.exitCode;
2898
+ } catch (err) {
2899
+ const message = err instanceof Error ? err.message : String(err);
2900
+ process.stderr.write(errorBlock(message));
2901
+ process.exitCode = 1;
2902
+ }
2690
2903
  });
2691
2904
  }
2692
2905
 
2693
2906
  // src/cli.ts
2694
2907
  function createProgram() {
2695
- const program2 = new Command10();
2908
+ const program2 = new Command11();
2696
2909
  program2.name("tested").description(
2697
2910
  [
2698
2911
  "Coverage your agent can use.",
@@ -2708,6 +2921,8 @@ function createProgram() {
2708
2921
  registerDiffCommand(program2);
2709
2922
  registerCheckCommand(program2);
2710
2923
  registerPushCommand(program2);
2924
+ registerTokenCommand(program2);
2925
+ registerWhoamiCommand(program2);
2711
2926
  registerExplainCommand(program2);
2712
2927
  registerIgnoresCommand(program2);
2713
2928
  return program2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tested/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tested.dev",