Skip to content

Commit c4390f7

Browse files
fix(cli): os plugin publish decodes the compiled manifest once (#28075)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DWfbWGe13nw95fycDvobgKZ)_ domain:server#3 · os-elon-musk · session_016WV4cxECYe6ksiqRSsT4Rm Fixes #27914 ## 维护者速读 - 改了什么:`os plugin publish` 只解码一次插件包里的编译清单;`readCompiledPluginManifest` 在解析结果旁一并交回解码出的原始文档,发布直接上传它。 - 为什么改:原来发布要二次解码同一条目,还靠 CLI 与 runtime 两处各自拼写的文件名做非空断言查找,两边拼写一旦不同就会崩。 - 风险与回滚:无行为变化,上传内容逐键不变(不带 schema 补的默认值);回滚即撤此 PR。 - 先看:`packages/runtime/src/compiled-plugin-manifest.ts` 的返回类型与 `publish.ts` 的一行替换。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run test/plugin-publish-manifest-entry.test.ts`(在 main 上红:TypeError)。 - 需要你做的:无;席位复核后按落地授权处理。 ## What changes - `readCompiledPluginManifest` (`@objectstack/runtime`): the `ok` arm of `CompiledPluginManifestRead` gains `document`, the manifest entry as decoded before the `ManifestSchema` parse. Nothing else in the result moves. - `os plugin publish` (`@objectstack/cli`): `plugin_manifest` is `read.document`. The second `JSON.parse` and the non-null `archiveFiles.find(f => f.path === MANIFEST_FILENAME)!` are gone. - Changeset: `patch` on both packages. ## Why The publish door decoded the same entry twice and found it the second time by the CLI's own spelling of the filename, so it held only while `MANIFEST_FILENAME` (CLI) and `COMPILED_PLUGIN_MANIFEST_FILE` (runtime) spelled the same string (contract review 6514352615 on PR #27910). The verbatim upload stays verbatim: the parsed value stamps `scope` and `defaultDatasource`, which would no longer match the signed bytes. ## Verification Pins first, measured against `main`'s source (runtime rebuilt from it): - **Control, green on `main` and here:** `plugin-publish.test.ts` now also checks `JSON.stringify(plugin_manifest)` equals the archive entry's decoded document, key for key and in order. - **Drift pin, red on `main`, green here:** `plugin-publish-manifest-entry.test.ts` mocks the CLI's `MANIFEST_FILENAME` to a different spelling. On `main`: `TypeError: Cannot read properties of undefined (reading 'data')` at the second lookup. Here: publish posts both requests and uploads the entry's document. Reached without a product change. - **Runtime unit:** `compiled-plugin-manifest.test.ts` (new): `document` carries no stamped default and matches the entry key for key; red on `main` (no `document`). - `@objectstack/cli` full suite: 535 files passed, 1 skipped (6663 tests passed, 9 skipped). Runtime: `compiled-plugin-manifest` + `installed-plugin-loader` suites, 61 passed. `tsc --noEmit` clean on both packages. - `dispatch-gates --commands`: 61 families derived, 60 run with exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, prerequisite). `--ran` reconcile: 0 UNRUN. `pnpm lint`: exit 0. Out of scope, noted only: the integrity preflight's `exempt` list in `publish.ts` is still spelled with the CLI's `MANIFEST_FILENAME`, so the same two-spelling dependency remains there (the drift pin uses a manifest with no `integrity` map for that reason). Skills-docs: none. `grep -rn "readCompiledPluginManifest\|CompiledPluginManifestRead\|plugin_manifest"` over `skills/`, `content/docs/`, `docs/` (`.md`/`.mdx`): 0 hits. `docs/qa/platform-checklist/areas/cli.json` names `readCompiledPluginManifest` and says the uploaded `plugin_manifest` stays the artifact's own manifest, which still holds. Clause-②: yes (widening: the `ok` arm of `@objectstack/runtime`'s exported `CompiledPluginManifestRead` gains `document`; every existing key keeps its value) Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a, not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the fleet's Chinese channel). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxECYe6ksiqRSsT4Rm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b125a7 commit c4390f7

6 files changed

Lines changed: 137 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(cli): `os plugin publish` decodes the artifact's compiled manifest once, through `readCompiledPluginManifest` (#27914)
7+
8+
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)
9+
Metadata-protocol: none
10+
11+
- **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.
12+
- **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`).

‎packages/cli/src/commands/plugin/publish.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,9 @@ export default class PluginPublish extends Command {
137137
// What is uploaded is the manifest the artifact carries, as it carries
138138
// it: the parse above stamps the schema's defaults (`scope`,
139139
// `defaultDatasource`), which would make the declared manifest differ
140-
// from the signed bytes' own. The entry is there and is a JSON object:
141-
// the read above answered `ok`.
142-
const declaredManifest = JSON.parse(
143-
Buffer.from(archiveFiles.find((f) => f.path === MANIFEST_FILENAME)!.data).toString('utf8'),
144-
) as Record<string, unknown>;
140+
// from the signed bytes' own. The read hands back the document it
141+
// decoded, so the entry is decoded once and looked up by one spelling.
142+
const declaredManifest = read.document;
145143
const id = String(flags['manifest-id'] ?? manifest.id).trim();
146144
const version = manifest.version;
147145
const displayName = String(flags['display-name'] ?? manifest.name ?? id).trim();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os plugin publish` uploads the compiled manifest from the one decode
5+
* `readCompiledPluginManifest` makes (#27914): the `plugin_manifest` payload
6+
* is the archive entry's own document, key for key, and the door does not
7+
* depend on the CLI's `MANIFEST_FILENAME` spelling the runtime's
8+
* `COMPILED_PLUGIN_MANIFEST_FILE`.
9+
*/
10+
11+
import { describe, it, expect, afterEach, vi } from 'vitest';
12+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
13+
import { tmpdir } from 'node:os';
14+
import { join } from 'node:path';
15+
16+
// The CLI's constant spelled differently from the runtime's, as a drift
17+
// between the two packages would leave it.
18+
vi.mock('../src/utils/osplugin.js', async (importOriginal) => ({
19+
...(await importOriginal<typeof import('../src/utils/osplugin.js')>()),
20+
MANIFEST_FILENAME: 'objectstack.plugin.drifted.json',
21+
}));
22+
23+
import { createTarGz, type ArchiveFile } from '../src/utils/osplugin.js';
24+
import PluginPublish from '../src/commands/plugin/publish.js';
25+
26+
// The runtime's spelling (`COMPILED_PLUGIN_MANIFEST_FILE`), which the artifact uses.
27+
const RUNTIME_MANIFEST_FILE = 'objectstack.plugin.json';
28+
29+
// No `integrity` map: the preflight's exemption list is spelled with the
30+
// CLI constant, and this file is about the manifest read only.
31+
const manifest = {
32+
version: '1.2.0', id: 'com.acme.demo', name: 'Demo', type: 'plugin',
33+
main: 'dist/index.mjs', packaging: 'bundled', runtime: 'sandbox',
34+
permissions: { services: ['object'] },
35+
};
36+
const manifestText = JSON.stringify(manifest, null, 4);
37+
38+
describe('os plugin publish: the uploaded manifest is the entry it decoded once', () => {
39+
let dir: string | undefined;
40+
const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY };
41+
afterEach(async () => {
42+
vi.unstubAllGlobals();
43+
process.env.OS_CLOUD_URL = prevEnv.url;
44+
process.env.OS_CLOUD_API_KEY = prevEnv.key;
45+
if (dir) await rm(dir, { recursive: true, force: true });
46+
});
47+
48+
it("publishes when the CLI's filename constant and the runtime's differ, and uploads the entry's document key for key", async () => {
49+
const files: ArchiveFile[] = [
50+
{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) },
51+
{ path: RUNTIME_MANIFEST_FILE, data: new Uint8Array(Buffer.from(manifestText)) },
52+
];
53+
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-entry-'));
54+
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
55+
await writeFile(artifactPath, new Uint8Array(createTarGz(files)));
56+
process.env.OS_CLOUD_URL = 'http://cloud.test';
57+
process.env.OS_CLOUD_API_KEY = 'tok_123';
58+
const bodies: any[] = [];
59+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
60+
bodies.push(JSON.parse(init.body));
61+
const data = url.endsWith('/versions') ? { version: '1.2.0' } : { id: 'pkg_1', created: true };
62+
return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any;
63+
}));
64+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
65+
try {
66+
await PluginPublish.run([artifactPath]);
67+
} finally {
68+
logSpy.mockRestore();
69+
}
70+
expect(bodies).toHaveLength(2);
71+
expect(JSON.stringify(bodies[1].plugin_manifest)).toBe(JSON.stringify(JSON.parse(manifestText)));
72+
});
73+
});

