337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
/**
|
|
* Guest-side Pear release + seed for /bin/pear (VFS + HDMS Hyperdrive).
|
|
* Publishes `.pear/stage/` to a writable HDMS mount and emits pear:// links.
|
|
*/
|
|
|
|
function pearReleaseSanitizeLabel(name) {
|
|
let base = String(name || 'app')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/^-+/, '')
|
|
.replace(/-+$/, '')
|
|
if (!base) base = 'app'
|
|
let label = 'pear-' + base
|
|
if (label.length > 63) label = label.slice(0, 63)
|
|
if (!/^[a-zA-Z0-9]/.test(label)) label = 'pear-' + label.replace(/^[^a-zA-Z0-9]+/, '')
|
|
if (label.length > 63) label = label.slice(0, 63)
|
|
return label
|
|
}
|
|
|
|
const PEAR_RELEASE_Z32_ALPHABET = 'ybndrfg8ejkmcpqxot1uwisza345h769'
|
|
|
|
function pearReleaseZ32Encode(b4a, buf) {
|
|
const key = buf instanceof Uint8Array ? buf : b4a.from(buf)
|
|
if (key.byteLength !== 32) {
|
|
throw new Error('pear release: drive key must be 32 bytes')
|
|
}
|
|
const max = key.byteLength * 8
|
|
let s = ''
|
|
for (let p = 0; p < max; p += 5) {
|
|
const i = p >>> 3
|
|
const j = p & 7
|
|
if (j <= 3) {
|
|
s += PEAR_RELEASE_Z32_ALPHABET[(key[i] >>> (3 - j)) & 0b11111]
|
|
continue
|
|
}
|
|
const of = j - 3
|
|
const h = (key[i] << of) & 0b11111
|
|
const l = (i >= key.byteLength ? 0 : key[i + 1]) >>> (8 - of)
|
|
s += PEAR_RELEASE_Z32_ALPHABET[h | l]
|
|
}
|
|
return s
|
|
}
|
|
|
|
function pearReleaseIdEnc(ctx) {
|
|
const bare = ctx.bare && typeof ctx.bare === 'object' ? ctx.bare : {}
|
|
const enc = bare.hypercoreIdEncoding
|
|
if (enc && typeof enc.encode === 'function' && typeof enc.decode === 'function') {
|
|
return enc
|
|
}
|
|
const pear = ctx.pear && typeof ctx.pear === 'object' ? ctx.pear : {}
|
|
const ref = pear.pearRef
|
|
if (ref && typeof ref.encode === 'function') return ref
|
|
return null
|
|
}
|
|
|
|
function pearReleaseAssertHdms(ctx) {
|
|
const hdms = ctx.disk && ctx.disk.hdmsController
|
|
if (!hdms || !hdms.active) {
|
|
throw new Error(
|
|
'pear release: HDMS inactive — log in (unlock identity vault) so Hyperdrive mounts are available'
|
|
)
|
|
}
|
|
if (ctx.identity?.state !== 'unlocked') {
|
|
throw new Error('pear release: identity must be unlocked (use login)')
|
|
}
|
|
return hdms
|
|
}
|
|
|
|
async function pearReleaseReadJson(vfs, b4a, path) {
|
|
try {
|
|
const buf = await vfs.readFile(path)
|
|
if (!buf || !buf.byteLength) return null
|
|
const text =
|
|
b4a && typeof b4a.toString === 'function'
|
|
? b4a.toString(buf, 'utf8')
|
|
: new TextDecoder().decode(buf instanceof Uint8Array ? buf : new Uint8Array(buf))
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function pearReleaseWriteJson(vfs, b4a, path, obj) {
|
|
const text = JSON.stringify(obj, null, 2) + '\n'
|
|
const payload =
|
|
b4a && typeof b4a.from === 'function'
|
|
? b4a.from(text, 'utf8')
|
|
: new TextEncoder().encode(text)
|
|
if (typeof vfs.mkdir === 'function') {
|
|
await vfs.mkdir(pearDirname(path), { recursive: true }).catch(() => {})
|
|
}
|
|
await vfs.writeFile(path, payload)
|
|
}
|
|
|
|
async function pearReleaseCollectFiles(vfs, root) {
|
|
/** @type {string[]} */
|
|
const out = []
|
|
/** @type {string[]} */
|
|
const queue = [root]
|
|
while (queue.length) {
|
|
const dir = queue.shift()
|
|
if (!vfs || typeof vfs.readdir !== 'function') continue
|
|
let names
|
|
try {
|
|
names = await vfs.readdir(dir)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (!Array.isArray(names)) continue
|
|
for (const name of names) {
|
|
if (!name || name === '.' || name === '..') continue
|
|
const abs = pearJoinPath(dir, name)
|
|
let st
|
|
try {
|
|
st = typeof vfs.stat === 'function' ? await vfs.stat(abs) : null
|
|
} catch {
|
|
st = null
|
|
}
|
|
const isDir = st && (st.isDirectory === true || st.type === 'directory')
|
|
if (isDir) queue.push(abs)
|
|
else out.push(abs)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function pearReleaseMirrorDir(vfs, b4a, srcDir, dstDir) {
|
|
const files = await pearReleaseCollectFiles(vfs, srcDir)
|
|
let copied = 0
|
|
for (const src of files) {
|
|
const rel = src.slice(srcDir.length).replace(/^\//, '')
|
|
const dst = pearJoinPath(dstDir, rel)
|
|
if (typeof vfs.mkdir === 'function') {
|
|
await vfs.mkdir(pearDirname(dst), { recursive: true }).catch(() => {})
|
|
}
|
|
const buf = await vfs.readFile(src)
|
|
await vfs.writeFile(dst, buf)
|
|
copied++
|
|
}
|
|
return copied
|
|
}
|
|
|
|
function pearReleaseDriveKeyZ32(hdms, label, drive, ctx) {
|
|
const slot = hdms.byLabel.get(label)
|
|
if (slot?.entry?.key) return String(slot.entry.key)
|
|
const regEntry = hdms.registry?.drives?.find((d) => d && d.label === label)
|
|
if (regEntry?.key) return String(regEntry.key)
|
|
|
|
const idEnc = pearReleaseIdEnc(ctx)
|
|
if (idEnc && drive?.key) return idEnc.encode(drive.key)
|
|
|
|
const b4a = ctx.b4a
|
|
if (b4a && drive?.key) return pearReleaseZ32Encode(b4a, drive.key)
|
|
|
|
throw new Error('pear release: could not determine drive public key')
|
|
}
|
|
|
|
function pearReleaseEncodeLinks(hdms, label, drive, ctx) {
|
|
if (!drive) throw new Error('pear release: drive unavailable')
|
|
const keyZ32 = pearReleaseDriveKeyZ32(hdms, label, drive, ctx)
|
|
const version = Number(drive.version)
|
|
const length = Number.isFinite(version) && version >= 0 ? Math.floor(version) : 0
|
|
const pearLink = `pear://${keyZ32}`
|
|
const versionedLink = length > 0 ? `pear://0.${length}.${keyZ32}` : pearLink
|
|
return { keyZ32, length, pearLink, versionedLink }
|
|
}
|
|
|
|
async function pearReleaseEnsureDrive(ctx, hdms, label) {
|
|
if (hdms.byLabel.has(label)) {
|
|
const slot = hdms.byLabel.get(label)
|
|
if (!slot?.drive) throw new Error(`pear release: HDMS mount "${label}" has no drive`)
|
|
if (!slot.writable) {
|
|
throw new Error(`pear release: HDMS mount "${label}" is read-only`)
|
|
}
|
|
return { label, drive: slot.drive, created: false }
|
|
}
|
|
await hdms.create(ctx, label)
|
|
const slot = hdms.byLabel.get(label)
|
|
if (!slot?.drive) throw new Error(`pear release: HDMS create failed for "${label}"`)
|
|
return { label, drive: slot.drive, created: true }
|
|
}
|
|
|
|
async function pearReleaseFlushDrive(drive) {
|
|
if (drive && typeof drive.flush === 'function') {
|
|
try {
|
|
await drive.flush()
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
if (drive?.core && typeof drive.core.update === 'function') {
|
|
try {
|
|
await drive.core.update()
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release a staged Pear project to HDMS and return pear:// links.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} targetDir
|
|
* @param {{ json?: boolean, label?: string, quiet?: boolean }} [opts]
|
|
*/
|
|
async function pearReleaseProject(ctx, targetDir, opts = {}) {
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
|
throw new Error('pear release: ctx.vfs read/write unavailable')
|
|
}
|
|
if (!ctx.b4a) {
|
|
throw new Error('pear release: ctx.b4a unavailable')
|
|
}
|
|
|
|
const hdms = pearReleaseAssertHdms(ctx)
|
|
const projectDir = pearResolveProjectDir(ctx, targetDir)
|
|
const stageDir = pearJoinPath(projectDir, '.pear/stage')
|
|
const releaseMetaPath = pearJoinPath(projectDir, '.pear/release.json')
|
|
|
|
const stageMeta = await pearReleaseReadJson(vfs, b4a, pearJoinPath(stageDir, 'stage.json'))
|
|
if (!stageMeta) {
|
|
throw new Error(`pear release: no staged tree — run "pear stage" in ${projectDir} first`)
|
|
}
|
|
|
|
const priorRelease = await pearReleaseReadJson(vfs, b4a, releaseMetaPath)
|
|
const pkgName = String(stageMeta.name || priorRelease?.name || 'app')
|
|
let label = String(opts.label || priorRelease?.label || '').trim()
|
|
if (!label) label = pearReleaseSanitizeLabel(pkgName)
|
|
|
|
const { drive, created } = await pearReleaseEnsureDrive(ctx, hdms, label)
|
|
const mountRoot = pearJoinPath('/mnt', label)
|
|
|
|
const copied = await pearReleaseMirrorDir(vfs, b4a, stageDir, mountRoot)
|
|
await pearReleaseFlushDrive(drive)
|
|
|
|
const links = pearReleaseEncodeLinks(hdms, label, drive, ctx)
|
|
const releaseRecord = {
|
|
schema: 1,
|
|
name: pkgName,
|
|
releasedAtMs: Date.now(),
|
|
projectDir,
|
|
stageDir,
|
|
label,
|
|
mountRoot,
|
|
fileCount: copied,
|
|
driveCreated: created,
|
|
...links
|
|
}
|
|
|
|
const releaseDoc = {
|
|
schema: 1,
|
|
name: pkgName,
|
|
label,
|
|
key: links.keyZ32,
|
|
latest: releaseRecord,
|
|
releases: Array.isArray(priorRelease?.releases) ? priorRelease.releases.slice(-31) : []
|
|
}
|
|
releaseDoc.releases.push(releaseRecord)
|
|
await pearReleaseWriteJson(vfs, b4a, releaseMetaPath, releaseDoc)
|
|
|
|
if (typeof ctx.bareOsEmitPearStageHint === 'function') {
|
|
ctx.bareOsEmitPearStageHint({
|
|
stage: stageDir,
|
|
note: `pear release ${links.versionedLink}`
|
|
})
|
|
}
|
|
|
|
return { ok: true, ...releaseRecord, releaseMetaPath, releaseDoc }
|
|
}
|
|
|
|
/**
|
|
* Keep an released Pear app replicating on the swarm (HDMS already joins on mount).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} targetDir
|
|
* @param {{ json?: boolean, waitMs?: number }} [opts]
|
|
*/
|
|
async function pearSeedProject(ctx, targetDir, opts = {}) {
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
if (!vfs || typeof vfs.readFile !== 'function') {
|
|
throw new Error('pear seed: ctx.vfs unavailable')
|
|
}
|
|
|
|
const hdms = pearReleaseAssertHdms(ctx)
|
|
const projectDir = pearResolveProjectDir(ctx, targetDir)
|
|
const releaseMetaPath = pearJoinPath(projectDir, '.pear/release.json')
|
|
const releaseDoc = await pearReleaseReadJson(vfs, b4a, releaseMetaPath)
|
|
if (!releaseDoc?.latest?.label) {
|
|
throw new Error(`pear seed: no release metadata — run "pear release" in ${projectDir} first`)
|
|
}
|
|
|
|
const label = String(releaseDoc.latest.label)
|
|
let slot = hdms.byLabel.get(label)
|
|
if (!slot?.drive) {
|
|
const entry = hdms.registry?.drives?.find((d) => d && d.label === label)
|
|
if (!entry) {
|
|
throw new Error(`pear seed: HDMS mount "${label}" not found (was it removed?)`)
|
|
}
|
|
await hdms._openEntry(entry)
|
|
slot = hdms.byLabel.get(label)
|
|
}
|
|
if (!slot?.drive) throw new Error(`pear seed: could not open HDMS mount "${label}"`)
|
|
|
|
const drive = slot.drive
|
|
const swarm = hdms.swarm
|
|
if (swarm && drive.discoveryKey) {
|
|
try {
|
|
swarm.join(drive.discoveryKey)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const waitMs = Math.min(120_000, Math.max(0, Number(opts.waitMs) || 8000))
|
|
if (swarm && typeof swarm.flush === 'function' && waitMs > 0) {
|
|
await Promise.race([
|
|
swarm.flush().catch(() => {}),
|
|
new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
])
|
|
}
|
|
|
|
await pearReleaseFlushDrive(drive)
|
|
|
|
const links = pearReleaseEncodeLinks(hdms, label, drive, ctx)
|
|
|
|
return {
|
|
ok: true,
|
|
label,
|
|
mountRoot: pearJoinPath('/mnt', label),
|
|
pearLink: links?.pearLink || releaseDoc.latest.pearLink || null,
|
|
versionedLink: links?.versionedLink || releaseDoc.latest.versionedLink || null,
|
|
length: links?.length ?? releaseDoc.latest.length ?? null,
|
|
waitMs
|
|
}
|
|
}
|