Skip to content

Commit 3786f15

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(cloud-connection): install-local accepts compiled stack bundles (#1799)
A published version payload (dist/objectstack.json) nests its meta under .manifest ({manifest:{id,…}, objects, views, …}) while ObjectQL's registerApp expects the flat app shape — so every install of a published compiled bundle died with 'Invalid manifest payload'. Found running the org-private install loop E2E (publish hotcrm → bearer catalog → install-local). Flatten on detection, both fetch and inline paths. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 60ba54f commit 3786f15

3 files changed

Lines changed: 128 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
install-local accepts compiled stack bundles: a published version payload (`dist/objectstack.json`) nests its meta under `.manifest` while ObjectQL's registerApp expects the flat app shape — every install of a published compiled bundle failed with "Invalid manifest payload". The handler now flattens the bundle shape (both the cloud-fetch and inline/file-import paths).
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* install-local accepts a COMPILED stack bundle (`dist/objectstack.json`
5+
* shape: meta nested under `.manifest`, sections top-level) — what publish
6+
* uploads as the version payload — by flattening it to the app shape
7+
* ObjectQL's registerApp expects. Without the normalization every install
8+
* of a published compiled bundle failed with "Invalid manifest payload".
9+
*/
10+
11+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12+
import { mkdtempSync, rmSync } from 'node:fs';
13+
import { join } from 'node:path';
14+
import { tmpdir } from 'node:os';
15+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
16+
17+
type Handler = (c: any) => Promise<any>;
18+
19+
function makeRawApp() {
20+
const routes = new Map<string, Handler>();
21+
return {
22+
routes,
23+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
24+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
25+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
26+
};
27+
}
28+
29+
function makeCtx(rawApp: any, services: Record<string, any>) {
30+
const hooks = new Map<string, any>();
31+
return {
32+
ctx: {
33+
hook: (e: string, h: any) => hooks.set(e, h),
34+
getService: (name: string) => {
35+
if (name === 'http-server') return { getRawApp: () => rawApp };
36+
const svc = services[name];
37+
if (svc === undefined) throw new Error(`no ${name}`);
38+
return svc;
39+
},
40+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
41+
},
42+
fire: async () => { await hooks.get('kernel:ready')?.(); },
43+
};
44+
}
45+
46+
function makeC(body: any) {
47+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
48+
return { req: { url: 'http://localhost:3000/api/v1/marketplace/install-local', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
49+
}
50+
51+
let dir: string;
52+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-')); });
53+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
54+
55+
describe('install-local compiled-bundle normalization', () => {
56+
it('flattens {manifest:{id…}, objects…} and registers the app shape', async () => {
57+
const register = vi.fn();
58+
const rawApp = makeRawApp();
59+
const { ctx, fire } = makeCtx(rawApp, {
60+
manifest: { register },
61+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
62+
objectql: { syncSchemas: async () => undefined },
63+
});
64+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
65+
await plugin.start(ctx as any);
66+
await fire();
67+
68+
const compiledBundle = {
69+
manifest: { id: 'app.acme.crm', namespace: 'crm', version: '2.0.0', type: 'app' },
70+
objects: [{ name: 'account', fields: {} }],
71+
views: [],
72+
};
73+
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
74+
makeC({ manifest: compiledBundle }),
75+
);
76+
expect(res.payload?.success).toBe(true);
77+
78+
// The UI bundle registers at kernel:ready; the LAST register call is
79+
// the installed package — flattened: top-level id + sections.
80+
const installed = register.mock.calls.at(-1)![0];
81+
expect(installed.id).toBe('app.acme.crm');
82+
expect(installed.namespace).toBe('crm');
83+
expect(installed.version).toBe('2.0.0');
84+
expect(Array.isArray(installed.objects)).toBe(true);
85+
expect(installed.manifest).toBeUndefined();
86+
});
87+
88+
it('leaves an already-flat manifest untouched', async () => {
89+
const register = vi.fn();
90+
const rawApp = makeRawApp();
91+
const { ctx, fire } = makeCtx(rawApp, {
92+
manifest: { register },
93+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
94+
objectql: { syncSchemas: async () => undefined },
95+
});
96+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
97+
await plugin.start(ctx as any);
98+
await fire();
99+
100+
const flat = { id: 'com.acme.flat', version: '1.0.0', objects: [] };
101+
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
102+
makeC({ manifest: flat }),
103+
);
104+
expect(res.payload?.success).toBe(true);
105+
expect(register.mock.calls.at(-1)![0].id).toBe('com.acme.flat');
106+
});
107+
});

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
186186
// Bypass the cloud-fetch entirely; no OS_CLOUD_URL required.
187187
const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null;
188188

189+
// A COMPILED stack bundle (`dist/objectstack.json`, what publish
190+
// uploads as the version payload) nests its meta under `.manifest`:
191+
// { manifest: { id, namespace, version, … }, objects, views, … }
192+
// while ObjectQL's registerApp expects the FLAT app shape (top-level
193+
// id + sections). Flatten when detected — otherwise every install of
194+
// a published compiled bundle dies with "Invalid manifest payload".
195+
const normalizeBundle = (m: any): any => {
196+
if (m && !m.id && !m.name && m.manifest && typeof m.manifest === 'object' && (m.manifest.id || m.manifest.name)) {
197+
const { manifest: meta, ...sections } = m;
198+
return { ...meta, ...sections };
199+
}
200+
return m;
201+
};
202+
189203
let manifest: any;
190204
let resolvedVersionId: string;
191205
let version: string;
192206
let packageId: string;
193207

194208
if (inlineManifest) {
195-
manifest = inlineManifest;
209+
manifest = normalizeBundle(inlineManifest);
196210
packageId = String(manifest.id ?? manifest.name ?? '').trim();
197211
version = String(manifest.version ?? 'unknown');
198212
resolvedVersionId = String(body?.versionId ?? version);
@@ -278,7 +292,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
278292
}
279293

280294
const data = payload?.data ?? payload;
281-
manifest = data?.manifest;
295+
manifest = normalizeBundle(data?.manifest);
282296
resolvedVersionId = String(data?.version_id ?? versionId);
283297
version = String(data?.version ?? 'unknown');
284298
}

0 commit comments

Comments
 (0)