fix: auto-update silently fails in multiple scenarios (#1725)

- Prevent recursive update check during install (SPAWN_NO_UPDATE_CHECK=1)
- Increase fetch timeout from 5s to 10s for slow/cold connections
- Add 1-hour failure backoff to avoid repeated failed update attempts
- Bump CLI version to 0.6.6

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 11:17:54 -08:00 committed by GitHub
parent f2010ce3bd
commit 5192912b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 61 additions and 3 deletions

View file

@ -99,7 +99,7 @@ ensure_in_path() {
local install_dir="$1"
if echo "${PATH}" | tr ':' '\n' | grep -qx "${install_dir}"; then
echo ""
"${install_dir}/spawn" version
SPAWN_NO_UPDATE_CHECK=1 "${install_dir}/spawn" version
echo ""
printf "${GREEN}[spawn]${NC} Run ${BOLD}spawn${NC} to get started\n"
else

View file

@ -1,7 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import fs from "fs";
import path from "path";
// ── Test Helpers ───────────────────────────────────────────────────────────────
/** Remove the .update-failed backoff file so it doesn't interfere with tests */
function clearUpdateBackoff() {
try {
fs.unlinkSync(path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed"));
} catch {
// File may not exist
}
}
function mockEnv() {
const originalEnv = { ...process.env };
process.env.NODE_ENV = undefined;
@ -23,6 +34,7 @@ describe("update-check", () => {
beforeEach(() => {
originalEnv = mockEnv();
clearUpdateBackoff();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
// Mock process.exit to prevent tests from exiting
processExitSpy = spyOn(process, "exit").mockImplementation((() => {}) as any);

View file

@ -1,5 +1,7 @@
import "./unicode-detect.js"; // Ensure TERM is set before using symbols
import { execSync as nodeExecSync, execFileSync as nodeExecFileSync, type ExecSyncOptions, type ExecFileSyncOptions } from "child_process";
import fs from "fs";
import path from "path";
import pc from "picocolors";
import pkg from "../package.json" with { type: "json" };
import { RAW_BASE } from "./manifest.js";
@ -14,7 +16,8 @@ export const executor = {
// ── Constants ──────────────────────────────────────────────────────────────────
const FETCH_TIMEOUT = 5000; // 5 seconds
const FETCH_TIMEOUT = 10000; // 10 seconds
const UPDATE_BACKOFF_MS = 60 * 60 * 1000; // 1 hour
// Validate RAW_BASE matches expected GitHub raw content URL pattern (defense-in-depth, CWE-78)
const GITHUB_RAW_URL_PATTERN = /^https:\/\/raw\.githubusercontent\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@ -59,6 +62,42 @@ function compareVersions(current: string, latest: string): boolean {
return false; // Versions are equal
}
// ── Failure Backoff ──────────────────────────────────────────────────────────
function getUpdateFailedPath(): string {
return path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed");
}
export function isUpdateBackedOff(): boolean {
try {
const failedPath = getUpdateFailedPath();
const content = fs.readFileSync(failedPath, "utf8").trim();
const failedAt = parseInt(content, 10);
if (isNaN(failedAt)) return false;
return Date.now() - failedAt < UPDATE_BACKOFF_MS;
} catch {
return false;
}
}
function markUpdateFailed(): void {
try {
const failedPath = getUpdateFailedPath();
fs.mkdirSync(path.dirname(failedPath), { recursive: true });
fs.writeFileSync(failedPath, String(Date.now()));
} catch {
// Best-effort — don't break the CLI if we can't write the file
}
}
function clearUpdateFailed(): void {
try {
fs.unlinkSync(getUpdateFailedPath());
} catch {
// File may not exist — that's fine
}
}
/** Print boxed update banner to stderr */
function printUpdateBanner(latestVersion: string): void {
const line1 = `Update available: v${VERSION} -> v${latestVersion}`;
@ -143,8 +182,10 @@ function performAutoUpdate(latestVersion: string): void {
console.error();
console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`)));
clearUpdateFailed();
reExecWithArgs();
} catch {
markUpdateFailed();
console.error();
console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));
console.error(pc.dim(" Please update manually:"));
@ -159,7 +200,7 @@ function performAutoUpdate(latestVersion: string): void {
/**
* Check for updates on every run and auto-update if available.
* Uses a 5-second timeout to avoid blocking for too long.
* Uses a 10-second timeout to avoid blocking for too long.
*/
export async function checkForUpdates(): Promise<void> {
// Skip in test environment
@ -172,6 +213,11 @@ export async function checkForUpdates(): Promise<void> {
return;
}
// Skip if a recent auto-update failed (backoff for 1 hour)
if (isUpdateBackedOff()) {
return;
}
// Always fetch the latest version on every run
try {
const latestVersion = await fetchLatestVersion();