fix(updater): harden auto-update execution and release gating (#976)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-16 13:20:33 -08:00
committed by GitHub
parent 0e652712f5
commit ba71ad2696
12 changed files with 905 additions and 51 deletions

View File

@@ -31,6 +31,37 @@ jobs:
- name: Lint & Type Check
run: bun run check
update-chain-smoke:
needs: check
name: Update Chain Smoke
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bun install
- name: Build bundle
run: bun run build
- name: Manual update-chain smoke test
run: bun run test:update-chain:manual
build:
needs: check
name: ${{ matrix.name }}

View File

@@ -0,0 +1,37 @@
name: Nightly Update Smoke
on:
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
jobs:
startup-update-chain:
name: Startup Update Chain
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bun install
- name: Build bundle
run: bun run build
- name: Startup update-chain smoke test
run: bun run test:update-chain:startup

View File

@@ -13,7 +13,40 @@ on:
default: ""
jobs:
preflight:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bun install
- name: Lint & Type Check
run: bun run check
- name: Build bundle
run: bun run build
- name: Update-chain preflight smoke
run: bun run test:update-chain:manual
publish:
needs: preflight
runs-on: ubuntu-latest
environment: npm-publish
permissions:
@@ -107,18 +140,20 @@ jobs:
# Update package.json
jq --arg version "$NEW_VERSION" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
# Keep package-lock versions in sync for npm publish consistency
jq --arg version "$NEW_VERSION" '.version = $version | .packages[""].version = $version' package-lock.json > package-lock.json.tmp
mv package-lock.json.tmp package-lock.json
echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Commit and push version bump
- name: Commit and tag version bump (local only)
run: |
git add package.json
git add package.json package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} [skip ci]"
git tag "${{ steps.version.outputs.tag }}"
git push origin main
git push origin "${{ steps.version.outputs.tag }}"
- name: Install dependencies
env:
@@ -142,6 +177,14 @@ jobs:
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
run: ./letta.js --prompt "ping" --tools "" --permission-mode plan
- name: Publish to npm
run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }}
- name: Push commit and tag after publish
run: |
git push origin main
git push origin "${{ steps.version.outputs.tag }}"
- name: Create GitHub Release
if: steps.version.outputs.is_prerelease == 'false'
uses: softprops/action-gh-release@v2
@@ -153,6 +196,3 @@ jobs:
generate_release_notes: true
files: letta.js
fail_on_unmatched_files: true
- name: Publish to npm
run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }}

View File

@@ -62,6 +62,8 @@
"check": "bun run scripts/check.js",
"dev": "bun --loader:.md=text --loader:.mdx=text --loader:.txt=text run src/index.ts",
"build": "node scripts/postinstall-patches.js && bun run build.js",
"test:update-chain:manual": "bun run src/tests/update-chain-smoke.ts --mode manual",
"test:update-chain:startup": "bun run src/tests/update-chain-smoke.ts --mode startup",
"prepublishOnly": "bun run build",
"postinstall": "node scripts/postinstall-patches.js"
},

View File

