import test from 'brittle'
import { execFile, spawn } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
async function importLoaderWithHome (home) {
const oldHome = process.env.HOME
process.env.HOME = home
try {
const url = pathToFileURL(path.resolve('packages/core/config/loader.js'))
url.searchParams.set('t', `${Date.now()}-${Math.random()}`)
return await import(url.href)
} finally {
if (oldHome === undefined) delete process.env.HOME
else process.env.HOME = oldHome
}
}
test('config loader: saving nested default edits does not mutate defaults away', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-config-loader-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const { loadConfig, saveConfig } = await importLoaderWithHome(home)
const config = loadConfig()
config.subsidy.payoutDestination = 'operator@example.com'
const savedPath = saveConfig(config)
const saved = JSON.parse(await readFile(savedPath, 'utf8'))
t.is(saved.subsidy.payoutDestination, 'operator@example.com')
t.is(loadConfig().subsidy.payoutDestination, 'operator@example.com')
})
test('cli start rejects invalid HIVERELAY_ACCEPT_MODE before boot', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-env-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const res = await execCli(['start'], {
...process.env,
HOME: home,
HIVERELAY_ACCEPT_MODE: 'surprise'
})
t.is(res.code, 1)
t.ok(res.stderr.includes('Invalid HIVERELAY_ACCEPT_MODE'))
})
test('cli start rejects invalid HIVERELAY_MAX_STORAGE before boot', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-env-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const res = await execCli(['start'], {
...process.env,
HOME: home,
HIVERELAY_MAX_STORAGE: 'ten-ish'
})
t.is(res.code, 1)
t.ok(res.stderr.includes('Invalid HIVERELAY_MAX_STORAGE'))
})
test('cli start keeps HIVERELAY_MAX_STORAGE explicit until a cap is persisted', async (t) => {
const freshHome = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-env-fresh-'))
const savedHome = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-env-saved-'))
t.teardown(async () => {
await rm(freshHome, { recursive: true, force: true })
await rm(savedHome, { recursive: true, force: true })
})
const startArgs = ['start', '--no-api', '--no-relay', '--no-seeding', '--quiet']
const fresh = await execCliUntil(startArgs, {
...process.env,
HOME: freshHome,
HIVERELAY_MAX_STORAGE: '10GB'
}, 'Max Store:')
t.ok(fresh.sawNeedle)
t.ok(fresh.stdout.includes('Max Store: 10.0 GB'))
const configDir = path.join(savedHome, '.hiverelay')
await mkdir(configDir, { recursive: true })
await writeFile(path.join(configDir, 'config.json'), JSON.stringify({ maxConnections: 64 }, null, 2) + '\n')
const saved = await execCliUntil(startArgs, {
...process.env,
HOME: savedHome,
HIVERELAY_MAX_STORAGE: '10GB'
}, 'Max Store:')
t.ok(saved.sawNeedle)
t.ok(saved.stdout.includes('Max Store: 10.0 GB'), 'an unrelated config file does not erase the env designation')
await writeFile(path.join(configDir, 'config.json'), JSON.stringify({ maxStorageBytes: 50 * 1024 ** 3 }, null, 2) + '\n')
const persisted = await execCliUntil(startArgs, {
...process.env,
HOME: savedHome,
HIVERELAY_MAX_STORAGE: '10GB'
}, 'Max Store:')
t.ok(persisted.sawNeedle)
t.ok(persisted.stdout.includes('Max Store: 50.0 GB'), 'persisted explicit cap wins over env')
})
test('cli start preserves an explicit --max-storage equal to 50 GiB', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-explicit-default-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const res = await execCliUntil([
'start',
'--max-storage', '50GB',
'--no-api',
'--no-relay',
'--no-seeding',
'--quiet'
], { ...process.env, HOME: home }, 'Max Store:')
t.ok(res.sawNeedle)
t.ok(res.stdout.includes('Max Store: 50.0 GB'))
})
test('cli restart with --no-seeding recovers existing bare-core debt without serving it', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-no-seeding-recovery-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const storage = path.join(home, '.hiverelay', 'storage')
await mkdir(storage, { recursive: true })
await writeFile(path.join(storage, 'seeded-cores.json'), JSON.stringify({
schemaVersion: 3,
cores: [{
key: 'a'.repeat(64),
maxStorageBytes: 1024 * 1024,
state: 'bounded'
}]
}))
const res = await execCliUntil([
'start',
'--no-api',
'--no-relay',
'--no-seeding',
'--quiet'
], { ...process.env, HOME: home }, 'Node is running.')
t.ok(res.sawNeedle, 'core inventory sealed without starting Seeder serving')
t.ok(res.stdout.includes('Seeding: disabled'))
})
test('config loader: explicit-equals-default persists and reloads its provenance', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-config-cap-explicit-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const {
getStorageCapProvenance,
loadConfig,
resolveStorageCap,
saveConfig
} = await importLoaderWithHome(home)
const bytes = 50 * 1024 ** 3
const config = loadConfig({ maxStorageBytes: bytes })
resolveStorageCap(config, {
stat: () => ({ dev: 9, isDirectory: () => true }),
realpath: p => p,
statfs: () => ({ blocks: 100 * 1024 ** 3, bavail: 5 * 1024 ** 3, bsize: 1 }),
measureStorageBytes: () => 0
})
t.is(config.maxStorageBytes, bytes, 'low available space never enlarges or rewrites explicit bytes')
saveConfig(config)
const saved = JSON.parse(await readFile(path.join(home, '.hiverelay', 'config.json'), 'utf8'))
t.is(saved.maxStorageBytes, bytes, 'numeric equality with the default is still persisted')
const restarted = loadConfig()
t.is(restarted.maxStorageBytes, bytes)
t.is(getStorageCapProvenance(restarted).explicit, true)
t.is(getStorageCapProvenance(restarted).source, 'persisted')
})
test('config loader: setup-style plain config persists explicit 50 GiB', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-config-cap-setup-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const { saveConfig } = await importLoaderWithHome(home)
saveConfig({ maxStorageBytes: 50 * 1024 ** 3 })
const saved = JSON.parse(await readFile(path.join(home, '.hiverelay', 'config.json'), 'utf8'))
t.is(saved.maxStorageBytes, 50 * 1024 ** 3)
})
test('config loader: a resolved unset cap is not persisted as an operator designation', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-config-cap-derived-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const { loadConfig, resolveStorageCap, saveConfig } = await importLoaderWithHome(home)
const config = loadConfig()
resolveStorageCap(config, {
stat: () => ({ dev: 10, isDirectory: () => true }),
realpath: p => p,
statfs: () => ({ blocks: 100 * 1024 ** 3, bavail: 18 * 1024 ** 3, bsize: 1 }),
measureStorageBytes: () => 0
})
t.is(config.maxStorageBytes, 8 * 1024 ** 3)
saveConfig(config)
const saved = JSON.parse(await readFile(path.join(home, '.hiverelay', 'config.json'), 'utf8'))
t.absent(Object.prototype.hasOwnProperty.call(saved, 'maxStorageBytes'), 'derived value stays derived after restart')
})
test('cli init persists an explicit cap equal to the built-in default', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-init-cap-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const res = await execCli(['init', '--max-storage', '50GB'], { ...process.env, HOME: home })
t.is(res.code, 0)
const saved = JSON.parse(await readFile(path.join(home, '.hiverelay', 'config.json'), 'utf8'))
t.is(saved.maxStorageBytes, 50 * 1024 ** 3)
})
test('cli start uses HIVERELAY_STORAGE when --storage is absent', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-storage-env-'))
const storage = path.join(home, 'env-storage')
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
await mkdir(storage, { recursive: true })
const res = await execCliUntil(['start', '--no-api', '--no-relay', '--no-seeding', '--quiet'], {
...process.env,
HOME: home,
HIVERELAY_STORAGE: storage
}, 'Storage:')
t.ok(res.sawNeedle)
t.ok(res.stdout.includes(`Storage: ${storage}`))
})
test('cli start --storage overrides HIVERELAY_STORAGE', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-storage-flag-'))
const envStorage = path.join(home, 'env-storage')
const flagStorage = path.join(home, 'flag-storage')
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
await mkdir(envStorage, { recursive: true })
await mkdir(flagStorage, { recursive: true })
const res = await execCliUntil(['start', '--storage', flagStorage, '--no-api', '--no-relay', '--no-seeding', '--quiet'], {
...process.env,
HOME: home,
HIVERELAY_STORAGE: envStorage
}, 'Storage:')
t.ok(res.sawNeedle)
t.ok(res.stdout.includes(`Storage: ${flagStorage}`))
t.absent(res.stdout.includes(`Storage: ${envStorage}`))
})
test('applyOutboxlogNamespaceEnv: env sets config.outboxlog.namespace on a fresh box', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-outboxlog-ns-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const { loadConfig, applyOutboxlogNamespaceEnv } = await importLoaderWithHome(home)
// Fresh box (no persisted config.json) → env is applied.
const cliOverrides = {}
applyOutboxlogNamespaceEnv(cliOverrides, 'peerit', false)
t.is(cliOverrides.outboxlog.namespace, 'peerit', 'env value lands on cliOverrides')
const config = loadConfig(cliOverrides)
t.is(config.outboxlog.namespace, 'peerit', 'loadConfig surfaces it as config.outboxlog.namespace')
})
test('applyOutboxlogNamespaceEnv: unset env leaves config unchanged (default behavior)', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-outboxlog-ns-unset-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const { loadConfig, applyOutboxlogNamespaceEnv } = await importLoaderWithHome(home)
const cliOverrides = {}
applyOutboxlogNamespaceEnv(cliOverrides, undefined, false)
t.absent(cliOverrides.outboxlog, 'no env → no outboxlog override created')
const config = loadConfig(cliOverrides)
// Default config carries no outboxlog.namespace; the engine falls back to
// the app-neutral DEFAULT_OUTBOXLOG_NAMESPACE. Assert we did not invent one.
t.absent(config.outboxlog && config.outboxlog.namespace, 'default config has no forced namespace')
})
test('applyOutboxlogNamespaceEnv: a persisted config.json namespace wins over env (precedence)', async (t) => {
const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-outboxlog-ns-persisted-'))
t.teardown(async () => {
await rm(home, { recursive: true, force: true })
})
const configDir = path.join(home, '.hiverelay')
await mkdir(configDir, { recursive: true })
await writeFile(
path.join(configDir, 'config.json'),
JSON.stringify({ outboxlog: { namespace: 'saved-ns' } }, null, 2) + '\n'
)
const { loadConfig, applyOutboxlogNamespaceEnv } = await importLoaderWithHome(home)
// hasPersistedNamespace === true → env is a no-op, the saved value survives.
const cliOverrides = {}
applyOutboxlogNamespaceEnv(cliOverrides, 'peerit', true)
t.absent(cliOverrides.outboxlog, 'env skipped when a namespace is already persisted')
const config = loadConfig(cliOverrides)
t.is(config.outboxlog.namespace, 'saved-ns', 'persisted namespace wins over the env default')
})
function execCli (argv, env) {
return new Promise((resolve, reject) => {
execFile(process.execPath, ['packages/core/cli/index.js', ...argv], {
cwd: process.cwd(),
env,
timeout: 10000
}, (err, stdout, stderr) => {
if (err && err.killed) return reject(err)
resolve({
code: err && typeof err.code === 'number' ? err.code : 0,
stdout,
stderr
})
})
})
}
function execCliUntil (argv, env, needle, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['packages/core/cli/index.js', ...argv], {
cwd: process.cwd(),
env
})
let stdout = ''
let stderr = ''
let sawNeedle = false
let timedOut = false
let closed = false
let forceTimer = null
const stopChild = () => {
if (!child.killed) child.kill('SIGTERM')
forceTimer = setTimeout(() => {
if (!closed) child.kill('SIGKILL')
}, 1000)
if (forceTimer.unref) forceTimer.unref()
}
const timer = setTimeout(() => {
timedOut = true
stopChild()
}, timeoutMs)
if (timer.unref) timer.unref()
child.stdout.on('data', (chunk) => {
stdout += chunk
if (!sawNeedle && stdout.includes(needle)) {
sawNeedle = true
stopChild()
}
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code, signal) => {
closed = true
clearTimeout(timer)
if (forceTimer) clearTimeout(forceTimer)
const result = { code, signal, stdout, stderr, sawNeedle }
if (timedOut) {
const err = new Error(`Timed out waiting for CLI output: ${needle}`)
Object.assign(err, result)
reject(err)
return
}
resolve(result)
})
})
}