/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/commands/release.mjs * Purpose : `spbgit release upload|list|rm` — binary assets on tags * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createReadStream, statSync, existsSync } from 'node:fs'; import { basename } from 'node:path'; import pc from 'picocolors'; import { requireCliConfig } from '../lib/config.mjs'; import { api, EXIT } from '../lib/api.mjs'; import { printTable } from '../lib/ui.mjs'; function humanBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let value = Number(bytes) || 0; let i = 0; while (value >= 1024 && i < units.length - 1) { value /= 1024; i += 1; } return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; } export function registerRelease(program) { const release = program.command('release').description('manage release assets (dmg, pkg, zip…) attached to tags'); release .command('upload ') .description('upload one or more files as release assets on an existing tag') .action(async (repo, tag, files) => { const config = requireCliConfig(); let failures = 0; for (const file of files) { if (!existsSync(file)) { console.error(`${pc.red('✗')} ${file}: file not found`); failures += 1; continue; } const name = basename(file); const size = statSync(file).size; process.stdout.write(`${pc.dim('↑')} ${name} (${humanBytes(size)})… `); let response; try { response = await fetch( `${config.server}/api/v1/repos/${encodeURIComponent(repo)}/releases/${encodeURIComponent(tag)}/assets/${encodeURIComponent(name)}`, { method: 'PUT', headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), }, body: createReadStream(file), duplex: 'half', }, ); } catch (err) { console.log(pc.red(`network error: ${err.cause?.code ?? err.message}`)); failures += 1; continue; } const body = await response.json().catch(() => null); if (!response.ok) { console.log(pc.red(`failed: ${body?.error?.message ?? `HTTP ${response.status}`}`)); failures += 1; continue; } console.log(pc.green('✓')); console.log(` ${pc.dim('url')} ${body.url}`); console.log(` ${pc.dim('sha256')} ${body.sha256}`); } if (failures > 0) process.exit(EXIT.PARTIAL); }); release .command('list ') .description('list releases and their assets') .option('--json', 'machine-readable output') .action(async (repo, opts) => { const config = requireCliConfig(); const { releases } = await api(config, 'GET', `/api/v1/repos/${encodeURIComponent(repo)}/releases`, undefined, { auth: false }); if (opts.json) { console.log(JSON.stringify(releases, null, 2)); return; } if (releases.length === 0) { console.log(pc.dim('No tags yet. Tag a version first: git tag v1.0.0 && git push --tags')); return; } const rows = [[pc.bold('TAG'), pc.bold('ASSET'), pc.bold('SIZE'), pc.bold('UPLOADED')]]; for (const r of releases) { if (r.assets.length === 0) { rows.push([pc.bold(r.tag), pc.dim('(source archives only)'), '', '']); continue; } r.assets.forEach((a, i) => { rows.push([i === 0 ? pc.bold(r.tag) : '', a.name, humanBytes(a.size), a.uploaded?.slice(0, 10) ?? '']); }); } printTable(rows); }); release .command('rm ') .description('delete a release asset') .action(async (repo, tag, asset) => { const config = requireCliConfig(); await api(config, 'DELETE', `/api/v1/repos/${encodeURIComponent(repo)}/releases/${encodeURIComponent(tag)}/assets/${encodeURIComponent(asset)}`); console.log(`${pc.green('✓')} ${asset} removed from ${repo}@${tag}`); }); }