9282440143
The product was always more than YouTube — it handles podcast feeds
too, and the upcoming multi-provider work makes it less Gemini-
specific. New name: Recap.
This is a coordinated identity change across:
• StartOS package id: youtube-summarizer → recap
(manifest.id; the .s9pk filename, Docker image namespace, and
install path under StartOS all derive from this automatically)
• Display name: "YouTube Summarizer" → "Recap"
(manifest title, activation screen heading, page <title>, console
log on boot, i18n strings, ABOUT.md, Dockerfile header,
docker_entrypoint banner)
• Keysat product slug: youtube-summarizer → recap
(server/license.js PRODUCT_SLUG; frontend fallback strings)
• Daemon subscription id: youtube-summarizer-sub → recap-sub
• Env var prefix: YT_SUMMARIZER_* → RECAP_*
(LICENSE_KEY, LICENSE_KEY_PATH, MAX_OFFLINE_DAYS,
VALIDATE_INTERVAL_MS)
• localStorage keys: yt-summarizer-* → recap-*
(gemini-key, activation-skipped, clips)
• Library export filename: youtube-summarizer-library.json →
recap-library.json
• npm package names: youtube-summarizer-{startos,server} → recap-*
• Deploy paths: youtube-summarizer_x86_64.s9pk → recap_x86_64.s9pk
(default values in bin/deploy.sh; .deploy.env on dev machine
needs the same update before next push)
• Self-hosted registry directory: startos-registry/packages/
youtube-summarizer → .../recap (with package.json + INSTRUCTIONS
rewritten)
What does NOT change:
• Filesystem repo path (still /Users/.../youtube-summarizer/)
• Git history / commit messages
• Existing version files in startos/versions/ (kept as-is — the
version chain belongs to the package's own history regardless of
its display name)
User-side follow-ups required:
1. Create "recap" product in Keysat admin, set up Core/Pro tier
policies (same entitlements as before), mint a fresh test
license. Old "youtube-summarizer" licenses won't activate
against the new slug.
2. Update .deploy.env (gitignored) so FILEBROWSER_PATH and
REGISTRY_PUBLIC_URL point at recap_x86_64.s9pk.
StartOS will treat this as a brand-new app on install — existing
youtube-summarizer installs will not auto-migrate (acknowledged
intentional given no real users yet).
421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
/**
|
|
* 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., "recap.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`)
|
|
})
|