@@ -267,7 +267,7 @@ export async function handleHeadlessCommand(
const fromAfFile = values["from-af"] as string | undefined;
const preLoadSkillsRaw = values["pre-load-skills"] as string | undefined;
const maxTurnsRaw = values["max-turns"] as string | undefined;
const tagsRaw = values["tags"] as string | undefined;
const tagsRaw = values.tags as string | undefined;
// Parse and validate base tools
let tags: string[] | undefined;

View File

@@ -19,6 +19,7 @@ import { ProfileSelectionInline } from "./cli/profile-selection";
import { runSubcommand } from "./cli/subcommands/router";
import { permissionMode } from "./permissions/mode";
import { settingsManager } from "./settings-manager";
import { startStartupAutoUpdateCheck } from "./startup-auto-update";
import { telemetry } from "./telemetry";
import { loadTools } from "./tools/manager";
import { markMilestone } from "./utils/timing";
@@ -365,21 +366,7 @@ async function main(): Promise<void> {
// Check for updates on startup (non-blocking)
const { checkAndAutoUpdate } = await import("./updater/auto-update");
checkAndAutoUpdate()
.then((result) => {
// Surface ENOTEMPTY failures so users know how to fix
if (result?.enotemptyFailed) {
console.error(
"\nAuto-update failed due to filesystem issue (ENOTEMPTY).",
);
console.error(
"Fix: rm -rf $(npm prefix -g)/lib/node_modules/@letta-ai/letta-code && npm i -g @letta-ai/letta-code\n",
);
}
})
.catch(() => {
// Silently ignore other update failures (network timeouts, etc.)
});
startStartupAutoUpdateCheck(checkAndAutoUpdate);
// Clean up old overflow files (non-blocking, 24h retention)
const { cleanupOldOverflowFiles } = await import("./tools/impl/overflow");
@@ -472,6 +459,16 @@ async function main(): Promise<void> {
// Handle help flag first
if (values.help) {
printHelp();
// Test-only hook to keep process alive briefly so startup auto-update can run.
const helpDelayMs = Number.parseInt(
process.env.LETTA_TEST_HELP_EXIT_DELAY_MS ?? "",
10,
);
if (Number.isFinite(helpDelayMs) && helpDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, helpDelayMs));
}
process.exit(0);
}

View File

@@ -0,0 +1,21 @@
export function startStartupAutoUpdateCheck(
checkAndAutoUpdate: () => Promise<{ enotemptyFailed?: boolean } | undefined>,
logError: (
message?: unknown,
...optionalParams: unknown[]
) => void = console.error,
): void {
checkAndAutoUpdate()
.then((result) => {
// Surface ENOTEMPTY failures so users know how to fix
if (result?.enotemptyFailed) {
logError("\nAuto-update failed due to filesystem issue (ENOTEMPTY).");
logError(
"Fix: rm -rf $(npm prefix -g)/lib/node_modules/@letta-ai/letta-code && npm i -g @letta-ai/letta-code\n",
);
}
})
.catch(() => {
// Silently ignore other update failures (network timeouts, etc.)
});
}

View File

@@ -455,7 +455,7 @@ describe("rebuildInputWithFreshDenials", () => {
// Stale approval stripped, fresh denial prepended, message preserved
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe("approval");
expect(result[0]?.type).toBe("approval");
const approvalPayload = result[0] as {
type: "approval";
approvals: Array<{
@@ -464,10 +464,10 @@ describe("rebuildInputWithFreshDenials", () => {
reason: string;
}>;
};
expect(approvalPayload.approvals[0]!.tool_call_id).toBe("real-id");
expect(approvalPayload.approvals[0]!.approve).toBe(false);
expect(approvalPayload.approvals[0]!.reason).toBe("Auto-denied");
expect(result[1]!.type).toBe("message");
expect(approvalPayload.approvals[0]?.tool_call_id).toBe("real-id");
expect(approvalPayload.approvals[0]?.approve).toBe(false);
expect(approvalPayload.approvals[0]?.reason).toBe("Auto-denied");
expect(result[1]?.type).toBe("message");
});
test("strips stale approval with no server approvals (clean retry)", () => {
@@ -480,7 +480,7 @@ describe("rebuildInputWithFreshDenials", () => {
// Only message remains
expect(result).toHaveLength(1);
expect(result[0]!.type).toBe("message");
expect(result[0]?.type).toBe("message");
});
test("prepends fresh denials when no stale approvals to strip", () => {
@@ -499,8 +499,8 @@ describe("rebuildInputWithFreshDenials", () => {
// Fresh denial prepended, message preserved
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe("approval");
expect(result[1]!.type).toBe("message");
expect(result[0]?.type).toBe("approval");
expect(result[1]?.type).toBe("message");
});
test("handles multiple stale approvals and multiple server approvals", () => {
@@ -542,15 +542,15 @@ describe("rebuildInputWithFreshDenials", () => {
// Both stale approvals stripped, one fresh denial payload prepended, message kept
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe("approval");
expect(result[0]?.type).toBe("approval");
const approvalPayload = result[0] as {
type: "approval";
approvals: Array<{ tool_call_id: string }>;
};
expect(approvalPayload.approvals).toHaveLength(2);
expect(approvalPayload.approvals[0]!.tool_call_id).toBe("new-1");
expect(approvalPayload.approvals[1]!.tool_call_id).toBe("new-2");
expect(result[1]!.type).toBe("message");
expect(approvalPayload.approvals[0]?.tool_call_id).toBe("new-1");
expect(approvalPayload.approvals[1]?.tool_call_id).toBe("new-2");
expect(result[1]?.type).toBe("message");
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import { startStartupAutoUpdateCheck } from "../startup-auto-update";
describe("startStartupAutoUpdateCheck", () => {
test("logs ENOTEMPTY guidance when updater returns enotemptyFailed", async () => {
const logs: string[] = [];
const logError = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(" "));
};
startStartupAutoUpdateCheck(
async () => ({ enotemptyFailed: true }),
logError,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logs.length).toBe(2);
expect(logs[0]).toContain("ENOTEMPTY");
expect(logs[1]).toContain("npm i -g @letta-ai/letta-code");
});
test("does not throw when updater rejects", async () => {
const logs: string[] = [];
const logError = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(" "));
};
startStartupAutoUpdateCheck(async () => {
throw new Error("boom");
}, logError);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logs.length).toBe(0);
});
test("does not log when updater succeeds without ENOTEMPTY", async () => {
const logs: string[] = [];
const logError = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(" "));
};
startStartupAutoUpdateCheck(async () => undefined, logError);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logs.length).toBe(0);
});
});

View File

@@ -0,0 +1,461 @@
import { spawn } from "node:child_process";
import {
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
type Mode = "manual" | "startup";
type Args = {
mode: Mode;
};
const PROJECT_ROOT = process.cwd();
const PACKAGE_NAME = "@letta-ai/letta-code";
const OLD_VERSION = "0.0.1";
const NEW_VERSION = "0.0.2";
const REGISTRY_PORT = 4873;
const REGISTRY_URL = `http://127.0.0.1:${REGISTRY_PORT}`;
const VERDACCIO_IMAGE = "verdaccio/verdaccio:5";
const REGISTRY_USER = "ci-user";
const REGISTRY_PASS = "ci-pass";
const REGISTRY_EMAIL = "ci@example.com";
function parseArgs(argv: string[]): Args {
const args: Args = { mode: "manual" };
for (let i = 0; i < argv.length; i++) {
const value = argv[i];
if (value === "--mode") {
const next = argv[++i] as Mode | undefined;
if (next === "manual" || next === "startup") {
args.mode = next;
} else {
throw new Error(`Invalid --mode value: ${next}`);
}
}
}
return args;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractSemver(text: string): string {
const match = text.match(/\b\d+\.\d+\.\d+\b/);
if (!match) {
throw new Error(`Could not parse semantic version from: ${text}`);
}
return match[0];
}
async function runCommand(
command: string,
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
expectExit?: number;
} = {},
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
const {
cwd = PROJECT_ROOT,
env = process.env,
timeoutMs = 180000,
expectExit = 0,
} = options;
return new Promise((resolve, reject) => {
const proc = spawn(command, args, {
cwd,
env,
shell: false,
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(
new Error(
`Command timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
}, timeoutMs);
proc.on("close", (exitCode) => {
clearTimeout(timeout);
if (typeof expectExit === "number" && exitCode !== expectExit) {
reject(
new Error(
`Unexpected exit ${exitCode}: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
return;
}
resolve({ stdout, stderr, exitCode });
});
proc.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
}
async function waitForVerdaccio(timeoutMs = 45000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(`${REGISTRY_URL}/-/ping`, {
signal: AbortSignal.timeout(2500),
});
if (response.ok) return;
} catch {
// retry
}
await sleep(500);
}
throw new Error(`Timed out waiting for Verdaccio at ${REGISTRY_URL}`);
}
function writePermissiveVerdaccioConfig(configPath: string) {
const config = `storage: /verdaccio/storage
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@*/*':
access: $all
publish: $all
unpublish: $all
proxy: npmjs
'**':
access: $all
publish: $all
unpublish: $all
proxy: npmjs
auth:
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: 1000
server:
keepAliveTimeout: 60
logs:
- { type: stdout, format: pretty, level: http }
`;
writeFileSync(configPath, config, "utf8");
}
function setVersionInPackageJson(packageJsonPath: string, version: string) {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version: string;
};
packageJson.version = version;
writeFileSync(
packageJsonPath,
`${JSON.stringify(packageJson, null, 2)}\n`,
"utf8",
);
}
async function buildAndPackVersion(
workspaceDir: string,
version: string,
env: NodeJS.ProcessEnv,
): Promise<string> {
const packageJsonPath = join(workspaceDir, "package.json");
setVersionInPackageJson(packageJsonPath, version);
const packageLockPath = join(workspaceDir, "package-lock.json");
setVersionInPackageJson(packageLockPath, version);
await runCommand("bun", ["run", "build"], {
cwd: workspaceDir,
env,
timeoutMs: 300000,
});
const packResult = await runCommand("npm", ["pack", "--json"], {
cwd: workspaceDir,
env,
});
const packed = JSON.parse(packResult.stdout) as Array<{ filename: string }>;
const tarballName = packed[0]?.filename;
if (!tarballName) {
throw new Error(`npm pack did not return filename: ${packResult.stdout}`);
}
const sourceTarball = join(workspaceDir, tarballName);
const targetTarball = join(workspaceDir, `letta-code-${version}.tgz`);
renameSync(sourceTarball, targetTarball);
return targetTarball;
}
async function authenticateToRegistry(
npmUserConfigPath: string,
): Promise<void> {
const response = await fetch(
`${REGISTRY_URL}/-/user/org.couchdb.user:${encodeURIComponent(REGISTRY_USER)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: REGISTRY_USER,
password: REGISTRY_PASS,
email: REGISTRY_EMAIL,
}),
signal: AbortSignal.timeout(10000),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Failed to authenticate with Verdaccio (${response.status}): ${text}`,
);
}
const data = (await response.json()) as { token?: string };
if (typeof data.token !== "string" || data.token.length === 0) {
throw new Error("Verdaccio auth response missing token");
}
const registryHost = new URL(REGISTRY_URL).host;
writeFileSync(
npmUserConfigPath,
`registry=${REGISTRY_URL}\n//${registryHost}/:_authToken=${data.token}\nalways-auth=true\n`,
"utf8",
);
}
async function publishTarball(
tarballPath: string,
env: NodeJS.ProcessEnv,
): Promise<void> {
await runCommand(
"npm",
["publish", tarballPath, "--registry", REGISTRY_URL, "--access", "public"],
{ env, timeoutMs: 180000 },
);
}
async function getGlobalBinDir(env: NodeJS.ProcessEnv): Promise<string> {
const result = await runCommand("npm", ["prefix", "-g"], { env });
return `${result.stdout.trim()}/bin`;
}
async function getInstalledVersion(env: NodeJS.ProcessEnv): Promise<string> {
const result = await runCommand("letta", ["--version"], { env });
return extractSemver(result.stdout.trim());
}
async function prepareWorkspace(
baseEnv: NodeJS.ProcessEnv,
workspaceDir: string,
) {
await runCommand(
"git",
["clone", "--depth", "1", PROJECT_ROOT, workspaceDir],
{
env: baseEnv,
timeoutMs: 240000,
},
);
await runCommand("bun", ["install"], {
cwd: workspaceDir,
env: baseEnv,
timeoutMs: 240000,
});
}
async function runManualUpdateFlow(env: NodeJS.ProcessEnv) {
const versionBefore = await getInstalledVersion(env);
if (versionBefore !== OLD_VERSION) {
throw new Error(
`Expected pre-update version ${OLD_VERSION}, got ${versionBefore}`,
);
}
await runCommand("letta", ["update"], {
env: {
...env,
DISABLE_AUTOUPDATER: "1",
},
timeoutMs: 180000,
});
const versionAfter = await getInstalledVersion(env);
if (versionAfter !== NEW_VERSION) {
throw new Error(
`Expected post-update version ${NEW_VERSION}, got ${versionAfter}`,
);
}
}
async function runStartupUpdateFlow(env: NodeJS.ProcessEnv) {
const versionBefore = await getInstalledVersion(env);
if (versionBefore !== OLD_VERSION) {
throw new Error(
`Expected pre-startup version ${OLD_VERSION}, got ${versionBefore}`,
);
}
const maxAttempts = 15;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await runCommand("letta", ["--help"], {
env: {
...env,
LETTA_TEST_HELP_EXIT_DELAY_MS: "3000",
},
timeoutMs: 120000,
});
const current = await getInstalledVersion(env);
if (current === NEW_VERSION) {
return;
}
await sleep(1500);
}
const finalVersion = await getInstalledVersion(env);
throw new Error(
`Startup auto-update did not converge to ${NEW_VERSION}; final version ${finalVersion}`,
);
}
async function main() {
const { mode } = parseArgs(process.argv.slice(2));
if (process.platform !== "linux") {
console.log(
"SKIP: update-chain smoke currently targets Linux runners only",
);
return;
}
const tempRoot = mkdtempSync(join(tmpdir(), "letta-update-chain-"));
const workspaceDir = join(tempRoot, "workspace");
const npmPrefix = join(tempRoot, "npm-prefix");
const npmCache = join(tempRoot, "npm-cache");
const npmUserConfig = join(tempRoot, ".npmrc");
const verdaccioConfigPath = join(tempRoot, "verdaccio.yaml");
const containerName = `letta-update-smoke-${Date.now()}`;
const baseEnv: NodeJS.ProcessEnv = {
...process.env,
npm_config_prefix: npmPrefix,
npm_config_cache: npmCache,
npm_config_userconfig: npmUserConfig,
NPM_CONFIG_PREFIX: npmPrefix,
NPM_CONFIG_CACHE: npmCache,
NPM_CONFIG_USERCONFIG: npmUserConfig,
};
writePermissiveVerdaccioConfig(verdaccioConfigPath);
try {
await runCommand("docker", [
"run",
"-d",
"--rm",
"--name",
containerName,
"-p",
`${REGISTRY_PORT}:4873`,
"-v",
`${verdaccioConfigPath}:/verdaccio/conf/config.yaml:ro`,
VERDACCIO_IMAGE,
]);
await waitForVerdaccio();
await prepareWorkspace(baseEnv, workspaceDir);
await authenticateToRegistry(npmUserConfig);
const oldTarball = await buildAndPackVersion(
workspaceDir,
OLD_VERSION,
baseEnv,
);
const newTarball = await buildAndPackVersion(
workspaceDir,
NEW_VERSION,
baseEnv,
);
await publishTarball(oldTarball, baseEnv);
await runCommand(
"npm",
[
"install",
"-g",
`${PACKAGE_NAME}@${OLD_VERSION}`,
"--registry",
REGISTRY_URL,
],
{ env: baseEnv, timeoutMs: 180000 },
);
await publishTarball(newTarball, baseEnv);
const globalBinDir = await getGlobalBinDir(baseEnv);
const testEnv: NodeJS.ProcessEnv = {
...baseEnv,
PATH: `${globalBinDir}:${process.env.PATH ?? ""}`,
LETTA_CODE_AGENT_ROLE: "subagent",
LETTA_UPDATE_PACKAGE_NAME: PACKAGE_NAME,
LETTA_UPDATE_REGISTRY_BASE_URL: REGISTRY_URL,
LETTA_UPDATE_INSTALL_REGISTRY_URL: REGISTRY_URL,
};
const resolved = await runCommand("bash", ["-lc", "command -v letta"], {
env: testEnv,
});
if (!resolved.stdout.trim().startsWith(globalBinDir)) {
throw new Error(
`Expected letta binary in ${globalBinDir}, got ${resolved.stdout.trim()}`,
);
}
if (mode === "manual") {
await runManualUpdateFlow(testEnv);
console.log("OK: manual update-chain smoke passed");
} else {
await runStartupUpdateFlow(testEnv);
console.log("OK: startup update-chain smoke passed");
}
} finally {
await runCommand("docker", ["stop", containerName], {
expectExit: undefined,
}).catch(() => {});
rmSync(tempRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(String(error instanceof Error ? error.stack : error));
process.exit(1);
});

View File

@@ -3,8 +3,13 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
buildInstallCommand,
buildLatestVersionUrl,
checkForUpdate,
detectPackageManager,
resolveUpdateInstallRegistryUrl,
resolveUpdatePackageName,
resolveUpdateRegistryBaseUrl,
} from "../../updater/auto-update";
describe("auto-update ENOTEMPTY handling", () => {
@@ -218,15 +223,117 @@ describe("detectPackageManager", () => {
});
});
describe("update config resolution", () => {
test("resolveUpdatePackageName uses default when unset", () => {
expect(resolveUpdatePackageName({} as NodeJS.ProcessEnv)).toBe(
"@letta-ai/letta-code",
);
});
test("resolveUpdatePackageName uses valid override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "@scope/pkg",
} as NodeJS.ProcessEnv),
).toBe("@scope/pkg");
});
test("resolveUpdatePackageName ignores invalid override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "bad pkg",
} as NodeJS.ProcessEnv),
).toBe("@letta-ai/letta-code");
});
test("resolveUpdatePackageName ignores command-substitution-like override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "@scope/pkg$(id)",
} as NodeJS.ProcessEnv),
).toBe("@letta-ai/letta-code");
});
test("resolveUpdateRegistryBaseUrl uses default when unset", () => {
expect(resolveUpdateRegistryBaseUrl({} as NodeJS.ProcessEnv)).toBe(
"https://registry.npmjs.org",
);
});
test("resolveUpdateRegistryBaseUrl uses valid override", () => {
expect(
resolveUpdateRegistryBaseUrl({
LETTA_UPDATE_REGISTRY_BASE_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toBe("http://localhost:4873");
});
test("resolveUpdateRegistryBaseUrl ignores invalid override", () => {
expect(
resolveUpdateRegistryBaseUrl({
LETTA_UPDATE_REGISTRY_BASE_URL: "javascript:alert(1)",
} as NodeJS.ProcessEnv),
).toBe("https://registry.npmjs.org");
});
test("resolveUpdateInstallRegistryUrl returns null when unset", () => {
expect(resolveUpdateInstallRegistryUrl({} as NodeJS.ProcessEnv)).toBeNull();
});
test("resolveUpdateInstallRegistryUrl returns valid override", () => {
expect(
resolveUpdateInstallRegistryUrl({
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toBe("http://localhost:4873");
});
test("resolveUpdateInstallRegistryUrl rejects command-substitution-like override", () => {
expect(
resolveUpdateInstallRegistryUrl({
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873/$(id)",
} as NodeJS.ProcessEnv),
).toBeNull();
});
test("buildLatestVersionUrl constructs expected endpoint", () => {
expect(
buildLatestVersionUrl("@letta-ai/letta-code", "http://localhost:4873/"),
).toBe("http://localhost:4873/@letta-ai/letta-code/latest");
});
test("buildInstallCommand adds registry when configured", () => {
expect(
buildInstallCommand("npm", {
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toContain("--registry http://localhost:4873");
});
test("buildInstallCommand uses default package and no registry by default", () => {
expect(buildInstallCommand("pnpm", {} as NodeJS.ProcessEnv)).toBe(
"pnpm add -g @letta-ai/letta-code@latest",
);
});
});
describe("checkForUpdate with fetch", () => {
let originalFetch: typeof globalThis.fetch;
let originalRegistry: string | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalRegistry = process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
delete process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalRegistry === undefined) {
delete process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
} else {
process.env.LETTA_UPDATE_REGISTRY_BASE_URL = originalRegistry;
}
});
test("returns updateAvailable when registry version differs", async () => {
@@ -273,4 +380,22 @@ describe("checkForUpdate with fetch", () => {
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("uses registry override URL", async () => {
process.env.LETTA_UPDATE_REGISTRY_BASE_URL = "http://localhost:4873";
const capturedUrls: string[] = [];
globalThis.fetch = mock((url: string | URL | Request) => {
capturedUrls.push(String(url));
return Promise.resolve(
new Response(JSON.stringify({ version: "99.0.0" }), { status: 200 }),
);
}) as unknown as typeof fetch;
await checkForUpdate();
expect(capturedUrls.length).toBe(1);
expect(capturedUrls[0]).toBe(
"http://localhost:4873/@letta-ai/letta-code/latest",
);
});
});

View File

@@ -1,11 +1,11 @@
import { exec } from "node:child_process";
import { execFile } from "node:child_process";
import { realpathSync } from "node:fs";
import { readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { getVersion } from "../version";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// Debug logging - set LETTA_DEBUG_AUTOUPDATE=1 to enable
const DEBUG = process.env.LETTA_DEBUG_AUTOUPDATE === "1";
@@ -26,13 +26,97 @@ interface UpdateCheckResult {
// Supported package managers for global install/update
export type PackageManager = "npm" | "bun" | "pnpm";
const INSTALL_CMD: Record<PackageManager, string> = {
npm: "npm install -g @letta-ai/letta-code@latest",
bun: "bun add -g @letta-ai/letta-code@latest",
pnpm: "pnpm add -g @letta-ai/letta-code@latest",
const DEFAULT_UPDATE_PACKAGE_NAME = "@letta-ai/letta-code";
const DEFAULT_UPDATE_REGISTRY_BASE_URL = "https://registry.npmjs.org";
const UPDATE_PACKAGE_NAME_ENV = "LETTA_UPDATE_PACKAGE_NAME";
const UPDATE_REGISTRY_BASE_URL_ENV = "LETTA_UPDATE_REGISTRY_BASE_URL";
const UPDATE_INSTALL_REGISTRY_URL_ENV = "LETTA_UPDATE_INSTALL_REGISTRY_URL";
const INSTALL_ARG_PREFIX: Record<PackageManager, string[]> = {
npm: ["install", "-g"],
bun: ["add", "-g"],
pnpm: ["add", "-g"],
};
const VALID_PACKAGE_MANAGERS = new Set<string>(Object.keys(INSTALL_CMD));
const VALID_PACKAGE_MANAGERS = new Set<string>(Object.keys(INSTALL_ARG_PREFIX));
function normalizeUpdatePackageName(raw: string | undefined): string | null {
if (!raw) return null;
const value = raw.trim();
if (!value) return null;
// Basic npm package name validation: no whitespace/shell separators.
if (/\s/.test(value) || /["'`;|&$]/.test(value)) return null;
return value;
}
function normalizeRegistryUrl(raw: string | undefined): string | null {
if (!raw) return null;
const value = raw.trim();
if (!value || /\s/.test(value) || /["'`;|&$]/.test(value)) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return value.replace(/\/+$/, "");
} catch {
return null;
}
}
export function resolveUpdatePackageName(
env: NodeJS.ProcessEnv = process.env,
): string {
const custom = normalizeUpdatePackageName(env[UPDATE_PACKAGE_NAME_ENV]);
if (custom) {
return custom;
}
return DEFAULT_UPDATE_PACKAGE_NAME;
}
export function resolveUpdateRegistryBaseUrl(
env: NodeJS.ProcessEnv = process.env,
): string {
const custom = normalizeRegistryUrl(env[UPDATE_REGISTRY_BASE_URL_ENV]);
if (custom) {
return custom;
}
return DEFAULT_UPDATE_REGISTRY_BASE_URL;
}
export function resolveUpdateInstallRegistryUrl(
env: NodeJS.ProcessEnv = process.env,
): string | null {
return normalizeRegistryUrl(env[UPDATE_INSTALL_REGISTRY_URL_ENV]);
}
export function buildLatestVersionUrl(
packageName: string,
registryBaseUrl: string,
): string {
return `${registryBaseUrl.replace(/\/+$/, "")}/${packageName}/latest`;
}
export function buildInstallCommand(
pm: PackageManager,
env: NodeJS.ProcessEnv = process.env,
): string {
return `${pm} ${buildInstallArgs(pm, env).join(" ")}`;
}
export function buildInstallArgs(
pm: PackageManager,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const packageName = resolveUpdatePackageName(env);
const installRegistry = resolveUpdateInstallRegistryUrl(env);
const args = [...INSTALL_ARG_PREFIX[pm], `${packageName}@latest`];
if (installRegistry) {
args.push("--registry", installRegistry);
}
return args;
}
/**
* Detect which package manager was used to install this binary.
@@ -107,12 +191,15 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
return { updateAvailable: false, currentVersion };
}
const packageName = resolveUpdatePackageName();
const registryBaseUrl = resolveUpdateRegistryBaseUrl();
const latestUrl = buildLatestVersionUrl(packageName, registryBaseUrl);
try {
debugLog("Checking registry for latest version...");
const res = await fetch(
"https://registry.npmjs.org/@letta-ai/letta-code/latest",
{ signal: AbortSignal.timeout(5000) },
);
debugLog("Checking registry for latest version:", latestUrl);
const res = await fetch(latestUrl, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
throw new Error(`Registry returned ${res.status}`);
}
@@ -152,7 +239,9 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
*/
async function getNpmGlobalPath(): Promise<string | null> {
try {
const { stdout } = await execAsync("npm prefix -g", { timeout: 5000 });
const { stdout } = await execFileAsync("npm", ["prefix", "-g"], {
timeout: 5000,
});
return stdout.trim();
} catch {
return null;
@@ -186,7 +275,8 @@ async function performUpdate(): Promise<{
enotemptyFailed?: boolean;
}> {
const pm = detectPackageManager();
const installCmd = INSTALL_CMD[pm];
const installCmd = buildInstallCommand(pm);
const installArgs = buildInstallArgs(pm);
debugLog("Detected package manager:", pm);
debugLog("Install command:", installCmd);
@@ -202,7 +292,7 @@ async function performUpdate(): Promise<{
try {
debugLog(`Running ${installCmd}...`);
await execAsync(installCmd, { timeout: 60000 });
await execFileAsync(pm, installArgs, { timeout: 60000 });
debugLog("Update completed successfully");
return { success: true };
} catch (error) {
@@ -214,7 +304,7 @@ async function performUpdate(): Promise<{
await cleanupOrphanedDirs(globalPath);
try {
await execAsync(installCmd, { timeout: 60000 });
await execFileAsync(pm, installArgs, { timeout: 60000 });
debugLog("Update succeeded after cleanup retry");
return { success: true };
} catch (retryError) {