OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / schema.js

246 lines · javascript ref:
/**
 * 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')
}