Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/install-local-bundle-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/cloud-connection": patch
---

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).
107 changes: 107 additions & 0 deletions packages/cloud-connection/src/marketplace-install-local-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* install-local accepts a COMPILED stack bundle (`dist/objectstack.json`
* shape: meta nested under `.manifest`, sections top-level) — what publish
* uploads as the version payload — by flattening it to the app shape
* ObjectQL's registerApp expects. Without the normalization every install
* of a published compiled bundle failed with "Invalid manifest payload".
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';

type Handler = (c: any) => Promise<any>;

function makeRawApp() {
const routes = new Map<string, Handler>();
return {
routes,
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
};
}

function makeCtx(rawApp: any, services: Record<string, any>) {
const hooks = new Map<string, any>();
return {
ctx: {
hook: (e: string, h: any) => hooks.set(e, h),
getService: (name: string) => {
if (name === 'http-server') return { getRawApp: () => rawApp };
const svc = services[name];
if (svc === undefined) throw new Error(`no ${name}`);
return svc;
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
},
fire: async () => { await hooks.get('kernel:ready')?.(); },
};
}

function makeC(body: any) {
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
return { req: { url: 'http://localhost:3000/api/v1/marketplace/install-local', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
}

let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });

describe('install-local compiled-bundle normalization', () => {
it('flattens {manifest:{id…}, objects…} and registers the app shape', async () => {
const register = vi.fn();
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, {
manifest: { register },
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
objectql: { syncSchemas: async () => undefined },
});
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(ctx as any);
await fire();

const compiledBundle = {
manifest: { id: 'app.acme.crm', namespace: 'crm', version: '2.0.0', type: 'app' },
objects: [{ name: 'account', fields: {} }],
views: [],
};
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
makeC({ manifest: compiledBundle }),
);
expect(res.payload?.success).toBe(true);

// The UI bundle registers at kernel:ready; the LAST register call is
// the installed package — flattened: top-level id + sections.
const installed = register.mock.calls.at(-1)![0];
expect(installed.id).toBe('app.acme.crm');
expect(installed.namespace).toBe('crm');
expect(installed.version).toBe('2.0.0');
expect(Array.isArray(installed.objects)).toBe(true);
expect(installed.manifest).toBeUndefined();
});

it('leaves an already-flat manifest untouched', async () => {
const register = vi.fn();
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, {
manifest: { register },
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
objectql: { syncSchemas: async () => undefined },
});
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(ctx as any);
await fire();

const flat = { id: 'com.acme.flat', version: '1.0.0', objects: [] };
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
makeC({ manifest: flat }),
);
expect(res.payload?.success).toBe(true);
expect(register.mock.calls.at(-1)![0].id).toBe('com.acme.flat');
});
});
18 changes: 16 additions & 2 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// Bypass the cloud-fetch entirely; no OS_CLOUD_URL required.
const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null;

// A COMPILED stack bundle (`dist/objectstack.json`, what publish
// uploads as the version payload) nests its meta under `.manifest`:
// { manifest: { id, namespace, version, … }, objects, views, … }
// while ObjectQL's registerApp expects the FLAT app shape (top-level
// id + sections). Flatten when detected — otherwise every install of
// a published compiled bundle dies with "Invalid manifest payload".
const normalizeBundle = (m: any): any => {
if (m && !m.id && !m.name && m.manifest && typeof m.manifest === 'object' && (m.manifest.id || m.manifest.name)) {
const { manifest: meta, ...sections } = m;
return { ...meta, ...sections };
}
return m;
};

let manifest: any;
let resolvedVersionId: string;
let version: string;
let packageId: string;

if (inlineManifest) {
manifest = inlineManifest;
manifest = normalizeBundle(inlineManifest);
packageId = String(manifest.id ?? manifest.name ?? '').trim();
version = String(manifest.version ?? 'unknown');
resolvedVersionId = String(body?.versionId ?? version);
Expand Down Expand Up @@ -278,7 +292,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}

const data = payload?.data ?? payload;
manifest = data?.manifest;
manifest = normalizeBundle(data?.manifest);
resolvedVersionId = String(data?.version_id ?? versionId);
version = String(data?.version ?? 'unknown');
}
Expand Down