/**
* schema — package.json `pgh: {}` section schema + validator.
*
* P.10 Phase 0.5h. Apps declare pgh-relevant metadata under a `pgh`
* object in their standard package.json (no new file). Validated at
* publish time by pgh publish.
*
* Example:
* {
* "name": "pear-git-v3",
* "version": "0.1.0",
* "pgh": {
* "bin": "pear-git",
* "entrypoint": "index.js",
* "upgrade": "pgh://fleeky/pear-git-v3",
* "service": { "port": 4040, "prefix": "/git" },
* "systemd": { "user": true },
* "type": "terminal"
* }
* }
*
* Resolved tree hashes DO NOT live here — those are in the .pgh-lock.json
* lockfile produced by pgh publish. Human-authored metadata in
* package.json, machine-generated hashes in the lockfile.
*/
// All fields optional — schema describes shapes, not required-ness.
// Unknown fields are preserved (forward-compat) but warned.
const KNOWN_FIELDS = new Set([
'bin', // string: name of the launcher script in ~/.local/bin/
'entrypoint', // string: entry file relative to app dir (default 'index.js')
'upgrade', // string: pgh://<publisher>/<name> URL for OTA source
'service', // object: service config (port, prefix, host, env)
'systemd', // object: systemd integration hints
'type', // string: 'terminal' | 'desktop' | 'daemon' (advisory)
'requires', // object: runtime requirements { bare: ">=1.6", node: ">=20" }
'stage', // object: files/patterns to include-only or exclude (ignore list)
'bootstrap', // true | object: auto-build a bootstrap-bare tarball on publish
'runtime', // string: 'node' | 'bare' | 'godot' — materializer + launcher
'category' // string: conflict compartment (default 'production'). Publish
// conflicts are only computed between apps in the SAME category;
// cross-category divergence is expected (e.g. experimental vs
// production during a major-version test). See conflict-policy.js.
])
const SERVICE_FIELDS = new Set(['port', 'prefix', 'host', 'env', 'requiresNetwork'])
const SYSTEMD_FIELDS = new Set(['user', 'unit', 'enable', 'restart'])
/**
* Validate a package.json `pgh` object.
*
* @param {object|undefined} pgh - the pgh section from package.json
* @returns {{ valid: boolean, errors: string[], warnings: string[], normalized: object }}
*/
export function validatePghSection (pgh) {
const errors = []
const warnings = []
if (pgh === undefined || pgh === null) {
// Missing section is fine — pgh publish just won't know about bin/entrypoint.
return { valid: true, errors, warnings, normalized: {} }
}
if (typeof pgh !== 'object' || Array.isArray(pgh)) {
errors.push('pgh: must be an object, got ' + typeof pgh)
return { valid: false, errors, warnings, normalized: {} }
}
const normalized = {}
// Unknown top-level keys — warn, preserve
for (const key of Object.keys(pgh)) {
if (!KNOWN_FIELDS.has(key)) {
warnings.push('pgh.' + key + ': unknown field (preserved but unvalidated)')
}
normalized[key] = pgh[key]
}
// bin
if ('bin' in pgh) {
if (typeof pgh.bin !== 'string' || pgh.bin.length === 0) {
errors.push('pgh.bin: must be a non-empty string (launcher name in ~/.local/bin/)')
} else if (/[\\/\s]/.test(pgh.bin)) {
errors.push('pgh.bin: must not contain slashes or whitespace (got ' + JSON.stringify(pgh.bin) + ')')
}
}
// entrypoint
if ('entrypoint' in pgh) {
if (typeof pgh.entrypoint !== 'string' || pgh.entrypoint.length === 0) {
errors.push('pgh.entrypoint: must be a non-empty string (path relative to app dir)')
} else if (pgh.entrypoint.startsWith('/') || pgh.entrypoint.includes('..')) {
errors.push('pgh.entrypoint: must be a relative path without "..": ' + JSON.stringify(pgh.entrypoint))
}
}
// upgrade
if ('upgrade' in pgh) {
if (typeof pgh.upgrade !== 'string') {
errors.push('pgh.upgrade: must be a string URL')
} else if (!/^(pgh|punch):\/\//.test(pgh.upgrade)) {
errors.push('pgh.upgrade: must start with "pgh://" or "punch://" (got ' + JSON.stringify(pgh.upgrade) + ')')
}
}
// service
if ('service' in pgh) {
if (typeof pgh.service !== 'object' || Array.isArray(pgh.service) || pgh.service === null) {
errors.push('pgh.service: must be an object')
} else {
for (const k of Object.keys(pgh.service)) {
if (!SERVICE_FIELDS.has(k)) {
warnings.push('pgh.service.' + k + ': unknown field')
}
}
if ('port' in pgh.service) {
const p = pgh.service.port
if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) {
errors.push('pgh.service.port: must be an integer in [1, 65535]')
}
}
if ('prefix' in pgh.service) {
if (typeof pgh.service.prefix !== 'string' || !pgh.service.prefix.startsWith('/')) {
errors.push('pgh.service.prefix: must be a string starting with "/"')
}
}
if ('host' in pgh.service && typeof pgh.service.host !== 'string') {
errors.push('pgh.service.host: must be a string')
}
if ('requiresNetwork' in pgh.service && typeof pgh.service.requiresNetwork !== 'boolean') {
errors.push('pgh.service.requiresNetwork: must be boolean')
}
}
}
// systemd
if ('systemd' in pgh) {
if (typeof pgh.systemd !== 'object' || Array.isArray(pgh.systemd) || pgh.systemd === null) {
errors.push('pgh.systemd: must be an object')
} else {
for (const k of Object.keys(pgh.systemd)) {
if (!SYSTEMD_FIELDS.has(k)) {
warnings.push('pgh.systemd.' + k + ': unknown field')
}
}
if ('user' in pgh.systemd && typeof pgh.systemd.user !== 'boolean') {
errors.push('pgh.systemd.user: must be boolean')
}
if ('enable' in pgh.systemd && typeof pgh.systemd.enable !== 'boolean') {
errors.push('pgh.systemd.enable: must be boolean')
}
if ('restart' in pgh.systemd && !['no', 'on-failure', 'always'].includes(pgh.systemd.restart)) {
errors.push('pgh.systemd.restart: must be "no" | "on-failure" | "always"')
}
}
}
// type
if ('type' in pgh) {
const VALID_TYPES = ['terminal', 'desktop', 'daemon']
if (!VALID_TYPES.includes(pgh.type)) {
errors.push('pgh.type: must be one of ' + VALID_TYPES.join(' | ') + ' (got ' + JSON.stringify(pgh.type) + ')')
}
}
// requires
if ('requires' in pgh) {
if (typeof pgh.requires !== 'object' || Array.isArray(pgh.requires) || pgh.requires === null) {
errors.push('pgh.requires: must be an object of { runtime: semverRange }')
}
}
// stage
if ('stage' in pgh) {
if (typeof pgh.stage !== 'object' || Array.isArray(pgh.stage) || pgh.stage === null) {
errors.push('pgh.stage: must be an object')
} else if ('ignore' in pgh.stage && !Array.isArray(pgh.stage.ignore)) {
errors.push('pgh.stage.ignore: must be an array of gitignore-style patterns')
}
}
// bootstrap — true | { targets?: ["<os>-<arch>"], deployDir?: string, deploy?: boolean }
// When set, `pgh publish` auto-builds a bootstrap-bare tarball (and, unless
// deploy:false, copies it into the served bootstrap dir). Keeps the
// curl-installable tarballs from going stale.
if ('bootstrap' in pgh) {
const b = pgh.bootstrap
if (typeof b !== 'boolean' && (typeof b !== 'object' || Array.isArray(b) || b === null)) {
errors.push('pgh.bootstrap: must be true|false or an object { targets?, deployDir?, deploy? }')
} else if (typeof b === 'object') {
if ('targets' in b && (!Array.isArray(b.targets) || b.targets.some(t => typeof t !== 'string' || !/^[a-z0-9]+-[a-z0-9]+$/i.test(t)))) {
errors.push('pgh.bootstrap.targets: must be an array of "<os>-<arch>" strings (e.g. "linux-x64")')
}
if ('deployDir' in b && typeof b.deployDir !== 'string') {
errors.push('pgh.bootstrap.deployDir: must be a string path')
}
if ('deploy' in b && typeof b.deploy !== 'boolean') {
errors.push('pgh.bootstrap.deploy: must be boolean')
}
}
}
return { valid: errors.length === 0, errors, warnings, normalized }
}
/**
* Human-readable summary of a pgh section — used by `pgh publish` output.
*/
export function summarizePghSection (pgh) {
if (!pgh) return '(no pgh section)'
const lines = []
if (pgh.bin) lines.push('bin: ' + pgh.bin)
if (pgh.entrypoint) lines.push('entrypoint: ' + pgh.entrypoint)
if (pgh.upgrade) lines.push('upgrade: ' + pgh.upgrade)
if (pgh.service) {
const bits = []
if (pgh.service.port) bits.push('port=' + pgh.service.port)
if (pgh.service.prefix) bits.push('prefix=' + pgh.service.prefix)
if (pgh.service.host) bits.push('host=' + pgh.service.host)
lines.push('service: ' + bits.join(' '))
}
if (pgh.systemd) lines.push('systemd: ' + JSON.stringify(pgh.systemd))
if (pgh.type) lines.push('type: ' + pgh.type)
if (pgh.bootstrap) lines.push('bootstrap: ' + (pgh.bootstrap === true ? 'on (host arch, auto-deploy)' : JSON.stringify(pgh.bootstrap)))
return lines.length ? lines.join('\n ') : '(empty pgh section)'
}
/**
* Format validation errors / warnings the same "loud" style as 0.5a / 0.5d.
*/
export function formatValidation (result, context = 'package.json') {
const lines = []
if (result.errors.length > 0) {
lines.push('')
lines.push('ERROR: ' + context + ' pgh section has ' + result.errors.length + ' error' + (result.errors.length === 1 ? '' : 's') + ':')
for (const e of result.errors) lines.push(' ' + e)
lines.push('')
}
if (result.warnings.length > 0) {
lines.push('WARNING: ' + context + ' pgh section:')
for (const w of result.warnings) lines.push(' ' + w)
lines.push('')
}
return lines.join('\n')
}