|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `os package publish` sends `visibility` only when the author passed |
| 5 | + * `--visibility` (#20892). |
| 6 | + * |
| 7 | + * The package upsert (`POST /api/v1/cloud/packages`) is also the RE-publish |
| 8 | + * path, and for an existing package the control plane patches visibility |
| 9 | + * whenever the body carries one. The flag used to default to `'org'` and was |
| 10 | + * always sent, so re-publishing a new version of a `marketplace` package |
| 11 | + * without repeating `--visibility marketplace` silently moved it to `org` and |
| 12 | + * out of the marketplace. The route cannot tell a CLI default from a choice, |
| 13 | + * so the fix lives at the producer: an omitted flag is an omitted key. |
| 14 | + * |
| 15 | + * ## The stand-in route, and what it is NOT |
| 16 | + * |
| 17 | + * The consumer is objectstack-ai/cloud's route |
| 18 | + * (`packages/service-cloud/src/routes/package-publish.ts`), which this |
| 19 | + * repository cannot run. `standInRoute()` below models only the two facts the |
| 20 | + * card's cloud seat read and measured on that route — for an EXISTING package |
| 21 | + * `if (body.visibility) patch.visibility = visibility`, and for a NEW package |
| 22 | + * an absent value defaults to `org` (cloud ADR-0007 decision 2) — so the |
| 23 | + * "re-publish leaves it unchanged" and "create yields the route's default" |
| 24 | + * cases can be stated end to end. Every assertion that decides pass or fail |
| 25 | + * here is about what the CLI SENT or PRINTED; the stand-in's stored value is |
| 26 | + * the consequence, and the control case (`--visibility org` DOES change it) |
| 27 | + * proves the stand-in is capable of the demotion the pins say no longer |
| 28 | + * happens. |
| 29 | + */ |
| 30 | + |
| 31 | +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; |
| 32 | +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; |
| 33 | +import { tmpdir } from 'node:os'; |
| 34 | +import { join } from 'node:path'; |
| 35 | +import { CreatePackageRequestSchema } from '@objectstack/spec/marketplace'; |
| 36 | +import PackagePublish from '../src/commands/package/publish.js'; |
| 37 | + |
| 38 | +type Call = { url: string; body: Record<string, unknown> }; |
| 39 | +type StoredPackage = { id: string; visibility: string }; |
| 40 | + |
| 41 | +const MANIFEST_ID = 'com.acme.crm'; |
| 42 | + |
| 43 | +/** |
| 44 | + * Stub `fetch` as the stand-in route described in the header. `rows` is the |
| 45 | + * control plane's package table, keyed by manifest id; pass a row to model a |
| 46 | + * re-publish, omit it to model a first publish. `echoVisibility: false` models |
| 47 | + * a control plane whose upsert answer does not report the stored value. |
| 48 | + */ |
| 49 | +function standInRoute( |
| 50 | + rows: Map<string, StoredPackage>, |
| 51 | + opts: { echoVisibility?: boolean } = {}, |
| 52 | +): Call[] { |
| 53 | + const calls: Call[] = []; |
| 54 | + vi.stubGlobal('fetch', vi.fn(async (url: string, init: { body: string }) => { |
| 55 | + const body = JSON.parse(init.body) as Record<string, unknown>; |
| 56 | + calls.push({ url, body }); |
| 57 | + let data: Record<string, unknown>; |
| 58 | + if (url.endsWith('/versions')) { |
| 59 | + data = { id: 'ver_1', version: '1.2.0', listing_status: 'draft' }; |
| 60 | + } else { |
| 61 | + const manifestId = String(body.manifest_id); |
| 62 | + const existing = rows.get(manifestId); |
| 63 | + let row: StoredPackage; |
| 64 | + if (existing) { |
| 65 | + if (body.visibility) existing.visibility = String(body.visibility); |
| 66 | + row = existing; |
| 67 | + } else { |
| 68 | + row = { id: 'pkg_new', visibility: typeof body.visibility === 'string' ? body.visibility : 'org' }; |
| 69 | + rows.set(manifestId, row); |
| 70 | + } |
| 71 | + data = { id: row.id, created: !existing }; |
| 72 | + if (opts.echoVisibility !== false) data.visibility = row.visibility; |
| 73 | + } |
| 74 | + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as unknown as Response; |
| 75 | + })); |
| 76 | + return calls; |
| 77 | +} |
| 78 | + |
| 79 | +describe('os package publish — `visibility` is sent only when asked for', () => { |
| 80 | + let dir = ''; |
| 81 | + const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; |
| 82 | + const prevCwd = process.cwd(); |
| 83 | + let output: string[] = []; |
| 84 | + |
| 85 | + beforeEach(() => { |
| 86 | + output = []; |
| 87 | + for (const channel of ['error', 'log', 'warn'] as const) { |
| 88 | + vi.spyOn(console, channel).mockImplementation((...args: unknown[]) => { |
| 89 | + // Strip ANSI styling so the assertions read the words, not the colours. |
| 90 | + output.push(args.map(String).join(' ').replace(/\u001b\[[0-9;]*m/g, '')); |
| 91 | + }); |
| 92 | + } |
| 93 | + }); |
| 94 | + |
| 95 | + afterEach(async () => { |
| 96 | + process.chdir(prevCwd); |
| 97 | + vi.unstubAllGlobals(); |
| 98 | + vi.restoreAllMocks(); |
| 99 | + process.env.OS_CLOUD_URL = prevEnv.url; |
| 100 | + process.env.OS_CLOUD_API_KEY = prevEnv.key; |
| 101 | + if (dir) await rm(dir, { recursive: true, force: true }); |
| 102 | + dir = ''; |
| 103 | + }); |
| 104 | + |
| 105 | + async function artifact(): Promise<string> { |
| 106 | + dir = await mkdtemp(join(tmpdir(), 'package-publish-visibility-')); |
| 107 | + const path = join(dir, 'objectstack.json'); |
| 108 | + await writeFile( |
| 109 | + path, |
| 110 | + JSON.stringify({ manifest: { id: MANIFEST_ID, name: 'Acme CRM', version: '1.2.0' }, objects: [] }), |
| 111 | + ); |
| 112 | + process.env.OS_CLOUD_URL = 'http://cloud.test'; |
| 113 | + process.env.OS_CLOUD_API_KEY = 'tok_123'; |
| 114 | + return path; |
| 115 | + } |
| 116 | + |
| 117 | + function printed(label: string): string | undefined { |
| 118 | + const line = output.find((l) => l.trimStart().startsWith(`${label}:`)); |
| 119 | + return line?.slice(line.indexOf(':') + 1).trim(); |
| 120 | + } |
| 121 | + |
| 122 | + it('omits the `visibility` key from the upsert body when --visibility is not passed', async () => { |
| 123 | + const path = await artifact(); |
| 124 | + const calls = standInRoute(new Map()); |
| 125 | + |
| 126 | + await PackagePublish.run([path]); |
| 127 | + |
| 128 | + expect(calls).toHaveLength(2); |
| 129 | + expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages'); |
| 130 | + expect('visibility' in calls[0].body).toBe(false); |
| 131 | + // The acceptance face treats an absent visibility as legal. |
| 132 | + expect(CreatePackageRequestSchema.shape.visibility.safeParse(undefined).success).toBe(true); |
| 133 | + }); |
| 134 | + |
| 135 | + it.each(['private', 'org', 'marketplace'] as const)( |
| 136 | + 'carries the flag value when --visibility %s is passed', |
| 137 | + async (value) => { |
| 138 | + const path = await artifact(); |
| 139 | + const calls = standInRoute(new Map()); |
| 140 | + |
| 141 | + await PackagePublish.run([path, '--visibility', value]); |
| 142 | + |
| 143 | + expect(calls[0].body.visibility).toBe(value); |
| 144 | + expect(CreatePackageRequestSchema.shape.visibility.safeParse(value).success).toBe(true); |
| 145 | + }, |
| 146 | + ); |
| 147 | + |
| 148 | + it('re-publishing a `marketplace` package without the flag leaves its visibility unchanged', async () => { |
| 149 | + const path = await artifact(); |
| 150 | + const rows = new Map([[MANIFEST_ID, { id: 'pkg_1', visibility: 'marketplace' }]]); |
| 151 | + const calls = standInRoute(rows); |
| 152 | + |
| 153 | + await PackagePublish.run([path]); |
| 154 | + |
| 155 | + expect('visibility' in calls[0].body).toBe(false); |
| 156 | + expect(rows.get(MANIFEST_ID)?.visibility).toBe('marketplace'); |
| 157 | + // The summary reports the control plane's answer, not a local default… |
| 158 | + expect(printed('Visibility')).toBe('marketplace'); |
| 159 | + // …and the draft-listing hint fires on that answer too. |
| 160 | + expect(output.join('\n')).toContain('Hint: visibility is marketplace but the version is still draft'); |
| 161 | + }); |
| 162 | + |
| 163 | + // Control: the stand-in DOES demote when the body asks it to, so the case |
| 164 | + // above is green because the CLI stopped asking, not because nothing could |
| 165 | + // have changed the row. |
| 166 | + it('control — an explicit --visibility org on the same re-publish does move it', async () => { |
| 167 | + const path = await artifact(); |
| 168 | + const rows = new Map([[MANIFEST_ID, { id: 'pkg_1', visibility: 'marketplace' }]]); |
| 169 | + standInRoute(rows); |
| 170 | + |
| 171 | + await PackagePublish.run([path, '--visibility', 'org']); |
| 172 | + |
| 173 | + expect(rows.get(MANIFEST_ID)?.visibility).toBe('org'); |
| 174 | + expect(printed('Visibility')).toBe('org'); |
| 175 | + expect(output.join('\n')).not.toContain('Hint: visibility is marketplace'); |
| 176 | + }); |
| 177 | + |
| 178 | + it('creating a package without the flag yields what the route decides, and prints that', async () => { |
| 179 | + const path = await artifact(); |
| 180 | + const rows = new Map<string, StoredPackage>(); |
| 181 | + const calls = standInRoute(rows); |
| 182 | + |
| 183 | + await PackagePublish.run([path]); |
| 184 | + |
| 185 | + expect('visibility' in calls[0].body).toBe(false); |
| 186 | + expect(rows.get(MANIFEST_ID)?.visibility).toBe('org'); |
| 187 | + expect(printed('Visibility')).toBe('org'); |
| 188 | + }); |
| 189 | + |
| 190 | + it('says so when neither the flag nor the control plane names the visibility — never `undefined`', async () => { |
| 191 | + const path = await artifact(); |
| 192 | + standInRoute(new Map([[MANIFEST_ID, { id: 'pkg_1', visibility: 'marketplace' }]]), { echoVisibility: false }); |
| 193 | + |
| 194 | + await PackagePublish.run([path]); |
| 195 | + |
| 196 | + expect(printed('Visibility')).toBe('not reported by the control plane'); |
| 197 | + expect(output.join('\n')).not.toMatch(/Visibility:\s*undefined/); |
| 198 | + }); |
| 199 | + |
| 200 | + // `--submit` has no client-side precondition on the flag; the control plane |
| 201 | + // judges the STORED visibility. With the default gone, an omitted flag must |
| 202 | + // neither be refused locally nor re-introduce a visibility on the wire. |
| 203 | + it('--submit without --visibility submits for review and sends no visibility', async () => { |
| 204 | + const path = await artifact(); |
| 205 | + const rows = new Map([[MANIFEST_ID, { id: 'pkg_1', visibility: 'marketplace' }]]); |
| 206 | + const calls = standInRoute(rows); |
| 207 | + |
| 208 | + await PackagePublish.run([path, '--submit']); |
| 209 | + |
| 210 | + expect(calls).toHaveLength(2); |
| 211 | + expect('visibility' in calls[0].body).toBe(false); |
| 212 | + expect(calls[1].url).toBe('http://cloud.test/api/v1/cloud/packages/pkg_1/versions'); |
| 213 | + expect(calls[1].body.submit_for_review).toBe(true); |
| 214 | + expect(rows.get(MANIFEST_ID)?.visibility).toBe('marketplace'); |
| 215 | + }); |
| 216 | +}); |
0 commit comments