From 24971327f3bf8718a677cfa140099ba1d496405c Mon Sep 17 00:00:00 2001 From: Santhosh Balasa Date: Sun, 25 Oct 2026 14:57:20 +0000 Subject: [PATCH 1/2] Add a foreground `freebuff update` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users could only pick up a new release by waiting for the launcher's background check or restarting the CLI. The npm wrapper already knows how to fetch, verify and install a release, so `freebuff update` now runs that same install in the foreground and reports the result. A binary started without the wrapper says so and points at npm instead of crashing on an unknown command. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- README.md | 6 + cli/release-core/launcher.js | 88 +++++++ cli/src/__tests__/cli-args.test.ts | 11 + .../__tests__/release/update-command.test.ts | 242 ++++++++++++++++++ cli/src/cli-args.ts | 5 +- cli/src/index.tsx | 12 + 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 cli/src/__tests__/release/update-command.test.ts 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..f7a4d58849 100644 --- a/cli/release-core/launcher.js +++ b/cli/release-core/launcher.js @@ -1405,6 +1405,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 +2338,32 @@ 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)) { + 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 +2403,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..d1891f9922 --- /dev/null +++ b/cli/src/__tests__/release/update-command.test.ts @@ -0,0 +1,242 @@ +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, + }).__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', + }).__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) + }) +}) + +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/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..31f4e92c05 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -264,9 +264,21 @@ async function main(): Promise { } = parseArgs() const isLoginCommand = command === 'login' + const isUpdateCommand = 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 From 63427637fa3d73e83d0f7a56e029ca2ec07c52d4 Mon Sep 17 00:00:00 2001 From: Santhosh Balasa Date: Sun, 25 Oct 2026 18:03:13 +0000 Subject: [PATCH 2/2] Scope the freebuff update command to the freebuff wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher and the binary's command check are shared with codebuff and codecane, where the first positional argument is the prompt. Gating on a wrapper config flag keeps `codebuff update the readme` from installing a release and dropping the prompt. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- cli/release-core/launcher.js | 6 ++++++ cli/src/__tests__/release/update-command.test.ts | 12 ++++++++++++ cli/src/__tests__/release/wrapper-safety.test.ts | 1 + cli/src/index.tsx | 5 ++++- freebuff/cli/release/index.js | 1 + 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cli/release-core/launcher.js b/cli/release-core/launcher.js index f7a4d58849..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. @@ -2345,6 +2350,7 @@ function createLauncher(productConfig) { 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 diff --git a/cli/src/__tests__/release/update-command.test.ts b/cli/src/__tests__/release/update-command.test.ts index d1891f9922..d06f443c90 100644 --- a/cli/src/__tests__/release/update-command.test.ts +++ b/cli/src/__tests__/release/update-command.test.ts @@ -98,6 +98,7 @@ async function createFixture(installedVersion: string): Promise { displayName: 'Freebuff', configDir, deferUpdatesUntilExit: true, + supportsUpdateCommand: true, }).__testing const config = launcher.CONFIG const setInstalled = (version: string) => { @@ -159,6 +160,7 @@ describe('freebuff update: command detection', () => { const { isUpdateCommand } = createLauncher({ packageName: 'freebuff', displayName: 'Freebuff', + supportsUpdateCommand: true, }).__testing test('detects the command as the first positional argument', () => { @@ -176,6 +178,16 @@ describe('freebuff update: command detection', () => { 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', () => { 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/index.tsx b/cli/src/index.tsx index 31f4e92c05..1254fa2dec 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -264,7 +264,10 @@ async function main(): Promise { } = parseArgs() const isLoginCommand = command === 'login' - const isUpdateCommand = command === 'update' + // 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()) 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