fix(updater): harden auto-update execution and release gating (#976)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
37
.github/workflows/nightly-update-smoke.yml
vendored
Normal file
37
.github/workflows/nightly-update-smoke.yml
vendored
Normal 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
|
||||
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
src/index.ts
27
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<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);
|
||||
}
|
||||
|
||||
|
||||
21
src/startup-auto-update.ts
Normal file
21
src/startup-auto-update.ts
Normal 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.)
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
50
src/tests/startup-auto-update.test.ts
Normal file
50
src/tests/startup-auto-update.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
461
src/tests/update-chain-smoke.ts
Normal file
461
src/tests/update-chain-smoke.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user