diff --git a/AGENTS.md b/AGENTS.md index 0ec1d5789..255f4c5f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -407,21 +407,22 @@ When working on a GitHub issue, **ALWAYS** follow this workflow: The feature branch must be based on the freshly updated `main`, not a stale local checkout. -4. **Implement the changes** and commit following the commit convention - -5. **Push regularly and open a draft Pull Request early**: +4. **After your first commit, push and open a draft PR immediately:** ```bash git push -u origin gh pr create --draft --title "(scope): description" --body "Closes #" ``` - Push incremental commits to the draft PR as you work so progress is visible and recoverable. - -6. **Before marking the PR ready for review or merging a low-risk change**, ensure (in this order): - 1. **E2E verification completed** (see "Completing Work — E2E Checklist") — this must pass first. - 2. For CLI or other user-facing changes, run at least one manual end-to-end check of the real user flow, not just unit/integration tests. - 3. **After e2e passes**, spawn a final subagent code review pass and address or call out any findings. Do NOT run the code review before e2e — if e2e fails you'll need to fix it first, which invalidates the review. - 4. CI pipeline passes (all checks green). - 5. No merge conflicts with `main`. + Do NOT wait until implementation is complete. The draft PR is a handoff artifact — if the session is interrupted, the user or another agent can pick up where you left off. + +5. **Implement the changes.** Commit and push incrementally as you work. Every meaningful checkpoint (feature compiles, tests pass, new behavior added) should be pushed to the draft PR so progress is visible and recoverable. + +6. **Complete E2E verification** (see "Completing Work — E2E Checklist") — this is BLOCKING. Do NOT mark the PR ready for review until every step of the E2E checklist has passed and evidence is documented in the PR body. Specifically: + 1. Run unit tests. + 2. Execute every test plan item from the issue/PR checklist, mark each `[x]`, and paste CLI output as evidence. + 3. Manual red/green UAT with before/after evidence. + 4. **After e2e passes**, spawn a final subagent code review pass and address or call out any findings. Do NOT run the code review before e2e — if e2e fails you'll need to fix it first, which invalidates the review. + 5. CI pipeline passes (all checks green). + 6. No merge conflicts with `main`. 7. **Only after verification is complete**: - Mark the draft PR ready for review, or diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index beeff3834..0a08a7923 100644 --- a/apps/cli/src/commands/self/index.ts +++ b/apps/cli/src/commands/self/index.ts @@ -1,34 +1,9 @@ -import { spawn } from 'node:child_process'; import { command, flag, subcommands } from 'cmd-ts'; import packageJson from '../../../package.json' with { type: 'json' }; +import { detectPackageManager, performSelfUpdate } from '../../self-update.js'; -/** - * Detect package manager from the script path. - * If the path contains '.bun', it was installed via bun; otherwise assume npm. - */ -export function detectPackageManagerFromPath(scriptPath: string): 'bun' | 'npm' { - if (scriptPath.includes('.bun')) { - return 'bun'; - } - return 'npm'; -} - -function detectPackageManager(): 'bun' | 'npm' { - return detectPackageManagerFromPath(process.argv[1] ?? ''); -} - -function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], shell: true }); - let stdout = ''; - child.stdout?.on('data', (data: Buffer) => { - process.stdout.write(data); - stdout += data.toString(); - }); - child.on('error', reject); - child.on('close', (code) => resolve({ exitCode: code ?? 1, stdout })); - }); -} +// Re-export for existing tests +export { detectPackageManagerFromPath } from '../../self-update.js'; const updateCommand = command({ name: 'update', @@ -56,41 +31,17 @@ const updateCommand = command({ console.log(`Current version: ${currentVersion}`); console.log(`Updating agentv using ${pm}...\n`); - const args = pm === 'npm' ? ['install', '-g', 'agentv@latest'] : ['add', '-g', 'agentv@latest']; - - try { - const result = await runCommand(pm, args); - - if (result.exitCode !== 0) { - console.error('\nUpdate failed.'); - process.exit(1); - } + const result = await performSelfUpdate({ pm, currentVersion }); - // Get new version - let newVersion: string | undefined; - try { - const versionResult = await runCommand('agentv', ['--version']); - newVersion = versionResult.stdout.trim(); - } catch { - // Ignore - version check is best-effort - } + if (!result.success) { + console.error('\nUpdate failed.'); + process.exit(1); + } - if (newVersion) { - console.log(`\nUpdate complete: ${currentVersion} → ${newVersion}`); - } else { - console.log('\nUpdate complete.'); - } - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('ENOENT') || error.message.includes('not found')) { - const alternative = pm === 'npm' ? 'bun' : 'npm'; - console.error(`Error: ${pm} not found. Try using --${alternative} flag.`); - } else { - console.error(`Error: ${error.message}`); - } - process.exit(1); - } - throw error; + if (result.newVersion) { + console.log(`\nUpdate complete: ${currentVersion} → ${result.newVersion}`); + } else { + console.log('\nUpdate complete.'); } }, }); diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts new file mode 100644 index 000000000..a7409d748 --- /dev/null +++ b/apps/cli/src/self-update.ts @@ -0,0 +1,104 @@ +/** + * Shared self-update logic for agentv. + * + * Used by both `agentv self update` and the version-check prompt + * when the installed version doesn't satisfy `required_version`. + * + * When called from the version-check prompt, a `versionRange` (from the + * project's `required_version` config) is passed through as the npm/bun + * version specifier (e.g., `agentv@">=4.1.0"`). This ensures the update + * respects the project's constraints and avoids unintended major-version jumps. + * + * When called from `agentv self update` (no range), it installs `@latest`. + * + * To add a new package manager: add a case to `detectPackageManagerFromPath()` + * and a corresponding install-args entry in `getInstallArgs()`. + */ + +import { spawn } from 'node:child_process'; + +/** + * Detect package manager from the script path. + * If the path contains '.bun', it was installed via bun; otherwise assume npm. + */ +export function detectPackageManagerFromPath(scriptPath: string): 'bun' | 'npm' { + if (scriptPath.includes('.bun')) { + return 'bun'; + } + return 'npm'; +} + +export function detectPackageManager(): 'bun' | 'npm' { + return detectPackageManagerFromPath(process.argv[1] ?? ''); +} + +function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> { + return new Promise((resolve, reject) => { + // No shell: true — args are passed directly to execvp, avoiding shell + // interpretation of semver operators (>, <, |) in version ranges. + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] }); + let stdout = ''; + child.stdout?.on('data', (data: Buffer) => { + process.stdout.write(data); + stdout += data.toString(); + }); + child.on('error', reject); + child.on('close', (code) => resolve({ exitCode: code ?? 1, stdout })); + }); +} + +function getInstallArgs(pm: 'bun' | 'npm', versionSpec: string): string[] { + const pkg = `agentv@${versionSpec}`; + return pm === 'npm' ? ['install', '-g', pkg] : ['add', '-g', pkg]; +} + +/** + * Run the self-update flow: install agentv globally using the detected + * (or specified) package manager. + * + * @param options.pm - Force a specific package manager + * @param options.currentVersion - Current installed version (for display) + * @param options.versionRange - Semver range from config (e.g., ">=4.1.0"). + * When provided, used as the npm/bun version specifier so the update + * stays within the project's constraints. When omitted, installs `@latest`. + */ +export async function performSelfUpdate(options?: { + pm?: 'bun' | 'npm'; + currentVersion?: string; + versionRange?: string; +}): Promise<{ success: boolean; currentVersion: string; newVersion?: string }> { + const pm = options?.pm ?? detectPackageManager(); + const currentVersion = options?.currentVersion ?? 'unknown'; + const versionSpec = options?.versionRange ?? 'latest'; + + const args = getInstallArgs(pm, versionSpec); + + try { + const result = await runCommand(pm, args); + + if (result.exitCode !== 0) { + return { success: false, currentVersion }; + } + + // Best-effort version check after update + let newVersion: string | undefined; + try { + const versionResult = await runCommand('agentv', ['--version']); + newVersion = versionResult.stdout.trim(); + } catch { + // Ignore - version check is best-effort + } + + return { success: true, currentVersion, newVersion }; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('ENOENT') || error.message.includes('not found')) { + const alternative = pm === 'npm' ? 'bun' : 'npm'; + console.error(`Error: ${pm} not found. Try using --${alternative} flag.`); + } else { + console.error(`Error: ${error.message}`); + } + } + return { success: false, currentVersion }; + } +} diff --git a/apps/cli/src/version-check.ts b/apps/cli/src/version-check.ts index b0356ce6e..d3bbaeed1 100644 --- a/apps/cli/src/version-check.ts +++ b/apps/cli/src/version-check.ts @@ -1,9 +1,11 @@ -import { coerce, satisfies, validRange } from 'semver'; +import { coerce, major, satisfies, validRange } from 'semver'; import packageJson from '../package.json' with { type: 'json' }; +import { performSelfUpdate } from './self-update.js'; const ANSI_YELLOW = '\u001b[33m'; const ANSI_RED = '\u001b[31m'; +const ANSI_GREEN = '\u001b[32m'; const ANSI_RESET = '\u001b[0m'; export interface VersionCheckResult { @@ -38,7 +40,10 @@ export function checkVersion(requiredVersion: string): VersionCheckResult { * - If the version satisfies the range, returns silently. * - If the range is malformed, prints an error and exits with code 1. * - If the version is below the range: - * - Interactive (TTY): warns and prompts to continue or abort. + * - Interactive (TTY): warns and prompts "Update now? (Y/n)". + * Y → runs self-update inline (constrained to the config range), + * then exits with a message to re-run the command. + * N → continues the command as-is. * - Non-interactive: warns to stderr, continues (unless strict). * - Strict mode: warns and exits with code 1. */ @@ -58,10 +63,10 @@ export async function enforceRequiredVersion( return; } - const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}\n Run \`agentv self update\` to upgrade.`; + const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}`; if (options?.strict) { - console.error(warning); + console.error(`${warning}\n Run \`agentv self update\` to upgrade.`); console.error( `${ANSI_RED}Aborting: --strict mode requires the installed version to satisfy the required range.${ANSI_RESET}`, ); @@ -70,17 +75,44 @@ export async function enforceRequiredVersion( if (process.stdin.isTTY && process.stdout.isTTY) { console.warn(warning); - const shouldContinue = await promptContinue(); - if (!shouldContinue) { - process.exit(1); + const shouldUpdate = await promptUpdate(); + if (shouldUpdate) { + await runInlineUpdate(result.currentVersion, result.requiredRange); } + // N → continue the command without interruption } else { // Non-interactive: warn to stderr and continue - process.stderr.write(`${warning}\n`); + process.stderr.write(`${warning}\n Run \`agentv self update\` to upgrade.\n`); } } -async function promptContinue(): Promise { +async function promptUpdate(): Promise { const { confirm } = await import('@inquirer/prompts'); - return confirm({ message: 'Continue anyway?', default: false }); + return confirm({ message: 'Update now?', default: true }); +} + +async function runInlineUpdate(currentVersion: string, versionRange: string): Promise { + // Cap at the current major version to avoid unintended breaking changes. + // e.g., if current is 4.14.2 and range is ">=4.1.0", install ">=4.1.0 <5.0.0" + // so that a hypothetical 5.0.0 is never pulled in by auto-update. + const currentMajor = major(coerce(currentVersion) ?? currentVersion); + const safeRange = `${versionRange} <${currentMajor + 1}.0.0`; + + console.log(''); + const result = await performSelfUpdate({ currentVersion, versionRange: safeRange }); + + if (!result.success) { + console.error(`${ANSI_RED}Update failed. Run \`agentv self update\` manually.${ANSI_RESET}`); + process.exit(1); + } + + if (result.newVersion) { + console.log( + `\n${ANSI_GREEN}Update complete: ${currentVersion} → ${result.newVersion}${ANSI_RESET}`, + ); + } else { + console.log(`\n${ANSI_GREEN}Update complete.${ANSI_RESET}`); + } + console.log('Please re-run your command.'); + process.exit(0); }