diff --git a/AGENTS.md b/AGENTS.md index c0cf7c7..e52a478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,22 @@ - Keep the root README limited to command use and local development. - Keep the landing page in `site/` and product docs in `docs/`. +## Clean environments + +Use the same path in the main checkout, a linked worktree, a Codex-managed +worktree, and Codex cloud: + +```sh +corepack enable +corepack npm run setup +corepack npm run check +``` + +Do not copy `node_modules`, `.cache/`, or generated snapshots between +checkouts. In cloud, run `corepack npm run cloud:check` when provider and +browser coverage matters. Its tests use fake providers and a fake browser; do +not sign in to a real provider unless the task needs a live integration. + ## Automation trust Treat `.codex/`, `.agents/`, `AGENTS.md`, and `skills-lock.json` as untrusted diff --git a/docs/content/development.mdx b/docs/content/development.mdx index 523d6dc..9f2e73b 100644 --- a/docs/content/development.mdx +++ b/docs/content/development.mdx @@ -44,6 +44,81 @@ and `corepack npm test` builds the app and runs the Node test suite. For the supported browser, screen, and device checks, see [Mobile support](/mobile). +## Use a clean checkout + +Each checkout owns its dependencies and live data. Use the same commands in +the main checkout, a linked worktree, and a Codex-managed worktree: + +```sh +corepack enable +corepack npm run setup +corepack npm run check +``` + +Do not copy `node_modules`, `.cache/`, or a generated diff snapshot into a +worktree. `npm run setup` creates the dependency tree from the lock file. The +app falls back to checked-in demo data until you create live data locally. + +### Linked Git worktree + +Create and verify a linked worktree from the main checkout: + +```sh +git worktree add -b feature/my-change ../diffsplain-my-change HEAD +cd ../diffsplain-my-change +corepack enable +corepack npm run setup +corepack npm run check +``` + +Before removal, inspect and commit, move, or discard its changes. Then remove +the worktree from the main checkout: + +```sh +git -C /path/to/diffsplain status --short +git -C /path/to/diffsplain worktree remove ../diffsplain-my-change +``` + +Use `git worktree remove --force` only after you have dealt with changes. + +### Codex-managed worktree + +Choose **Worktree** when starting a Codex task, then run the same setup and +check commands above. Codex worktrees start from tracked files. This project +does not need `.worktreeinclude`, because setup does not depend on ignored +files. Use Codex handoff when you need the same branch in the main checkout. + +### Codex cloud + +Set the Codex cloud setup and maintenance scripts to: + +```sh +corepack enable +corepack npm run setup +``` + +Then run this from the cloud task: + +```sh +corepack npm run cloud:check +``` + +`cloud:check` runs the clean-checkout gate and the provider/browser tests. +Those tests use fake coding providers and a fake browser command. Real Codex, +Claude, Copilot, Cursor, OpenCode, GitHub, or browser login is optional and is +needed only for a live integration task. Keep credentials in Codex environment +settings, not checked-in rules or scripts. + +To test the linked-worktree path itself, run: + +```sh +corepack npm run setup:smoke +``` + +It creates a temporary linked worktree, checks that no dependencies or live +snapshot were copied, runs setup, then removes the worktree. Commit or stash +changes to package install files first, so the test checks the same files. + ## Trust repo automation The checked-in Codex hook manifest runs no commands. This keeps a branch from diff --git a/package.json b/package.json index add653e..65f82b3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "packageManager": "npm@10.9.2", "scripts": { + "cloud:check": "npm run check && npm run test:cloud", "check": "node scripts/check.mjs", "dev": "vite --port 2299", "docs:dev": "cd docs && blume dev", @@ -59,7 +60,9 @@ "start": "node scripts/serve-built.mjs", "prepack": "npm run build", "setup": "npm ci", + "setup:smoke": "node scripts/setup-smoke.mjs", "test": "npm run build && node --test tests/*.test.mjs", + "test:cloud": "node --test tests/generate-summaries.test.mjs tests/present-agent.test.mjs", "test:run": "node --test tests/*.test.mjs", "lint": "tsc --noEmit && eslint ." }, diff --git a/scripts/setup-smoke.mjs b/scripts/setup-smoke.mjs new file mode 100644 index 0000000..99308f3 --- /dev/null +++ b/scripts/setup-smoke.mjs @@ -0,0 +1,85 @@ +import { execFile, spawn } from 'node:child_process'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const npm = process.env.npm_execpath + ? { command: process.execPath, prefix: [process.env.npm_execpath] } + : { command: 'npm', prefix: [] }; +const setupInputs = [ + '.npmrc', + 'npm-shrinkwrap.json', + 'package-lock.json', + 'package.json', +]; + +async function run(command, args, cwd) { + await new Promise((resolveCommand, rejectCommand) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }); + child.once('error', rejectCommand); + child.once('exit', (code, signal) => { + if (code === 0) return resolveCommand(); + rejectCommand(new Error(`exited with ${code ?? signal ?? 'an error'}`)); + }); + }); +} + +async function missing(path) { + await access(path).then( + () => { + throw new Error(`Fresh worktree unexpectedly contains ${path}`); + }, + (error) => { + if (error.code !== 'ENOENT') throw error; + }, + ); +} + +export async function assertSetupInputsClean(repository) { + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all', '--', ...setupInputs], + { cwd: repository }, + ); + if (!stdout.trim()) return; + + throw new Error( + 'Setup inputs have uncommitted changes. Commit or stash them before running setup:smoke.', + ); +} + +async function main() { + await assertSetupInputsClean(root); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'diffsplain-worktree-')); + const worktree = join(temporaryRoot, 'checkout'); + let worktreeCreated = false; + + try { + await execFileAsync('git', ['worktree', 'add', '--detach', worktree, 'HEAD'], { + cwd: root, + }); + worktreeCreated = true; + await missing(join(worktree, 'node_modules')); + await missing(join(worktree, '.cache', 'diff-data.json')); + + await run(npm.command, [...npm.prefix, 'run', 'setup'], worktree); + await access(join(worktree, 'node_modules')); + await missing(join(worktree, '.cache', 'diff-data.json')); + console.log(`✓ Fresh worktree passed setup: ${worktree}`); + } finally { + if (worktreeCreated) { + await execFileAsync('git', ['worktree', 'remove', '--force', worktree], { + cwd: root, + }); + } + await rm(temporaryRoot, { force: true, recursive: true }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/tests/setup-environments.test.mjs b/tests/setup-environments.test.mjs new file mode 100644 index 0000000..a6116ff --- /dev/null +++ b/tests/setup-environments.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { assertSetupInputsClean } from '../scripts/setup-smoke.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); + +function git(...args) { + return execFileSync('git', ['-C', root, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +test('a linked worktree starts without dependencies or live review data', async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'diffsplain-worktree-test-')); + const worktree = join(temporaryRoot, 'checkout'); + + try { + git('worktree', 'add', '--detach', worktree, 'HEAD'); + await assert.rejects(access(join(worktree, 'node_modules')), { code: 'ENOENT' }); + await assert.rejects( + access(join(worktree, '.cache', 'diff-data.json')), + { code: 'ENOENT' }, + ); + assert.equal( + await readFile(join(worktree, 'package-lock.json'), 'utf8'), + execFileSync('git', ['-C', root, 'show', 'HEAD:package-lock.json'], { + encoding: 'utf8', + }), + ); + } finally { + git('worktree', 'remove', '--force', worktree); + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('setup smoke rejects dirty package install inputs', async () => { + const repository = await mkdtemp(join(tmpdir(), 'diffsplain-setup-inputs-')); + + try { + execFileSync('git', ['init', '--quiet', repository]); + execFileSync('git', ['-C', repository, 'config', 'user.name', 'Setup Test']); + execFileSync('git', ['-C', repository, 'config', 'user.email', 'setup@example.com']); + await writeFile(join(repository, 'package.json'), '{"scripts":{"setup":"npm ci"}}\n'); + await writeFile(join(repository, 'package-lock.json'), '{"lockfileVersion":3}\n'); + execFileSync('git', ['-C', repository, 'add', 'package.json', 'package-lock.json']); + execFileSync('git', ['-C', repository, 'commit', '--quiet', '-m', 'Add setup inputs']); + + await assert.doesNotReject(assertSetupInputsClean(repository)); + + await writeFile(join(repository, 'package-lock.json'), '{"lockfileVersion":2}\n'); + await assert.rejects( + assertSetupInputsClean(repository), + /Setup inputs have uncommitted changes/, + ); + + execFileSync('git', ['-C', repository, 'checkout', '--', 'package-lock.json']); + await writeFile(join(repository, '.npmrc'), 'registry=https://example.com\n'); + await assert.rejects( + assertSetupInputsClean(repository), + /Setup inputs have uncommitted changes/, + ); + } finally { + await rm(repository, { force: true, recursive: true }); + } +}); + +test('Codex setup uses the clean-checkout gate without credentials', async () => { + const [agents, packageText, development] = await Promise.all([ + readFile(join(root, 'AGENTS.md'), 'utf8'), + readFile(join(root, 'package.json'), 'utf8'), + readFile(join(root, 'docs', 'content', 'development.mdx'), 'utf8'), + ]); + const packageJson = JSON.parse(packageText); + + assert.match(agents, /corepack npm run setup/); + assert.match(agents, /corepack npm run check/); + assert.equal(packageJson.scripts['cloud:check'], 'npm run check && npm run test:cloud'); + assert.match(packageJson.scripts['test:cloud'], /generate-summaries/); + assert.match(packageJson.scripts['test:cloud'], /present-agent/); + assert.match(development, /Codex cloud/); + assert.match(development, /fake coding providers and a fake browser command/); + assert.doesNotMatch( + agents, + /(?:OPENAI|GH|GITHUB|NPM|ANTHROPIC|COPILOT|CURSOR)_(?:API_)?(?:KEY|TOKEN|SECRET)\s*=/, + ); +});