OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / drive-boot / publish-code-drive.mjs

134 lines · javascript ref:
#!/usr/bin/env node
// publish-code-drive.mjs — package the Blindspark JS app code (packages/) into a
// signed Hyperdrive for drive-boot self-updating appliances (docs/DRIVE-BOOT-APPLIANCE.md,
// milestone M1). This is the publisher half; the loader (M2) is the consumer.
//
// v1 boundary (D2): only the PURE-JS app tree ships on the drive. Native addons
// (sodium-native, udx-native, …) and the rest of node_modules stay baked in the thin
// Docker image, so the drive is arch-agnostic and a dependency-graph change still rides
// a (rare) image bump. `.node` / `.bare` prebuilds and test trees are excluded here.
//
// Output: a Hyperdrive in a corestore, carrying every app file plus a signed
// /release-manifest.json + /release-manifest.sig (see release-manifest.mjs). The
// release key is an OFFLINE Ed25519 seed supplied out-of-band:
//
//   DRIVE_BOOT_RELEASE_SEED=<64hex> node scripts/drive-boot/publish-code-drive.mjs \
//     --root . --store /var/lib/blindspark-code-drive --version 0.24.2 [--key <hex>]
//
// Seeding the resulting drive durably onto the fleet is the next step (client.seed /
// POST /seed-core); this script produces + signs the drive and prints its key.

import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import { readdir, readFile, stat, mkdir } from 'node:fs/promises'
import { join, sep } from 'node:path'
import { sha256Hex, signDriveManifest } from './release-manifest.mjs'

// App code that ships on the drive. Kept explicit (not "everything") so the drive
// never accidentally carries node_modules, secrets, or build junk.
export const DEFAULT_INCLUDE = [
  'packages/core',
  'packages/services',
  'packages/client',
  'packages/verifier',
  'dashboard'
]
const EXCLUDE_DIRS = new Set(['node_modules', 'test', '__tests__', 'tests', '.git', 'prebuilds', 'coverage', '.nyc_output'])
const EXCLUDE_EXT = new Set(['.node', '.bare', '.map', '.log', '.tsbuildinfo'])
const MANIFEST_PATH = '/release-manifest.json'
const MANIFEST_SIG_PATH = '/release-manifest.sig'

// Walk `include` dirs under root and return sorted { path (posix drive path), abs,
// content } for every pure-JS app file. Deterministic order → reproducible manifest.
export async function collectCodeFiles (root, { include = DEFAULT_INCLUDE } = {}) {
  const files = []
  async function walk (absDir, driveDir) {
    let entries
    try { entries = await readdir(absDir, { withFileTypes: true }) } catch { return }
    for (const e of entries) {
      if (e.isDirectory()) {
        if (EXCLUDE_DIRS.has(e.name)) continue
        await walk(join(absDir, e.name), driveDir + '/' + e.name)
      } else if (e.isFile()) {
        const dot = e.name.lastIndexOf('.')
        const ext = dot >= 0 ? e.name.slice(dot) : ''
        if (EXCLUDE_EXT.has(ext)) continue
        const abs = join(absDir, e.name)
        files.push({ path: driveDir + '/' + e.name, abs, content: await readFile(abs) })
      }
    }
  }
  for (const inc of include) {
    const absInc = join(root, inc)
    try { if ((await stat(absInc)).isDirectory()) await walk(absInc, '/' + inc.split(sep).join('/')) } catch { /* skip missing */ }
  }
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
  return files
}

// Map the collected files to their sha256 for the manifest `files` field.
export function fileHashMap (files) {
  const map = {}
  for (const f of files) map[f.path] = sha256Hex(f.content)
  return map
}

// Write the app files + the signed manifest into `drive`. Returns { manifest, signature }.
// The manifest+sig are written AFTER the app files and are NOT part of the signed
// `files` map (they'd be self-referential) — exactly like peerit's asset-manifest.json.
export async function writeCodeDrive (drive, { files, version, seedHex, runtime }) {
  for (const f of files) await drive.put(f.path, f.content)
  const driveKey = drive.key.toString('hex')
  const manifest = { files: fileHashMap(files), driveKey: driveKey.toLowerCase(), version, runtime }
  const signature = signDriveManifest(manifest, seedHex)
  await drive.put(MANIFEST_PATH, Buffer.from(JSON.stringify(manifest, null, 2)))
  await drive.put(MANIFEST_SIG_PATH, Buffer.from(JSON.stringify(signature, null, 2)))
  // Puts are durable once resolved; no flush() needed (and hyperdrive@11's flush()
  // routes through a Hyperbee method not present in this version).
  return { manifest, signature, driveKey }
}

function arg (name, dflt) {
  const i = process.argv.indexOf(name)
  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt
}

async function main () {
  const root = arg('--root', process.cwd())
  const storeDir = arg('--store', join(root, '.drive-boot-store'))
  const version = arg('--version') || JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version
  const seedHex = (process.env.DRIVE_BOOT_RELEASE_SEED || '').trim()
  if (!seedHex) { console.error('publish-code-drive: set DRIVE_BOOT_RELEASE_SEED (64 hex) — the OFFLINE release key'); process.exit(1) }
  const runtime = { nodeMajor: String(process.versions.node.split('.')[0]), packagesOnly: true }

  const files = await collectCodeFiles(root)
  if (!files.length) { console.error('publish-code-drive: no app files found under ' + root); process.exit(1) }
  const total = files.reduce((n, f) => n + f.content.length, 0)

  await mkdir(storeDir, { recursive: true })
  const store = new Corestore(storeDir)
  const existingKey = arg('--key')
  const drive = existingKey ? new Hyperdrive(store, Buffer.from(existingKey, 'hex')) : new Hyperdrive(store)
  await drive.ready()

  const { signature, driveKey } = await writeCodeDrive(drive, { files, version, seedHex, runtime })

  console.log('publish-code-drive: signed code drive built')
  console.log('  version:    ' + version)
  console.log('  files:      ' + files.length + ' (' + (total / 1024).toFixed(1) + ' KB pure-JS)')
  console.log('  driveKey:   ' + driveKey)
  console.log('  releaseKey: ' + signature.key + '  (pin this in the loader / store manifest)')
  console.log('  version@:   ' + drive.version)
  console.log('  store:      ' + storeDir)
  console.log('\nNext: seed this drive durably onto the fleet (client.seed / POST /seed-core), then')
  console.log('advance the drive-boot channel pointer to driveKey@' + drive.version + '.')

  await drive.close()
  await store.close()
}

// Only run the CLI when invoked directly, not when imported by the test/loader.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error('publish-code-drive: ' + ((err && err.stack) || err)); process.exit(1) })
}