From aba557a62e59762a55642d663466b4e0642227d9 Mon Sep 17 00:00:00 2001 From: YG Park Date: Wed, 5 Aug 2026 14:07:51 +0900 Subject: [PATCH] feat: add continuous live contract verification --- .github/workflows/ci.yml | 34 ++++ .github/workflows/release.yml | 82 +++++++++- README.md | 11 ++ bin/install.js | 2 +- docs/live-canary.md | 23 +++ package.json | 5 +- skills/guide/SKILL.md | 18 +++ src/commands/doctor.ts | 282 +++++++++++++++++++++++++++++++++ src/commands/pat/create.ts | 7 +- src/commands/support/create.ts | 11 +- src/commands/support/reply.ts | 34 ++-- src/index.ts | 2 + tests/doctor.test.ts | 116 ++++++++++++++ 13 files changed, 597 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/live-canary.md create mode 100644 src/commands/doctor.ts create mode 100644 tests/doctor.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2df148d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "17 2 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Verify (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun test + - run: bun run build + - if: matrix.os == 'ubuntu-latest' + run: npm pack --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1241834..ac88183 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,17 @@ permissions: contents: write jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun test + version: + needs: verify runs-on: ubuntu-latest outputs: version: ${{ steps.bump.outputs.version }} @@ -62,10 +72,12 @@ jobs: - name: Build binary run: | bun build --compile --target=${{ matrix.target }} --outfile=gh2 src/index.ts - if [[ "${{ matrix.target }}" == bun-darwin-* ]]; then - codesign --remove-signature gh2 - codesign --force --deep -s - gh2 - fi + case "${{ matrix.target }}" in + bun-darwin-*) + codesign --remove-signature gh2 + codesign --force --deep -s - gh2 + ;; + esac tar czf ${{ matrix.name }}.tar.gz gh2 - name: Package Debian artifact @@ -147,7 +159,7 @@ jobs: SHA_LINUX_ARM64=$(shasum -a 256 artifacts/gh2-linux-arm64/*.tar.gz | cut -d' ' -f1) SHA_LINUX_X64=$(shasum -a 256 artifacts/gh2-linux-x64/*.tar.gz | cut -d' ' -f1) - git clone https://x-access-token:${GH_TOKEN}@github.com/circlesac/homebrew-tap.git + git clone "https://x-access-token:${GH_TOKEN}@github.com/circlesac/homebrew-tap.git" cd homebrew-tap cp ../homebrew/gh2.rb.template Formula/gh2.rb sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" Formula/gh2.rb @@ -175,3 +187,63 @@ jobs: printf 'push attempt %s failed\n' "$attempt" >&2 done exit 1 + + verify-release: + needs: [version, publish-npm, publish-package-indexes] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Verify npm installation + shell: bash + run: | + for attempt in {1..10}; do + if npm install --prefix "$RUNNER_TEMP/npm" "@circlesac/gh2@$VERSION"; then + break + fi + test "$attempt" -lt 10 + sleep 15 + done + test "$("$RUNNER_TEMP/npm/node_modules/.bin/gh2" --version)" = "$VERSION" + "$RUNNER_TEMP/npm/node_modules/.bin/gh2" doctor --help >/dev/null + + - name: Verify standalone installation + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/standalone" + curl -fsSL https://github.com/circlesac/gh2-cli/releases/latest/download/install.sh | INSTALL_DIR="$RUNNER_TEMP/standalone" sh + test "$("$RUNNER_TEMP/standalone/gh2" --version)" = "$VERSION" + "$RUNNER_TEMP/standalone/gh2" doctor --help >/dev/null + + - name: Verify Debian package + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + curl -fsSL -o "$RUNNER_TEMP/gh2.deb" "https://github.com/circlesac/gh2-cli/releases/download/v$VERSION/gh2_${VERSION}_amd64.deb" + dpkg-deb --extract "$RUNNER_TEMP/gh2.deb" "$RUNNER_TEMP/deb" + test "$("$RUNNER_TEMP/deb/usr/bin/gh2" --version)" = "$VERSION" + "$RUNNER_TEMP/deb/usr/bin/gh2" doctor --help >/dev/null + + - name: Verify Homebrew installation + if: matrix.os == 'macos-latest' + shell: bash + run: | + for attempt in {1..10}; do + brew update + if brew install circlesac/tap/gh2; then + break + fi + test "$attempt" -lt 10 + sleep 15 + done + test "$(gh2 --version)" = "$VERSION" + gh2 doctor --help >/dev/null diff --git a/README.md b/README.md index a99f59c..7b3dd69 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ gh2 repo restore [--yes] gh2 org pat-policy show gh2 org pat-policy update [--access restricted|unrestricted] \ [--requests auto|manual] [--max-lifetime none|] [--yes] +gh2 doctor --org [--app ] [--installation ] [--support] gh2 pat login --account gh2 pat create --account --name --owner \ --repos --permissions \ @@ -59,6 +60,16 @@ settings page before reporting success. The full personal, organization, repository, and enterprise settings census is in [`docs/github-admin-gap-census.md`](docs/github-admin-gap-census.md). +### Continuous live verification + +`gh2 doctor` runs selected browser-cookie integrations in read-only or dry-run mode and classifies authentication gates separately from parser drift. Use it from a trusted local scheduler because GitHub's sensitive settings pages periodically require sudo authentication and browser cookies must not be stored in GitHub Actions secrets. + +```bash +gh2 doctor --org example-org --app example-app --support --output json +``` + +See [`docs/live-canary.md`](docs/live-canary.md) for the full probe set, exit-code contract, and macOS scheduling guidance. + ### GitHub App permissions Permission changes are a live-authenticated dry run unless `--yes` is present. diff --git a/bin/install.js b/bin/install.js index 4fd8ca8..deae050 100644 --- a/bin/install.js +++ b/bin/install.js @@ -32,7 +32,7 @@ function download(url) { }); } -if (process.env.CI) process.exit(0); +if (process.env.CI || fs.existsSync(path.join(__dirname, "..", ".git"))) process.exit(0); const nativeDir = path.join(__dirname, "native"); const binPath = path.join(nativeDir, "gh2"); diff --git a/docs/live-canary.md b/docs/live-canary.md new file mode 100644 index 0000000..cc6b89c --- /dev/null +++ b/docs/live-canary.md @@ -0,0 +1,23 @@ +# Live contract canary + +`gh2 doctor` runs the browser-cookie integrations without submitting any change. It executes only read operations and commands whose default behavior is a dry run, captures their output instead of printing settings data, and reports one status per GitHub surface. + +```bash +gh2 app login + +gh2 doctor \ + --org example-org \ + --app example-app \ + --installation 12345 \ + --support \ + --pat-account example-user \ + --pat-owner example-org \ + --pat-repo example-repo \ + --output json +``` + +Exit `0` means every requested parser reached and understood the live page. Exit `2` means GitHub requires a refreshed browser session or sudo authentication; this is not reported as parser drift. Exit `1` means a form contract changed or another probe failed. + +GitHub Actions cannot safely keep a browser session with periodic sudo authentication, so schedule this command on a trusted local machine rather than storing GitHub cookies in repository secrets. Run `gh2 app login` again when the canary reports `reauth_required`, then rerun the same doctor command to distinguish an authentication gate from markup drift. + +On macOS, use `launchd` with the absolute Homebrew `gh2` path and write stdout and stderr to a local state directory. Keep the job read-only and point it at a dedicated test App when App settings are included. diff --git a/package.json b/package.json index 670783e..b7fb4af 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build": "bun build --compile --outfile=dist/gh2 src/index.ts", "dev": "bun run src/index.ts", "postinstall": "node bin/install.js", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "citty": "^0.2.0" @@ -30,5 +31,5 @@ "type": "git", "url": "https://github.com/circlesac/gh2-cli" }, - "version": "0.0.1" + "version": "0.0.0" } diff --git a/skills/guide/SKILL.md b/skills/guide/SKILL.md index 3b31860..b39f56f 100644 --- a/skills/guide/SKILL.md +++ b/skills/guide/SKILL.md @@ -81,6 +81,24 @@ permissions and webhook-event subscriptions not named in `--set`. Existing installations can require a separate owner approval after the App registration is updated. +## Live contract verification + +Run the read-only canary after `gh2 app login` to distinguish GitHub markup drift from an expired or sudo-gated browser session: + +```bash +gh2 doctor \ + --org example-org \ + --app example-app \ + --installation 12345 \ + --support \ + --pat-account example-user \ + --pat-owner example-org \ + --pat-repo example-repo \ + --output json +``` + +Exit `0` means every requested live parser returned a valid contract, exit `2` means browser or sudo reauthentication is required, and exit `1` means a contract changed or another probe failed. The doctor never passes `--yes`; schedule it only on a trusted local machine because browser cookies must not be stored in GitHub Actions secrets. + ### App private keys ```bash diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..78961c9 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,282 @@ +import { spawnSync } from "node:child_process"; +import { defineCommand } from "citty"; +import { getOutputFormat, printOutput } from "../lib/output.ts"; + +type ProbeStatus = "ok" | "reauth_required" | "contract_changed" | "failed"; + +interface DoctorProbe { + name: string; + args: string[]; + validate?: (data: unknown) => string | undefined; +} + +interface ProbeResult { + name: string; + status: ProbeStatus; + message?: string; +} + +interface CommandResult { + status: number | null; + stdout: string; + stderr: string; + error?: string; +} + +type ProbeRunner = (args: string[]) => CommandResult; + +interface DoctorOptions { + org: string; + app?: string; + installation?: string; + support: boolean; + supportAccount?: string; + patAccount?: string; + patOwner?: string; + patRepo?: string; +} + +function selfInvocation(): string[] { + const script = process.argv[1]; + if (script && /\.(?:[cm]?js|ts)$/.test(script)) return [process.execPath, script]; + return [process.execPath]; +} + +function runSelf(args: string[]): CommandResult { + const invocation = selfInvocation(); + const result = spawnSync(invocation[0]!, [...invocation.slice(1), ...args], { + encoding: "utf8", + env: { ...process.env, GH2_NO_UPDATE_CHECK: "1" }, + timeout: 30_000, + }); + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + error: result.error?.message, + }; +} + +function messageForFailure(result: CommandResult): string { + const output = `${result.error ?? ""}\n${result.stderr}\n${result.stdout}`; + if (/sudo authentication|sudo_required|confirm access/i.test(output)) { + return "GitHub sudo authentication is required."; + } + if (/session cookie is likely stale|session_expired|session expired|capture .*session|run `gh2 .* login`/i.test(output)) { + return "The captured GitHub browser session must be refreshed."; + } + if (/form_schema_changed|form_ambiguous|markup changes|could not be parsed|unknown permission|missing required field|unexpected writable field/i.test(output)) { + return "GitHub's live settings contract no longer matches the parser."; + } + const errorLine = output + .split("\n") + .map((line) => line.trim()) + .find((line) => /^error:|Error:|WebSettingsError:/.test(line)); + return errorLine?.slice(0, 300) ?? `Probe exited with status ${result.status ?? "unknown"}.`; +} + +export function classifyProbeResult(result: CommandResult): Omit { + if (result.status === 0) { + try { + JSON.parse(result.stdout); + return { status: "ok" }; + } catch { + return { status: "failed", message: "Probe returned invalid JSON output." }; + } + } + const message = messageForFailure(result); + if (message.includes("sudo authentication") || message.includes("browser session")) { + return { status: "reauth_required", message }; + } + if (message.includes("live settings contract")) { + return { status: "contract_changed", message }; + } + return { status: "failed", message }; +} + +export function buildDoctorProbes(options: DoctorOptions): DoctorProbe[] { + const probes: DoctorProbe[] = [ + { + name: "app-list", + args: ["app", "list", "--org", options.org, "--output", "json"], + validate: (data) => { + if (!Array.isArray(data)) return "App listing did not return an array."; + if (options.app && !data.some((item) => item && typeof item === "object" && Reflect.get(item, "slug") === options.app)) { + return `App ${options.app} was not present in the live App listing.`; + } + }, + }, + { + name: "deleted-repositories", + args: ["repo", "deleted", "list", "--org", options.org, "--output", "json"], + validate: (data) => + data && typeof data === "object" && Array.isArray(Reflect.get(data, "repositories")) + ? undefined + : "Deleted-repository output did not contain a repository list.", + }, + { + name: "organization-pat-policy", + args: ["org", "pat-policy", "show", options.org, "--output", "json"], + validate: (data) => + data && typeof data === "object" && Reflect.get(data, "policies") + ? undefined + : "Organization PAT policy output did not contain policies.", + }, + ]; + + if (options.app) { + probes.push( + { + name: "app-permissions", + args: ["app", "permissions", options.app, "--org", options.org, "--output", "json"], + validate: (data) => { + if (!data || typeof data !== "object" || Reflect.get(data, "app") !== options.app) { + return "App permission output did not identify the requested App."; + } + const current = Reflect.get(data, "current"); + if (!current || typeof current !== "object" || Object.keys(current).length === 0) { + return "App permission output contained no selected permissions."; + } + }, + }, + { + name: "app-private-keys", + args: ["app", "key", "list", options.app, "--org", options.org, "--output", "json"], + validate: (data) => + data && typeof data === "object" && Reflect.get(data, "app") === options.app && Array.isArray(Reflect.get(data, "keys")) + ? undefined + : "App key output did not contain the requested App and key list.", + }, + ); + } + + if (options.installation) { + probes.push({ + name: "installation-approval", + args: ["install", "approval", "show", options.installation, "--org", options.org, "--output", "json"], + validate: (data) => + data && typeof data === "object" && typeof Reflect.get(data, "pending") === "boolean" + ? undefined + : "Installation approval output did not contain pending state.", + }); + } + + if (options.support) { + const args = [ + "support", + "create", + "--subject", + "gh2 live contract probe - do not submit", + "--body", + "Read-only parser validation. This request must not be submitted.", + "--output", + "json", + ]; + if (options.supportAccount) args.push("--account", options.supportAccount); + probes.push({ + name: "support-ticket-dry-run", + args, + validate: (data) => + data && typeof data === "object" && Reflect.get(data, "mode") === "dry-run" + ? undefined + : "Support probe did not return a dry-run preview.", + }); + } + + const patValues = [options.patAccount, options.patOwner, options.patRepo]; + if (patValues.some(Boolean) && !patValues.every(Boolean)) { + throw new Error("--pat-account, --pat-owner, and --pat-repo must be provided together."); + } + if (options.patAccount && options.patOwner && options.patRepo) { + probes.push({ + name: "fine-grained-pat-dry-run", + args: [ + "pat", + "create", + "--account", + options.patAccount, + "--name", + "gh2 live contract probe", + "--owner", + options.patOwner, + "--repos", + options.patRepo, + "--permissions", + "issues=read", + "--expires-in", + "7", + "--format", + "json", + ], + validate: (data) => + data && typeof data === "object" && Reflect.get(data, "mode") === "dry-run" + ? undefined + : "Fine-grained PAT probe did not return a dry-run preview.", + }); + } + + return probes; +} + +export function runDoctorProbes(probes: DoctorProbe[], runner: ProbeRunner = runSelf) { + const results: ProbeResult[] = probes.map((probe) => { + const commandResult = runner(probe.args); + const classification = classifyProbeResult(commandResult); + if (classification.status !== "ok" || !probe.validate) { + return { name: probe.name, ...classification }; + } + const validationError = probe.validate(JSON.parse(commandResult.stdout)); + return validationError + ? { name: probe.name, status: "contract_changed", message: validationError } + : { name: probe.name, status: "ok" }; + }); + const status = results.some((result) => result.status === "contract_changed") + ? "contract_changed" + : results.some((result) => result.status === "failed") + ? "failed" + : results.some((result) => result.status === "reauth_required") + ? "reauth_required" + : "healthy"; + return { + report: { + mode: "read-only", + status, + probes: results, + }, + exitCode: status === "healthy" ? 0 : status === "reauth_required" ? 2 : 1, + }; +} + +export const doctorCommand = defineCommand({ + meta: { + name: "doctor", + description: "Run read-only live contract probes against GitHub's administration pages", + }, + args: { + org: { type: "string", description: "Organization slug", required: true }, + app: { type: "string", description: "GitHub App slug to inspect" }, + installation: { type: "string", description: "Installation ID to inspect for pending approval" }, + support: { type: "boolean", description: "Include a Support ticket dry run", default: false }, + "support-account": { type: "string", description: "Support account identifier" }, + "pat-account": { type: "string", description: "GitHub login captured for the PAT dry run" }, + "pat-owner": { type: "string", description: "PAT resource owner" }, + "pat-repo": { type: "string", description: "Repository selected for the PAT dry run" }, + output: { type: "string", description: "Output format: json | table", default: "table" }, + }, + async run({ args }) { + const result = runDoctorProbes( + buildDoctorProbes({ + org: args.org, + app: args.app, + installation: args.installation, + support: args.support, + supportAccount: args["support-account"], + patAccount: args["pat-account"], + patOwner: args["pat-owner"], + patRepo: args["pat-repo"], + }), + ); + printOutput(result.report, getOutputFormat(args.output)); + process.exitCode = result.exitCode; + }, +}); diff --git a/src/commands/pat/create.ts b/src/commands/pat/create.ts index f3b8183..1074459 100644 --- a/src/commands/pat/create.ts +++ b/src/commands/pat/create.ts @@ -812,8 +812,11 @@ export const patCreateCommand = defineCommand({ }; if (!args.yes) { - printOutput(preview, getOutputFormat(args.format)); - console.log("Dry run only. Re-run with --yes and --token-output to create the PAT."); + const outputFormat = getOutputFormat(args.format); + printOutput(preview, outputFormat); + if (outputFormat !== "json") { + console.log("Dry run only. Re-run with --yes and --token-output to create the PAT."); + } return; } diff --git a/src/commands/support/create.ts b/src/commands/support/create.ts index b25d015..6581066 100644 --- a/src/commands/support/create.ts +++ b/src/commands/support/create.ts @@ -109,9 +109,12 @@ export const supportCreateCommand = defineCommand({ captcha_required: captchaRequired, }; - printOutput(preview, getOutputFormat(args.output)); + const outputFormat = getOutputFormat(args.output); if (!args.yes) { - console.log("\nDry run only. Re-run with --yes to create the ticket."); + printOutput(preview, outputFormat); + if (outputFormat !== "json") { + console.log("\nDry run only. Re-run with --yes to create the ticket."); + } return; } @@ -134,7 +137,7 @@ export const supportCreateCommand = defineCommand({ userLogin: bootstrap.userLogin, }), ); - console.log("\nGitHub Support ticket created."); - if (result !== undefined) printOutput(result, getOutputFormat(args.output)); + if (outputFormat !== "json") console.log("GitHub Support ticket created."); + printOutput(result ?? { ...preview, mode: "created" }, outputFormat); }, }); diff --git a/src/commands/support/reply.ts b/src/commands/support/reply.ts index 25e3399..41aa4ef 100644 --- a/src/commands/support/reply.ts +++ b/src/commands/support/reply.ts @@ -73,27 +73,29 @@ export const supportReplyCommand = defineCommand({ : ["personal/0", ...(await session.ticketScopes())]; const page = await session.openTicket(ticket, scopes); - printOutput( - { - mode: args.yes ? "submit" : "dry-run", - as: auth.account ? `@${auth.account}` : "(re-run `gh2 support login`)", - ticket: page.ticketId, - scope: page.scope, - subject: page.subject, - close: args.close, - body, - }, - getOutputFormat(args.output), - ); + const outputFormat = getOutputFormat(args.output); + const preview = { + mode: args.yes ? "submit" : "dry-run", + as: auth.account ? `@${auth.account}` : "(re-run `gh2 support login`)", + ticket: page.ticketId, + scope: page.scope, + subject: page.subject, + close: args.close, + body, + }; if (!args.yes) { - console.log("\nDry run only. Re-run with --yes to post the reply."); + printOutput(preview, outputFormat); + if (outputFormat !== "json") { + console.log("\nDry run only. Re-run with --yes to post the reply."); + } return; } await session.commentOnTicket(page, body, args.close); - console.log( - `\nReplied to GitHub Support ticket #${page.ticketId}${args.close ? " and closed it" : ""}.`, - ); + if (outputFormat !== "json") { + console.log(`Replied to GitHub Support ticket #${page.ticketId}${args.close ? " and closed it" : ""}.`); + } + printOutput({ ...preview, mode: "created" }, outputFormat); }, }); diff --git a/src/index.ts b/src/index.ts index 76c3284..01c81e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { supportCommand } from "./commands/support/index.ts"; import { installCommand } from "./commands/install/index.ts"; import { repoCommand } from "./commands/repo/index.ts"; import { orgCommand } from "./commands/org/index.ts"; +import { doctorCommand } from "./commands/doctor.ts"; import { checkForUpdate } from "./lib/update-check.ts"; import pkg from "../package.json"; @@ -22,6 +23,7 @@ const main = defineCommand({ install: installCommand, repo: repoCommand, org: orgCommand, + doctor: doctorCommand, }, }); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..bb8211b --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + buildDoctorProbes, + classifyProbeResult, + runDoctorProbes, +} from "../src/commands/doctor.ts"; + +describe("doctor probe planning", () => { + it("builds only read-only and dry-run commands", () => { + const probes = buildDoctorProbes({ + org: "example-org", + app: "example-app", + installation: "42", + support: true, + supportAccount: "Example", + patAccount: "monalisa", + patOwner: "example-org", + patRepo: "example-repo", + }); + + expect(probes.map((probe) => probe.name)).toEqual([ + "app-list", + "deleted-repositories", + "organization-pat-policy", + "app-permissions", + "app-private-keys", + "installation-approval", + "support-ticket-dry-run", + "fine-grained-pat-dry-run", + ]); + expect(probes.flatMap((probe) => probe.args)).not.toContain("--yes"); + expect(probes.every((probe) => probe.args.includes("--output") || probe.args.includes("--format"))).toBe(true); + expect(probes.find((probe) => probe.name === "fine-grained-pat-dry-run")?.args).toContain("--format"); + }); + + it("requires a complete PAT probe target", () => { + expect(() => + buildDoctorProbes({ + org: "example-org", + support: false, + patAccount: "monalisa", + }), + ).toThrow(/must be provided together/); + }); +}); + +describe("doctor result classification", () => { + it("accepts successful JSON output", () => { + expect(classifyProbeResult({ status: 0, stdout: "{}", stderr: "" })).toEqual({ + status: "ok", + }); + }); + + it("distinguishes authentication gates from contract drift", () => { + expect( + classifyProbeResult({ + status: 1, + stdout: "", + stderr: "WebSettingsError: GitHub requires sudo authentication", + }), + ).toEqual({ + status: "reauth_required", + message: "GitHub sudo authentication is required.", + }); + expect( + classifyProbeResult({ + status: 1, + stdout: "", + stderr: "WebSettingsError: form_schema_changed: unexpected writable field", + }), + ).toEqual({ + status: "contract_changed", + message: "GitHub's live settings contract no longer matches the parser.", + }); + }); + + it("returns exit 2 when only reauthentication is needed", () => { + const probes = buildDoctorProbes({ org: "example-org", support: false }); + const results = [ + { status: 0, stdout: "[]", stderr: "" }, + { status: 1, stdout: "", stderr: "session cookie is likely stale" }, + { status: 0, stdout: '{"policies":{}}', stderr: "" }, + ]; + const result = runDoctorProbes(probes, () => results.shift()!); + + expect(result.report.status).toBe("reauth_required"); + expect(result.exitCode).toBe(2); + }); + + it("rejects a successful permission response with an empty permission map", () => { + const probes = buildDoctorProbes({ + org: "example-org", + app: "example-app", + support: false, + }); + const outputs = new Map([ + ["app-list", [{ slug: "example-app" }]], + ["deleted-repositories", { repositories: [] }], + ["organization-pat-policy", { policies: {} }], + ["app-permissions", { app: "example-app", current: {} }], + ["app-private-keys", { app: "example-app", keys: [] }], + ]); + const result = runDoctorProbes(probes, (args) => { + const probe = probes.find((candidate) => candidate.args === args)!; + return { status: 0, stdout: JSON.stringify(outputs.get(probe.name)), stderr: "" }; + }); + + expect(result.report.status).toBe("contract_changed"); + expect(result.report.probes.find((probe) => probe.name === "app-permissions")).toEqual({ + name: "app-permissions", + status: "contract_changed", + message: "App permission output contained no selected permissions.", + }); + expect(result.exitCode).toBe(1); + }); +});