‎packages/cli/test/plugin-publish.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ describe('os plugin publish (end-to-end, mocked cloud)', () => {
135135
// Exactly the artifact's own manifest: `ManifestSchema` would stamp
136136
// `scope` and `defaultDatasource`, and neither is uploaded.
137137
expect(bodies[1].plugin_manifest).toEqual(manifest);
138+
// Key for key, in the archive entry's own order (#27914).
139+
const entry = readTarGz(blob).find((f) => f.path === MANIFEST_FILENAME)!;
140+
expect(JSON.stringify(bodies[1].plugin_manifest)).toBe(JSON.stringify(JSON.parse(Buffer.from(entry.data).toString('utf8'))));
138141
expect(bodies[1].plugin_manifest).not.toHaveProperty('scope');
139142
expect(bodies[1].plugin_manifest).not.toHaveProperty('defaultDatasource');
140143
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { readCompiledPluginManifest, COMPILED_PLUGIN_MANIFEST_FILE } from './compiled-plugin-manifest.js';
5+
6+
const authored = {
7+
id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin',
8+
runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs',
9+
};
10+
11+
function entry(text: string) {
12+
return [{ path: COMPILED_PLUGIN_MANIFEST_FILE, data: new Uint8Array(Buffer.from(text)) }];
13+
}
14+
15+
describe('readCompiledPluginManifest', () => {
16+
it('answers the document it decoded beside the parsed manifest, without the defaults the parse stamps', () => {
17+
const text = JSON.stringify(authored, null, 2);
18+
const read = readCompiledPluginManifest(entry(text));
19+
expect(read.ok).toBe(true);
20+
if (!read.ok) return;
21+
// The parse stamps `scope` / `defaultDatasource`; the document does not.
22+
expect(read.manifest).toHaveProperty('scope');
23+
expect(read.document).not.toHaveProperty('scope');
24+
expect(read.document).not.toHaveProperty('defaultDatasource');
25+
// Key for key and in the entry's own order.
26+
expect(JSON.stringify(read.document)).toBe(JSON.stringify(JSON.parse(text)));
27+
});
28+
29+
it('refuses a missing entry or a non-object document as unreadable', () => {
30+
expect(readCompiledPluginManifest([])).toMatchObject({ ok: false, reason: 'unreadable' });
31+
expect(readCompiledPluginManifest(entry('[1,2]'))).toMatchObject({ ok: false, reason: 'unreadable' });
32+
});
33+
});

‎packages/runtime/src/compiled-plugin-manifest.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ export interface CompiledManifestEntry {
2929
}
3030

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

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

0 commit comments

Comments
 (0)