diff --git a/README.md b/README.md index f88c5cb54c..4b1fe6442b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ cd ~/my-project freebuff ``` +Update to the latest release at any time: + +```bash +freebuff update +``` + Then describe what you want. Freebuff finds the relevant files, makes changes, and runs the checks that matter for your project. ## Models diff --git a/cli/release-core/launcher.js b/cli/release-core/launcher.js index d830cf074e..99e5f56f44 100644 --- a/cli/release-core/launcher.js +++ b/cli/release-core/launcher.js @@ -188,6 +188,11 @@ function createLauncher(productConfig) { // Freebuff sessions remain open between turns. The launcher cannot know // when a restart is safe, so keep the process alive until the user exits. deferUpdatesUntilExit = false, + // Only the freebuff binary defines an `update` command. The codebuff and + // codecane binaries take a prompt as their first positional argument, so + // `codebuff update the readme` is a prompt there — intercepting it would + // install a release and drop what the user typed. + supportsUpdateCommand = false, tempDownloadDirName = `.${packageName}-download-temp`, // Tests only. os.homedir() ignores $HOME under `bun test`, so pointing HOME // at a temp dir is not enough to keep a test off the real ~/.config. @@ -1405,6 +1410,67 @@ function createLauncher(productConfig) { installStagedBinary(stagedBinary) } + /** + * `freebuff update`: install the latest release in the foreground. + * + * The background check stages an update while the session runs and installs + * it on exit (see checkForUpdates / installPendingUpdate). This is the same + * install, asked for explicitly, so it runs before the binary is spawned and + * nothing holds the files being replaced. + */ + async function runUpdateCommand(fetchLatestRelease = getLatestRelease) { + const currentVersion = getCurrentVersion() + if (currentVersion) { + console.log(`${displayName} ${currentVersion} is installed.`) + } else { + console.log(`${displayName} is not installed yet.`) + } + + const latestRelease = await fetchLatestRelease() + if (!latestRelease?.version) { + console.error('❌ Failed to determine the latest version') + console.error('Please check your internet connection and try again') + process.exit(1) + return + } + + if ( + currentVersion && + compareVersions(currentVersion, latestRelease.version) >= 0 + ) { + console.log(`✅ ${displayName} is already up to date.`) + return + } + + console.log(`Updating ${displayName} to ${latestRelease.version}...`) + + let stagedBinary + try { + stagedBinary = await stageBinary( + latestRelease.version, + getDownloadTargetKey(), + { binaryChecksums: latestRelease.binaryChecksums }, + ) + } catch (error) { + term.clearLine() + printDownloadFailure(error) + process.exit(1) + return + } + + try { + installStagedBinary(stagedBinary, { quiet: true }) + } catch (error) { + term.clearLine() + console.error(`❌ Could not install the update: ${error.message}`) + process.exit(1) + return + } + + term.clearLine() + console.log(`✅ Updated ${displayName} to ${latestRelease.version}.`) + } + function getRequiredWrapperVersion(currentVersion) { if ( !wrapperVersion || @@ -2277,7 +2343,33 @@ function createLauncher(productConfig) { return exitListener } + /** + * argv entries that consume the next value. Without this, `--cwd update` + * (a directory actually named `update`) would read as the command. + */ + const FLAGS_WITH_VALUES = new Set(['--cwd']) + + function isUpdateCommand(argv = process.argv.slice(2)) { + if (!supportsUpdateCommand) return false + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--') return false + if (FLAGS_WITH_VALUES.has(arg)) { + i++ + continue + } + if (arg.startsWith('-')) continue + return arg === 'update' + } + return false + } + async function main() { + if (isUpdateCommand()) { + await runUpdateCommand() + return + } + for (const line of startupBanner) { console.log(line) } @@ -2317,6 +2409,8 @@ function createLauncher(productConfig) { adoptOrphanedStagedUpdates, printCrashDiagnostics, checkForUpdates, + isUpdateCommand, + runUpdateCommand, spawnInstalledBinary, attachExitHandler, getDefaultTargetKey, diff --git a/cli/src/__tests__/cli-args.test.ts b/cli/src/__tests__/cli-args.test.ts index c8c3db485b..76100641d5 100644 --- a/cli/src/__tests__/cli-args.test.ts +++ b/cli/src/__tests__/cli-args.test.ts @@ -155,6 +155,17 @@ describe('Freebuff CLI Argument Parsing', () => { expect(result.initialMode).toBe('LITE') }) + test('accepts update as a command, not an unexpected argument', () => { + const result = parseArgs({ + argv: ['node', 'freebuff', 'update'], + isFreebuff: true, + version: '1.0.0', + }) + + expect(result.command).toBe('update') + expect(result.initialPrompt).toBeNull() + }) + test('allows cwd before the login command', () => { const result = parseArgs({ argv: ['node', 'freebuff', '--cwd', '/tmp', 'login'], diff --git a/cli/src/__tests__/release/update-command.test.ts b/cli/src/__tests__/release/update-command.test.ts new file mode 100644 index 0000000000..d06f443c90 --- /dev/null +++ b/cli/src/__tests__/release/update-command.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const require = createRequire(import.meta.url) +const { createLauncher } = require('../../../release-core/launcher.js') +const tar = require('tar') as typeof import('tar') + +const binaryName = process.platform === 'win32' ? 'freebuff.exe' : 'freebuff' +const target = `${process.platform}-${process.arch}` + +type Fixture = { + configDir: string + output: string[] + errors: string[] + exits: number[] + requests: () => number + setInstalled: (version: string) => void + installedVersion: () => string | null + installedBinary: () => string + update: (options?: { + version?: string | null + checksum?: string | null + }) => Promise + cleanup: () => Promise +} + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()!() + } +}) + +async function createFixture(installedVersion: string): Promise { + const root = mkdtempSync(join(tmpdir(), 'freebuff-update-command-')) + const configDir = join(root, 'config') + const archiveDir = join(root, 'archive') + mkdirSync(configDir) + mkdirSync(archiveDir) + writeFileSync(join(archiveDir, binaryName), 'new binary') + writeFileSync(join(archiveDir, 'tree-sitter.wasm'), 'new wasm') + const archivePath = join(root, 'release.tar.gz') + await tar.c({ cwd: archiveDir, file: archivePath, gzip: true }, [ + binaryName, + 'tree-sitter.wasm', + ]) + const archive = readFileSync(archivePath) + const checksum = createHash('sha256').update(archive).digest('hex') + + let requestCount = 0 + const server = createServer((_request, response) => { + requestCount++ + response.writeHead(200, { 'content-length': archive.length }) + response.end(archive) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const originalEnv = { + app: process.env.NEXT_PUBLIC_CODEBUFF_APP_URL, + noProxy: process.env.NO_PROXY, + target: process.env.FREEBUFF_BINARY_TARGET, + } + process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + process.env.NO_PROXY = '127.0.0.1' + process.env.FREEBUFF_BINARY_TARGET = target + + const output: string[] = [] + const errors: string[] = [] + const exits: number[] = [] + const originals = { + log: console.log, + error: console.error, + exit: process.exit, + } + console.log = (...args) => output.push(args.join(' ')) + console.error = (...args) => errors.push(args.join(' ')) + process.exit = ((code: number) => { + exits.push(code) + }) as typeof process.exit + + const launcher = createLauncher({ + packageName: 'freebuff', + displayName: 'Freebuff', + configDir, + deferUpdatesUntilExit: true, + supportsUpdateCommand: true, + }).__testing + const config = launcher.CONFIG + const setInstalled = (version: string) => { + writeFileSync(config.binaryPath, `binary ${version}`) + writeFileSync(config.metadataPath, JSON.stringify({ version, target })) + } + setInstalled(installedVersion) + + const cleanup = async () => { + console.log = originals.log + console.error = originals.error + process.exit = originals.exit + for (const [key, value] of Object.entries({ + NEXT_PUBLIC_CODEBUFF_APP_URL: originalEnv.app, + NO_PROXY: originalEnv.noProxy, + FREEBUFF_BINARY_TARGET: originalEnv.target, + })) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(root, { recursive: true, force: true }) + } + cleanups.push(cleanup) + + return { + configDir, + output, + errors, + exits, + requests: () => requestCount, + setInstalled, + installedVersion: () => { + try { + return JSON.parse(readFileSync(config.metadataPath, 'utf8')).version + } catch { + return null + } + }, + installedBinary: () => readFileSync(config.binaryPath, 'utf8'), + update: ({ + version = '2.0.0', + checksum: digest = checksum, + }: { version?: string | null; checksum?: string | null } = {}) => + launcher.runUpdateCommand(async () => + version === null + ? null + : { + version, + binaryChecksums: digest ? { [target]: digest } : null, + }, + ), + cleanup, + } +} + +describe('freebuff update: command detection', () => { + const { isUpdateCommand } = createLauncher({ + packageName: 'freebuff', + displayName: 'Freebuff', + supportsUpdateCommand: true, + }).__testing + + test('detects the command as the first positional argument', () => { + expect(isUpdateCommand(['update'])).toBe(true) + expect(isUpdateCommand(['--cwd', '/tmp', 'update'])).toBe(true) + expect(isUpdateCommand(['update', '--cwd', '/tmp'])).toBe(true) + }) + + test('ignores flags and their values', () => { + expect(isUpdateCommand([])).toBe(false) + expect(isUpdateCommand(['--version'])).toBe(false) + expect(isUpdateCommand(['--help'])).toBe(false) + // A directory named `update` is not the command. + expect(isUpdateCommand(['--cwd', 'update'])).toBe(false) + expect(isUpdateCommand(['continue'])).toBe(false) + expect(isUpdateCommand(['--', 'update'])).toBe(false) + }) + + test('is off for the wrappers that take a prompt as the first argument', () => { + const codebuff = createLauncher({ + packageName: 'codebuff', + displayName: 'Codebuff', + }).__testing + + // `codebuff update the readme` is a prompt, not a command. + expect(codebuff.isUpdateCommand(['update', 'the', 'readme'])).toBe(false) + }) +}) + +describe('freebuff update: installing', () => { + test('is a no-op when the installed version is current', async () => { + const fixture = await createFixture('2.0.0') + + await fixture.update({ version: '2.0.0' }) + + expect(fixture.requests()).toBe(0) + expect(fixture.exits).toEqual([]) + expect(fixture.output.join('\n')).toContain('already up to date') + expect(fixture.installedVersion()).toBe('2.0.0') + }) + + test('downloads, verifies and installs the latest release', async () => { + const fixture = await createFixture('1.0.0') + + await fixture.update() + + expect(fixture.requests()).toBe(1) + expect(fixture.exits).toEqual([]) + expect(fixture.installedVersion()).toBe('2.0.0') + expect(fixture.installedBinary()).toBe('new binary') + expect(existsSync(join(fixture.configDir, 'tree-sitter.wasm'))).toBe(true) + expect(fixture.output.join('\n')).toContain('Updated Freebuff to 2.0.0') + // The staging directory is cleaned up once the binary is installed. + expect( + readdirSync(fixture.configDir).filter((name) => + name.startsWith('.freebuff-download-temp'), + ), + ).toEqual([]) + }) + + test('fails without touching the installed binary when the latest version is unknown', async () => { + const fixture = await createFixture('1.0.0') + + await fixture.update({ version: null }) + + expect(fixture.exits).toEqual([1]) + expect(fixture.installedVersion()).toBe('1.0.0') + expect(fixture.installedBinary()).toBe('binary 1.0.0') + }) + + test('fails closed when the download does not match the published checksum', async () => { + const fixture = await createFixture('1.0.0') + + await fixture.update({ checksum: '0'.repeat(64) }) + + expect(fixture.exits).toEqual([1]) + expect(fixture.installedVersion()).toBe('1.0.0') + expect(fixture.installedBinary()).toBe('binary 1.0.0') + }) + + test('installs the latest release when nothing is installed yet', async () => { + const fixture = await createFixture('1.0.0') + rmSync(join(fixture.configDir, binaryName), { force: true }) + + await fixture.update() + + expect(fixture.exits).toEqual([]) + expect(fixture.installedVersion()).toBe('2.0.0') + expect(fixture.output.join('\n')).toContain('is not installed yet') + }) +}) diff --git a/cli/src/__tests__/release/wrapper-safety.test.ts b/cli/src/__tests__/release/wrapper-safety.test.ts index 26ac6f9062..ed0b80c047 100644 --- a/cli/src/__tests__/release/wrapper-safety.test.ts +++ b/cli/src/__tests__/release/wrapper-safety.test.ts @@ -89,6 +89,7 @@ const wrappers = [ displayName: 'Freebuff', telemetryEvent: 'cli.update_freebuff_failed', deferUpdatesUntilExit: true, + supportsUpdateCommand: true, }, }, ] diff --git a/cli/src/cli-args.ts b/cli/src/cli-args.ts index 1ce73347cc..2d8b976729 100644 --- a/cli/src/cli-args.ts +++ b/cli/src/cli-args.ts @@ -68,7 +68,10 @@ export function parseArgs({ "Load this repository's .agents files and mcp.json without asking (for CI)", ) .addArgument( - new Argument('[command]', 'Command to run').choices(['login']), + new Argument('[command]', 'Command to run').choices([ + 'login', + 'update', + ]), ) .helpOption('-h, --help', 'Show this help message') } else { diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 3ab9686443..1254fa2dec 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -264,9 +264,24 @@ async function main(): Promise { } = parseArgs() const isLoginCommand = command === 'login' + // Only freebuff has an `update` command; for the other products the first + // positional argument is the prompt, so `codebuff update the readme` must + // reach the agent instead of exiting here. + const isUpdateCommand = IS_FREEBUFF && command === 'update' const isPublishCommand = command === 'publish' const hasAgentOverride = Boolean(agent?.trim()) + // The npm wrapper (`freebuff` on PATH) runs `update` itself, before it + // spawns this binary (see cli/release-core/launcher.js). Landing here means + // the binary was started without that wrapper, so npm is the way to update. + if (isUpdateCommand) { + console.error( + 'Freebuff updates through its npm launcher, which is not running here.', + ) + console.error('Update with: npm install -g freebuff@latest') + process.exit(1) + } + await initializeApp({ cwd }) // Set the auth token for the API client diff --git a/freebuff/cli/release/index.js b/freebuff/cli/release/index.js index 3ff20107d3..7c9c9da4d9 100644 --- a/freebuff/cli/release/index.js +++ b/freebuff/cli/release/index.js @@ -30,6 +30,7 @@ const launcher = createLauncher({ binaryChecksums: packageJson.binaryChecksums ?? null, telemetryEvent: 'cli.update_freebuff_failed', deferUpdatesUntilExit: true, + supportsUpdateCommand: true, }) module.exports = launcher