From 1b1115edf37fad34d1f829cc3e6df7a3739fa9a6 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:51:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20`os=20plugin=20publish`=20?= =?UTF-8?q?=E2=80=94=20upload=20a=20signed=20.osplugin=20(ADR-0025=20?= =?UTF-8?q?=C2=A73.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the build → sign → publish pipeline for plugin marketplace artifacts. - osplugin.ts: add the tar READER (readTar / readTarGz / readOspluginManifest) — the inverse of createTar — so publish can extract the compiled objectstack.plugin.json from inside the artifact. - commands/plugin/publish.ts: `os plugin publish `: reads the artifact + its detached `.sig`, extracts the manifest (id/version/name/runtime/permissions), then POSTs /cloud/packages and /cloud/packages/:id/versions with { artifact_kind:'plugin', osplugin (base64), plugin_manifest, signature, artifact_checksum (sha256) }, plus --submit / --auto-approve / --visibility / --org. Warns when publishing unsigned (the server rejects a node-tier plugin from an unverified publisher). The platform counter-signature is written by the review flow. Verified: 9 tests — tar round-trip + manifest extraction, and an end-to-end publish against a mocked cloud asserting the exact request contract (package register, then plugin version with base64 artifact + signature + checksum). tsc clean. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/plugin/publish.ts | 189 ++++++++++++++++++++ packages/cli/src/utils/osplugin.ts | 35 +++- packages/cli/test/plugin-publish.test.ts | 98 ++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/plugin/publish.ts create mode 100644 packages/cli/test/plugin-publish.test.ts diff --git a/packages/cli/src/commands/plugin/publish.ts b/packages/cli/src/commands/plugin/publish.ts new file mode 100644 index 000000000..68bcf5f6c --- /dev/null +++ b/packages/cli/src/commands/plugin/publish.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin publish` — upload a signed `.osplugin` to ObjectStack Cloud + * (ADR-0025 §3.4 step 3). Completes the build → sign → publish pipeline. + * + * Flow: + * 1. Read the `.osplugin` bytes + the detached `.sig` (publisher signature). + * 2. Extract the compiled `objectstack.plugin.json` from inside the + * artifact (id / version / name / runtime / permissions / integrity). + * 3. POST /cloud/packages — ensure the sys_package row exists. + * 4. POST /cloud/packages/:id/versions with `artifact_kind: 'plugin'`, + * the base64 artifact, the declared manifest, the signature, and the + * whole-artifact sha256 checksum. The cloud verifies the signature, + * audits permissions/runtime tier, stores the blob, and sets + * listing_status=pending_review (or approved with --auto-approve). + * + * The platform counter-signature is written by the marketplace review/approve + * flow, not here. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve as resolvePath, basename } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js'; +import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js'; +import { OSPLUGIN_EXT, sha256Hex, readOspluginManifest } from '../../utils/osplugin.js'; + +interface PostResult { ok: boolean; status: number; body: any; error?: string } + +export default class PluginPublish extends Command { + static override description = + 'Publish a signed .osplugin to ObjectStack Cloud (ADR-0025 §3.4)'; + + static override examples = [ + '$ os plugin publish ./com.acme.stripe-1.0.0.osplugin --visibility marketplace --submit', + '$ os plugin publish ./x.osplugin --sig ./x.osplugin.sig --org org_123', + '$ OS_CLOUD_URL=http://localhost:4000 os plugin publish ./x.osplugin --auto-approve', + ]; + + static override args = { + artifact: Args.string({ description: 'Path to the .osplugin (default: the single .osplugin in cwd)', required: false }), + }; + + static override flags = { + server: Flags.string({ char: 's', description: 'Cloud control-plane URL', env: 'OS_CLOUD_URL', default: DEFAULT_CLOUD_URL }), + token: Flags.string({ char: 't', description: 'Cloud API key (bearer)', env: 'OS_CLOUD_API_KEY' }), + sig: Flags.string({ description: 'Path to the detached signature (default: .sig)' }), + 'manifest-id': Flags.string({ description: 'Override package id (default: from the manifest)' }), + 'display-name': Flags.string({ description: 'Marketplace display name (default: manifest.name)' }), + visibility: Flags.string({ description: 'Who can see/install', options: ['private', 'org', 'marketplace'], default: 'private' }), + org: Flags.string({ description: 'owner_org_id (service mode)', env: 'OS_ORG_ID' }), + note: Flags.string({ char: 'n', description: 'Release notes (markdown ok)' }), + 'pre-release': Flags.boolean({ description: 'Mark as a pre-release', default: false }), + submit: Flags.boolean({ description: 'Submit for marketplace review after publish', default: false }), + 'auto-approve': Flags.boolean({ description: 'Platform admin only: skip review queue', default: false }), + timeout: Flags.integer({ description: 'HTTP timeout (ms, 0 disables)', env: 'OS_CLOUD_TIMEOUT_MS', default: 120_000 }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginPublish); + printHeader('Publish Plugin'); + + // 1. Resolve the artifact path. ──────────────────────────────────── + let artifactPath: string; + if (args.artifact) { + artifactPath = resolvePath(process.cwd(), args.artifact); + } else { + const here = (await readdir(process.cwd())).filter((f) => f.endsWith(OSPLUGIN_EXT)); + if (here.length === 0) { printError(`No ${OSPLUGIN_EXT} found in cwd. Run \`os plugin build\` first, or pass a path.`); this.exit(1); return; } + if (here.length > 1) { printError(`Multiple ${OSPLUGIN_EXT} files found — pass one explicitly.`); this.exit(1); return; } + artifactPath = resolvePath(process.cwd(), here[0]); + } + if (!existsSync(artifactPath)) { printError(`Artifact not found: ${artifactPath}`); this.exit(1); return; } + + const bytes = new Uint8Array(await readFile(artifactPath)); + const checksum = sha256Hex(bytes); + const base64 = Buffer.from(bytes).toString('base64'); + + // 2. Extract the compiled manifest from inside the artifact. ──────── + let manifest: Record; + try { + manifest = readOspluginManifest(bytes); + } catch (err: any) { + printError(`Cannot read manifest from artifact: ${err?.message ?? err}`); + this.exit(1); + return; + } + const id = String(flags['manifest-id'] ?? manifest.id ?? '').trim(); + const version = String(manifest.version ?? '').trim(); + const displayName = String(flags['display-name'] ?? manifest.name ?? id).trim(); + if (!id || !version) { printError('Artifact manifest is missing id or version.'); this.exit(1); return; } + printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`); + + // 3. Detached publisher signature. ───────────────────────────────── + const sigPath = resolvePath(process.cwd(), flags.sig ?? `${artifactPath}.sig`); + let signature: string | undefined; + if (existsSync(sigPath)) { + signature = (await readFile(sigPath, 'utf-8')).trim(); + printKV(' Signature', signature.length > 48 ? signature.slice(0, 48) + '…' : signature); + } else { + printStep(`No signature sidecar at ${basename(sigPath)} — publishing UNSIGNED (a "node"-tier plugin will be rejected by the server unless the publisher is verified). Run \`os plugin sign\` first.`); + } + + // 4. Auth + server URL (same precedence as `os package publish`). ─── + let token = flags.token ?? process.env.OS_TOKEN ?? undefined; + let baseUrl = flags.server.replace(/\/+$/, ''); + const serverFlagWasDefault = !process.env.OS_CLOUD_URL && baseUrl === DEFAULT_CLOUD_URL; + if (!token || serverFlagWasDefault) { + const stored = await tryReadCloudConfig(); + if (!token && stored?.token) token = stored.token; + if (serverFlagWasDefault && stored?.url) baseUrl = stored.url.replace(/\/+$/, ''); + } + if (!token) { printError('Not logged in. Run `os cloud login`, or pass --token / set $OS_CLOUD_API_KEY.'); this.exit(1); return; } + + // 5. Register the package row. ────────────────────────────────────── + printStep(`Registering package '${id}'...`); + const pkgBody: Record = { manifest_id: id, display_name: displayName, visibility: flags.visibility }; + if (flags.org) pkgBody.owner_org_id = flags.org; + if (typeof manifest.description === 'string') pkgBody.description = manifest.description; + const pkgRes = await this.postJson(`${baseUrl}/api/v1/cloud/packages`, pkgBody, token, flags.timeout); + if (!pkgRes.ok) { printError(`Register package failed (${pkgRes.status}): ${pkgRes.error}`); this.exit(1); return; } + const pkg = pkgRes.body?.data ?? pkgRes.body; + printSuccess(`${pkg?.created ? 'Created' : 'Updated'} sys_package ${pkg?.id} (${id})`); + + // 6. Publish the plugin version. ──────────────────────────────────── + printStep(`Publishing version ${version}...`); + const verBody: Record = { + version, + artifact_kind: 'plugin', + osplugin: base64, + plugin_manifest: manifest, + artifact_checksum: checksum, + is_pre_release: flags['pre-release'] || /-(alpha|beta|rc|dev|preview|staging|pr)/i.test(version), + }; + if (signature) verBody.signature = signature; + if (flags.note) verBody.release_notes = flags.note; + if (flags.submit) verBody.submit_for_review = true; + if (flags['auto-approve']) verBody.auto_approve = true; + + const verRes = await this.postJson( + `${baseUrl}/api/v1/cloud/packages/${encodeURIComponent(pkg.id)}/versions`, verBody, token, flags.timeout, + ); + if (!verRes.ok) { + printError(`Publish version failed (${verRes.status}): ${verRes.error}`); + const violations = Array.isArray(verRes.body?.violations) ? verRes.body.violations : []; + if (violations.length > 0) { + console.log('\n Violations:'); + for (const v of violations) console.log(` • ${v}`); + } + this.exit(1); + return; + } + const ver = verRes.body?.data ?? verRes.body; + printSuccess('Plugin version published'); + printKV(' Version', String(ver?.version ?? version)); + printKV(' Listing status', String(ver?.listing_status ?? (flags.submit ? 'pending_review' : 'draft'))); + printKV(' Artifact sha256', checksum); + if (!flags.submit && !flags['auto-approve'] && flags.visibility === 'marketplace') { + printStep('Re-run with --submit to send this version for marketplace review.'); + } + } + + /** Tiny fetch wrapper returning a normalized envelope; honours a timeout. */ + private async postJson(url: string, body: unknown, token: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + signal: controller.signal, + }); + let parsed: any = null; + try { parsed = await response.json(); } catch { /* empty body */ } + if (!response.ok) { + const errMsg = parsed?.error?.message ?? parsed?.error ?? response.statusText; + return { ok: false, status: response.status, body: parsed, error: String(errMsg) }; + } + return { ok: true, status: response.status, body: parsed }; + } catch (err: any) { + return { ok: false, status: 0, body: null, error: err?.message ?? String(err) }; + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts index 4e9c06170..d44f72ab8 100644 --- a/packages/cli/src/utils/osplugin.ts +++ b/packages/cli/src/utils/osplugin.ts @@ -29,7 +29,7 @@ */ import { createHash } from 'node:crypto'; -import { gzipSync } from 'node:zlib'; +import { gzipSync, gunzipSync } from 'node:zlib'; /** A single file destined for the archive. `path` is POSIX, archive-relative. */ export interface ArchiveFile { @@ -132,3 +132,36 @@ export function createTar(files: ArchiveFile[]): Buffer { export function createTarGz(files: ArchiveFile[]): Buffer { return gzipSync(createTar(files), { level: 9 }); } + +/** + * Parse a ustar buffer back into its files — the inverse of {@link createTar}. + * Reads regular-file entries only; stops at the end-of-archive zero block. + */ +export function readTar(buf: Uint8Array): ArchiveFile[] { + const b = Buffer.from(buf); + const files: ArchiveFile[] = []; + let off = 0; + while (off + BLOCK <= b.length) { + const name = b.toString('utf8', off, off + 100).replace(/\0.*$/s, ''); + if (!name) break; // zero block → end of archive + const size = parseInt(b.toString('ascii', off + 124, off + 136).replace(/\0.*$/s, '').trim(), 8) || 0; + files.push({ path: name, data: new Uint8Array(b.subarray(off + BLOCK, off + BLOCK + size)) }); + off += BLOCK + Math.ceil(size / BLOCK) * BLOCK; + } + return files; +} + +/** gunzip + untar an `.osplugin` blob into its files. */ +export function readTarGz(blob: Uint8Array): ArchiveFile[] { + return readTar(gunzipSync(Buffer.from(blob))); +} + +/** + * Extract + parse the compiled `objectstack.plugin.json` from an `.osplugin` + * blob. Throws if the manifest is absent or not valid JSON. + */ +export function readOspluginManifest(blob: Uint8Array): Record { + const entry = readTarGz(blob).find((f) => f.path === MANIFEST_FILENAME); + if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in artifact`); + return JSON.parse(Buffer.from(entry.data).toString('utf8')) as Record; +} diff --git a/packages/cli/test/plugin-publish.test.ts b/packages/cli/test/plugin-publish.test.ts new file mode 100644 index 000000000..b13d52573 --- /dev/null +++ b/packages/cli/test/plugin-publish.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createTarGz, + readOspluginManifest, + readTarGz, + sha256Hex, + MANIFEST_FILENAME, + SIGNATURE_FILENAME, + type ArchiveFile, +} from '../src/utils/osplugin.js'; +import PluginPublish from '../src/commands/plugin/publish.js'; + +const manifest = { + id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin', + runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs', + permissions: { services: ['object'] }, + integrity: { 'dist/index.mjs': 'sha256-abc' }, +}; + +function buildArtifact(): Uint8Array { + const files: ArchiveFile[] = [ + { path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) }, + { path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2))) }, + { path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n')) }, + ]; + return new Uint8Array(createTarGz(files)); +} + +describe('readTarGz / readOspluginManifest (inverse of createTarGz)', () => { + it('round-trips the files and extracts the manifest', () => { + const blob = buildArtifact(); + const files = readTarGz(blob); + expect(files.map((f) => f.path).sort()).toEqual([MANIFEST_FILENAME, SIGNATURE_FILENAME, 'dist/index.mjs'].sort()); + expect(Buffer.from(files.find((f) => f.path === 'dist/index.mjs')!.data).toString()).toBe('export const x=1;\n'); + expect(readOspluginManifest(blob)).toMatchObject({ id: 'com.acme.demo', version: '1.2.0' }); + }); + it('throws when the manifest is absent', () => { + const blob = new Uint8Array(createTarGz([{ path: 'dist/index.mjs', data: new Uint8Array([1, 2, 3]) }])); + expect(() => readOspluginManifest(blob)).toThrow(/not found/); + }); +}); + +describe('os plugin publish (end-to-end, mocked cloud)', () => { + let dir: string; + const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; + afterEach(async () => { + vi.unstubAllGlobals(); + process.env.OS_CLOUD_URL = prevEnv.url; + process.env.OS_CLOUD_API_KEY = prevEnv.key; + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it('POSTs the package then the plugin version with artifact + signature + checksum', async () => { + dir = await mkdtemp(join(tmpdir(), 'plugin-publish-')); + const blob = buildArtifact(); + const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin'); + await writeFile(artifactPath, blob); + await writeFile(`${artifactPath}.sig`, 'ed25519:acme:SIGVALUE\n'); + + process.env.OS_CLOUD_URL = 'http://cloud.test'; + process.env.OS_CLOUD_API_KEY = 'tok_123'; + + const calls: { url: string; body: any; auth?: string }[] = []; + const fetchMock = vi.fn(async (url: string, init: any) => { + calls.push({ url, body: JSON.parse(init.body), auth: init.headers?.Authorization }); + const data = url.endsWith('/versions') + ? { version: '1.2.0', listing_status: 'pending_review' } + : { id: 'pkg_1', created: true }; + return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any; + }); + vi.stubGlobal('fetch', fetchMock); + + await PluginPublish.run([artifactPath, '--visibility', 'marketplace', '--submit']); + + expect(calls).toHaveLength(2); + // 1) package register + expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages'); + expect(calls[0].auth).toBe('Bearer tok_123'); + expect(calls[0].body).toMatchObject({ manifest_id: 'com.acme.demo', display_name: 'Demo', visibility: 'marketplace' }); + // 2) version publish — the exact plugin contract + expect(calls[1].url).toBe('http://cloud.test/api/v1/cloud/packages/pkg_1/versions'); + expect(calls[1].body).toMatchObject({ + version: '1.2.0', + artifact_kind: 'plugin', + signature: 'ed25519:acme:SIGVALUE', + artifact_checksum: sha256Hex(blob), + submit_for_review: true, + }); + // artifact round-trips through base64 + expect(Buffer.from(calls[1].body.osplugin, 'base64').equals(Buffer.from(blob))).toBe(true); + expect(calls[1].body.plugin_manifest).toMatchObject({ id: 'com.acme.demo', runtime: 'sandbox' }); + }); +});