mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
feat(update): one codeburn update command for CLI, menu bar and desktop (#1740)
Adds codeburn update [--check] [--cli|--menubar|--desktop|--all] [--local] [--json] with a fixed JSON step/result contract that the desktop app, menu bar and Windows tray call, so every surface updates in one click without Terminal. Also documents the npm-first release order and adds always-latest /download links.
This commit is contained in:
parent
7431a34782
commit
679d2f715b
8 changed files with 1211 additions and 25 deletions
16
README.md
16
README.md
|
|
@ -32,16 +32,16 @@
|
|||
<p align="center"><sub>To keep it: <code>npm install -g codeburn</code> or <code>brew install codeburn</code>. Needs Node.js 22.13+.</sub></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-0.9.26-arm64.dmg"><img src="https://img.shields.io/badge/macOS-Apple_Silicon-F97316?logo=apple&logoColor=white" alt="Download CodeBurn for macOS (Apple Silicon)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-0.9.26.dmg"><img src="https://img.shields.io/badge/macOS-Intel-F97316?logo=apple&logoColor=white" alt="Download CodeBurn for macOS (Intel)" /></a>
|
||||
<a href="https://codeburn.app/download/mac-arm64"><img src="https://img.shields.io/badge/macOS-Apple_Silicon-F97316?logo=apple&logoColor=white" alt="Download CodeBurn for macOS (Apple Silicon)" /></a>
|
||||
<a href="https://codeburn.app/download/mac-x64"><img src="https://img.shields.io/badge/macOS-Intel-F97316?logo=apple&logoColor=white" alt="Download CodeBurn for macOS (Intel)" /></a>
|
||||
<a href="https://apps.microsoft.com/detail/9P0R4ZL5XMB8"><img src="https://img.shields.io/badge/Windows-Microsoft_Store-F97316?logo=microsoft&logoColor=white" alt="Get CodeBurn from the Microsoft Store" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-Setup-0.9.26.exe"><img src="https://img.shields.io/badge/Windows-.exe-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows installer (.exe)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/codeburn-desktop_0.9.26_amd64.deb"><img src="https://img.shields.io/badge/Linux-.deb-F97316?logo=debian&logoColor=white" alt="Download CodeBurn for Linux (.deb)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/codeburn-desktop-0.9.26.x86_64.rpm"><img src="https://img.shields.io/badge/Linux-.rpm-F97316?logo=redhat&logoColor=white" alt="Download CodeBurn for Linux (.rpm)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-0.9.26.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage-F97316?logo=linux&logoColor=white" alt="Download CodeBurn for Linux (AppImage)" /></a>
|
||||
<a href="https://codeburn.app/download/windows-exe"><img src="https://img.shields.io/badge/Windows-.exe-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows installer (.exe)" /></a>
|
||||
<a href="https://codeburn.app/download/linux-deb"><img src="https://img.shields.io/badge/Linux-.deb-F97316?logo=debian&logoColor=white" alt="Download CodeBurn for Linux (.deb)" /></a>
|
||||
<a href="https://codeburn.app/download/linux-rpm"><img src="https://img.shields.io/badge/Linux-.rpm-F97316?logo=redhat&logoColor=white" alt="Download CodeBurn for Linux (.rpm)" /></a>
|
||||
<a href="https://codeburn.app/download/linux-appimage"><img src="https://img.shields.io/badge/Linux-AppImage-F97316?logo=linux&logoColor=white" alt="Download CodeBurn for Linux (AppImage)" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>Desktop app 0.9.26. The macOS builds are signed with a Developer ID and notarized by Apple.</sub></p>
|
||||
<p align="center"><sub>The macOS builds are signed with a Developer ID and notarized by Apple.</sub></p>
|
||||
|
||||
## The problem
|
||||
|
||||
|
|
@ -142,7 +142,7 @@ codeburn menubar
|
|||
|
||||
On Linux the same view lives in the top panel, as a GNOME Shell extension.
|
||||
|
||||
The desktop app runs on Windows. Install it from the [Microsoft Store](https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is the recommended way and keeps itself up to date, or take the [direct installer](https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-Setup-0.9.26.exe). The Windows tray app shows today's cost next to the clock, the same way the macOS menu bar does, and clicking it opens the same popover. The Capacity Dock is there too, from the tray menu or the app's Plugins page. If you run your agents inside WSL, CodeBurn reads the distro's home directory as well as your Windows profile, so sessions you ran in Linux are counted without you installing anything twice.
|
||||
The desktop app runs on Windows. Install it from the [Microsoft Store](https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is the recommended way and keeps itself up to date, or take the [direct installer](https://codeburn.app/download/windows-exe). The Windows tray app shows today's cost next to the clock, the same way the macOS menu bar does, and clicking it opens the same popover. The Capacity Dock is there too, from the tray menu or the app's Plugins page. If you run your agents inside WSL, CodeBurn reads the distro's home directory as well as your Windows profile, so sessions you ran in Linux are counted without you installing anything twice.
|
||||
|
||||
Setup for all three platforms, including WSL, is in [Menu bar and tray](docs/menubar.md).
|
||||
|
||||
|
|
|
|||
16
RELEASING.md
16
RELEASING.md
|
|
@ -37,6 +37,22 @@ Verify that the build completes without errors:
|
|||
npm run build
|
||||
```
|
||||
|
||||
## Release Order
|
||||
|
||||
Every surface's updater reads its own channel, and the menubar and `codeburn update` install the CLI from npm first. Publish in this order, so nothing points at a version a later step has not published yet. 0.9.26 went out the other way round, and menubars that checked in between were told to install a CLI npm did not have yet (npm 404):
|
||||
|
||||
1. `npm publish`.
|
||||
2. Wait until the tarball downloads: run `npm pack codeburn@<version>` in a temp directory until it writes `codeburn-<version>.tgz`.
|
||||
3. Homebrew: `brew bump-formula-pr` (see "Homebrew Core") and bump the tap (CLI step 5b).
|
||||
4. GitHub `v<version>` release.
|
||||
5. Push `mac-v<version>` (writes `menubar-latest.json`).
|
||||
6. Push `windows-v<version>` (writes `windows-latest.json`).
|
||||
7. Publish the `desktop-v<version>` release (writes `latest*.yml`).
|
||||
|
||||
Push tags one at a time and wait for each workflow to start. GitHub drops the push events when more than three tags arrive in one push, so `git push --tags` can leave a release with no workflow run.
|
||||
|
||||
The README and the website link to `https://codeburn.app/download/<target>`, which redirects to whatever the update feeds name. Neither needs a version bump per release.
|
||||
|
||||
## CLI Release Process
|
||||
|
||||
### 1. Update the Version
|
||||
|
|
|
|||
21
src/main.ts
21
src/main.ts
|
|
@ -1,6 +1,7 @@
|
|||
import { isAbsolute } from 'path'
|
||||
import { Command, Option } from 'commander'
|
||||
import { installMenubarApp, uninstallMenubarApp } from './menubar-installer.js'
|
||||
import { COMPONENTS, defaultUpdateDeps, runUpdate, type Component } from './update.js'
|
||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||
import { findUnpricedModels, modelRowKey, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setFlatRateModels, setFlatRateRemoved, setProxyPaths, normalizeProxyPath, unpricedModelHint, isBuiltInFlatRateModel, isSameFlatRateModel, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, getFlatRateModelsConfigHash, getPricingGenerationKey } from './models.js'
|
||||
import { cachedProjectIdentitiesForRange } from './daily-cache.js'
|
||||
|
|
@ -1624,6 +1625,26 @@ program
|
|||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update the global CLI, the menubar app and the desktop app')
|
||||
.option('--check', 'Report what is out of date; change nothing')
|
||||
.option('--cli', 'The global codeburn command (npm or Homebrew)')
|
||||
.option('--menubar', 'The macOS menubar app, to this CLI\'s version')
|
||||
.option('--desktop', 'The desktop app, by handing off to its own updater')
|
||||
.option('--all', 'Every component (the default)')
|
||||
.option('--local', 'With --check: no network, compare against this CLI\'s version')
|
||||
.option('--json', 'One JSON object per line on stdout')
|
||||
.action(async (opts: Partial<Record<Component | 'all' | 'check' | 'local' | 'json', boolean>>) => {
|
||||
const picked = COMPONENTS.filter(c => opts[c])
|
||||
process.exitCode = await runUpdate({
|
||||
components: opts.all || picked.length === 0 ? COMPONENTS : picked,
|
||||
check: opts.check,
|
||||
local: opts.local,
|
||||
json: opts.json,
|
||||
}, defaultUpdateDeps(version))
|
||||
})
|
||||
|
||||
program
|
||||
.command('currency [code]')
|
||||
.description('Set display currency (e.g. codeburn currency GBP)')
|
||||
|
|
|
|||
|
|
@ -18,7 +18,17 @@ import {
|
|||
/// Public GitHub repo that hosts macOS release builds. Normal installs use direct
|
||||
/// versioned release asset URLs; the API scan is only a fallback for missing assets.
|
||||
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20'
|
||||
const RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download'
|
||||
const DEFAULT_RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download'
|
||||
/// `CODEBURN_RELEASE_BASE` points installs and update checks at a local feed server for the
|
||||
/// update end-to-end test. Only loopback http or another GitHub releases/download base is taken;
|
||||
/// every signature pin still applies, so an override can only offer builds we signed.
|
||||
export function resolveReleaseBase(value: string | undefined): string {
|
||||
const loopback = /^http:\/\/127\.0\.0\.1:(\d{1,5})$/.exec(value ?? '')
|
||||
if (loopback && Number(loopback[1]) > 0 && Number(loopback[1]) < 65536) return value!
|
||||
if (/^https:\/\/github\.com\/[\w-]+\/[\w.-]+\/releases\/download$/.test(value ?? '')) return value!
|
||||
return DEFAULT_RELEASE_DOWNLOAD_BASE
|
||||
}
|
||||
export const RELEASE_DOWNLOAD_BASE = resolveReleaseBase(process.env['CODEBURN_RELEASE_BASE'])
|
||||
/// Written by release-menubar.yml to the rolling `update-feeds` prerelease after a mac-v*
|
||||
/// release's assets are verified, so "latest" never depends on which release line GitHub calls Latest.
|
||||
export const MAC_FEED_URL = `${RELEASE_DOWNLOAD_BASE}/update-feeds/menubar-latest.json`
|
||||
|
|
@ -153,7 +163,7 @@ function matchesNoProxy(hostname: string, noProxy?: string): boolean {
|
|||
})
|
||||
}
|
||||
|
||||
function fetchWithProxy(url: string, options: FetchOptions = {}) {
|
||||
export function fetchWithProxy(url: string, options: FetchOptions = {}) {
|
||||
const proxyUrl = resolveProxyUrlForUrl(url)
|
||||
const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined
|
||||
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options)
|
||||
|
|
@ -203,6 +213,13 @@ export function resolveVersionedMenubarReleaseAssets(cliVersion: string, spec: R
|
|||
}
|
||||
}
|
||||
|
||||
/// The fallbacks can only name what is published, which trails the CLI during a release, so
|
||||
/// taking it unconditionally would replace a newer installed copy with an older one.
|
||||
export function isFallbackUpgrade(assets: ResolvedAssets, installedVersion: string | null, spec: ReleaseSpec = MAC_RELEASE): boolean {
|
||||
if (!installedVersion) return true
|
||||
return compareMenubarVersions(assets.release.tag_name.slice(spec.tagPrefix.length), installedVersion) > 0
|
||||
}
|
||||
|
||||
export function shouldFallbackToReleaseApi(status: number): boolean {
|
||||
return status === 404 || status === 410
|
||||
}
|
||||
|
|
@ -713,6 +730,14 @@ async function captureCommand(command: string, args: string[]): Promise<string>
|
|||
})
|
||||
}
|
||||
|
||||
export async function readBundleVersion(appPath: string): Promise<string | null> {
|
||||
return captureCommand('/usr/libexec/PlistBuddy', [
|
||||
'-c',
|
||||
'Print :CFBundleShortVersionString',
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
]).catch(() => null)
|
||||
}
|
||||
|
||||
async function verifyBundleIdentity(appPath: string): Promise<void> {
|
||||
const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [
|
||||
'-c',
|
||||
|
|
@ -798,7 +823,8 @@ export async function hasRunnableRecordedCli(recordPath: string = PERSISTED_CLI_
|
|||
/// own ancestors by default, and the app's Update button runs this install as the app's
|
||||
/// child, so without it the app asking for the update is never seen (#1541).
|
||||
export async function runningAppPids(name: string = APP_PROCESS_NAME): Promise<number[]> {
|
||||
const out = await captureCommand('/usr/bin/pgrep', ['-a', '-x', name]).catch(() => '')
|
||||
// -U: another user's menubar (fast user switching) is not ours to stop or wait for.
|
||||
const out = await captureCommand('/usr/bin/pgrep', ['-a', '-U', String(process.getuid!()), '-x', name]).catch(() => '')
|
||||
return out.split('\n').map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
||||
}
|
||||
|
||||
|
|
@ -829,21 +855,34 @@ async function killRunningApp(pids: number[]): Promise<void> {
|
|||
}
|
||||
|
||||
/// Runs detached in its own session, so nothing that happens to the app or to this CLI
|
||||
/// reaches it. Waits for the CLI ($1) to exit first: the app reads the CLI's stderr and waits
|
||||
/// on its exit status, so the app must outlive it. Then asks the app ($2) to go, forces it
|
||||
/// after 5s, and launches the new bundle only once no old copy is left for `open` to
|
||||
/// re-activate instead.
|
||||
const RELAUNCH_SCRIPT = `cli=$1 pids=$2; shift 2
|
||||
alive() { for p in $pids; do kill -0 "$p" 2>/dev/null && return 0; done; return 1; }
|
||||
i=0; while kill -0 "$cli" 2>/dev/null && [ $i -lt 100 ]; do sleep 0.1; i=$((i+1)); done
|
||||
/// reaches it. Waits for the CLI and every process between it and the app ($1) to exit first:
|
||||
/// the app reads the CLI's output and waits on its exit status, so the app must outlive them.
|
||||
/// Then asks the app ($2) to go, forces it after 5s, and launches the new bundle only once no
|
||||
/// old copy is left for `open` to re-activate instead. The 120s cap covers the desktop step
|
||||
/// that `codeburn update` runs after this install.
|
||||
const RELAUNCH_SCRIPT = `waits=$1 pids=$2; shift 2
|
||||
alive() { for p in $1; do kill -0 "$p" 2>/dev/null && return 0; done; return 1; }
|
||||
i=0; while alive "$waits" && [ $i -lt 1200 ]; do sleep 0.1; i=$((i+1)); done
|
||||
kill -TERM $pids 2>/dev/null
|
||||
i=0; while alive && [ $i -lt 50 ]; do sleep 0.1; i=$((i+1)); done
|
||||
i=0; while alive "$pids" && [ $i -lt 50 ]; do sleep 0.1; i=$((i+1)); done
|
||||
kill -KILL $pids 2>/dev/null
|
||||
i=0; while alive && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done
|
||||
i=0; while alive "$pids" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done
|
||||
exec "$@"`
|
||||
|
||||
async function relaunchAfterExit(appPids: number[], launch: string[]): Promise<void> {
|
||||
const child = spawn('/bin/sh', ['-c', RELAUNCH_SCRIPT, 'codeburn-relaunch', String(process.pid), appPids.join(' '), ...launch], {
|
||||
/// This process and every ancestor below the app. After a CLI update `codeburn update` re-runs
|
||||
/// itself, so the app's own child is the parent engine, which still prints the final result
|
||||
/// once this process has exited.
|
||||
export function pidsBelowApp(pid: number, appPids: number[], ancestors: Set<number>): number[] {
|
||||
const out = [pid]
|
||||
for (const p of ancestors) {
|
||||
if (appPids.includes(p)) break
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function relaunchAfterExit(waitPids: number[], appPids: number[], launch: string[]): Promise<void> {
|
||||
const child = spawn('/bin/sh', ['-c', RELAUNCH_SCRIPT, 'codeburn-relaunch', waitPids.join(' '), appPids.join(' '), ...launch], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
|
|
@ -875,7 +914,7 @@ export async function replaceAndRelaunch(
|
|||
await placeMenubarBundle(unpackedApp, targetPath)
|
||||
// -n: the old copy is gone by now, but Launch Services can take a moment to notice,
|
||||
// and a plain `open` in that window re-activates it instead of launching anything.
|
||||
await relaunchAfterExit(running, [...(hooks.launchCommand ?? ['/usr/bin/open', '-n']), targetPath])
|
||||
await relaunchAfterExit(pidsBelowApp(process.pid, running, ancestors), running, [...(hooks.launchCommand ?? ['/usr/bin/open', '-n']), targetPath])
|
||||
console.log('CodeBurn Menubar will restart into the new version.')
|
||||
return
|
||||
}
|
||||
|
|
@ -1387,7 +1426,7 @@ export function storeAppUserModelId(installLocation: string): string | undefined
|
|||
return match ? `${match[1]}_${match[2]}!${STORE_APP_ID}` : undefined
|
||||
}
|
||||
|
||||
async function queryStoreInstallLocation(env: NodeJS.ProcessEnv): Promise<string> {
|
||||
export async function queryStoreInstallLocation(env: NodeJS.ProcessEnv): Promise<string> {
|
||||
// Same rule msiexec and reg follow above: an absolute System32 path, never a bare name.
|
||||
const powershell = resolveSystem32Path('WindowsPowerShell\\v1.0\\powershell.exe', env)
|
||||
return captureBounded(powershell, [
|
||||
|
|
@ -1608,6 +1647,10 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise<Instal
|
|||
if (!cliVersion || !isMissingDirectAssetError(err)) throw err
|
||||
log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`)
|
||||
assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch)
|
||||
if (installed && !isFallbackUpgrade(assets, installed.version, WINDOWS_RELEASE)) {
|
||||
log(`The newest published CodeBurn Menubar (${assets.release.tag_name}) is not newer than the installed ${installed.version}; leaving it alone.`)
|
||||
return { installedPath: installed.exePath, launched: false }
|
||||
}
|
||||
msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log)
|
||||
}
|
||||
|
||||
|
|
@ -1691,6 +1734,11 @@ export async function installMenubarApp(options: InstallOptions = {}): Promise<I
|
|||
if (!cliVersion || !isMissingDirectAssetError(err)) throw err
|
||||
console.log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`)
|
||||
assets = await fetchLatestMacReleaseAssets()
|
||||
const installedVersion = installedPath ? await readBundleVersion(installedPath) : null
|
||||
if (installedPath && !isFallbackUpgrade(assets, installedVersion)) {
|
||||
console.log(`The newest published CodeBurn Menubar (${assets.release.tag_name}) is not newer than the installed ${installedVersion}; leaving it alone.`)
|
||||
return { installedPath, launched: false }
|
||||
}
|
||||
unpackedApp = await stageMenubarApp(assets, stagingDir)
|
||||
}
|
||||
|
||||
|
|
|
|||
586
src/update.ts
Normal file
586
src/update.ts
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { access, link, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, platform as osPlatform } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
|
||||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
import {
|
||||
RELEASE_DOWNLOAD_BASE,
|
||||
compareMenubarVersions,
|
||||
fetchWithProxy,
|
||||
installMenubarApp,
|
||||
pidIsLive,
|
||||
queryStoreInstallLocation,
|
||||
readBundleVersion,
|
||||
resolveMacInstallTarget,
|
||||
resolveSystem32Path,
|
||||
} from './menubar-installer.js'
|
||||
import {
|
||||
buildPersistentCodeburnLookupPath,
|
||||
isTransientNpxPath,
|
||||
resolvePersistentCodeburnPathFromPath,
|
||||
} from './persistent-codeburn.js'
|
||||
|
||||
export type Component = 'cli' | 'menubar' | 'desktop'
|
||||
export const COMPONENTS: Component[] = ['cli', 'menubar', 'desktop']
|
||||
export type StepState = 'start' | 'progress' | 'done' | 'skipped' | 'error'
|
||||
export type StepCode =
|
||||
| 'ok' | 'up_to_date' | 'not_installed' | 'managed' | 'not_propagated' | 'permission'
|
||||
| 'no_manager' | 'download_fail' | 'verify_fail' | 'install_fail' | 'busy' | 'timeout'
|
||||
export type StepEvent = {
|
||||
type: 'step'
|
||||
component: Component
|
||||
state: StepState
|
||||
from?: string
|
||||
to?: string
|
||||
code?: StepCode
|
||||
message: string
|
||||
command?: string
|
||||
}
|
||||
/// `installed` is null when the component is not installed; `latest` is null when it could not
|
||||
/// be read. A component is behind only when both are strings and latest > installed numerically.
|
||||
/// For a Homebrew CLI `latest` is the formula version, so a lagging formula never reads as an
|
||||
/// update the click cannot apply. `code` is the component's final step code.
|
||||
export type ComponentResult = { installed: string | null; latest: string | null; state: StepState; code: StepCode; method?: string }
|
||||
export type ResultEvent = { type: 'result'; ok: boolean; components: Partial<Record<Component, ComponentResult>> }
|
||||
|
||||
export type CliMethod = 'npm' | 'brew' | 'pnpm' | 'bun' | 'volta' | 'managed' | 'unknown'
|
||||
export type GlobalCli = { launcher: string; method: CliMethod; root: string | null; version: string | null }
|
||||
export type RunResult = { code: number | null; stdout: string; stderr: string; timedOut: boolean }
|
||||
export type RunOptions = { timeoutMs: number; env?: NodeJS.ProcessEnv; verbatim?: boolean }
|
||||
export type InstalledApp = { path: string; version: string | null; store?: boolean }
|
||||
|
||||
export type UpdateOptions = { components: Component[]; check?: boolean; local?: boolean; json?: boolean }
|
||||
|
||||
export type UpdateDeps = {
|
||||
platform: NodeJS.Platform
|
||||
/// This process's own version: the menubar target, and the --local target.
|
||||
cliVersion: string
|
||||
findGlobalCli: () => Promise<GlobalCli | null>
|
||||
findExecutable: (name: string, near?: string) => Promise<string | null>
|
||||
run: (command: string, args: string[], options: RunOptions) => Promise<RunResult>
|
||||
fetchText: (url: string) => Promise<string>
|
||||
menubar: () => Promise<InstalledApp | null>
|
||||
installMenubar: (cliVersion: string) => Promise<void>
|
||||
desktop: () => Promise<InstalledApp | null>
|
||||
launchDesktop: (path: string) => Promise<void>
|
||||
/// realpath of the script this process runs, to tell whether npm/brew just replaced it.
|
||||
selfPath: () => Promise<string>
|
||||
reexec: (entry: string, args: string[], onLine: (line: string) => void) => Promise<number>
|
||||
write: (line: string) => void
|
||||
lockDir: () => string
|
||||
}
|
||||
|
||||
const VIEW_TIMEOUT_MS = 30_000
|
||||
const INSTALL_TIMEOUT_MS = 10 * 60_000
|
||||
const BREW_INFO_TIMEOUT_MS = 60_000
|
||||
const FEED_TIMEOUT_MS = 15_000
|
||||
const REEXEC_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
const MANUAL_COMMAND: Record<CliMethod, string> = {
|
||||
npm: 'npm install -g codeburn@latest',
|
||||
brew: 'brew upgrade codeburn',
|
||||
pnpm: 'pnpm add -g codeburn@latest',
|
||||
bun: 'bun add -g codeburn@latest',
|
||||
volta: 'volta install codeburn@latest',
|
||||
managed: 'npm install -g codeburn@latest',
|
||||
unknown: 'npm install -g codeburn@latest',
|
||||
}
|
||||
|
||||
/// Same rule the menubar pill uses (UpdateChecker.swift), on the launcher's realpath. The
|
||||
/// managers checked before npm all keep their own node_modules/codeburn, so order matters.
|
||||
export function detectCliMethod(resolvedPath: string): CliMethod {
|
||||
const p = resolvedPath.replace(/\\/g, '/')
|
||||
if (isTransientNpxPath(p)) return 'managed'
|
||||
if (/\.app\/Contents\/Resources\//.test(p) || p.includes('codeburn-desktop-cli') || /\/resources\/cli\//i.test(p)) return 'managed'
|
||||
if (/\/\.[\w-]+\/extensions\//.test(p)) return 'managed'
|
||||
if (p.includes('/Cellar/codeburn/')) return 'brew'
|
||||
if (p.includes('/.volta/')) return 'volta'
|
||||
if (p.includes('/.pnpm/') || p.includes('/pnpm/global/')) return 'pnpm'
|
||||
if (p.includes('/.bun/')) return 'bun'
|
||||
if (p.includes('/node_modules/codeburn/')) return 'npm'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function packageRootOf(resolvedPath: string): string | null {
|
||||
return /^(.*[\\/]node_modules[\\/]codeburn)[\\/]/.exec(resolvedPath)?.[1] ?? null
|
||||
}
|
||||
|
||||
export function classifyCommandFailure(result: RunResult, fallback: StepCode): StepCode {
|
||||
if (result.timedOut) return 'timeout'
|
||||
const text = `${result.stderr}\n${result.stdout}`
|
||||
if (/\b(EACCES|EPERM)\b|permission denied/i.test(text)) return 'permission'
|
||||
if (/\b(E404|ETARGET)\b|404 Not Found|No matching version/i.test(text)) return 'not_propagated'
|
||||
if (/\b(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH)\b/.test(text)) return 'download_fail'
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function parseFeedVersion(yml: string): string | null {
|
||||
return /^version:\s*['"]?([\w.+-]+)['"]?\s*$/m.exec(yml)?.[1] ?? null
|
||||
}
|
||||
|
||||
/// One update at a time per user: a second run gets `busy` instead of a second `npm -g`. The
|
||||
/// pid goes in before the name exists (write, then hard-link), so a reader never sees an empty
|
||||
/// lock. A dead pid is a crashed run; the run that re-executed this one is the holder, not a rival.
|
||||
export async function acquireUpdateLock(dir: string): Promise<(() => Promise<void>) | 'busy'> {
|
||||
const path = join(dir, 'update.lock')
|
||||
const staged = `${path}.${process.pid}`
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(staged, String(process.pid), { mode: 0o600 })
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await link(staged, path)
|
||||
return () => rm(path, { force: true })
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
||||
const pid = Number((await readFile(path, 'utf8').catch(() => '')).trim())
|
||||
if (pid === process.ppid) return async () => {}
|
||||
if (Number.isInteger(pid) && pid > 0 && pidIsLive(pid)) return 'busy'
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
}
|
||||
return 'busy'
|
||||
} finally {
|
||||
await rm(staged, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// `cmd.exe /d /s /c` argv for a `.cmd` shim, ported from app/electron/cli.ts (cross-spawn's rule).
|
||||
const CMD_META = /([()\][%!^"`<>&|;, *?])/g
|
||||
function escapeForCmd(arg: string, doubleEscape: boolean): string {
|
||||
let escaped = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')
|
||||
escaped = `"${escaped}"`.replace(CMD_META, '^$1')
|
||||
return doubleEscape ? escaped.replace(CMD_META, '^$1') : escaped
|
||||
}
|
||||
export function cmdShimArgs(bin: string, args: string[]): string[] {
|
||||
const line = [escapeForCmd(bin, false), ...args.map(arg => escapeForCmd(arg, true))].join(' ')
|
||||
return ['/d', '/s', '/c', `"${line}"`]
|
||||
}
|
||||
|
||||
function versionAtLeast(installed: string | null, target: string): boolean {
|
||||
return installed !== null && compareMenubarVersions(installed, target) >= 0
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promise<number> {
|
||||
const check = Boolean(options.check || options.local)
|
||||
const results: Partial<Record<Component, ComponentResult>> = {}
|
||||
|
||||
const emit = (event: StepEvent) => {
|
||||
if (options.json) return deps.write(JSON.stringify(event))
|
||||
if (event.state === 'progress') return deps.write(` ${event.message}`)
|
||||
deps.write(` ${event.component}: ${event.message}`)
|
||||
if (event.command) deps.write(` Run: ${event.command}`)
|
||||
}
|
||||
const finish = (
|
||||
component: Component,
|
||||
state: 'done' | 'skipped' | 'error',
|
||||
code: StepCode,
|
||||
message: string,
|
||||
versions: { installed: string | null; latest: string | null; to?: string; method?: string; command?: string },
|
||||
) => {
|
||||
emit({
|
||||
type: 'step', component, state,
|
||||
...(versions.installed ? { from: versions.installed } : {}),
|
||||
...((versions.to ?? versions.latest) ? { to: versions.to ?? versions.latest! } : {}),
|
||||
code, message,
|
||||
...(state === 'error' && versions.command ? { command: versions.command } : {}),
|
||||
})
|
||||
results[component] = {
|
||||
installed: versions.installed,
|
||||
latest: versions.latest,
|
||||
state,
|
||||
code,
|
||||
...(versions.method ? { method: versions.method } : {}),
|
||||
}
|
||||
}
|
||||
const report = (): number => {
|
||||
const ok = options.components.every(c => results[c]?.state === 'done' || results[c]?.state === 'skipped')
|
||||
if (options.json) deps.write(JSON.stringify({ type: 'result', ok, components: results } satisfies ResultEvent))
|
||||
return ok ? 0 : 1
|
||||
}
|
||||
|
||||
let release: (() => Promise<void>) | undefined
|
||||
if (!check) {
|
||||
const lock = await acquireUpdateLock(deps.lockDir())
|
||||
if (lock === 'busy') {
|
||||
for (const c of options.components) {
|
||||
finish(c, 'error', 'busy', 'Another CodeBurn update is running. Try again when it has finished.', { installed: null, latest: null })
|
||||
}
|
||||
return report()
|
||||
}
|
||||
release = lock
|
||||
}
|
||||
|
||||
try {
|
||||
let remaining = options.components
|
||||
if (remaining.includes('cli')) {
|
||||
remaining = remaining.filter(c => c !== 'cli')
|
||||
const reexec = await cliStep()
|
||||
if (reexec && remaining.length > 0) {
|
||||
await forward(reexec, remaining)
|
||||
remaining = []
|
||||
}
|
||||
}
|
||||
if (remaining.includes('menubar')) await menubarStep()
|
||||
if (remaining.includes('desktop')) await desktopStep()
|
||||
return report()
|
||||
} finally {
|
||||
await release?.()
|
||||
}
|
||||
|
||||
/// Returns the new CLI's entry point when npm/brew replaced the files this process runs from,
|
||||
/// so the remaining steps run in the new CLI rather than this half-replaced one.
|
||||
async function cliStep(): Promise<string | null> {
|
||||
const cli = await deps.findGlobalCli()
|
||||
if (!cli) {
|
||||
finish('cli', 'skipped', 'not_installed', 'No global codeburn command is installed.', { installed: null, latest: null })
|
||||
return null
|
||||
}
|
||||
const base = { installed: cli.version, method: cli.method, command: MANUAL_COMMAND[cli.method] }
|
||||
if (cli.method === 'managed') {
|
||||
finish('cli', 'skipped', 'managed', `${cli.launcher} is bundled with another app and updates with it.`, { ...base, latest: null })
|
||||
return null
|
||||
}
|
||||
|
||||
let latest = deps.cliVersion
|
||||
let npm: string | null = null
|
||||
if (!options.local) {
|
||||
npm = await deps.findExecutable(deps.platform === 'win32' ? 'npm.cmd' : 'npm', dirname(cli.launcher))
|
||||
if (!npm) {
|
||||
finish('cli', 'error', 'no_manager', 'npm was not found, so the latest version cannot be checked.', { ...base, latest: null })
|
||||
return null
|
||||
}
|
||||
const view = await runNpm(npm, ['view', 'codeburn@latest', 'version', '--fetch-retries=1', '--fetch-timeout=15000', '--fetch-retry-mintimeout=2000'], VIEW_TIMEOUT_MS)
|
||||
const viewed = view.code === 0 ? view.stdout.trim().split(/\s+/).pop() ?? '' : ''
|
||||
if (!/^\d+\.\d+\.\d+$/.test(viewed)) {
|
||||
const code = view.code === 0 ? 'download_fail' : classifyCommandFailure(view, 'download_fail')
|
||||
finish('cli', 'error', code, code === 'timeout'
|
||||
? 'The npm registry did not answer in time.'
|
||||
: 'Could not read the latest version from the npm registry.', { ...base, latest: null })
|
||||
return null
|
||||
}
|
||||
latest = viewed
|
||||
}
|
||||
|
||||
let brew: string | null = null
|
||||
if (cli.method === 'brew' && !options.local) {
|
||||
brew = await deps.findExecutable('brew', dirname(cli.launcher))
|
||||
const info = brew
|
||||
? await deps.run(brew, ['info', '--json=v2', 'codeburn'], { timeoutMs: BREW_INFO_TIMEOUT_MS, env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1' } })
|
||||
: null
|
||||
let formula: string | null = null
|
||||
try { formula = JSON.parse(info?.stdout ?? '').formulae?.[0]?.versions?.stable ?? null } catch {}
|
||||
if (!info || !formula) {
|
||||
finish('cli', 'error', info ? classifyCommandFailure(info, 'install_fail') : 'no_manager',
|
||||
'Could not read the Homebrew formula version.', { ...base, latest: null })
|
||||
return null
|
||||
}
|
||||
if (versionAtLeast(cli.version, formula) && !versionAtLeast(formula, latest)) {
|
||||
finish('cli', 'skipped', 'managed', `Homebrew has not published ${latest} yet.`, { ...base, latest: formula })
|
||||
return null
|
||||
}
|
||||
latest = formula
|
||||
}
|
||||
if (versionAtLeast(cli.version, latest)) {
|
||||
finish('cli', 'skipped', 'up_to_date', `CLI ${cli.version} is up to date.`, { ...base, latest })
|
||||
return null
|
||||
}
|
||||
if (brew) {
|
||||
if (check) {
|
||||
finish('cli', 'skipped', 'ok', `CLI ${latest} is available from Homebrew.`, { ...base, latest })
|
||||
return null
|
||||
}
|
||||
emit({ type: 'step', component: 'cli', state: 'start', from: cli.version ?? undefined, to: latest, message: `Updating CLI to ${latest} with Homebrew...` })
|
||||
return afterInstall(cli, await deps.run(brew, ['upgrade', 'codeburn'], { timeoutMs: INSTALL_TIMEOUT_MS }), latest, latest)
|
||||
}
|
||||
|
||||
if (check) {
|
||||
finish('cli', 'skipped', 'ok', `CLI ${latest} is available.`, { ...base, latest })
|
||||
return null
|
||||
}
|
||||
if (cli.method !== 'npm' || !npm) {
|
||||
finish('cli', 'error', 'no_manager', `CodeBurn was installed with ${cli.method === 'unknown' ? 'an unknown tool' : cli.method}; update it with the command below.`, { ...base, latest })
|
||||
return null
|
||||
}
|
||||
emit({ type: 'step', component: 'cli', state: 'start', from: cli.version ?? undefined, to: latest, message: `Updating CLI to ${latest} with npm...` })
|
||||
const install = await runNpm(npm, ['install', '-g', `codeburn@${latest}`, '--force', '--prefer-online', '--no-fund', '--no-audit'], INSTALL_TIMEOUT_MS)
|
||||
return afterInstall(cli, install, latest, latest)
|
||||
}
|
||||
|
||||
async function afterInstall(before: GlobalCli, result: RunResult, target: string, latest: string): Promise<string | null> {
|
||||
const base = { installed: before.version, latest, method: before.method, command: MANUAL_COMMAND[before.method] }
|
||||
if (result.code !== 0) {
|
||||
const code = classifyCommandFailure(result, 'install_fail')
|
||||
const messages: Record<string, string> = {
|
||||
permission: `No permission to update the global CLI. Run the command below in Terminal.`,
|
||||
not_propagated: `CLI ${target} is not on the registry yet; it will be retried later.`,
|
||||
timeout: 'The CLI update took too long and was stopped.',
|
||||
download_fail: 'Could not reach the registry to download the CLI.',
|
||||
}
|
||||
const message = messages[code] ?? `The CLI update failed: ${lastLine(result.stderr)}`
|
||||
finish('cli', 'error', code, message, base)
|
||||
return null
|
||||
}
|
||||
const after = await deps.findGlobalCli()
|
||||
if (!after || !versionAtLeast(after.version, target)) {
|
||||
finish('cli', 'error', 'install_fail', `The update finished, but ${before.launcher} is still ${after?.version ?? 'missing'}.`, base)
|
||||
return null
|
||||
}
|
||||
finish('cli', 'done', 'ok', `CLI updated to ${after.version}.`, { ...base, to: after.version! })
|
||||
const self = await deps.selfPath().catch(() => '')
|
||||
const replacedSelf = before.root !== null && self.startsWith(before.root)
|
||||
return replacedSelf && after.root ? join(after.root, 'dist', 'cli.js') : null
|
||||
}
|
||||
|
||||
async function forward(entry: string, components: Component[]): Promise<void> {
|
||||
const code = await deps.reexec(entry, ['update', ...components.map(c => `--${c}`), '--json'], line => {
|
||||
let event: StepEvent | ResultEvent
|
||||
try { event = JSON.parse(line) } catch { return }
|
||||
if (event.type === 'step') emit(event)
|
||||
else if (event.type === 'result') Object.assign(results, event.components)
|
||||
}).catch(() => 1)
|
||||
for (const c of components) {
|
||||
if (!results[c]) {
|
||||
finish(c, 'error', 'install_fail', `The updated CLI stopped (exit ${code}) before updating the ${c}.`, {
|
||||
installed: null, latest: null, command: `codeburn update --${c}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function menubarStep(): Promise<void> {
|
||||
const none = { installed: null, latest: null }
|
||||
if (deps.platform === 'win32') {
|
||||
return finish('menubar', 'skipped', 'managed', 'The Windows tray is updated by the desktop app or its own updater.', none)
|
||||
}
|
||||
if (deps.platform !== 'darwin') return finish('menubar', 'skipped', 'not_installed', 'The menu bar app is macOS only.', none)
|
||||
const before = await deps.menubar()
|
||||
const target = deps.cliVersion
|
||||
if (!before) return finish('menubar', 'skipped', 'not_installed', 'The menu bar app is not installed.', { installed: null, latest: target })
|
||||
const base = { installed: before.version, latest: target, command: 'codeburn menubar --force' }
|
||||
if (versionAtLeast(before.version, target)) {
|
||||
return finish('menubar', 'skipped', 'up_to_date', `Menu bar app ${before.version} is up to date.`, base)
|
||||
}
|
||||
if (check) return finish('menubar', 'skipped', 'ok', `Menu bar app ${target} is available.`, base)
|
||||
|
||||
emit({ type: 'step', component: 'menubar', state: 'start', from: before.version ?? undefined, to: target, message: `Updating the menu bar app to ${target}...` })
|
||||
const log = console.log
|
||||
if (options.json) {
|
||||
console.log = (...args: unknown[]) => emit({ type: 'step', component: 'menubar', state: 'progress', message: args.join(' ') })
|
||||
}
|
||||
try {
|
||||
await deps.installMenubar(target)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return finish('menubar', 'error', classifyMenubarError(message), message, base)
|
||||
} finally {
|
||||
console.log = log
|
||||
}
|
||||
const after = await deps.menubar()
|
||||
if (after && versionAtLeast(after.version, target)) {
|
||||
return finish('menubar', 'done', 'ok', `Menu bar app updated to ${after.version}.`, { ...base, to: after.version! })
|
||||
}
|
||||
if (after?.version && before.version && compareMenubarVersions(after.version, before.version) > 0) {
|
||||
return finish('menubar', 'done', 'ok', `Menu bar app updated to ${after.version}; ${target} is not published yet.`, { ...base, to: after.version })
|
||||
}
|
||||
finish('menubar', 'skipped', 'not_propagated', `Menu bar app ${target} is not published yet; it will be retried later.`, base)
|
||||
}
|
||||
|
||||
async function desktopStep(): Promise<void> {
|
||||
if (deps.platform !== 'darwin' && deps.platform !== 'win32') {
|
||||
return finish('desktop', 'skipped', 'managed', 'On Linux the desktop app is updated by its package manager or AppImage.', { installed: null, latest: null })
|
||||
}
|
||||
const app = await deps.desktop()
|
||||
if (!app) return finish('desktop', 'skipped', 'not_installed', 'The desktop app is not installed.', { installed: null, latest: null })
|
||||
if (app.store) return finish('desktop', 'skipped', 'managed', 'The Microsoft Store keeps the desktop app up to date.', { installed: null, latest: null })
|
||||
|
||||
const command = deps.platform === 'darwin' ? `open -n "${app.path}" --args --codeburn-update` : `"${app.path}" --codeburn-update`
|
||||
let latest: string
|
||||
if (options.local) {
|
||||
latest = deps.cliVersion
|
||||
} else {
|
||||
const feed = deps.platform === 'darwin' ? 'latest-mac.yml' : 'latest.yml'
|
||||
try {
|
||||
const version = parseFeedVersion(await deps.fetchText(`${RELEASE_DOWNLOAD_BASE}/update-feeds/${feed}`))
|
||||
if (!version) throw new Error(`${feed} has no version`)
|
||||
latest = version
|
||||
} catch (err) {
|
||||
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')
|
||||
return finish('desktop', 'error', timedOut ? 'timeout' : 'download_fail', timedOut
|
||||
? 'The desktop update feed did not answer in time.'
|
||||
: 'Could not read the desktop update feed.', { installed: app.version, latest: null })
|
||||
}
|
||||
}
|
||||
const base = { installed: app.version, latest, command }
|
||||
if (versionAtLeast(app.version, latest)) {
|
||||
return finish('desktop', 'skipped', 'up_to_date', `Desktop app ${app.version} is up to date.`, base)
|
||||
}
|
||||
if (check) return finish('desktop', 'skipped', 'ok', `Desktop app ${latest} is available.`, base)
|
||||
|
||||
try {
|
||||
await deps.launchDesktop(app.path)
|
||||
} catch (err) {
|
||||
return finish('desktop', 'error', 'install_fail', `Could not open the desktop app: ${err instanceof Error ? err.message : String(err)}`, base)
|
||||
}
|
||||
// Mirrors WINDOWS_AUTO_UPDATE = false in app/electron/auto-update.ts: there the window opens
|
||||
// with its download banner instead of updating itself.
|
||||
if (deps.platform === 'win32') {
|
||||
return finish('desktop', 'skipped', 'managed', 'Opened CodeBurn. Update it from its banner.', base)
|
||||
}
|
||||
finish('desktop', 'done', 'ok', 'Handed to CodeBurn. It downloads the update and restarts.', base)
|
||||
}
|
||||
|
||||
function runNpm(npm: string, args: string[], timeoutMs: number): Promise<RunResult> {
|
||||
// npm is `#!/usr/bin/env node`: the node beside it has to be first on PATH, or an app's
|
||||
// launchd PATH finds no node at all.
|
||||
const env = { ...process.env, PATH: [dirname(npm), process.env['PATH'] ?? ''].join(delimiter) }
|
||||
if (deps.platform === 'win32') {
|
||||
return deps.run(resolveSystem32Path('cmd.exe'), cmdShimArgs(npm, args), { timeoutMs, env, verbatim: true })
|
||||
}
|
||||
return deps.run(npm, args, { timeoutMs, env })
|
||||
}
|
||||
}
|
||||
|
||||
function classifyMenubarError(message: string): StepCode {
|
||||
if (/Checksum mismatch|Refusing to install|Unexpected menubar bundle id|did not contain/.test(message)) return 'verify_fail'
|
||||
if (/Another CodeBurn Menubar install/.test(message)) return 'busy'
|
||||
if (/Download|Checksum download|HTTP \d|lookup failed|fetch failed|ENOTFOUND|ECONNREFUSED/.test(message)) return 'download_fail'
|
||||
return 'install_fail'
|
||||
}
|
||||
|
||||
function lastLine(text: string): string {
|
||||
return text.trim().split(/\r?\n/).filter(Boolean).pop()?.slice(0, 300) ?? 'unknown error'
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
return access(path).then(() => true, () => false)
|
||||
}
|
||||
|
||||
function runCapture(command: string, args: string[], options: RunOptions): Promise<RunResult> {
|
||||
return new Promise(resolve => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: options.verbatim,
|
||||
})
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, options.timeoutMs)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
||||
child.on('error', err => { clearTimeout(timer); resolve({ code: null, stdout, stderr: `${stderr}\n${err.message}`, timedOut }) })
|
||||
child.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr, timedOut }) })
|
||||
})
|
||||
}
|
||||
|
||||
async function analyzeLauncher(launcher: string): Promise<GlobalCli> {
|
||||
let resolved = await realpath(launcher).catch(() => launcher)
|
||||
// A Windows npm shim (`codeburn.cmd`) is a file, not a link; the package sits beside it.
|
||||
const shimTarget = join(dirname(launcher), 'node_modules', 'codeburn', 'dist', 'cli.js')
|
||||
if (!packageRootOf(resolved) && await exists(shimTarget)) resolved = shimTarget
|
||||
const root = packageRootOf(resolved)
|
||||
let version: string | null = null
|
||||
if (root) {
|
||||
try { version = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version ?? null } catch {}
|
||||
}
|
||||
return { launcher, method: detectCliMethod(resolved), root, version }
|
||||
}
|
||||
|
||||
/// The global codeburn, not necessarily this process: the desktop app runs its bundled CLI.
|
||||
/// PATH first, then this process when it is a real global install PATH does not reach (nvm
|
||||
/// under an app's launchd PATH). A bundled or npx copy only counts when nothing else exists.
|
||||
async function findGlobalCli(): Promise<GlobalCli | null> {
|
||||
const found: GlobalCli[] = []
|
||||
const onPath = await resolvePersistentCodeburnPathFromPath(buildPersistentCodeburnLookupPath()).catch(() => null)
|
||||
if (onPath) found.push(await analyzeLauncher(onPath))
|
||||
if (process.argv[1]) {
|
||||
const self = await analyzeLauncher(process.argv[1])
|
||||
if (self.method !== 'managed' && self.method !== 'unknown') found.push(self)
|
||||
}
|
||||
return found.find(cli => cli.method !== 'managed') ?? found[0] ?? null
|
||||
}
|
||||
|
||||
async function findExecutable(name: string, near?: string): Promise<string | null> {
|
||||
if (near && await exists(join(near, name))) return join(near, name)
|
||||
for (const dir of buildPersistentCodeburnLookupPath().split(delimiter).filter(Boolean)) {
|
||||
if (await exists(join(dir, name))) return join(dir, name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetchWithProxy(url, {
|
||||
headers: { 'User-Agent': 'codeburn-update' },
|
||||
signal: AbortSignal.timeout(FEED_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.text()
|
||||
}
|
||||
|
||||
async function findDesktop(): Promise<InstalledApp | null> {
|
||||
if (osPlatform() === 'win32') {
|
||||
const exe = join(process.env['LOCALAPPDATA'] || join(homedir(), 'AppData', 'Local'), 'Programs', 'CodeBurn', 'CodeBurn.exe')
|
||||
if (await exists(exe)) {
|
||||
const result = await runCapture(resolveSystem32Path('WindowsPowerShell\\v1.0\\powershell.exe'), [
|
||||
'-NoProfile', '-NonInteractive', '-Command',
|
||||
`(Get-Item -LiteralPath '${exe.replace(/'/g, "''")}').VersionInfo.ProductVersion`,
|
||||
], { timeoutMs: 15_000 })
|
||||
return { path: exe, version: result.code === 0 ? result.stdout.trim() || null : null }
|
||||
}
|
||||
if ((await queryStoreInstallLocation(process.env)).trim()) return { path: '', version: null, store: true }
|
||||
return null
|
||||
}
|
||||
// Never by bundle id: Launch Services may pick another user's or another folder's copy.
|
||||
for (const dir of [join(homedir(), 'Applications'), '/Applications']) {
|
||||
const path = join(dir, 'CodeBurn.app')
|
||||
if (await exists(path)) return { path, version: await readBundleVersion(path) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function launchDesktop(path: string): Promise<void> {
|
||||
if (osPlatform() === 'win32') {
|
||||
const child = spawn(path, ['--codeburn-update'], { detached: true, stdio: 'ignore' })
|
||||
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
||||
child.unref()
|
||||
return
|
||||
}
|
||||
const result = await runCapture('/usr/bin/open', ['-n', path, '--args', '--codeburn-update'], { timeoutMs: 30_000 })
|
||||
if (result.code !== 0) throw new Error(lastLine(result.stderr))
|
||||
}
|
||||
|
||||
function reexec(entry: string, args: string[], onLine: (line: string) => void): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [entry, ...args], { stdio: ['ignore', 'pipe', 'inherit'], windowsHide: true })
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), REEXEC_TIMEOUT_MS)
|
||||
createInterface({ input: child.stdout }).on('line', onLine)
|
||||
child.on('error', err => { clearTimeout(timer); reject(err) })
|
||||
child.on('close', code => { clearTimeout(timer); resolve(code ?? 1) })
|
||||
})
|
||||
}
|
||||
|
||||
export function defaultUpdateDeps(cliVersion: string): UpdateDeps {
|
||||
return {
|
||||
platform: osPlatform(),
|
||||
cliVersion,
|
||||
findGlobalCli,
|
||||
findExecutable,
|
||||
run: runCapture,
|
||||
fetchText,
|
||||
menubar: async () => {
|
||||
const { installedPath } = await resolveMacInstallTarget()
|
||||
return installedPath ? { path: installedPath, version: await readBundleVersion(installedPath) } : null
|
||||
},
|
||||
installMenubar: async version => { await installMenubarApp({ force: true, cliVersion: version }) },
|
||||
desktop: findDesktop,
|
||||
launchDesktop,
|
||||
selfPath: () => realpath(process.argv[1] ?? ''),
|
||||
reexec,
|
||||
write: line => { process.stdout.write(`${line}\n`) },
|
||||
lockDir: getCodeburnCacheDir,
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
fetchLatestMacReleaseAssets,
|
||||
formatGitHubReleaseLookupError,
|
||||
hasRunnableRecordedCli,
|
||||
isFallbackUpgrade,
|
||||
isMissingDirectAssetError,
|
||||
leftoverBundleLines,
|
||||
resolveLatestMenubarReleaseAssets,
|
||||
|
|
@ -19,11 +20,13 @@ import {
|
|||
resolveMenubarReleaseAssets,
|
||||
resolvePersistentCodeburnPathFromWhichOutput,
|
||||
resolveProxyUrlForUrl,
|
||||
resolveReleaseBase,
|
||||
resolveVersionedMenubarReleaseAssets,
|
||||
shouldFallbackToReleaseApi,
|
||||
verifyBundleSignature,
|
||||
verifyChecksum,
|
||||
MAC_FEED_URL,
|
||||
WINDOWS_RELEASE,
|
||||
type ReleaseApiFetch,
|
||||
type ReleaseResponse,
|
||||
} from '../src/menubar-installer.js'
|
||||
|
|
@ -862,6 +865,24 @@ describe('fetchLatestMacReleaseAssets', () => {
|
|||
expect(assets.release.tag_name).toBe('mac-v0.9.29')
|
||||
})
|
||||
|
||||
it('never takes a fallback release older than, or equal to, the installed bundle', async () => {
|
||||
// The 0.9.26 release window: CLI 0.9.27 is out, mac-v0.9.27 is not, and the feed still
|
||||
// names 0.9.26 while 0.9.27 was already installed by hand.
|
||||
const feed: ReleaseApiFetch = async () => json(200, {
|
||||
version: '0.9.26',
|
||||
url: 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.26/CodeBurnMenubar-v0.9.26.zip',
|
||||
})
|
||||
const assets = await fetchLatestMacReleaseAssets(feed)
|
||||
expect(isFallbackUpgrade(assets, '0.9.27')).toBe(false)
|
||||
expect(isFallbackUpgrade(assets, '0.9.26')).toBe(false)
|
||||
expect(isFallbackUpgrade(assets, '0.9.25')).toBe(true)
|
||||
expect(isFallbackUpgrade(assets, null)).toBe(true)
|
||||
const scanOnly: ReleaseApiFetch = async (url) => url === MAC_FEED_URL ? json(404, null) : json(200, scan)
|
||||
expect(isFallbackUpgrade(await fetchLatestMacReleaseAssets(scanOnly), '0.9.30')).toBe(false)
|
||||
expect(isFallbackUpgrade(await fetchLatestMacReleaseAssets(scanOnly), '0.9.28')).toBe(true)
|
||||
expect(isFallbackUpgrade(resolveVersionedMenubarReleaseAssets('0.9.9', WINDOWS_RELEASE), '0.9.10', WINDOWS_RELEASE)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a feed whose url points anywhere but the matching mac-v release asset', async () => {
|
||||
const fetchImpl: ReleaseApiFetch = async (url) => url === MAC_FEED_URL
|
||||
? json(200, { version: '0.9.30', url: 'https://evil.test/CodeBurnMenubar-v0.9.30.zip' })
|
||||
|
|
@ -870,3 +891,32 @@ describe('fetchLatestMacReleaseAssets', () => {
|
|||
expect(assets.release.tag_name).toBe('mac-v0.9.29')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveReleaseBase', () => {
|
||||
const DEFAULT = 'https://github.com/getagentseal/codeburn/releases/download'
|
||||
|
||||
it('takes only loopback http with a port or a GitHub releases/download base', () => {
|
||||
expect(resolveReleaseBase('http://127.0.0.1:8787')).toBe('http://127.0.0.1:8787')
|
||||
expect(resolveReleaseBase('https://github.com/someone/codeburn-fork/releases/download')).toBe('https://github.com/someone/codeburn-fork/releases/download')
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'',
|
||||
'https://evil.example',
|
||||
'http://127.0.0.1:8787/',
|
||||
'http://127.0.0.1',
|
||||
'http://127.0.0.1:0',
|
||||
'http://127.0.0.1:70000',
|
||||
'http://localhost:8787',
|
||||
'http://127.0.0.1.evil.example:8787',
|
||||
'https://127.0.0.1:8787',
|
||||
'http://github.com/getagentseal/codeburn/releases/download',
|
||||
'https://github.com.evil.example/a/b/releases/download',
|
||||
'https://github.com/getagentseal/codeburn/releases/download/',
|
||||
'https://github.com/getagentseal/codeburn/releases/download/../../x',
|
||||
'https://github.com/a/b/releases/download?x=1',
|
||||
])('ignores %s', value => {
|
||||
expect(resolveReleaseBase(value)).toBe(DEFAULT)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { join } from 'node:path'
|
|||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ancestorsOf, replaceAndRelaunch, runningAppPids } from '../src/menubar-installer.js'
|
||||
import { ancestorsOf, pidsBelowApp, replaceAndRelaunch, runningAppPids } from '../src/menubar-installer.js'
|
||||
|
||||
describe('ancestorsOf', () => {
|
||||
const ps = [' 1 0', ' 50 1', ' 60 50', ' 70 60', ' 80 1', ''].join('\n')
|
||||
|
|
@ -27,6 +27,18 @@ describe('ancestorsOf', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('pidsBelowApp', () => {
|
||||
const ps = [' 1 0', ' 40 1', ' 50 40', ' 60 50', ' 70 60', ''].join('\n')
|
||||
|
||||
it('waits on the re-run engine and its parent, not the app or above', () => {
|
||||
expect(pidsBelowApp(70, [50], ancestorsOf(70, ps))).toEqual([70, 60])
|
||||
})
|
||||
|
||||
it('is just this process when the app is its parent', () => {
|
||||
expect(pidsBelowApp(60, [50], ancestorsOf(60, ps))).toEqual([60])
|
||||
})
|
||||
})
|
||||
|
||||
// A stand-in for the menubar app: a real process with a unique name, so the pgrep, kill and
|
||||
// ancestry code runs for real without going near the installed CodeBurnMenubar. It runs an
|
||||
// optional command as its child (the Update button's `codeburn menubar --force`), records the
|
||||
|
|
|
|||
453
tests/update.test.ts
Normal file
453
tests/update.test.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
acquireUpdateLock,
|
||||
classifyCommandFailure,
|
||||
cmdShimArgs,
|
||||
detectCliMethod,
|
||||
packageRootOf,
|
||||
parseFeedVersion,
|
||||
runUpdate,
|
||||
type GlobalCli,
|
||||
type InstalledApp,
|
||||
type RunResult,
|
||||
type UpdateDeps,
|
||||
} from '../src/update.js'
|
||||
|
||||
const LATEST_MAC_YML = `version: 0.9.26
|
||||
files:
|
||||
- url: https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-0.9.26-mac.zip
|
||||
sha512: dJ2j71eKn/OHyDhsG0Smswiw6dffDwmOuFb6jfdgT0X0a2faRd7w0Q0cb65AJG2ZTt8Izf2bGQ1+IJ6/D8Y9FA==
|
||||
size: 149589365
|
||||
path: https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.26/CodeBurn-0.9.26-mac.zip
|
||||
releaseDate: '2026-10-10T22:31:00.100Z'
|
||||
`
|
||||
|
||||
describe('detectCliMethod', () => {
|
||||
it.each([
|
||||
['/opt/homebrew/Cellar/codeburn/0.9.26/libexec/lib/node_modules/codeburn/dist/cli.js', 'brew'],
|
||||
['/home/linuxbrew/.linuxbrew/Cellar/codeburn/0.9.26/libexec/lib/node_modules/codeburn/dist/cli.js', 'brew'],
|
||||
['/opt/homebrew/lib/node_modules/codeburn/dist/cli.js', 'npm'],
|
||||
['/Users/a/.nvm/versions/node/v22.13.0/lib/node_modules/codeburn/dist/cli.js', 'npm'],
|
||||
['C:\\Users\\a\\AppData\\Roaming\\npm\\node_modules\\codeburn\\dist\\cli.js', 'npm'],
|
||||
['/Users/a/.npm/_npx/1a2b/node_modules/codeburn/dist/cli.js', 'managed'],
|
||||
['/Applications/CodeBurn.app/Contents/Resources/cli/dist/launch.js', 'managed'],
|
||||
['/Users/a/Library/Application Support/CodeBurn/codeburn-desktop-cli.sh', 'managed'],
|
||||
['C:\\Users\\a\\AppData\\Local\\Programs\\CodeBurn\\resources\\cli\\dist\\launch.js', 'managed'],
|
||||
['/Users/a/.vscode/extensions/agentseal.codeburn-1.0.1/dist/cli/node_modules/codeburn/dist/cli.js', 'managed'],
|
||||
['/Users/a/.volta/tools/image/packages/codeburn/lib/node_modules/codeburn/dist/cli.js', 'volta'],
|
||||
['/Users/a/Library/pnpm/global/5/.pnpm/codeburn@0.9.26/node_modules/codeburn/dist/cli.js', 'pnpm'],
|
||||
['/Users/a/.bun/install/global/node_modules/codeburn/dist/cli.js', 'bun'],
|
||||
['/usr/local/bin/codeburn', 'unknown'],
|
||||
])('%s -> %s', (path, method) => {
|
||||
expect(detectCliMethod(path)).toBe(method)
|
||||
})
|
||||
|
||||
it('finds the package root through nested node_modules', () => {
|
||||
expect(packageRootOf('/Users/a/Library/pnpm/global/5/.pnpm/codeburn@0.9.26/node_modules/codeburn/dist/cli.js'))
|
||||
.toBe('/Users/a/Library/pnpm/global/5/.pnpm/codeburn@0.9.26/node_modules/codeburn')
|
||||
expect(packageRootOf('/usr/local/bin/codeburn')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyCommandFailure', () => {
|
||||
const failed = (stderr: string, timedOut = false): RunResult => ({ code: 1, stdout: '', stderr, timedOut })
|
||||
|
||||
it('maps npm stderr to the contract codes', () => {
|
||||
expect(classifyCommandFailure(failed('npm error code EACCES\nnpm error syscall rename'), 'install_fail')).toBe('permission')
|
||||
expect(classifyCommandFailure(failed('npm error code EPERM'), 'install_fail')).toBe('permission')
|
||||
expect(classifyCommandFailure(failed('npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/codeburn/-/codeburn-0.9.27.tgz'), 'install_fail')).toBe('not_propagated')
|
||||
expect(classifyCommandFailure(failed('npm error code ETARGET\nnpm error notarget No matching version found for codeburn@0.9.27.'), 'install_fail')).toBe('not_propagated')
|
||||
expect(classifyCommandFailure(failed('npm error code ENOTFOUND'), 'install_fail')).toBe('download_fail')
|
||||
expect(classifyCommandFailure(failed('npm error code ECONNREFUSED'), 'install_fail')).toBe('download_fail')
|
||||
expect(classifyCommandFailure(failed('', true), 'install_fail')).toBe('timeout')
|
||||
expect(classifyCommandFailure(failed('npm error code EINTEGRITY'), 'install_fail')).toBe('install_fail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseFeedVersion', () => {
|
||||
it('reads the top-level version of an electron-builder feed', () => {
|
||||
expect(parseFeedVersion(LATEST_MAC_YML)).toBe('0.9.26')
|
||||
expect(parseFeedVersion("version: '0.9.27'\n")).toBe('0.9.27')
|
||||
expect(parseFeedVersion('<html>not a feed</html>')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdShimArgs', () => {
|
||||
it('quotes a .cmd path with spaces and escapes cmd metacharacters', () => {
|
||||
expect(cmdShimArgs('C:\\Program Files\\nodejs\\npm.cmd', ['install', '-g', 'codeburn@0.9.27']))
|
||||
.toEqual(['/d', '/s', '/c', '"^"C:\\Program^ Files\\nodejs\\npm.cmd^" ^^^"install^^^" ^^^"-g^^^" ^^^"codeburn@0.9.27^^^""'])
|
||||
})
|
||||
})
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'codeburn-update-')) })
|
||||
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
describe('acquireUpdateLock', () => {
|
||||
it('gives a second run busy while the first holds it, and frees it on release', async () => {
|
||||
const release = await acquireUpdateLock(dir)
|
||||
expect(typeof release).toBe('function')
|
||||
expect(await acquireUpdateLock(dir)).toBe('busy')
|
||||
await (release as () => Promise<void>)()
|
||||
const again = await acquireUpdateLock(dir)
|
||||
expect(typeof again).toBe('function')
|
||||
await (again as () => Promise<void>)()
|
||||
})
|
||||
|
||||
it('takes over a lock left by a dead pid', async () => {
|
||||
await writeFile(join(dir, 'update.lock'), '2147483646')
|
||||
const release = await acquireUpdateLock(dir)
|
||||
expect(typeof release).toBe('function')
|
||||
expect(await readFile(join(dir, 'update.lock'), 'utf8')).toBe(String(process.pid))
|
||||
await (release as () => Promise<void>)()
|
||||
})
|
||||
|
||||
it('lets the run that re-executed this one keep its lock', async () => {
|
||||
await writeFile(join(dir, 'update.lock'), String(process.ppid))
|
||||
const release = await acquireUpdateLock(dir)
|
||||
expect(typeof release).toBe('function')
|
||||
await (release as () => Promise<void>)()
|
||||
expect(await readFile(join(dir, 'update.lock'), 'utf8')).toBe(String(process.ppid))
|
||||
})
|
||||
})
|
||||
|
||||
type Fake = {
|
||||
deps: UpdateDeps
|
||||
lines: string[]
|
||||
runs: string[][]
|
||||
launched: string[]
|
||||
menubarInstalls: string[]
|
||||
reexecs: string[][]
|
||||
events: () => Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const npmCli = (version: string): GlobalCli => ({
|
||||
launcher: '/opt/homebrew/bin/codeburn',
|
||||
method: 'npm',
|
||||
root: '/opt/homebrew/lib/node_modules/codeburn',
|
||||
version,
|
||||
})
|
||||
|
||||
function fake(overrides: Partial<UpdateDeps> & {
|
||||
npmLatest?: string
|
||||
npmView?: RunResult
|
||||
npmInstall?: RunResult
|
||||
brewFormula?: string
|
||||
menubarVersions?: Array<string | null>
|
||||
desktopApp?: InstalledApp | null
|
||||
feed?: string | Error
|
||||
childLines?: string[]
|
||||
} = {}): Fake {
|
||||
const lines: string[] = []
|
||||
const runs: string[][] = []
|
||||
const launched: string[] = []
|
||||
const menubarInstalls: string[] = []
|
||||
const reexecs: string[][] = []
|
||||
const menubarVersions = [...(overrides.menubarVersions ?? ['0.9.26'])]
|
||||
const ok = (stdout: string): RunResult => ({ code: 0, stdout, stderr: '', timedOut: false })
|
||||
const deps: UpdateDeps = {
|
||||
platform: 'darwin',
|
||||
cliVersion: '0.9.26',
|
||||
findGlobalCli: async () => npmCli('0.9.26'),
|
||||
findExecutable: async name => `/opt/homebrew/bin/${name}`,
|
||||
run: async (command, args) => {
|
||||
runs.push([command, ...args])
|
||||
if (args[0] === 'view') return overrides.npmView ?? ok(`${overrides.npmLatest ?? '0.9.26'}\n`)
|
||||
if (args[0] === 'install') return overrides.npmInstall ?? ok('')
|
||||
if (args[0] === 'info') return ok(JSON.stringify({ formulae: [{ versions: { stable: overrides.brewFormula ?? '0.9.26' } }] }))
|
||||
return ok('')
|
||||
},
|
||||
fetchText: async () => {
|
||||
if (overrides.feed instanceof Error) throw overrides.feed
|
||||
return overrides.feed ?? LATEST_MAC_YML
|
||||
},
|
||||
menubar: async () => {
|
||||
const version = menubarVersions.length > 1 ? menubarVersions.shift()! : menubarVersions[0]
|
||||
return version === undefined ? null : { path: '/Users/a/Applications/CodeBurnMenubar.app', version }
|
||||
},
|
||||
installMenubar: async version => {
|
||||
menubarInstalls.push(version)
|
||||
console.log('Downloading CodeBurnMenubar-v0.9.27.zip...')
|
||||
},
|
||||
desktop: async () => overrides.desktopApp === undefined
|
||||
? { path: '/Applications/CodeBurn.app', version: '0.9.26' }
|
||||
: overrides.desktopApp,
|
||||
launchDesktop: async path => { launched.push(path) },
|
||||
selfPath: async () => '/opt/homebrew/lib/node_modules/codeburn/dist/cli.js',
|
||||
reexec: async (entry, args, onLine) => {
|
||||
reexecs.push([entry, ...args])
|
||||
for (const line of overrides.childLines ?? []) onLine(line)
|
||||
return 0
|
||||
},
|
||||
write: line => { lines.push(line) },
|
||||
lockDir: () => dir,
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
deps, lines, runs, launched, menubarInstalls, reexecs,
|
||||
events: () => lines.map(line => JSON.parse(line) as Record<string, unknown>),
|
||||
}
|
||||
}
|
||||
|
||||
const ALL = ['cli', 'menubar', 'desktop'] as const
|
||||
|
||||
describe('runUpdate --json contract', () => {
|
||||
it('prints one step per component and one result, every line JSON', async () => {
|
||||
const f = fake()
|
||||
const exit = await runUpdate({ components: [...ALL], check: true, json: true }, f.deps)
|
||||
expect(exit).toBe(0)
|
||||
const events = f.events()
|
||||
expect(events.map(e => [e.type, e.component, e.state, e.code])).toEqual([
|
||||
['step', 'cli', 'skipped', 'up_to_date'],
|
||||
['step', 'menubar', 'skipped', 'up_to_date'],
|
||||
['step', 'desktop', 'skipped', 'up_to_date'],
|
||||
['result', undefined, undefined, undefined],
|
||||
])
|
||||
for (const step of events.slice(0, 3)) {
|
||||
expect(Object.keys(step).every(k => ['type', 'component', 'state', 'from', 'to', 'code', 'message', 'command'].includes(k))).toBe(true)
|
||||
expect(step.command).toBeUndefined()
|
||||
}
|
||||
expect(events[3]).toEqual({
|
||||
type: 'result',
|
||||
ok: true,
|
||||
components: {
|
||||
cli: { installed: '0.9.26', latest: '0.9.26', state: 'skipped', code: 'up_to_date', method: 'npm' },
|
||||
menubar: { installed: '0.9.26', latest: '0.9.26', state: 'skipped', code: 'up_to_date' },
|
||||
desktop: { installed: '0.9.26', latest: '0.9.26', state: 'skipped', code: 'up_to_date' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('--check reports an available update as skipped/ok and changes nothing', async () => {
|
||||
const f = fake({ npmLatest: '0.9.27', menubarVersions: ['0.9.25'], desktopApp: { path: '/Applications/CodeBurn.app', version: '0.9.25' } })
|
||||
expect(await runUpdate({ components: [...ALL], check: true, json: true }, f.deps)).toBe(0)
|
||||
expect(f.events().slice(0, 3).map(e => [e.component, e.state, e.code, e.from, e.to])).toEqual([
|
||||
['cli', 'skipped', 'ok', '0.9.26', '0.9.27'],
|
||||
['menubar', 'skipped', 'ok', '0.9.25', '0.9.26'],
|
||||
['desktop', 'skipped', 'ok', '0.9.25', '0.9.26'],
|
||||
])
|
||||
expect(f.runs.some(r => r[1] === 'install')).toBe(false)
|
||||
expect(f.menubarInstalls).toEqual([])
|
||||
expect(f.launched).toEqual([])
|
||||
})
|
||||
|
||||
it('--local never touches the network and compares against this CLI', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => npmCli('0.9.25'),
|
||||
fetchText: async () => { throw new Error('network used') },
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli', 'menubar'], local: true, json: true }, f.deps)).toBe(0)
|
||||
expect(f.runs).toEqual([])
|
||||
expect(f.events()[0]).toMatchObject({ component: 'cli', state: 'skipped', code: 'ok', from: '0.9.25', to: '0.9.26' })
|
||||
})
|
||||
|
||||
it('never downgrades a CLI newer than npm latest', async () => {
|
||||
const f = fake({ findGlobalCli: async () => npmCli('0.9.27'), npmLatest: '0.9.26' })
|
||||
expect(await runUpdate({ components: ['cli'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.events()[0]).toMatchObject({ component: 'cli', state: 'skipped', code: 'up_to_date' })
|
||||
expect(f.runs.some(r => r[1] === 'install')).toBe(false)
|
||||
})
|
||||
|
||||
it('updates with npm, then re-executes the new CLI for the rest and merges its result', async () => {
|
||||
let installed = '0.9.25'
|
||||
const f = fake({
|
||||
findGlobalCli: async () => npmCli(installed),
|
||||
npmLatest: '0.9.26',
|
||||
run: async (command, args) => {
|
||||
f.runs.push([command, ...args])
|
||||
if (args[0] === 'install') installed = '0.9.26'
|
||||
return { code: 0, stdout: args[0] === 'view' ? '0.9.26\n' : '', stderr: '', timedOut: false }
|
||||
},
|
||||
childLines: [
|
||||
'{"type":"step","component":"menubar","state":"done","from":"0.9.25","to":"0.9.26","code":"ok","message":"Menu bar app updated to 0.9.26."}',
|
||||
'not json',
|
||||
'{"type":"step","component":"desktop","state":"skipped","code":"up_to_date","message":"Desktop app 0.9.26 is up to date."}',
|
||||
'{"type":"result","ok":true,"components":{"menubar":{"installed":"0.9.25","latest":"0.9.26","state":"done"},"desktop":{"installed":"0.9.26","latest":"0.9.26","state":"skipped"}}}',
|
||||
],
|
||||
})
|
||||
expect(await runUpdate({ components: [...ALL], json: true }, f.deps)).toBe(0)
|
||||
expect(f.runs.find(r => r[1] === 'install')).toEqual([
|
||||
'/opt/homebrew/bin/npm', 'install', '-g', 'codeburn@0.9.26', '--force', '--prefer-online', '--no-fund', '--no-audit',
|
||||
])
|
||||
expect(f.reexecs).toEqual([[join('/opt/homebrew/lib/node_modules/codeburn/dist/cli.js'), 'update', '--menubar', '--desktop', '--json']])
|
||||
const events = f.events()
|
||||
expect(events.filter(e => e.type === 'result')).toHaveLength(1)
|
||||
expect(events.map(e => [e.component, e.state])).toEqual([
|
||||
['cli', 'start'], ['cli', 'done'], ['menubar', 'done'], ['desktop', 'skipped'], [undefined, undefined],
|
||||
])
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
ok: true,
|
||||
components: {
|
||||
cli: { installed: '0.9.25', latest: '0.9.26', state: 'done', method: 'npm' },
|
||||
menubar: { state: 'done' },
|
||||
desktop: { state: 'skipped' },
|
||||
},
|
||||
})
|
||||
expect(f.menubarInstalls).toEqual([])
|
||||
})
|
||||
|
||||
it('stays in-process when the updated CLI is not the one running (desktop bundled CLI)', async () => {
|
||||
let installed = '0.9.25'
|
||||
const f = fake({
|
||||
findGlobalCli: async () => npmCli(installed),
|
||||
selfPath: async () => '/Applications/CodeBurn.app/Contents/Resources/cli/dist/launch.js',
|
||||
run: async (_command, args) => {
|
||||
if (args[0] === 'install') installed = '0.9.26'
|
||||
return { code: 0, stdout: args[0] === 'view' ? '0.9.26\n' : '', stderr: '', timedOut: false }
|
||||
},
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli', 'menubar'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.reexecs).toEqual([])
|
||||
expect(f.events().map(e => [e.component, e.code])).toContainEqual(['menubar', 'up_to_date'])
|
||||
})
|
||||
|
||||
it('reports permission with the exact command and no retry', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => npmCli('0.9.25'),
|
||||
npmInstall: { code: 243, stdout: '', stderr: 'npm error code EACCES\nnpm error path /usr/local/lib/node_modules/codeburn', timedOut: false },
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli'], json: true }, f.deps)).toBe(1)
|
||||
expect(f.events().find(e => e.state === 'error')).toMatchObject({
|
||||
component: 'cli', code: 'permission', command: 'npm install -g codeburn@latest', from: '0.9.25', to: '0.9.26',
|
||||
})
|
||||
expect(f.runs.filter(r => r[1] === 'install')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a tarball npm has not propagated as not_propagated', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => npmCli('0.9.25'),
|
||||
npmInstall: { code: 1, stdout: '', stderr: 'npm error code ETARGET\nnpm error notarget No matching version found for codeburn@0.9.26.', timedOut: false },
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli'], json: true }, f.deps)).toBe(1)
|
||||
expect(f.events()[1]).toMatchObject({ state: 'error', code: 'not_propagated' })
|
||||
})
|
||||
|
||||
it('offline: a registry that cannot be reached and a feed that times out give clear codes', async () => {
|
||||
const timeout = new Error('The operation was aborted due to timeout')
|
||||
timeout.name = 'TimeoutError'
|
||||
const f = fake({
|
||||
npmView: { code: 1, stdout: '', stderr: 'npm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/codeburn failed', timedOut: false },
|
||||
feed: timeout,
|
||||
})
|
||||
expect(await runUpdate({ components: [...ALL], check: true, json: true }, f.deps)).toBe(1)
|
||||
expect(f.events().map(e => [e.component, e.state, e.code])).toEqual([
|
||||
['cli', 'error', 'download_fail'],
|
||||
['menubar', 'skipped', 'up_to_date'],
|
||||
['desktop', 'error', 'timeout'],
|
||||
[undefined, undefined, undefined],
|
||||
])
|
||||
expect(f.events().at(-1)?.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('a second run gets busy for every component and never runs npm', async () => {
|
||||
const release = await acquireUpdateLock(dir)
|
||||
const f = fake({ findGlobalCli: async () => npmCli('0.9.25') })
|
||||
expect(await runUpdate({ components: [...ALL], json: true }, f.deps)).toBe(1)
|
||||
expect(f.events().slice(0, 3).map(e => [e.component, e.state, e.code])).toEqual([
|
||||
['cli', 'error', 'busy'], ['menubar', 'error', 'busy'], ['desktop', 'error', 'busy'],
|
||||
])
|
||||
expect(f.runs).toEqual([])
|
||||
await (release as () => Promise<void>)()
|
||||
})
|
||||
|
||||
it('brew: a formula behind npm is managed, not an error', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => ({ ...npmCli('0.9.26'), method: 'brew', root: '/opt/homebrew/Cellar/codeburn/0.9.26/libexec/lib/node_modules/codeburn' }),
|
||||
npmLatest: '0.9.27',
|
||||
brewFormula: '0.9.26',
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.events()[0]).toMatchObject({ state: 'skipped', code: 'managed', message: 'Homebrew has not published 0.9.27 yet.' })
|
||||
expect(f.events()[1]).toMatchObject({ components: { cli: { installed: '0.9.26', latest: '0.9.26', state: 'skipped', code: 'managed', method: 'brew' } } })
|
||||
expect(f.runs.some(r => r[1] === 'upgrade')).toBe(false)
|
||||
})
|
||||
|
||||
it('brew: an available formula is reported as cli.latest, not npm latest', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => ({ ...npmCli('0.9.25'), method: 'brew', root: '/opt/homebrew/Cellar/codeburn/0.9.25/libexec/lib/node_modules/codeburn' }),
|
||||
npmLatest: '0.9.27',
|
||||
brewFormula: '0.9.26',
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli'], check: true, json: true }, f.deps)).toBe(0)
|
||||
expect(f.events()[1]).toMatchObject({ components: { cli: { installed: '0.9.25', latest: '0.9.26', code: 'ok' } } })
|
||||
})
|
||||
|
||||
it('brew: up to date reports the formula as cli.latest', async () => {
|
||||
const f = fake({
|
||||
findGlobalCli: async () => ({ ...npmCli('0.9.26'), method: 'brew', root: '/opt/homebrew/Cellar/codeburn/0.9.26/libexec/lib/node_modules/codeburn' }),
|
||||
npmLatest: '0.9.26',
|
||||
brewFormula: '0.9.26',
|
||||
})
|
||||
expect(await runUpdate({ components: ['cli'], check: true, json: true }, f.deps)).toBe(0)
|
||||
expect(f.events()[1]).toMatchObject({ components: { cli: { latest: '0.9.26', code: 'up_to_date', method: 'brew' } } })
|
||||
expect(f.runs.some(r => r[1] === 'info')).toBe(true)
|
||||
})
|
||||
|
||||
it('pnpm, bun and volta get no_manager with their own command', async () => {
|
||||
const f = fake({ findGlobalCli: async () => ({ ...npmCli('0.9.25'), method: 'pnpm' }) })
|
||||
expect(await runUpdate({ components: ['cli'], json: true }, f.deps)).toBe(1)
|
||||
expect(f.events()[0]).toMatchObject({ state: 'error', code: 'no_manager', command: 'pnpm add -g codeburn@latest' })
|
||||
})
|
||||
|
||||
it('menubar: installs only an existing bundle, keeps stdout JSON, re-reads the version', async () => {
|
||||
const f = fake({ menubarVersions: ['0.9.25', '0.9.26'] })
|
||||
expect(await runUpdate({ components: ['menubar'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.menubarInstalls).toEqual(['0.9.26'])
|
||||
expect(f.events().map(e => [e.state, e.code])).toEqual([
|
||||
['start', undefined], ['progress', undefined], ['done', 'ok'], [undefined, undefined],
|
||||
])
|
||||
expect(f.events()[1]?.message).toBe('Downloading CodeBurnMenubar-v0.9.27.zip...')
|
||||
|
||||
const none = fake({ menubar: async () => null })
|
||||
expect(await runUpdate({ components: ['menubar'], json: true }, none.deps)).toBe(0)
|
||||
expect(none.events()[0]).toMatchObject({ state: 'skipped', code: 'not_installed' })
|
||||
expect(none.events()[1]).toMatchObject({ components: { menubar: { installed: null, code: 'not_installed' } } })
|
||||
expect(none.menubarInstalls).toEqual([])
|
||||
})
|
||||
|
||||
it('menubar: an unpublished target leaves the bundle alone as not_propagated', async () => {
|
||||
const f = fake({ menubarVersions: ['0.9.25', '0.9.25'] })
|
||||
expect(await runUpdate({ components: ['menubar'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.events().at(-2)).toMatchObject({ state: 'skipped', code: 'not_propagated' })
|
||||
})
|
||||
|
||||
it('menubar: a failed checksum is verify_fail', async () => {
|
||||
const f = fake({
|
||||
menubarVersions: ['0.9.25'],
|
||||
installMenubar: async () => { throw new Error('Checksum mismatch for /tmp/x.zip.') },
|
||||
})
|
||||
expect(await runUpdate({ components: ['menubar'], json: true }, f.deps)).toBe(1)
|
||||
expect(f.events().at(-2)).toMatchObject({ state: 'error', code: 'verify_fail', command: 'codeburn menubar --force' })
|
||||
})
|
||||
|
||||
it('desktop: hands off to the located copy on macOS', async () => {
|
||||
const f = fake({ desktopApp: { path: '/Users/cbtest/Applications/CodeBurn.app', version: '0.9.25' } })
|
||||
expect(await runUpdate({ components: ['desktop'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.launched).toEqual(['/Users/cbtest/Applications/CodeBurn.app'])
|
||||
expect(f.events()[0]).toMatchObject({ state: 'done', code: 'ok', from: '0.9.25', to: '0.9.26' })
|
||||
})
|
||||
|
||||
it('desktop: Windows opens the app but reports managed while NSIS auto-update is off', async () => {
|
||||
const exe = 'C:\\Users\\a\\AppData\\Local\\Programs\\CodeBurn\\CodeBurn.exe'
|
||||
const f = fake({ platform: 'win32', desktopApp: { path: exe, version: '0.9.25' }, feed: 'version: 0.9.26\n' })
|
||||
expect(await runUpdate({ components: ['desktop'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.launched).toEqual([exe])
|
||||
expect(f.events()[0]).toMatchObject({ state: 'skipped', code: 'managed' })
|
||||
|
||||
const store = fake({ platform: 'win32', desktopApp: { path: '', version: null, store: true } })
|
||||
expect(await runUpdate({ components: ['desktop'], json: true }, store.deps)).toBe(0)
|
||||
expect(store.events()[0]).toMatchObject({ state: 'skipped', code: 'managed' })
|
||||
expect(store.launched).toEqual([])
|
||||
})
|
||||
|
||||
it('desktop: Linux is managed without looking', async () => {
|
||||
const f = fake({ platform: 'linux', desktop: async () => { throw new Error('looked') } })
|
||||
expect(await runUpdate({ components: ['desktop', 'menubar'], json: true }, f.deps)).toBe(0)
|
||||
expect(f.events()[0]).toMatchObject({ component: 'menubar', state: 'skipped', code: 'not_installed' })
|
||||
expect(f.events()[1]).toMatchObject({ component: 'desktop', state: 'skipped', code: 'managed' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue