@tested/cli 0.1.4 → 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 +11 -8
- package/dist/td.js +286 -37
- package/dist/tested.js +286 -37
- package/package.json +1 -1
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.
|
|
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
|
|
@@ -51,7 +53,7 @@ tested push --pr <n> # needs TESTED_TOKEN
|
|
|
51
53
|
|---------|--------|
|
|
52
54
|
| Ingest token | Prefer `TESTED_TOKEN` / `TESTED_INGEST_TOKEN` / `TESTED_TOKEN_FILE` (mode `0600`). Avoid `--token` on shared hosts — it appears on process argv (`ps`). |
|
|
53
55
|
| Safe run args | In CI, non-interactive shells, or when `TESTED_SAFE_RUN=1`, `tested run` rejects `--watch` / `--watchAll` and `--config` paths outside the repo root. |
|
|
54
|
-
| API URL | Ingest posts only to `https
|
|
56
|
+
| API URL | Ingest posts only to `https://app.tested.dev` / `*.tested.dev` (or `http://localhost`). Other HTTPS hosts need `TESTED_ALLOW_CUSTOM_API_URL=1`. Redirects with Bearer token are refused. |
|
|
55
57
|
| MCP hosts | For always-on agent hosts, set `TESTED_ALLOWED_CWDS` (see `@tested/mcp`) so tools cannot target arbitrary directories. |
|
|
56
58
|
|
|
57
59
|
## Agent loop
|
|
@@ -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
|
|
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.
|
|
138
|
+
version: 0.1.6
|
|
137
139
|
push: true
|
|
138
140
|
pr-number: ${{ github.event.pull_request.number }}
|
|
139
141
|
token: ${{ secrets.TESTED_TOKEN }}
|
|
@@ -167,8 +169,8 @@ $ tested push
|
|
|
167
169
|
| `TESTED_TOKEN` / `TESTED_INGEST_TOKEN` / `TESTED_TOKEN_FILE` | Ingest auth (**preferred** — not on argv) |
|
|
168
170
|
| `--token` | Ingest auth (works; warns on TTY; visible in `ps`) |
|
|
169
171
|
| `--pr` / `GITHUB_PR_NUMBER` / `PR_NUMBER` | PR number (required) |
|
|
170
|
-
| `--url` / `TESTED_API_URL` | API base (default `https://app.tested.dev`) |
|
|
171
|
-
| `--owner` / `--name` | Repo identity (default:
|
|
172
|
+
| `--url` / `TESTED_API_URL` | API base (default `https://app.tested.dev`; other hosts need `TESTED_ALLOW_CUSTOM_API_URL=1`) |
|
|
173
|
+
| `--owner` / `--name` | Repo identity (default: `GITHUB_REPOSITORY`, else `origin` remote) |
|
|
172
174
|
| `--pr-title`, `--author`, `--base-ref`, `--head-ref` | PR metadata overrides |
|
|
173
175
|
| `--base` | Git base for the coverage diff (same as `tested diff --base`) |
|
|
174
176
|
| `--run-url` | Optional CI run URL |
|
|
@@ -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).
|
|
180
|
-
|
|
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
|
|
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("--
|
|
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 && !
|
|
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
|
|
621
|
-
|
|
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
|
|
918
|
-
const base = await
|
|
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);
|
|
@@ -965,7 +1011,24 @@ async function computeDiff(opts) {
|
|
|
965
1011
|
|
|
966
1012
|
// src/commands/push.ts
|
|
967
1013
|
var DEFAULT_API_BASE = "https://app.tested.dev";
|
|
1014
|
+
var DEFAULT_API_HOST = "app.tested.dev";
|
|
968
1015
|
var LOCAL_HTTP_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
1016
|
+
var SAFE_GITHUB_REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
1017
|
+
function isAllowedApiHost(hostname) {
|
|
1018
|
+
const host = hostname.toLowerCase();
|
|
1019
|
+
return host === "tested.dev" || host === DEFAULT_API_HOST || host.endsWith(".tested.dev");
|
|
1020
|
+
}
|
|
1021
|
+
function isLocalDevHost(hostname) {
|
|
1022
|
+
const host = hostname.toLowerCase();
|
|
1023
|
+
return LOCAL_HTTP_HOSTS.has(host) || host.endsWith(".localhost");
|
|
1024
|
+
}
|
|
1025
|
+
function parseGitHubRepository(repo) {
|
|
1026
|
+
const raw = repo?.trim();
|
|
1027
|
+
if (!raw || !SAFE_GITHUB_REPOSITORY_RE.test(raw)) return null;
|
|
1028
|
+
const [owner, name] = raw.split("/");
|
|
1029
|
+
if (!owner || !name) return null;
|
|
1030
|
+
return { owner, name };
|
|
1031
|
+
}
|
|
969
1032
|
var tokenArgvWarned = false;
|
|
970
1033
|
function readTokenFile(filePath, opts) {
|
|
971
1034
|
const read = opts?.readFileSyncFn ?? readFileSync2;
|
|
@@ -1022,7 +1085,7 @@ function resolveToken(opts) {
|
|
|
1022
1085
|
}
|
|
1023
1086
|
return null;
|
|
1024
1087
|
}
|
|
1025
|
-
function assertSafeApiBase(raw) {
|
|
1088
|
+
function assertSafeApiBase(raw, opts = {}) {
|
|
1026
1089
|
const trimmed = raw.trim();
|
|
1027
1090
|
if (!trimmed) {
|
|
1028
1091
|
throw new Error("API URL must not be empty");
|
|
@@ -1039,9 +1102,14 @@ function assertSafeApiBase(raw) {
|
|
|
1039
1102
|
throw new Error("API URL must not embed credentials");
|
|
1040
1103
|
}
|
|
1041
1104
|
const host = u.hostname.toLowerCase();
|
|
1042
|
-
const
|
|
1105
|
+
const local = isLocalDevHost(host);
|
|
1043
1106
|
if (u.protocol === "https:") {
|
|
1044
|
-
|
|
1107
|
+
if (!local && !isAllowedApiHost(host) && !opts.allowCustom) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`API URL host "${u.hostname}" is not allowed. Ingest tokens are sent to this host. Use https://app.tested.dev, or set TESTED_ALLOW_CUSTOM_API_URL=1 for a private endpoint.`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (u.protocol === "http:" && local) {
|
|
1045
1113
|
} else {
|
|
1046
1114
|
throw new Error(
|
|
1047
1115
|
`API URL must use https:// (http:// allowed only for localhost). Got ${u.protocol}//${u.host}`
|
|
@@ -1051,12 +1119,16 @@ function assertSafeApiBase(raw) {
|
|
|
1051
1119
|
const pathPart = !path || path === "/" ? "" : path;
|
|
1052
1120
|
return `${u.origin}${pathPart}`;
|
|
1053
1121
|
}
|
|
1122
|
+
function allowCustomApiUrl(env) {
|
|
1123
|
+
const raw = env.TESTED_ALLOW_CUSTOM_API_URL;
|
|
1124
|
+
return raw === "1" || raw === "true";
|
|
1125
|
+
}
|
|
1054
1126
|
function resolveApiBase(opts) {
|
|
1055
1127
|
const env = opts.env ?? process.env;
|
|
1056
1128
|
const flag = opts.flag?.trim();
|
|
1057
1129
|
const fromEnv = env.TESTED_API_URL?.trim();
|
|
1058
1130
|
const raw = flag || fromEnv || DEFAULT_API_BASE;
|
|
1059
|
-
return assertSafeApiBase(raw);
|
|
1131
|
+
return assertSafeApiBase(raw, { allowCustom: allowCustomApiUrl(env) });
|
|
1060
1132
|
}
|
|
1061
1133
|
function redactGitRemote(url) {
|
|
1062
1134
|
const scrubbed = url.replace(/\/\/([^/@\s]+)@/g, "//***@");
|
|
@@ -1403,6 +1475,13 @@ async function executePush(cli, deps) {
|
|
|
1403
1475
|
}
|
|
1404
1476
|
let owner = cli.owner;
|
|
1405
1477
|
let name = cli.name;
|
|
1478
|
+
if (!owner || !name) {
|
|
1479
|
+
const fromActions = parseGitHubRepository(env.GITHUB_REPOSITORY);
|
|
1480
|
+
if (fromActions) {
|
|
1481
|
+
owner = owner ?? fromActions.owner;
|
|
1482
|
+
name = name ?? fromActions.name;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1406
1485
|
if (!owner || !name) {
|
|
1407
1486
|
let origin;
|
|
1408
1487
|
try {
|
|
@@ -1767,7 +1846,8 @@ async function runDoctor(deps) {
|
|
|
1767
1846
|
const rawApi = env.TESTED_API_URL;
|
|
1768
1847
|
if (rawApi !== void 0 && rawApi !== "") {
|
|
1769
1848
|
try {
|
|
1770
|
-
const
|
|
1849
|
+
const allowCustom = env.TESTED_ALLOW_CUSTOM_API_URL === "1" || env.TESTED_ALLOW_CUSTOM_API_URL === "true";
|
|
1850
|
+
const normalized = assertSafe(rawApi, { allowCustom });
|
|
1771
1851
|
checks.push({
|
|
1772
1852
|
id: "api_url",
|
|
1773
1853
|
label: "API URL",
|
|
@@ -1881,7 +1961,7 @@ import pc3 from "picocolors";
|
|
|
1881
1961
|
// package.json
|
|
1882
1962
|
var package_default = {
|
|
1883
1963
|
name: "@tested/cli",
|
|
1884
|
-
version: "0.1.
|
|
1964
|
+
version: "0.1.6",
|
|
1885
1965
|
description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
|
|
1886
1966
|
license: "MIT",
|
|
1887
1967
|
homepage: "https://tested.dev",
|
|
@@ -2126,6 +2206,26 @@ import { existsSync as existsSync5 } from "fs";
|
|
|
2126
2206
|
import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
|
|
2127
2207
|
import { spawn } from "child_process";
|
|
2128
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
|
+
}
|
|
2129
2229
|
function resolveRunCommand(opts) {
|
|
2130
2230
|
const runner = opts.runner ?? "vitest";
|
|
2131
2231
|
switch (runner) {
|
|
@@ -2208,12 +2308,15 @@ function assertSafeRunArgs(extraArgs, repoRoot) {
|
|
|
2208
2308
|
}
|
|
2209
2309
|
function registerRunCommand(program2) {
|
|
2210
2310
|
program2.command("run").description(
|
|
2211
|
-
"Run the
|
|
2212
|
-
).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) => {
|
|
2213
2313
|
const cwd = process.cwd();
|
|
2314
|
+
const split = splitRunArgs(extraArgs ?? []);
|
|
2315
|
+
const json = Boolean(opts.json) || split.json;
|
|
2316
|
+
const forwarded = split.forwarded;
|
|
2214
2317
|
try {
|
|
2215
2318
|
if (shouldEnforceSafeRun()) {
|
|
2216
|
-
assertSafeRunArgs(
|
|
2319
|
+
assertSafeRunArgs(forwarded, cwd);
|
|
2217
2320
|
}
|
|
2218
2321
|
} catch (err) {
|
|
2219
2322
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2225,14 +2328,28 @@ function registerRunCommand(program2) {
|
|
|
2225
2328
|
const coveragePath = resolve5(cwd, config.coverage.path);
|
|
2226
2329
|
const { command, args } = resolveRunCommand({
|
|
2227
2330
|
runner: config.testRunner,
|
|
2228
|
-
extraArgs
|
|
2331
|
+
extraArgs: forwarded
|
|
2229
2332
|
});
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
+
}
|
|
2232
2337
|
const child = spawn(command, args, { stdio: "inherit" });
|
|
2233
2338
|
child.on("exit", (code) => {
|
|
2234
2339
|
const exit = code ?? 1;
|
|
2235
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
|
+
}
|
|
2236
2353
|
if (exit === 0) {
|
|
2237
2354
|
process.stderr.write("\n");
|
|
2238
2355
|
process.stderr.write(tip("tested diff") + "\n");
|
|
@@ -2347,24 +2464,21 @@ function formatHuman(out, opts = {}) {
|
|
|
2347
2464
|
);
|
|
2348
2465
|
}
|
|
2349
2466
|
}
|
|
2467
|
+
const patchFiles = out.files.filter((f) => f.patchCoverage !== null);
|
|
2350
2468
|
if (isEmptyPatch(out.patch)) {
|
|
2351
2469
|
lines.push("");
|
|
2352
2470
|
lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
|
|
2353
|
-
} else if (
|
|
2471
|
+
} else if (patchFiles.length > 0) {
|
|
2354
2472
|
lines.push("");
|
|
2355
2473
|
lines.push(heading("Files in diff:"));
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
for (const f of out.files) {
|
|
2361
|
-
const hasPatch = f.patchCoverage !== null;
|
|
2362
|
-
const pctPart = hasPatch ? coloredPctCell(f.patchCoverage) : coloredPctCell(f.projectCoverage);
|
|
2363
|
-
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}`);
|
|
2364
2478
|
if (f.uncoveredRanges.length > 0) {
|
|
2365
2479
|
const ranges = formatRangeList(f.uncoveredRanges);
|
|
2366
2480
|
lines.push(dim(` uncovered: ${ranges}`));
|
|
2367
|
-
} else
|
|
2481
|
+
} else {
|
|
2368
2482
|
lines.push(dim(" fully covered in patch"));
|
|
2369
2483
|
}
|
|
2370
2484
|
}
|
|
@@ -2648,17 +2762,150 @@ function formatIgnoresList(patterns, asJson) {
|
|
|
2648
2762
|
if (asJson) return JSON.stringify({ ignores: [...patterns] });
|
|
2649
2763
|
return patterns.join("\n");
|
|
2650
2764
|
}
|
|
2765
|
+
async function printIgnores(asJson) {
|
|
2766
|
+
const config = await loadConfig({ cwd: process.cwd() });
|
|
2767
|
+
process.stdout.write(formatIgnoresList(config.ignores, asJson) + "\n");
|
|
2768
|
+
}
|
|
2651
2769
|
function registerIgnoresCommand(program2) {
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
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
|
+
}
|
|
2656
2903
|
});
|
|
2657
2904
|
}
|
|
2658
2905
|
|
|
2659
2906
|
// src/cli.ts
|
|
2660
2907
|
function createProgram() {
|
|
2661
|
-
const program2 = new
|
|
2908
|
+
const program2 = new Command11();
|
|
2662
2909
|
program2.name("tested").description(
|
|
2663
2910
|
[
|
|
2664
2911
|
"Coverage your agent can use.",
|
|
@@ -2674,6 +2921,8 @@ function createProgram() {
|
|
|
2674
2921
|
registerDiffCommand(program2);
|
|
2675
2922
|
registerCheckCommand(program2);
|
|
2676
2923
|
registerPushCommand(program2);
|
|
2924
|
+
registerTokenCommand(program2);
|
|
2925
|
+
registerWhoamiCommand(program2);
|
|
2677
2926
|
registerExplainCommand(program2);
|
|
2678
2927
|
registerIgnoresCommand(program2);
|
|
2679
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
|
|
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("--
|
|
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 && !
|
|
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
|
|
621
|
-
|
|
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
|
|
918
|
-
const base = await
|
|
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);
|
|
@@ -965,7 +1011,24 @@ async function computeDiff(opts) {
|
|
|
965
1011
|
|
|
966
1012
|
// src/commands/push.ts
|
|
967
1013
|
var DEFAULT_API_BASE = "https://app.tested.dev";
|
|
1014
|
+
var DEFAULT_API_HOST = "app.tested.dev";
|
|
968
1015
|
var LOCAL_HTTP_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
1016
|
+
var SAFE_GITHUB_REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
1017
|
+
function isAllowedApiHost(hostname) {
|
|
1018
|
+
const host = hostname.toLowerCase();
|
|
1019
|
+
return host === "tested.dev" || host === DEFAULT_API_HOST || host.endsWith(".tested.dev");
|
|
1020
|
+
}
|
|
1021
|
+
function isLocalDevHost(hostname) {
|
|
1022
|
+
const host = hostname.toLowerCase();
|
|
1023
|
+
return LOCAL_HTTP_HOSTS.has(host) || host.endsWith(".localhost");
|
|
1024
|
+
}
|
|
1025
|
+
function parseGitHubRepository(repo) {
|
|
1026
|
+
const raw = repo?.trim();
|
|
1027
|
+
if (!raw || !SAFE_GITHUB_REPOSITORY_RE.test(raw)) return null;
|
|
1028
|
+
const [owner, name] = raw.split("/");
|
|
1029
|
+
if (!owner || !name) return null;
|
|
1030
|
+
return { owner, name };
|
|
1031
|
+
}
|
|
969
1032
|
var tokenArgvWarned = false;
|
|
970
1033
|
function readTokenFile(filePath, opts) {
|
|
971
1034
|
const read = opts?.readFileSyncFn ?? readFileSync2;
|
|
@@ -1022,7 +1085,7 @@ function resolveToken(opts) {
|
|
|
1022
1085
|
}
|
|
1023
1086
|
return null;
|
|
1024
1087
|
}
|
|
1025
|
-
function assertSafeApiBase(raw) {
|
|
1088
|
+
function assertSafeApiBase(raw, opts = {}) {
|
|
1026
1089
|
const trimmed = raw.trim();
|
|
1027
1090
|
if (!trimmed) {
|
|
1028
1091
|
throw new Error("API URL must not be empty");
|
|
@@ -1039,9 +1102,14 @@ function assertSafeApiBase(raw) {
|
|
|
1039
1102
|
throw new Error("API URL must not embed credentials");
|
|
1040
1103
|
}
|
|
1041
1104
|
const host = u.hostname.toLowerCase();
|
|
1042
|
-
const
|
|
1105
|
+
const local = isLocalDevHost(host);
|
|
1043
1106
|
if (u.protocol === "https:") {
|
|
1044
|
-
|
|
1107
|
+
if (!local && !isAllowedApiHost(host) && !opts.allowCustom) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`API URL host "${u.hostname}" is not allowed. Ingest tokens are sent to this host. Use https://app.tested.dev, or set TESTED_ALLOW_CUSTOM_API_URL=1 for a private endpoint.`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (u.protocol === "http:" && local) {
|
|
1045
1113
|
} else {
|
|
1046
1114
|
throw new Error(
|
|
1047
1115
|
`API URL must use https:// (http:// allowed only for localhost). Got ${u.protocol}//${u.host}`
|
|
@@ -1051,12 +1119,16 @@ function assertSafeApiBase(raw) {
|
|
|
1051
1119
|
const pathPart = !path || path === "/" ? "" : path;
|
|
1052
1120
|
return `${u.origin}${pathPart}`;
|
|
1053
1121
|
}
|
|
1122
|
+
function allowCustomApiUrl(env) {
|
|
1123
|
+
const raw = env.TESTED_ALLOW_CUSTOM_API_URL;
|
|
1124
|
+
return raw === "1" || raw === "true";
|
|
1125
|
+
}
|
|
1054
1126
|
function resolveApiBase(opts) {
|
|
1055
1127
|
const env = opts.env ?? process.env;
|
|
1056
1128
|
const flag = opts.flag?.trim();
|
|
1057
1129
|
const fromEnv = env.TESTED_API_URL?.trim();
|
|
1058
1130
|
const raw = flag || fromEnv || DEFAULT_API_BASE;
|
|
1059
|
-
return assertSafeApiBase(raw);
|
|
1131
|
+
return assertSafeApiBase(raw, { allowCustom: allowCustomApiUrl(env) });
|
|
1060
1132
|
}
|
|
1061
1133
|
function redactGitRemote(url) {
|
|
1062
1134
|
const scrubbed = url.replace(/\/\/([^/@\s]+)@/g, "//***@");
|
|
@@ -1403,6 +1475,13 @@ async function executePush(cli, deps) {
|
|
|
1403
1475
|
}
|
|
1404
1476
|
let owner = cli.owner;
|
|
1405
1477
|
let name = cli.name;
|
|
1478
|
+
if (!owner || !name) {
|
|
1479
|
+
const fromActions = parseGitHubRepository(env.GITHUB_REPOSITORY);
|
|
1480
|
+
if (fromActions) {
|
|
1481
|
+
owner = owner ?? fromActions.owner;
|
|
1482
|
+
name = name ?? fromActions.name;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1406
1485
|
if (!owner || !name) {
|
|
1407
1486
|
let origin;
|
|
1408
1487
|
try {
|
|
@@ -1767,7 +1846,8 @@ async function runDoctor(deps) {
|
|
|
1767
1846
|
const rawApi = env.TESTED_API_URL;
|
|
1768
1847
|
if (rawApi !== void 0 && rawApi !== "") {
|
|
1769
1848
|
try {
|
|
1770
|
-
const
|
|
1849
|
+
const allowCustom = env.TESTED_ALLOW_CUSTOM_API_URL === "1" || env.TESTED_ALLOW_CUSTOM_API_URL === "true";
|
|
1850
|
+
const normalized = assertSafe(rawApi, { allowCustom });
|
|
1771
1851
|
checks.push({
|
|
1772
1852
|
id: "api_url",
|
|
1773
1853
|
label: "API URL",
|
|
@@ -1881,7 +1961,7 @@ import pc3 from "picocolors";
|
|
|
1881
1961
|
// package.json
|
|
1882
1962
|
var package_default = {
|
|
1883
1963
|
name: "@tested/cli",
|
|
1884
|
-
version: "0.1.
|
|
1964
|
+
version: "0.1.6",
|
|
1885
1965
|
description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
|
|
1886
1966
|
license: "MIT",
|
|
1887
1967
|
homepage: "https://tested.dev",
|
|
@@ -2126,6 +2206,26 @@ import { existsSync as existsSync5 } from "fs";
|
|
|
2126
2206
|
import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
|
|
2127
2207
|
import { spawn } from "child_process";
|
|
2128
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
|
+
}
|
|
2129
2229
|
function resolveRunCommand(opts) {
|
|
2130
2230
|
const runner = opts.runner ?? "vitest";
|
|
2131
2231
|
switch (runner) {
|
|
@@ -2208,12 +2308,15 @@ function assertSafeRunArgs(extraArgs, repoRoot) {
|
|
|
2208
2308
|
}
|
|
2209
2309
|
function registerRunCommand(program2) {
|
|
2210
2310
|
program2.command("run").description(
|
|
2211
|
-
"Run the
|
|
2212
|
-
).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) => {
|
|
2213
2313
|
const cwd = process.cwd();
|
|
2314
|
+
const split = splitRunArgs(extraArgs ?? []);
|
|
2315
|
+
const json = Boolean(opts.json) || split.json;
|
|
2316
|
+
const forwarded = split.forwarded;
|
|
2214
2317
|
try {
|
|
2215
2318
|
if (shouldEnforceSafeRun()) {
|
|
2216
|
-
assertSafeRunArgs(
|
|
2319
|
+
assertSafeRunArgs(forwarded, cwd);
|
|
2217
2320
|
}
|
|
2218
2321
|
} catch (err) {
|
|
2219
2322
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2225,14 +2328,28 @@ function registerRunCommand(program2) {
|
|
|
2225
2328
|
const coveragePath = resolve5(cwd, config.coverage.path);
|
|
2226
2329
|
const { command, args } = resolveRunCommand({
|
|
2227
2330
|
runner: config.testRunner,
|
|
2228
|
-
extraArgs
|
|
2331
|
+
extraArgs: forwarded
|
|
2229
2332
|
});
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
+
}
|
|
2232
2337
|
const child = spawn(command, args, { stdio: "inherit" });
|
|
2233
2338
|
child.on("exit", (code) => {
|
|
2234
2339
|
const exit = code ?? 1;
|
|
2235
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
|
+
}
|
|
2236
2353
|
if (exit === 0) {
|
|
2237
2354
|
process.stderr.write("\n");
|
|
2238
2355
|
process.stderr.write(tip("tested diff") + "\n");
|
|
@@ -2347,24 +2464,21 @@ function formatHuman(out, opts = {}) {
|
|
|
2347
2464
|
);
|
|
2348
2465
|
}
|
|
2349
2466
|
}
|
|
2467
|
+
const patchFiles = out.files.filter((f) => f.patchCoverage !== null);
|
|
2350
2468
|
if (isEmptyPatch(out.patch)) {
|
|
2351
2469
|
lines.push("");
|
|
2352
2470
|
lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
|
|
2353
|
-
} else if (
|
|
2471
|
+
} else if (patchFiles.length > 0) {
|
|
2354
2472
|
lines.push("");
|
|
2355
2473
|
lines.push(heading("Files in diff:"));
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
for (const f of out.files) {
|
|
2361
|
-
const hasPatch = f.patchCoverage !== null;
|
|
2362
|
-
const pctPart = hasPatch ? coloredPctCell(f.patchCoverage) : coloredPctCell(f.projectCoverage);
|
|
2363
|
-
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}`);
|
|
2364
2478
|
if (f.uncoveredRanges.length > 0) {
|
|
2365
2479
|
const ranges = formatRangeList(f.uncoveredRanges);
|
|
2366
2480
|
lines.push(dim(` uncovered: ${ranges}`));
|
|
2367
|
-
} else
|
|
2481
|
+
} else {
|
|
2368
2482
|
lines.push(dim(" fully covered in patch"));
|
|
2369
2483
|
}
|
|
2370
2484
|
}
|
|
@@ -2648,17 +2762,150 @@ function formatIgnoresList(patterns, asJson) {
|
|
|
2648
2762
|
if (asJson) return JSON.stringify({ ignores: [...patterns] });
|
|
2649
2763
|
return patterns.join("\n");
|
|
2650
2764
|
}
|
|
2765
|
+
async function printIgnores(asJson) {
|
|
2766
|
+
const config = await loadConfig({ cwd: process.cwd() });
|
|
2767
|
+
process.stdout.write(formatIgnoresList(config.ignores, asJson) + "\n");
|
|
2768
|
+
}
|
|
2651
2769
|
function registerIgnoresCommand(program2) {
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
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
|
+
}
|
|
2656
2903
|
});
|
|
2657
2904
|
}
|
|
2658
2905
|
|
|
2659
2906
|
// src/cli.ts
|
|
2660
2907
|
function createProgram() {
|
|
2661
|
-
const program2 = new
|
|
2908
|
+
const program2 = new Command11();
|
|
2662
2909
|
program2.name("tested").description(
|
|
2663
2910
|
[
|
|
2664
2911
|
"Coverage your agent can use.",
|
|
@@ -2674,6 +2921,8 @@ function createProgram() {
|
|
|
2674
2921
|
registerDiffCommand(program2);
|
|
2675
2922
|
registerCheckCommand(program2);
|
|
2676
2923
|
registerPushCommand(program2);
|
|
2924
|
+
registerTokenCommand(program2);
|
|
2925
|
+
registerWhoamiCommand(program2);
|
|
2677
2926
|
registerExplainCommand(program2);
|
|
2678
2927
|
registerIgnoresCommand(program2);
|
|
2679
2928
|
return program2;
|
package/package.json
CHANGED