From 263039c193a9c9986fe2913e5f589af7ddecbba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:46:26 +0000 Subject: [PATCH] fix(cli): os plugin publish decodes the compiled manifest once readCompiledPluginManifest now answers the document it decoded beside the parsed manifest, and os plugin publish uploads that document as plugin_manifest instead of decoding the archive entry a second time behind a non-null lookup by the CLI's own spelling of the filename. The upload is unchanged: the artifact's manifest, key for key, without the defaults the ManifestSchema parse stamps. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxECYe6ksiqRSsT4Rm --- .../27914-publish-decodes-manifest-once.md | 12 +++ packages/cli/src/commands/plugin/publish.ts | 8 +- .../plugin-publish-manifest-entry.test.ts | 73 +++++++++++++++++++ packages/cli/test/plugin-publish.test.ts | 3 + .../src/compiled-plugin-manifest.test.ts | 33 +++++++++ .../runtime/src/compiled-plugin-manifest.ts | 18 +++-- 6 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 .changeset/27914-publish-decodes-manifest-once.md create mode 100644 packages/cli/test/plugin-publish-manifest-entry.test.ts create mode 100644 packages/runtime/src/compiled-plugin-manifest.test.ts diff --git a/.changeset/27914-publish-decodes-manifest-once.md b/.changeset/27914-publish-decodes-manifest-once.md new file mode 100644 index 00000000000..1facbcb0202 --- /dev/null +++ b/.changeset/27914-publish-decodes-manifest-once.md @@ -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`). diff --git a/packages/cli/src/commands/plugin/publish.ts b/packages/cli/src/commands/plugin/publish.ts index 8846d8389b8..e231574a47d 100644 --- a/packages/cli/src/commands/plugin/publish.ts +++ b/packages/cli/src/commands/plugin/publish.ts @@ -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; + // 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(); diff --git a/packages/cli/test/plugin-publish-manifest-entry.test.ts b/packages/cli/test/plugin-publish-manifest-entry.test.ts new file mode 100644 index 00000000000..8d9665a0059 --- /dev/null +++ b/packages/cli/test/plugin-publish-manifest-entry.test.ts @@ -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()), + 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))); + }); +}); diff --git a/packages/cli/test/plugin-publish.test.ts b/packages/cli/test/plugin-publish.test.ts index ffdabcf6256..de701563c19 100644 --- a/packages/cli/test/plugin-publish.test.ts +++ b/packages/cli/test/plugin-publish.test.ts @@ -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'); }); diff --git a/packages/runtime/src/compiled-plugin-manifest.test.ts b/packages/runtime/src/compiled-plugin-manifest.test.ts new file mode 100644 index 00000000000..4666f677cd1 --- /dev/null +++ b/packages/runtime/src/compiled-plugin-manifest.test.ts @@ -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' }); + }); +}); diff --git a/packages/runtime/src/compiled-plugin-manifest.ts b/packages/runtime/src/compiled-plugin-manifest.ts index 5a2eb28a723..71d6e589095 100644 --- a/packages/runtime/src/compiled-plugin-manifest.ts +++ b/packages/runtime/src/compiled-plugin-manifest.ts @@ -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 } /** `unreadable`: no manifest, or not a JSON object. `invalid`: `ManifestSchema` refused it. */ | { ok: false; reason: 'unreadable' | 'invalid'; message: string; issues: string[] }; @@ -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; 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; } 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[0]) .split('\n')