diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de3bb1d..313c601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.github/workflows/nightly-update-smoke.yml b/.github/workflows/nightly-update-smoke.yml new file mode 100644 index 0000000..9643210 --- /dev/null +++ b/.github/workflows/nightly-update-smoke.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03febf2..eac8056 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/package.json b/package.json index 1d9eadd..ebe7ab7 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/headless.ts b/src/headless.ts index bc821d3..8b41031 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 0127774..1c36889 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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 { // 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 { // 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); } diff --git a/src/startup-auto-update.ts b/src/startup-auto-update.ts new file mode 100644 index 0000000..067548b --- /dev/null +++ b/src/startup-auto-update.ts @@ -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.) + }); +} diff --git a/src/tests/approval-recovery.test.ts b/src/tests/approval-recovery.test.ts index 06798b4..3fec690 100644 --- a/src/tests/approval-recovery.test.ts +++ b/src/tests/approval-recovery.test.ts @@ -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"); }); }); diff --git a/src/tests/startup-auto-update.test.ts b/src/tests/startup-auto-update.test.ts new file mode 100644 index 0000000..629d8ca --- /dev/null +++ b/src/tests/startup-auto-update.test.ts @@ -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); + }); +}); diff --git a/src/tests/update-chain-smoke.ts b/src/tests/update-chain-smoke.ts new file mode 100644 index 0000000..7979dc4 --- /dev/null +++ b/src/tests/update-chain-smoke.ts @@ -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 { + 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 { + 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 { + await runCommand( + "npm", + ["publish", tarballPath, "--registry", REGISTRY_URL, "--access", "public"], + { env, timeoutMs: 180000 }, + ); +} + +async function getGlobalBinDir(env: NodeJS.ProcessEnv): Promise { + const result = await runCommand("npm", ["prefix", "-g"], { env }); + return `${result.stdout.trim()}/bin`; +} + +async function getInstalledVersion(env: NodeJS.ProcessEnv): Promise { + 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); +}); diff --git a/src/tests/updater/auto-update.test.ts b/src/tests/updater/auto-update.test.ts index fdfa3f5..651b4f0 100644 --- a/src/tests/updater/auto-update.test.ts +++ b/src/tests/updater/auto-update.test.ts @@ -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", + ); + }); }); diff --git a/src/updater/auto-update.ts b/src/updater/auto-update.ts index fdd4712..9b1654e 100644 --- a/src/updater/auto-update.ts +++ b/src/updater/auto-update.ts @@ -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 = { - 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 = { + npm: ["install", "-g"], + bun: ["add", "-g"], + pnpm: ["add", "-g"], }; -const VALID_PACKAGE_MANAGERS = new Set(Object.keys(INSTALL_CMD)); +const VALID_PACKAGE_MANAGERS = new Set(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 { 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 { */ async function getNpmGlobalPath(): Promise { 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) {