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
232 changes: 232 additions & 0 deletions packages/cli/src/commands/plugin/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os plugin build` — compile a plugin into a signed-ready `.osplugin`
* artifact (ADR-0025 §3.4 step 1, framework F2).
*
* Flow:
* 1. Load + validate `objectstack.plugin.json` against the canonical
* ManifestSchema (@objectstack/spec/kernel, ADR-0025 §3.2 — landed by
* framework F1). Malformed manifests fail fast with zod diagnostics.
* 2. esbuild-bundle the entry to `dist/index.mjs`, externalizing
* `@objectstack/*` (peer-provided by the host runtime; ADR §3.10 #2).
* For `packaging: manifest-deps`, dependencies are externalized too and
* `package.json` + lockfile are carried for install-time resolution.
* 3. Compute per-file `integrity` (`sha256-<base64>`, ADR §3.2) and write
* the compiled manifest with that map + a `dist/index.mjs` entry.
* 4. Pack everything (+ a `SIGNATURE` placeholder) into a reproducible
* ustar+gzip `<id>-<version>.osplugin`.
*
* Signing is a separate step (`os plugin sign`); this command emits an
* unsigned artifact whose `SIGNATURE` is a placeholder.
*/

import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, relative, resolve as resolvePath, sep as PATH_SEP } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
import { ManifestSchema } from '@objectstack/spec/kernel';
import {
printError,
printHeader,
printKV,
printStep,
printSuccess,
formatZodErrors,
} from '../../utils/format.js';
import {
type ArchiveFile,
MANIFEST_FILENAME,
OSPLUGIN_EXT,
SIGNATURE_FILENAME,
computeIntegrity,
createTarGz,
sha256Hex,
} from '../../utils/osplugin.js';

const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.mjs', 'src/index.js'];

/** Walk a directory recursively, returning archive files under `prefix`. */
async function collectDir(dir: string, prefix: string): Promise<ArchiveFile[]> {
const out: ArchiveFile[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const abs = join(dir, e.name);
const rel = `${prefix}/${e.name}`;
if (e.isDirectory()) {
out.push(...(await collectDir(abs, rel)));
} else if (e.isFile()) {
out.push({ path: rel, data: new Uint8Array(await readFile(abs)) });
}
}
return out;
}

