From f1012d6f26bd7823f60b17c0561f50de2f7f4431 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:11 +0800 Subject: [PATCH] feat(client): pass `overwrite` through packages.install for upgrade flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /packages now 409s when the manifest id is already installed (#2971, duplicate-id guard). The SDK's packages.install() had no way to opt into the intentional-overwrite escape hatch, so any upgrade / re-install flow built on it would hit the 409 with no recourse. Add `overwrite?: boolean` to the options bag and pass it through the POST body only when explicitly set — the default request stays byte-identical so the server keeps rejecting duplicate ids. First SDK tests for packages.install (default omits the flag; overwrite:true carries it). Co-Authored-By: Claude Fable 5 --- packages/client/src/client.test.ts | 25 +++++++++++++++++++++++++ packages/client/src/index.ts | 13 +++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 4afecfd4e7..3c5579d425 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1179,3 +1179,28 @@ describe('HTTP error shaping', () => { expect(caught.httpStatus).toBe(500); }); }); + +describe('packages.install', () => { + const MANIFEST = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }; + + it('POSTs the manifest and omits `overwrite` unless requested', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { package: { manifest: MANIFEST } } }); + await client.packages.install(MANIFEST, { enableOnInstall: true }); + + expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/packages', expect.any(Object)); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.manifest).toEqual(MANIFEST); + expect(body.enableOnInstall).toBe(true); + // Duplicate-id guard semantics: never send `overwrite` implicitly — + // the server must keep 409ing on an existing id by default. + expect('overwrite' in body).toBe(false); + }); + + it('passes `overwrite: true` through for intentional upgrade / re-install', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { package: { manifest: MANIFEST } } }); + await client.packages.install(MANIFEST, { overwrite: true }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.overwrite).toBe(true); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f0549fd426..db88be760a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -592,8 +592,16 @@ export class ObjectStackClient { /** * Install a new package from its manifest. - */ - install: async (manifest: any, options?: { settings?: Record; enableOnInstall?: boolean }) => { + * + * By default the server rejects a manifest whose `id` is already + * installed with **409 Conflict** (duplicate-id guard) instead of + * silently overwriting the existing package. Intentional upgrade / + * re-install flows opt back in with `overwrite: true`. + */ + install: async ( + manifest: any, + options?: { settings?: Record; enableOnInstall?: boolean; overwrite?: boolean }, + ) => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}`, { method: 'POST', @@ -601,6 +609,7 @@ export class ObjectStackClient { manifest, settings: options?.settings, enableOnInstall: options?.enableOnInstall, + ...(options?.overwrite !== undefined ? { overwrite: options.overwrite } : {}), }), }); return this.unwrapResponse<{ package: any; message?: string }>(res);