Skip to content

Commit a888ba5

Browse files
test(cli): json-stdout-purity drives os plugin install and os plugin trust list (nightly red) (#27621)
domain:devx#2 · os-justin · session_01QSeKYoZarrfSp6eFqVaxPo Part of #27114 (the card stays open until a scheduled `Nightly tiers` run is green) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:夜间测试 `json-stdout-purity` 现在实际驱动 `os plugin install --json` 与 `os plugin trust list --json` 两条新命令,并且命令发现会顺着 `src/utils` 里替命令启动内核的工具模块找成员。 - 为什么改:两条命令随插件安装功能上线后,这个只在夜间跑的测试把它们识别为新成员却没人驱动,`main` 的夜间检查因此一直是红的。 - 风险与回滚:只改一个测试文件,不动任何产品代码,不发布任何包;回滚就是撤掉这一个提交。 - 先看:`packages/cli/test/json-stdout-purity.e2e.test.ts` 里的 `bootingUtils()` 与 `discoverFamily()`,其次是 `PREPARE['plugin install']`。 - 两分钟验证:`OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/json-stdout-purity.e2e.test.ts`,应看到 `Tests 77 passed (77)`。 - 需要你做的:无,席位落地。 ## What changes `packages/cli/test/json-stdout-purity.e2e.test.ts` only. It is a nightly-tier file, so no pull request or queue run executes it. - **Discovery follows boot utilities.** `bootingUtils()` collects the `src/utils` modules that call `bootSchemaStack(`, plus the modules that import one of them, until nothing new joins. The function's own definition in `schema-migrate.ts` does not count. A command joins the family if it boots directly or imports one of those modules, and it declares `--json`. Before this change, the discovery was a probe of the command's own text. Since #27374, `os plugin install` boots only through `utils/plugin-trust-read.ts`, so that probe had dropped it from the family while it still boots and still has a `--json` face. Today the closure is `plugin-trust-read` and `installed-plugins-boot`. The only commands that import them are `plugin install` and `serve`, and `serve` has no `--json`. The discovered set is 25. - **`plugin install` is driven to its full install.** A new `PREPARE` map holds per-member setup. For this member it builds the artifact and signature the way the command's own integration tests do (`createTarGz` + `computeIntegrity`, `generateEd25519KeyPair` + `signPayload`). It then trusts the key in this member's database with `os plugin trust add`, and throws if that exits non-zero. The member runs under an `OS_HOME` inside the fixture directory. The face driven is the `installed` document, emitted after the read-only trusted-key boot has shut down. - **`plugin trust list` is driven bare.** It boots read-only against a database file that does not exist yet, so the face driven is its empty listing. - `config-miss-stdout-purity.e2e.test.ts` is unchanged. Neither member imports `utils/config.js`, neither refuses before boot, and that file stays green (below). ## Tests <details><summary>Reproduction on origin/main ad3a640: red, 1 failed / 70 passed</summary> `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/json-stdout-purity.e2e.test.ts` ``` AssertionError: expected [ 'db orphans', …(23) ] to deeply equal [ 'db orphans', …(22) ] + "plugin trust list", Tests 1 failed | 70 passed (71) os-verify-lock: VERDICT command-exit 1 ``` The 2026-10-29 scheduled run showed `…(24)` vs `…(22)` with `plugin install` too. #27374 landed afterwards, which is why `plugin install` is absent here: its boot moved into the util. </details> <details><summary>After, at merged head b743a63: both files green</summary> ``` json-stdout-purity.e2e.test.ts Test Files 1 passed (1) · Tests 77 passed (77) · VERDICT command-exit 0 config-miss-stdout-purity.e2e.test.ts Test Files 1 passed (1) · Tests 174 passed (174) · VERDICT command-exit 0 ``` The count goes from 71 to 77: two members, three cases each. </details> <details><summary>Hand probe: install reaches its success face, not a refusal</summary> I ran the same fixture by hand through `bin/run-dev.js`: - `plugin trust add` exits 0. - `plugin install --json purity.osplugin` exits 0. stdout parses as one document with keys `installed, replaced, integrity, boot`, `installed.signature.keyId = purity-2026` and `boot.loads = true`. stderr carries the boot diagnostics. - `plugin trust list --json` exits 0 with one `{"keys":[…]}` document. </details> <details><summary>Teeth: one member removed from FAMILY goes red, then restored</summary> `node scripts/ablation-replace.mjs --file packages/cli/test/json-stdout-purity.e2e.test.ts --anchor "'plugin trust list': []," --delete -- …` (the same nightly invocation): ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 60a3d4d -> 5cd1f1c7f07f AssertionError: expected [ 'db orphans', …(24) ] to deeply equal [ 'db orphans', …(23) ] + "plugin trust list", Tests 1 failed | 73 passed (74) ablation-replace: blob after restore 60a3d4d ablation-replace: blob at HEAD 60a3d4d ``` The discovery half has its own control: the reproduction on main is that same discovery without the util hop, and it does not find `plugin install`. </details> ## Acceptance notes - `skip-changeset`: tests only. No published file changes. - Route taken for `plugin install`: teach the discovery the util, rather than listing the command by hand, which note 6490929251 offered as the other route. A hand-listed member would break the file's own `discovered == FAMILY` equality. It would also leave the next command that boots through a util silently outside the pin. - Opened by the seat (`session_01QSeKYmTYfaPbLQD4BJfomW`) on the maintainer's authorisation 「授权本席位打开这个草稿 PR」, from the dev's prepared title, body and labels, because the dev session's classifier refused its own `pr_create` (card note 6499909922). ## Verification Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) were run locally at b743a63. The readings are in the dev report on #27114. CI runs the required set on this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYoZarrfSp6eFqVaxPo)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 178b523 commit a888ba5

1 file changed

Lines changed: 92 additions & 9 deletions

File tree

‎packages/cli/test/json-stdout-purity.e2e.test.ts‎

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
* The contract has one implementation face per command. A file that pinned
1919
* `migrate recorded-by` alone would go green while the others stayed broken,
2020
* and would say nothing at all about a new one. So the family is DISCOVERED
21-
* from the source tree — every command that calls `bootSchemaStack` and
22-
* declares a `--json` flag — and the discovered set is reconciled against
21+
* from the source tree — every command that calls `bootSchemaStack`, itself or
22+
* through a `src/utils` module that does, and declares a `--json` flag — and
23+
* the discovered set is reconciled against
2324
* {@link FAMILY} below. Add a member and this file goes red until it is listed
2425
* here and passes; drop one and it goes red until it is removed. The seam
2526
* itself (`bootSchemaStack`'s required `jsonOutput` option) makes the mistake a
@@ -60,25 +61,28 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6061
import { execFile } from 'node:child_process';
6162
import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
6263
import { tmpdir } from 'node:os';
63-
import { join, resolve, relative, sep } from 'node:path';
64+
import { basename, join, resolve, relative, sep } from 'node:path';
6465
import { fileURLToPath } from 'node:url';
66+
import { generateEd25519KeyPair, signPayload } from '@objectstack/core';
6567
import { maskComments } from '../../../scripts/js-comment-mask.mjs';
68+
import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../src/utils/osplugin.js';
6669
import { childEnv } from './helpers/serve-process.js';
6770
import { CONFIG_MISS_FAMILY, discoverConfigMissFamily } from './helpers/config-miss-family.js';
6871

6972
const HERE = resolve(fileURLToPath(import.meta.url), '..');
7073
const CLI = resolve(HERE, '../bin/run-dev.js');
7174
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
7275
const COMMANDS_DIR = resolve(HERE, '../src/commands');
76+
const UTILS_DIR = resolve(HERE, '../src/utils');
7377

7478
/**
7579
* The family, and how to drive each member into a boot.
7680
*
7781
* The value is the EXTRA argv a member needs beyond `--json`; `[]` means the
78-
* bare form already boots. Only one member needs anything: `os migrate meta`
79-
* migrates an authored config in memory and boots a kernel solely under
80-
* `--stored`, which is why the issue's own list names it as `migrate meta
81-
* --stored` rather than `migrate meta`.
82+
* bare form already boots. `os migrate meta` migrates an authored config in
83+
* memory and boots a kernel solely under `--stored`, which is why the issue's
84+
* own list names it as `migrate meta --stored` rather than `migrate meta`. A
85+
* member that needs a fixture as well as argv is prepared by {@link PREPARE}.
8286
*/
8387
const FAMILY: Record<string, string[]> = {
8488
// Report-only is its DEFAULT and the only form driven here: without
@@ -146,6 +150,16 @@ const FAMILY: Record<string, string[]> = {
146150
// face driven here is its empty-work answer for a table not there yet.
147151
'migrate unmapped-columns': ['--object', 'jp_ticket'],
148152
'migrate value-shapes': [],
153+
// It boots only to read the trusted key a SIGNED artifact names (through
154+
// `utils/plugin-trust-read.ts`, which is why the discovery follows utils),
155+
// so it is driven with one: `PREPARE` builds and signs the artifact and
156+
// trusts its key in this member's database, and the face driven here is the
157+
// full install, its `installed` document emitted after the read-only boot
158+
// has shut down. An unsigned artifact would refuse without booting at all.
159+
'plugin install': ['purity.osplugin'],
160+
// Its bare form is read-only (`deferSchemaDdl` + `readOnlyProbe`). No
161+
// database file exists, so the face driven here is its empty listing.
162+
'plugin trust list': [],
149163
// Report-only is its DEFAULT and the only form driven here: without
150164
// `--delete` it boots, reports and writes nothing, so the family gains a
151165
// member without this fixture gaining a destructive run.
@@ -165,6 +179,41 @@ const BOOT_DIAGNOSTICS = [
165179
'Graceful shutdown complete',
166180
];
167181

182+
/**
183+
* Members that need a fixture beyond argv to reach the face driven here. Each
184+
* gets the project directory and its own environment, prepares what it needs,
185+
* and returns the extra environment its run takes. A preparation that fails
186+
* throws: the member would otherwise reach some other face and still pass.
187+
*/
188+
const PREPARE: Record<string, (dir: string, env: Record<string, string>) => Promise<Record<string, string>>> = {
189+
// An artifact built and signed the way `os plugin build` / `os plugin sign`
190+
// make one, its key trusted by `os plugin trust add` in this member's
191+
// database. The install materializes under OS_HOME, so it gets one of its
192+
// own inside the fixture rather than the runner's.
193+
'plugin install': async (dir, env) => {
194+
const keyId = 'purity-2026';
195+
const publisher = generateEd25519KeyPair();
196+
const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: new TextEncoder().encode('export const id = "com.purity.e2e";\n') }];
197+
const manifest = {
198+
id: 'com.purity.e2e',
199+
version: '1.0.0',
200+
type: 'plugin',
201+
runtime: 'node',
202+
packaging: 'bundled',
203+
integrity: computeIntegrity(files),
204+
};
205+
const artifact = join(dir, 'purity.osplugin');
206+
writeFileSync(artifact, createTarGz([...files, { path: MANIFEST_FILENAME, data: new TextEncoder().encode(JSON.stringify(manifest)) }]));
207+
writeFileSync(`${artifact}.sig`, signPayload(new Uint8Array(readFileSync(artifact)), publisher.privateKeyPem, keyId));
208+
writeFileSync(join(dir, 'purity.pub.pem'), publisher.publicKeyPem);
209+
const trusted = await runCli(['plugin', 'trust', 'add', 'purity.pub.pem', '--key-id', keyId], dir, env);
210+
if (trusted.code !== 0) {
211+
throw new Error(`os plugin trust add exited ${trusted.code}:\n${trusted.stdout}\n${trusted.stderr}`);
212+
}
213+
return { OS_HOME: join(dir, 'os-home-plugin-install') };
214+
},
215+
};
216+
168217
/** Every `.ts` under `src/commands`, excluding tests. */
169218
function commandFiles(dir: string): string[] {
170219
const out: string[] = [];
@@ -180,13 +229,43 @@ function commandFiles(dir: string): string[] {
180229
return out;
181230
}
182231

232+
/**
233+
* The `src/utils` modules that boot the shared stack on a command's behalf:
234+
* those that call `bootSchemaStack(` (its own definition does not count), and
235+
* those that import one that does, to a fixed point. `os plugin install` boots
236+
* only through `plugin-trust-read.ts`, so a probe of the command's own text
237+
* would drop it from the family while it still boots.
238+
*/
239+
function bootingUtils(): Set<string> {
240+
const sources = new Map<string, string>();
241+
for (const abs of commandFiles(UTILS_DIR)) {
242+
sources.set(basename(abs, '.ts'), maskComments(readFileSync(abs, 'utf-8')));
243+
}
244+
const booting = new Set<string>();
245+
for (const [name, src] of sources) {
246+
if (src.replace(/function bootSchemaStack\(/g, '').includes('bootSchemaStack(')) booting.add(name);
247+
}
248+
for (let grew = true; grew;) {
249+
grew = false;
250+
for (const [name, src] of sources) {
251+
if (booting.has(name)) continue;
252+
if ([...booting].some((b) => src.includes(`from './${b}.js'`))) {
253+
booting.add(name);
254+
grew = true;
255+
}
256+
}
257+
}
258+
return booting;
259+
}
260+
183261
/**
184262
* The family, read off the source rather than remembered: a command belongs iff
185263
* it boots the shared stack AND offers a machine-readable mode. Both halves are
186264
* needed — `os serve` boots a kernel with no `--json`, and `os lint --json`
187265
* emits JSON without booting one.
188266
*/
189267
function discoverFamily(): string[] {
268+
const utils = [...bootingUtils()];
190269
const ids: string[] = [];
191270
for (const abs of commandFiles(COMMANDS_DIR)) {
192271
// Masked: both halves are text probes, and both are satisfiable by prose. A
@@ -195,7 +274,9 @@ function discoverFamily(): string[] {
195274
// diagnostics it never writes — a red in a tier no pull request collects,
196275
// wearing this file's title rather than the docblock's (#18520).
197276
const src = maskComments(readFileSync(abs, 'utf-8'));
198-
if (!src.includes('bootSchemaStack(')) continue;
277+
const boots = src.includes('bootSchemaStack(')
278+
|| utils.some((u) => new RegExp(`from '(?:\\.\\./)+utils/${u}\\.js'`).test(src));
279+
if (!boots) continue;
199280
if (!/\bjson:\s*Flags\.boolean\(/.test(src)) continue;
200281
const rel = relative(COMMANDS_DIR, abs).replace(/\.ts$/, '');
201282
const parts = rel.split(sep).filter((p) => p !== 'index');
@@ -256,10 +337,12 @@ beforeAll(async () => {
256337
runs = [];
257338
for (const [id, extra] of Object.entries(FAMILY)) {
258339
const slug = id.replace(/[^a-z0-9]+/gi, '-');
340+
const env = { OS_DATABASE_URL: `file:${join(dir, `${slug}.db`)}` };
341+
const prepared = PREPARE[id] ? await PREPARE[id](dir, env) : {};
259342
const { code, stdout, stderr } = await runCli(
260343
[...id.split(' '), '--json', ...extra],
261344
dir,
262-
{ OS_DATABASE_URL: `file:${join(dir, `${slug}.db`)}` },
345+
{ ...env, ...prepared },
263346
);
264347
runs.push({ id, code, stdout, stderr });
265348
}

0 commit comments

Comments
 (0)