|
| 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 | +} |
0 commit comments