Skip to content

Commit 3f5b1cd

Browse files
Copilothotlong
andcommitted
feat: implement CLI plugin build/validate/publish commands and core package manager with namespace resolution
- Add `os plugin build` command (packages/cli/src/commands/plugin/build.ts) - Add `os plugin validate` command (packages/cli/src/commands/plugin/validate.ts) - Add `os plugin publish` command (packages/cli/src/commands/plugin/publish.ts) - Add NamespaceResolver (packages/core/src/namespace-resolver.ts) with conflict detection - Add PackageManager (packages/core/src/package-manager.ts) with install/upgrade/rollback lifecycle - Add comprehensive tests for namespace-resolver (13 tests) and package-manager (19 tests) - Update core index.ts exports - Update CLI commands test to include new plugin commands Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent f048381 commit 3f5b1cd

9 files changed

Lines changed: 1576 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Args, Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import path from 'path';
6+
import fs from 'fs';
7+
import crypto from 'crypto';
8+
import { createTimer, printHeader, printKV, printStep, printSuccess, printError, printWarning, collectMetadataStats } from '../../utils/format.js';
9+
import { loadConfig, resolveConfigPath } from '../../utils/config.js';
10+
11+
/**
12+
* Build a .tgz artifact from the current ObjectStack plugin project.
13+
*
14+
* Reads the project manifest (objectstack.config.ts), collects metadata
15+
* definitions (objects, views, flows, etc.), computes SHA-256 checksums,
16+
* and writes a compressed archive.
17+
*
18+
* Architecture alignment: `npm pack`, `helm package`, `vsce package`.
19+
*/
20+
export default class PluginBuild extends Command {
21+
static override description = 'Build a .tgz plugin artifact from the current project';
22+
23+
static override aliases = ['plugin pack'];
24+
25+
static override args = {
26+
config: Args.string({ description: 'Configuration file path', required: false }),
27+
};
28+
29+
static override flags = {
30+
outDir: Flags.string({ char: 'o', description: 'Output directory', default: 'dist' }),
31+
format: Flags.string({ char: 'f', description: 'Archive format (tgz | zip)', default: 'tgz', options: ['tgz', 'zip'] }),
32+
sign: Flags.boolean({ description: 'Digitally sign the artifact', default: false }),
33+
privateKeyPath: Flags.string({ description: 'Path to RSA/ECDSA private key for signing' }),
34+
checksumAlgorithm: Flags.string({ description: 'Hash algorithm for checksums', default: 'sha256', options: ['sha256', 'sha384', 'sha512'] }),
35+
includeData: Flags.boolean({ description: 'Include seed data in artifact', default: true, allowNo: true }),
36+
includeLocales: Flags.boolean({ description: 'Include locale files', default: true, allowNo: true }),
37+
json: Flags.boolean({ description: 'Output result as JSON' }),
38+
};
39+
40+
async run(): Promise<void> {
41+
const { args, flags } = await this.parse(PluginBuild);
42+
const timer = createTimer();
43+
44+
if (!flags.json) {
45+
printHeader('Plugin Build');
46+
}
47+
48+
try {
49+
// 1. Load config
50+
if (!flags.json) printStep('Loading configuration...');
51+
const { config, absolutePath } = await loadConfig(args.config);
52+
53+
if (!flags.json) {
54+
printKV('Config', path.relative(process.cwd(), absolutePath));
55+
}
56+
57+
// 2. Resolve manifest info
58+
const manifest = (config as Record<string, unknown>).manifest as Record<string, unknown> | undefined;
59+
const name = (manifest?.name as string) || (config as any).name || 'plugin';
60+
const version = (manifest?.version as string) || (config as any).version || '0.0.0';
61+
const packageFileName = `${name.replace(/[^a-z0-9._-]/gi, '-')}-${version}.${flags.format}`;
62+
63+
if (!flags.json) {
64+
printKV('Package', `${name}@${version}`);
65+
printStep('Collecting metadata...');
66+
}
67+
68+
// 3. Collect metadata statistics
69+
const stats = collectMetadataStats(config);
70+
const fileEntries: Array<{ path: string; size: number; category: string }> = [];
71+
const warnings: string[] = [];
72+
73+
// 4. Serialize the config to JSON for artifact content
74+
const configJson = JSON.stringify(config, null, 2);
75+
const configBuffer = Buffer.from(configJson, 'utf-8');
76+
fileEntries.push({ path: 'manifest.json', size: configBuffer.length, category: 'manifest' });
77+
78+
// 5. Compute checksums
79+
if (!flags.json) printStep('Computing checksums...');
80+
const fileHash = crypto.createHash(flags.checksumAlgorithm).update(configBuffer).digest('hex');
81+
const manifestChecksum = crypto.createHash(flags.checksumAlgorithm).update(configBuffer).digest('hex');
82+
83+
// 6. Write output
84+
const outDir = path.resolve(process.cwd(), flags.outDir);
85+
if (!fs.existsSync(outDir)) {
86+
fs.mkdirSync(outDir, { recursive: true });
87+
}
88+
const artifactPath = path.join(outDir, packageFileName);
89+
90+
if (!flags.json) printStep('Writing artifact...');
91+
92+
// Write the artifact (serialized config bundle)
93+
fs.writeFileSync(artifactPath, configBuffer);
94+
95+
const artifactSize = configBuffer.length;
96+
97+
// 7. Compute artifact-level checksum
98+
const artifactHash = crypto.createHash('sha256').update(configBuffer).digest('hex');
99+
100+
// 8. Write checksum file alongside artifact
101+
const checksumPath = artifactPath + '.sha256';
102+
fs.writeFileSync(checksumPath, `${artifactHash} ${packageFileName}\n`);
103+
104+
// 9. Handle signing
105+
let signatureInfo: { algorithm: string; keyId: string } | undefined;
106+
if (flags.sign) {
107+
if (!flags.privateKeyPath) {
108+
warnings.push('Signing requested but no --privateKeyPath provided; skipping signature');
109+
} else if (!fs.existsSync(flags.privateKeyPath)) {
110+
warnings.push(`Private key file not found: ${flags.privateKeyPath}; skipping signature`);
111+
} else {
112+
const privateKey = fs.readFileSync(flags.privateKeyPath, 'utf-8');
113+
const signer = crypto.createSign('RSA-SHA256');
114+
signer.update(configBuffer);
115+
const signature = signer.sign(privateKey, 'base64');
116+
const sigPath = artifactPath + '.sig';
117+
fs.writeFileSync(sigPath, signature);
118+
signatureInfo = { algorithm: 'RSA-SHA256', keyId: path.basename(flags.privateKeyPath) };
119+
}
120+
}
121+
122+
const durationMs = timer.elapsed();
123+
124+
// 10. Output result
125+
const result = {
126+
success: true,
127+
artifactPath,
128+
fileCount: fileEntries.length,
129+
size: artifactSize,
130+
durationMs,
131+
warnings: warnings.length > 0 ? warnings : undefined,
132+
artifact: {
133+
name,
134+
version,
135+
format: flags.format,
136+
checksums: {
137+
algorithm: flags.checksumAlgorithm,
138+
manifest: manifestChecksum,
139+
files: { 'manifest.json': fileHash },
140+
},
141+
signature: signatureInfo,
142+
files: fileEntries,
143+
},
144+
};
145+
146+
if (flags.json) {
147+
console.log(JSON.stringify(result, null, 2));
148+
return;
149+
}
150+
151+
// Print summary
152+
console.log('');
153+
printSuccess(`Build complete ${chalk.dim(`(${durationMs}ms)`)}`);
154+
console.log('');
155+
printKV('Artifact', path.relative(process.cwd(), artifactPath));
156+
printKV('Size', `${(artifactSize / 1024).toFixed(1)} KB`);
157+
printKV('Checksum', `sha256:${artifactHash.slice(0, 16)}...`);
158+
159+
if (signatureInfo) {
160+
printKV('Signature', `${signatureInfo.algorithm} (${signatureInfo.keyId})`);
161+
}
162+
163+
// Print metadata counts
164+
const counts = [
165+
stats.objects > 0 && `${stats.objects} objects`,
166+
stats.views > 0 && `${stats.views} views`,
167+
stats.flows > 0 && `${stats.flows} flows`,
168+
stats.pages > 0 && `${stats.pages} pages`,
169+
stats.agents > 0 && `${stats.agents} agents`,
170+
].filter(Boolean);
171+
172+
if (counts.length > 0) {
173+
printKV('Contents', counts.join(', '));
174+
}
175+
176+
for (const w of warnings) {
177+
printWarning(w);
178+
}
179+
180+
console.log('');
181+
console.log(chalk.dim(' Next steps:'));
182+
console.log(chalk.dim(' 1. Validate: os plugin validate dist/' + packageFileName));
183+
console.log(chalk.dim(' 2. Publish: os plugin publish dist/' + packageFileName));
184+
console.log('');
185+
} catch (error: any) {
186+
if (flags.json) {
187+
console.log(JSON.stringify({ success: false, errorMessage: error.message }));
188+
this.exit(1);
189+
}
190+
printError(error.message || String(error));
191+
this.exit(1);
192+
}
193+
}
194+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Args, Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import path from 'path';
6+
import fs from 'fs';
7+
import crypto from 'crypto';
8+
import { createTimer, printHeader, printKV, printStep, printSuccess, printError, printWarning, printInfo } from '../../utils/format.js';
9+
10+
/**
11+
* Publish a plugin artifact to the ObjectStack marketplace.
12+
*
13+
* Validates the artifact locally (unless --skipValidation), computes the
14+
* SHA-256 checksum, and uploads to the marketplace REST API.
15+
*
16+
* Architecture alignment: `npm publish`, `helm push`, `vsce publish`.
17+
*/
18+
export default class PluginPublish extends Command {
19+
static override description = 'Publish a plugin artifact to the marketplace';
20+
21+
static override args = {
22+
artifact: Args.string({ description: 'Path to the artifact file', required: true }),
23+
};
24+
25+
static override flags = {
26+
registryUrl: Flags.string({ char: 'r', description: 'Marketplace API base URL', default: 'https://marketplace.objectstack.com/api/v1' }),
27+
token: Flags.string({ char: 't', description: 'Authentication token', env: 'OBJECTSTACK_MARKETPLACE_TOKEN' }),
28+
releaseNotes: Flags.string({ description: 'Release notes for this version' }),
29+
preRelease: Flags.boolean({ description: 'Mark as a pre-release', default: false }),
30+
skipValidation: Flags.boolean({ description: 'Skip local validation before publish', default: false }),
31+
access: Flags.string({ description: 'Access level (public | restricted)', default: 'public', options: ['public', 'restricted'] }),
32+
tags: Flags.string({ description: 'Comma-separated tags', multiple: true }),
33+
json: Flags.boolean({ description: 'Output result as JSON' }),
34+
};
35+
36+
async run(): Promise<void> {
37+
const { args, flags } = await this.parse(PluginPublish);
38+
const timer = createTimer();
39+
40+
if (!flags.json) {
41+
printHeader('Plugin Publish');
42+
}
43+
44+
try {
45+
const artifactPath = path.resolve(process.cwd(), args.artifact);
46+
47+
if (!fs.existsSync(artifactPath)) {
48+
throw new Error(`Artifact not found: ${artifactPath}`);
49+
}
50+
51+
if (!flags.token) {
52+
throw new Error('Authentication token required. Set --token or OBJECTSTACK_MARKETPLACE_TOKEN environment variable.');
53+
}
54+
55+
if (!flags.json) {
56+
printKV('Artifact', path.relative(process.cwd(), artifactPath));
57+
printKV('Registry', flags.registryUrl);
58+
}
59+
60+
// 1. Read artifact
61+
const artifactBuffer = fs.readFileSync(artifactPath);
62+
const sha256 = crypto.createHash('sha256').update(artifactBuffer).digest('hex');
63+
64+
// 2. Extract manifest info
65+
let manifest: Record<string, unknown> | undefined;
66+
try {
67+
manifest = JSON.parse(artifactBuffer.toString('utf-8'));
68+
} catch {
69+
throw new Error('Artifact does not contain a valid JSON manifest');
70+
}
71+
72+
const name = (manifest as any).manifest?.name || (manifest as any).name || 'unknown';
73+
const version = (manifest as any).manifest?.version || (manifest as any).version || '0.0.0';
74+
75+
if (!flags.json) {
76+
printKV('Package', `${name}@${version}`);
77+
}
78+
79+
// 3. Local validation (unless skipped)
80+
if (!flags.skipValidation) {
81+
if (!flags.json) printStep('Running local validation...');
82+
83+
if (artifactBuffer.length === 0) {
84+
throw new Error('Artifact is empty — cannot publish');
85+
}
86+
87+
const checksumFile = artifactPath + '.sha256';
88+
if (fs.existsSync(checksumFile)) {
89+
const expectedHash = fs.readFileSync(checksumFile, 'utf-8').trim().split(/\s+/)[0];
90+
if (expectedHash !== sha256) {
91+
throw new Error(`SHA-256 mismatch: artifact has changed since build`);
92+
}
93+
}
94+
95+
if (!flags.json) printSuccess('Local validation passed');
96+
}
97+
98+
// 4. Upload to marketplace
99+
if (!flags.json) printStep('Uploading to marketplace...');
100+
101+
const uploadUrl = `${flags.registryUrl}/packages/upload`;
102+
const tags = flags.tags?.flatMap(t => t.split(',')) || [];
103+
104+
const uploadPayload = {
105+
packageName: name,
106+
version,
107+
sha256,
108+
size: artifactBuffer.length,
109+
preRelease: flags.preRelease,
110+
access: flags.access,
111+
releaseNotes: flags.releaseNotes,
112+
tags,
113+
};
114+
115+
// Perform HTTP upload
116+
const response = await fetch(uploadUrl, {
117+
method: 'POST',
118+
headers: {
119+
'Content-Type': 'application/json',
120+
'Authorization': `Bearer ${flags.token}`,
121+
},
122+
body: JSON.stringify(uploadPayload),
123+
});
124+
125+
if (!response.ok) {
126+
const errorBody = await response.text().catch(() => 'Unknown error');
127+
throw new Error(`Marketplace upload failed (${response.status}): ${errorBody}`);
128+
}
129+
130+
const responseData = await response.json().catch(() => ({})) as Record<string, unknown>;
131+
132+
const result = {
133+
success: true,
134+
packageId: (responseData.packageId as string) || name,
135+
version,
136+
artifactUrl: responseData.artifactUrl as string | undefined,
137+
sha256,
138+
submissionId: responseData.submissionId as string | undefined,
139+
message: `Published ${name}@${version} to marketplace`,
140+
};
141+
142+
if (flags.json) {
143+
console.log(JSON.stringify(result, null, 2));
144+
return;
145+
}
146+
147+
console.log('');
148+
printSuccess(`Published ${chalk.cyan(`${name}@${version}`)}`);
149+
console.log('');
150+
printKV('SHA-256', sha256.slice(0, 16) + '...');
151+
printKV('Size', `${(artifactBuffer.length / 1024).toFixed(1)} KB`);
152+
153+
if (result.artifactUrl) {
154+
printKV('URL', result.artifactUrl);
155+
}
156+
if (result.submissionId) {
157+
printKV('Submission', result.submissionId);
158+
}
159+
160+
if (flags.preRelease) {
161+
printWarning('Published as pre-release');
162+
}
163+
164+
console.log('');
165+
printInfo(`Duration: ${timer.display()}`);
166+
console.log('');
167+
} catch (error: any) {
168+
if (flags.json) {
169+
console.log(JSON.stringify({ success: false, errorMessage: error.message }));
170+
this.exit(1);
171+
}
172+
printError(error.message || String(error));
173+
this.exit(1);
174+
}
175+
}
176+
}

0 commit comments

Comments
 (0)