Skip to content

Commit 50646f0

Browse files
fix(cli): the compiled plugin manifest filename is the runtime's constant (#28530)
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XAzrBawWCXDWe5RxPWMYpti)_ Fixes #28080 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:CLI 里插件清单文件名 `MANIFEST_FILENAME` 不再自己写一遍字符串,直接取运行时的 `COMPILED_PLUGIN_MANIFEST_FILE`。为了不让 `os plugin` 每条命令多花约 1 秒加载整个运行时,运行时新开一个只含这个常量的轻量子路径 `@objectstack/runtime/compiled-plugin-manifest-file`。 - **为什么改**:卡 #28080:`os plugin build` 写入、`publish`/`install` 完整性检查豁免用的文件名,和运行时读取用的文件名是两份手抄的同一字符串,一旦漂移,build 出的包运行时找不到清单。 - **风险与回滚**:文件名本身不变(`objectstack.plugin.json`),已构建的插件包照常可读;只是新增一个子路径导出。回滚即 revert。 - **先看**:`packages/cli/src/utils/osplugin.ts`(常量改为引用);`packages/runtime/package.json` 与 `tsup.config.ts`(新子路径);`packages/adapters/hono/vitest.config.ts`(为新子路径补的一条测试别名)。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit test/osplugin-manifest-filename.test.ts` → 2 passed;把 osplugin.ts 里那行改成别的字面量,同一命令转红。 - **需要你做的**:无,席位落地。 ## What changes `MANIFEST_FILENAME` in `packages/cli/src/utils/osplugin.ts` was a hand-kept twin of `COMPILED_PLUGIN_MANIFEST_FILE` in `packages/runtime/src/compiled-plugin-manifest.ts`. It is now `export const MANIFEST_FILENAME = COMPILED_PLUGIN_MANIFEST_FILE`, imported from the runtime, so the entry `os plugin build` writes (`build.ts`), the entry `os plugin publish` and `os plugin install` exempt from the integrity map, the entry `computeIntegrity` leaves out, and the entry `readCompiledPluginManifest` finds are one name. The tree now holds one `'objectstack.plugin.json'` literal for it. `MANIFEST_FILENAME` stays as an alias because ten CLI test files and three command files import it. **Import path, measured.** The constant was not reachable from any published runtime entry: `@objectstack/runtime` published only `.`, and its root `index.ts` did not export it. Importing the root entry from `osplugin.ts` is the heavy path that file's own header already refuses (`readOspluginManifest` imports the runtime on call for this reason). Measured on this box, three fresh `node` loads each: | import | ms | | :--- | :--- | | `@objectstack/core` (osplugin.ts already imports it) | 335, 344, 355 | | `@objectstack/runtime` root | 1447, 1388, 1363 | | built `packages/cli/dist/utils/osplugin.js` after this change | 450, 417, 447 | So the constant moved into its own import-free module, `packages/runtime/src/compiled-plugin-manifest-file.ts`, published as the subpath `@objectstack/runtime/compiled-plugin-manifest-file` (new `tsup` entry, new `exports` key with `import`/`require` conditions, matching the root entry's shape). `compiled-plugin-manifest.ts` imports and re-exports it, so the runtime's own readers and tests are unchanged. The runtime has no lean-entry gate of its own (`check:lean-entry-closure` guards `@objectstack/objectql/core` only); the module's header says it must stay import-free. **Hono test alias.** `check:test-source-alias` resolves every published subpath through every vitest alias table. `packages/adapters/hono/vitest.config.ts` maps bare `@objectstack/runtime` by prefix to a mock FILE, which would have sent the new subpath to `src/__mocks__/runtime.ts/compiled-plugin-manifest-file` (ENOTDIR). The gate's remedy is applied: an anchored entry for the subpath ahead of the prefix entry, pointing at the runtime source. The comment there that said the runtime publishes only `.` is corrected. This file is outside the claim's declared file surface; it is a forced consequence of the new subpath (see Acceptance notes). ## Tests `packages/cli/test/osplugin-manifest-filename.test.ts` (unit tier): - `MANIFEST_FILENAME` is `COMPILED_PLUGIN_MANIFEST_FILE` (the runtime's constant, imported from the new subpath), not compared to a literal. - An archive whose manifest entry is named `MANIFEST_FILENAME` is read `ok` by the runtime's `readCompiledPluginManifest`, and `computeIntegrity` leaves exactly that entry out. **Ablation** (head `9c90f75a`, the implementation committed first): `node scripts/ablation-replace.mjs` replaced `export const MANIFEST_FILENAME = COMPILED_PLUGIN_MANIFEST_FILE;` with `export const MANIFEST_FILENAME = 'objectstack.plugin.drifted.json';` (anchor 1 → 0, blob `89db86b4` → `76a05359`). Both cases red: `expected 'objectstack.plugin.drifted.json' to be 'objectstack.plugin.json'` and `expected false to be true`. Restored: blob back to `89db86b4` = the HEAD blob, `git diff HEAD` empty. The test imports `osplugin.ts` from source, so no build sits between the mutation and the run. ## Acceptance notes - The triage line says the CLI imports the constant "from the runtime". It does, from a new runtime subpath rather than the root entry, because the root costs about a second on every `os plugin` command (table above). The order anticipated "export the constant from a new entry". A home in `@objectstack/core` (which already holds the artifact tar reader both sides share) would avoid the new subpath and the hono alias line; it was not taken because it departs from the triage ruling. - Claim file surface: `packages/adapters/hono/vitest.config.ts` was added (one alias entry and a comment fix), forced by `check:test-source-alias` once the subpath exists. The seat may amend the claim's surface. - `test/plugin-publish-manifest-entry.test.ts` still mocks a drifted `MANIFEST_FILENAME` to prove publish's manifest read does not depend on it. That drift can no longer happen in the real tree; the test stays because it still pins publish's single decode. Not touched. - `MANIFEST_FILENAME` also names the SOURCE manifest `os plugin build` reads from the project directory (`build.ts`). That was already the same string, so nothing changes; noted because the alias now ties the source name to the runtime constant too. - Commit trailers follow the repo's model-free pair (`check:commit-card-trailers` refused the first push; the tip was reworded before it was published). ## Verification Final head `ce89ca90`. Local, per `.claude/agents/os-dev.md`: - `pnpm --filter '@objectstack/cli^...' build`, then `pnpm --filter @objectstack/runtime build` (new entry emitted: `dist/compiled-plugin-manifest-file.{js,cjs,d.ts,d.cts}`; `check-dts-emitted: 4/4`), `pnpm --filter @objectstack/cli build`. - `pnpm --filter @objectstack/cli typecheck` and `pnpm --filter @objectstack/runtime typecheck`: exit 0 (test layers held in their debt files). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 361 files, 5152 tests passed. The integration tier is declared to CI (no integration file or spawn entry touched). - `pnpm --filter @objectstack/runtime test`: 498 files passed, 8084 tests passed, 19 skipped. - `pnpm --filter @objectstack/hono test`: 14 files, 231 tests passed. - New subpath loads under both conditions from `packages/cli`: `require(...)` and `import(...)` each answer `objectstack.plugin.json`. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 76 commands on head `ce89ca90`): 74 exit 0, including `check:test-source-alias`, `check:published-files`, `check:dts-closure`, `check:type-check-debt`, `check:undeclared-dep-imports`, `check:lean-entry-closure`, `check:adr-0087-registration`. `check-plugin-teardown-shape --self-test` needed its anchor commit fetched on this shallow clone, then exit 0. - The other two read every package's build output and first answered exit 3 (`PREREQUISITE NOT MET` / `COULD NOT MEASURE`, nothing measured). After `turbo run build` over `./packages/**` and `./examples/**` (78/78 tasks), both exit 0: `check:dual-build-cjs-loads` (114 require entry points across 70 packages load, the new subpath among them) and `check:i18n-coverage` (14 configs, none new). So all 76 derived gates exit 0 on `ce89ca90`. - Hand gates: `check:commit-card-trailers` 0, `check:nul-bytes` 0, `eslint --no-inline-config packages/cli packages/runtime packages/adapters/hono` 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched packages; the repo-wide run is CI's.) - [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. (`.changeset/28080-one-manifest-filename.md`: `@objectstack/cli` and `@objectstack/runtime` `patch`.) - [x] 6. `packages/spec` touched: n/a. - [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 maintainer brief is in Chinese by the agent contract.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mhnqXsq7TSYsr6HPG --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mhnqXsq7TSYsr6HPG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69dfac0 commit 50646f0

