Skip to content

Commit fbbbaee

Browse files
fix(cli): os package install prints the connector maps the package export withheld (#30044)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Fixes #29873 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** `os package install` 在安装成功后读取安装应答里的 `withheldMaps`,逐条打印被导出扣下的连接器映射(`connector 'NAME' PATH`),附一句补救:到连接器的编写处重新填入该映射。托管(热加载与未热加载)和非托管两种模式、目录 id 与制品文件两种来源都打印。 - **为什么改:** 导出包时连接器的 header / env 映射被整张扣下,服务端 install-local 的应答已逐条点名,但 CLI 把这个键丢了。按文档用 `os package install DIR/NN-ID/objectstack.json` 安装的运维看不到任何提示,直到连接器运行时不带 header 才失败。 - **风险与回滚:** 只改 CLI 的输出文字:应答不带该列表(或列表为空)时一行都不多印,退出码不变,服务端与导出不动。命令没有 `--json` 输出。`@objectstack/cli` patch。回滚 = revert 本 PR。 - **先看:** `packages/cli/src/commands/package/install.ts` 顶部的 `printWithheldMaps`,以及两处调用(非托管分支 `Sample data` 之后、托管分支 `Hot-load error` 之后);测试 `packages/cli/test/package-install-withheld-maps.test.ts`。 - **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run --project unit test/package-install-withheld-maps.test.ts`,预期 `12 passed`。 - **需要你做的:** 无,席位落地。 ## What changes - **`packages/cli/src/commands/package/install.ts`.** A module-local `printWithheldMaps(answer.withheldMaps)` prints, when the install-local answer carries a non-empty list: ```text ⚠ 2 connector map(s) arrived empty — the package export withheld each one whole, so the connector sends none of those headers or environment variables: connector 'w5export_erp' providerConfig.headers connector 'w5export_mcp' providerConfig.transport.env Re-enter each map where the connector is authored. ``` - **Every install mode the command has.** It is called in the unmanaged success branch (after `Sample data`) and in the managed success branch (after `Hot-load error`), which covers a hot-loaded and a not-hot-loaded managed install. Both sources (catalog id, artifact file) reach those same branches. - **From the answer only.** An absent or empty list prints nothing. Nothing is read from the artifact the command sent, so a runtime that predates the key gets the output it always got. This matches the `strippedLocks` reader in the same file. - **No `--json`.** The command's `flags` block declares no `json` flag and the class sets no `enableJsonFlag`, so there is no machine output to carry the list. - **Exit code unchanged.** The connector is installed and runs; the warning changes nothing about success. - **Not changed.** The server answer (`marketplace-install-local-plugin.ts`, both modes already answer `withheldMaps`) and the export (`assemblePackageManifest` already writes it), per the ruling. Neither frozen file (`protocol.ts`, `engine.ts`) is touched. - **Declarations.** `Clause-②: no`: `printWithheldMaps` is not exported, no flag or exported signature changes, and no input the command accepted is refused. `Metadata-protocol: none`: CLI text only. Changeset `29873-install-prints-withheld-maps.md`: `@objectstack/cli` patch. ## Tests New `packages/cli/test/package-install-withheld-maps.test.ts` (unit tier, derived by `vitest-tiers.ts`), 12 cases, the stub-`fetch` harness of the sibling `package-install-*.test.ts` files. For each of managed hot-loaded, managed not hot-loaded, and unmanaged (`--install-mode unmanaged`): - a catalog install whose answer carries two maps prints the warning, one line per map with connector name and path, and the remedy, and exits normally; - an artifact-file install (an `objectstack.json` shaped like an `os package export` directory, with `withheldMaps`) prints the same for its one map; - an answer whose `withheldMaps` is absent, or empty, prints none of those lines. <details><summary>Ablations (both red as expected, both restored to HEAD)</summary> Run with `node scripts/ablation-replace.mjs` on `packages/cli/src/commands/package/install.ts`; the test imports `../src/...` directly, so no dist leg is involved. 1. **Old behaviour back** (anchor ` if (maps.length === 0) return;` replaced by ` return;`, so nothing is printed): `Tests 6 failed | 6 passed (12)`. The 6 red are the positive pins (`names each withheld map …` and `names the withheld map for an exported artifact file …`, in all three modes). Restored: `blob == HEAD (0e2e99b) and git diff HEAD is empty`. 2. **Negative pin** (the empty-list guard deleted, so an absent or empty list prints `0 connector map(s) arrived empty`): `Tests 6 failed | 6 passed (12)`, all six `prints no withheld-map line when the answer's list is absent|empty`, `AssertionError: … not to contain 'arrived empty'`. Restored the same way. </details> ## Acceptance notes - The card's step 3 (server answer and `WARN [MarketplaceInstallLocal]` line) is unchanged and was not re-measured on a boot here; the pins stub the answer in the shape `marketplace-install-local-plugin.ts` returns (`...(withheldMaps.length > 0 ? { withheldMaps } : {})` in both modes). - The warning goes to stdout with the rest of the command's output, through the existing `printWarning`. ## Verification All runs at head `d3e4350de` (the last commit; the PR adds no later commit). - **Build:** `pnpm --filter '@objectstack/cli^...' build` under `os-verify-lock`: `VERDICT command-exit 0` (5m54s). Then `pnpm --filter @objectstack/cli build && pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `VERDICT command-exit 0`, `Test Files 379 passed (379)`, `Tests 5323 passed (5323)`. The integration tier is declared to CI: the diff touches no integration-tier file or spawn entry. - **Lint:** `pnpm lint`: exit 0. `pnpm exec eslint --no-inline-config` on the two touched `.ts` files: exit 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 60 families for the 3 changed paths; all 60 run, `--ran` reconciles `58 run, 2 NOT-MEASURED, 0 UNRUN`, exit 0. The two NOT MEASURED families are `check:dual-build-cjs-loads` and `check:i18n-coverage`, both exit 3 (PREREQUISITE NOT MET: they read every package's `dist/`, and this checkout built only the cli closure). CI runs both on a full build. `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone; after `git fetch origin 621a487` it passed (48 cases). <details><summary>Derived gate exit codes (60)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 3 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgboTwuVmeMX96wNxm <!-- signed: domain:server#6 · os-support-ai · session_01WGufzgboTwuVmeMX96wNxm --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b0c2209 commit fbbbaee

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os package install` prints the connector maps the package export withheld (#29873)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An exported package names each connector header or environment map the export withheld whole in its `withheldMaps`, and both install modes of the runtime's install-local endpoint name each map in their answer. The command dropped that list, so an operator who installed an export the documented way (`os package install DIR/NN-ID/objectstack.json`) was never told that the connector sends none of those headers or environment variables.
11+
12+
The command now prints one warning, one line per map (`connector 'NAME' PATH`, for example `connector 'erp' providerConfig.headers`), and the remedy: re-enter each map where the connector is authored. It does this for a managed install (hot-loaded or not) and for an unmanaged one, from a catalog id or an artifact file. An answer without the list prints nothing new, and the exit code is unchanged. The command has no `--json` output.

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,33 @@ import { Args, Command, Flags } from '@oclif/core';
4646
import { INSTALL_MODES, type InstallMode } from '@objectstack/spec/kernel';
4747
import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js';
4848

49+
/**
50+
* [#28640 ruling ③A] The connector maps the package export withheld whole,
51+
* as the install-local answer names them (`withheldMaps`, either install
52+
* mode): one warning, one line per map — `connector 'NAME' PATH`, the
53+
* answer's own position spelling — and the remedy. The connector is installed
54+
* and runs, but sends none of those headers or environment variables until
55+
* each map is supplied again, so an operator who installs an export from the
56+
* command line is told here; the runtime's `warn` line lands in a log the
57+
* operator may never read.
58+
*
59+
* Printed from the answer only: an answer without the list (nothing was
60+
* withheld, or a runtime that predates the key) prints nothing, and nothing
61+
* is guessed from the artifact the command sent.
62+
*/
63+
function printWithheldMaps(withheldMaps: unknown): void {
64+
const maps: Array<{ type?: unknown; name?: unknown; path?: unknown }> =
65+
Array.isArray(withheldMaps) ? withheldMaps : [];
66+
if (maps.length === 0) return;
67+
console.log('');
68+
printWarning(
69+
`${maps.length} connector map(s) arrived empty — the package export withheld each one whole, so the connector `
70+
+ 'sends none of those headers or environment variables:',
71+
);
72+
for (const map of maps) console.log(` ${String(map.type)} '${String(map.name)}' ${String(map.path)}`);
73+
console.log(' Re-enter each map where the connector is authored.');
74+
}
75+
4976
export default class PackageInstall extends Command {
5077
static override description =
5178
'Install a package into a running ObjectStack runtime (catalog id or local artifact file)';
@@ -313,6 +340,7 @@ export default class PackageInstall extends Command {
313340
if (data.seeded?.mode === 'skipped' && typeof data.seeded.reason === 'string') {
314341
printKV(' Sample data', `not loaded — ${data.seeded.reason}`);
315342
}
343+
printWithheldMaps(data.withheldMaps);
316344
console.log('');
317345
return;
318346
}
@@ -356,6 +384,7 @@ export default class PackageInstall extends Command {
356384
printKV(' Runtime', runtime);
357385
if (data.installedAt) printKV(' Installed', String(data.installedAt));
358386
if (notHotLoaded && hotLoadError) printKV(' Hot-load error', hotLoadError);
387+
printWithheldMaps(data.withheldMaps);
359388
console.log('');
360389
// #6721: the cache directory is quoted from the RESPONSE, never from a
361390
// literal or a locally-resolved constant. The directory lives on the
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28640 ruling ③A] `os package install` prints the connector maps the
5+
* package export withheld, as the install-local answer names them.
6+
*
7+
* `os package export` writes each connector header / environment map it
8+
* withheld whole into the export's `withheldMaps`, and both install modes of
9+
* `POST /api/v1/marketplace/install-local` read that list, install the
10+
* package, and name each map in their answer's `withheldMaps`. Before this
11+
* change the command dropped the key, so an operator installing an export the
12+
* documented way (`os package install DIR/NN-ID/objectstack.json`) never
13+
* learned that the connector sends none of those headers.
14+
*
15+
* Pinned, per install mode (managed hot-loaded, managed not hot-loaded,
16+
* unmanaged) and per source (catalog id, artifact file):
17+
* - an answer carrying `withheldMaps` prints one warning, one line per map
18+
* (`connector 'NAME' PATH`), and the remedy;
19+
* - an answer without the list prints none of those lines;
20+
* - the install still succeeds — the warning changes no exit code.
21+
*/
22+
23+
import { describe, it, expect, afterEach, vi } from 'vitest';
24+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
25+
import { tmpdir } from 'node:os';
26+
import { join } from 'node:path';
27+
import PackageInstall from '../src/commands/package/install.js';
28+
29+
/** Stub the runtime's install POST with the given `data` block. */
30+
function stubRuntime(data: Record<string, unknown>): void {
31+
vi.stubGlobal('fetch', vi.fn(async () => ({
32+
ok: true,
33+
status: 200,
34+
statusText: 'OK',
35+
headers: { get: () => null },
36+
json: async () => ({ success: true, data }),
37+
}) as any));
38+
}
39+
40+
/** Run the command; return everything it printed and how it exited. */
41+
async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> {
42+
const lines: string[] = [];
43+
const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); };
44+
vi.spyOn(console, 'log').mockImplementation(capture);
45+
vi.spyOn(console, 'error').mockImplementation(capture);
46+
let exit: number | undefined;
47+
try {
48+
await PackageInstall.run([...args, '--runtime', 'http://runtime.test']);
49+
} catch (err: any) {
50+
exit = err?.oclif?.exit ?? err?.code ?? 1;
51+
}
52+
return { out: lines.join('\n'), exit };
53+
}
54+
55+
/** The list exactly as install-local answers it: positions only, never a header name. */
56+
const WITHHELD = [
57+
{ type: 'connector', name: 'w5export_erp', path: 'providerConfig.headers' },
58+
{ type: 'connector', name: 'w5export_mcp', path: 'providerConfig.transport.env' },
59+
];
60+
61+
const MANAGED = {
62+
manifestId: 'com.qa.w5export', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-04T00:00:00.000Z',
63+
hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages',
64+
};
65+
const MANAGED_NOT_HOT = { ...MANAGED, hotLoaded: false, hotLoadError: 'register threw' };
66+
const UNMANAGED = {
67+
manifestId: 'com.qa.w5export', version: '1.0.0', versionId: 'ver_1', installMode: 'unmanaged',
68+
installedAt: '2026-11-04T00:00:00.000Z', targetPackageId: 'com.qa.w5export-copy', copiedCount: 3, copied: [],
69+
};
70+
71+
const WARNING = /2 connector map\(s\) arrived empty — the package export withheld each one whole/;
72+
const REMEDY = 'Re-enter each map where the connector is authored.';
73+
74+
/** An exported package directory's `objectstack.json`, as `os package export` writes it. */
75+
function writeExport(): { file: string; dir: string } {
76+
const dir = mkdtempSync(join(tmpdir(), 'os-install-withheld-'));
77+
const file = join(dir, 'objectstack.json');
78+
writeFileSync(file, JSON.stringify({
79+
id: 'com.qa.w5export', name: 'w5export', version: '1.0.0',
80+
connectors: [{ name: 'w5export_erp', providerConfig: {} }],
81+
withheldMaps: WITHHELD.slice(0, 1),
82+
}));
83+
return { file, dir };
84+
}
85+
86+
const CASES: Array<[label: string, data: Record<string, unknown>, flags: string[]]> = [
87+
['managed, hot-loaded', MANAGED, []],
88+
['managed, not hot-loaded', MANAGED_NOT_HOT, []],
89+
['unmanaged', UNMANAGED, ['--install-mode', 'unmanaged']],
90+
];
91+
92+
describe('os package install — prints the connector maps the export withheld', () => {
93+
const dirs: string[] = [];
94+
afterEach(() => {
95+
vi.unstubAllGlobals();
96+
vi.restoreAllMocks();
97+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
98+
});
99+
100+
describe.each(CASES)('%s', (_label, data, flags) => {
101+
it('names each withheld map with its connector and path, then the remedy (catalog source)', async () => {
102+
stubRuntime({ ...data, withheldMaps: WITHHELD });
103+
104+
const { out, exit } = await runInstall(['com.qa.w5export', ...flags]);
105+
106+
expect(exit).toBeUndefined();
107+
expect(out).toMatch(WARNING);
108+
const lines = out.split('\n').map((line) => line.trim());
109+
expect(lines).toContain("connector 'w5export_erp' providerConfig.headers");
110+
expect(lines).toContain("connector 'w5export_mcp' providerConfig.transport.env");
111+
expect(lines).toContain(REMEDY);
112+
});
113+
114+
it('names the withheld map for an exported artifact file (the documented export path)', async () => {
115+
const { file, dir } = writeExport();
116+
dirs.push(dir);
117+
stubRuntime({ ...data, withheldMaps: WITHHELD.slice(0, 1) });
118+
119+
const { out, exit } = await runInstall([file, ...flags]);
120+
121+
expect(exit).toBeUndefined();
122+
expect(out).toMatch(/1 connector map\(s\) arrived empty/);
123+
expect(out.split('\n').map((line) => line.trim())).toContain("connector 'w5export_erp' providerConfig.headers");
124+
expect(out).toContain(REMEDY);
125+
});
126+
127+
it.each([
128+
['absent', undefined],
129+
['empty', []],
130+
])('prints no withheld-map line when the answer\'s list is %s', async (_which, list) => {
131+
stubRuntime({ ...data, ...(list === undefined ? {} : { withheldMaps: list }) });
132+
133+
const { out, exit } = await runInstall(['com.qa.w5export', ...flags]);
134+
135+
expect(exit).toBeUndefined();
136+
expect(out).not.toContain('arrived empty');
137+
expect(out).not.toContain('providerConfig.');
138+
expect(out).not.toContain(REMEDY);
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)