diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb34d18..ada75f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - name: Build run: pnpm build + + - name: Validate installed CLI package + run: pnpm release:check diff --git a/README.md b/README.md index c88c46c..c4ab50d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Fencier gives AI-assisted development a deterministic operating boundary. Before Fencier does not replace Codex, code review, or security tooling. It makes the contract around an agent's work explicit, inspectable, and difficult to ignore. > [!IMPORTANT] -> Fencier is currently a development preview and is not published to npm. Use the local development workflow below. +> Fencier v0.1 is a release candidate. The CLI is validated as an installed package, but it is not published to npm yet. Use the local package workflow below until registry ownership is finalized. ## See it in action @@ -77,6 +77,7 @@ git clone https://github.com/DerMayer1/Fencier.git cd Fencier pnpm install pnpm run ci +pnpm release:check cd packages/cli npm link @@ -133,6 +134,12 @@ fencier verify --staged fencier verify --base origin/main ``` +Use `--json` for machine-readable output. Raw added lines are omitted so possible secret values cannot be exposed through the JSON result: + +```bash +fencier verify --json --no-audit +``` + ## Policy as code Every governed repository owns its boundary in `fencier.yaml`: @@ -264,14 +271,17 @@ This split keeps policy decisions unit-testable and makes every report traceable | Command | Purpose | |---|---| | `fencier init --codex` | Initialize policy, audit workspace, and the Codex adapter | -| `fencier doctor` | Check that the CLI is wired correctly | +| `fencier doctor` | Check the installed CLI version, Node.js requirement, and Git runtime | +| `fencier codex install` | Install the Codex `AGENTS.md` adapter on its own | | `fencier codex prepare` | Check policy, adapter, local skills, and latest audit readiness | | `fencier codex brief` | Print deterministic repository context for Codex | | `fencier codex runbook ` | Compose a complete task runbook | +| `fencier codex fix-audit brief` | Print the runbook for fixing the latest audit | | `fencier codex prompt list\|show` | Inspect versioned prompt templates | | `fencier codex checklist list\|show` | Inspect task checklists | | `fencier codex skill list\|show\|install` | Inspect and install repository-local skills | -| `fencier verify` | Evaluate the current diff and write configured audits | +| `fencier codex skill path` | Print the local skill installation directory | +| `fencier verify` | Evaluate the current diff and write configured audits; supports safe `--json` output | | `fencier check` | Compatibility alias for `verify` | | `fencier audit list` | List local audit reports | | `fencier audit show latest` | Print the latest Markdown audit | @@ -290,6 +300,8 @@ Fencier is local-first by design: - full patches are excluded from audits by default; - all decisions are derived from local policy and local Git state. +Setting `audit.include_patch: true` opts into embedding the raw diff in audit reports. That patch is not masked, so a diff that adds a secret will store it in `.fencier/audits/` in plaintext. The default is `false`. + Fencier is a boundary verifier, not a complete secret scanner or security analyzer. Human review remains required, especially for authentication, payments, CI/CD, migrations, and infrastructure changes. See the [security model](docs/security.md) for the complete contract. ## What Fencier is not @@ -308,6 +320,7 @@ These boundaries are deliberate. Fencier stays useful by remaining small, determ pnpm install pnpm run ci pnpm run pack:cli +pnpm release:check ``` Run the built CLI without linking it globally: @@ -318,11 +331,11 @@ node packages/cli/dist/index.js codex prepare node packages/cli/dist/index.js verify --no-audit ``` -The repository currently contains 45 tests across the policy engine, Git collection, initialization, audits, adapters, skills, runbooks, and CLI behavior. +The test suite covers the policy engine, Git collection, initialization, audits, adapters, skills, runbooks, CLI behavior, and secret-safe machine output. `pnpm release:check` additionally packs the CLI, installs it into a temporary Git repository, and exercises doctor, initialization, skills, passing verification, blocking verification, and secret masking end to end. ## Project status -Implemented today: +The v0.1 local product contract is implemented: - deterministic local diff verification; - Markdown and JSON audit reports; @@ -331,14 +344,16 @@ Implemented today: - implementation, bugfix, review, refactor, security, and audit-fix runbooks; - compatibility adapters for Claude Code, Cursor, and GitHub Copilot; - idempotent repository initialization; +- real runtime diagnostics for Node.js and Git; +- secret-safe JSON verification output; +- install-from-tarball end-to-end release validation; - local CLI packaging validation and GitHub Actions CI. -Not implemented yet: +External release gate: + +- publish `@fencier/cli` after npm scope ownership and release credentials are confirmed. -- npm publication; -- global Codex skill installation; -- policy rule plugins; -- a richer fixture-based benchmark suite. +Possible post-v0.1 extensions, not required by the current product contract, include global Codex skill installation, policy rule plugins, and a larger benchmark corpus. ## Documentation diff --git a/docs/security.md b/docs/security.md index 53f75e8..0ae147b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,6 +16,8 @@ The current scanner checks added lines from git patches and untracked files. It Audit reports are written to `.fencier/audits/` as Markdown and JSON. Full patches are not included by default. +Setting `audit.include_patch: true` embeds the raw `git diff` in both report formats. That patch is the one part of an audit that is **not** masked: if the diff adds a secret, the secret appears in `.fencier/audits/` in plaintext. Keep the default unless the audit directory is git-ignored and treated as sensitive local state. + ## Limitations Fencier verification does not prove a change is safe. It identifies risk signals and policy violations so humans and Codex can review or correct faster. diff --git a/package.json b/package.json index 1f6f843..43be8b5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,21 @@ { "name": "fencier-monorepo", - "version": "0.0.0", + "version": "0.1.0", "private": true, + "description": "Local-first policy boundary for AI coding agents.", + "license": "MIT", "type": "module", "packageManager": "pnpm@11.7.0", + "engines": { + "node": ">=22" + }, "scripts": { "build": "pnpm -r build", "ci": "pnpm lint && pnpm typecheck && pnpm test && pnpm build", "format": "biome format --write .", "lint": "biome check .", "pack:cli": "pnpm --filter @fencier/cli pack", + "release:check": "node scripts/release-check.mjs", "test": "vitest run", "typecheck": "pnpm -r typecheck" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index f892cd1..bc24471 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,29 @@ { "name": "@fencier/cli", - "version": "0.0.0", + "version": "0.1.0", + "description": "Local-first boundary layer that prepares Codex sessions and verifies Git diffs.", + "license": "MIT", + "homepage": "https://github.com/DerMayer1/Fencier#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/DerMayer1/Fencier.git", + "directory": "packages/cli" + }, + "bugs": { + "url": "https://github.com/DerMayer1/Fencier/issues" + }, + "keywords": ["codex", "ai-agents", "policy-as-code", "git", "security"], "type": "module", + "engines": { + "node": ">=22" + }, "bin": { "fencier": "./dist/index.js" }, "files": ["dist"], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsup", "dev": "tsx src/index.ts", diff --git a/packages/cli/src/audit.test.ts b/packages/cli/src/audit.test.ts index d21f8fd..bf0a216 100644 --- a/packages/cli/src/audit.test.ts +++ b/packages/cli/src/audit.test.ts @@ -69,4 +69,79 @@ describe("writeAuditReports", () => { expect(contents).not.toContain("abcdefghijklmnop"); expect(contents).not.toContain("addedLines"); }); + + it("omits the patch unless audit.include_patch is enabled", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-audit-excluded-")); + + await writeAuditReports({ + cwd, + policy: parsePolicyConfig("version: 1\n"), + result: createCleanResult(), + repo: { path: cwd }, + patch: "diff --git a/src/a.ts b/src/a.ts\n+const token = 1;\n", + }); + + const contents = await readAuditPair(cwd); + + expect(contents.json).not.toContain('"patch"'); + expect(contents.markdown).not.toContain("## Patch"); + expect(contents.markdown).not.toContain("const token = 1;"); + }); + + it("writes the patch when audit.include_patch is enabled", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-audit-patch-")); + + await writeAuditReports({ + cwd, + policy: parsePolicyConfig("version: 1\naudit:\n include_patch: true\n"), + result: createCleanResult(), + repo: { path: cwd }, + patch: "diff --git a/src/a.ts b/src/a.ts\n+const token = 1;\n", + }); + + const contents = await readAuditPair(cwd); + + expect(contents.json).toContain("const token = 1;"); + expect(contents.markdown).toContain("## Patch"); + expect(contents.markdown).toContain("```diff"); + expect(contents.markdown).toContain("const token = 1;"); + }); }); + +function createCleanResult(): PolicyResult { + return { + status: "pass", + risk: "low", + score: 0, + summary: { + filesChanged: 1, + linesAdded: 1, + linesRemoved: 0, + totalLinesChanged: 1, + }, + findings: [], + evaluatedFiles: [ + { + path: "src/a.ts", + status: "modified", + additions: 1, + deletions: 0, + }, + ], + }; +} + +async function readAuditPair(cwd: string): Promise<{ json: string; markdown: string }> { + const auditDir = join(cwd, ".fencier", "audits"); + const auditFiles = await readdir(auditDir); + const jsonAudit = auditFiles.find((file) => file.endsWith(".json")); + const markdownAudit = auditFiles.find((file) => file.endsWith(".md")); + + expect(jsonAudit).toBeDefined(); + expect(markdownAudit).toBeDefined(); + + return { + json: await readFile(join(auditDir, jsonAudit as string), "utf8"), + markdown: await readFile(join(auditDir, markdownAudit as string), "utf8"), + }; +} diff --git a/packages/cli/src/audit.ts b/packages/cli/src/audit.ts index 2e9169e..7090d8b 100644 --- a/packages/cli/src/audit.ts +++ b/packages/cli/src/audit.ts @@ -23,6 +23,12 @@ export type AuditEvent = { summary: PolicyResult["summary"]; findings: PolicyFinding[]; changedFiles: DiffFile[]; + /** + * Raw patch text, written only when `audit.include_patch` is enabled. + * Unlike every other audit field, this is not masked and may contain + * secret values in plaintext. + */ + patch?: string; }; export type WrittenAudit = { @@ -36,6 +42,7 @@ export async function writeAuditReports(input: { policy: FencierPolicy; result: PolicyResult; repo: RepoContext; + patch?: string; }): Promise { const timestamp = new Date().toISOString(); const id = timestamp.replace(/[:.]/g, "-"); @@ -45,6 +52,7 @@ export async function writeAuditReports(input: { policy: input.policy, result: input.result, repo: input.repo, + patch: input.patch, }); const auditDir = join(input.cwd, ".fencier", "audits"); @@ -100,7 +108,10 @@ function createAuditEvent(input: { policy: FencierPolicy; result: PolicyResult; repo: RepoContext; + patch?: string; }): AuditEvent { + const patch = input.policy.audit.include_patch ? input.patch : undefined; + return { version: 1, id: input.id, @@ -121,6 +132,7 @@ function createAuditEvent(input: { additions: file.additions, deletions: file.deletions, })), + ...(patch ? { patch } : {}), }; } @@ -181,6 +193,13 @@ function formatAuditMarkdown(audit: AuditEvent): string { lines.push(`| \`${file.path}\` | ${file.status} | ${file.additions} | ${file.deletions} |`); } + if (audit.patch) { + lines.push("", "## Patch", ""); + lines.push("Written because `audit.include_patch` is enabled."); + lines.push("This section is unmasked and may contain secret values."); + lines.push("", "```diff", audit.patch.replace(/\n+$/, ""), "```"); + } + lines.push("", "## Policy", ""); lines.push(`Policy file: \`${audit.policy.file}\``); lines.push(`Policy version: ${audit.policy.version}`); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 40b9e9f..4dac50c 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createProgram } from "./cli"; +import { createProgram, formatDoctorReport } from "./cli"; +import { formatCheckJson } from "./output"; describe("createProgram", () => { it("configures the fencier CLI", () => { @@ -49,4 +50,60 @@ describe("createProgram", () => { expect.arrayContaining(["list", "show", "path", "install"]), ); }); + + it("exposes safe JSON verification output", () => { + const program = createProgram(); + const verify = program.commands.find((command) => command.name() === "verify"); + + expect(verify?.options.map((option) => option.long)).toContain("--json"); + }); +}); + +describe("runtime output", () => { + it("reports concrete doctor checks", () => { + const output = formatDoctorReport({ + version: "0.1.0", + nodeVersion: "22.0.0", + nodeSupported: true, + gitVersion: "git version 2.50.0", + ready: true, + }); + + expect(output).toContain("CLI: v0.1.0"); + expect(output).toContain("Node.js: PASS"); + expect(output).toContain("Status: READY"); + }); + + it("omits raw added lines from machine-readable output", () => { + const rawSecret = ["sk", "test", "secret", "value"].join("-"); + const secretLine = [["OPENAI", "API", "KEY"].join("_"), rawSecret].join("="); + const output = formatCheckJson({ + status: "fail", + risk: "high", + score: 50, + findings: [ + { + ruleId: "secret_pattern", + severity: "critical", + message: "Possible secret detected", + score: 50, + preview: "sk-t...redacted", + }, + ], + summary: { filesChanged: 1, linesAdded: 1, linesRemoved: 0, totalLinesChanged: 1 }, + evaluatedFiles: [ + { + path: "src/config.ts", + status: "modified", + additions: 1, + deletions: 0, + addedLines: [{ content: secretLine }], + }, + ], + }); + + expect(output).toContain("sk-t...redacted"); + expect(output).not.toContain(rawSecret); + expect(output).not.toContain("addedLines"); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d19ade7..796acfe 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { listAdapters } from "@fencier/adapters"; import { getChecklist, @@ -9,31 +11,46 @@ import { listPrompts, listSkills, } from "@fencier/codex-kit"; -import { createHealthCheck, evaluatePolicy, parsePolicyConfig } from "@fencier/core"; +import { evaluatePolicy, parsePolicyConfig } from "@fencier/core"; import { Command } from "commander"; +import cliPackage from "../package.json" with { type: "json" }; import { installAdapters, parseAdapterInstallSelection } from "./adapters"; import { listAuditFiles, readLatestAuditMarkdown, writeAuditReports } from "./audit"; import { createCodexBrief } from "./codex"; import { collectGitDiff, getGitBranch, getGitCommit, isInsideGitRepo } from "./git"; import { initializeFencier } from "./init"; -import { formatCheckResult } from "./output"; +import { formatCheckJson, formatCheckResult } from "./output"; import { getLocalSkillRoot, installSkills, parseSkillInstallSelection } from "./skills"; import { createCodexPrepareReport, createCodexRunbook } from "./workflow"; +const execFileAsync = promisify(execFile); + +export type DoctorReport = { + version: string; + nodeVersion: string; + nodeSupported: boolean; + gitVersion?: string; + ready: boolean; +}; + export function createProgram(): Command { const program = new Command(); program .name("fencier") .description("Local-first boundary layer for AI coding agents") - .version("0.0.0"); + .version(cliPackage.version); program .command("doctor") - .description("Check that the Fencier CLI is wired correctly") - .action(() => { - const health = createHealthCheck(); - console.log(`${health.name}: ${health.status}`); + .description("Check the installed CLI, Node.js, and Git runtime") + .action(async () => { + const report = await inspectDoctor(); + console.log(formatDoctorReport(report)); + + if (!report.ready) { + process.exitCode = 3; + } }); program @@ -93,6 +110,7 @@ export function createProgram(): Command { .description("Alias for `verify`; evaluate the current git diff against fencier.yaml") .option("--staged", "Evaluate staged changes only") .option("--base ", "Evaluate changes against a base ref") + .option("--json", "Print machine-readable JSON without raw added lines") .option("--no-audit", "Skip writing local audit reports") .action(runVerification); @@ -101,6 +119,7 @@ export function createProgram(): Command { .description("Run deterministic verification for the current Codex-produced diff") .option("--staged", "Verify staged changes only") .option("--base ", "Verify changes against a base ref") + .option("--json", "Print machine-readable JSON without raw added lines") .option("--no-audit", "Skip writing local audit reports") .action(runVerification); @@ -323,6 +342,38 @@ export function createProgram(): Command { return program; } +export async function inspectDoctor(): Promise { + const nodeVersion = process.versions.node; + const nodeSupported = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10) >= 22; + let gitVersion: string | undefined; + + try { + gitVersion = (await execFileAsync("git", ["--version"], { windowsHide: true })).stdout.trim(); + } catch { + gitVersion = undefined; + } + + return { + version: cliPackage.version, + nodeVersion, + nodeSupported, + gitVersion, + ready: nodeSupported && Boolean(gitVersion), + }; +} + +export function formatDoctorReport(report: DoctorReport): string { + return [ + "Fencier Doctor", + "", + `CLI: v${report.version}`, + `Node.js: ${report.nodeSupported ? "PASS" : "FAIL"} (v${report.nodeVersion}; requires >=22)`, + `Git: ${report.gitVersion ? `PASS (${report.gitVersion})` : "FAIL (not available)"}`, + "", + `Status: ${report.ready ? "READY" : "NOT_READY"}`, + ].join("\n"); +} + function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } @@ -371,6 +422,7 @@ function parsePromptId(value: string): Parameters[1] async function runVerification(options: { staged?: boolean; base?: string; + json?: boolean; audit?: boolean; }): Promise { const cwd = process.cwd(); @@ -381,50 +433,50 @@ async function runVerification(options: { return; } - let policyText: string; - try { - policyText = await readFile(join(cwd, "fencier.yaml"), "utf8"); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - console.error("Missing fencier.yaml. Run `fencier init` first."); - process.exitCode = 3; - return; - } - - throw error; - } - - const policy = parsePolicyConfig(policyText); - const files = await collectGitDiff({ - cwd, - staged: options.staged, - base: options.base, - }); - const result = evaluatePolicy({ policy, files }); - - console.log(formatCheckResult(result)); - - if (options.audit !== false) { - const written = await writeAuditReports({ + const policyText = await readFile(join(cwd, "fencier.yaml"), "utf8"); + const policy = parsePolicyConfig(policyText); + const writeAudit = options.audit !== false; + const diff = await collectGitDiff({ cwd, - policy, - result, - repo: { - path: cwd, - branch: await getGitBranch(cwd), - commit: await getGitCommit(cwd), - }, + staged: options.staged, + base: options.base, + includePatch: writeAudit && policy.audit.include_patch, }); + const result = evaluatePolicy({ policy, files: diff.files }); + + console.log(options.json ? formatCheckJson(result) : formatCheckResult(result)); + + if (writeAudit) { + const written = await writeAuditReports({ + cwd, + policy, + result, + patch: diff.patch, + repo: { + path: cwd, + branch: await getGitBranch(cwd), + commit: await getGitCommit(cwd), + }, + }); - if (written.markdownPath) { - console.log(""); - console.log(`Audit report: ${written.markdownPath}`); + if (written.markdownPath && !options.json) { + console.log(""); + console.log(`Audit report: ${written.markdownPath}`); + } } - } - if (result.status === "fail") { - process.exitCode = 1; + if (result.status === "fail") { + process.exitCode = 1; + } + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + console.error("Missing fencier.yaml. Run `fencier init` first."); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exitCode = 3; } } diff --git a/packages/cli/src/git.test.ts b/packages/cli/src/git.test.ts index 78dcf98..8b8ff6f 100644 --- a/packages/cli/src/git.test.ts +++ b/packages/cli/src/git.test.ts @@ -14,13 +14,30 @@ describe("collectGitDiff", () => { await execFileAsync("git", ["init"], { cwd, windowsHide: true }); await writeFile(join(cwd, "fencier.yaml"), "version: 1\n", "utf8"); - const files = await collectGitDiff({ cwd }); + const diff = await collectGitDiff({ cwd }); - expect(files).toContainEqual( + expect(diff.files).toContainEqual( expect.objectContaining({ path: "fencier.yaml", status: "added", }), ); }); + + it("returns patch text only when includePatch is requested", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-patch-git-test-")); + await execFileAsync("git", ["init"], { cwd, windowsHide: true }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd }); + await execFileAsync("git", ["config", "user.name", "Fencier Test"], { cwd }); + await writeFile(join(cwd, "src.ts"), "export const a = 1;\n", "utf8"); + await execFileAsync("git", ["add", "-A"], { cwd, windowsHide: true }); + await execFileAsync("git", ["commit", "-m", "base"], { cwd, windowsHide: true }); + await writeFile(join(cwd, "src.ts"), "export const a = 2;\n", "utf8"); + + const withoutPatch = await collectGitDiff({ cwd }); + const withPatch = await collectGitDiff({ cwd, includePatch: true }); + + expect(withoutPatch.patch).toBeUndefined(); + expect(withPatch.patch).toContain("export const a = 2;"); + }); }); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 859a536..98364e8 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -10,6 +10,16 @@ export type GitDiffOptions = { cwd: string; staged?: boolean; base?: string; + includePatch?: boolean; +}; + +export type GitDiffResult = { + files: DiffFile[]; + /** + * Full-context patch text, collected only when `includePatch` is set. + * Untracked files are not represented; `git diff` does not report them. + */ + patch?: string; }; export async function isInsideGitRepo(cwd: string): Promise { @@ -37,27 +47,36 @@ export async function getGitCommit(cwd: string): Promise { } } -export async function collectGitDiff(options: GitDiffOptions): Promise { +export async function collectGitDiff(options: GitDiffOptions): Promise { if (!options.staged && !options.base && !(await hasGitHead(options.cwd))) { - return collectUntrackedFiles(options.cwd); + return { files: await collectUntrackedFiles(options.cwd) }; } const numstat = await execGit(options.cwd, [...createDiffArgs(options), "--numstat"]); const nameStatus = await execGit(options.cwd, [...createDiffArgs(options), "--name-status"]); - const patch = await execGit(options.cwd, [...createDiffArgs(options), "--unified=0"]); + const scanPatch = await execGit(options.cwd, [...createDiffArgs(options), "--unified=0"]); const statusByPath = parseNameStatus(nameStatus); - const addedLinesByPath = parseAddedLines(patch); + const addedLinesByPath = parseAddedLines(scanPatch); const trackedFiles = parseNumstat(numstat).map((file) => ({ ...file, status: statusByPath.get(file.path) ?? "unknown", addedLines: addedLinesByPath.get(file.path) ?? [], })); + // The scan patch drops context lines, so re-read the diff with default + // context when the policy asks for a reviewable patch. + const patch = options.includePatch + ? await execGit(options.cwd, createDiffArgs(options)) + : undefined; + if (options.staged || options.base) { - return trackedFiles; + return { files: trackedFiles, patch }; } - return [...trackedFiles, ...(await collectUntrackedFiles(options.cwd))]; + return { + files: [...trackedFiles, ...(await collectUntrackedFiles(options.cwd))], + patch, + }; } async function execGit(cwd: string, args: string[]): Promise { diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index c5175e3..8d08446 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -26,3 +26,14 @@ export function formatCheckResult(result: PolicyResult): string { return lines.join("\n"); } + +export function formatCheckJson(result: PolicyResult): string { + return JSON.stringify( + { + ...result, + evaluatedFiles: result.evaluatedFiles.map(({ addedLines: _addedLines, ...file }) => file), + }, + null, + 2, + ); +} diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..59bab39 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const workspace = await mkdtemp(join(tmpdir(), "fencier-release-check-")); +const tarballDir = join(workspace, "tarball"); +const targetRepo = join(workspace, "target"); +async function run(file, args, cwd, expectedCodes = [0]) { + try { + const result = await execFileAsync(file, args, { cwd, windowsHide: true }); + return { code: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const code = typeof error.code === "number" ? error.code : -1; + + if (expectedCodes.includes(code)) { + return { code, stdout: error.stdout ?? "", stderr: error.stderr ?? "" }; + } + + throw new Error(`${basename(file)} failed with exit code ${code}: ${error.stderr ?? ""}`); + } +} + +async function runPackageCommand(name, args, cwd) { + if (process.platform === "win32") { + return run(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", name, ...args], cwd); + } + + return run(name, args, cwd); +} + +try { + await mkdir(tarballDir, { recursive: true }); + await mkdir(targetRepo, { recursive: true }); + await runPackageCommand("pnpm", ["build"], process.cwd()); + await runPackageCommand( + "pnpm", + ["--filter", "@fencier/cli", "pack", "--pack-destination", tarballDir], + process.cwd(), + ); + + const tarball = (await readdir(tarballDir)).find((file) => file.endsWith(".tgz")); + if (!tarball) throw new Error("CLI pack did not produce a tarball."); + + await writeFile( + join(targetRepo, "package.json"), + `${JSON.stringify({ name: "fencier-release-check", private: true }, null, 2)}\n`, + "utf8", + ); + await runPackageCommand( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", join(tarballDir, tarball)], + targetRepo, + ); + + const cli = join(targetRepo, "node_modules", "@fencier", "cli", "dist", "index.js"); + const doctor = await run(process.execPath, [cli, "doctor"], targetRepo); + if (!doctor.stdout.includes("Status: READY")) throw new Error("Installed CLI doctor failed."); + + await run("git", ["init"], targetRepo); + await run(process.execPath, [cli, "init", "--codex"], targetRepo); + await run(process.execPath, [cli, "codex", "skill", "install", "all"], targetRepo); + await run("git", ["add", "."], targetRepo); + await run( + "git", + ["-c", "user.name=Fencier", "-c", "user.email=fencier@invalid", "commit", "-m", "baseline"], + targetRepo, + ); + + await mkdir(join(targetRepo, "src"), { recursive: true }); + await writeFile(join(targetRepo, "src", "index.ts"), "export const ready = true;\n", "utf8"); + const passing = await run(process.execPath, [cli, "verify", "--json", "--no-audit"], targetRepo); + if (JSON.parse(passing.stdout).status !== "pass") throw new Error("PASS fixture did not pass."); + + await run("git", ["add", "src/index.ts"], targetRepo); + await run( + "git", + ["-c", "user.name=Fencier", "-c", "user.email=fencier@invalid", "commit", "-m", "safe"], + targetRepo, + ); + + const syntheticKey = ["sk", "fencier", "release", "check", "1234567890"].join("-"); + const assignment = [["OPENAI", "API", "KEY"].join("_"), syntheticKey].join("="); + await writeFile(join(targetRepo, ".env"), `${assignment}\n`, "utf8"); + const failing = await run( + process.execPath, + [cli, "verify", "--json", "--no-audit"], + targetRepo, + [1], + ); + const failedResult = JSON.parse(failing.stdout); + if (failedResult.status !== "fail") throw new Error("FAIL fixture did not fail."); + if (failing.stdout.includes(syntheticKey)) throw new Error("JSON output exposed a raw secret."); + + console.log("Release check passed: packed install, doctor, init, skills, PASS, and safe FAIL."); +} finally { + const tempRoot = `${resolve(tmpdir())}${sep}`; + const resolvedWorkspace = resolve(workspace); + if ( + resolvedWorkspace.startsWith(tempRoot) && + basename(resolvedWorkspace).startsWith("fencier-release-check-") + ) { + await rm(resolvedWorkspace, { recursive: true, force: true }); + } +}