export default class PluginBuild extends Command {
static override description =
'Compile a plugin into a signed-ready `.osplugin` artifact (ADR-0025 §3.4)';

static override examples = [
'$ os plugin build',
'$ os plugin build --entry src/main.ts',
'$ os plugin build --out dist/my-plugin.osplugin',
];

static override args = {
dir: Args.string({
description: 'Plugin project directory (defaults to cwd)',
required: false,
}),
};

static override flags = {
entry: Flags.string({
char: 'e',
description: 'Entry module to bundle (defaults to the first of src/index.{ts,tsx,mjs,js})',
}),
out: Flags.string({
char: 'o',
description: 'Output path for the .osplugin (defaults to <id>-<version>.osplugin in cwd)',
}),
minify: Flags.boolean({ description: 'Minify the bundled output', default: false }),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(PluginBuild);
const cwd = resolvePath(process.cwd(), args.dir ?? '.');

printHeader('Build Plugin');

// 1. Load + validate the source manifest. ──────────────────────────
const manifestPath = resolvePath(cwd, MANIFEST_FILENAME);
let rawManifest: Record<string, unknown>;
try {
rawManifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
} catch (err) {
printError(`Cannot read ${MANIFEST_FILENAME} in ${cwd}: ${(err as Error).message}`);
this.exit(1);
return;
}

const parsed = ManifestSchema.safeParse(rawManifest);
if (!parsed.success) {
printError(`${MANIFEST_FILENAME} is invalid:`);
formatZodErrors(parsed.error);
this.exit(1);
return;
}
const manifest = parsed.data;
const id = manifest.id;
const version = manifest.version;
if (!id || !version) {
printError(`${MANIFEST_FILENAME} must declare both "id" and "version".`);
this.exit(1);
return;
}
const packaging = manifest.packaging ?? 'bundled';
printStep(`Loaded ${id}@${version} (runtime: ${manifest.runtime ?? 'unset'}, packaging: ${packaging})`);

// 2. Resolve entry + esbuild bundle. ───────────────────────────────
const entryRel =
flags.entry ?? (typeof rawManifest.main === 'string' ? rawManifest.main : undefined) ??
ENTRY_CANDIDATES.find((c) => existsSync(resolvePath(cwd, c)));
if (!entryRel) {
printError(`No entry module found. Add a "main" to ${MANIFEST_FILENAME} or pass --entry.`);
this.exit(1);
return;
}
const entryAbs = resolvePath(cwd, entryRel);
if (!existsSync(entryAbs)) {
printError(`Entry module not found: ${entryRel}`);
this.exit(1);
return;
}
printStep(`Bundling ${relative(cwd, entryAbs).split(PATH_SEP).join('/')}...`);

let esbuild: typeof import('esbuild');
try {
esbuild = await import('esbuild');
} catch (err) {
printError(`esbuild is required to build plugins but is not installed: ${(err as Error).message}`);
this.exit(1);
return;
}

// Externalize peer-provided @objectstack/*; for manifest-deps, also keep
// declared dependencies external (resolved at install time).
const external = ['@objectstack/*'];
if (packaging === 'manifest-deps') {
try {
const pkg = JSON.parse(await readFile(resolvePath(cwd, 'package.json'), 'utf-8'));
external.push(...Object.keys(pkg.dependencies ?? {}));
} catch {
/* no package.json — nothing extra to externalize */
}
}

let bundleBytes: Uint8Array;
try {
const result = await esbuild.build({
entryPoints: [entryAbs],
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
write: false,
outfile: resolvePath(cwd, 'dist/index.mjs'),
sourcemap: false,
minify: flags.minify,
external,
logLevel: 'silent',
legalComments: 'none',
banner: { js: '// @generated by `os plugin build` — do not edit.' },
});
const outputs = result.outputFiles ?? [];
const js = outputs.find((f) => f.path.endsWith('.mjs') || f.path.endsWith('.js')) ?? outputs[0];
if (!js) throw new Error('esbuild produced no output');
bundleBytes = js.contents;
} catch (err) {
printError(`Bundle failed: ${(err as Error).message}`);
this.exit(1);
return;
}

// 3. Stage archive files, compute integrity, compile manifest. ──────
const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: bundleBytes }];

const assetsDir = resolvePath(cwd, 'assets');
if (existsSync(assetsDir) && (await stat(assetsDir)).isDirectory()) {
files.push(...(await collectDir(assetsDir, 'assets')));
}

if (packaging === 'manifest-deps') {
for (const dep of ['package.json', 'pnpm-lock.yaml']) {
const p = resolvePath(cwd, dep);
if (existsSync(p)) files.push({ path: dep, data: new Uint8Array(await readFile(p)) });
}
}

