1258 lines
38 KiB
Plaintext
1258 lines
38 KiB
Plaintext
/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */
|
|
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
|
function bareStdin(ctx) {
|
|
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
|
}
|
|
|
|
/** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */
|
|
function bareFormatModeString(mode, type) {
|
|
const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-'
|
|
const perm = mode & 0o777
|
|
const r = (bit) => (perm & bit ? 'r' : '-')
|
|
const w = (bit) => (perm & bit ? 'w' : '-')
|
|
const x = (bit) => (perm & bit ? 'x' : '-')
|
|
return (
|
|
typeChar +
|
|
r(0o400) +
|
|
w(0o200) +
|
|
x(0o100) +
|
|
r(0o040) +
|
|
w(0o020) +
|
|
x(0o010) +
|
|
r(0o004) +
|
|
w(0o002) +
|
|
x(0o001)
|
|
)
|
|
}
|
|
|
|
/** @param {number} mtimeMs @param {number} [nowMs] */
|
|
function bareFormatLsMtime(mtimeMs, nowMs) {
|
|
const now = nowMs != null ? nowMs : Date.now()
|
|
const d = new Date(mtimeMs)
|
|
const months = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'Jun',
|
|
'Jul',
|
|
'Aug',
|
|
'Sep',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec'
|
|
]
|
|
const mon = months[d.getMonth()]
|
|
const day = String(d.getDate()).padStart(2, ' ')
|
|
const sixMo = 180 * 24 * 3600 * 1000
|
|
if (Math.abs(now - mtimeMs) > sixMo) {
|
|
const yr = String(d.getFullYear()).padStart(4, ' ')
|
|
return mon + ' ' + day + ' ' + yr
|
|
}
|
|
const hh = String(d.getHours()).padStart(2, '0')
|
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
return mon + ' ' + day + ' ' + hh + ':' + mm
|
|
}
|
|
|
|
/** @param {number} size */
|
|
function barePosixBlocks(size) {
|
|
return Math.ceil(Number(size) / 512) || 0
|
|
}
|
|
|
|
/**
|
|
* Raw stdout for NUL/binary when **`process.stdout.write`** is missing.
|
|
* If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string | Uint8Array} chunk
|
|
* @returns {boolean}
|
|
*/
|
|
function bareOsEmitRaw(ctx, chunk) {
|
|
if (typeof ctx.bareOsBinWrite === 'function') {
|
|
const b4 = ctx.b4a
|
|
const u8 =
|
|
typeof chunk === 'string'
|
|
? b4 && typeof b4.from === 'function'
|
|
? b4.from(chunk)
|
|
: new TextEncoder().encode(chunk)
|
|
: chunk
|
|
ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8))
|
|
return true
|
|
}
|
|
const w = globalThis.process?.stdout?.write
|
|
if (typeof w === 'function') {
|
|
w.call(globalThis.process.stdout, chunk)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Session env map (`vfs.env`, then `ctx.env`). Never throws. */
|
|
function bareOsEnv(ctx) {
|
|
const v = ctx && ctx.vfs && ctx.vfs.env
|
|
if (v && typeof v === 'object') return v
|
|
const e = ctx && ctx.env
|
|
if (e && typeof e === 'object') return e
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Strict POSIX-ish decimal integer (no octal, no exponent, no empty).
|
|
* @param {unknown} s
|
|
* @returns {number}
|
|
*/
|
|
function bareOsParseDecInt(s) {
|
|
const t = String(s == null ? '' : s).trim()
|
|
if (!/^[+-]?(?:0|[1-9][0-9]*)$/.test(t)) return NaN
|
|
const n = Number.parseInt(t, 10)
|
|
return Number.isSafeInteger(n) ? n : NaN
|
|
}
|
|
|
|
/** @param {unknown} s */
|
|
function bareOsParseNonNegInt(s) {
|
|
const n = bareOsParseDecInt(s)
|
|
return n >= 0 ? n : NaN
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} name
|
|
* @param {number} fallback
|
|
* @param {number} [min]
|
|
* @param {number} [max]
|
|
*/
|
|
function bareOsEnvInt(ctx, name, fallback, min, max) {
|
|
const raw = bareOsEnv(ctx)[name]
|
|
if (raw == null || raw === '') return fallback
|
|
const n = Number.parseInt(String(raw), 10)
|
|
if (!Number.isFinite(n)) return fallback
|
|
let v = n
|
|
if (min != null && v < min) v = min
|
|
if (max != null && v > max) v = max
|
|
return v
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} msg
|
|
* @param {number} [code]
|
|
*/
|
|
function bareOsFail(ctx, msg, code) {
|
|
if (msg) ctx.console.error(msg)
|
|
ctx.exitCode = code == null ? 1 : code
|
|
}
|
|
|
|
/** @param {unknown} e */
|
|
function bareOsIsNotFoundErr(e) {
|
|
const code = e && typeof e === 'object' ? e.code : ''
|
|
if (code === 'ENOENT') return true
|
|
const msg = String((e && e.message) || e || '')
|
|
return /ENOENT|No such file|not found/i.test(msg)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {unknown} buf
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsToU8(ctx, buf) {
|
|
if (!buf) return new Uint8Array(0)
|
|
if (buf instanceof Uint8Array) return buf
|
|
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(buf)
|
|
return new Uint8Array(buf)
|
|
}
|
|
|
|
/** @param {string} dir @param {string} name */
|
|
function bareOsJoinPath(dir, name) {
|
|
const d = String(dir || '').replace(/\/+$/, '')
|
|
const n = String(name || '').replace(/^\/+/, '')
|
|
if (!d || d === '/') return '/' + n
|
|
return d + '/' + n
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsBaseName(p) {
|
|
const t = String(p || '').replace(/\/+$/, '')
|
|
if (!t || t === '/') return t === '/' ? '/' : ''
|
|
const i = t.lastIndexOf('/')
|
|
return i < 0 ? t : t.slice(i + 1) || t
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsParentDir(p) {
|
|
const t = String(p || '').replace(/\/+$/, '') || '/'
|
|
if (t === '/') return '/'
|
|
const i = t.lastIndexOf('/')
|
|
return i <= 0 ? '/' : t.slice(0, i) || '/'
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsNormPath(p) {
|
|
return String(p || '').replace(/\/+$/, '') || '/'
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} p
|
|
*/
|
|
function bareOsResolvePath(ctx, p) {
|
|
if (ctx && ctx.vfs && typeof ctx.vfs.resolveLogical === 'function') {
|
|
try {
|
|
return String(ctx.vfs.resolveLogical(p) || p)
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return String(p || '')
|
|
}
|
|
|
|
/**
|
|
* True when dest is src or lives under src (self-copy / self-move).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} src
|
|
* @param {string} dest
|
|
*/
|
|
function bareOsDestInsideSrc(ctx, src, dest) {
|
|
const s = bareOsNormPath(bareOsResolvePath(ctx, src))
|
|
const d = bareOsNormPath(bareOsResolvePath(ctx, dest))
|
|
if (s === d) return true
|
|
if (s === '/') return d !== '/'
|
|
return d === s || d.startsWith(s + '/')
|
|
}
|
|
|
|
const BARE_OS_B64_ALPH =
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsB64Encode(u8) {
|
|
let out = ''
|
|
let i = 0
|
|
for (; i + 2 < u8.length; i += 3) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8) | u8[i + 2]
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
BARE_OS_B64_ALPH[n & 63]
|
|
}
|
|
const rest = u8.length - i
|
|
if (rest === 1) {
|
|
const n = u8[i] << 16
|
|
out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + '=='
|
|
} else if (rest === 2) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8)
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
'='
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* RFC 4648 Base64 decode (also accepts URL-safe alphabet). Rejects junk.
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsB64Decode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (!t) return new Uint8Array(0)
|
|
if (t.length % 4 === 1) throw new Error('invalid base64 length')
|
|
let pad = 0
|
|
if (t.endsWith('==')) pad = 2
|
|
else if (t.endsWith('=')) pad = 1
|
|
const body = pad ? t.slice(0, t.length - pad) : t
|
|
const bytes = []
|
|
let buf = 0
|
|
let bits = 0
|
|
for (let i = 0; i < body.length; i++) {
|
|
const c = body[i]
|
|
let v = BARE_OS_B64_ALPH.indexOf(c)
|
|
if (v < 0) {
|
|
if (c === '-') v = 62
|
|
else if (c === '_') v = 63
|
|
else throw new Error('invalid base64 character')
|
|
}
|
|
buf = (buf << 6) | v
|
|
bits += 6
|
|
if (bits >= 8) {
|
|
bits -= 8
|
|
bytes.push((buf >> bits) & 255)
|
|
}
|
|
}
|
|
if (pad) {
|
|
const want = Math.floor((body.length * 6) / 8)
|
|
if (bytes.length > want) bytes.length = want
|
|
}
|
|
return new Uint8Array(bytes)
|
|
}
|
|
|
|
/**
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsHexDecode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (t.length % 2 !== 0) throw new Error('odd hex length')
|
|
const out = new Uint8Array(t.length / 2)
|
|
for (let i = 0; i < out.length; i++) {
|
|
const pair = t.slice(i * 2, i * 2 + 2)
|
|
if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw new Error('invalid hex')
|
|
out[i] = Number.parseInt(pair, 16)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsHexEncode(u8) {
|
|
let s = ''
|
|
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, '0')
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* Guest-side Pear project staging for /bin/pear (VFS-backed).
|
|
* Uses ctx.pear / ctx.bare pack+compile tools when available.
|
|
*/
|
|
|
|
function pearJoinPath(...parts) {
|
|
const raw = parts
|
|
.filter((p) => p != null && String(p) !== '')
|
|
.map((p) => String(p).replace(/\\/g, '/'))
|
|
.join('/')
|
|
.replace(/\/+/g, '/')
|
|
const absolute = raw.startsWith('/')
|
|
const segs = []
|
|
for (const seg of raw.split('/')) {
|
|
if (!seg || seg === '.') continue
|
|
if (seg === '..') {
|
|
if (segs.length) segs.pop()
|
|
continue
|
|
}
|
|
segs.push(seg)
|
|
}
|
|
const out = segs.join('/')
|
|
return absolute ? '/' + out.replace(/^\//, '') : out
|
|
}
|
|
|
|
function pearDirname(p) {
|
|
const s = String(p || '').replace(/\\/g, '/')
|
|
const i = s.lastIndexOf('/')
|
|
if (i <= 0) return s.startsWith('/') ? '/' : '.'
|
|
return s.slice(0, i) || '/'
|
|
}
|
|
|
|
function pearExpandHome(p, home) {
|
|
const s = String(p || '')
|
|
if (s === '~') return home || '/home/guest'
|
|
if (s.startsWith('~/')) return pearJoinPath(home || '/home/guest', s.slice(2))
|
|
return s
|
|
}
|
|
|
|
function pearResolveProjectDir(ctx, target) {
|
|
let cwd = ctx.env?.PWD || '/home/guest'
|
|
if (ctx.vfs && typeof ctx.vfs.getcwd === 'function') {
|
|
try {
|
|
const g = ctx.vfs.getcwd()
|
|
if (g) cwd = String(g)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
const t = String(target || '.').trim() || '.'
|
|
const base = pearExpandHome(t === '.' ? cwd : t, ctx.env?.HOME)
|
|
if (base.startsWith('/')) return pearJoinPath(base)
|
|
return pearJoinPath(cwd, base)
|
|
}
|
|
|
|
function pearPathToFileURL(absPath) {
|
|
const p = pearJoinPath(absPath)
|
|
return new URL(`file://${p}`)
|
|
}
|
|
|
|
async function pearReadUtf8(vfs, b4a, path) {
|
|
const buf = await vfs.readFile(path)
|
|
if (!buf || !buf.byteLength) return null
|
|
if (b4a && typeof b4a.toString === 'function') return b4a.toString(buf, 'utf8')
|
|
return new TextDecoder().decode(buf instanceof Uint8Array ? buf : new Uint8Array(buf))
|
|
}
|
|
|
|
async function pearWriteUtf8(vfs, b4a, path, text) {
|
|
const payload =
|
|
b4a && typeof b4a.from === 'function'
|
|
? b4a.from(String(text), 'utf8')
|
|
: new TextEncoder().encode(String(text))
|
|
if (typeof vfs.mkdir === 'function') {
|
|
await vfs.mkdir(pearDirname(path), { recursive: true }).catch(() => {})
|
|
}
|
|
await vfs.writeFile(path, payload)
|
|
}
|
|
|
|
function pearSkipDir(name) {
|
|
return (
|
|
name === 'node_modules' ||
|
|
name === '.git' ||
|
|
name === '.pear' ||
|
|
name === 'dist' ||
|
|
name === 'coverage'
|
|
)
|
|
}
|
|
|
|
async function pearCollectProjectFiles(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
|
|
if (pearSkipDir(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
|
|
}
|
|
|
|
function pearVfsReadModule(vfs, b4a) {
|
|
return async function readModule(url) {
|
|
const href = typeof url === 'string' ? url : url.href
|
|
let path = href
|
|
if (href.startsWith('file://')) {
|
|
try {
|
|
path = decodeURIComponent(new URL(href).pathname)
|
|
} catch {
|
|
path = href.slice('file://'.length)
|
|
}
|
|
}
|
|
try {
|
|
const buf = await vfs.readFile(path)
|
|
if (!buf || !buf.byteLength) return null
|
|
if (b4a && typeof b4a.toString === 'function') return b4a.toString(buf, 'utf8')
|
|
return new TextDecoder().decode(buf instanceof Uint8Array ? buf : new Uint8Array(buf))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
function pearVfsListPrefix(vfs) {
|
|
return async function* listPrefix(url) {
|
|
const href = typeof url === 'string' ? url : url.href
|
|
let path = href
|
|
if (href.startsWith('file://')) {
|
|
try {
|
|
path = decodeURIComponent(new URL(href).pathname)
|
|
} catch {
|
|
path = href.slice('file://'.length)
|
|
}
|
|
}
|
|
if (!vfs || typeof vfs.readdir !== 'function') return
|
|
/** @type {string[]} */
|
|
const queue = [path]
|
|
while (queue.length) {
|
|
const dir = queue.shift()
|
|
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 yield pearPathToFileURL(abs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stage a Pear app project directory on the guest VFS.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} targetDir
|
|
* @param {{ json?: boolean, quiet?: boolean }} [opts]
|
|
*/
|
|
async function pearStageProject(ctx, targetDir, opts = {}) {
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
const pear = ctx.pear && typeof ctx.pear === 'object' ? ctx.pear : {}
|
|
const bare = ctx.bare && typeof ctx.bare === 'object' ? ctx.bare : {}
|
|
|
|
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
|
throw new Error('pear stage: ctx.vfs read/write unavailable')
|
|
}
|
|
|
|
const projectDir = pearResolveProjectDir(ctx, targetDir)
|
|
let pkgRaw
|
|
try {
|
|
pkgRaw = await pearReadUtf8(vfs, b4a, pearJoinPath(projectDir, 'package.json'))
|
|
} catch (err) {
|
|
throw new Error(
|
|
`pear stage: cannot read ${pearJoinPath(projectDir, 'package.json')}: ${err?.message || err}`
|
|
)
|
|
}
|
|
if (!pkgRaw) {
|
|
throw new Error(`pear stage: missing package.json in ${projectDir}`)
|
|
}
|
|
|
|
let pkg
|
|
try {
|
|
pkg = JSON.parse(pkgRaw)
|
|
} catch {
|
|
throw new Error(`pear stage: invalid package.json in ${projectDir}`)
|
|
}
|
|
|
|
const entryRel = String(pkg.main || 'index.js').replace(/^\.\//, '')
|
|
const entryPath = pearJoinPath(projectDir, entryRel)
|
|
try {
|
|
await vfs.readFile(entryPath)
|
|
} catch {
|
|
throw new Error(`pear stage: entry ${entryRel} not found under ${projectDir}`)
|
|
}
|
|
|
|
const stageDir = pearJoinPath(projectDir, '.pear/stage')
|
|
const sourcesDir = pearJoinPath(stageDir, 'sources')
|
|
|
|
if (typeof vfs.mkdir === 'function') {
|
|
await vfs.mkdir(sourcesDir, { recursive: true })
|
|
}
|
|
|
|
const files = await pearCollectProjectFiles(vfs, projectDir)
|
|
for (const src of files) {
|
|
const rel = src.slice(projectDir.length).replace(/^\//, '')
|
|
const dst = pearJoinPath(sourcesDir, 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)
|
|
}
|
|
|
|
await pearWriteUtf8(vfs, b4a, pearJoinPath(stageDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
|
|
|
|
/** @type {string | null} */
|
|
let bundleJs = null
|
|
let bundleMethod = 'sources-only'
|
|
/** @type {string | null} */
|
|
let bundleError = null
|
|
|
|
const compileFn = pear.bareBundleCompile || bare.bareBundleCompile
|
|
const packFn = bare.barePack
|
|
const BundleCtor = bare.bareBundle
|
|
|
|
if (typeof packFn === 'function') {
|
|
try {
|
|
const entryUrl = pearPathToFileURL(entryPath)
|
|
const bundle = await packFn(
|
|
entryUrl,
|
|
{ preset: 'node' },
|
|
pearVfsReadModule(vfs, b4a),
|
|
pearVfsListPrefix(vfs)
|
|
)
|
|
if (typeof compileFn === 'function') {
|
|
bundleJs = compileFn(bundle)
|
|
bundleMethod = 'bare-pack+bare-bundle-compile'
|
|
}
|
|
} catch (err) {
|
|
bundleError = err?.message || String(err)
|
|
}
|
|
}
|
|
|
|
if (!bundleJs && typeof compileFn === 'function' && typeof BundleCtor === 'function') {
|
|
try {
|
|
const source = await pearReadUtf8(vfs, b4a, entryPath)
|
|
const entryUrl = pearPathToFileURL(entryPath)
|
|
const bundle = new BundleCtor()
|
|
bundle.write(entryUrl.href, source, { main: true })
|
|
bundle.main = entryUrl.href
|
|
bundleJs = compileFn(bundle)
|
|
bundleMethod = 'single-entry+bare-bundle-compile'
|
|
} catch (err) {
|
|
bundleError = err?.message || String(err)
|
|
}
|
|
}
|
|
|
|
if (bundleJs) {
|
|
await pearWriteUtf8(vfs, b4a, pearJoinPath(stageDir, 'app.bundle.js'), bundleJs)
|
|
await pearWriteUtf8(vfs, b4a, pearJoinPath(stageDir, 'boot.bundle.js'), bundleJs)
|
|
}
|
|
|
|
const stageMeta = {
|
|
schema: 1,
|
|
stagedAtMs: Date.now(),
|
|
projectDir,
|
|
stageDir,
|
|
entry: entryRel,
|
|
bundleMethod,
|
|
bundleBytes: bundleJs ? String(bundleJs).length : 0,
|
|
sourceFileCount: files.length,
|
|
pear: pkg.pear && typeof pkg.pear === 'object' ? pkg.pear : null,
|
|
name: pkg.name || null,
|
|
version: pkg.version || null
|
|
}
|
|
if (bundleError && !bundleJs) stageMeta.bundleError = String(bundleError).slice(0, 512)
|
|
|
|
await pearWriteUtf8(
|
|
vfs,
|
|
b4a,
|
|
pearJoinPath(stageDir, 'stage.json'),
|
|
JSON.stringify(stageMeta, null, 2) + '\n'
|
|
)
|
|
|
|
if (typeof ctx.bareOsEmitPearStageHint === 'function') {
|
|
ctx.bareOsEmitPearStageHint({
|
|
stage: stageDir,
|
|
note: `pear stage ${bundleMethod}`
|
|
})
|
|
}
|
|
|
|
return { ok: true, ...stageMeta }
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pear — Pear development tools inside Bare OS.
|
|
*
|
|
* Provides access to the Pear runtime stack (build, bundle, stage, release, seed)
|
|
* from within a booted Bare OS guest via the new ctx.pear surface.
|
|
*
|
|
* See docs/design/ctx-pear-surface-and-bare-audit-plan.md for the full plan and design.
|
|
*/
|
|
|
|
async function run(ctx, argv = []) {
|
|
function printHelp(argv0 = 'pear') {
|
|
ctx.console.log(`${argv0} — Pear development surface for Bare OS
|
|
|
|
Usage:
|
|
${argv0} help
|
|
${argv0} info
|
|
${argv0} list
|
|
${argv0} init [dir]
|
|
${argv0} stage [dir]
|
|
${argv0} build [dir] (alias for stage)
|
|
${argv0} bundle [dir] (alias for stage)
|
|
${argv0} release [dir] [--label <hdms-label>]
|
|
${argv0} seed [dir] [--wait-ms <n>]
|
|
|
|
The ctx.pear surface exposes selected Pear and Bare build/bundling packages
|
|
(pear-build, pear-bundle, bare-bundle-compile, etc.) when BARE_OS_BARE_MODULES
|
|
is enabled.
|
|
|
|
pear stage writes a deployment tree under <project>/.pear/stage/:
|
|
package.json, sources/**, stage.json, and app.bundle.js when packing succeeds.
|
|
|
|
pear release publishes the staged tree to a writable HDMS Hyperdrive and prints
|
|
pear:// links (requires logged-in identity + HDMS). pear seed keeps the release
|
|
drive replicating on Hyperswarm.
|
|
|
|
Use the pear-dev agent skill together with the appstore skill for
|
|
autonomous "build Pear app → publish to my store" workflows.
|
|
`)
|
|
}
|
|
|
|
async function cmdInfo() {
|
|
ctx.console.log('pear — Pear development tools (ctx.pear surface)')
|
|
ctx.console.log('Bare OS version of selected Pear runtime packages.')
|
|
ctx.console.log('')
|
|
|
|
const bareModulesEnabled = !ctx.env || (ctx.env.BARE_OS_BARE_MODULES !== '0' && ctx.env.BARE_OS_BARE_MODULES !== 'false')
|
|
|
|
if (ctx.pear && typeof ctx.pear === 'object') {
|
|
const keys = Object.keys(ctx.pear).filter(k => !k.startsWith('_')).sort()
|
|
if (keys.length > 0) {
|
|
ctx.console.log(`Exposed on ctx.pear (${keys.length} packages):`)
|
|
for (const k of keys) {
|
|
const val = ctx.pear[k]
|
|
const type = typeof val
|
|
const fallback = ctx.pear[`_${k}_fromBare`] ? ' (from ctx.bare fallback)' : ''
|
|
ctx.console.log(` ${k.padEnd(20)} ${type}${fallback}`)
|
|
}
|
|
} else {
|
|
ctx.console.log('ctx.pear exists but is empty.')
|
|
ctx.console.log('')
|
|
if (!bareModulesEnabled) {
|
|
ctx.console.log('Reason: BARE_OS_BARE_MODULES is disabled.')
|
|
} else {
|
|
ctx.console.log('Reason: No Pear packages were successfully imported during boot.')
|
|
ctx.console.log(' This usually means the packages (pear-build, pear-bundle, pear-ref)')
|
|
ctx.console.log(' are not installed in the booter\'s node_modules.')
|
|
ctx.console.log('')
|
|
ctx.console.log('Fix: On the machine running the booter/seeder, run:')
|
|
ctx.console.log(' npm install')
|
|
ctx.console.log(' Then rebuild and restage the image.')
|
|
}
|
|
}
|
|
} else {
|
|
ctx.console.log('ctx.pear property is missing from the guest context.')
|
|
if (!bareModulesEnabled) {
|
|
ctx.console.log('BARE_OS_BARE_MODULES is disabled.')
|
|
}
|
|
}
|
|
|
|
ctx.console.log('')
|
|
ctx.console.log('Run "pear list" for the same view.')
|
|
}
|
|
|
|
async function cmdList() {
|
|
if (!ctx.pear || typeof ctx.pear !== 'object') {
|
|
ctx.console.log('ctx.pear is not available in this environment.')
|
|
ctx.console.log('Run "pear info" for diagnostics.')
|
|
return
|
|
}
|
|
const keys = Object.keys(ctx.pear).filter(k => !k.startsWith('_')).sort()
|
|
if (keys.length === 0) {
|
|
ctx.console.log('ctx.pear exists but no packages loaded.')
|
|
ctx.console.log('Run "pear info" for detailed reasons and fix instructions.')
|
|
return
|
|
}
|
|
ctx.console.log(`ctx.pear — ${keys.length} package(s) available:\n`)
|
|
for (const k of keys) {
|
|
ctx.console.log(` ${k}`)
|
|
}
|
|
}
|
|
|
|
async function cmdInit(targetDir = '.') {
|
|
const name = 'my-pear-app'
|
|
const dir = targetDir === '.' ? `${(ctx.env?.HOME || '/home/guest')}/pear-projects/${name}` : targetDir
|
|
|
|
ctx.console.log(`pear init: creating minimal Pear app skeleton at ${dir}`)
|
|
|
|
try {
|
|
await ctx.vfs.mkdir(dir, { recursive: true })
|
|
const packageJson = {
|
|
name,
|
|
version: '0.1.0',
|
|
main: 'index.js',
|
|
type: 'module',
|
|
pear: {
|
|
name: 'my-pear-app',
|
|
type: 'desktop'
|
|
}
|
|
}
|
|
await ctx.vfs.writeFile(`${dir}/package.json`, ctx.b4a.from(JSON.stringify(packageJson, null, 2)))
|
|
await ctx.vfs.writeFile(`${dir}/index.js`, ctx.b4a.from('console.log("Hello from my Pear app!");\n'))
|
|
ctx.console.log('Created basic package.json + index.js')
|
|
ctx.console.log(`Next: run "pear stage ${dir}"`)
|
|
} catch (err) {
|
|
ctx.console.error('Failed to init:', err?.message || err)
|
|
ctx.exitCode = 1
|
|
}
|
|
}
|
|
|
|
async function cmdStage(target = '.', opts = {}) {
|
|
if (!ctx.pear || typeof ctx.pear !== 'object') {
|
|
ctx.console.error('pear stage: ctx.pear is not available.')
|
|
ctx.console.error('Run "pear info" for diagnostics.')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
|
|
const hasStageTools =
|
|
ctx.pear.bareBundleCompile ||
|
|
ctx.pear.pearBuild ||
|
|
ctx.pear.pearBundle ||
|
|
(ctx.bare && (ctx.bare.barePack || ctx.bare.bareBundle))
|
|
|
|
if (!hasStageTools) {
|
|
ctx.console.error('pear stage: no pack/bundle tools on ctx.pear or ctx.bare.')
|
|
ctx.console.error('Ensure BARE_OS_BARE_MODULES is enabled and the booter was restaged.')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await pearStageProject(ctx, target, opts)
|
|
if (opts.json) {
|
|
ctx.console.log(JSON.stringify(result, null, 2))
|
|
return
|
|
}
|
|
ctx.console.log(`Staged ${result.name || 'project'} → ${result.stageDir}`)
|
|
ctx.console.log(` entry: ${result.entry}`)
|
|
ctx.console.log(` method: ${result.bundleMethod}`)
|
|
ctx.console.log(` sources: ${result.sourceFileCount} file(s)`)
|
|
if (result.bundleBytes > 0) {
|
|
ctx.console.log(` bundle: app.bundle.js (${result.bundleBytes} bytes)`)
|
|
} else if (result.bundleError) {
|
|
ctx.console.warn(` bundle: skipped (${result.bundleError})`)
|
|
ctx.console.warn(' sources mirror is still available under .pear/stage/sources/')
|
|
}
|
|
ctx.console.log('')
|
|
ctx.console.log('Next: pear release (publishes pear:// link via HDMS) or appstore install.')
|
|
} catch (err) {
|
|
ctx.console.error('pear stage failed:', err?.message || String(err))
|
|
ctx.exitCode = 1
|
|
}
|
|
}
|
|
|
|
async function cmdRelease(target = '.', opts = {}) {
|
|
try {
|
|
const result = await pearReleaseProject(ctx, target, opts)
|
|
if (opts.json) {
|
|
ctx.console.log(JSON.stringify(result, null, 2))
|
|
return
|
|
}
|
|
ctx.console.log(`Released ${result.name || 'project'} → ${result.mountRoot}`)
|
|
ctx.console.log(` HDMS label: ${result.label}`)
|
|
ctx.console.log(` files: ${result.fileCount}`)
|
|
ctx.console.log(` length: ${result.length}`)
|
|
ctx.console.log(` link: ${result.pearLink}`)
|
|
ctx.console.log(` versioned: ${result.versionedLink}`)
|
|
ctx.console.log('')
|
|
ctx.console.log('Share the versioned link for pinned installs; run "pear seed" to replicate.')
|
|
} catch (err) {
|
|
ctx.console.error('pear release failed:', err?.message || String(err))
|
|
ctx.exitCode = 1
|
|
}
|
|
}
|
|
|
|
async function cmdSeed(target = '.', opts = {}) {
|
|
try {
|
|
const result = await pearSeedProject(ctx, target, opts)
|
|
if (opts.json) {
|
|
ctx.console.log(JSON.stringify(result, null, 2))
|
|
return
|
|
}
|
|
ctx.console.log(`Seeding ${result.label} at ${result.mountRoot}`)
|
|
if (result.versionedLink) ctx.console.log(` versioned: ${result.versionedLink}`)
|
|
ctx.console.log(` swarm flush: ${result.waitMs}ms (best-effort)`)
|
|
} catch (err) {
|
|
ctx.console.error('pear seed failed:', err?.message || String(err))
|
|
ctx.exitCode = 1
|
|
}
|
|
}
|
|
|
|
function parseReleaseFlags(args) {
|
|
const rest = []
|
|
const opt = { json: false, label: '', waitMs: undefined }
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i]
|
|
if (a === '--json') opt.json = true
|
|
else if (a === '--label' && args[i + 1]) {
|
|
opt.label = args[++i]
|
|
} else if (a.startsWith('--label=')) {
|
|
opt.label = a.slice('--label='.length)
|
|
} else if (a === '--wait-ms' && args[i + 1]) {
|
|
opt.waitMs = Number(args[++i])
|
|
} else if (a.startsWith('--wait-ms=')) {
|
|
opt.waitMs = Number(a.slice('--wait-ms='.length))
|
|
} else rest.push(a)
|
|
}
|
|
return { opt, rest }
|
|
}
|
|
|
|
function parseFlags(args) {
|
|
const rest = []
|
|
const opt = { json: false }
|
|
for (const a of args) {
|
|
if (a === '--json') opt.json = true
|
|
else rest.push(a)
|
|
}
|
|
return { opt, rest }
|
|
}
|
|
|
|
const sub = (argv[1] || 'help').toLowerCase()
|
|
const releaseParsed = parseReleaseFlags(argv.slice(2))
|
|
const { opt, rest } =
|
|
sub === 'release' || sub === 'seed' ? releaseParsed : parseFlags(argv.slice(2))
|
|
const positional = rest[0] || '.'
|
|
|
|
switch (sub) {
|
|
case 'help':
|
|
case '--help':
|
|
case '-h':
|
|
printHelp(argv[0] || 'pear')
|
|
break
|
|
case 'info':
|
|
await cmdInfo()
|
|
break
|
|
case 'list':
|
|
case 'ls':
|
|
await cmdList()
|
|
break
|
|
case 'stage':
|
|
case 'build':
|
|
case 'bundle':
|
|
await cmdStage(positional, opt)
|
|
break
|
|
case 'init':
|
|
await cmdInit(positional === '--json' ? '.' : positional)
|
|
break
|
|
case 'release':
|
|
await cmdRelease(positional, opt)
|
|
break
|
|
case 'seed':
|
|
await cmdSeed(positional, opt)
|
|
break
|
|
default:
|
|
ctx.console.error(`Unknown subcommand: ${sub}`)
|
|
printHelp(argv[0] || 'pear')
|
|
ctx.exitCode = 1
|
|
break
|
|
}
|
|
}
|