Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions packages/cli/src/commands/plugin/publish.ts
Original file line number Diff line number Diff line change
@@ -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: <artifact>.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<void> {
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<string, any>;
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<string, any> = { 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<string, any> = {
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<PostResult> {
const controller = new AbortController();
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
try {
const response = await fetch(url, {

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
body: JSON.stringify(body),

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
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);
}
}
}
35 changes: 34 additions & 1 deletion packages/cli/src/utils/osplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> {
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<string, unknown>;
}
98 changes: 98 additions & 0 deletions packages/cli/test/plugin-publish.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});