Skip to content

Commit a4e8d80

Browse files
authored
Merge pull request #778 from objectstack-ai/copilot/complete-protocol-tasks
2 parents e4cec8e + 0aaf258 commit a4e8d80

10 files changed

Lines changed: 1636 additions & 0 deletions

File tree

ROADMAP.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,9 +701,14 @@ Final polish and advanced features.
701701
- [x] CLI: `os plugin build` — protocol schemas for build options & results (`cli-plugin-commands.zod.ts`)
702702
- [x] CLI: `os plugin validate` — protocol schemas for validation options, findings & results
703703
- [x] CLI: `os plugin publish` — protocol schemas for publish options & results
704+
- [x] CLI: `os plugin build` — command implementation with checksum computation & optional signing (`packages/cli`)
705+
- [x] CLI: `os plugin validate` — command implementation with checksum, signature, and platform checks (`packages/cli`)
706+
- [x] CLI: `os plugin publish` — command implementation with marketplace REST API upload (`packages/cli`)
704707
- [x] Runtime: package dependency resolution & platform compatibility enforcement (`IPackageService` contract)
705708
- [x] Runtime: namespace conflict detection at install time (`IPackageService.checkNamespaces`)
706709
- [x] Runtime: package upgrade lifecycle — plan, snapshot, execute, validate, rollback (`IPackageService` contract)
710+
- [x] Runtime: `NamespaceResolver` — namespace registration, conflict detection, suggestion generation (`@objectstack/core`)
711+
- [x] Runtime: `PackageManager` — install, upgrade, rollback, uninstall with dependency & namespace checks (`@objectstack/core`)
707712
- [x] API: `/api/v1/packages/install` — install with dependency & namespace checks (`package-api.zod.ts`)
708713
- [x] API: `/api/v1/packages/upgrade` — upgrade with plan, rollback support
709714
- [x] API: `/api/v1/packages/resolve-dependencies` — topological sort & conflict detection
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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 manifestChecksum = crypto.createHash(flags.checksumAlgorithm).update(configBuffer).digest('hex');
81+
82+
// 6. Write output
83+
const outDir = path.resolve(process.cwd(), flags.outDir);
84+
if (!fs.existsSync(outDir)) {
85+
fs.mkdirSync(outDir, { recursive: true });
86+
}
87+
const artifactPath = path.join(outDir, packageFileName);
88+
89+
if (!flags.json) printStep('Writing artifact...');
90+
91+
// Write the artifact (serialized config bundle)
92+
fs.writeFileSync(artifactPath, configBuffer);
93+
94+
const artifactSize = configBuffer.length;
95+
96+
// 7. Compute artifact-level checksum
97+
const artifactHash = crypto.createHash('sha256').update(configBuffer).digest('hex');
98+
99+
// 8. Write checksum file alongside artifact
100+
const checksumPath = artifactPath + '.sha256';
101+
fs.writeFileSync(checksumPath, `${artifactHash} ${packageFileName}\n`);
102+
103+
// 9. Handle signing
104+
let signatureInfo: { algorithm: string; keyId: string } | undefined;
105+
if (flags.sign) {
106+
if (!flags.privateKeyPath) {
107+
warnings.push('Signing requested but no --privateKeyPath provided; skipping signature');
108+
} else if (!fs.existsSync(flags.privateKeyPath)) {
109+
warnings.push(`Private key file not found: ${flags.privateKeyPath}; skipping signature`);
110+
} else {
111+
const privateKey = fs.readFileSync(flags.privateKeyPath, 'utf-8');
112+
const signer = crypto.createSign('RSA-SHA256');
113+
signer.update(configBuffer);
114+
const signature = signer.sign(privateKey, 'base64');
115+
const sigPath = artifactPath + '.sig';
116+
fs.writeFileSync(sigPath, signature);
117+
signatureInfo = { algorithm: 'RSA-SHA256', keyId: path.basename(flags.privateKeyPath) };
118+
}
119+
}
120+
121+
const durationMs = timer.elapsed();
122+
123+
// 10. Output result
124+
const result = {
125+
success: true,
126+
artifactPath,
127+
fileCount: fileEntries.length,
128+
size: artifactSize,
129+
durationMs,
130+
warnings: warnings.length > 0 ? warnings : undefined,
131+
artifact: {
132+
name,
133+
version,
134+
format: flags.format,
135+
checksums: {
136+
algorithm: flags.checksumAlgorithm,
137+
manifest: manifestChecksum,
138+
files: { 'manifest.json': manifestChecksum },
139+
},
140+
signature: signatureInfo,
141+
files: fileEntries,
142+
},
143+
};
144+
145+
if (flags.json) {
146+
console.log(JSON.stringify(result, null, 2));
147+
return;
148+
}
149+
150+
// Print summary
151+
console.log('');
152+
printSuccess(`Build complete ${chalk.dim(`(${durationMs}ms)`)}`);
153+
console.log('');
154+
printKV('Artifact', path.relative(process.cwd(), artifactPath));
155+
printKV('Size', `${(artifactSize / 1024).toFixed(1)} KB`);
156+
printKV('Checksum', `sha256:${artifactHash.slice(0, 16)}...`);
157+
158+
if (signatureInfo) {
159+
printKV('Signature', `${signatureInfo.algorithm} (${signatureInfo.keyId})`);
160+
}
161+
162+
// Print metadata counts
163+
const counts = [
164+
stats.objects > 0 && `${stats.objects} objects`,
165+
stats.views > 0 && `${stats.views} views`,
166+
stats.flows > 0 && `${stats.flows} flows`,
167+
stats.pages > 0 && `${stats.pages} pages`,
168+
stats.agents > 0 && `${stats.agents} agents`,
169+
].filter(Boolean);
170+
171+
if (counts.length > 0) {
172+
printKV('Contents', counts.join(', '));
173+
}
174+
175+
for (const w of warnings) {
176+
printWarning(w);
177+
}
178+
179+
console.log('');
180+
console.log(chalk.dim(' Next steps:'));
181+
console.log(chalk.dim(' 1. Validate: os plugin validate dist/' + packageFileName));
182+
console.log(chalk.dim(' 2. Publish: os plugin publish dist/' + packageFileName));
183+
console.log('');
184+
} catch (error: any) {
185+
if (flags.json) {
186+
console.log(JSON.stringify({ success: false, errorMessage: error.message }));
187+
this.exit(1);
188+
}
189+
printError(error.message || String(error));
190+
this.exit(1);
191+
}
192+
}
193+
}
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)