Skip to content

Commit 6e0e7d7

Browse files
fix(cli): os package publish names the objectstack.manifest.json keys it does not send (#27368)
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27325 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`os package publish` 读 `objectstack.manifest.json` 时,若文件声明了它不发送的键(`tags`、`publisher`、`isStarter`、`preview`),打印一条警告,说明这些键尚未被托管目录承接,并附云端跟进卡链接;文件里写了 `visibility` 时另打一条,提示改用 `--visibility`。发包照常成功(exit 0)。 - **为什么改**:清单文件的 schema 接受这些键,作者(含 AI)照写,但发包从不发送,目录里什么都看不到,也没有任何提示。按 triage 裁定(路线 B),先结束“静默丢弃”;真正发送这些键要等云端确认接口(跟进卡 #27348)。 - **风险与回滚**:只多打印警告,请求体与退出码都不变;空白模板自带 `isStarter: true`,因此用警告而非拒绝。回滚即还原本 PR。 - **先看**:`packages/cli/src/commands/package/publish.ts` 新增的 `UNCARRIED_MANIFEST_KEYS` 与 `uncarriedManifestWarnings`,以及 `run()` 里读完清单后那一行;`packages/cli/test/package-publish-uncarried-manifest-keys.test.ts` 的键分区测试。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit test/package-publish-uncarried-manifest-keys.test.ts`,预期 5 条全过。 - **需要你做的**:无,席位落地。 ## What changes **The silent path ends.** `objectstack.manifest.json` is validated by `TemplateManifestSchema`, which accepts `tags`, `publisher`, `isStarter` and `visibility` (inherited from `CreatePackageRequestSchema`) and the scaffold-side `preview`. `os package publish` sends none of them, so an author's listing tags or starter flag never reached the catalog and nothing said why. Right after publish reads the manifest (the one place it does), it now prints: - one warning naming each declared catalog key (`tags`, `publisher`, `isStarter`, `preview`): declared, but the hosted catalog does not carry it yet, with a link to #27348, the cloud card that owns the send leg; - a separate warning for a declared `visibility`, pointing at `--visibility`, which does carry it today. The publish still succeeds (exit 0): the bundled blank template ships `isStarter: true`, so a refusal would break every freshly scaffolded app. **Why warn instead of send.** The only receiver of `POST /api/v1/cloud/packages` is the hosted control plane; no handler lives in this repo. `CreatePackageRequestSchema` declares the keys, but it is not provably the route's validator (the wire is snake_case, and publish already sends a `readme` and a top-level `tagline` the schema lacks). The read-side row stores `tags` as a string, not an array. `publisher` is a trust tier that gates listing privileges, so sending an author's self-declared value is a privilege claim only the control plane can judge. Triage ruled route (B) here and filed the send leg, the `publisher` gate and the `tagline` leg as #27348 (comment 6485703694 on the card). A comment on `UNCARRIED_MANIFEST_KEYS` says not to start sending them before that card answers. **The class is guarded.** A test partitions every `TemplateManifestSchema` key into sent, warned about, or scaffold-only. A key the schema gains later lands in no bucket and turns the test red until publish either sends it or names it. **Not touched.** Nothing under `packages/spec`. The top-level `tagline` publish sends is unchanged; its leg moved to #27348 per the ruling. ## Tests New `packages/cli/test/package-publish-uncarried-manifest-keys.test.ts` (5): - a manifest declaring `tags`, `publisher`, `isStarter` and `preview` publishes (both POSTs made); exactly one warning names all four and links the tracking issue; none of the keys (nor `is_starter`) is on the request; - a declared `visibility` gets its own warning naming `--visibility`, without the tracking link, and is not on the request; - a manifest with none of them prints no such warning; - the schema-key partition (sent / warned / scaffold-only) is exact and disjoint; - a fixture carrying every warned key parses against `TemplateManifestSchema`, and the helper reports all of them. <details><summary>Ablation: the warning call removed, at HEAD fdecadf</summary> `node scripts/ablation-replace.mjs --file packages/cli/src/commands/package/publish.ts --anchor 'for (const warning of uncarriedManifestWarnings(m)) printWarning(warning);' --delete -- …vitest… test/package-publish-uncarried-manifest-keys.test.ts` ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2464b74 -> 1c7b57198a9f × names each declared catalog key it does not send, points at the tracking issue, and still publishes × names `visibility` separately, with the flag that does carry it Tests 2 failed | 3 passed (5) ablation-replace: ok restored: blob == HEAD (2464b74) and `git diff HEAD` is empty ``` The cli tests import `src/` directly, so no rebuild sits between the mutation and the run. </details> ## Acceptance notes - **Deviation, `visibility`.** The card names `tags`, `publisher` and `isStarter`; the ruling adds `preview`. `visibility` is the same defect in the same file (accepted by the manifest schema, silently not read), so it is in the warned set under the bounded in-place exemption, with the seat's agreement. Its warning points at the flag rather than at #27348. - **`preview`** is a documented scaffold-side key (`TemplateManifestSchema.preview`, screenshots and demo URL). Publish has no screenshot upload path, so it is warned about with the catalog keys, as ruled. ## Verification Local, at HEAD `fdecadf7`, rebased on `origin/main` `efdf11ae`: - `pnpm --filter @objectstack/cli exec vitest run --project unit` over the six publish test files: 72/72 passed. Full cli `unit` layer: 347 files, 5037/5037 passed (lock VERDICT command-exit 0). The `integration` layer is declared to CI (no spawn entry or integration file touched). - `pnpm --filter @objectstack/cli typecheck` and `eslint --no-inline-config` on the two touched files: exit 0. <details><summary>Derived gates: 62 derived, 61 run, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `fdecadf7` derived 62 families; all were run, and `--ran` reconciles 62 accounted, 61 run with exit 0, 1 NOT MEASURED: - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (it needs the whole workspace built; only the cli closure is built here). Left to CI. - `check-plugin-teardown-shape.mjs --self-test` and `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity` first exited 3 (shallow clone; cli not built). They were re-run after fetching the self-test's pinned commit and building `@objectstack/cli`, and all four exited 0. Families CI runs beyond this list are read from this PR's check-runs, not re-run locally. </details> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4c515a commit 6e0e7d7

3 files changed

Lines changed: 231 additions & 1 deletion

File tree

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 package publish` names the `objectstack.manifest.json` keys it does not send (`tags`, `publisher`, `isStarter`, `preview`, `visibility`) instead of dropping them silently
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The listing file's schema accepts these keys, but publish never carried them to the control plane, so an author's tags, starter flag or preview simply never reached the catalog and nothing said why. Publish now prints a warning naming each declared catalog key: it is declared but not yet carried by the hosted catalog, with a link to the issue tracking that (#27348). A declared `visibility` gets its own warning pointing at `--visibility`, which does carry it. The publish still succeeds: the bundled blank template ships `isStarter: true`. A test classifies every key of the listing-file schema as sent, warned about, or scaffold-only, so a key the schema gains later fails until publish handles it.

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { readFile } from 'node:fs/promises';
3232
import { resolve as resolvePath, basename, dirname, isAbsolute } from 'node:path';
3333
import { Args, Command, Flags } from '@oclif/core';
3434
import { PackageSchema } from '@objectstack/spec/marketplace';
35-
import { printHeader, printKV, printSuccess, printError, printStep, isExitSignal } from '../../utils/format.js';
35+
import { printHeader, printKV, printSuccess, printError, printStep, printWarning, isExitSignal } from '../../utils/format.js';
3636
import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js';
3737
import { resolveCloudActiveEnvironmentId } from '../../utils/active-environment.js';
3838
import { readErrorMessage } from '../../utils/response-envelope.js';
@@ -256,6 +256,65 @@ async function tryLoadTemplateManifest(
256256
return null;
257257
}
258258

259+
/**
260+
* Keys `objectstack.manifest.json` accepts that this command does NOT carry to
261+
* the control plane. `tags`, `publisher`, `isStarter` and `visibility` are
262+
* inherited from `CreatePackageRequestSchema`; `preview` is a scaffold-side
263+
* extra (publish has no screenshot upload path).
264+
*
265+
* They are not sent because nothing in this repo shows the hosted
266+
* `POST /cloud/packages` route writes them: the request schema declares them,
267+
* but the read-side row stores `tags` as a string, and `publisher` is a trust
268+
* tier that gates listing privileges, so a value self-declared in an author's
269+
* file is a privilege claim only the control plane can judge. ⛔ Do not start
270+
* sending them before {@link CATALOG_SEND_ISSUE} answers what the hosted side
271+
* accepts. `visibility` is flag-only on purpose (see the `--visibility` flag).
272+
*
273+
* Dropping an authored key silently is the defect, so each one the file
274+
* declares is named in a warning. The warning does not fail the publish: the
275+
* bundled blank template ships `isStarter: true`, and refusing it would break
276+
* every project scaffolded from it.
277+
*
278+
* Every `TemplateManifestSchema` key is classified in
279+
* `test/package-publish-uncarried-manifest-keys.test.ts`, so a key the schema
280+
* gains later goes red there until publish either sends it or lists it here.
281+
*/
282+
export const UNCARRIED_MANIFEST_KEYS = ['tags', 'publisher', 'isStarter', 'preview', 'visibility'] as const;
283+
284+
/** Where carrying the catalog keys to the hosted control plane is tracked. */
285+
export const CATALOG_SEND_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/27348';
286+
287+
/** The {@link UNCARRIED_MANIFEST_KEYS} that `manifest` declares, in list order. */
288+
export function uncarriedManifestKeys(manifest: Record<string, unknown>): string[] {
289+
return UNCARRIED_MANIFEST_KEYS.filter((key) => manifest[key] !== undefined);
290+
}
291+
292+
/**
293+
* The warnings for the {@link uncarriedManifestKeys} a manifest declares: one
294+
* for the catalog keys (declared, not yet carried by the hosted catalog) and
295+
* one for `visibility` (carried by the flag instead). Empty when none.
296+
*/
297+
export function uncarriedManifestWarnings(manifest: Record<string, unknown>): string[] {
298+
const declared = uncarriedManifestKeys(manifest);
299+
const catalog = declared.filter((key) => key !== 'visibility');
300+
const warnings: string[] = [];
301+
if (catalog.length > 0) {
302+
warnings.push(
303+
`objectstack.manifest.json declares ${catalog.map((key) => `\`${key}\``).join(', ')}, which the `
304+
+ 'hosted catalog does not carry yet: `os package publish` does not send '
305+
+ (catalog.length === 1 ? 'it' : 'them')
306+
+ `, so the published package will not show ${catalog.length === 1 ? 'it' : 'them'}. See ${CATALOG_SEND_ISSUE}.`,
307+
);
308+
}
309+
if (declared.includes('visibility')) {
310+
warnings.push(
311+
'objectstack.manifest.json declares `visibility`, which `os package publish` does not read from '
312+
+ 'the file. Pass --visibility to set it.',
313+
);
314+
}
315+
return warnings;
316+
}
317+
259318
/**
260319
* Read a string that is EITHER inlined markdown OR a path relative to
261320
* the template manifest directory. Treats short, single-line `.md` /
@@ -450,6 +509,7 @@ export default class PackagePublish extends Command {
450509
printStep(`Loaded objectstack.manifest.json`);
451510
}
452511
const m = tplManifest?.data ?? {};
512+
for (const warning of uncarriedManifestWarnings(m)) printWarning(warning);
453513
const baseDir = tplManifest?.baseDir ?? process.cwd();
454514

455515
// One gate for both paths — the explicitly supplied id and the derived one
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+
/**
4+
* `os package publish` names every `objectstack.manifest.json` key it does not
5+
* send, instead of dropping it silently.
6+
*
7+
* `TemplateManifestSchema` accepts `tags`, `publisher`, `isStarter`, `preview`
8+
* and `visibility`, so an author (or an AI agent) writes them and the file
9+
* validates — but publish never carried them, and the catalog simply showed
10+
* none of it. Carrying them waits on what the hosted side accepts
11+
* (`CATALOG_SEND_ISSUE`). These cases pin the warning, pin that it does not
12+
* fail the publish (the blank template ships `isStarter: true`), and classify
13+
* every schema key so a key added later cannot be dropped silently again.
14+
*/
15+
16+
import { describe, it, expect, afterEach, vi } from 'vitest';
17+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
18+
import { tmpdir } from 'node:os';
19+
import { join } from 'node:path';
20+
import { TemplateManifestSchema } from '@objectstack/spec/marketplace';
21+
import PackagePublish, {
22+
CATALOG_SEND_ISSUE,
23+
UNCARRIED_MANIFEST_KEYS,
24+
uncarriedManifestKeys,
25+
} from '../src/commands/package/publish.js';
26+
27+
type Call = { url: string; body: any };
28+
29+
/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */
30+
function stubCloud(): Call[] {
31+
const calls: Call[] = [];
32+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
33+
calls.push({ url, body: JSON.parse(init.body) });
34+
const data = url.endsWith('/versions')
35+
? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' }
36+
: { id: 'pkg_1', created: true, visibility: 'org' };
37+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any;
38+
}));
39+
return calls;
40+
}
41+
42+
/**
43+
* Every `TemplateManifestSchema` key, by what publish does with it. A key the
44+
* schema gains lands in none of these and fails the partition test below.
45+
*/
46+
const SENT = [
47+
'manifestId', 'displayName', 'description', 'category', 'iconUrl',
48+
'homepageUrl', 'license', 'translations', 'readmePath',
49+
] as const;
50+
/** Scaffold-time extras: read by `create-objectstack` or docs, never a publish key. */
51+
const SCAFFOLD_ONLY = ['name', 'specVersion', 'namespace', 'skills', 'scaffold'] as const;
52+
53+
describe('os package publish — manifest keys it does not send', () => {
54+
let dir = '';
55+
const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY };
56+
const prevCwd = process.cwd();
57+
58+
afterEach(async () => {
59+
process.chdir(prevCwd);
60+
vi.unstubAllGlobals();
61+
vi.restoreAllMocks();
62+
process.env.OS_CLOUD_URL = prevEnv.url;
63+
process.env.OS_CLOUD_API_KEY = prevEnv.key;
64+
if (dir) await rm(dir, { recursive: true, force: true });
65+
});
66+
67+
async function projectWith(manifest: Record<string, unknown>): Promise<string> {
68+
dir = await mkdtemp(join(tmpdir(), 'package-publish-uncarried-'));
69+
const path = join(dir, 'objectstack.json');
70+
await writeFile(path, JSON.stringify({
71+
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0' },
72+
objects: [],
73+
}));
74+
await writeFile(join(dir, 'objectstack.manifest.json'), JSON.stringify(manifest));
75+
process.chdir(dir);
76+
process.env.OS_CLOUD_URL = 'http://cloud.test';
77+
process.env.OS_CLOUD_API_KEY = 'tok_123';
78+
return path;
79+
}
80+
81+
function captureLog(): string[] {
82+
const lines: string[] = [];
83+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
84+
lines.push(args.map(String).join(' '));
85+
});
86+
return lines;
87+
}
88+
89+
it('names each declared catalog key it does not send, points at the tracking issue, and still publishes', async () => {
90+
const path = await projectWith({
91+
name: 'acme-crm',
92+
specVersion: '^18.0.0',
93+
displayName: 'Acme CRM',
94+
tags: ['crm', 'sales'],
95+
publisher: 'community',
96+
isStarter: true,
97+
preview: { screenshots: ['shot.png'] },
98+
});
99+
const calls = stubCloud();
100+
const log = captureLog();
101+
102+
await PackagePublish.run([path]);
103+
104+
expect(calls).toHaveLength(2);
105+
const warnings = log.filter((line) => line.includes('objectstack.manifest.json declares'));
106+
expect(warnings).toHaveLength(1);
107+
for (const key of ['tags', 'publisher', 'isStarter', 'preview']) expect(warnings[0]).toContain(`\`${key}\``);
108+
expect(warnings[0]).toContain('hosted catalog does not carry yet');
109+
expect(warnings[0]).toContain(CATALOG_SEND_ISSUE);
110+
expect(warnings[0]).not.toContain('`visibility`');
111+
// Not sent under either spelling — the warning describes the wire.
112+
for (const key of ['tags', 'publisher', 'isStarter', 'is_starter', 'preview']) {
113+
expect(key in calls[0].body).toBe(false);
114+
}
115+
});
116+
117+
it('names `visibility` separately, with the flag that does carry it', async () => {
118+
const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', visibility: 'marketplace' });
119+
const calls = stubCloud();
120+
const log = captureLog();
121+
122+
await PackagePublish.run([path]);
123+
124+
expect('visibility' in calls[0].body).toBe(false);
125+
const warnings = log.filter((line) => line.includes('objectstack.manifest.json declares'));
126+
expect(warnings).toHaveLength(1);
127+
expect(warnings[0]).toContain('`visibility`');
128+
expect(warnings[0]).toContain('--visibility');
129+
// `visibility` is not waiting on the hosted catalog: the flag carries it today.
130+
expect(warnings[0]).not.toContain(CATALOG_SEND_ISSUE);
131+
});
132+
133+
it('stays quiet when the manifest declares none of them', async () => {
134+
const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM' });
135+
stubCloud();
136+
const log = captureLog();
137+
138+
await PackagePublish.run([path]);
139+
140+
expect(log.some((line) => line.includes('objectstack.manifest.json declares'))).toBe(false);
141+
});
142+
});
143+
144+
describe('every TemplateManifestSchema key is sent, warned about, or scaffold-only', () => {
145+
it('partitions the schema keys exactly', () => {
146+
const schemaKeys = Object.keys(TemplateManifestSchema.shape).sort();
147+
const classified = [...SENT, ...UNCARRIED_MANIFEST_KEYS, ...SCAFFOLD_ONLY].sort();
148+
expect(classified).toEqual(schemaKeys);
149+
// No key sits in two buckets.
150+
expect(new Set(classified).size).toBe(classified.length);
151+
});
152+
153+
it('warns only about keys the schema accepts', () => {
154+
// A warned key the schema rejects would never reach publish; the list
155+
// would then be naming a key no valid file can carry.
156+
const fixture = { name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', tags: ['crm'], publisher: 'community', isStarter: true, preview: { demoUrl: 'https://demo.example.com' }, visibility: 'org' };
157+
expect(TemplateManifestSchema.safeParse(fixture).success).toBe(true);
158+
expect(uncarriedManifestKeys(fixture)).toEqual([...UNCARRIED_MANIFEST_KEYS]);
159+
});
160+
});

0 commit comments

Comments
 (0)