Skip to content
3 changes: 2 additions & 1 deletion packages/cli/src/commands/packages/outdated.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getOutdatedBundles } from "./outdated.js";

vi.mock("../../utils/cache.js", () => ({
vi.mock("../../utils/cache.js", async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
listCachedBundles: vi.fn(),
}));

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/packages/outdated.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { listCachedBundles } from "../../utils/cache.js";
import { isSemverEqual, listCachedBundles } from "../../utils/cache.js";
import { createClient } from "../../utils/client.js";
import { table } from "../../utils/format.js";

Expand Down Expand Up @@ -28,7 +28,7 @@ export async function getOutdatedBundles(): Promise<OutdatedEntry[]> {
cached.map(async (bundle) => {
try {
const detail = await client.getBundle(bundle.name);
if (detail.latest_version !== bundle.version) {
if (!isSemverEqual(detail.latest_version, bundle.version)) {
results.push({
name: bundle.name,
current: bundle.version,
Expand All @@ -37,7 +37,7 @@ export async function getOutdatedBundles(): Promise<OutdatedEntry[]> {
});
}
} catch {
// Skip bundles that fail to resolve (e.g. deleted from registry)
process.stderr.write(`=> Warning: could not check ${bundle.name} (may have been removed from registry)\n`);
}
}),
);
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/packages/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
extractZip,
resolveBundle,
downloadAndExtract,
isSemverEqual,
} from "../../utils/cache.js";
import type { CacheMetadata } from "../../utils/cache.js";
import { ConfigManager } from "../../utils/config-manager.js";
Expand Down Expand Up @@ -409,7 +410,7 @@ export async function handleRun(
if (cachedMeta && !options.update) {
if (requestedVersion) {
// Specific version requested - check if cached version matches
needsPull = cachedMeta.version !== requestedVersion;
needsPull = !isSemverEqual(cachedMeta.version, requestedVersion);
} else {
// Latest requested - use cache (user can --update to refresh)
needsPull = false;
Expand All @@ -422,7 +423,7 @@ export async function handleRun(
// Check if cached version is already the latest
if (
cachedMeta &&
cachedMeta.version === downloadInfo.bundle.version &&
isSemverEqual(cachedMeta.version, downloadInfo.bundle.version) &&
!options.update
) {
needsPull = false;
Expand Down Expand Up @@ -544,7 +545,11 @@ export async function handleRun(
child.on("exit", async (code) => {
// Let the update check finish before exiting (but don't block indefinitely)
if (updateCheckPromise) {
await Promise.race([updateCheckPromise, new Promise((r) => setTimeout(r, 3000))]);
try {
await Promise.race([updateCheckPromise, new Promise((r) => setTimeout(r, 3000))]);
} catch {
// Silently swallow — update check is best-effort and should not affect UX
}
}
process.exit(code ?? 0);
});
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/packages/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const fakeDownloadInfo = {
bundle: { version: "2.0.0", platform: { os: "darwin", arch: "arm64" } },
};

const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
Expand Down Expand Up @@ -131,6 +133,20 @@ describe("handleUpdate", () => {
);
});

it("exits non-zero when all updates fail", async () => {
mockGetOutdatedBundles.mockResolvedValue([
{ name: "@scope/a", current: "1.0.0", latest: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z" },
]);
mockResolveBundle.mockRejectedValueOnce(new Error("Network error"));

await handleUpdate(undefined, {});

expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("Failed to update @scope/a"),
);
expect(mockExit).toHaveBeenCalledWith(1);
});

it("outputs empty JSON array when nothing is outdated with --json", async () => {
mockGetOutdatedBundles.mockResolvedValue([]);

Expand Down
19 changes: 13 additions & 6 deletions packages/cli/src/commands/packages/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,20 @@ export async function handleUpdate(

const updated: Array<{ name: string; from: string; to: string }> = [];

for (const entry of outdated) {
try {
const results = await Promise.allSettled(
outdated.map(async (entry) => {
const downloadInfo = await resolveBundle(entry.name, client);
const { version } = await downloadAndExtract(entry.name, downloadInfo);
updated.push({ name: entry.name, from: entry.current, to: version });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`=> Failed to update ${entry.name}: ${message}\n`);
return { name: entry.name, from: entry.current, to: version };
}),
);

for (const [i, result] of results.entries()) {
if (result.status === "fulfilled") {
updated.push(result.value);
} else {
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
process.stderr.write(`=> Failed to update ${outdated[i]!.name}: ${message}\n`);
}
}

Expand All @@ -62,6 +68,7 @@ export async function handleUpdate(

if (updated.length === 0) {
fmtError("All updates failed.");
process.exit(1);
}

for (const u of updated) {
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import {
writeFileSync,
} from "fs";
import { execFileSync } from "child_process";
import { randomUUID } from "crypto";
import { homedir } from "os";
import { dirname, join } from "path";
import { MpakClient } from "@nimblebrain/mpak-sdk";

/**
* Compare two semver strings for equality, ignoring leading 'v' prefix.
*/
export function isSemverEqual(a: string, b: string): boolean {
return a.replace(/^v/, "") === b.replace(/^v/, "");
}

export interface CacheMetadata {
version: string;
pulledAt: string;
Expand Down Expand Up @@ -85,7 +93,7 @@ export async function checkForUpdateAsync(
lastCheckedAt: new Date().toISOString(),
});

if (detail.latest_version !== cachedMeta.version) {
if (!isSemverEqual(detail.latest_version, cachedMeta.version)) {
process.stderr.write(
`\n=> Update available: ${packageName} ${cachedMeta.version} -> ${detail.latest_version}\n` +
` Run 'mpak run ${packageName} --update' to update\n`,
Expand Down Expand Up @@ -216,7 +224,7 @@ export async function downloadAndExtract(
const cacheDir = getCacheDir(name);

// Download to temp file
const tempPath = join(homedir(), ".mpak", "tmp", `${Date.now()}.mcpb`);
const tempPath = join(homedir(), ".mpak", "tmp", `${Date.now()}-${randomUUID().slice(0, 8)}.mcpb`);
mkdirSync(dirname(tempPath), { recursive: true });

process.stderr.write(`=> Pulling ${name}@${bundle.version}...\n`);
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/tests/integration/update-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from "vitest";
import { readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { getOutdatedBundles } from "../../src/commands/packages/outdated.js";
import { handleUpdate } from "../../src/commands/packages/update.js";
import {
getCacheDir,
getCacheMetadata,
downloadAndExtract,
resolveBundle,
} from "../../src/utils/cache.js";
import { createClient } from "../../src/utils/client.js";

/**
* Integration test for the outdated → update flow.
*
* Uses the live registry with @nimblebraininc/echo as a fixture.
* Downgrades cached metadata to simulate an outdated bundle, then
* verifies that outdated detection and update work end-to-end.
*
* Run with: pnpm test -- tests/integration
*/
describe("Update Flow Integration", () => {
const testBundle = "@nimblebraininc/echo";
let originalMeta: string | null = null;
let metaPath: string;

afterEach(() => {
// Restore original metadata if we modified it
if (originalMeta && metaPath) {
writeFileSync(metaPath, originalMeta);
}
});

it("should detect outdated bundle and update it", async () => {
const client = createClient();

// 1. Ensure bundle is cached (pull latest if not already cached)
const cacheDir = getCacheDir(testBundle);
let meta = getCacheMetadata(cacheDir);
if (!meta) {
const downloadInfo = await resolveBundle(testBundle, client);
await downloadAndExtract(testBundle, downloadInfo);
meta = getCacheMetadata(cacheDir)!;
}

// 2. Save original metadata for cleanup
metaPath = join(cacheDir, ".mpak-meta.json");
originalMeta = readFileSync(metaPath, "utf8");
const realVersion = meta.version;

// 3. Downgrade version in metadata
const downgraded = { ...meta, version: "0.0.1" };
writeFileSync(metaPath, JSON.stringify(downgraded));

// 4. Verify outdated detects it
const outdated = await getOutdatedBundles();
const entry = outdated.find((e) => e.name === testBundle);
expect(entry).toBeDefined();
expect(entry!.current).toBe("0.0.1");
expect(entry!.latest).toBe(realVersion);

// 5. Run update
await handleUpdate(testBundle);

// 6. Verify no longer outdated
const afterUpdate = await getOutdatedBundles();
const stillOutdated = afterUpdate.find((e) => e.name === testBundle);
expect(stillOutdated).toBeUndefined();
}, 30000);
});
Loading