From d93a0be1c55bfe5a8bc4c1bfa23f1f8ee9175936 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 13:54:53 +0700 Subject: [PATCH 1/2] feat(uncheck): add `uncheck init` to set up a project `uncheck init` sets up a new or existing project, single repo or monorepo, with the package manager it uses: it asks which tools to install (oxlint, oxfmt, and sherif at a workspace root), which tools start from the middleapi presets, whether to check every commit, and which agents run uncheck after each turn, then installs and writes the scripts and hooks. `--yes` takes the default answers without a terminal. Running it again only sets up what is missing. Package manager detection now lives in one place: the `packageManager` field, else the lockfile (Yarn 2+ also from `.yarnrc.yml`), else the one that started uncheck (`npm_config_user_agent`), else npm. The `prepare` and `hooks install` commands use it too. Their hook-writing bodies are extracted so `init` reuses them unchanged. --- packages/uncheck/README.md | 23 +- packages/uncheck/src/checks/sherif.ts | 21 +- packages/uncheck/src/checks/tsc.ts | 6 +- packages/uncheck/src/cli.ts | 3 +- .../uncheck/src/commands/hooks/install.ts | 273 +++++++++------ packages/uncheck/src/commands/init.ts | 317 ++++++++++++++++++ packages/uncheck/src/commands/prepare.ts | 298 ++++++++-------- packages/uncheck/src/commands/uncheck.ts | 8 +- packages/uncheck/src/files.ts | 24 ++ packages/uncheck/src/pm.ts | 82 +++-- packages/uncheck/src/style.ts | 4 + .../tests/hooks/install/prompt.test.ts | 19 +- packages/uncheck/tests/init/defaults.test.ts | 192 +++++++++++ .../tests/init/package-manager.test.ts | 186 ++++++++++ packages/uncheck/tests/init/prompt.test.ts | 131 ++++++++ packages/uncheck/tests/init/scripts.test.ts | 83 +++++ packages/uncheck/tests/init/utils.ts | 83 +++++ .../uncheck/tests/staged/interrupt.test.ts | 5 +- packages/uncheck/tests/uncheck/cli.test.ts | 2 +- packages/uncheck/tests/utils/project.ts | 44 ++- 20 files changed, 1474 insertions(+), 330 deletions(-) create mode 100644 packages/uncheck/src/commands/init.ts create mode 100644 packages/uncheck/tests/init/defaults.test.ts create mode 100644 packages/uncheck/tests/init/package-manager.test.ts create mode 100644 packages/uncheck/tests/init/prompt.test.ts create mode 100644 packages/uncheck/tests/init/scripts.test.ts create mode 100644 packages/uncheck/tests/init/utils.ts diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index c432769..6e8e6e1 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -24,7 +24,7 @@ `uncheck` lints, format checks and type checks your project with one command, and keeps a monorepo consistent. It runs the tools your project already has, so you, your git hooks and your coding agents all run the same check. ```sh -npm i -D uncheck oxlint oxfmt typescript # add sherif in a monorepo +npx uncheck init # install the tools, add the scripts and hooks npx uncheck # check everything npx uncheck --fix # fix what can be fixed, report the rest @@ -32,7 +32,7 @@ npx uncheck prepare --pre-commit # check every commit npx uncheck hooks install claude # check every agent turn ``` -uncheck needs Node 22.20 or later. Install only the tools you want: a check runs when its tool is installed and is skipped otherwise, except that a `tsconfig.json` without TypeScript installed fails. uncheck always uses the versions you installed. +uncheck needs Node 22.20 or later. Install only the tools you want, with [`init`](#set-up-a-project) or by hand (`npm i -D uncheck oxlint oxfmt typescript`, plus sherif in a monorepo): a check runs when its tool is installed and is skipped otherwise, except that a `tsconfig.json` without TypeScript installed fails. uncheck always uses the versions you installed. | Check | Checks | Runs when | | -------- | -------------------- | -------------------------------------------------------------------------------------------------- | @@ -41,6 +41,25 @@ uncheck needs Node 22.20 or later. Install only the tools you want: a check runs | `oxfmt` | formatting | [oxfmt](https://oxc.rs) is installed | | `tsc` | types | the project has a `tsconfig.json` | +## Set up a project + +```sh +npx uncheck init # or pnpm dlx, yarn dlx, bunx +``` + +`init` sets up the folder it runs in, a new project or an existing one, and installs with the package manager the project uses: the one its `packageManager` field or lockfile names, or else the one that started `init`. It asks: + +1. **Which tools to install:** oxlint and oxfmt, plus sherif at a [workspace root](#monorepos). uncheck itself is always installed. +2. **Which tools get a preset config:** oxlint and oxfmt can start from the [presets](#presets) when they have no config yet. +3. **Whether to check every commit:** it adds the [`prepare` script](#run-it-before-every-commit) (`postinstall` with Yarn 2+) and writes the hook. +4. **Which agents run uncheck** [after every turn](#run-it-after-every-agent-turn). + +It also adds a `check` script (`uncheck`) and a `fix` script (`uncheck --fix`), unless the project has scripts with those names. Running `init` again only sets up what is missing. A `prepare` script that already runs `uncheck prepare` keeps its hook, and agents that already run uncheck keep their flags. + +`npx uncheck init --yes` takes the default answers, which it needs without a terminal. It installs the missing tools, checks every commit, and sets up the agents whose folders the project has, such as `.claude`, that do not run uncheck yet. It writes no preset config. + +In a monorepo, run `init` at the workspace root to set up every package at once. TypeScript is left to you: tsc runs once the project has a `tsconfig.json` and TypeScript is installed. + ## Check your project ```text diff --git a/packages/uncheck/src/checks/sherif.ts b/packages/uncheck/src/checks/sherif.ts index 87e712b..b2ace3e 100644 --- a/packages/uncheck/src/checks/sherif.ts +++ b/packages/uncheck/src/checks/sherif.ts @@ -1,22 +1,18 @@ import process from 'node:process' -import { Effect, FileSystem, Path, Predicate } from 'effect' +import { Effect, Path, Predicate } from 'effect' import { CannotCheck, NothingToCheck } from '../errors' -import { readJson } from '../files' +import { isWorkspaceRoot, readJson } from '../files' import { resolveBin } from '../tool' import type { Check } from '../types' const WORKSPACE_FILES = new Set(['package.json', 'pnpm-workspace.yaml']) -// pnpm also keeps its settings in pnpm-workspace.yaml, and sherif fails on one without packages. -const DECLARES_PACKAGES = /^["']?packages["']?\s*:/m - export const sherif: Check = { name: 'sherif', fixes: 'workspace', plan: Effect.fn(function* ({ cwd, fix, files, deleted }) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const bin = yield* resolveBin('sherif', cwd) @@ -34,22 +30,13 @@ export const sherif: Check = { ) } - const [manifest, pnpmWorkspace] = yield* Effect.all( - [ - readJson(path.join(cwd, 'package.json')), - fs.readFileString(path.join(cwd, 'pnpm-workspace.yaml')).pipe( - Effect.map((text) => DECLARES_PACKAGES.test(text)), - Effect.orElseSucceed(() => false), - ), - ], - { concurrency: 'unbounded' }, - ) + const manifest = yield* readJson(path.join(cwd, 'package.json')) if (manifest === undefined) { return yield* Effect.fail(new NothingToCheck({ reason: 'no package.json found' })) } - if (manifest.workspaces === undefined && !pnpmWorkspace) { + if (!(yield* isWorkspaceRoot(cwd, manifest))) { return yield* Effect.fail(new NothingToCheck({ reason: 'not a workspace root' })) } diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 441751a..87a5262 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -7,6 +7,7 @@ import { CannotCheck, NothingToCheck } from '../errors' import { ancestors, fileKind, + firstFile, isOutside, listProjectFiles, readJson, @@ -813,11 +814,6 @@ const loadExtendsChain = Effect.fn(function* ( return [...chains.flat(), { file: configPath, dir: path.dirname(configPath), raw }] }) -const firstFile = (candidates: ReadonlyArray) => - Effect.findFirst(candidates, (candidate) => - Effect.map(fileKind(candidate), (type) => type === 'File'), - ) - const resolveExtends = Effect.fn(function* (spec: string, dir: string) { const path = yield* Path.Path diff --git a/packages/uncheck/src/cli.ts b/packages/uncheck/src/cli.ts index 8070344..4779412 100644 --- a/packages/uncheck/src/cli.ts +++ b/packages/uncheck/src/cli.ts @@ -7,6 +7,7 @@ import { CliConfig, CliError, CliOutput, Command, GlobalFlag } from 'effect/unst import pkg from '../package.json' import { hooks } from './commands/hooks' +import { init } from './commands/init' import { prepare } from './commands/prepare' import { staged } from './commands/staged' import { uncheck } from './commands/uncheck' @@ -21,7 +22,7 @@ for (const stream of [process.stdout, process.stderr]) { // (the shell forwards it, then the kernel), so `once` is not enough. process.on('SIGHUP', () => process.kill(process.pid, 'SIGTERM')) -Command.run(uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])), { +Command.run(uncheck.pipe(Command.withSubcommands([init, staged, prepare, hooks])), { version: pkg.version, }).pipe( Effect.catchTag('CheckFailed', () => diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 9a007bf..e1a3c9c 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -9,6 +9,7 @@ import { readJson, readTextIfExists } from '../../files' import { gitLocation } from '../../git' import { detectExec, invokes } from '../../pm' import { bold, dim, green } from '../../style' +import type { CheckSelection } from '../uncheck' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck' // Copilot (30 s) and CodeBuddy (60 s) kill a typecheck at their default timeout and end the turn as @@ -23,7 +24,7 @@ const CLAUDE_FORMAT = { group: (entry: object) => ({ hooks: [entry] }), } -const AGENTS = [ +export const AGENTS = [ { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', ...CLAUDE_FORMAT }, { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', ...CLAUDE_FORMAT }, { @@ -50,6 +51,165 @@ const AGENTS = [ const AGENT_IDS = AGENTS.map((agent) => agent.id) +type Agent = (typeof AGENTS)[number] + +export type AgentId = Agent['id'] + +// Agents run the hook wherever they last `cd`'d, so a project below the top of the repository is +// named relative to it. +export function agentHookDir(cwd: string) { + return gitLocation(cwd).pipe( + Effect.map(({ prefix }) => prefix.replace(/\/$/, '')), + Effect.orElseSucceed(() => ''), + ) +} + +// A reinstall that cannot recognise the command would add a second hook next to it. +export function validateAgentHookDir(dir: string) { + return invokes(`${HOOK_COMMAND} --dir=${dir}`, HOOK_COMMAND) + ? Effect.void + : userError( + `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`, + ) +} + +export const agentHookCommand = Effect.fn(function* ( + cwd: string, + dir: string, + selection: CheckSelection, +) { + const path = yield* Path.Path + + yield* validateAgentHookDir(dir) + + const manifest = yield* readJson(path.join(cwd, 'package.json')) + const declaresUncheck = [manifest?.dependencies, manifest?.devDependencies].some( + (dependencies) => Predicate.isObject(dependencies) && 'uncheck' in dependencies, + ) + const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' || !declaresUncheck }) + const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] + + return { dir, command: `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` } +}) + +export function chooseAgents( + agents: ReadonlyArray, + { preselected = [], min }: { readonly preselected?: ReadonlyArray; readonly min?: number }, +) { + return Prompt.run( + Prompt.MultiSelect({ + message: 'Which agents should run uncheck when they finish a turn?', + choices: agents.map((agent) => ({ + title: agent.name, + value: agent.id, + selected: preselected.includes(agent), + })), + min, + }), + ) +} + +const parseConfig = (text: string, errors: ParseError[] = []): unknown => + parseJsonc(text, errors, { allowTrailingComma: true }) + +export const hasOwnHook = Effect.fn(function* (cwd: string, agent: Agent) { + const path = yield* Path.Path + const config = parseConfig((yield* readTextIfExists(path.join(cwd, agent.path))) ?? '') + const hooks = Predicate.isObject(config) && Predicate.isObject(config.hooks) ? config.hooks : {} + let found = false + + mapOwnEntries(hooks[agent.event], (hook) => { + found = true + return hook + }) + + return found +}) + +export const writeAgentHooks = Effect.fn(function* ( + cwd: string, + { dir, command }: { readonly dir: string; readonly command: string }, + selected: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + if (dir !== '' && selected.includes('copilot')) { + return yield* userError( + `Copilot reads .github/hooks only at the top of the repository, not in ${dir}: install copilot from there`, + ) + } + + const updates = yield* Effect.forEach( + AGENTS.filter((agent) => selected.includes(agent.id)), + (agent) => + Effect.gen(function* () { + const file = path.join(cwd, agent.path) + const existing = yield* readTextIfExists(file) + const text = existing ?? '' + const errors: ParseError[] = [] + const current = parseConfig(text, errors) + + if (text.trim() !== '' && (errors.length > 0 || !Predicate.isObject(current))) { + const [error] = errors + const problem = + error === undefined + ? 'is not a JSON object' + : `has ${printParseErrorCode(error.error)} on line ${text.slice(0, error.offset).split('\n').length}` + + return yield* userError(`${agent.path} ${problem}, fix it and run again`) + } + + const base = Predicate.isObject(current) ? current : {} + const hooks = Predicate.isObject(base.hooks) ? base.hooks : {} + const entry = agent.entry(command) + const timeout = { [agent.timeout]: TIMEOUT_SECONDS } + const entries = hooks[agent.event] + const found: object[] = [] + // Copilot takes `timeout` as another name for `timeoutSec`, so either is one the user chose. + const replaced = mapOwnEntries(entries, (hook) => { + found.push(hook) + return { + ...hook, + ...entry, + ...('timeout' in hook || 'timeoutSec' in hook ? {} : timeout), + } + }) + const next = { + ...base, + ...(found.length > 0 ? {} : agent.root), + hooks: { + ...hooks, + [agent.event]: + found.length > 0 + ? replaced + : [ + ...(Array.isArray(entries) ? entries : []), + agent.group({ ...entry, ...timeout }), + ], + }, + } + const result = + existing === undefined + ? 'created' + : isDeepStrictEqual(next, base) + ? 'unchanged' + : 'updated' + + return { agent, file, next, result } + }), + ) + + for (const { agent, file, next, result } of updates) { + if (result !== 'unchanged') { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, `${JSON.stringify(next, null, 2)}\n`) + } + + yield* Console.log(`${green('✔')} ${bold(agent.name)} ${dim(`${agent.path} ${result}`)}`) + } +}) + export const install = Command.make( 'install', { @@ -63,34 +223,13 @@ export const install = Command.make( ), }, Effect.fn(function* ({ cwd, agents, ...selection }) { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path const stdio = yield* Stdio.Stdio yield* validateSelection(selection) - // Agents run the hook wherever they last `cd`'d, so a project below the top of the repository - // is named relative to it. - const dir = yield* gitLocation(cwd).pipe( - Effect.map(({ prefix }) => prefix.replace(/\/$/, '')), - Effect.orElseSucceed(() => ''), - ) - const manifest = yield* readJson(path.join(cwd, 'package.json')) - const declaresUncheck = [manifest?.dependencies, manifest?.devDependencies].some( - (dependencies) => Predicate.isObject(dependencies) && 'uncheck' in dependencies, - ) - const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' || !declaresUncheck }) - const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] - const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` - - // A reinstall that cannot recognise the command would add a second hook next to it. - if (!invokes(command, HOOK_COMMAND)) { - return yield* userError( - `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`, - ) - } + const hook = yield* agentHookCommand(cwd, yield* agentHookDir(cwd), selection) - let selected: ReadonlyArray<(typeof AGENT_IDS)[number]> = agents + let selected: ReadonlyArray = agents if (selected.length === 0) { if (!(yield* stdio.stdinIsTerminal)) { @@ -99,93 +238,13 @@ export const install = Command.make( ) } - selected = yield* Prompt.run( - Prompt.MultiSelect({ - message: 'Which agents should run uncheck when they finish a turn?', - choices: AGENTS.map((agent) => ({ title: agent.name, value: agent.id })), - min: 1, - }), - ) - } - - if (dir !== '' && selected.includes('copilot')) { - return yield* userError( - `Copilot reads .github/hooks only at the top of the repository, not in ${dir}: install copilot from there`, - ) - } - - const updates = yield* Effect.forEach( - AGENTS.filter((agent) => selected.includes(agent.id)), - (agent) => - Effect.gen(function* () { - const file = path.join(cwd, agent.path) - const existing = yield* readTextIfExists(file) - const text = existing ?? '' - const errors: ParseError[] = [] - const current: unknown = parseJsonc(text, errors, { allowTrailingComma: true }) - - if (text.trim() !== '' && (errors.length > 0 || !Predicate.isObject(current))) { - const [error] = errors - const problem = - error === undefined - ? 'is not a JSON object' - : `has ${printParseErrorCode(error.error)} on line ${text.slice(0, error.offset).split('\n').length}` - - return yield* userError(`${agent.path} ${problem}, fix it and run again`) - } - - const base = Predicate.isObject(current) ? current : {} - const hooks = Predicate.isObject(base.hooks) ? base.hooks : {} - const entry = agent.entry(command) - const timeout = { [agent.timeout]: TIMEOUT_SECONDS } - const entries = hooks[agent.event] - const found: object[] = [] - // Copilot takes `timeout` as another name for `timeoutSec`, so either is one the user chose. - const replaced = mapOwnEntries(entries, (hook) => { - found.push(hook) - return { - ...hook, - ...entry, - ...('timeout' in hook || 'timeoutSec' in hook ? {} : timeout), - } - }) - const next = { - ...base, - ...(found.length > 0 ? {} : agent.root), - hooks: { - ...hooks, - [agent.event]: - found.length > 0 - ? replaced - : [ - ...(Array.isArray(entries) ? entries : []), - agent.group({ ...entry, ...timeout }), - ], - }, - } - const result = - existing === undefined - ? 'created' - : isDeepStrictEqual(next, base) - ? 'unchanged' - : 'updated' - - return { agent, file, next, result } - }), - ) - - for (const { agent, file, next, result } of updates) { - if (result !== 'unchanged') { - yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString(file, `${JSON.stringify(next, null, 2)}\n`) - } - - yield* Console.log(`${green('✔')} ${bold(agent.name)} ${dim(`${agent.path} ${result}`)}`) + selected = yield* chooseAgents(AGENTS, { min: 1 }) } + yield* writeAgentHooks(cwd, hook, selected) yield* Console.log('') yield* Console.log( - `${dim('The hook runs')} ${bold(command)} ${dim('whenever the agent finishes a turn.')}`, + `${dim('The hook runs')} ${bold(hook.command)} ${dim('whenever the agent finishes a turn.')}`, ) }), ).pipe( diff --git a/packages/uncheck/src/commands/init.ts b/packages/uncheck/src/commands/init.ts new file mode 100644 index 0000000..290dcc6 --- /dev/null +++ b/packages/uncheck/src/commands/init.ts @@ -0,0 +1,317 @@ +import process from 'node:process' + +import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio } from 'effect' +import { Command, Flag, Prompt } from 'effect/unstable/cli' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' + +import { userError } from '../errors' +import { ancestors, firstFile, isWorkspaceRoot, readJson } from '../files' +import { detectExec, detectPackageManager } from '../pm' +import { bold, dim, green, listed } from '../style' +import { resolveBin } from '../tool' +import type { AgentId } from './hooks/install' +import { + AGENTS, + agentHookCommand, + agentHookDir, + chooseAgents, + hasOwnHook, + validateAgentHookDir, + writeAgentHooks, +} from './hooks/install' +import { writePreCommitHook } from './prepare' +import { cwdFlag } from './uncheck' + +const NO_SELECTION = { only: [], required: [], skipped: [] } + +const TOOLS = [ + { name: 'oxlint', checks: 'lint rules' }, + { name: 'oxfmt', checks: 'formatting' }, + { name: 'sherif', checks: 'monorepo consistency' }, +] as const + +const PRESETS = [ + { + tool: 'oxlint', + summary: "oxlint's defaults plus a few rules that catch real bugs", + content: + "import { defineConfig } from 'oxlint'\nimport { middleapi } from 'uncheck/oxlint'\n\nexport default defineConfig({ extends: [middleapi] })\n", + }, + { + tool: 'oxfmt', + summary: 'no semicolons, single quotes and sorted imports', + content: + "import { defineConfig } from 'oxfmt'\nimport { middleapi } from 'uncheck/oxfmt'\n\nexport default defineConfig({ ...middleapi })\n", + }, +] as const + +const SCRIPTS = { check: 'uncheck', fix: 'uncheck --fix' } + +const PREPARE = 'uncheck prepare --pre-commit' + +const RUNS_PREPARE = /\buncheck prepare\b/ + +const INSTALL_ARGS = { + pnpm: ['add', '--save-dev'], + yarn: ['add', '--dev'], + bun: ['add', '--dev'], + npm: ['install', '--save-dev'], +} + +const INDENT = /^[ \t]+(?=")/m + +function withPrepare(script: unknown): string { + return typeof script !== 'string' || script.trim() === '' ? PREPARE : `${script} && ${PREPARE}` +} + +const runInstall = Effect.fn(function* (command: ReadonlyArray, cwd: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + yield* Console.log(`${dim('▶')} ${bold(command[0]!)} ${dim(command.slice(1).join(' '))}`) + + // Package managers ask before they replace a node_modules another one installed, so they get the + // terminal. On Windows they are `.cmd` scripts, which only a shell runs. + const handle = yield* spawner + .spawn( + ChildProcess.make(command[0]!, command.slice(1), { + cwd, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + shell: process.platform === 'win32', + }), + ) + .pipe( + Effect.catchReason('PlatformError', 'NotFound', () => + userError(`${command[0]} is not installed, install it and run uncheck init again`), + ), + ) + + return yield* handle.exitCode +}, Effect.scoped) + +export const init = Command.make( + 'init', + { + cwd: cwdFlag, + yes: Flag.Boolean('yes').pipe( + Flag.withAlias('y'), + Flag.withDefault(false), + Flag.withDescription( + 'Take the default answers instead of asking: install the missing tools, check every commit, and run uncheck after the turns of the agents whose folders exist and that do not run it yet', + ), + ), + }, + Effect.fn(function* ({ cwd, yes }) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const stdio = yield* Stdio.Stdio + + const manifestFile = path.join(cwd, 'package.json') + const manifest = yield* readJson(manifestFile) + + if (manifest === undefined) { + return yield* userError( + `No package.json in ${cwd} to set up: create one first, for example with \`npm init\``, + ) + } + + if (!yes && !(yield* stdio.stdinIsTerminal)) { + return yield* userError( + 'Answer the questions in a terminal, or pass --yes to take the default answers', + ) + } + + yield* Console.log(dim(`uncheck init in ${cwd}`)) + + const workspaceRoot = yield* isWorkspaceRoot(cwd, manifest) + const enclosingWorkspace = workspaceRoot + ? Option.none() + : yield* Effect.findFirst(ancestors(path, cwd).slice(1), (dir) => + readJson(path.join(dir, 'package.json')).pipe( + Effect.flatMap((parent) => isWorkspaceRoot(dir, parent ?? {})), + ), + ) + + if (Option.isSome(enclosingWorkspace)) { + yield* Console.log( + `${dim('○')} ${dim(`inside the workspace at ${path.relative(cwd, enclosingWorkspace.value)}, run uncheck init there to set up every package`)}`, + ) + } + + const packageManager = yield* detectPackageManager(cwd) + + const missing = yield* Effect.filter( + TOOLS.filter((tool) => tool.name !== 'sherif' || workspaceRoot), + (tool) => resolveBin(tool.name, cwd).pipe(Effect.map((bin) => bin === undefined)), + ) + const tools = + yes || missing.length === 0 + ? missing.map((tool) => tool.name) + : yield* Prompt.run( + Prompt.MultiSelect({ + message: 'Which tools should uncheck install?', + choices: missing.map((tool) => ({ + title: tool.name, + value: tool.name, + description: `checks ${tool.checks}`, + selected: true, + })), + }), + ) + + const configurable = yield* Effect.filter( + PRESETS.filter( + (preset) => + tools.includes(preset.tool) || !missing.some((tool) => tool.name === preset.tool), + ), + (preset) => + firstFile( + ancestors(path, cwd).flatMap((dir) => + [ + `.${preset.tool}rc.json`, + `.${preset.tool}rc.jsonc`, + `${preset.tool}.config.ts`, + `${preset.tool}.config.mts`, + ].map((name) => path.join(dir, name)), + ), + ).pipe(Effect.map(Option.isNone)), + ) + const presets = + yes || configurable.length === 0 + ? [] + : yield* Prompt.run( + Prompt.MultiSelect({ + message: 'Which tools should get a config from the middleapi preset?', + choices: configurable.map((preset) => ({ + title: preset.tool, + value: preset, + description: preset.summary, + })), + }), + ) + + const hookScript = packageManager.yarnBerry ? 'postinstall' : 'prepare' + const scripts = Predicate.isObject(manifest.scripts) ? manifest.scripts : {} + const hooked = typeof scripts[hookScript] === 'string' && RUNS_PREPARE.test(scripts[hookScript]) + const preCommit = + !hooked && + (yes || + (yield* Prompt.run( + Prompt.Confirm({ message: 'Check the staged files before every commit?', initial: true }), + ))) + + const dir = yield* agentHookDir(cwd) + const offeredAgents = AGENTS.filter((agent) => dir === '' || agent.id !== 'copilot') + const unhookedAgents = yield* Effect.filter(offeredAgents, (agent) => + Effect.zipWith( + fs.exists(path.join(cwd, path.dirname(agent.path))), + hasOwnHook(cwd, agent), + (used, hooked) => used && !hooked, + ), + ) + const agents: ReadonlyArray = yes + ? unhookedAgents.map((agent) => agent.id) + : yield* chooseAgents(offeredAgents, { preselected: unhookedAgents }) + + // A folder the agent hook cannot name would otherwise fail the run after the install. + yield* agents.length > 0 ? validateAgentHookDir(dir) : Effect.void + + const packages = [ + ...((yield* resolveBin('uncheck', cwd)) === undefined ? ['uncheck'] : []), + ...tools, + ] + + if (packages.length > 0) { + // pnpm and Yarn 1 refuse to add to the root of a workspace unless told that is meant. + const rootFlags = + packageManager.name === 'pnpm' && (yield* fs.exists(path.join(cwd, 'pnpm-workspace.yaml'))) + ? ['--workspace-root'] + : packageManager.name === 'yarn' && + !packageManager.yarnBerry && + manifest.workspaces !== undefined + ? ['--ignore-workspace-root-check'] + : [] + const command = [ + packageManager.name, + ...INSTALL_ARGS[packageManager.name], + ...rootFlags, + ...packages, + ] + const exitCode = yield* runInstall(command, cwd) + + if (exitCode !== 0) { + return yield* userError( + `\`${command.join(' ')}\` failed, so nothing else was set up. Run uncheck init again once it installs`, + ) + } + } + + for (const preset of presets) { + const file = `${preset.tool}.config.${manifest.type === 'module' ? 'ts' : 'mts'}` + + yield* fs.writeFileString(path.join(cwd, file), preset.content, { flag: 'wx' }) + yield* Console.log(`${green('✔')} ${bold(preset.tool)} ${dim(`${file} created`)}`) + } + + // The install rewrote package.json, so the scripts go into what it wrote. + const text = yield* fs.readFileString(manifestFile) + const installed: Record = JSON.parse(text) + const before = Predicate.isObject(installed.scripts) ? installed.scripts : {} + const after: Record = { + ...before, + ...Object.fromEntries(Object.entries(SCRIPTS).filter(([name]) => before[name] === undefined)), + ...(preCommit ? { [hookScript]: withPrepare(before[hookScript]) } : {}), + } + const written = Object.keys(after).filter((name) => after[name] !== before[name]) + + if (written.length > 0) { + const indent = INDENT.exec(text)?.[0] ?? ' ' + + yield* fs.writeFileString( + manifestFile, + `${JSON.stringify({ ...installed, scripts: after }, null, indent)}\n`, + ) + } + + yield* Console.log( + `${green('✔')} ${bold('package.json')} ${dim(written.length === 0 ? 'unchanged' : `scripts ${listed(written)} written`)}`, + ) + + if (hooked) { + yield* Console.log( + `${dim('○')} ${bold('pre-commit')} ${dim(`left to the ${hookScript} script, which writes it on every install`)}`, + ) + } + + if (preCommit) { + yield* writePreCommitHook(cwd, { fix: true, allowEmpty: false, ...NO_SELECTION }) + + if (hookScript === 'postinstall' && manifest.private !== true) { + yield* Console.log( + `${dim('○')} ${dim('postinstall also runs where this package is installed, turn it off while packing, for example with pinst')}`, + ) + } + } + + if (agents.length > 0) { + // Only after the install declares uncheck here does Yarn 2+ run the package's own binary. + yield* writeAgentHooks(cwd, yield* agentHookCommand(cwd, dir, NO_SELECTION), agents) + } + + const exec = yield* detectExec(cwd) + const run = (name: keyof typeof SCRIPTS) => + after[name] === SCRIPTS[name] + ? `${packageManager.name} run ${name}` + : `${exec} ${SCRIPTS[name]}` + + yield* Console.log('') + yield* Console.log( + `${dim('Run')} ${bold(run('check'))} ${dim('to check the project, and')} ${bold(run('fix'))} ${dim('to fix what can be fixed.')}`, + ) + }), +).pipe( + Command.withDescription( + 'Set up uncheck in a project: install the tools it runs, add the check and fix scripts, and run it before every commit and after every agent turn', + ), +) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 78fb7e2..81455ba 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -8,6 +8,7 @@ import { readTextIfExists } from '../files' import { git, gitLocation, refusesRepository } from '../git' import { detectExec, invokes } from '../pm' import { bold, dim, green, red } from '../style' +import type { CheckSelection } from './uncheck' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck' const HOOK_COMMAND = 'uncheck staged' @@ -203,6 +204,156 @@ function locked(file: string, effect: Effect.Effect) { }) } +interface PreCommitOptions extends CheckSelection { + readonly fix: boolean + readonly allowEmpty: boolean +} + +export const writePreCommitHook = Effect.fn(function* ( + cwd: string, + { fix, allowEmpty, ...selection }: PreCommitOptions, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.result) + + if (Result.isFailure(repository)) { + const { failure } = repository + + if (failure._tag === 'GitFailed' && refusesRepository(failure)) { + const reason = failure.stderr.split('\n')[0]!.replace(/^fatal: /, '') + + return yield* Console.log( + `${red('✘')} ${bold('pre-commit')} ${dim(`not written, git refuses the repository: ${reason}`)}`, + ) + } + + // A `prepare` script runs on every install, including where there is no repository to hook. + return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) + } + + const { + prefix, + paths: [hooks], + } = repository.success + const inside = prefix.replace(/\/$/, '') + const exec = yield* detectExec(cwd) + const command = [ + exec, + HOOK_COMMAND, + ...(fix ? ['--fix'] : []), + ...(allowEmpty ? ['--allow-empty'] : []), + ...selectionArgs(selection), + ].join(' ') + const line = hookLine(inside, command) + + // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h` + // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above. + const configured = path.resolve(cwd, hooks!) + const dispatched = + path.basename(configured) === '_' && + (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false))) + const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit') + const relative = path.relative(cwd, file) + const shown = relative.startsWith('..') ? file : relative + // A scope option turns includes off, and an included file can set a global core.hooksPath. + const hooksPath = (option: string) => + git(cwd, ['config', option, '--includes', '--get', 'core.hooksPath']) + const shared = yield* hooksPath('--show-scope').pipe( + Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]), + // Git before 2.26 has no --show-scope, which must not pass for an unset core.hooksPath. + Effect.catchIf( + (error) => error._tag === 'GitFailed' && error.exitCode === 129, + () => + Effect.findFirst(['local', 'global', 'system'], (scope) => + hooksPath(`--${scope}`).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + ).pipe(Effect.map(Option.getOrUndefined)), + ), + Effect.map((scope) => (scope === 'local' ? undefined : scope)), + Effect.orElseSucceed(() => undefined), + ) + + const written = yield* Effect.gen(function* () { + if (shared !== undefined) { + return yield* Effect.fail( + `core.hooksPath is set in the ${shared} git config, so every repository runs it`, + ) + } + + if (UNQUOTABLE.test(inside)) { + return yield* Effect.fail( + `sh would misread the folder name ${JSON.stringify(inside)} between double quotes`, + ) + } + + // Renaming over a symlinked hook would replace the link, not the script it points to. + const target = yield* fs.realPath(file).pipe( + Effect.catch(() => fs.readLink(file)), + Effect.map((link) => path.resolve(path.dirname(file), link)), + Effect.orElseSucceed(() => file), + ) + + yield* fs.makeDirectory(path.dirname(target), { recursive: true }) + + return yield* locked( + target, + Effect.gen(function* () { + const existing = yield* readTextIfExists(target) + + if ( + existing !== undefined && + (OTHER_INTERPRETER.test(existing) || NOT_TEXT.test(existing)) + ) { + return yield* Effect.fail(`it is not a shell script, have it run \`${line}\` yourself`) + } + + const next = rewrite(existing ?? HEADER, line, inside) + const result = + existing === undefined ? 'created' : next === existing ? 'unchanged' : 'updated' + + // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would + // leave every clone with a change to commit. + if (result === 'unchanged') { + if (!dispatched) { + yield* fs.chmod(target, 0o755) + } + + return result + } + + const mode = dispatched + ? yield* fs.stat(target).pipe( + Effect.map((info) => info.mode & 0o7777), + Effect.orElseSucceed(() => undefined), + ) + : 0o755 + + yield* replaceFile(target, next, mode) + + return result + }), + ) + }).pipe( + Effect.mapError((error) => (typeof error === 'string' ? error : platformMessage(error))), + Effect.result, + ) + + // `prepare` runs on every install, so a hook it may not write says so rather than failing it. + if (Result.isFailure(written)) { + return yield* Console.log( + `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${written.failure}`)}`, + ) + } + + yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${written.success}`)}`) + + return command +}) + export const prepare = Command.make( 'prepare', { @@ -227,11 +378,10 @@ export const prepare = Command.make( ), ...selectionFlags, }, - Effect.fn(function* ({ cwd: directory, preCommit, fix, allowEmpty, ...selection }) { - const fs = yield* FileSystem.FileSystem + Effect.fn(function* ({ cwd: directory, preCommit, ...options }) { const path = yield* Path.Path - yield* validateSelection(selection) + yield* validateSelection(options) if (!preCommit) { return yield* userError( @@ -239,146 +389,14 @@ export const prepare = Command.make( ) } - const cwd = path.resolve(directory) - - const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.result) - - if (Result.isFailure(repository)) { - const { failure } = repository - - if (failure._tag === 'GitFailed' && refusesRepository(failure)) { - const reason = failure.stderr.split('\n')[0]!.replace(/^fatal: /, '') - - return yield* Console.log( - `${red('✘')} ${bold('pre-commit')} ${dim(`not written, git refuses the repository: ${reason}`)}`, - ) - } - - // A `prepare` script runs on every install, including where there is no repository to hook. - return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) - } - - const { - prefix, - paths: [hooks], - } = repository.success - const inside = prefix.replace(/\/$/, '') - const exec = yield* detectExec(cwd) - const command = [ - exec, - HOOK_COMMAND, - ...(fix ? ['--fix'] : []), - ...(allowEmpty ? ['--allow-empty'] : []), - ...selectionArgs(selection), - ].join(' ') - const line = hookLine(inside, command) - - // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h` - // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above. - const configured = path.resolve(cwd, hooks!) - const dispatched = - path.basename(configured) === '_' && - (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false))) - const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit') - const relative = path.relative(cwd, file) - const shown = relative.startsWith('..') ? file : relative - // A scope option turns includes off, and an included file can set a global core.hooksPath. - const hooksPath = (option: string) => - git(cwd, ['config', option, '--includes', '--get', 'core.hooksPath']) - const shared = yield* hooksPath('--show-scope').pipe( - Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]), - // Git before 2.26 has no --show-scope, which must not pass for an unset core.hooksPath. - Effect.catchIf( - (error) => error._tag === 'GitFailed' && error.exitCode === 129, - () => - Effect.findFirst(['local', 'global', 'system'], (scope) => - hooksPath(`--${scope}`).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ), - ).pipe(Effect.map(Option.getOrUndefined)), - ), - Effect.map((scope) => (scope === 'local' ? undefined : scope)), - Effect.orElseSucceed(() => undefined), - ) + const command = yield* writePreCommitHook(path.resolve(directory), options) - const written = yield* Effect.gen(function* () { - if (shared !== undefined) { - return yield* Effect.fail( - `core.hooksPath is set in the ${shared} git config, so every repository runs it`, - ) - } - - if (UNQUOTABLE.test(inside)) { - return yield* Effect.fail( - `sh would misread the folder name ${JSON.stringify(inside)} between double quotes`, - ) - } - - // Renaming over a symlinked hook would replace the link, not the script it points to. - const target = yield* fs.realPath(file).pipe( - Effect.catch(() => fs.readLink(file)), - Effect.map((link) => path.resolve(path.dirname(file), link)), - Effect.orElseSucceed(() => file), - ) - - yield* fs.makeDirectory(path.dirname(target), { recursive: true }) - - return yield* locked( - target, - Effect.gen(function* () { - const existing = yield* readTextIfExists(target) - - if ( - existing !== undefined && - (OTHER_INTERPRETER.test(existing) || NOT_TEXT.test(existing)) - ) { - return yield* Effect.fail(`it is not a shell script, have it run \`${line}\` yourself`) - } - - const next = rewrite(existing ?? HEADER, line, inside) - const result = - existing === undefined ? 'created' : next === existing ? 'unchanged' : 'updated' - - // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would - // leave every clone with a change to commit. - if (result === 'unchanged') { - if (!dispatched) { - yield* fs.chmod(target, 0o755) - } - - return result - } - - const mode = dispatched - ? yield* fs.stat(target).pipe( - Effect.map((info) => info.mode & 0o7777), - Effect.orElseSucceed(() => undefined), - ) - : 0o755 - - yield* replaceFile(target, next, mode) - - return result - }), - ) - }).pipe( - Effect.mapError((error) => (typeof error === 'string' ? error : platformMessage(error))), - Effect.result, - ) - - // `prepare` runs on every install, so a hook it may not write says so rather than failing it. - if (Result.isFailure(written)) { - return yield* Console.log( - `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${written.failure}`)}`, + if (command !== undefined) { + yield* Console.log('') + yield* Console.log( + `${dim('The hook runs')} ${bold(command)} ${dim('before every commit, `git commit --no-verify` skips it.')}`, ) } - - yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${written.success}`)}`) - yield* Console.log('') - yield* Console.log( - `${dim('The hook runs')} ${bold(command)} ${dim('before every commit, `git commit --no-verify` skips it.')}`, - ) }), ).pipe( Command.withDescription( diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 1d09a59..23070d4 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -15,7 +15,7 @@ import { tsc } from '../checks/tsc' import { CheckFailed, platformMessage, userError } from '../errors' import { checkableFiles, listProjectFiles, resolvePaths } from '../files' import type { GitFailed } from '../git' -import { bold, dim, green, listFiles, red } from '../style' +import { bold, dim, green, listed, listFiles, red } from '../style' import { captureLines, execute, logLines } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' @@ -263,13 +263,13 @@ export const checkPaths = Effect.fn(function* ( appliesFixes(CHECKS.find((check) => check.name === outcome.name)!.fixes), ) .map((outcome) => outcome.name) - .join(', ') - .replace(/, ([^,]+)$/, ' and $1') yield* summarize([ '', `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map((outcome) => outcome.name).join(', ')}`, - ...(!fix && fixable !== '' ? [dim(` rerun with \`--fix\` to apply ${fixable} fixes`)] : []), + ...(!fix && fixable.length > 0 + ? [dim(` rerun with \`--fix\` to apply ${listed(fixable)} fixes`)] + : []), ]) return yield* Effect.fail(new CheckFailed({ outcomes })) diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index a86170d..6ea5ed5 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -272,6 +272,11 @@ export const fileKind = Effect.fn(function* (target: string) { ) }) +export const firstFile = (candidates: ReadonlyArray) => + Effect.findFirst(candidates, (candidate) => + Effect.map(fileKind(candidate), (type) => type === 'File'), + ) + export const readJson = Effect.fn( function* (file: string) { const fs = yield* FileSystem.FileSystem @@ -283,6 +288,25 @@ export const readJson = Effect.fn( Effect.orElseSucceed(() => undefined), ) +// pnpm also keeps its settings in pnpm-workspace.yaml, and sherif fails on one without packages. +const DECLARES_PACKAGES = /^["']?packages["']?\s*:/m + +export const isWorkspaceRoot = Effect.fn(function* ( + dir: string, + manifest: Readonly>, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + return ( + manifest.workspaces !== undefined || + (yield* fs.readFileString(path.join(dir, 'pnpm-workspace.yaml')).pipe( + Effect.map((text) => DECLARES_PACKAGES.test(text)), + Effect.orElseSucceed(() => false), + )) + ) +}) + /** The folders `tsc` itself never looks into. */ const SKIPPED_DIRECTORIES = new Set(['node_modules', 'bower_components', 'jspm_packages']) diff --git a/packages/uncheck/src/pm.ts b/packages/uncheck/src/pm.ts index 4328f90..25e5e17 100644 --- a/packages/uncheck/src/pm.ts +++ b/packages/uncheck/src/pm.ts @@ -1,16 +1,18 @@ +import process from 'node:process' + import { Effect, FileSystem, Option, Path } from 'effect' -import { ancestors, readJson } from './files' +import { ancestors, fileKind, readJson } from './files' // Hooks never get a terminal to ask in, so plain `npx` and `bunx` would download and run the latest // release whenever the project has none, and Yarn 1 wraps a run in lines of its own on stdout, where // agents expect nothing but their JSON. -const EXEC_BY_PACKAGE_MANAGER: ReadonlyMap = new Map([ - ['pnpm', 'pnpm exec'], - ['yarn', 'yarn run --silent'], - ['bun', 'bunx --no-install'], - ['npm', 'npx --no'], -]) +const EXEC_BY_PACKAGE_MANAGER = { + pnpm: 'pnpm exec', + yarn: 'yarn run --silent', + bun: 'bunx --no-install', + npm: 'npx --no', +} const YARN_TOP_LEVEL = 'yarn run -T --silent' @@ -24,7 +26,7 @@ const PACKAGE_MANAGER_BY_LOCKFILE = [ /** Every prefix uncheck writes or once wrote, so a generated command line can be recognised again. */ const EXECS: ReadonlyArray = [ - ...EXEC_BY_PACKAGE_MANAGER.values(), + ...Object.values(EXEC_BY_PACKAGE_MANAGER), YARN_TOP_LEVEL, 'yarn', 'bunx', @@ -44,31 +46,37 @@ export function invokes(text: string, command: string): boolean { }) } +type PackageManagerName = keyof typeof EXEC_BY_PACKAGE_MANAGER + +export interface PackageManager { + readonly name: PackageManagerName + readonly yarnBerry: boolean +} + +function isPackageManagerName(name: string): name is PackageManagerName { + return Object.hasOwn(EXEC_BY_PACKAGE_MANAGER, name) +} + +function isYarnBerry(name: string, version: string | undefined): boolean { + return name === 'yarn' && version !== undefined && !version.startsWith('1.') +} + /** - * The `npx`-like prefix that runs a project binary, from the package manager the nearest project - * declares in `packageManager` or, failing that, its lockfile. + * The package manager the nearest project declares in `packageManager` or, failing that, its + * lockfile, or else the one that started uncheck, which `npx`, `pnpm dlx`, `yarn dlx` and `bunx` say + * in `npm_config_user_agent`. */ -export const detectExec = Effect.fn(function* (cwd: string, { fromAnyWorkspace = false } = {}) { +export const detectPackageManager = Effect.fn(function* (cwd: string) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path for (const dir of ancestors(path, cwd)) { const manifest = yield* readJson(path.join(dir, 'package.json')) - const packageManager = - typeof manifest?.packageManager === 'string' ? manifest.packageManager : undefined + const [name = '', version] = + typeof manifest?.packageManager === 'string' ? manifest.packageManager.split('@') : [] - // Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's. - if (fromAnyWorkspace && packageManager !== undefined && /^yarn@(?!1\.)/.test(packageManager)) { - return YARN_TOP_LEVEL - } - - const declared = - packageManager === undefined - ? undefined - : EXEC_BY_PACKAGE_MANAGER.get(packageManager.split('@')[0]!) - - if (declared !== undefined) { - return declared + if (isPackageManagerName(name)) { + return { name, yarnBerry: isYarnBerry(name, version) } satisfies PackageManager } const lockfile = yield* Effect.findFirst(PACKAGE_MANAGER_BY_LOCKFILE, ([file]) => @@ -76,9 +84,29 @@ export const detectExec = Effect.fn(function* (cwd: string, { fromAnyWorkspace = ) if (Option.isSome(lockfile)) { - return EXEC_BY_PACKAGE_MANAGER.get(lockfile.value[1])! + const [, found] = lockfile.value + + // A Yarn 2+ project that declares no version still has its settings, such as `yarnPath`, here. + return { + name: found, + yarnBerry: + found === 'yarn' && (yield* fileKind(path.join(dir, '.yarnrc.yml'))) !== undefined, + } satisfies PackageManager } } - return EXEC_BY_PACKAGE_MANAGER.get('npm')! + const [, name = '', version] = + /^([\w-]+)\/(\S+)/.exec(process.env.npm_config_user_agent ?? '') ?? [] + + return isPackageManagerName(name) + ? ({ name, yarnBerry: isYarnBerry(name, version) } satisfies PackageManager) + : ({ name: 'npm', yarnBerry: false } satisfies PackageManager) +}) + +/** The `npx`-like prefix that runs a project binary, from the package manager of `cwd`. */ +export const detectExec = Effect.fn(function* (cwd: string, { fromAnyWorkspace = false } = {}) { + const { name, yarnBerry } = yield* detectPackageManager(cwd) + + // Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's. + return fromAnyWorkspace && yarnBerry ? YARN_TOP_LEVEL : EXEC_BY_PACKAGE_MANAGER[name] }) diff --git a/packages/uncheck/src/style.ts b/packages/uncheck/src/style.ts index d15907b..8d5a190 100644 --- a/packages/uncheck/src/style.ts +++ b/packages/uncheck/src/style.ts @@ -13,3 +13,7 @@ export const green = paint('green') export function listFiles(files: ReadonlyArray): string { return files.length <= 3 ? files.join(' ') : `[${files.length} files]` } + +export function listed(names: ReadonlyArray): string { + return names.join(', ').replace(/, ([^,]+)$/, ' and $1') +} diff --git a/packages/uncheck/tests/hooks/install/prompt.test.ts b/packages/uncheck/tests/hooks/install/prompt.test.ts index dd3fb92..32db641 100644 --- a/packages/uncheck/tests/hooks/install/prompt.test.ts +++ b/packages/uncheck/tests/hooks/install/prompt.test.ts @@ -1,15 +1,8 @@ -import { LAYOUTS, cliError, monorepo } from '../../utils/project' +import { DOWN, ENTER, LAYOUTS, SPACE, cliError, fromAnswer, monorepo } from '../../utils/project' import { asWritten, claudeSettings, cursorHooks, hookCommand, installOutput } from './utils' const QUESTION = 'Which agents should run uncheck when they finish a turn?' const CHOICES = `? ${QUESTION} › \n Select All\n Inverse Selection\n ☐ Claude Code \n ☐ CodeBuddy \n ☐ Cursor \n ☐ GitHub Copilot ` -const DOWN = '\u001B[B' -const SPACE = ' ' -const ENTER = '\r' - -function fromAnswer(output: string): string { - return output.slice(output.lastIndexOf(`✔ ${QUESTION}`)) -} describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app }) => { it('writes the agents chosen in a terminal', async () => { @@ -18,13 +11,12 @@ describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { cwd: app, - waitFor: QUESTION, - keys: [DOWN, DOWN, SPACE, DOWN, DOWN, SPACE, ENTER], + answers: [{ waitFor: QUESTION, keys: [DOWN, DOWN, SPACE, DOWN, DOWN, SPACE, ENTER] }], }) expect(exitCode).toBe(0) expect(stdout.slice(0, CHOICES.length)).toBe(CHOICES) - expect(fromAnswer(stdout)).toBe( + expect(fromAnswer(stdout, QUESTION)).toBe( `✔ ${QUESTION} … Claude Code, Cursor\n${installOutput( ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], command, @@ -57,12 +49,11 @@ describe('hooks install without agents in a monorepo package', () => { const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { cwd: 'packages/app', - waitFor: QUESTION, - keys: [SPACE, ENTER], + answers: [{ waitFor: QUESTION, keys: [SPACE, ENTER] }], }) expect(exitCode).toBe(1) - expect(fromAnswer(stdout)).toBe( + expect(fromAnswer(stdout, QUESTION)).toBe( `✔ ${QUESTION} … Claude Code, CodeBuddy, Cursor, GitHub Copilot\n${cliError( 'Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there', )}`, diff --git a/packages/uncheck/tests/init/defaults.test.ts b/packages/uncheck/tests/init/defaults.test.ts new file mode 100644 index 0000000..e10bb95 --- /dev/null +++ b/packages/uncheck/tests/init/defaults.test.ts @@ -0,0 +1,192 @@ +import { claudeSettings, cursorHooks, hookCommand } from '../hooks/install/utils' +import { HEADER, hookLine } from '../prepare/utils' +import { cliError, monorepo, singleRepo } from '../utils/project' +import { SCRIPTS, bareProject, fakePackageManagers, initOutput, manifestOf } from './utils' + +describe('init --yes in a project without the tools', () => { + it('installs uncheck, oxlint and oxfmt, adds the scripts and writes the pre-commit hook', async () => { + const project = bareProject() + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout, stderr } = await project.uncheck(['init', '--yes'], { env }) + + expect(stderr).toBe('') + expect(project.normalize(stdout)).toBe( + `uncheck init in \n${initOutput([ + '▶ pnpm add --save-dev uncheck oxlint oxfmt', + 'pnpm added uncheck oxlint oxfmt', + '✔ package.json scripts check, fix and prepare written', + '✔ pre-commit .git/hooks/pre-commit created', + ])}`, + ) + expect(exitCode).toBe(0) + expect(calls()).toEqual(['pnpm add --save-dev uncheck oxlint oxfmt']) + expect(manifestOf(project)).toEqual({ + name: 'app', + version: '1.0.0', + private: true, + type: 'module', + devDependencies: { uncheck: '^1.0.0', oxlint: '^1.0.0', oxfmt: '^1.0.0' }, + scripts: SCRIPTS, + }) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine('')}\n`) + }) + + it('writes the hooks of the agents whose folders the project has', async () => { + const project = bareProject({ '.claude/agents/review.md': '# Review\n', '.cursor/rules': '' }) + const { env } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { env }) + + expect(exitCode).toBe(0) + expect(stdout).toContain( + '✔ pre-commit .git/hooks/pre-commit created\n✔ Claude Code .claude/settings.json created\n✔ Cursor .cursor/hooks.json created\n', + ) + expect(JSON.parse(project.read('.claude/settings.json'))).toEqual( + claudeSettings(hookCommand('')), + ) + expect(JSON.parse(project.read('.cursor/hooks.json'))).toEqual(cursorHooks(hookCommand(''))) + expect(project.exists('.codebuddy')).toBe(false) + }) + + it('leaves the agents that already run uncheck as they are', async () => { + const fast = { + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: hookCommand('', ['--only=oxlint']) }], + }, + ], + }, + } + const project = bareProject({ '.claude/settings.json': fast, '.codebuddy/settings.json': {} }) + const { env } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { env }) + + expect(exitCode).toBe(0) + expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json updated\n') + expect(stdout).not.toContain('Claude Code') + expect(JSON.parse(project.read('.claude/settings.json'))).toEqual(fast) + expect(JSON.parse(project.read('.codebuddy/settings.json'))).toEqual( + claudeSettings(hookCommand('')), + ) + }) + + it('refuses agents in a folder the hook cannot name before installing anything', async () => { + const project = bareProject({ + 'my tools/package.json': { name: 'tools', private: true }, + 'my tools/.claude/settings.json': {}, + }) + const { env, calls } = fakePackageManagers() + + const { exitCode, stderr } = await project.uncheck(['init', '--yes'], { + cwd: 'my tools', + env, + }) + + expect(exitCode).toBe(1) + expect(stderr).toBe( + cliError( + 'The hook command cannot name my tools: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-', + ), + ) + expect(calls()).toEqual([]) + expect(manifestOf(project).scripts).toBeUndefined() + }) +}) + +describe('init --yes in a project with the tools installed', () => { + it('installs nothing and changes nothing when run again', async () => { + const project = singleRepo() + const { env, calls } = fakePackageManagers() + + const first = await project.uncheck(['init', '--yes'], { env }) + + expect(project.normalize(first.stdout)).toBe( + `uncheck init in \n${initOutput([ + '✔ package.json scripts check, fix and prepare written', + '✔ pre-commit .git/hooks/pre-commit created', + ])}`, + ) + expect(first.exitCode).toBe(0) + + const manifest = project.read('package.json') + const second = await project.uncheck(['init', '--yes'], { env }) + + expect(project.normalize(second.stdout)).toBe( + `uncheck init in \n${initOutput([ + '✔ package.json unchanged', + '○ pre-commit left to the prepare script, which writes it on every install', + ])}`, + ) + expect(second.exitCode).toBe(0) + expect(project.read('package.json')).toBe(manifest) + expect(calls()).toEqual([]) + }) + + it('sets up a package of a monorepo on its own, pointing at the workspace root', async () => { + const project = monorepo() + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { + cwd: 'packages/app', + env, + }) + + expect(project.normalize(stdout)).toBe( + `uncheck init in /packages/app\n○ inside the workspace at ../.., run uncheck init there to set up every package\n${initOutput( + [ + '✔ package.json scripts check, fix and prepare written', + '✔ pre-commit /.git/hooks/pre-commit created', + ], + )}`, + ) + expect(exitCode).toBe(0) + expect(calls()).toEqual([]) + expect(manifestOf(project).scripts).toBeUndefined() + expect(JSON.parse(project.read('packages/app/package.json'))).toHaveProperty('scripts', SCRIPTS) + expect(project.read('.git/hooks/pre-commit')).toContain(`${hookLine('packages/app/')}\n`) + }) + + it('writes nothing for the pre-commit hook outside a git repository', async () => { + const project = singleRepo({}, { git: 'none' }) + + const { exitCode, stdout } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(0) + expect(stdout).toContain('○ no git repository found, nothing to prepare\n') + expect(manifestOf(project).scripts).toEqual(SCRIPTS) + }) +}) + +describe('init refuses to start', () => { + it('without a package.json', async () => { + const project = bareProject({ 'package.json': null }) + + const { exitCode, stdout, stderr } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `No package.json in ${project.dir} to set up: create one first, for example with \`npm init\``, + ), + ) + }) + + it('without a terminal to ask in, unless told to take the defaults', async () => { + const project = bareProject() + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout, stderr } = await project.uncheck(['init'], { env }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError('Answer the questions in a terminal, or pass --yes to take the default answers'), + ) + expect(calls()).toEqual([]) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/init/package-manager.test.ts b/packages/uncheck/tests/init/package-manager.test.ts new file mode 100644 index 0000000..fc39891 --- /dev/null +++ b/packages/uncheck/tests/init/package-manager.test.ts @@ -0,0 +1,186 @@ +import { symlinkSync } from 'node:fs' +import { join } from 'node:path' + +import type { Env, Files } from '../utils/project' +import { REAL_GIT, cliError, temporaryDirectory } from '../utils/project' +import { bareProject, fakePackageManagers, initOutput, manifest, manifestOf } from './utils' + +const WORKSPACES = { workspaces: ['packages/*'] } + +describe('init installs with the package manager of the project', () => { + it.each<[string, Files, Env, string]>([ + [ + 'an npm lockfile', + { 'pnpm-lock.yaml': null, 'package-lock.json': {} }, + {}, + 'npm install --save-dev', + ], + ['a Yarn lockfile', { 'pnpm-lock.yaml': null, 'yarn.lock': '' }, {}, 'yarn add --dev'], + ['a Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lock': '{}\n' }, {}, 'bun add --dev'], + ['no lockfile', { 'pnpm-lock.yaml': null }, {}, 'npm install --save-dev'], + [ + 'no lockfile, started by pnpm dlx', + { 'pnpm-lock.yaml': null }, + { npm_config_user_agent: 'pnpm/10.18.0 npm/? node/v24.9.0 linux x64' }, + 'pnpm add --save-dev', + ], + [ + 'no lockfile, started by bunx', + { 'pnpm-lock.yaml': null }, + { npm_config_user_agent: 'bun/1.3.0 npm/? node/v24.3.0 linux x64' }, + 'bun add --dev', + ], + [ + 'a pnpm lockfile, started by npx', + {}, + { npm_config_user_agent: 'npm/11.6.0 node/v24.9.0 linux x64 workspaces/false' }, + 'pnpm add --save-dev', + ], + [ + 'npm declared over a pnpm lockfile', + manifest({ packageManager: 'npm@11.6.0' }), + {}, + 'npm install --save-dev', + ], + ])('with %s', async (_, files, userAgent, install) => { + const project = bareProject(files) + const { env, calls } = fakePackageManagers() + const packageManager = install.split(' ')[0]! + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { + env: { ...env, ...userAgent }, + }) + + expect(project.normalize(stdout)).toBe( + `uncheck init in \n${initOutput( + [ + `▶ ${install} uncheck oxlint oxfmt`, + `${packageManager} added uncheck oxlint oxfmt`, + '✔ package.json scripts check, fix and prepare written', + '✔ pre-commit .git/hooks/pre-commit created', + ], + packageManager, + )}`, + ) + expect(exitCode).toBe(0) + expect(calls()).toEqual([`${install} uncheck oxlint oxfmt`]) + }) +}) + +describe('init adds to the root of a workspace', () => { + it.each<[string, Files, string]>([ + [ + 'pnpm', + { 'pnpm-workspace.yaml': 'packages:\n - packages/*\n' }, + 'pnpm add --save-dev --workspace-root', + ], + [ + 'Yarn 1', + { ...manifest(WORKSPACES), 'pnpm-lock.yaml': null, 'yarn.lock': '' }, + 'yarn add --dev --ignore-workspace-root-check', + ], + [ + 'Yarn 4', + { ...manifest({ ...WORKSPACES, packageManager: 'yarn@4.10.3' }), 'pnpm-lock.yaml': null }, + 'yarn add --dev', + ], + [ + 'npm', + { ...manifest(WORKSPACES), 'pnpm-lock.yaml': null, 'package-lock.json': {} }, + 'npm install --save-dev', + ], + ])('with %s', async (_, files, install) => { + const project = bareProject(files) + const { env, calls } = fakePackageManagers() + + const { exitCode } = await project.uncheck(['init', '--yes'], { env }) + + expect(exitCode).toBe(0) + expect(calls()).toEqual([`${install} uncheck oxlint oxfmt sherif`]) + }) +}) + +describe('init with Yarn 2+, which runs postinstall but not prepare', () => { + it.each<[string, Files, Env]>([ + ['declared', manifest({ packageManager: 'yarn@4.10.3' }), {}], + [ + 'configured in .yarnrc.yml', + { 'yarn.lock': '', '.yarnrc.yml': 'nodeLinker: node-modules\n' }, + {}, + ], + [ + 'started by yarn dlx', + {}, + { npm_config_user_agent: 'yarn/4.10.3 npm/? node/v24.9.0 linux x64' }, + ], + ])('writes the hook from postinstall when %s', async (_, files, userAgent) => { + const project = bareProject({ 'pnpm-lock.yaml': null, ...files }) + const { env } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { + env: { ...env, ...userAgent }, + }) + + expect(exitCode).toBe(0) + expect(stdout).toContain( + '✔ package.json scripts check, fix and postinstall written\n✔ pre-commit .git/hooks/pre-commit created\n', + ) + expect(manifestOf(project).scripts).toEqual({ + check: 'uncheck', + fix: 'uncheck --fix', + postinstall: 'uncheck prepare --pre-commit', + }) + }) + + it('warns that a package others install runs postinstall too', async () => { + const project = bareProject({ + ...manifest({ private: false, packageManager: 'yarn@4.10.3' }), + 'pnpm-lock.yaml': null, + }) + const { env } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { env }) + + expect(exitCode).toBe(0) + expect(stdout).toContain( + '✔ pre-commit .git/hooks/pre-commit created\n○ postinstall also runs where this package is installed, turn it off while packing, for example with pinst\n', + ) + }) +}) + +describe('init stops when the install does not work', () => { + it('sets up nothing else after a failed install', async () => { + const project = bareProject() + const { env, calls } = fakePackageManagers() + const before = project.read('package.json') + + const { exitCode, stdout, stderr } = await project.uncheck(['init', '--yes'], { + env: { ...env, FAKE_INSTALL_FAILS: '1' }, + }) + + expect(exitCode).toBe(1) + expect(project.normalize(stdout)).toBe( + 'uncheck init in \n▶ pnpm add --save-dev uncheck oxlint oxfmt\n', + ) + expect(stderr).toBe( + `pnpm: install failed\n${cliError('`pnpm add --save-dev uncheck oxlint oxfmt` failed, so nothing else was set up. Run uncheck init again once it installs')}`, + ) + expect(calls()).toEqual(['pnpm add --save-dev uncheck oxlint oxfmt']) + expect(project.read('package.json')).toBe(before) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('says so when the package manager is not installed', async () => { + const project = bareProject() + const onlyGit = temporaryDirectory() + + symlinkSync(REAL_GIT, join(onlyGit, 'git')) + + const { exitCode, stderr } = await project.uncheck(['init', '--yes'], { + env: { PATH: onlyGit }, + }) + + expect(exitCode).toBe(1) + expect(stderr).toBe(cliError('pnpm is not installed, install it and run uncheck init again')) + }) +}) diff --git a/packages/uncheck/tests/init/prompt.test.ts b/packages/uncheck/tests/init/prompt.test.ts new file mode 100644 index 0000000..0dc0265 --- /dev/null +++ b/packages/uncheck/tests/init/prompt.test.ts @@ -0,0 +1,131 @@ +import { claudeSettings, hookCommand } from '../hooks/install/utils' +import { DOWN, ENTER, SPACE, fromAnswer, monorepo } from '../utils/project' +import { + SCRIPTS, + bareProject, + fakePackageManagers, + initOutput, + manifest, + manifestOf, +} from './utils' + +const TOOLS = 'Which tools should uncheck install?' +const PRESETS = 'Which tools should get a config from the middleapi preset?' +const COMMIT = 'Check the staged files before every commit?' +const AGENTS = 'Which agents should run uncheck when they finish a turn?' + +describe('init in a terminal', () => { + it('installs the chosen tools and writes the chosen preset, hook and agent configs', async () => { + const project = bareProject() + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheckInTerminal(['init'], { + env, + answers: [ + { waitFor: TOOLS, keys: [DOWN, DOWN, DOWN, SPACE, ENTER] }, + { waitFor: PRESETS, keys: [DOWN, DOWN, SPACE, ENTER] }, + { waitFor: COMMIT, keys: [ENTER] }, + { waitFor: AGENTS, keys: [DOWN, DOWN, SPACE, ENTER] }, + ], + }) + + expect(exitCode).toBe(0) + expect(stdout).toContain( + `? ${TOOLS} › \n Select None\n Inverse Selection\n ☒ oxlint \n ☒ oxfmt `, + ) + expect(stdout).toContain(`✔ ${TOOLS} … oxlint\n`) + expect(stdout).toContain(`✔ ${PRESETS} … oxlint\n`) + expect(stdout).toContain(`✔ ${COMMIT} … yes\n`) + expect(fromAnswer(stdout, AGENTS)).toBe( + `✔ ${AGENTS} … Claude Code\n${initOutput([ + '▶ pnpm add --save-dev uncheck oxlint', + 'pnpm added uncheck oxlint', + '✔ oxlint oxlint.config.ts created', + '✔ package.json scripts check, fix and prepare written', + '✔ pre-commit .git/hooks/pre-commit created', + '✔ Claude Code .claude/settings.json created', + ])}`, + ) + expect(calls()).toEqual(['pnpm add --save-dev uncheck oxlint']) + expect(project.read('oxlint.config.ts')).toBe( + "import { defineConfig } from 'oxlint'\nimport { middleapi } from 'uncheck/oxlint'\n\nexport default defineConfig({ extends: [middleapi] })\n", + ) + expect(project.exists('.git/hooks/pre-commit')).toBe(true) + expect(JSON.parse(project.read('.claude/settings.json'))).toEqual( + claudeSettings(hookCommand('')), + ) + }) + + it('writes a preset config CommonJS can load, and no hooks when declined', async () => { + const project = bareProject(manifest({ type: 'commonjs' })) + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheckInTerminal(['init'], { + env, + answers: [ + { waitFor: TOOLS, keys: [ENTER] }, + { waitFor: PRESETS, keys: [DOWN, DOWN, DOWN, SPACE, ENTER] }, + { waitFor: COMMIT, keys: ['n'] }, + { waitFor: AGENTS, keys: [ENTER] }, + ], + }) + + expect(exitCode).toBe(0) + expect(stdout).toContain(`✔ ${COMMIT} … no\n`) + expect(fromAnswer(stdout, AGENTS)).toBe( + `✔ ${AGENTS} … \n${initOutput([ + '▶ pnpm add --save-dev uncheck oxlint oxfmt', + 'pnpm added uncheck oxlint oxfmt', + '✔ oxfmt oxfmt.config.mts created', + '✔ package.json scripts check and fix written', + ])}`, + ) + expect(calls()).toEqual(['pnpm add --save-dev uncheck oxlint oxfmt']) + expect(project.read('oxfmt.config.mts')).toBe( + "import { defineConfig } from 'oxfmt'\nimport { middleapi } from 'uncheck/oxfmt'\n\nexport default defineConfig({ ...middleapi })\n", + ) + expect(project.exists('oxlint.config.mts')).toBe(false) + expect(manifestOf(project).scripts).toEqual({ check: 'uncheck', fix: 'uncheck --fix' }) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('asks only what is not set up yet, and offers no Copilot below the top', async () => { + const project = monorepo({ + '.oxfmtrc.json': {}, + 'packages/app/.claude/agents/review.md': '# Review\n', + }) + const { env, calls } = fakePackageManagers() + + project.update('packages/app/package.json', (manifest) => ({ + ...manifest, + scripts: SCRIPTS, + })) + + const { exitCode, stdout } = await project.uncheckInTerminal(['init'], { + cwd: 'packages/app', + env, + answers: [{ waitFor: AGENTS, keys: [ENTER] }], + }) + const command = hookCommand('packages/app/') + + expect(exitCode).toBe(0) + expect(stdout).not.toContain(TOOLS) + expect(stdout).not.toContain(PRESETS) + expect(stdout).not.toContain(COMMIT) + expect(stdout).toContain( + `? ${AGENTS} › \n Select All\n Inverse Selection\n ☒ Claude Code \n ☐ CodeBuddy \n ☐ Cursor `, + ) + expect(stdout).not.toContain('GitHub Copilot') + expect(fromAnswer(stdout, AGENTS)).toBe( + `✔ ${AGENTS} … Claude Code\n${initOutput([ + '✔ package.json unchanged', + '○ pre-commit left to the prepare script, which writes it on every install', + '✔ Claude Code .claude/settings.json created', + ])}`, + ) + expect(calls()).toEqual([]) + expect(JSON.parse(project.read('packages/app/.claude/settings.json'))).toEqual( + claudeSettings(command), + ) + }) +}) diff --git a/packages/uncheck/tests/init/scripts.test.ts b/packages/uncheck/tests/init/scripts.test.ts new file mode 100644 index 0000000..a3bf58e --- /dev/null +++ b/packages/uncheck/tests/init/scripts.test.ts @@ -0,0 +1,83 @@ +import { singleRepo } from '../utils/project' +import { SCRIPTS, manifest, manifestOf } from './utils' + +describe('init adds its scripts next to the ones a project has', () => { + it('keeps the existing scripts and their order, adding the missing ones after them', async () => { + const project = singleRepo(manifest({ scripts: { test: 'vitest', fix: 'eslint --fix' } })) + + const { exitCode, stdout } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(0) + expect(stdout).toContain('✔ package.json scripts check and prepare written\n') + expect(stdout).toContain( + '\nRun pnpm run check to check the project, and pnpm exec uncheck --fix to fix what can be fixed.\n', + ) + expect(Object.entries(manifestOf(project).scripts as object)).toEqual([ + ['test', 'vitest'], + ['fix', 'eslint --fix'], + ['check', 'uncheck'], + ['prepare', 'uncheck prepare --pre-commit'], + ]) + }) + + it.each([ + ['husky', 'husky && uncheck prepare --pre-commit'], + ['', 'uncheck prepare --pre-commit'], + ])('turns the prepare script %j into %j', async (prepare, written) => { + const project = singleRepo( + manifest({ scripts: { check: 'uncheck', fix: 'uncheck --fix', prepare } }), + ) + + const { exitCode } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(0) + expect(manifestOf(project).scripts).toEqual({ + check: 'uncheck', + fix: 'uncheck --fix', + prepare: written, + }) + }) + + it('leaves the hook to a prepare script that already runs uncheck prepare', async () => { + const scripts = { + check: 'uncheck', + fix: 'uncheck --fix', + prepare: 'husky && uncheck prepare --pre-commit --only=oxlint', + } + const project = singleRepo(manifest({ scripts })) + + const { exitCode, stdout } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(0) + expect(stdout).toContain( + '✔ package.json unchanged\n○ pre-commit left to the prepare script, which writes it on every install\n', + ) + expect(manifestOf(project).scripts).toEqual(scripts) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) + +describe('init keeps the indentation of package.json', () => { + it.each([ + ['tabs', '{\n\t"name": "app",\n\t"private": true\n}\n', '\t'], + ['four spaces', '{\n "name": "app",\n "private": true\n}\n', ' '], + ['nothing, on one line', '{"name":"app","private":true}\n', ' '], + ])('indented with %s', async (_, text, indent) => { + const project = singleRepo({ 'package.json': text }) + + const { exitCode } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(0) + expect(project.read('package.json')).toBe( + `${JSON.stringify( + { + name: 'app', + private: true, + scripts: SCRIPTS, + }, + null, + indent, + )}\n`, + ) + }) +}) diff --git a/packages/uncheck/tests/init/utils.ts b/packages/uncheck/tests/init/utils.ts new file mode 100644 index 0000000..29ef739 --- /dev/null +++ b/packages/uncheck/tests/init/utils.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { delimiter, join } from 'node:path' +import process from 'node:process' + +import type { Files, Project } from '../utils/project' +import { environment, project, temporaryDirectory } from '../utils/project' + +const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const + +export const SCRIPTS = { + check: 'uncheck', + fix: 'uncheck --fix', + prepare: 'uncheck prepare --pre-commit', +} + +const FAKE = ` +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' + +const [log, name, ...args] = process.argv.slice(2) + +appendFileSync(log, \`\${[name, ...args].join(' ')}\\n\`) + +if (process.env.FAKE_INSTALL_FAILS !== undefined) { + console.error(\`\${name}: install failed\`) + process.exit(1) +} + +const text = readFileSync('package.json', 'utf8') +const manifest = JSON.parse(text) +const added = args.filter((arg) => !arg.startsWith('-') && arg !== 'add' && arg !== 'install') + +manifest.devDependencies = { + ...manifest.devDependencies, + ...Object.fromEntries(added.map((name) => [name, '^1.0.0'])), +} +writeFileSync( + 'package.json', + \`\${JSON.stringify(manifest, null, /^[ \\t]+(?=")/m.exec(text)?.[0] ?? 2)}\\n\`, +) +console.log(\`\${name} added \${added.join(' ')}\`) +` + +export function fakePackageManagers() { + const dir = temporaryDirectory() + const log = join(dir, 'calls.log') + const script = join(dir, 'fake.mjs') + + writeFileSync(script, FAKE) + + for (const name of PACKAGE_MANAGERS) { + writeFileSync( + join(dir, name), + `#!/bin/sh\nexec '${process.execPath}' '${script}' '${log}' ${name} "$@"\n`, + { mode: 0o755 }, + ) + } + + return { + env: { PATH: `${dir}${delimiter}${environment().PATH}` }, + calls: () => (existsSync(log) ? readFileSync(log, 'utf8').split('\n').slice(0, -1) : []), + } +} + +export function manifest(fields: object = {}): Files { + return { + 'package.json': { name: 'app', version: '1.0.0', private: true, type: 'module', ...fields }, + } +} + +export function bareProject(files: Files = {}): Project { + return project( + { ...manifest(), 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', ...files }, + { tools: [] }, + ).write({ 'node_modules/uncheck': null, 'node_modules/.bin/uncheck': null }) +} + +export function manifestOf(project: Project): Record { + return JSON.parse(project.read('package.json')) as Record +} + +export function initOutput(lines: ReadonlyArray, packageManager = 'pnpm'): string { + return `${lines.map((line) => `${line}\n`).join('')}\nRun ${packageManager} run check to check the project, and ${packageManager} run fix to fix what can be fixed.\n` +} diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index e3cd514..bdca132 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -56,8 +56,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, const { exitCode } = await project.uncheckInTerminal(['staged', '--only=oxlint'], { cwd: folder, - waitFor: 'waiting', - keys: ['\u0003'], + answers: [{ waitFor: 'waiting', keys: ['\u0003'] }], }) expect(exitCode).toBe(130) @@ -75,7 +74,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, const { exitCode, stdout } = await project.uncheckInTerminal( ['staged', '--fix', '--only=oxfmt'], - { cwd: folder, waitFor: 'waiting', keys: ['\u0003'] }, + { cwd: folder, answers: [{ waitFor: 'waiting', keys: ['\u0003'] }] }, ) expect(exitCode).toBe(130) diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts index 54cd8b2..13f5fb6 100644 --- a/packages/uncheck/tests/uncheck/cli.test.ts +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -107,7 +107,7 @@ describe('uncheck command line', () => { const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--completions', 'bash']) expect(stdout).toMatch(/^###-begin-uncheck-completions-###\n/) - expect(stdout).toContain("compgen -W 'staged prepare hooks'") + expect(stdout).toContain("compgen -W 'init staged prepare hooks'") expect(stdout).toMatch(/\ncomplete -F _uncheck uncheck\n###-end-uncheck-completions-###\n$/) expect(stderr).toBe('') expect(exitCode).toBe(0) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 8d87c2b..9e577ec 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -38,12 +38,16 @@ export interface RunOptions { readonly env?: Env } +export interface Answer { + /** Typed one at a time once the output shows `waitFor`. */ + readonly keys: ReadonlyArray + readonly waitFor: string +} + export interface TerminalOptions { readonly cwd?: string readonly env?: Env - /** Typed one at a time once the output shows `waitFor`. */ - readonly keys?: ReadonlyArray - readonly waitFor?: string + readonly answers?: ReadonlyArray } export interface Run { @@ -98,7 +102,7 @@ for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'uncheck-e2e-'))) const GIT_CONFIG = join(ROOT, 'gitconfig') const SHIMS = join(ROOT, 'bin') -const REAL_GIT = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() +export const REAL_GIT = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() writeFileSync(GIT_CONFIG, '') mkdirSync(SHIMS) @@ -251,10 +255,18 @@ function shellQuote(arg: string): string { return `'${arg.replaceAll("'", `'\\''`)}'` } +export const DOWN = '\u001B[B' +export const SPACE = ' ' +export const ENTER = '\r' + +export function fromAnswer(output: string, question: string): string { + return output.slice(output.lastIndexOf(`✔ ${question}`)) +} + /** Runs `command` in a pseudo-terminal, which merges stdout and stderr into `stdout`. */ export function runInTerminal( command: ReadonlyArray, - { cwd, env, keys = [], waitFor }: Omit & { readonly cwd: string }, + { cwd, env, answers = [] }: Omit & { readonly cwd: string }, ): Promise { // Without exec, the shell that script starts also gets Ctrl-C and exits 130 whatever the command does. const args = @@ -265,15 +277,29 @@ export function runInTerminal( return new Promise((resolve, reject) => { const child = spawn('script', args, { cwd: inside(cwd), env: environment(env) }) let stdout = '' - let typing = false + let answered = 0 + let shownFrom = 0 child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk - if (!typing && waitFor !== undefined && stripVTControlCharacters(stdout).includes(waitFor)) { - typing = true - keys.forEach((key, index) => setTimeout(() => child.stdin.write(key), 100 * (index + 1))) + const answer = answers[answered] + + if (answer === undefined) { + return + } + + const shown = stripVTControlCharacters(stdout).indexOf(answer.waitFor, shownFrom) + + if (shown === -1) { + return } + + answered += 1 + shownFrom = shown + answer.waitFor.length + answer.keys.forEach((key, index) => + setTimeout(() => child.stdin.write(key), 100 * (index + 1)), + ) }) child.on('error', reject) child.on('close', (exitCode, signal) => From 5c862b920422c1a9938526fbdab78db9e1ecdfc1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 14:26:40 +0700 Subject: [PATCH 2/2] fix(uncheck): keep hooked agents out of init's question, report a broken package.json Agents that already run uncheck are no longer offered by `init`, so picking them can no longer replace their custom flags; they are reported as kept, and the question is skipped when no agent is left to offer. A package.json that is not a JSON object is now reported as such instead of as missing. --- packages/uncheck/README.md | 2 +- packages/uncheck/src/commands/init.ts | 30 ++++++---- packages/uncheck/tests/init/defaults.test.ts | 22 +++++++- packages/uncheck/tests/init/prompt.test.ts | 58 ++++++++++++++++---- 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 6e8e6e1..b1020fb 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -54,7 +54,7 @@ npx uncheck init # or pnpm dlx, yarn dlx, bunx 3. **Whether to check every commit:** it adds the [`prepare` script](#run-it-before-every-commit) (`postinstall` with Yarn 2+) and writes the hook. 4. **Which agents run uncheck** [after every turn](#run-it-after-every-agent-turn). -It also adds a `check` script (`uncheck`) and a `fix` script (`uncheck --fix`), unless the project has scripts with those names. Running `init` again only sets up what is missing. A `prepare` script that already runs `uncheck prepare` keeps its hook, and agents that already run uncheck keep their flags. +It also adds a `check` script (`uncheck`) and a `fix` script (`uncheck --fix`), unless the project has scripts with those names. Running `init` again only sets up what is missing. A `prepare` script that already runs `uncheck prepare` keeps its hook, and agents that already run uncheck keep their flags: change those in the `prepare` script or with `uncheck hooks install`. `npx uncheck init --yes` takes the default answers, which it needs without a terminal. It installs the missing tools, checks every commit, and sets up the agents whose folders the project has, such as `.claude`, that do not run uncheck yet. It writes no preset config. diff --git a/packages/uncheck/src/commands/init.ts b/packages/uncheck/src/commands/init.ts index 290dcc6..73ee503 100644 --- a/packages/uncheck/src/commands/init.ts +++ b/packages/uncheck/src/commands/init.ts @@ -112,7 +112,9 @@ export const init = Command.make( if (manifest === undefined) { return yield* userError( - `No package.json in ${cwd} to set up: create one first, for example with \`npm init\``, + (yield* fs.exists(manifestFile)) + ? `${manifestFile} is not a JSON object, fix it and run uncheck init again` + : `No package.json in ${cwd} to set up: create one first, for example with \`npm init\``, ) } @@ -202,17 +204,17 @@ export const init = Command.make( ))) const dir = yield* agentHookDir(cwd) - const offeredAgents = AGENTS.filter((agent) => dir === '' || agent.id !== 'copilot') - const unhookedAgents = yield* Effect.filter(offeredAgents, (agent) => - Effect.zipWith( - fs.exists(path.join(cwd, path.dirname(agent.path))), - hasOwnHook(cwd, agent), - (used, hooked) => used && !hooked, - ), + const hookedAgents = yield* Effect.filter(AGENTS, (agent) => hasOwnHook(cwd, agent)) + const offeredAgents = AGENTS.filter( + (agent) => !hookedAgents.includes(agent) && (dir === '' || agent.id !== 'copilot'), + ) + const usedAgents = yield* Effect.filter(offeredAgents, (agent) => + fs.exists(path.join(cwd, path.dirname(agent.path))), ) - const agents: ReadonlyArray = yes - ? unhookedAgents.map((agent) => agent.id) - : yield* chooseAgents(offeredAgents, { preselected: unhookedAgents }) + const agents: ReadonlyArray = + yes || offeredAgents.length === 0 + ? usedAgents.map((agent) => agent.id) + : yield* chooseAgents(offeredAgents, { preselected: usedAgents }) // A folder the agent hook cannot name would otherwise fail the run after the install. yield* agents.length > 0 ? validateAgentHookDir(dir) : Effect.void @@ -294,6 +296,12 @@ export const init = Command.make( } } + for (const agent of hookedAgents) { + yield* Console.log( + `${dim('○')} ${bold(agent.name)} ${dim(`${agent.path} already runs uncheck`)}`, + ) + } + if (agents.length > 0) { // Only after the install declares uncheck here does Yarn 2+ run the package's own binary. yield* writeAgentHooks(cwd, yield* agentHookCommand(cwd, dir, NO_SELECTION), agents) diff --git a/packages/uncheck/tests/init/defaults.test.ts b/packages/uncheck/tests/init/defaults.test.ts index e10bb95..8b2ed53 100644 --- a/packages/uncheck/tests/init/defaults.test.ts +++ b/packages/uncheck/tests/init/defaults.test.ts @@ -65,8 +65,9 @@ describe('init --yes in a project without the tools', () => { const { exitCode, stdout } = await project.uncheck(['init', '--yes'], { env }) expect(exitCode).toBe(0) - expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json updated\n') - expect(stdout).not.toContain('Claude Code') + expect(stdout).toContain( + '○ Claude Code .claude/settings.json already runs uncheck\n✔ CodeBuddy .codebuddy/settings.json updated\n', + ) expect(JSON.parse(project.read('.claude/settings.json'))).toEqual(fast) expect(JSON.parse(project.read('.codebuddy/settings.json'))).toEqual( claudeSettings(hookCommand('')), @@ -175,6 +176,23 @@ describe('init refuses to start', () => { ) }) + it.each(['{ "name": "app", }\n', '[]\n'])( + 'with a package.json that is not a JSON object: %j', + async (manifest) => { + const project = bareProject({ 'package.json': manifest }) + + const { exitCode, stdout, stderr } = await project.uncheck(['init', '--yes']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `${project.path('package.json')} is not a JSON object, fix it and run uncheck init again`, + ), + ) + }, + ) + it('without a terminal to ask in, unless told to take the defaults', async () => { const project = bareProject() const { env, calls } = fakePackageManagers() diff --git a/packages/uncheck/tests/init/prompt.test.ts b/packages/uncheck/tests/init/prompt.test.ts index 0dc0265..0382625 100644 --- a/packages/uncheck/tests/init/prompt.test.ts +++ b/packages/uncheck/tests/init/prompt.test.ts @@ -1,4 +1,5 @@ -import { claudeSettings, hookCommand } from '../hooks/install/utils' +import { claudeSettings, cursorHooks, hookCommand } from '../hooks/install/utils' +import type { Files, Project } from '../utils/project' import { DOWN, ENTER, SPACE, fromAnswer, monorepo } from '../utils/project' import { SCRIPTS, @@ -89,37 +90,33 @@ describe('init in a terminal', () => { expect(project.exists('.git/hooks/pre-commit')).toBe(false) }) - it('asks only what is not set up yet, and offers no Copilot below the top', async () => { - const project = monorepo({ - '.oxfmtrc.json': {}, + it('asks only what is not set up yet, offering neither Copilot below the top nor hooked agents', async () => { + const command = hookCommand('packages/app/') + const project = setUpPackage({ 'packages/app/.claude/agents/review.md': '# Review\n', + 'packages/app/.codebuddy/settings.json': claudeSettings(`${command} --only=oxlint`), }) const { env, calls } = fakePackageManagers() - project.update('packages/app/package.json', (manifest) => ({ - ...manifest, - scripts: SCRIPTS, - })) - const { exitCode, stdout } = await project.uncheckInTerminal(['init'], { cwd: 'packages/app', env, answers: [{ waitFor: AGENTS, keys: [ENTER] }], }) - const command = hookCommand('packages/app/') expect(exitCode).toBe(0) expect(stdout).not.toContain(TOOLS) expect(stdout).not.toContain(PRESETS) expect(stdout).not.toContain(COMMIT) expect(stdout).toContain( - `? ${AGENTS} › \n Select All\n Inverse Selection\n ☒ Claude Code \n ☐ CodeBuddy \n ☐ Cursor `, + `? ${AGENTS} › \n Select All\n Inverse Selection\n ☒ Claude Code \n ☐ Cursor `, ) expect(stdout).not.toContain('GitHub Copilot') expect(fromAnswer(stdout, AGENTS)).toBe( `✔ ${AGENTS} … Claude Code\n${initOutput([ '✔ package.json unchanged', '○ pre-commit left to the prepare script, which writes it on every install', + '○ CodeBuddy .codebuddy/settings.json already runs uncheck', '✔ Claude Code .claude/settings.json created', ])}`, ) @@ -127,5 +124,44 @@ describe('init in a terminal', () => { expect(JSON.parse(project.read('packages/app/.claude/settings.json'))).toEqual( claudeSettings(command), ) + expect(JSON.parse(project.read('packages/app/.codebuddy/settings.json'))).toEqual( + claudeSettings(`${command} --only=oxlint`), + ) + }) + + it('asks nothing once everything is set up', async () => { + const command = hookCommand('packages/app/') + const project = setUpPackage({ + 'packages/app/.claude/settings.json': claudeSettings(command), + 'packages/app/.codebuddy/settings.json': claudeSettings(command), + 'packages/app/.cursor/hooks.json': cursorHooks(command), + }) + const { env, calls } = fakePackageManagers() + + const { exitCode, stdout } = await project.uncheckInTerminal(['init'], { + cwd: 'packages/app', + env, + }) + + expect(project.normalize(stdout)).toBe( + `uncheck init in /packages/app\n○ inside the workspace at ../.., run uncheck init there to set up every package\n${initOutput( + [ + '✔ package.json unchanged', + '○ pre-commit left to the prepare script, which writes it on every install', + '○ Claude Code .claude/settings.json already runs uncheck', + '○ CodeBuddy .codebuddy/settings.json already runs uncheck', + '○ Cursor .cursor/hooks.json already runs uncheck', + ], + )}`, + ) + expect(exitCode).toBe(0) + expect(calls()).toEqual([]) }) }) + +function setUpPackage(files: Files): Project { + return monorepo({ '.oxfmtrc.json': {}, ...files }).update( + 'packages/app/package.json', + (manifest) => ({ ...manifest, scripts: SCRIPTS }), + ) +}