@davidrydberg/proofy 0.0.0-stage → 0.1.0

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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/dist/args.d.ts +10 -0
  4. package/dist/args.js +36 -0
  5. package/dist/bin/proofy.d.ts +2 -0
  6. package/dist/bin/proofy.js +4 -0
  7. package/dist/cli.d.ts +9 -0
  8. package/dist/cli.js +140 -0
  9. package/dist/commands/approve.d.ts +4 -0
  10. package/dist/commands/approve.js +48 -0
  11. package/dist/commands/context.d.ts +16 -0
  12. package/dist/commands/context.js +27 -0
  13. package/dist/commands/dev.d.ts +4 -0
  14. package/dist/commands/dev.js +161 -0
  15. package/dist/commands/home.d.ts +3 -0
  16. package/dist/commands/home.js +122 -0
  17. package/dist/commands/init.d.ts +4 -0
  18. package/dist/commands/init.js +122 -0
  19. package/dist/commands/report.d.ts +4 -0
  20. package/dist/commands/report.js +26 -0
  21. package/dist/commands/run.d.ts +6 -0
  22. package/dist/commands/run.js +166 -0
  23. package/dist/commands/types.d.ts +8 -0
  24. package/dist/commands/types.js +1 -0
  25. package/dist/config.d.ts +34 -0
  26. package/dist/config.js +62 -0
  27. package/dist/deps.d.ts +10 -0
  28. package/dist/deps.js +1 -0
  29. package/dist/detect.d.ts +17 -0
  30. package/dist/detect.js +96 -0
  31. package/dist/errors.d.ts +6 -0
  32. package/dist/errors.js +21 -0
  33. package/dist/github.d.ts +16 -0
  34. package/dist/github.js +121 -0
  35. package/dist/human.d.ts +30 -0
  36. package/dist/human.js +160 -0
  37. package/dist/output.d.ts +8 -0
  38. package/dist/output.js +21 -0
  39. package/dist/project.d.ts +21 -0
  40. package/dist/project.js +71 -0
  41. package/dist/run/approvals.d.ts +15 -0
  42. package/dist/run/approvals.js +26 -0
  43. package/dist/run/engine.d.ts +48 -0
  44. package/dist/run/engine.js +168 -0
  45. package/dist/run/fingerprint.d.ts +11 -0
  46. package/dist/run/fingerprint.js +40 -0
  47. package/dist/run/history.d.ts +29 -0
  48. package/dist/run/history.js +85 -0
  49. package/dist/run/report.d.ts +14 -0
  50. package/dist/run/report.js +115 -0
  51. package/dist/run/results.d.ts +70 -0
  52. package/dist/run/results.js +129 -0
  53. package/dist/run/spec.d.ts +6 -0
  54. package/dist/run/spec.js +16 -0
  55. package/dist/sandbox/compose.d.ts +38 -0
  56. package/dist/sandbox/compose.js +132 -0
  57. package/dist/sandbox/contract.d.ts +19 -0
  58. package/dist/sandbox/contract.js +20 -0
  59. package/dist/sandbox/docker.d.ts +17 -0
  60. package/dist/sandbox/docker.js +48 -0
  61. package/dist/sandbox/sandbox.d.ts +46 -0
  62. package/dist/sandbox/sandbox.js +239 -0
  63. package/dist/standin/ca.d.ts +6 -0
  64. package/dist/standin/ca.js +40 -0
  65. package/dist/standin/dns.d.ts +10 -0
  66. package/dist/standin/dns.js +53 -0
  67. package/dist/standin/forward.d.ts +1 -0
  68. package/dist/standin/forward.js +17 -0
  69. package/dist/standin/handle.d.ts +49 -0
  70. package/dist/standin/handle.js +130 -0
  71. package/dist/standin/server.d.ts +1 -0
  72. package/dist/standin/server.js +88 -0
  73. package/dist/style.d.ts +23 -0
  74. package/dist/style.js +38 -0
  75. package/dist/test/global-setup.d.ts +1 -0
  76. package/dist/test/global-setup.js +20 -0
  77. package/dist/test/index.d.ts +102 -0
  78. package/dist/test/index.js +184 -0
  79. package/docker/standin.Dockerfile +3 -0
  80. package/package.json +68 -4
  81. package/schema/proofy.schema.json +179 -0
  82. package/templates/e2e/AGENTS.md +90 -0
  83. package/templates/e2e/templates/compose.yml +33 -0
  84. package/templates/e2e/templates/spec.md +20 -0
  85. package/templates/e2e/templates/standin.mjs +29 -0
  86. package/templates/e2e/templates/test.spec.ts +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Rydberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,65 @@
