fix(opencode): name the release when upgrading OpenCode
Since opencode 1.18.x, `POST /global/upgrade` requires a `target` semver in
the body. OpenChamber sent an empty object, so every "Update OpenCode" click
came back 400. The rejection arrives as `{name, data:{message}}`, which has
no `error` field, so the user was left with the bare status text: "Bad
Request".
Resolve the target from the latest release — the same lookup the upgrade
prompt already uses to decide there is anything to offer — and fail with an
explicit code when it cannot be resolved, rather than sending a body opencode
is guaranteed to reject. Read the upstream rejection message so a refused
upgrade explains itself.
The VS Code extension carries its own copy of this flow and had the same two
defects; both are fixed there.
fixes #3121
This commit is contained in:
@@ -9,6 +9,13 @@ afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const supportedCapability = { supported: true, manager: 'opencode', reason: null };
|
||||
|
||||
const createApp = (overrides = {}) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -67,22 +74,99 @@ describe('OpenCode upgrade routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('names the latest release as the upgrade target when the caller sends none', async () => {
|
||||
const requests = [];
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
|
||||
if (String(url).includes('registry.npmjs.org')) {
|
||||
return jsonResponse({ version: '1.18.23' });
|
||||
}
|
||||
if (String(url).includes('api.github.com')) {
|
||||
return jsonResponse({ tag_name: 'v1.18.23' });
|
||||
}
|
||||
return jsonResponse({ success: true, version: '1.18.23' });
|
||||
});
|
||||
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
|
||||
|
||||
await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(200, { success: true, version: '1.18.23', restarted: true });
|
||||
|
||||
const upgradeRequest = requests.find((entry) => entry.url.includes('/global/upgrade'));
|
||||
expect(upgradeRequest?.body).toEqual({ target: '1.18.23' });
|
||||
});
|
||||
|
||||
it('keeps an explicitly requested target instead of resolving the latest release', async () => {
|
||||
const requests = [];
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
|
||||
return jsonResponse({ success: true, version: '1.18.20' });
|
||||
});
|
||||
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
|
||||
|
||||
await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({ target: '1.18.20' })
|
||||
.expect(200);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].url).toContain('/global/upgrade');
|
||||
expect(requests[0].body).toEqual({ target: '1.18.20' });
|
||||
});
|
||||
|
||||
it('fails without calling the updater when the latest release cannot be resolved', async () => {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes('/global/upgrade')) {
|
||||
throw new Error('the updater must not be called without a target');
|
||||
}
|
||||
return new Response('nope', { status: 503 });
|
||||
});
|
||||
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(502);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.code).toBe('OPENCODE_UPGRADE_TARGET_UNRESOLVED');
|
||||
expect(response.body.error).toContain('Could not determine which OpenCode version to install');
|
||||
expect(dependencies.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes('/global/upgrade')) {
|
||||
return jsonResponse(
|
||||
{ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } },
|
||||
400,
|
||||
);
|
||||
}
|
||||
return jsonResponse({ version: '1.18.23' });
|
||||
});
|
||||
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
|
||||
|
||||
await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(400, { success: false, error: 'Expected a semantic version' });
|
||||
});
|
||||
|
||||
it('serializes supported upgrades and preserves the in-flight lock', async () => {
|
||||
let releaseUpgrade;
|
||||
const upstreamResponse = new Promise((resolve) => {
|
||||
releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
releaseUpgrade = () => resolve(jsonResponse({ success: true, version: '1.18.9' }));
|
||||
});
|
||||
globalThis.fetch = vi.fn(() => upstreamResponse);
|
||||
const { app, dependencies } = createApp({
|
||||
getOpenCodeUpgradeCapability: () => ({
|
||||
supported: true,
|
||||
manager: 'opencode',
|
||||
reason: null,
|
||||
}),
|
||||
const upgradeCalls = vi.fn();
|
||||
globalThis.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('/global/upgrade')) {
|
||||
upgradeCalls();
|
||||
return upstreamResponse;
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ version: '1.18.9' }));
|
||||
});
|
||||
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
|
||||
|
||||
const first = request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
@@ -94,7 +178,7 @@ describe('OpenCode upgrade routes', () => {
|
||||
})
|
||||
.then((response) => response);
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(upgradeCalls).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await request(app)
|
||||
|
||||
@@ -164,6 +164,41 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
return versions.sort((left, right) => compareVersions(right, left))[0];
|
||||
};
|
||||
|
||||
// OpenCode's `/global/upgrade` requires an explicit semver target and rejects
|
||||
// a bodyless call, so "update to the latest" has to name the version. The
|
||||
// release lookup is the same one the upgrade-status check already uses to
|
||||
// decide there is anything to offer.
|
||||
const resolveOpenCodeUpgradeTarget = async (requestedTarget) => {
|
||||
if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) {
|
||||
return { resolved: true, target: requestedTarget.trim() };
|
||||
}
|
||||
try {
|
||||
const latest = await fetchLatestOpenCodeVersion();
|
||||
if (!latest) {
|
||||
return { resolved: false, reason: 'The latest OpenCode version could not be determined.' };
|
||||
}
|
||||
return { resolved: true, target: latest };
|
||||
} catch (error) {
|
||||
return {
|
||||
resolved: false,
|
||||
reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
|
||||
// which carries no `error` field. Reading only `error` left the user with the
|
||||
// bare HTTP status text ("Bad Request") and nothing to act on.
|
||||
const readOpenCodeUpgradeErrorMessage = (payload, response) => {
|
||||
const candidates = [payload?.error, payload?.data?.message, payload?.message];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return response.statusText || 'Failed to upgrade OpenCode';
|
||||
};
|
||||
|
||||
const pruneExpiredPendingMcpAuthContexts = () => {
|
||||
const now = Date.now();
|
||||
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
|
||||
@@ -218,10 +253,23 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
});
|
||||
}
|
||||
|
||||
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
||||
? req.body.target.trim()
|
||||
: undefined;
|
||||
const requestedTarget = req.body?.target;
|
||||
// The target lookup reaches the network, so it runs inside the operation:
|
||||
// the in-flight lock is taken synchronously above, and a second click
|
||||
// cannot slip past while the release version is being resolved.
|
||||
const upgradeOperation = (async () => {
|
||||
const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget);
|
||||
if (!targetResolution.resolved) {
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
|
||||
error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -229,7 +277,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify(target ? { target } : {}),
|
||||
body: JSON.stringify({ target: targetResolution.target }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
@@ -237,7 +285,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
status: response.status,
|
||||
body: {
|
||||
success: false,
|
||||
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
||||
error: readOpenCodeUpgradeErrorMessage(payload, response),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user