Skip to content

Commit 665db00

Browse files
fix(runtime,cli): a newcomer's first boot is quiet — the empty stub declares its protocol range, and a scaffold's os dev composes one MetadataPlugin (#30333)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgBgqBcs88QEaQ8R1f3pax9S)_ domain:server#3 · os-bill · session_01Utp6CZgQTno2oPVD2GLd2n Fixes #29937 Fixes #29936 Clause-②: no — boot diagnostics only; nothing accepted or published changes Metadata-protocol: none ## 维护者速读 - **改了什么:** ① 空内核存根 `com.objectstack.empty` 现在声明 `engines.protocol: ^18`(写它的运行时自己的大版本);旧 CLI 写过的存根(无范围、或别的大版本)在下次 `os start` 时就地补上,只改这一个键,且只改 OS_HOME 下仍是存根的文件。② `os serve` / `os dev` 的 dev artifact door 与 host-config door 共用一个 `hasMetadataPlugin` 判定(先认插件名 `com.objectstack.metadata`,再认类名),dev door 不再在已有 `MetadataPlugin` 的栈上再加一个。 - **为什么改:** 文档首页一行命令 `npm i -g @objectstack/cli && os start` 的首启诊断报「`com.objectstack.empty` 没声明 engines.protocol」,新人既不能修也找不到这个包;`os init -t app` 脚手架上 `os dev` 每次启动都报「Plugin superseded: 'com.objectstack.metadata'」,怪用户配错了一个他没写过的插件。后者的根因:发布的 `@objectstack/metadata` 打包产物写成 `var MetadataPlugin = class _MetadataPlugin`,`constructor.name` 是 `_MetadataPlugin`,dev door 只比类名,所以认不出 standalone stack 已组好的那个;源码跑的测试看不到这个改名。 - **风险与回滚:** 只动启动组装与存根文件。存根刷新只在 OS_HOME 的 `dist/objectstack.json` 且 `manifest.id` 仍是 `com.objectstack.empty` 时发生,别处的同形文件、非 JSON 文件一律不碰(有 pin)。dev door 的判定只比原来多认一种已有插件的写法,host config 没有自己的 `MetadataPlugin` 时照旧组 dev door。两个包各一条 patch changeset。回滚即撤 PR。 - **先看:** `packages/runtime/src/default-host.ts` 的 `ensureEmptyBootStub` 与 `createDefaultHostConfig` 里按路径传入时的刷新分支;`packages/cli/src/utils/stack-collections.ts` 的 `hasMetadataPlugin`;`packages/cli/src/commands/serve.ts` 两处门控。 - **两分钟验证:** 空目录、空 HOME 下跑 `os start`:首启诊断里不再出现 `declares no engines.protocol range`,`~/.objectstack/dist/objectstack.json` 带 `"engines": { "protocol": "^18" }`。 - **需要你做的:** 无,席位落地。 ## What changes **#29937 — the empty kernel's stub declares its range** (`@objectstack/runtime`, commits 4a5bb32 and 8e3cead) - `ensureEmptyBootStub(stubPath)` (new, in `default-host.ts`; not re-exported from the package index) owns the stub. Absent: it is written with `manifest.engines.protocol` = `^PROTOCOL_MAJOR` (from `@objectstack/spec/kernel`), the major of the runtime that writes it, so it is compatible by construction. Present and still the stub (`manifest.id` is `com.objectstack.empty`) with no range or another major's: only `manifest.engines.protocol` is rewritten. Present and not the stub, or not JSON: left alone; the loader reports it. - The re-stamp of another major's range is deliberate: a stub stamped `^18` by this CLI would otherwise be refused as `OS_PROTOCOL_INCOMPATIBLE` the day the runtime moves to 19, failing the one boot that has nothing in it. - **Found by the before/after measurement, not in the card:** the empty-boot branch of `createDefaultHostConfig` runs only on the FIRST `os start`. From the second on, the `os start` supervisor resolves `HOME_DIR/.objectstack/dist/objectstack.json` as an artifact rung (`resolveArtifactBootSource`, rung `home-dist`) and hands it down as `artifactPath`, so a stub an older CLI wrote was never reached by the first commit and kept warning (measured, log below). The second commit refreshes the stub on that path too, keyed on the resolved path being exactly the home stub path; a stub-shaped file named anywhere else is left alone. **#29936 — one `MetadataPlugin` on a scaffold's `os dev`** (`@objectstack/cli`, commit 028a043) - `hasMetadataPlugin(plugins)` (new, `utils/stack-collections.ts`, beside `hasObjectQLPlugin` / `hasStorageDriverPlugin`): a plugin named `com.objectstack.metadata`, or a class named `MetadataPlugin`. - `serve.ts`: the dev artifact door's gate was `!plugins.some((p) => p?.constructor?.name === 'MetadataPlugin')`; the host-config door's was the name-or-class form. Both now read `!hasMetadataPlugin(plugins)`. `createStandaloneStack` is untouched, as the dispatch asked. - Why only the dev door missed: the published bundle (checked on `@objectstack/metadata@18.3.0` `dist/index.js` and on this branch's own build of `packages/metadata/dist/index.js` and `node.js`, one hit each) declares `var MetadataPlugin = class _MetadataPlugin {`. The host-config door already checked the name, so it was immune; the dev door was not. ## Tests - `packages/runtime/src/default-host-empty-stub.test.ts` (new, 9 cases): a fresh stub passes the handshake (`checkProtocolCompat(...).status === 'ok'`); a legacy stub (exactly the 18.3.0 bytes' shape) is refreshed with everything else kept; another major's range is re-stamped; a current stub is left byte for byte; a non-stub artifact and a non-JSON file are untouched; through `createDefaultHostConfig`: the legacy stub named as `artifactPath` at the home location is refreshed, the empty boot writes the range, and a stub-shaped file named elsewhere is untouched. - `packages/cli/src/utils/stack-collections.test.ts`: three `hasMetadataPlugin` cases, one built on the bundle's shape (`class _MetadataPlugin { name = 'com.objectstack.metadata' }`). - Source pins updated: `serve-host-config-security-registrar.pin.test.ts` (the dev door's gate string) and `serve-host-config-metadata-door.pin.test.ts` (both doors ask `!hasMetadataPlugin(plugins)`, count 2, and no class-name-only spelling is left in `serve.ts`). ## Verification All readings on head 2041627 (gates: the 61-family run on 8e3cead, then the 17 families that read the last commit's file re-run on 2041627; the last commit adds one `SERVE_ONLY` entry to a cli test). <details><summary>Real boot, before/after (#29937: os start, empty directory, empty HOME)</summary> Built CLI of this branch (`node packages/cli/bin/run.js start`, `HOME` a fresh temp dir). - **Before** (`default-host.ts` restored from a26abbc, runtime rebuilt; `ensureEmptyBootStub` count in dist 0): `Artifact: none (empty kernel …)`, then `ℹ Boot diagnostics — 1 informational: WARN [AppPlugin] [protocol] package 'com.objectstack.empty' declares no engines.protocol range; loading under protocol 18.0.0 without a compatibility check (ADR-0087).` - **After, fresh home:** `Server is ready`, no `Boot diagnostics` block; the stub on disk carries `"engines": { "protocol": "^18" }`. - **After commit 1 only, legacy stub** (the 18.3.0 stub bytes pre-seeded): banner `Artifact: HOME_DIR/.objectstack/dist/objectstack.json` (the `home-dist` rung), the same WARN, stub unchanged. This is what commit 8e3cead closes. - **After commit 2, legacy stub:** no `Boot diagnostics`; the stub now carries `"engines": { "protocol": "^18" }`. - Restore leg: `git diff HEAD` empty, runtime rebuilt from HEAD (dist `ensureEmptyBootStub(` count 3). </details> <details><summary>Real boot, before/after (#29936: os dev on an os init my-app -t app scaffold)</summary> - **Before** (`serve.ts` restored from a26abbc, cli rebuilt): `ℹ Boot diagnostics — 1 informational: WARN Plugin superseded: 'com.objectstack.metadata' — the later registration (v1.0.0) REPLACED the earlier one (v1.0.0). …` - **After** (cli rebuilt from HEAD): `Server is ready`, `superseded` count 0, no `Boot diagnostics` block; `Plugins: 41 loaded` lists `com.objectstack.metadata` once. - Restore leg: `git diff HEAD` empty. </details> <details><summary>Ablations (scripts/ablation-replace.mjs; each restored, blob == HEAD, git diff HEAD empty)</summary> - `hasMetadataPlugin` name clause dropped (`return ctor === 'MetadataPlugin';`): `stack-collections.test.ts` 1 failed / 21 passed — `finds the plugin by its name when the bundle renamed the class`. - Stub `engines: { protocol: range },` removed: `default-host-empty-stub.test.ts` 3 failed / 6 passed (fresh stub, current-stub-left-alone, empty boot writes the range). - Named-path refresh disabled (path compared to a sentinel): 1 failed / 8 passed — `refreshes a legacy stub handed in as artifactPath at the home location`. </details> <details><summary>Tests and typecheck</summary> - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`: Test Files 545 passed (545), Tests 9840 passed, 19 skipped (9859); lock VERDICT command-exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` on 8e3cead: 1 failed / 5429 passed — `boot-preparation-parity.test.ts` asked for `hasMetadataPlugin` to be listed; fixed in 2041627 (`SERVE_ONLY` entry with its reason), that file then 37/37. The cli `integration` tier is declared to CI. - `pnpm --filter @objectstack/runtime typecheck`, `pnpm --filter @objectstack/cli typecheck`: VERDICT command-exit 0 both. </details> <details><summary>Derived gates (dispatch-gates --commands, 61 families)</summary> - 59 exit 0. 2 exit 3, NOT MEASURED (prerequisite: a whole-workspace build): `check:dual-build-cjs-loads`, `check:i18n-coverage`; left to CI. - `dispatch-gates --ran`: 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN. - Re-run on 2041627, all exit 0: closing-keyword-parity, comment-mask-adoption, comment-mask-corpus, issue-citations (script and pnpm), closed-ceremony-text, cross-package-test-inputs, keyed-text-bounds, logger-receiver-detach, nul-bytes, refd-timer-probe, registry-log-declared, rest-log-spy-declared, test-source-alias, tier-file-adoption, type-check-debt, undeclared-dep-imports. </details> ## Acceptance notes - The seat asked for one commit per card. #29937 has two (4a5bb32, 8e3cead): the second is the named-path refresh the measurement found after the first was pushed. The branch is `claude/project-thread-*`, not `claude/issue-*`, so AGENTS.md §3 forbids rewriting it; they stay two commits. #29936 likewise gains 2041627, the boot-parity census entry its unit run asked for. - The scaffold measurement for #29936 ran this branch's CLI against an `os init my-app -t app` scaffold whose `@objectstack/spec`, `runtime` and `objectql` were linked to this worktree's packages, so both copies of the boot read this branch's builds. - Not changed: the ADR-0087 `no-range` warning itself, and `createStandaloneStack`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZgQTno2oPVD2GLd2n)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47226e6 commit 665db00

10 files changed

Lines changed: 345 additions & 37 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev` on an `os init -t app` scaffold no longer composes a second `MetadataPlugin` and warns "Plugin superseded: 'com.objectstack.metadata'"
6+
7+
Clause-②: no — boot diagnostics only; nothing accepted or published changes
8+
Metadata-protocol: none
9+
10+
The dev artifact door in `os serve` / `os dev` added its own `MetadataPlugin` unless the stack already held one, but it recognised that plugin only by its class name. The published `@objectstack/metadata` bundle names the class `_MetadataPlugin`, so on an installed CLI the check missed the `MetadataPlugin` the standalone stack had already composed, added a second one, and every dev boot of an untouched scaffold printed a "Plugin superseded" diagnostic blaming a composition the user never wrote. The dev door now asks the same question the host-config door asks: a plugin named `com.objectstack.metadata` (or, as before, a class named `MetadataPlugin`) means the stack has one. A host config with no `MetadataPlugin` of its own still gets the dev door, as before.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the empty kernel's own stub declares its protocol range, so `os start` in an empty directory boots with no compatibility warning
6+
7+
Clause-②: no — boot diagnostics only; nothing accepted or published changes
8+
Metadata-protocol: none
9+
10+
The empty-boot stub `createDefaultHostConfig({ requireArtifact: false })` writes to `~/.objectstack/dist/objectstack.json` (manifest id `com.objectstack.empty`) now declares `engines: { protocol: '^<major>' }`, the major of the runtime that writes it. Before, it declared no range, so the ADR-0087 handshake warned "package 'com.objectstack.empty' declares no engines.protocol range" on the first boot of the docs' one-line Community quickstart (`npm i -g @objectstack/cli && os start`), about a package the user never wrote. A stub already on disk from an older CLI is refreshed in place on the next `os start`, including when the start supervisor hands it down by path (it resolves `<home>/dist/objectstack.json` as an artifact once that file exists): only `manifest.engines.protocol` is rewritten, and only while the file at the home location is still the stub. The same refresh re-stamps a stub written under another protocol major, which would otherwise be refused as `OS_PROTOCOL_INCOMPATIBLE` once the runtime moves to the next major. An artifact that is not the stub, or a file that is not JSON, is left alone.

‎packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ describe('#22989 A — `os serve` of a HOST config composes an artifact-less Met
7171
const gate = source.slice(gateAt, source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({'));
7272
expect(gate).toContain('!devArtifactDoor');
7373
expect(gate).toContain('isHostConfig(config)');
74-
expect(gate).toContain(
75-
"!plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')",
76-
);
74+
expect(gate).toContain('!hasMetadataPlugin(plugins)');
75+
// #29936 — both doors ask the ONE predicate, and no class-name-only
76+
// spelling is left: the published bundle names the class
77+
// `_MetadataPlugin`, so such a check missed the standalone stack's own
78+
// plugin and composed a second one on every `os dev` of a scaffold.
79+
expect(count('!hasMetadataPlugin(plugins)')).toBe(2);
80+
expect(source).not.toContain("constructor?.name === 'MetadataPlugin'");
7781
// Decided after the dev door (it reads that value) and before the wrap.
7882
expect(source.indexOf('let devArtifactDoor: any;')).toBeLessThan(gateAt);
7983
expect(gateAt).toBeLessThan(

‎packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac
113113
expect(source, 'the door decision must be a single named value').toContain(
114114
'let devArtifactDoor: any;',
115115
);
116-
// The gate is the same one the composition has always used.
116+
// The gate asks the one `hasMetadataPlugin` predicate the host-config
117+
// door asks too (#29936: a class-name-only spelling missed the
118+
// published bundle's `_MetadataPlugin` and composed a second door).
117119
expect(source).toContain(
118-
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
120+
'if (isDev && flags.server && !hasMetadataPlugin(plugins)) {',
119121
);
120122
// Exactly one ARTIFACT door is constructed in this file, and it is that
121123
// value — a second artifact-door construction site is a second

‎packages/cli/src/commands/serve.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
resolveStackCollection,
126126
stackDeclaredCapabilities,
127127
stackBootPlugins,
128+
hasMetadataPlugin,
128129
} from '../utils/stack-collections.js';
129130
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
130131
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
@@ -3511,7 +3512,7 @@ export default class Serve extends Command {
35113512
// points except by the AppPlugin append below, and an AppPlugin is not a
35123513
// MetadataPlugin.
35133514
let devArtifactDoor: any;
3514-
if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {
3515+
if (isDev && flags.server && !hasMetadataPlugin(plugins)) {
35153516
try {
35163517
const { resolveDefaultArtifactPath } = await import('@objectstack/runtime');
35173518
// `os dev` is the only caller that reaches here, and it is a
@@ -3652,7 +3653,7 @@ export default class Serve extends Command {
36523653
if (
36533654
!devArtifactDoor
36543655
&& isHostConfig(config)
3655-
&& !plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')
3656+
&& !hasMetadataPlugin(plugins)
36563657
) {
36573658
const { MetadataPlugin } = await import('@objectstack/metadata');
36583659
hostConfigMetadataDoor = new MetadataPlugin({

‎packages/cli/src/utils/boot-preparation-parity.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ const SERVE_ONLY: Record<string, string> = {
155155
'decides serve\'s artifact-less MetadataPlugin for a host config that no artifact door serves ([#22989]); '
156156
+ 'the one-shot boot boots the standalone data stack, whose MetadataPlugin is always composed, and lays a '
157157
+ 'host config\'s plugins over it (buildSchemaMigrationPlugins)',
158+
hasMetadataPlugin:
159+
'decides whether serve adds its own MetadataPlugin, the dev artifact door or the artifact-less host-config '
160+
+ 'door, beside the one a composition already holds ([#29936]); the one-shot boot boots the standalone data '
161+
+ 'stack, whose MetadataPlugin is always composed, and adds none of its own',
158162
bundleDeclaresTranslations:
159163
'decides serve\'s tier-gated i18n service, which the declaration boot does not compose '
160164
+ '(schema-migration-plugins.ts, "What this does NOT compose")',

‎packages/cli/src/utils/stack-collections.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
artifactObjectNames,
1818
authoringRuleUnionStack,
1919
bundleDeclaresTranslations,
20+
hasMetadataPlugin,
2021
resolveStackCollection,
2122
shouldAutoRegisterObjectQL,
2223
shouldAutoRegisterStorageDriver,
@@ -268,3 +269,28 @@ describe('#22371 — stackBootPlugins, `os serve`\'s `plugins` and its `--dev` m
268269
expect(stackBootPlugins(undefined, true)).toEqual([]);
269270
});
270271
});
272+
273+
describe('#29936 — hasMetadataPlugin, the one gate both `os serve` metadata doors ask', () => {
274+
// The shape the published `@objectstack/metadata` bundle emits (18.3.0
275+
// dist/index.js: `var MetadataPlugin = class _MetadataPlugin {`): the class
276+
// name is `_MetadataPlugin`, only `name` still says what the plugin is.
277+
const BundledMetadataPlugin = class _MetadataPlugin {
278+
name = 'com.objectstack.metadata';
279+
};
280+
281+
it('finds the plugin by its name when the bundle renamed the class', () => {
282+
const bundled = new BundledMetadataPlugin();
283+
expect(bundled.constructor.name).toBe('_MetadataPlugin');
284+
expect(hasMetadataPlugin([{ name: 'com.objectstack.runtime.default-datasource' }, bundled])).toBe(true);
285+
});
286+
287+
it('finds the source-built class by its class name', () => {
288+
class MetadataPlugin {}
289+
expect(hasMetadataPlugin([new MetadataPlugin()])).toBe(true);
290+
});
291+
292+
it('answers false for a stack with no metadata plugin', () => {
293+
expect(hasMetadataPlugin([])).toBe(false);
294+
expect(hasMetadataPlugin([{ name: 'com.objectstack.engine.objectql' }, null, undefined, 'x'])).toBe(false);
295+
});
296+
});

‎packages/cli/src/utils/stack-collections.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,28 @@ function hasObjectQLPlugin(plugins: readonly unknown[]): boolean {
122122
});
123123
}
124124

125+
/**
126+
* Does `plugins[]` already hold `@objectstack/metadata`'s `MetadataPlugin`?
127+
* (`serve.ts`: the dev artifact door and the host-config door both ask.)
128+
*
129+
* The plugin's `name` (`com.objectstack.metadata`) is the identity; the class
130+
* name is only a fallback. ⚠️ A class-name-only check misses the real plugin:
131+
* the published `@objectstack/metadata` bundle emits
132+
* `var MetadataPlugin = class _MetadataPlugin { … }`, so on an installed CLI
133+
* `constructor.name` is `_MetadataPlugin`. Source-run tests never see that
134+
* rename. Measured on the published 18.3.0 CLI (#29936): the dev door's
135+
* class-name-only gate missed the standalone stack's own `MetadataPlugin`,
136+
* composed a second one, and every `os dev` boot of an `os init -t app`
137+
* scaffold warned "Plugin superseded: 'com.objectstack.metadata'".
138+
*/
139+
export function hasMetadataPlugin(plugins: readonly unknown[]): boolean {
140+
return plugins.some((p) => {
141+
const plugin = asBag(p);
142+
const ctor = (plugin?.constructor as { name?: string } | undefined)?.name;
143+
return plugin?.name === 'com.objectstack.metadata' || ctor === 'MetadataPlugin';
144+
});
145+
}
146+
125147
/**
126148
* Does `plugins[]` already provide a storage driver? (`serve.ts` step 2.)
127149
*
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #29937 — the empty-boot stub (`com.objectstack.empty`) is the platform's own
4+
// package, written by the runtime that boots it. With no `engines.protocol` the
5+
// ADR-0087 handshake answered `no-range`, and `os start` in an empty directory
6+
// (the docs' one-line Community quickstart) printed a compatibility WARN about
7+
// it as the newcomer's first boot diagnostic. The stub now declares this
8+
// runtime's major, and a stub an older CLI wrote is refreshed in place.
9+
10+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
12+
import { tmpdir } from 'node:os';
13+
import { join } from 'node:path';
14+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
15+
import { checkProtocolCompat } from '@objectstack/metadata-core';
16+
import { EMPTY_BOOT_STUB_ID, createDefaultHostConfig, ensureEmptyBootStub } from './default-host.js';
17+
18+
const RANGE = `^${PROTOCOL_MAJOR}`;
19+
20+
/** The stub exactly as CLIs up to 18.3.0 wrote it: no `engines`. */
21+
const LEGACY_STUB = {
22+
manifest: {
23+
id: EMPTY_BOOT_STUB_ID,
24+
name: 'empty',
25+
version: '0.0.0',
26+
type: 'app',
27+
description: 'Empty starter kernel — install apps via the Studio marketplace.',
28+
},
29+
objects: [],
30+
views: [],
31+
apps: [],
32+
flows: [],
33+
requires: [],
34+
};
35+
36+
let dir: string;
37+
let stubPath: string;
38+
39+
beforeEach(() => {
40+
dir = mkdtempSync(join(tmpdir(), 'os-29937-'));
41+
stubPath = join(dir, 'dist', 'objectstack.json');
42+
});
43+
44+
afterEach(() => {
45+
rmSync(dir, { recursive: true, force: true });
46+
});
47+
48+
function readStub(): any {
49+
return JSON.parse(readFileSync(stubPath, 'utf8'));
50+
}
51+
52+
function writeStub(value: unknown): void {
53+
mkdirSync(join(dir, 'dist'), { recursive: true });
54+
writeFileSync(stubPath, typeof value === 'string' ? value : JSON.stringify(value, null, 2), 'utf8');
55+
}
56+
57+
describe('ensureEmptyBootStub — the empty kernel declares its own protocol range (#29937)', () => {
58+
it('writes a fresh stub that passes the ADR-0087 handshake with no warning', () => {
59+
ensureEmptyBootStub(stubPath);
60+
61+
expect(existsSync(stubPath)).toBe(true);
62+
const stub = readStub();
63+
expect(stub.manifest.id).toBe(EMPTY_BOOT_STUB_ID);
64+
expect(stub.manifest.engines).toEqual({ protocol: RANGE });
65+
// `ok` is the only status `assertProtocolCompat` returns from silently.
66+
expect(checkProtocolCompat(stub.manifest).status).toBe('ok');
67+
});
68+
69+
it('refreshes a stub an older CLI wrote with no `engines`, keeping everything else', () => {
70+
writeStub(LEGACY_STUB);
71+
expect(checkProtocolCompat(LEGACY_STUB.manifest).status).toBe('no-range');
72+
73+
ensureEmptyBootStub(stubPath);
74+
75+
const stub = readStub();
76+
expect(stub).toEqual({
77+
...LEGACY_STUB,
78+
manifest: { ...LEGACY_STUB.manifest, engines: { protocol: RANGE } },
79+
});
80+
expect(checkProtocolCompat(stub.manifest).status).toBe('ok');
81+
});
82+
83+
it('re-stamps a stub a CLI of another major wrote, so a major bump never refuses the empty boot', () => {
84+
const otherMajor = `^${PROTOCOL_MAJOR - 1}`;
85+
writeStub({ ...LEGACY_STUB, manifest: { ...LEGACY_STUB.manifest, engines: { protocol: otherMajor } } });
86+
expect(checkProtocolCompat(readStub().manifest).status).toBe('incompatible');
87+
88+
ensureEmptyBootStub(stubPath);
89+
90+
expect(readStub().manifest.engines).toEqual({ protocol: RANGE });
91+
});
92+
93+
it('leaves a current stub byte for byte', () => {
94+
ensureEmptyBootStub(stubPath);
95+
const before = readFileSync(stubPath, 'utf8');
96+
97+
ensureEmptyBootStub(stubPath);
98+
99+
expect(readFileSync(stubPath, 'utf8')).toBe(before);
100+
});
101+
102+
it('never touches an artifact that is not the stub', () => {
103+
const foreign = JSON.stringify({ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app' } });
104+
writeStub(foreign);
105+
106+
ensureEmptyBootStub(stubPath);
107+
108+
expect(readFileSync(stubPath, 'utf8')).toBe(foreign);
109+
});
110+
111+
it('never touches a file it cannot read as JSON (the loader reports it)', () => {
112+
writeStub('{ not json');
113+
114+
ensureEmptyBootStub(stubPath);
115+
116+
expect(readFileSync(stubPath, 'utf8')).toBe('{ not json');
117+
});
118+
});
119+
120+
describe('createDefaultHostConfig — the stub is refreshed on every empty boot, named or not (#29937)', () => {
121+
const BOOT_TIMEOUT = 60_000;
122+
let savedHome: string | undefined;
123+
124+
beforeEach(() => {
125+
savedHome = process.env.OS_HOME;
126+
process.env.OS_HOME = dir;
127+
});
128+
129+
afterEach(() => {
130+
if (savedHome === undefined) delete process.env.OS_HOME;
131+
else process.env.OS_HOME = savedHome;
132+
});
133+
134+
// `os start` resolves `<home>/dist/objectstack.json` as an artifact rung
135+
// once it exists and hands it down as `artifactPath`, so every boot after
136+
// the first reaches the stub NAMED, never through the empty-boot branch.
137+
it('refreshes a legacy stub handed in as `artifactPath` at the home location', async () => {
138+
writeStub(LEGACY_STUB);
139+
140+
await createDefaultHostConfig({ requireArtifact: true, artifactPath: stubPath, databaseUrl: ':memory:' });
141+
142+
expect(readStub().manifest.engines).toEqual({ protocol: RANGE });
143+
}, BOOT_TIMEOUT);
144+
145+
it('writes the stub with its range on the empty boot itself', async () => {
146+
await createDefaultHostConfig({ requireArtifact: false, databaseUrl: ':memory:' });
147+
148+
expect(readStub().manifest.engines).toEqual({ protocol: RANGE });
149+
}, BOOT_TIMEOUT);
150+
151+
it('leaves a stub-shaped artifact named anywhere else alone', async () => {
152+
const elsewhere = join(dir, 'elsewhere.json');
153+
const bytes = JSON.stringify(LEGACY_STUB);
154+
writeFileSync(elsewhere, bytes, 'utf8');
155+
156+
await createDefaultHostConfig({ requireArtifact: true, artifactPath: elsewhere, databaseUrl: ':memory:' });
157+
158+
expect(readFileSync(elsewhere, 'utf8')).toBe(bytes);
159+
}, BOOT_TIMEOUT);
160+
});

0 commit comments

Comments
 (0)