/** * Minimal StartOS Package Registry Server * * Implements the Start9 Marketplace Protocol (Package API) * Reference: https://github.com/Start9Labs/registry/blob/master/marketplace_protocol.md * * Endpoints: * GET /package/v0/info - Registry metadata (name + categories) * GET /package/v0/index - Filtered package listing * GET /package/v0/latest - Latest version per package * GET /package/v0/:s9pk - Download .s9pk binary * GET /package/v0/manifest/:id - Package manifest JSON * GET /package/v0/release-notes/:id * GET /package/v0/icon/:id * GET /package/v0/license/:id * GET /package/v0/instructions/:id * GET /package/v0/version/:id */ const express = require('express') const fs = require('fs') const path = require('path') const crypto = require('crypto') const app = express() const PORT = process.env.PORT || 3030 // --------------------------------------------------------------------------- // Load configuration // --------------------------------------------------------------------------- const REGISTRY_CONFIG = JSON.parse( fs.readFileSync(path.join(__dirname, 'registry.json'), 'utf8'), ) /** * Load all package configs from packages//package.json * Returns a Map of pkgId -> config */ function loadPackages() { const pkgDir = path.join(__dirname, 'packages') const packages = new Map() if (!fs.existsSync(pkgDir)) return packages for (const dir of fs.readdirSync(pkgDir)) { const configPath = path.join(pkgDir, dir, 'package.json') if (!fs.existsSync(configPath)) continue try { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) // Validate required fields if (!config.id || !config.version || !config.title) { console.warn(`[WARN] Skipping ${dir}: missing required fields (id, version, title)`) continue } // Resolve file paths relative to the package directory config._dir = path.join(pkgDir, dir) packages.set(config.id, config) console.log(` Loaded: ${config.id} v${config.version}`) } catch (err) { console.warn(`[WARN] Failed to load ${configPath}: ${err.message}`) } } return packages } let PACKAGES = loadPackages() // Reload packages on SIGHUP (allows updating without restart) process.on('SIGHUP', () => { console.log('[INFO] Received SIGHUP, reloading packages...') PACKAGES = loadPackages() console.log(`[INFO] Loaded ${PACKAGES.size} package(s)`) }) // --------------------------------------------------------------------------- // Helper: Read a file from a package directory, with fallback // --------------------------------------------------------------------------- function readPackageFile(pkg, filename, fallback) { const filepath = path.join(pkg._dir, filename) if (fs.existsSync(filepath)) { return fs.readFileSync(filepath) } return fallback !== undefined ? Buffer.from(fallback) : null } function readPackageFileText(pkg, filename, fallback) { const buf = readPackageFile(pkg, filename, fallback) return buf ? buf.toString('utf8') : fallback || '' } /** * Get the icon as a data URL (base64-encoded PNG). * The /index endpoint requires this format. */ function getIconDataUrl(pkg) { const iconPath = path.join(pkg._dir, 'icon.png') if (!fs.existsSync(iconPath)) return null const data = fs.readFileSync(iconPath) return `data:image/png;base64,${data.toString('base64')}` } /** * Find the .s9pk file for a package. Supports multiple naming conventions: * - .s9pk * - _.s9pk * - Any .s9pk file in the directory * * If spec/version-priority query params are provided, we ignore them for now * (single-version-per-package simplification). */ function findS9pk(pkg, preferredArch) { const dir = pkg._dir const candidates = fs.readdirSync(dir).filter((f) => f.endsWith('.s9pk')) if (candidates.length === 0) return null // Prefer arch-specific if requested if (preferredArch) { const archSpecific = candidates.find((f) => f.includes(`_${preferredArch}`)) if (archSpecific) return path.join(dir, archSpecific) } // Prefer universal (no arch suffix), then first available const universal = candidates.find((f) => f === `${pkg.id}.s9pk`) if (universal) return path.join(dir, universal) return path.join(dir, candidates[0]) } /** * Build the manifest object for a package. * This is what the StartOS client expects from /manifest/:id */ function buildManifest(pkg) { return { id: pkg.id, title: pkg.title, version: pkg.version, description: { short: pkg.descriptionShort || '', long: pkg.descriptionLong || '', }, license: pkg.license || 'MIT', 'release-notes': pkg.releaseNotes || {}, 'upstream-repo': pkg.upstreamRepo || '', 'package-repo': pkg.packageRepo || '', 'marketing-url': pkg.marketingUrl || '', 'donation-url': pkg.donationUrl || null, images: pkg.images || {}, dependencies: pkg.dependencies || {}, alerts: pkg.alerts || {}, } } // --------------------------------------------------------------------------- // Routes: Package API v0 // --------------------------------------------------------------------------- const router = express.Router() /** * GET /info * Returns: { name: string, categories: string[] } */ router.get('/info', (req, res) => { res.json({ name: REGISTRY_CONFIG.name, categories: REGISTRY_CONFIG.categories, }) }) /** * GET /index * Query params (all optional for our purposes): * ids[] - filter to specific package IDs * category - filter by category * page - pagination (default 1) * per-page - items per page (default 20) * os.compat - OS version compatibility * hardware.* - hardware filters * * Returns: Array of package objects */ router.get('/index', (req, res) => { let packages = Array.from(PACKAGES.values()) // Filter by IDs if specified const ids = req.query['ids[]'] || req.query.ids if (ids) { const idList = Array.isArray(ids) ? ids : [ids] packages = packages.filter((p) => idList.includes(p.id)) } // Filter by category if specified const category = req.query.category if (category) { packages = packages.filter( (p) => p.categories && p.categories.includes(category), ) } // Pagination const page = parseInt(req.query.page) || 1 const perPage = parseInt(req.query['per-page']) || 20 const start = (page - 1) * perPage const paged = packages.slice(start, start + perPage) // Build response per marketplace protocol const result = paged.map((pkg) => ({ icon: getIconDataUrl(pkg), license: readPackageFileText(pkg, 'LICENSE', pkg.license || 'MIT'), instructions: readPackageFileText(pkg, 'INSTRUCTIONS.md', ''), manifest: buildManifest(pkg), categories: pkg.categories || [], versions: [pkg.version], 'dependency-metadata': pkg.dependencyMetadata || {}, })) res.json(result) }) /** * GET /latest * Query params: * ids[] - package IDs to check * * Returns: { "": "" | null } */ router.get('/latest', (req, res) => { const ids = req.query['ids[]'] || req.query.ids if (!ids) return res.json({}) const idList = Array.isArray(ids) ? ids : [ids] const result = {} for (const id of idList) { const pkg = PACKAGES.get(id) result[id] = pkg ? pkg.version : null } res.json(result) }) /** * GET /:s9pk * Downloads the .s9pk binary file. * The :s9pk param is the filename (e.g., "youtube-summarizer.s9pk") * * Optional query params: * spec - version range (ignored for single-version packages) * version-priority - "min" or "max" (ignored for single-version packages) */ router.get('/:s9pk', (req, res, next) => { const filename = req.params.s9pk if (!filename.endsWith('.s9pk')) return next() // Extract package ID from filename (remove .s9pk and optional _arch suffix) const base = filename.replace('.s9pk', '') const pkgId = base.replace(/_(?:x86_64|aarch64|riscv64)$/, '') const pkg = PACKAGES.get(pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) // Find the actual .s9pk file const arch = base.includes('_') ? base.split('_').pop() : null const s9pkPath = findS9pk(pkg, arch) if (!s9pkPath || !fs.existsSync(s9pkPath)) { return res.status(404).json({ error: 's9pk file not found' }) } const stat = fs.statSync(s9pkPath) res.setHeader('Content-Type', 'application/octet-stream') res.setHeader('Content-Length', stat.size) res.setHeader( 'Content-Disposition', `attachment; filename="${path.basename(s9pkPath)}"`, ) const stream = fs.createReadStream(s9pkPath) stream.pipe(res) }) /** * GET /manifest/:pkgId * Returns the package manifest as JSON. */ router.get('/manifest/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) res.json(buildManifest(pkg)) }) /** * GET /release-notes/:pkgId * Returns: { "": "" } */ router.get('/release-notes/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) res.json(pkg.releaseNotes || {}) }) /** * GET /icon/:pkgId * Returns the package icon as a binary PNG. */ router.get('/icon/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) const iconData = readPackageFile(pkg, 'icon.png') if (!iconData) return res.status(404).json({ error: 'Icon not found' }) res.setHeader('Content-Type', 'image/png') res.setHeader('Content-Length', iconData.length) res.send(iconData) }) /** * GET /license/:pkgId * Returns the license text. */ router.get('/license/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) const text = readPackageFileText(pkg, 'LICENSE', pkg.license || 'MIT') res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Length', Buffer.byteLength(text)) res.send(text) }) /** * GET /instructions/:pkgId * Returns the instructions markdown. */ router.get('/instructions/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) const text = readPackageFileText(pkg, 'INSTRUCTIONS.md', '') res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Length', Buffer.byteLength(text)) res.send(text) }) /** * GET /version/:pkgId * Returns: { version: "" } */ router.get('/version/:pkgId', (req, res) => { const pkg = PACKAGES.get(req.params.pkgId) if (!pkg) return res.status(404).json({ error: 'Package not found' }) res.json({ version: pkg.version }) }) // Mount at /package/v0 (the marketplace protocol path) app.use('/package/v0', router) // Also mount at /package/v1 for forward compatibility // (StartOS 0.4.0 may try v1 first, then fall back to v0) app.use('/package/v1', router) // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', packages: PACKAGES.size, uptime: process.uptime(), }) }) // Root - basic info app.get('/', (req, res) => { res.json({ name: REGISTRY_CONFIG.name, packages: PACKAGES.size, protocol: 'Start9 Marketplace Protocol', endpoints: { info: '/package/v0/info', index: '/package/v0/index', latest: '/package/v0/latest', health: '/health', }, }) }) // --------------------------------------------------------------------------- // Start server // --------------------------------------------------------------------------- app.listen(PORT, () => { console.log(`\n StartOS Registry Server`) console.log(` =======================`) console.log(` Name: ${REGISTRY_CONFIG.name}`) console.log(` Port: ${PORT}`) console.log(` Packages: ${PACKAGES.size}`) PACKAGES.forEach((pkg) => { console.log(` - ${pkg.id} v${pkg.version}`) }) console.log(`\n Endpoints:`) console.log(` GET /package/v0/info`) console.log(` GET /package/v0/index`) console.log(` GET /package/v0/latest?ids[]=`) console.log(` GET /package/v0/.s9pk`) console.log(` GET /package/v0/manifest/`) console.log(` GET /package/v0/release-notes/`) console.log(` GET /package/v0/icon/`) console.log(` GET /package/v0/license/`) console.log(` GET /package/v0/instructions/`) console.log(` GET /package/v0/version/`) console.log(`\n Ready.\n`) })