From f2c48fc011c66eb51dd8b4db6da955ae8985a2a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:07:59 +0000 Subject: [PATCH 1/3] test(cli): pin that os package publish sends manifest tags, maps tagline, and words the platform-decided keys (red) Claude-Session: https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw Co-authored-by: Claude --- ...ge-publish-uncarried-manifest-keys.test.ts | 265 ++++++++++++++---- 1 file changed, 207 insertions(+), 58 deletions(-) diff --git a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts index b0d8d9018a4..853b9fbc281 100644 --- a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts +++ b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts @@ -1,16 +1,24 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * `os package publish` names every `objectstack.manifest.json` key it does not - * send, instead of dropping it silently. + * `os package publish` carries the `objectstack.manifest.json` listing keys the + * hosted catalog stores, and names every key it does not send, instead of + * dropping it silently. * * `TemplateManifestSchema` accepts `tags`, `publisher`, `isStarter`, `preview` * and `visibility`, so an author (or an AI agent) writes them and the file - * validates — but publish never carried them, and the catalog simply showed - * none of it. Carrying them waits on what the hosted side accepts - * (`CATALOG_SEND_ISSUE`). These cases pin the warning, pin that it does not - * fail the publish (the blank template ships `isStarter: true`), and classify - * every schema key so a key added later cannot be dropped silently again. + * validates. The hosted publish route validates and stores `tags`, so publish + * sends them as written: the server owns the bounds, and its 400 is what the + * author reads. `publisher` and `isStarter` are decided by the platform, so an + * author's value has no effect and is not sent. `preview` still waits on what + * the hosted side accepts (`CATALOG_SEND_ISSUE`). The top-level `tagline` has + * no column on the hosted side: publish uses it as the `description` when none + * is given, and otherwise does not send it. + * + * These cases drive the real command and read the request body it sends, pin + * that the warning does not fail the publish (the blank template ships + * `isStarter: true`), and classify every schema key so a key added later + * cannot be dropped silently again. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -26,11 +34,17 @@ import PackagePublish, { type Call = { url: string; body: any }; -/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */ -function stubCloud(): Call[] { +/** + * Stub `fetch` so both publish POSTs succeed, and record what was sent. With + * `refusePackage`, the package POST answers that status and body instead. + */ +function stubCloud(refusePackage?: { status: number; body: unknown }): Call[] { const calls: Call[] = []; vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => { calls.push({ url, body: JSON.parse(init.body) }); + if (refusePackage && !url.endsWith('/versions')) { + return { ok: false, status: refusePackage.status, statusText: '', json: async () => refusePackage.body } as any; + } const data = url.endsWith('/versions') ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' } : { id: 'pkg_1', created: true, visibility: 'org' }; @@ -44,13 +58,15 @@ function stubCloud(): Call[] { * schema gains lands in none of these and fails the partition test below. */ const SENT = [ - 'manifestId', 'displayName', 'description', 'category', 'iconUrl', + 'manifestId', 'displayName', 'description', 'category', 'tags', 'iconUrl', 'homepageUrl', 'license', 'translations', 'readmePath', ] as const; /** Scaffold-time extras: read by `create-objectstack` or docs, never a publish key. */ const SCAFFOLD_ONLY = ['name', 'specVersion', 'namespace', 'skills', 'scaffold'] as const; -describe('os package publish — manifest keys it does not send', () => { +const DECLARES = 'objectstack.manifest.json declares'; + +describe('os package publish — manifest keys', () => { let dir = ''; const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; const prevCwd = process.cwd(); @@ -62,6 +78,7 @@ describe('os package publish — manifest keys it does not send', () => { process.env.OS_CLOUD_URL = prevEnv.url; process.env.OS_CLOUD_API_KEY = prevEnv.key; if (dir) await rm(dir, { recursive: true, force: true }); + dir = ''; }); async function projectWith(manifest: Record): Promise { @@ -78,66 +95,198 @@ describe('os package publish — manifest keys it does not send', () => { return path; } - function captureLog(): string[] { + /** Everything the command printed, on any console channel. */ + function captureOutput(): string[] { const lines: string[] = []; - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - lines.push(args.map(String).join(' ')); - }); + for (const channel of ['log', 'error', 'warn'] as const) { + vi.spyOn(console, channel).mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + } return lines; } - it('names each declared catalog key it does not send, points at the tracking issue, and still publishes', async () => { - const path = await projectWith({ - name: 'acme-crm', - specVersion: '^18.0.0', - displayName: 'Acme CRM', - tags: ['crm', 'sales'], - publisher: 'community', - isStarter: true, - preview: { screenshots: ['shot.png'] }, + const warningsIn = (lines: string[]) => lines.filter((line) => line.includes(DECLARES)); + + describe('tags', () => { + it('sends the manifest’s tags as written, and does not warn about them', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags: ['crm', 'sales'] }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages'); + expect(calls[0].body.tags).toEqual(['crm', 'sales']); + expect(warningsIn(out).some((line) => line.includes('`tags`'))).toBe(false); + }); + + it('sends a declared empty list, which clears the stored tags', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags: [] }); + const calls = stubCloud(); + captureOutput(); + + await PackagePublish.run([path]); + + expect(calls[0].body.tags).toEqual([]); + }); + + it('sends no `tags` key when the manifest declares none, so the stored tags are left alone', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0' }); + const calls = stubCloud(); + captureOutput(); + + await PackagePublish.run([path]); + + expect('tags' in calls[0].body).toBe(false); + }); + + it('applies no bound of its own: the server judges the list, and its refusal reaches the author as written', async () => { + // More tags, and longer ones, than the hosted route accepts. The CLI + // has no copy of those numbers, so the list reaches the wire unchanged + // and the server's 400 is the author's answer. + const tags = Array.from({ length: 21 }, (_, i) => `tag-${i}-${'x'.repeat(64)}`); + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags }); + const refusal = 'tags: at most 20 tags of at most 64 characters each'; + const calls = stubCloud({ + status: 400, + body: { success: false, error: { code: 'VALUE_TOO_LONG', message: refusal, details: { field: 'tags' } } }, + }); + const out = captureOutput(); + + let exitCode: number | undefined; + try { + await PackagePublish.run([path]); + } catch (err: any) { + exitCode = err?.oclif?.exit ?? err?.exitCode; + } + + expect(exitCode).toBe(1); + expect(calls).toHaveLength(1); + expect(calls[0].body.tags).toEqual(tags); + expect(out.join('\n')).toContain(refusal); }); - const calls = stubCloud(); - const log = captureLog(); - - await PackagePublish.run([path]); - - expect(calls).toHaveLength(2); - const warnings = log.filter((line) => line.includes('objectstack.manifest.json declares')); - expect(warnings).toHaveLength(1); - for (const key of ['tags', 'publisher', 'isStarter', 'preview']) expect(warnings[0]).toContain(`\`${key}\``); - expect(warnings[0]).toContain('hosted catalog does not carry yet'); - expect(warnings[0]).toContain(CATALOG_SEND_ISSUE); - expect(warnings[0]).not.toContain('`visibility`'); - // Not sent under either spelling — the warning describes the wire. - for (const key of ['tags', 'publisher', 'isStarter', 'is_starter', 'preview']) { - expect(key in calls[0].body).toBe(false); - } }); - it('names `visibility` separately, with the flag that does carry it', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', visibility: 'marketplace' }); - const calls = stubCloud(); - const log = captureLog(); + describe('top-level `tagline`', () => { + it('is sent as the `description` when neither --description nor the manifest gives one', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tagline: ' CRM for small teams ' }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); - await PackagePublish.run([path]); + expect(calls[0].body.description).toBe('CRM for small teams'); + expect('tagline' in calls[0].body).toBe(false); + expect(warningsIn(out).some((line) => line.includes('`tagline`'))).toBe(false); + }); - expect('visibility' in calls[0].body).toBe(false); - const warnings = log.filter((line) => line.includes('objectstack.manifest.json declares')); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain('`visibility`'); - expect(warnings[0]).toContain('--visibility'); - // `visibility` is not waiting on the hosted catalog: the flag carries it today. - expect(warnings[0]).not.toContain(CATALOG_SEND_ISSUE); + it('is not sent when the manifest gives a `description`, and a warning names it', async () => { + const path = await projectWith({ + name: 'acme-crm', specVersion: '^18.0.0', description: 'Customer records', tagline: 'CRM for small teams', + }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); + + expect(calls[0].body.description).toBe('Customer records'); + expect('tagline' in calls[0].body).toBe(false); + expect(warningsIn(out).some((line) => line.includes('`tagline`'))).toBe(true); + }); + + it('is not sent when --description is passed, and a warning names it', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tagline: 'CRM for small teams' }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path, '--description', 'From the flag']); + + expect(calls[0].body.description).toBe('From the flag'); + expect('tagline' in calls[0].body).toBe(false); + expect(warningsIn(out).some((line) => line.includes('`tagline`'))).toBe(true); + }); + + it('leaves the per-locale `translations.*.tagline` as it was', async () => { + const path = await projectWith({ + name: 'acme-crm', specVersion: '^18.0.0', translations: { 'zh-CN': { tagline: '小团队的 CRM' } }, + }); + const calls = stubCloud(); + captureOutput(); + + await PackagePublish.run([path]); + + expect(calls[0].body.translations).toEqual({ 'zh-CN': { tagline: '小团队的 CRM' } }); + }); }); - it('stays quiet when the manifest declares none of them', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM' }); - stubCloud(); - const log = captureLog(); + describe('keys it does not send', () => { + it('names `publisher` and `isStarter` as decided by the platform, and still publishes', async () => { + const path = await projectWith({ + name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', publisher: 'community', isStarter: true, + }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); - await PackagePublish.run([path]); + expect(calls).toHaveLength(2); + const warnings = warningsIn(out); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('`publisher`'); + expect(warnings[0]).toContain('`isStarter`'); + expect(warnings[0]).toContain('decided by the platform'); + // Not "not carried yet": nothing is pending on the hosted side for these two. + expect(warnings[0]).not.toContain('does not carry'); + expect(warnings[0]).not.toContain(CATALOG_SEND_ISSUE); + // ⛔ Never sent under either spelling: the platform decides both. + for (const key of ['publisher', 'isStarter', 'is_starter']) expect(key in calls[0].body).toBe(false); + }); + + it('names `preview` as not carried by the hosted catalog yet, with the tracking issue', async () => { + const path = await projectWith({ + name: 'acme-crm', specVersion: '^18.0.0', preview: { screenshots: ['shot.png'] }, + }); + const calls = stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); + + expect(calls).toHaveLength(2); + const warnings = warningsIn(out); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('`preview`'); + expect(warnings[0]).toContain('hosted catalog does not carry yet'); + expect(warnings[0]).toContain(CATALOG_SEND_ISSUE); + expect('preview' in calls[0].body).toBe(false); + }); + + it('names `visibility` separately, with the flag that does carry it', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', visibility: 'marketplace' }); + const calls = stubCloud(); + const out = captureOutput(); - expect(log.some((line) => line.includes('objectstack.manifest.json declares'))).toBe(false); + await PackagePublish.run([path]); + + expect('visibility' in calls[0].body).toBe(false); + const warnings = warningsIn(out); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('`visibility`'); + expect(warnings[0]).toContain('--visibility'); + // `visibility` is not waiting on the hosted catalog: the flag carries it today. + expect(warnings[0]).not.toContain(CATALOG_SEND_ISSUE); + }); + + it('stays quiet when the manifest declares only keys it sends', async () => { + const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', tags: ['crm'] }); + stubCloud(); + const out = captureOutput(); + + await PackagePublish.run([path]); + + expect(warningsIn(out)).toEqual([]); + }); }); }); From fadb51292fb6d74a1b1edde4658244892775bed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:22:31 +0000 Subject: [PATCH 2/3] fix(cli): os package publish sends the manifest's listing tags, words platform-decided keys, maps tagline to a missing description Claude-Session: https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw Co-authored-by: Claude --- .../27364-publish-sends-listing-tags.md | 15 ++++ packages/cli/src/commands/package/publish.ts | 80 ++++++++++++++----- 2 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 .changeset/27364-publish-sends-listing-tags.md diff --git a/.changeset/27364-publish-sends-listing-tags.md b/.changeset/27364-publish-sends-listing-tags.md new file mode 100644 index 00000000000..870d3b5d579 --- /dev/null +++ b/.changeset/27364-publish-sends-listing-tags.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os package publish` sends the manifest's listing `tags`, says `publisher` and `isStarter` are decided by the platform, and uses a top-level `tagline` only as a missing description + +Clause-②: no +Metadata-protocol: none + +The hosted publish route validates and stores a package's listing tags, so `os package publish` now carries them: + +- **`tags`.** When `objectstack.manifest.json` declares `tags`, publish sends them as written. The hosted route owns the shape and the bounds and refuses a bad list with a 400, which publish prints as the server wrote it; the CLI applies no limit of its own. A file with no `tags` leaves the stored tags alone, and `"tags": []` clears them. The warning no longer names `tags`. +- **`publisher` and `isStarter`.** The warning now says the platform decides them and an author's value has no effect. Publish still never sends either. +- **Top-level `tagline`.** The hosted catalog has no tagline column; `description` is the short listing text. When neither `--description` nor the manifest's `description` is given, publish sends the tagline as the description. Otherwise it no longer sends the tagline, and a warning says so. A per-locale `translations..tagline` is sent as before. +- **`preview` and `visibility`.** Unchanged: `preview` is still named as not carried by the hosted catalog yet, and `visibility` is still set only by `--visibility`. diff --git a/packages/cli/src/commands/package/publish.ts b/packages/cli/src/commands/package/publish.ts index 2464b74d16e..4bc0cb2f227 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -258,17 +258,21 @@ async function tryLoadTemplateManifest( /** * Keys `objectstack.manifest.json` accepts that this command does NOT carry to - * the control plane. `tags`, `publisher`, `isStarter` and `visibility` are - * inherited from `CreatePackageRequestSchema`; `preview` is a scaffold-side - * extra (publish has no screenshot upload path). + * the control plane. `publisher`, `isStarter` and `visibility` are inherited + * from `CreatePackageRequestSchema`; `preview` is a scaffold-side extra + * (publish has no screenshot upload path). * - * They are not sent because nothing in this repo shows the hosted - * `POST /cloud/packages` route writes them: the request schema declares them, - * but the read-side row stores `tags` as a string, and `publisher` is a trust - * tier that gates listing privileges, so a value self-declared in an author's - * file is a privilege claim only the control plane can judge. ⛔ Do not start - * sending them before {@link CATALOG_SEND_ISSUE} answers what the hosted side - * accepts. `visibility` is flag-only on purpose (see the `--visibility` flag). + * - `publisher` and `isStarter` are {@link PLATFORM_DECIDED_MANIFEST_KEYS}: + * the hosted control plane decides both and never reads an author's value + * (`publisher` is a trust tier that gates listing privileges; `isStarter` is + * honoured only for a first-party seed). ⛔ Never send them: a value + * self-declared in an author's file is a claim only the platform can judge. + * - `preview` is not carried by the hosted catalog yet; that is tracked at + * {@link CATALOG_SEND_ISSUE}. + * - `visibility` is flag-only on purpose (see the `--visibility` flag). + * + * `tags` is not here: the hosted route validates and stores it, so it is sent + * (see the request body in `run()`). * * Dropping an authored key silently is the defect, so each one the file * declares is named in a warning. The warning does not fail the publish: the @@ -279,9 +283,12 @@ async function tryLoadTemplateManifest( * `test/package-publish-uncarried-manifest-keys.test.ts`, so a key the schema * gains later goes red there until publish either sends it or lists it here. */ -export const UNCARRIED_MANIFEST_KEYS = ['tags', 'publisher', 'isStarter', 'preview', 'visibility'] as const; +export const UNCARRIED_MANIFEST_KEYS = ['publisher', 'isStarter', 'preview', 'visibility'] as const; + +/** The {@link UNCARRIED_MANIFEST_KEYS} the platform decides, whatever the file says. */ +const PLATFORM_DECIDED_MANIFEST_KEYS: readonly string[] = ['publisher', 'isStarter']; -/** Where carrying the catalog keys to the hosted control plane is tracked. */ +/** Where carrying the remaining catalog keys to the hosted control plane is tracked. */ export const CATALOG_SEND_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/27348'; /** The {@link UNCARRIED_MANIFEST_KEYS} that `manifest` declares, in list order. */ @@ -291,19 +298,30 @@ export function uncarriedManifestKeys(manifest: Record): string /** * The warnings for the {@link uncarriedManifestKeys} a manifest declares: one - * for the catalog keys (declared, not yet carried by the hosted catalog) and - * one for `visibility` (carried by the flag instead). Empty when none. + * for the platform-decided keys (an author's value has no effect), one for the + * catalog keys the hosted side does not carry yet, and one for `visibility` + * (carried by the flag instead). Empty when none. */ export function uncarriedManifestWarnings(manifest: Record): string[] { const declared = uncarriedManifestKeys(manifest); - const catalog = declared.filter((key) => key !== 'visibility'); + const named = (keys: string[]) => keys.map((key) => `\`${key}\``).join(', '); + const platform = declared.filter((key) => PLATFORM_DECIDED_MANIFEST_KEYS.includes(key)); + const catalog = declared.filter((key) => key !== 'visibility' && !PLATFORM_DECIDED_MANIFEST_KEYS.includes(key)); const warnings: string[] = []; + if (platform.length > 0) { + const one = platform.length === 1; + warnings.push( + `objectstack.manifest.json declares ${named(platform)}, which ${one ? 'is' : 'are'} decided by the ` + + `platform: an author's value has no effect, so \`os package publish\` does not send ${one ? 'it' : 'them'}.`, + ); + } if (catalog.length > 0) { + const one = catalog.length === 1; warnings.push( - `objectstack.manifest.json declares ${catalog.map((key) => `\`${key}\``).join(', ')}, which the ` + `objectstack.manifest.json declares ${named(catalog)}, which the ` + 'hosted catalog does not carry yet: `os package publish` does not send ' - + (catalog.length === 1 ? 'it' : 'them') - + `, so the published package will not show ${catalog.length === 1 ? 'it' : 'them'}. See ${CATALOG_SEND_ISSUE}.`, + + (one ? 'it' : 'them') + + `, so the published package will not show ${one ? 'it' : 'them'}. See ${CATALOG_SEND_ISSUE}.`, ); } if (declared.includes('visibility')) { @@ -599,11 +617,33 @@ export default class PackagePublish extends Command { // `''` would turn "declares no namespace" into a value the gate has to // interpret. if (namespace) pkgBody.namespace = namespace; + // A top-level `tagline` has no column on the hosted side: `description` + // is the catalog's short listing text. So a tagline stands in for the + // description only when neither the flag nor the file gives one, and is + // otherwise not sent — and the author is told, not dropped silently. + // The per-locale `translations.*.tagline` below is a different path. + const tagline = typeof m.tagline === 'string' && m.tagline.trim() ? m.tagline.trim() : undefined; const desc = flags.description ?? (typeof m.description === 'string' ? m.description : undefined); - if (desc) pkgBody.description = desc; + if (desc) { + pkgBody.description = desc; + if (tagline) { + printWarning( + 'objectstack.manifest.json declares a top-level `tagline`, which the hosted catalog does not store, ' + + 'and a description is already given, so `os package publish` does not send it. The catalog\'s ' + + 'short listing text is `description`.', + ); + } + } else if (tagline) { + pkgBody.description = tagline; + } const cat = flags.category ?? (typeof m.category === 'string' ? m.category : undefined); if (cat) pkgBody.category = cat; - if (typeof m.tagline === 'string' && m.tagline.trim()) pkgBody.tagline = m.tagline.trim(); + // Listing tags, as the author wrote them. The hosted route owns the + // shape and the bounds (it trims, de-duplicates and caps them) and + // answers a 400 that is printed as it is; this command keeps no copy of + // those numbers. An absent key leaves the stored tags alone; `[]` clears + // them. + if (m.tags !== undefined) pkgBody.tags = m.tags; if (flags.org) pkgBody.owner_org_id = flags.org; if (flags['icon-url'] && flags['icon-file']) { printError('Pass either --icon-url or --icon-file, not both.'); From 94a3512f57dc0aef7b63579268f662f46c61d885 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:50:03 +0000 Subject: [PATCH 3/3] docs(cli): publish sends manifest tags; publisher and isStarter are platform-decided; tagline only as a missing description Claude-Session: https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 8 ++++++-- content/docs/deployment/marketplace-handbook.mdx | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 39ce927ffb7..fd67ae920a1 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -2952,9 +2952,13 @@ and rebuild. **`objectstack.manifest.json` supplies the listing fields.** When that file is present in the working directory, publish reads `manifestId`, `displayName`, -`description`, `category`, `tagline`, `iconUrl`, `homepageUrl`, `license`, +`description`, `category`, `tags`, `iconUrl`, `homepageUrl`, `license`, `readmePath` and a `translations` map from it, so a listing need not be retyped -as flags on every publish. **CLI flags always win.** A per-locale `readme` entry +as flags on every publish. **CLI flags always win.** `tags` has no flag and is +sent as written: the control plane judges the list, and a refusal is its 400, +printed as it is. A top-level `tagline` is used only as the description, when +neither `--description` nor the file's `description` gives one; otherwise it is +not sent, and a warning says so. A per-locale `readme` entry may be inlined markdown or a path resolved against the manifest's own directory (`README.zh-CN.md`). Publishing without the file is fully supported — it stays flag-driven. diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 91d9402d6d2..5ae2a85bd0e 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -35,6 +35,7 @@ the declared keys below are the ones publish actually sends. | `displayName` | The name on the card | `--display-name` | | `description` | The short description | `--description` | | `category` | The catalog category slug (`crm`, `hr`, `finance`, …) | `--category` | +| `tags` | The listing tags, sent as written. The catalog validates and stores them and refuses a bad list with a 400, which publish prints as it is; `[]` clears the stored tags, and leaving the key out keeps them | — | | `iconUrl` | A public http(s) icon URL | `--icon-url`, or `--icon-file` to upload one | | `homepageUrl` | Your project or docs URL | `--homepage-url` | | `license` | An SPDX identifier | `--license` | @@ -83,10 +84,12 @@ columns for a missing key. The per-locale shape is the **Two things the file can say that never reach the listing.** Publish uploads no screenshot images, and the Console's marketplace pages render none: a `preview.screenshots` list stays on your disk, and `screenshotCaptions` travels -with no image to caption. The schema also accepts `tags`, `publisher` and -`isStarter`, but `os package publish` does not send them. Put the icon, the -description, the tagline and the README where the table above says, and nothing -else. +with no image to caption. The schema also accepts `publisher` and `isStarter`, +but the platform decides both: an author's value has no effect, so +`os package publish` never sends them and prints a warning naming each one the +file declares. `tags` is different: it is sent as written (see the table above). +Put the icon, the description, the tags, the tagline and the README where the +table above says, and nothing else. ## The README the listing depends on