Save in-progress keysat integration and StartOS 0.4 work
Snapshot of the working tree before cleanup. Captures: - Keysat licensing: server/license.js, /api/license/* endpoints in server/index.js, activation modal in public/index.html, embedded Ed25519 issuer key (assets/issuer.pub). - StartOS 0.4 expansion: setApiKey action, version files v0.1.1 through v0.1.15, file-models/config.json.ts, manifest updates. - Self-hosted registry server (startos-registry/). - Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored yt-dlp binary), .gitignore, .deploy.env.example. - Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) — retained here so they remain recoverable when removed in the follow-up cleanup commit.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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/<id>/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:
|
||||
* - <id>.s9pk
|
||||
* - <id>_<arch>.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: { "<pkgId>": "<version>" | 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: { "<version>": "<notes>" }
|
||||
*/
|
||||
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: "<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[]=<id>`)
|
||||
console.log(` GET /package/v0/<id>.s9pk`)
|
||||
console.log(` GET /package/v0/manifest/<id>`)
|
||||
console.log(` GET /package/v0/release-notes/<id>`)
|
||||
console.log(` GET /package/v0/icon/<id>`)
|
||||
console.log(` GET /package/v0/license/<id>`)
|
||||
console.log(` GET /package/v0/instructions/<id>`)
|
||||
console.log(` GET /package/v0/version/<id>`)
|
||||
console.log(`\n Ready.\n`)
|
||||
})
|
||||
Reference in New Issue
Block a user