8 files changed

Lines changed: 98 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli): the compiled plugin manifest's filename has one spelling, the runtime's
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`os plugin build` writes the compiled manifest under the CLI's `MANIFEST_FILENAME`, and `os plugin publish` / `install` exempt that entry from the integrity map by it, while the runtime's reader finds the entry by `COMPILED_PLUGIN_MANIFEST_FILE`. The two were the same string kept by hand. `MANIFEST_FILENAME` is now the runtime's constant itself, so they cannot drift. The filename is unchanged (`objectstack.plugin.json`), so every artifact built before reads the same.
12+
13+
**Public surface (additive):** `@objectstack/runtime` publishes a new subpath, `@objectstack/runtime/compiled-plugin-manifest-file`, exporting `COMPILED_PLUGIN_MANIFEST_FILE`. It is an import-free module, so the CLI reads the constant without loading the runtime's root entry, which every `os plugin` command would otherwise pay for (about a second).

‎packages/adapters/hono/vitest.config.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ export default defineConfig({
3535
find: /^@objectstack\/plugin-hono-server$/,
3636
replacement: path.resolve(__dirname, '../../plugins/plugin-hono-server/src/index.ts'),
3737
},
38+
// @objectstack/runtime's one published subpath, listed BEFORE the bare
39+
// entry below so that entry's prefix match cannot swallow it into
40+
// `src/__mocks__/runtime.ts/compiled-plugin-manifest-file` (ENOTDIR). It
41+
// is an import-free constant module, so it points at the real source,
42+
// not the mock (#28080).
43+
{
44+
find: /^@objectstack\/runtime\/compiled-plugin-manifest-file$/,
45+
replacement: path.resolve(__dirname, '../../runtime/src/compiled-plugin-manifest-file.ts'),
46+
},
3847
// Unchanged in reach from the object entry this array replaced: a bare
3948
// string `find` still matches by prefix, so the kernel mock keeps exactly
40-
// the surface it had. @objectstack/runtime publishes only `.`, so there
41-
// is no subpath here for the prefix to swallow.
49+
// the surface it had for the root entry.
4250
{
4351
find: '@objectstack/runtime',
4452
replacement: path.resolve(__dirname, 'src/__mocks__/runtime.ts'),

‎packages/cli/src/utils/osplugin.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { createHash } from 'node:crypto';
3939
import { gzipSync } from 'node:zlib';
4040
import { readTarGz, type ArchiveFile } from '@objectstack/core';
4141
import type { ObjectStackManifestParsed } from '@objectstack/spec/kernel';
42+
// The import-free subpath, not the root entry: see `readOspluginManifest` below.
43+
import { COMPILED_PLUGIN_MANIFEST_FILE } from '@objectstack/runtime/compiled-plugin-manifest-file';
4244

4345
export { readTar, readTarGz, sha256Hex, MAX_UNPACKED_ARTIFACT_BYTES, type ArchiveFile } from '@objectstack/core';
4446

@@ -70,7 +72,13 @@ export function computeIntegrity(files: ArchiveFile[]): Record<string, string> {
7072
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
7173
}
7274

73-
export const MANIFEST_FILENAME = 'objectstack.plugin.json';
75+
/**
76+
* The compiled manifest's filename: the runtime's own constant, so the entry
77+
* `os plugin build` writes is the one the runtime's reader finds, and the
78+
* entry publish/install exempt from the integrity map is that same one.
79+
* ⛔ Never a second string literal here.
80+
*/
81+
export const MANIFEST_FILENAME = COMPILED_PLUGIN_MANIFEST_FILE;
7482
export const SIGNATURE_FILENAME = 'SIGNATURE';
7583
export const OSPLUGIN_EXT = '.osplugin';
7684

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+
/**
4+
* The compiled manifest's filename has ONE spelling (#28080): the CLI's
5+
* `MANIFEST_FILENAME` (the entry `os plugin build` writes, the entry publish
6+
* and install exempt from the integrity map) is the runtime's
7+
* `COMPILED_PLUGIN_MANIFEST_FILE` (the entry `readCompiledPluginManifest`
8+
* finds), not a hand-kept twin of it.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { readCompiledPluginManifest } from '@objectstack/runtime';
13+
import { COMPILED_PLUGIN_MANIFEST_FILE } from '@objectstack/runtime/compiled-plugin-manifest-file';
14+
import { MANIFEST_FILENAME, computeIntegrity } from '../src/utils/osplugin.js';
15+
16+
describe('the compiled manifest filename', () => {
17+
it("is the runtime's constant", () => {
18+
expect(MANIFEST_FILENAME).toBe(COMPILED_PLUGIN_MANIFEST_FILE);
19+
});
20+
21+
it("names the entry the runtime's reader finds, and the entry the integrity map leaves out", () => {
22+
const manifest = {
23+
id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin',
24+
runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs',
25+
};
26+
const files = [
27+
{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x = 1;\n')) },
28+
{ path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifest))) },
29+
];
30+
expect(readCompiledPluginManifest(files).ok).toBe(true);
31+
expect(Object.keys(computeIntegrity(files))).toEqual(['dist/index.mjs']);
32+
});
33+
});

‎packages/runtime/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
"types": "./dist/index.d.cts",
1818
"default": "./dist/index.cjs"
1919
}
20+
},
21+
"./compiled-plugin-manifest-file": {
22+
"import": {
23+
"types": "./dist/compiled-plugin-manifest-file.d.ts",
24+
"default": "./dist/compiled-plugin-manifest-file.js"
25+
},
26+
"require": {
27+
"types": "./dist/compiled-plugin-manifest-file.d.cts",
28+
"default": "./dist/compiled-plugin-manifest-file.cjs"
29+
}
2030
}
2131
},
2232
"scripts": {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The filename of the compiled manifest at an `.osplugin` artifact's root
5+
* (ADR-0025 §3.2): the ONE spelling. `os plugin build` writes the entry under
6+
* it, `os plugin publish` / `install` exempt it from the integrity map, and
7+
* `readCompiledPluginManifest` finds it by it. The CLI imports this constant,
8+
* so build and the runtime cannot drift onto two names.
9+
*
10+
* It lives in a module with no imports, published as its own subpath
11+
* (`@objectstack/runtime/compiled-plugin-manifest-file`), because the CLI
12+
* reads it from `utils/osplugin.ts`, which every `os plugin` command loads:
13+
* the package's root entry costs about a second of load time that
14+
* `os plugin build` and `os plugin sign` never need. ⛔ Keep this module free
15+
* of imports, or the subpath stops being cheap.
16+
*/
17+
export const COMPILED_PLUGIN_MANIFEST_FILE = 'objectstack.plugin.json';

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818

1919
import { formatZodIssue } from '@objectstack/spec';
2020
import { ManifestSchema, type ObjectStackManifestParsed } from '@objectstack/spec/kernel';
21+
import { COMPILED_PLUGIN_MANIFEST_FILE } from './compiled-plugin-manifest-file.js';
2122

22-
/** The manifest at an artifact's root. */
23-
export const COMPILED_PLUGIN_MANIFEST_FILE = 'objectstack.plugin.json';
23+
/** The manifest at an artifact's root (its one spelling, shared with the CLI). */
24+
export { COMPILED_PLUGIN_MANIFEST_FILE };
2425

2526
/** An artifact entry, as `readTarGz` answers it. */
2627
export interface CompiledManifestEntry {

‎packages/runtime/tsup.config.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { defineConfig } from 'tsup';
55
import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs';
66

77
export default defineConfig({
8-
entry: ['src/index.ts'],
8+
// `compiled-plugin-manifest-file` is its own import-free entry, so the CLI
9+
// reads the manifest filename without loading the root entry (#28080).
10+
entry: ['src/index.ts', 'src/compiled-plugin-manifest-file.ts'],
911
splitting: false,
1012
sourcemap: true,
1113
clean: true,

0 commit comments

Comments
 (0)