1
- # Temporary Holding Version
1
+ # proofy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `proofy` gives your app end-to-end tests you can trust, written by your coding agent and approved by you.
4
+ You describe what to test in plain words.
5
+ Your agent follows the instructions proofy puts in your repo to write numbered requirements, a Playwright test and fakes for outside services.
6
+ You approve them, and every `proofy run` checks the app in a sealed Docker sandbox, with no AI, and reports per requirement what was asked, what happened and the evidence, with screenshots.
7
+
8
+ The sandbox has no way out.
9
+ Every outside call goes to a fake the agent wrote from the provider's docs, gets recorded, or is refused and listed in the report.
10
+
11
+ ## Install
12
+
13
+ proofy is not on npm yet. Install it from source:
14
+
15
+ ```sh
16
+ git clone https://github.com/davidrydberg/proofy && cd proofy && npm ci && npm run build && npm link
17
+ ```
18
+
19
+ Once it is published, `npm i -g @davidrydberg/proofy` will work.
20
+
21
+ ## Requirements
22
+
23
+ - Node 24 or newer
24
+ - Docker on macOS or Linux (Docker Desktop, OrbStack or Docker Engine)
25
+ - An app that starts with `docker compose`; your agent can draft the compose file
26
+ - The [GitHub CLI](https://cli.github.com), only to test pull requests or post reports on them
27
+
28
+ ## Usage
29
+
30
+ ```sh
31
+ proofy init # adds e2e/ with agent instructions, templates and a config filled from your repo
32
+ ```
33
+
34
+ Then tell your agent: "set up proofy", and after that what to test: "add a proofy test: a guest can buy a product and gets a confirmation email".
35
+ The agent interviews you, writes `e2e/specs/<flow>.md` and the test, and runs it until three runs agree.
36
+
37
+ ```sh
38
+ proofy # setup, every flow's state and the next step; safe for a session-start hook
39
+ proofy approve guest-checkout # you, after reading the requirements and the agent's notes
40
+ proofy run --all # every approved flow in a fresh sealed sandbox
41
+ proofy run --all --pr 42 --post # a pull request's code, report posted on the PR
42
+ ```
43
+
44
+ ```
45
+ PASS every requirement of every approved flow held
46
+ run 20261020-091512 e2e/out/20261020-091512
47
+
48
+ guest-checkout passed
49
+ ✓ R1 After placing the order, the guest sees a confirmation page showing the stored order's number.
50
+ ✓ R2 The order is stored with status "processing", payment method cash on delivery, ...
51
+ ✓ R3 Within 30 seconds the guest receives exactly one email, and it shows the order number.
52
+ ✓ R4 Stock for the coffee goes down by exactly one.
53
+ ✓ R5 No email that names the guest or the order number goes to anyone but the guest or the shop ...
54
+ ```
55
+
56
+ Exit codes: 0 pass, 1 an approved flow failed, 2 usage or config, 3 the sandbox failed, 4 not approved.
57
+ `examples/woocommerce` is a complete shop with two flows, and [pull request 1](https://github.com/davidrydberg/proofy/pull/1) shows a report posted with `--post`.
58
+ `prd.md` has the full design.
59
+
60
+ The agent instructions say only you run `proofy approve`.
61
+ That is a rule your agent follows, not a lock.
62
+
63
+ ## License
64
+
65
+ MIT
package/dist/args.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface FlagSpec {
2
+ values: string[];
3
+ booleans: string[];
4
+ }
5
+ export interface ParsedArgs {
6
+ positionals: string[];
7
+ values: Map<string, string>;
8
+ booleans: Set<string>;
9
+ }
10
+ export declare function parseArgs(args: string[], spec: FlagSpec, command: string): ParsedArgs;
package/dist/args.js ADDED
@@ -0,0 +1,36 @@
1
+ import { CliError } from "./errors.js";
2
+ export function parseArgs(args, spec, command) {
3
+ const parsed = { positionals: [], values: new Map(), booleans: new Set() };
4
+ for (let i = 0; i < args.length; i++) {
5
+ const arg = args[i] ?? "";
6
+ if (!arg.startsWith("--")) {
7
+ parsed.positionals.push(arg);
8
+ continue;
9
+ }
10
+ const [name = "", inline] = splitFlag(arg.slice(2));
11
+ if (spec.booleans.includes(name)) {
12
+ if (inline !== undefined) {
13
+ throw invalidFlag(`--${name} takes no value`, command);
14
+ }
15
+ parsed.booleans.add(name);
16
+ }
17
+ else if (spec.values.includes(name)) {
18
+ const value = inline ?? args[++i];
19
+ if (value === undefined || value === "") {
20
+ throw invalidFlag(`--${name} needs a value`, command);
21
+ }
22
+ parsed.values.set(name, value);
23
+ }
24
+ else {
25
+ throw invalidFlag(`Unknown flag --${name}`, command);
26
+ }
27
+ }
28
+ return parsed;
29
+ }
30
+ function splitFlag(flag) {
31
+ const eq = flag.indexOf("=");
32
+ return eq === -1 ? [flag, undefined] : [flag.slice(0, eq), flag.slice(eq + 1)];
33
+ }
34
+ function invalidFlag(message, command) {
35
+ return new CliError(message, "VALIDATION_ERROR", [`Run \`proofy ${command} --help\` to see its flags`]);
36
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../cli.js";
3
+ import { spawnProc } from "../sandbox/docker.js";
4
+ process.exitCode = await runCli(process.argv.slice(2), { cwd: process.cwd(), env: process.env, proc: spawnProc, now: () => new Date(), log: (line) => process.stderr.write(`proofy: ${line}\n`) }, { write: (text) => process.stdout.write(text), isTTY: process.stdout.isTTY === true, columns: process.stdout.columns });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Deps } from "./deps.js";
2
+ export declare const DESCRIPTION = "Sealed end-to-end tests that your coding agent writes and you approve";
3
+ export interface Io {
4
+ write: (text: string) => void;
5
+ isTTY: boolean;
6
+ columns?: number | undefined;
7
+ }
8
+ export declare function runCli(argv: string[], deps: Deps, io: Io): Promise<number>;
9
+ export declare function version(): string;
package/dist/cli.js ADDED
@@ -0,0 +1,140 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homeCommand } from "./commands/home.js";
3
+ import { APPROVE_HELP, approveCommand } from "./commands/approve.js";
4
+ import { DEV_HELP, devCommand } from "./commands/dev.js";
5
+ import { INIT_HELP, initCommand } from "./commands/init.js";
6
+ import { REPORT_HELP, reportCommand } from "./commands/report.js";
7
+ import { RUN_HELP, runCommand } from "./commands/run.js";
8
+ import { CliError, exitCodeFor } from "./errors.js";
9
+ import { renderErrorHuman } from "./human.js";
10
+ import { renderError, renderJson, renderResult } from "./output.js";
11
+ import { painter, resolveStyle } from "./style.js";
12
+ export const DESCRIPTION = "Sealed end-to-end tests that your coding agent writes and you approve";
13
+ const FORMAT_FLAGS = { "--toon": "toon", "--json": "json", "--pretty": "pretty" };
14
+ const TOP_HELP = `usage: proofy [command] [args] [flags]
15
+ description: ${DESCRIPTION}
16
+ commands[6]:
17
+ (none) Setup progress, flows and their states, last run, next step. Reads files only, safe for a session-start hook
18
+ init [--no-agents-md] Write e2e/ with agent instructions, templates and a config filled from what it finds
19
+ run [flow ...] [--all] [--pr <n>] [--post] [--keep] One sealed run with a report per requirement
20
+ dev up|test|down A sandbox that stays up while tests are written
21
+ approve <flow> For the human: trust a flow after 3 identical runs
22
+ report [run] Show a past run
23
+ flags[5]:
24
+ --help Show help, also after a command
25
+ -v, --version Print the version
26
+ --toon TOON output (default when not on a terminal)
27
+ --pretty Human view (default on a terminal)
28
+ --json Full data as JSON
29
+ exit_codes[5]:
30
+ 0 Done; in a run, every requirement of every approved flow held
31
+ 1 A requirement of an approved flow did not hold: a finding about the app
32
+ 2 Usage error, invalid config or not set up
33
+ 3 Environment failure: Docker, the sandbox, a time limit. Says nothing about the app
34
+ 4 Not trusted: the flows asked for are not approved or out of date
35
+ how_it_works:
36
+ You describe flows in plain words. Your agent follows e2e/AGENTS.md to write requirements, tests and fakes.
37
+ You approve them, and every run checks the app in a sealed Docker sandbox with no AI.
38
+ requires: Node 24+, Docker on macOS or Linux, git; gh for --pr and --post
39
+ examples:
40
+ proofy init
41
+ proofy
42
+ proofy dev up
43
+ proofy dev test guest-checkout
44
+ proofy approve guest-checkout
45
+ proofy run --all
46
+ `;
47
+ // One help text per command. The human view is derived from it, so the two never disagree.
48
+ function humanHelp(text, style) {
49
+ const p = painter(style);
50
+ const out = [];
51
+ for (const line of text.trimEnd().split("\n")) {
52
+ const header = /^([a-z_]+)(\[\d+\])?:\s*(.*)$/.exec(line);
53
+ if (header) {
54
+ const [, name = "", , rest = ""] = header;
55
+ if (name === "usage")
56
+ out.push(`${p.bold("USAGE")}\n ${rest}`);
57
+ else if (name === "description")
58
+ out.unshift(`${p.bold("proofy")} - ${rest}`);
59
+ else
60
+ out.push(`\n${p.bold(name.replace(/_/g, " ").toUpperCase())}${rest ? `\n ${rest}` : ""}`);
61
+ continue;
62
+ }
63
+ const row = /^ {2}(\S.*?) {2,}(.*)$/.exec(line);
64
+ out.push(row ? ` ${p.cyan(row[1] ?? "")} ${row[2] ?? ""}` : line);
65
+ }
66
+ return out.join("\n");
67
+ }
68
+ const COMMANDS = {
69
+ init: { run: initCommand, help: INIT_HELP },
70
+ run: { run: runCommand, help: RUN_HELP },
71
+ dev: { run: devCommand, help: DEV_HELP },
72
+ approve: { run: approveCommand, help: APPROVE_HELP },
73
+ report: { run: reportCommand, help: REPORT_HELP },
74
+ };
75
+ export async function runCli(argv, deps, io) {
76
+ const style = resolveStyle(deps.env, io.isTTY, io.columns);
77
+ let format = io.isTTY ? "pretty" : "toon";
78
+ try {
79
+ const formats = [...new Set(argv.filter((arg) => arg in FORMAT_FLAGS))];
80
+ if (formats.length > 1) {
81
+ throw new CliError("Use only one of --pretty, --toon and --json", "VALIDATION_ERROR", ["Run `proofy --help`"]);
82
+ }
83
+ const chosen = formats[0];
84
+ if (chosen !== undefined) {
85
+ format = FORMAT_FLAGS[chosen] ?? format;
86
+ }
87
+ const [command, ...args] = argv.filter((arg) => !(arg in FORMAT_FLAGS));
88
+ const pretty = format === "pretty";
89
+ if (command === undefined) {
90
+ return emit(await homeCommand(deps), format, style, io);
91
+ }
92
+ if (command === "--help" || command === "-h" || command === "help") {
93
+ io.write(pretty ? `${humanHelp(TOP_HELP, style)}\n` : TOP_HELP);
94
+ return 0;
95
+ }
96
+ if (command === "--version" || command === "-v") {
97
+ io.write(`${version()}\n`);
98
+ return 0;
99
+ }
100
+ if (command.startsWith("-")) {
101
+ throw new CliError(`Flags go after the command, got ${command}`, "VALIDATION_ERROR", ["Run `proofy --help`"]);
102
+ }
103
+ const entry = COMMANDS[command];
104
+ if (!entry) {
105
+ throw new CliError(`Unknown command: ${command}`, "VALIDATION_ERROR", ["Run `proofy --help` to see commands"]);
106
+ }
107
+ if (args.includes("--help") || args.includes("-h")) {
108
+ io.write(pretty ? `${humanHelp(entry.help, style)}\n` : entry.help);
109
+ return 0;
110
+ }
111
+ return emit(await entry.run(args, deps), format, style, io);
112
+ }
113
+ catch (error) {
114
+ const known = error instanceof CliError;
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ const code = known ? error.code : "UNEXPECTED";
117
+ const suggestions = known ? error.suggestions : ["This is a bug in proofy. Run again with the same arguments and report the message"];
118
+ io.write(`${format === "pretty" ? renderErrorHuman(message, code, suggestions, style) : renderError(message, code, suggestions)}\n`);
119
+ return exitCodeFor(error);
120
+ }
121
+ }
122
+ function emit(result, format, style, io) {
123
+ if (typeof result === "string") {
124
+ io.write(`${result}\n`);
125
+ }
126
+ else if (format === "json") {
127
+ io.write(`${renderJson(result.json ?? result.data)}\n`);
128
+ }
129
+ else if (format === "pretty" && result.pretty) {
130
+ io.write(`${result.pretty(style)}\n`);
131
+ }
132
+ else {
133
+ io.write(`${renderResult(result)}\n`);
134
+ }
135
+ return typeof result === "string" ? 0 : (result.exitCode ?? 0);
136
+ }
137
+ export function version() {
138
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
139
+ return pkg.version;
140
+ }
@@ -0,0 +1,4 @@
1
+ import type { Deps } from "../deps.js";
2
+ import type { CommandResult } from "./types.js";
3
+ export declare const APPROVE_HELP = "usage: proofy approve <flow>\ndescription: For the human, never the agent. Records fingerprints of the flow's requirements, test and fakes, after 3 runs with identical results\nexamples:\n proofy approve guest-checkout\n";
4
+ export declare function approveCommand(args: string[], deps: Deps): Promise<CommandResult>;
@@ -0,0 +1,48 @@
1
+ import { join } from "node:path";
2
+ import { parseArgs } from "../args.js";
3
+ import { CliError } from "../errors.js";
4
+ import { exists, flowNames } from "../project.js";
5
+ import { writeApprovals } from "../run/approvals.js";
6
+ import { flowStatuses, readReport, recentReports } from "../run/history.js";
7
+ import { loadProject } from "./context.js";
8
+ export const APPROVE_HELP = `usage: proofy approve <flow>
9
+ description: For the human, never the agent. Records fingerprints of the flow's requirements, test and fakes, after 3 runs with identical results
10
+ examples:
11
+ proofy approve guest-checkout
12
+ `;
13
+ export async function approveCommand(args, deps) {
14
+ const { positionals } = parseArgs(args, { values: [], booleans: [] }, "approve");
15
+ const [name] = positionals;
16
+ if (!name || positionals.length > 1)
17
+ throw new CliError("Name one flow to approve", "VALIDATION_ERROR", ["Example: proofy approve guest-checkout"]);
18
+ const project = await loadProject(deps);
19
+ const { paths, config, approvals } = project;
20
+ if (!(await flowNames(paths)).includes(name))
21
+ throw new CliError(`No flow named ${name}`, "VALIDATION_ERROR", ["Run `proofy` to see flows"]);
22
+ if (!(await exists(join(paths.tests, `${name}.spec.ts`))))
23
+ throw new CliError(`${name} has no test yet`, "NOT_APPROVED", ["Ask your agent to write it"]);
24
+ const [status] = await flowStatuses(paths, config, [name], approvals, await recentReports(paths));
25
+ if (!status)
26
+ throw new CliError(`No flow named ${name}`, "VALIDATION_ERROR");
27
+ if (status.state === "approved") {
28
+ return { data: { flow: name, state: "already approved" }, help: [`Run \`proofy run ${name}\` to check it`] };
29
+ }
30
+ if (status.state !== "ready" && !(status.state === "out-of-date" && status.runs.length === 3 && !status.reason)) {
31
+ throw new CliError(`${name} cannot be approved yet: ${status.reason ?? status.state}`, "NOT_APPROVED", [
32
+ "Approval needs 3 runs with the current files and identical results per requirement",
33
+ `Run \`proofy run ${name}\` or \`proofy dev test ${name}\` until then`,
34
+ ]);
35
+ }
36
+ const latest = status.runs[0] ? await readReport(paths, status.runs[0]) : undefined;
37
+ const flow = latest?.flows.find((candidate) => candidate.name === name);
38
+ const failing = flow?.requirements.filter((requirement) => requirement.result === "fail" || requirement.result === "not-reached").map((requirement) => requirement.id) ?? [];
39
+ approvals.flows[name] = { ...status.fingerprint, approvedAt: deps.now().toISOString(), runs: status.runs, requirements: flow?.requirements.length ?? 0 };
40
+ await writeApprovals(paths, approvals);
41
+ const help = [`Commit e2e/proofy.lock.json with the flow's files`, `Run \`proofy run --all\` to check every approved flow`];
42
+ if (failing.length > 0)
43
+ help.unshift(`${failing.join(", ")} fail on purpose as findings: runs stay red until the app is fixed`);
44
+ return {
45
+ data: { flow: name, state: "approved", requirements: flow?.requirements.map((requirement) => `${requirement.id} ${requirement.result}`) ?? [], based_on_runs: status.runs },
46
+ help,
47
+ };
48
+ }
@@ -0,0 +1,16 @@
1
+ import { type Config } from "../config.js";
2
+ import type { Deps } from "../deps.js";
3
+ import { type Paths } from "../project.js";
4
+ import { type Approvals } from "../run/approvals.js";
5
+ import type { RunReport } from "../run/results.js";
6
+ export interface Project {
7
+ root: string;
8
+ paths: Paths;
9
+ config: Config;
10
+ approvals: Approvals;
11
+ cacheDir: string;
12
+ }
13
+ export declare function loadProject(deps: Deps): Promise<Project>;
14
+ export declare function workingTreeInfo(deps: Deps, root: string): Promise<RunReport["source"]>;
15
+ /** Project name for docker compose: lowercase letters, digits and dashes. */
16
+ export declare function projectName(root: string, suffix: string): string;
@@ -0,0 +1,27 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { CliError } from "../errors.js";
3
+ import { exists, findRoot, pathsFor } from "../project.js";
4
+ import { readApprovals } from "../run/approvals.js";
5
+ import { cacheDirFor } from "../sandbox/sandbox.js";
6
+ export async function loadProject(deps) {
7
+ const root = await findRoot(deps.cwd);
8
+ const paths = pathsFor(root);
9
+ if (!(await exists(paths.config))) {
10
+ throw new CliError("proofy is not set up in this repository", "NOT_SET_UP", ["Run `proofy init`"]);
11
+ }
12
+ const config = await loadConfig(paths.config, "e2e/proofy.json");
13
+ return { root, paths, config, approvals: await readApprovals(paths), cacheDir: cacheDirFor(deps.env, root) };
14
+ }
15
+ export async function workingTreeInfo(deps, root) {
16
+ const head = await deps.proc("git", ["-C", root, "rev-parse", "HEAD"]);
17
+ const status = await deps.proc("git", ["-C", root, "status", "--porcelain", "--untracked-files=no"]);
18
+ return {
19
+ ...(head.code === 0 ? { commit: head.stdout.trim() } : {}),
20
+ ...(status.code === 0 ? { dirty: status.stdout.trim().length > 0 } : {}),
21
+ };
22
+ }
23
+ /** Project name for docker compose: lowercase letters, digits and dashes. */
24
+ export function projectName(root, suffix) {
25
+ const base = root.split("/").filter(Boolean).at(-1) ?? "app";
26
+ return `proofy-${base}-${suffix}`.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 60);
27
+ }
@@ -0,0 +1,4 @@
1
+ import type { Deps } from "../deps.js";
2
+ import type { CommandResult } from "./types.js";
3
+ export declare const DEV_HELP = "usage: proofy dev up|test|down\ndescription: A sealed sandbox that stays up while tests are written. Every dev test counts toward the 3 identical runs approval needs\ncommands[3]:\n up Start the sandbox and print where to open the app and Mailpit\n test [flow ...] [-- playwright args] Run flows in it; with no flows, every flow that has a test\n down Stop it and delete its data\nexamples:\n proofy dev up\n proofy dev test guest-checkout\n proofy dev test guest-checkout -- --grep \"declined card\"\n proofy dev down\n";
4
+ export declare function devCommand(args: string[], deps: Deps): Promise<CommandResult>;
@@ -0,0 +1,161 @@
1
+ import { readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseArgs } from "../args.js";
4
+ import { CliError } from "../errors.js";
5
+ import { exists, flowNames } from "../project.js";
6
+ import { finishRun, logOffsets, planFlows, reserveRunId, sandboxOptions, testsInSandbox } from "../run/engine.js";
7
+ import { outDir } from "../run/history.js";
8
+ import { compose, forwardedPorts, installImages, prepare, saveLogs, start, stop } from "../sandbox/sandbox.js";
9
+ import { loadProject, projectName, workingTreeInfo } from "./context.js";
10
+ import { runResult } from "./run.js";
11
+ export const DEV_HELP = `usage: proofy dev up|test|down
12
+ description: A sealed sandbox that stays up while tests are written. Every dev test counts toward the 3 identical runs approval needs
13
+ commands[3]:
14
+ up Start the sandbox and print where to open the app and Mailpit
15
+ test [flow ...] [-- playwright args] Run flows in it; with no flows, every flow that has a test
16
+ down Stop it and delete its data
17
+ examples:
18
+ proofy dev up
19
+ proofy dev test guest-checkout
20
+ proofy dev test guest-checkout -- --grep "declined card"
21
+ proofy dev down
22
+ `;
23
+ export async function devCommand(args, deps) {
24
+ const split = args.indexOf("--");
25
+ const own = split === -1 ? args : args.slice(0, split);
26
+ const extra = split === -1 ? [] : args.slice(split + 1);
27
+ const [sub, ...rest] = own;
28
+ const project = await loadProject(deps);
29
+ switch (sub) {
30
+ case "up":
31
+ parseArgs(rest, { values: [], booleans: [] }, "dev");
32
+ return devUp(deps, project);
33
+ case "test":
34
+ return devTest(deps, project, parseArgs(rest, { values: [], booleans: [] }, "dev").positionals, extra);
35
+ case "down":
36
+ parseArgs(rest, { values: [], booleans: [] }, "dev");
37
+ return devDown(deps, project);
38
+ default:
39
+ throw new CliError(sub ? `Unknown dev command: ${sub}` : "dev needs up, test or down", "VALIDATION_ERROR", ["Run `proofy dev --help`"]);
40
+ }
41
+ }
42
+ function stateFile(project) {
43
+ return join(outDir(project.paths), "dev.json");
44
+ }
45
+ async function readState(project) {
46
+ try {
47
+ return JSON.parse(await readFile(stateFile(project), "utf8"));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ async function running(deps, state) {
54
+ const result = await deps.proc("docker", [...compose(state), "ps", "--status", "running", "-q"]);
55
+ return result.code === 0 && result.stdout.trim().length > 0;
56
+ }
57
+ function context(deps, project, sourceInfo) {
58
+ return {
59
+ proc: deps.proc,
60
+ env: deps.env,
61
+ now: deps.now,
62
+ log: deps.log,
63
+ base: project.paths,
64
+ source: project.paths,
65
+ config: project.config,
66
+ approvals: project.approvals,
67
+ cacheDir: project.cacheDir,
68
+ sourceInfo,
69
+ };
70
+ }
71
+ async function devUp(deps, project) {
72
+ const existing = await readState(project);
73
+ if (existing && (await running(deps, existing))) {
74
+ return upResult(existing, true);
75
+ }
76
+ const ctx = context(deps, project, {});
77
+ const options = sandboxOptions(ctx, "dev", projectName(project.root, "dev"), outDir(project.paths));
78
+ let sandbox = await prepare(deps.proc, options);
79
+ try {
80
+ await installImages(deps.proc, options, sandbox);
81
+ sandbox = await start(deps.proc, options, sandbox);
82
+ }
83
+ catch (error) {
84
+ await saveLogs(deps.proc, sandbox, outDir(project.paths));
85
+ await stop(deps.proc, sandbox);
86
+ throw error;
87
+ }
88
+ const state = { ...sandbox, startedAt: deps.now().toISOString(), urls: await forwardedPorts(deps.proc, sandbox) };
89
+ await writeFile(stateFile(project), `${JSON.stringify(state, null, 2)}\n`);
90
+ return upResult(state, false);
91
+ }
92
+ function upResult(state, already) {
93
+ const help = [
94
+ "Run `proofy dev test <flow>` to run a flow in it; three identical runs make it ready to approve",
95
+ "Run `proofy dev down` when done",
96
+ ];
97
+ const data = { sandbox: state.project, status: already ? "already running" : "started", app: state.urls.app ?? "not forwarded", mailpit: state.urls.mail ?? "not forwarded" };
98
+ if (state.notes.length > 0)
99
+ data.notes = state.notes;
100
+ return {
101
+ data,
102
+ help,
103
+ pretty: () => [
104
+ `dev sandbox ${state.project} ${already ? "already running" : "started"}, sealed`,
105
+ ` app ${state.urls.app ?? "not forwarded"} (links inside the app may use its in-sandbox name)`,
106
+ ` mailpit ${state.urls.mail ?? "not forwarded"}`,
107
+ ...state.notes.map((note) => ` note: ${note}`),
108
+ "",
109
+ "NEXT",
110
+ " proofy dev test <flow> run a flow; three identical runs make it ready to approve",
111
+ " proofy dev down stop it and delete its data",
112
+ ].join("\n"),
113
+ };
114
+ }
115
+ async function devTest(deps, project, names, extra) {
116
+ const state = await readState(project);
117
+ if (!state || !(await running(deps, state))) {
118
+ throw new CliError("The dev sandbox is not running", "VALIDATION_ERROR", ["Run `proofy dev up` first"]);
119
+ }
120
+ const available = await flowNames(project.paths);
121
+ const withTests = [];
122
+ for (const name of available)
123
+ if (await exists(join(project.paths.tests, `${name}.spec.ts`)))
124
+ withTests.push(name);
125
+ const chosen = names.length > 0 ? names : withTests;
126
+ for (const name of chosen) {
127
+ if (!withTests.includes(name)) {
128
+ throw new CliError(available.includes(name) ? `${name} has no test yet` : `No flow named ${name}`, "VALIDATION_ERROR", [`Flows with tests: ${withTests.join(", ") || "none"}`]);
129
+ }
130
+ }
131
+ if (chosen.length === 0)
132
+ throw new CliError("No flow has a test yet", "VALIDATION_ERROR", ["Ask your agent to write one (step 4 in e2e/AGENTS.md)"]);
133
+ const ctx = context(deps, project, await workingTreeInfo(deps, project.root));
134
+ const planned = await planFlows(ctx, chosen);
135
+ const id = await reserveRunId(ctx);
136
+ const startedAt = deps.now().toISOString();
137
+ const offsets = await logOffsets(state);
138
+ let environmentError;
139
+ try {
140
+ await testsInSandbox(ctx, state, planned, extra);
141
+ }
142
+ catch (error) {
143
+ if (!(error instanceof CliError) || error.code !== "ENVIRONMENT_ERROR")
144
+ throw error;
145
+ environmentError = [error.message, ...error.suggestions].join("\n");
146
+ }
147
+ const notes = extra.length > 0 ? [`Playwright arguments: ${extra.join(" ")}. A filtered run can leave requirements unchecked`] : [];
148
+ const runDir = join(outDir(project.paths), id);
149
+ const report = await finishRun(ctx, { id, kind: "dev", startedAt, runDir, planned, notes, sandbox: state, offsets, ...(environmentError ? { environmentError } : {}) });
150
+ return runResult(report, runDir, project.root);
151
+ }
152
+ async function devDown(deps, project) {
153
+ const state = await readState(project);
154
+ if (!state) {
155
+ return { data: { sandbox: "none running" }, help: ["Run `proofy dev up` to start one"] };
156
+ }
157
+ await saveLogs(deps.proc, state, outDir(project.paths));
158
+ await stop(deps.proc, state);
159
+ await rm(stateFile(project), { force: true });
160
+ return { data: { sandbox: state.project, status: "stopped, data deleted" }, help: ["Run `proofy` to see each flow's state"] };
161
+ }
@@ -0,0 +1,3 @@
1
+ import type { Deps } from "../deps.js";
2
+ import type { CommandResult } from "./types.js";
3
+ export declare function homeCommand(deps: Deps): Promise<CommandResult | string>;