const integrity = computeIntegrity(files);
const compiledManifest = { ...manifest, main: 'dist/index.mjs', integrity };
const manifestBytes = new Uint8Array(
Buffer.from(JSON.stringify(compiledManifest, null, 2) + '\n', 'utf-8'),
);
files.push({ path: MANIFEST_FILENAME, data: manifestBytes });
// Unsigned placeholder; `os plugin sign` overwrites this (ADR §3.4).
files.push({ path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n', 'utf-8')) });

// 4. Pack the artifact. ─────────────────────────────────────────────
const blob = createTarGz(files);
const outPath = resolvePath(cwd, flags.out ?? `${id}-${version}${OSPLUGIN_EXT}`);
await mkdir(resolvePath(outPath, '..'), { recursive: true });
await writeFile(outPath, blob);

printSuccess('Plugin built');
printKV(' Artifact', relative(cwd, outPath).split(PATH_SEP).join('/') || outPath);
printKV(' Plugin', `${id}@${version}`);
printKV(' Files', String(files.length));
printKV(' Integrity entries', String(Object.keys(integrity).length));
printKV(' Size', `${(blob.byteLength / 1024).toFixed(1)} KB`);
printKV(' sha256', sha256Hex(blob));
}
}
113 changes: 113 additions & 0 deletions packages/cli/src/commands/plugin/sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os plugin sign` — produce a publisher signature over a built `.osplugin`
* (ADR-0025 §3.4 step 2, framework F3).
*
* The signature is DETACHED and computed over the exact artifact bytes that
* will be uploaded — the same bytes the cloud control plane verifies at
* publish time (`verifyPublisherSignature`) and the runtime re-verifies at
* materialize time. It is emitted as `ed25519:<keyId>:<base64url>` and
* written to a `<artifact>.sig` sidecar (and printed), to be passed as the
* `signature` field when publishing. The artifact itself is NOT modified, so
* signing is idempotent and the signed bytes are exactly the built bytes.
*/

import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { resolve as resolvePath } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
import { parseSignature, signPayload, verifyPayload } from '@objectstack/core';
import { printError, printHeader, printKV, printStep, printSuccess } from '../../utils/format.js';
import { OSPLUGIN_EXT } from '../../utils/osplugin.js';

export default class PluginSign extends Command {
static override description =
'Sign a built .osplugin with a publisher Ed25519 key (ADR-0025 §3.4)';

static override examples = [
'$ os plugin sign my-plugin-1.0.0.osplugin --key ./publisher.key.pem',
'$ os plugin sign my-plugin-1.0.0.osplugin --key ./publisher.key.pem --key-id acme-2026',
];

static override args = {
artifact: Args.string({ description: 'Path to the .osplugin artifact', required: true }),
};

static override flags = {
key: Flags.string({
char: 'k',
description: 'Path to the publisher Ed25519 private key (PKCS#8 PEM)',
required: true,
}),
'key-id': Flags.string({
description: 'Key identifier embedded in the signature (rotation handle)',
default: 'default',
}),
out: Flags.string({
char: 'o',
description: 'Output path for the detached signature (defaults to <artifact>.sig)',
}),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(PluginSign);
printHeader('Sign Plugin');

const artifactPath = resolvePath(process.cwd(), args.artifact);
if (!existsSync(artifactPath)) {
printError(`Artifact not found: ${args.artifact}`);
this.exit(1);
return;
}
if (!artifactPath.endsWith(OSPLUGIN_EXT)) {
printStep(`Warning: ${args.artifact} does not have a ${OSPLUGIN_EXT} extension`);
}

let privateKeyPem: string;
try {
privateKeyPem = await readFile(resolvePath(process.cwd(), flags.key), 'utf-8');
} catch (err) {
printError(`Cannot read private key: ${(err as Error).message}`);
this.exit(1);
return;
}

const artifact = new Uint8Array(await readFile(artifactPath));
const keyId = flags['key-id'];

let signature: string;
try {
signature = signPayload(artifact, privateKeyPem, keyId);
} catch (err) {
printError(`Signing failed: ${(err as Error).message}`);
this.exit(1);
return;
}

// Self-check: verify the freshly produced signature against the public
// half so a bad key / wrong format never ships silently.
try {
const pub = createPublicKey(createPrivateKey(privateKeyPem));
if (!verifyPayload(artifact, signature, pub)) {
printError('Self-verification of the produced signature failed.');
this.exit(1);
return;
}
} catch (err) {
printError(`Self-verification error: ${(err as Error).message}`);
this.exit(1);
return;
}

const outPath = resolvePath(process.cwd(), flags.out ?? `${args.artifact}.sig`);
await writeFile(outPath, signature + '\n', 'utf-8');

printSuccess('Plugin signed');
printKV(' Artifact', args.artifact);
printKV(' Key ID', parseSignature(signature)?.keyId ?? keyId);
printKV(' Signature', signature);
printKV(' Sidecar', flags.out ?? `${args.artifact}.sig`);
}
}
Loading
Loading