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
12 changes: 12 additions & 0 deletions .changeset/27914-publish-decodes-manifest-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/runtime': patch
'@objectstack/cli': patch
---

fix(cli): `os plugin publish` decodes the artifact's compiled manifest once, through `readCompiledPluginManifest` (#27914)

Clause-②: yes (widening: the `ok` arm of `@objectstack/runtime`'s exported `CompiledPluginManifestRead` gains `document`, the manifest entry as decoded before the `ManifestSchema` parse; every existing key keeps its value)
Metadata-protocol: none

- **Before.** `os plugin publish` validated the compiled manifest through `readCompiledPluginManifest`, then decoded the same archive entry a second time to build the verbatim `plugin_manifest` upload, finding it by the CLI's own spelling of the filename. That lookup held only while the CLI's `MANIFEST_FILENAME` and the runtime's `COMPILED_PLUGIN_MANIFEST_FILE` spelled the same string.
- **Now.** `readCompiledPluginManifest` answers the decoded `document` beside the parsed `manifest`, and publish uploads that document. The upload is unchanged: the artifact's own manifest, key for key, without the defaults the parse stamps (`scope`, `defaultDatasource`).
8 changes: 3 additions & 5 deletions packages/cli/src/commands/plugin/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,9 @@ export default class PluginPublish extends Command {
// What is uploaded is the manifest the artifact carries, as it carries
// it: the parse above stamps the schema's defaults (`scope`,
// `defaultDatasource`), which would make the declared manifest differ
// from the signed bytes' own. The entry is there and is a JSON object:
// the read above answered `ok`.
const declaredManifest = JSON.parse(
Buffer.from(archiveFiles.find((f) => f.path === MANIFEST_FILENAME)!.data).toString('utf8'),
) as Record<string, unknown>;
// from the signed bytes' own. The read hands back the document it
// decoded, so the entry is decoded once and looked up by one spelling.
const declaredManifest = read.document;
const id = String(flags['manifest-id'] ?? manifest.id).trim();
const version = manifest.version;
const displayName = String(flags['display-name'] ?? manifest.name ?? id).trim();
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/test/plugin-publish-manifest-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os plugin publish` uploads the compiled manifest from the one decode
* `readCompiledPluginManifest` makes (#27914): the `plugin_manifest` payload
* is the archive entry's own document, key for key, and the door does not
* depend on the CLI's `MANIFEST_FILENAME` spelling the runtime's
* `COMPILED_PLUGIN_MANIFEST_FILE`.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// The CLI's constant spelled differently from the runtime's, as a drift
// between the two packages would leave it.
vi.mock('../src/utils/osplugin.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/utils/osplugin.js')>()),
MANIFEST_FILENAME: 'objectstack.plugin.drifted.json',
}));

import { createTarGz, type ArchiveFile } from '../src/utils/osplugin.js';
import PluginPublish from '../src/commands/plugin/publish.js';

// The runtime's spelling (`COMPILED_PLUGIN_MANIFEST_FILE`), which the artifact uses.
const RUNTIME_MANIFEST_FILE = 'objectstack.plugin.json';

// No `integrity` map: the preflight's exemption list is spelled with the
// CLI constant, and this file is about the manifest read only.
const manifest = {
version: '1.2.0', id: 'com.acme.demo', name: 'Demo', type: 'plugin',
main: 'dist/index.mjs', packaging: 'bundled', runtime: 'sandbox',
permissions: { services: ['object'] },
};
const manifestText = JSON.stringify(manifest, null, 4);

describe('os plugin publish: the uploaded manifest is the entry it decoded once', () => {
let dir: string | undefined;
const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY };
afterEach(async () => {
vi.unstubAllGlobals();
process.env.OS_CLOUD_URL = prevEnv.url;
process.env.OS_CLOUD_API_KEY = prevEnv.key;
if (dir) await rm(dir, { recursive: true, force: true });
});

it("publishes when the CLI's filename constant and the runtime's differ, and uploads the entry's document key for key", async () => {
const files: ArchiveFile[] = [
{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) },
{ path: RUNTIME_MANIFEST_FILE, data: new Uint8Array(Buffer.from(manifestText)) },
];
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-entry-'));
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
await writeFile(artifactPath, new Uint8Array(createTarGz(files)));
process.env.OS_CLOUD_URL = 'http://cloud.test';
process.env.OS_CLOUD_API_KEY = 'tok_123';
const bodies: any[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
bodies.push(JSON.parse(init.body));
const data = url.endsWith('/versions') ? { version: '1.2.0' } : { id: 'pkg_1', created: true };
return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any;
}));
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await PluginPublish.run([artifactPath]);
} finally {
logSpy.mockRestore();
}
expect(bodies).toHaveLength(2);
expect(JSON.stringify(bodies[1].plugin_manifest)).toBe(JSON.stringify(JSON.parse(manifestText)));
});
});
3 changes: 3 additions & 0 deletions packages/cli/test/plugin-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ describe('os plugin publish (end-to-end, mocked cloud)', () => {
// Exactly the artifact's own manifest: `ManifestSchema` would stamp
// `scope` and `defaultDatasource`, and neither is uploaded.
expect(bodies[1].plugin_manifest).toEqual(manifest);
// Key for key, in the archive entry's own order (#27914).
const entry = readTarGz(blob).find((f) => f.path === MANIFEST_FILENAME)!;
expect(JSON.stringify(bodies[1].plugin_manifest)).toBe(JSON.stringify(JSON.parse(Buffer.from(entry.data).toString('utf8'))));
expect(bodies[1].plugin_manifest).not.toHaveProperty('scope');
expect(bodies[1].plugin_manifest).not.toHaveProperty('defaultDatasource');
});
Expand Down
33 changes: 33 additions & 0 deletions packages/runtime/src/compiled-plugin-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { readCompiledPluginManifest, COMPILED_PLUGIN_MANIFEST_FILE } from './compiled-plugin-manifest.js';

const authored = {
id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin',
runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs',
};

function entry(text: string) {
return [{ path: COMPILED_PLUGIN_MANIFEST_FILE, data: new Uint8Array(Buffer.from(text)) }];
}

describe('readCompiledPluginManifest', () => {
it('answers the document it decoded beside the parsed manifest, without the defaults the parse stamps', () => {
const text = JSON.stringify(authored, null, 2);
const read = readCompiledPluginManifest(entry(text));
expect(read.ok).toBe(true);
if (!read.ok) return;
// The parse stamps `scope` / `defaultDatasource`; the document does not.
expect(read.manifest).toHaveProperty('scope');
expect(read.document).not.toHaveProperty('scope');
expect(read.document).not.toHaveProperty('defaultDatasource');
// Key for key and in the entry's own order.
expect(JSON.stringify(read.document)).toBe(JSON.stringify(JSON.parse(text)));
});

it('refuses a missing entry or a non-object document as unreadable', () => {
expect(readCompiledPluginManifest([])).toMatchObject({ ok: false, reason: 'unreadable' });
expect(readCompiledPluginManifest(entry('[1,2]'))).toMatchObject({ ok: false, reason: 'unreadable' });
});
});
18 changes: 13 additions & 5 deletions packages/runtime/src/compiled-plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ export interface CompiledManifestEntry {
}

export type CompiledPluginManifestRead =
| { ok: true; manifest: ObjectStackManifestParsed }
/**
* `manifest` is the parsed value, with the schema's defaults stamped.
* `document` is the entry as decoded, before the parse: a door that
* forwards the manifest the artifact carries (`os plugin publish`'s
* upload, which must match the signed bytes) forwards this, and decodes
* nothing a second time.
*/
| { ok: true; manifest: ObjectStackManifestParsed; document: Record<string, unknown> }
/** `unreadable`: no manifest, or not a JSON object. `invalid`: `ManifestSchema` refused it. */
| { ok: false; reason: 'unreadable' | 'invalid'; message: string; issues: string[] };

Expand All @@ -39,17 +46,18 @@ export type CompiledPluginManifestRead =
* for `unreadable`) and `message` says the whole of it on one line.
*/
export function readCompiledPluginManifest(files: readonly CompiledManifestEntry[]): CompiledPluginManifestRead {
let raw: unknown;
let raw: Record<string, unknown>;
try {
const entry = files.find((f) => f.path === COMPILED_PLUGIN_MANIFEST_FILE);
if (!entry) throw new Error(`${COMPILED_PLUGIN_MANIFEST_FILE} not found`);
raw = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${COMPILED_PLUGIN_MANIFEST_FILE} is not a JSON object`);
const decoded = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown;
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error(`${COMPILED_PLUGIN_MANIFEST_FILE} is not a JSON object`);
raw = decoded as Record<string, unknown>;
} catch (error) {
return { ok: false, reason: 'unreadable', message: error instanceof Error ? error.message : String(error), issues: [] };
}
const parsed = ManifestSchema.safeParse(raw);
if (parsed.success) return { ok: true, manifest: parsed.data };
if (parsed.success) return { ok: true, manifest: parsed.data, document: raw };
const issues = parsed.error.issues.flatMap((issue) =>
formatZodIssue(issue as Parameters<typeof formatZodIssue>[0])
.split('\n')
Expand Down
Loading