728 lines
23 KiB
JavaScript
728 lines
23 KiB
JavaScript
/**
|
|
* Flying Jib CLI — Bare entry (ADR-0013).
|
|
*
|
|
* Usage:
|
|
* bare bin.mjs create <name>
|
|
* bare bin.mjs list
|
|
* bare bin.mjs start <name> [--port 25565]
|
|
* bare bin.mjs host <name> [--port 25565] # start + share invite
|
|
* bare bin.mjs join <invite> [--port 25565] # client tunnel
|
|
* bare bin.mjs status
|
|
*/
|
|
|
|
import { createRequire } from 'module'
|
|
import { command, flag, arg, summary, header, footer } from 'paparam'
|
|
import path from 'path'
|
|
import process from 'process'
|
|
import os from 'os'
|
|
import pkg from './package.json' with { type: 'json' }
|
|
import App from './app.js'
|
|
|
|
// createRequire needs a file: URL in Node; under bare-pack standalone, import.meta.url
|
|
// may be app.bundle — fall back to a dummy path for require() of CJS shims.
|
|
let require
|
|
try {
|
|
require = createRequire(import.meta.url)
|
|
} catch {
|
|
require = createRequire('file:///flying-jib/bin.mjs')
|
|
}
|
|
|
|
const appName = pkg.productName || pkg.name
|
|
const isBare = typeof Bare !== 'undefined'
|
|
const argv0 = isBare ? Bare.argv[0] : process.argv[0]
|
|
const base = path.basename(argv0)
|
|
const isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0)
|
|
const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2)
|
|
|
|
function defaultTmpDir() {
|
|
try {
|
|
if (typeof os.tmpdir === 'function') return os.tmpdir()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp'
|
|
}
|
|
|
|
function resolveStorage(flagStorage) {
|
|
if (flagStorage) return path.resolve(flagStorage)
|
|
const tmp = defaultTmpDir()
|
|
if (isBare) {
|
|
try {
|
|
const { persistent } = require('bare-storage')
|
|
return isDev ? path.join(tmp, 'pear', appName) : path.join(persistent(), appName)
|
|
} catch {
|
|
return path.join(tmp, 'pear', appName)
|
|
}
|
|
}
|
|
return path.join(tmp, 'pear', appName)
|
|
}
|
|
|
|
function exit(code) {
|
|
if (isBare) Bare.exit(code)
|
|
else process.exit(code)
|
|
}
|
|
|
|
function makeApp(storage, updates = false, displayName, mcUsername) {
|
|
return new App({
|
|
dir: storage,
|
|
appPath: isDev ? null : argv0,
|
|
updates,
|
|
version: pkg.version,
|
|
upgrade: pkg.upgrade,
|
|
name: appName,
|
|
displayName: displayName || 'player',
|
|
mcUsername: mcUsername || displayName || null
|
|
})
|
|
}
|
|
|
|
/** Wire chat/presence logging + optional stdin chat lines for host/join */
|
|
function attachPeerUi(app) {
|
|
app.on('chat', (m) => {
|
|
const tag = m.local ? 'you' : m.from
|
|
console.log(`[chat] <${tag}> ${m.text}`)
|
|
})
|
|
app.on('peer-join', (m) => {
|
|
console.log(`[peers] joined ${m.peerId.slice(0, 12)}…`)
|
|
})
|
|
app.on('peer-leave', (m) => {
|
|
console.log(`[peers] left ${m.peerId.slice(0, 12)}…`)
|
|
})
|
|
app.on('peers', (list) => {
|
|
if (list.length) {
|
|
console.log(`[peers] online: ${list.map((p) => p.name).join(', ')}`)
|
|
}
|
|
})
|
|
app.on('border', (evt) => {
|
|
if (evt.type === 'warn') {
|
|
console.log(
|
|
`[mesh] border warn ${evt.player?.username} → ${evt.neighbor?.name || evt.neighbor?.regionId || 'edge'}`
|
|
)
|
|
}
|
|
})
|
|
app.on('migrate', (evt) => {
|
|
console.log(
|
|
`[mesh] migrate ${evt.player?.username || evt.username} → ${evt.neighbor?.name || evt.neighbor?.regionId}`
|
|
)
|
|
})
|
|
app.on('migrated', (evt) => {
|
|
console.log(`[mesh] tunnel now 127.0.0.1:${evt.localPort} region=${evt.neighbor?.regionId}`)
|
|
})
|
|
app.on('reconnect-hint', (hint) => {
|
|
if (hint && hint.banner) console.log(hint.banner)
|
|
else if (hint && hint.address) {
|
|
console.log(`[mesh] RECONNECT Java Edition → ${hint.address}`)
|
|
}
|
|
})
|
|
app.on('migrate-failed', (evt) => {
|
|
console.log(`[mesh] migrate failed (${evt.kind || 'error'}) restored=${!!evt.restored}`)
|
|
})
|
|
|
|
// Type lines to chat (host/join sessions)
|
|
if (process.stdin && process.stdin.isTTY && typeof process.stdin.on === 'function') {
|
|
try {
|
|
process.stdin.setEncoding('utf8')
|
|
process.stdin.resume()
|
|
let buf = ''
|
|
process.stdin.on('data', (chunk) => {
|
|
buf += chunk
|
|
let i
|
|
while ((i = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, i).replace(/\r$/, '').trim()
|
|
buf = buf.slice(i + 1)
|
|
if (!line) continue
|
|
if (line === '/peers') {
|
|
const st = app.getStatus()
|
|
console.log(
|
|
'[peers]',
|
|
st.peers.length
|
|
? st.peers.map((p) => `${p.name}(${p.id.slice(0, 8)})`).join(', ')
|
|
: '(none)'
|
|
)
|
|
continue
|
|
}
|
|
try {
|
|
app.sendChat(line)
|
|
} catch (err) {
|
|
console.error('[chat]', err.message)
|
|
}
|
|
}
|
|
})
|
|
} catch {
|
|
// non-interactive
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Attach signal handlers once; re-entrant safe via app.exit */
|
|
function attachShutdown(app) {
|
|
let stopping = false
|
|
const shutdown = async (code) => {
|
|
if (stopping) return
|
|
stopping = true
|
|
console.log('\nStopping…')
|
|
try {
|
|
await app.exit(code)
|
|
} catch (err) {
|
|
console.error('[shutdown]', err.message || err)
|
|
}
|
|
exit(code)
|
|
}
|
|
process.on('SIGINT', () => {
|
|
shutdown(130)
|
|
})
|
|
process.on('SIGTERM', () => {
|
|
shutdown(143)
|
|
})
|
|
if (isBare && typeof Bare !== 'undefined' && Bare.on) {
|
|
try {
|
|
Bare.on('exit', () => {
|
|
// best-effort; async exit may not finish
|
|
})
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return shutdown
|
|
}
|
|
|
|
const createCmd = command(
|
|
'create',
|
|
summary('Create a new local world'),
|
|
arg('<name>', 'world name / id'),
|
|
flag('--version [ver]', 'Minecraft version for Flying Squid'),
|
|
flag('--motd [text]', 'server MOTD'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
const meta = app.createWorld({
|
|
name: cmd.args.name,
|
|
version: cmd.flags.version,
|
|
motd: cmd.flags.motd
|
|
})
|
|
console.log('Created world:', meta.id)
|
|
console.log(' version:', meta.version)
|
|
console.log(' path: ', path.join(storage, 'worlds', meta.id))
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
const listCmd = command(
|
|
'list',
|
|
summary('List local worlds'),
|
|
async () => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
const worlds = app.listWorlds()
|
|
if (!worlds.length) {
|
|
console.log('No worlds yet. Create one: flying-jib create <name>')
|
|
return
|
|
}
|
|
for (const w of worlds) {
|
|
console.log(`- ${w.id} (${w.version}) ${w.name}`)
|
|
}
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
const startCmd = command(
|
|
'start',
|
|
summary('Start local Flying Squid (127.0.0.1 only, no P2P share)'),
|
|
arg('<name>', 'world name / id'),
|
|
flag('--port|-p [port]', 'local Minecraft port (default 25565)'),
|
|
flag('--version [ver]', 'override Minecraft version'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
app.on('message', (m) => console.log(m))
|
|
app.on('error', (err) => console.error('[error]', err))
|
|
attachShutdown(app)
|
|
|
|
await app.ready()
|
|
try {
|
|
const status = await app.startWorld({
|
|
world: cmd.args.name,
|
|
port: cmd.flags.port ? Number(cmd.flags.port) : 25565,
|
|
version: cmd.flags.version
|
|
})
|
|
console.log('')
|
|
console.log(`${appName} local world running`)
|
|
console.log(` world: ${cmd.args.name}`)
|
|
console.log(` bind: ${status.host}:${status.port}`)
|
|
console.log('')
|
|
console.log('Connect Java Edition to:')
|
|
console.log(` ${status.host}:${status.port}`)
|
|
console.log('')
|
|
console.log('Press Ctrl+C to stop.')
|
|
await new Promise(() => {})
|
|
} catch (err) {
|
|
console.error('[start failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const hostCmd = command(
|
|
'host',
|
|
summary('Start world and share via HyperDHT (prints fj1. invite)'),
|
|
arg('<name>', 'world name / id'),
|
|
flag('--port|-p [port]', 'local Minecraft port (default 25565)'),
|
|
flag('--version [ver]', 'override Minecraft version'),
|
|
flag('--name|-n [display]', 'chat display name'),
|
|
flag('--mc-name [user]', 'Minecraft username for mesh migrate filter'),
|
|
flag('--mesh', 'create a new mesh and enroll this world'),
|
|
flag('--mesh-invite [invite]', 'open mesh invite and enroll this world'),
|
|
flag('--min-x [n]', 'enroll bounds minX'),
|
|
flag('--max-x [n]', 'enroll bounds maxX'),
|
|
flag('--min-z [n]', 'enroll bounds minZ'),
|
|
flag('--max-z [n]', 'enroll bounds maxZ'),
|
|
flag('--ttl [duration]', 'invite TTL (default 7d; e.g. 24h, 30m)'),
|
|
flag('--no-expire', 'mint invite with no expiry (discouraged)'),
|
|
flag('--role [role]', 'invite role hint: player|viewer (default player)'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false, cmd.flags.name, cmd.flags.mcName)
|
|
app.on('message', (m) => console.log(m))
|
|
app.on('error', (err) => console.error('[error]', err))
|
|
attachShutdown(app)
|
|
attachPeerUi(app)
|
|
|
|
await app.ready()
|
|
try {
|
|
const enroll = {
|
|
minX: cmd.flags.minX,
|
|
maxX: cmd.flags.maxX,
|
|
minZ: cmd.flags.minZ,
|
|
maxZ: cmd.flags.maxZ
|
|
}
|
|
const result = await app.hostWorld({
|
|
world: cmd.args.name,
|
|
port: cmd.flags.port ? Number(cmd.flags.port) : 25565,
|
|
version: cmd.flags.version,
|
|
displayName: cmd.flags.name,
|
|
mcUsername: cmd.flags.mcName,
|
|
createMesh: !!cmd.flags.mesh,
|
|
meshInvite: cmd.flags.meshInvite,
|
|
enroll,
|
|
ttl: cmd.flags.ttl,
|
|
noExpire: !!cmd.flags.noExpire,
|
|
role: cmd.flags.role || 'player'
|
|
})
|
|
console.log('')
|
|
console.log(`${appName} hosting private world (P2P)`)
|
|
console.log(` world: ${cmd.args.name}`)
|
|
console.log(` local: ${result.squid.host}:${result.squid.port}`)
|
|
console.log(` tunnel: HyperDHT host`)
|
|
console.log(` chat: type lines + Enter; /peers for list`)
|
|
if (result.region) {
|
|
console.log(` mesh: enrolled ${result.region.regionId}`)
|
|
console.log(` border: active (kick+migrate signal on leave)`)
|
|
}
|
|
if (result.inviteMeta) {
|
|
console.log(
|
|
` invite: role=${result.inviteMeta.role || 'player'}` +
|
|
(result.inviteMeta.expires
|
|
? ` expires=${new Date(result.inviteMeta.expires).toISOString()}`
|
|
: ' expires=never')
|
|
)
|
|
}
|
|
console.log('')
|
|
console.log('Invite (treat as secret — fj1. capability; share out-of-band only):')
|
|
console.log(result.invite)
|
|
if (result.mesh && result.mesh.invite) {
|
|
console.log('')
|
|
console.log('Mesh invite (also secret):')
|
|
console.log(result.mesh.invite)
|
|
}
|
|
console.log('')
|
|
console.log('Friends run:')
|
|
console.log(` flying-jib join '<invite>' --mc-name <TheirMcName>`)
|
|
console.log('')
|
|
console.log('You connect Java Edition to:')
|
|
console.log(` ${result.squid.host}:${result.squid.port}`)
|
|
console.log('')
|
|
console.log('To invalidate outstanding invites later: flying-jib rotate-cap <world>')
|
|
console.log('Press Ctrl+C to stop.')
|
|
await new Promise(() => {})
|
|
} catch (err) {
|
|
console.error('[host failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const joinCmd = command(
|
|
'join',
|
|
summary('Join a private world from an fj1. invite'),
|
|
arg('<invite>', 'fj1. invite string'),
|
|
flag('--port|-p [port]', 'local bind port (default: ephemeral)'),
|
|
flag('--name|-n [display]', 'chat display name'),
|
|
flag('--mc-name [user]', 'Minecraft username (mesh migrate filter)'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false, cmd.flags.name, cmd.flags.mcName)
|
|
app.on('message', (m) => console.log(m))
|
|
app.on('error', (err) => console.error('[error]', err))
|
|
attachShutdown(app)
|
|
attachPeerUi(app)
|
|
|
|
await app.ready()
|
|
try {
|
|
const result = await app.joinWorld({
|
|
invite: cmd.args.invite,
|
|
localPort: cmd.flags.port ? Number(cmd.flags.port) : 0,
|
|
displayName: cmd.flags.name,
|
|
mcUsername: cmd.flags.mcName
|
|
})
|
|
const port = result.tunnel.localPort
|
|
console.log('')
|
|
console.log(`${appName} joined remote world`)
|
|
console.log(` name: ${result.invite.name || '(unnamed)'}`)
|
|
console.log(` version: ${result.invite.mcVersion || 'unknown'}`)
|
|
console.log(` role: ${result.invite.role || 'player'}`)
|
|
if (result.invite.expires) {
|
|
console.log(` expires: ${new Date(result.invite.expires).toISOString()}`)
|
|
}
|
|
console.log(` local: 127.0.0.1:${port}`)
|
|
console.log(` chat: type lines + Enter; /peers for list`)
|
|
console.log(` migrate: auto if host signals mesh border for --mc-name`)
|
|
console.log('')
|
|
console.log('Connect Java Edition to:')
|
|
console.log(` 127.0.0.1:${port}`)
|
|
console.log('')
|
|
console.log('Press Ctrl+C to stop.')
|
|
await new Promise(() => {})
|
|
} catch (err) {
|
|
console.error('[join failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const statusCmd = command(
|
|
'status',
|
|
summary('Show storage path and worlds'),
|
|
async () => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
console.log('storage:', storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
const worlds = app.listWorlds()
|
|
console.log('worlds:', worlds.length)
|
|
for (const w of worlds) console.log(' -', w.id, w.version)
|
|
const meshes = app.listMeshes()
|
|
console.log('meshes:', meshes.length)
|
|
for (const m of meshes) console.log(' -', m.meshId, m.key)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
const meshCreateCmd = command(
|
|
'mesh-create',
|
|
summary('Create a new mesh and print fj1. mesh invite'),
|
|
flag('--name [name]', 'mesh display name'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
const r = await app.createMesh({ name: cmd.flags.name })
|
|
console.log('Mesh created')
|
|
console.log(' meshId:', r.meshId)
|
|
console.log(' key: ', r.key)
|
|
console.log(' writer:', r.localWriterKey)
|
|
console.log('')
|
|
console.log('Mesh invite (share to join registry):')
|
|
console.log(r.invite)
|
|
console.log('')
|
|
console.log('Keep process alive to seed registry, or re-open later with mesh-open.')
|
|
console.log('Enroll a world: flying-jib mesh-enroll <world> --min-x 0 --max-x 9999 ...')
|
|
// Keep mesh swarm alive until Ctrl+C
|
|
attachShutdown(app)
|
|
await new Promise(() => {})
|
|
} catch (err) {
|
|
console.error('[mesh-create failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const meshOpenCmd = command(
|
|
'mesh-open',
|
|
summary('Open mesh by key or fj1. mesh invite'),
|
|
flag('--key [key]', 'mesh public key (z32)'),
|
|
flag('--invite [invite]', 'fj1. mesh invite'),
|
|
flag('--name [name]', 'local display name for mesh folder'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
const r = await app.openMesh({
|
|
key: cmd.flags.key,
|
|
invite: cmd.flags.invite,
|
|
name: cmd.flags.name
|
|
})
|
|
console.log('Mesh open')
|
|
console.log(' meshId: ', r.meshId)
|
|
console.log(' key: ', r.key)
|
|
console.log(' writable:', r.writable)
|
|
console.log(' writer: ', r.localWriterKey)
|
|
if (!r.writable) {
|
|
console.log('')
|
|
console.log('Not yet a writer. Ask a mesh writer to run:')
|
|
console.log(` flying-jib mesh-admit ${r.localWriterKey}`)
|
|
}
|
|
attachShutdown(app)
|
|
await new Promise(() => {})
|
|
} catch (err) {
|
|
console.error('[mesh-open failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const meshAdmitCmd = command(
|
|
'mesh-admit',
|
|
summary('Admit a peer writer key (requires open writable mesh in this process)'),
|
|
arg('<writerKey>', 'peer local writer key (z32 or hex)'),
|
|
flag('--invite [invite]', 'open mesh from invite first'),
|
|
flag('--key [key]', 'open mesh from key first'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
if (cmd.flags.invite || cmd.flags.key) {
|
|
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
|
|
}
|
|
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
|
|
await app.meshAddWriter(cmd.args.writerKey)
|
|
console.log('Writer admitted:', cmd.args.writerKey)
|
|
await app.stopMesh()
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[mesh-admit failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const meshEnrollCmd = command(
|
|
'mesh-enroll',
|
|
summary('Enroll a local world region into the open mesh'),
|
|
arg('<world>', 'world id to enroll'),
|
|
flag('--invite [invite]', 'mesh invite to open'),
|
|
flag('--key [key]', 'mesh key to open'),
|
|
flag('--min-x [n]', 'bounds minX (default 0)'),
|
|
flag('--max-x [n]', 'bounds maxX (default 9999)'),
|
|
flag('--min-z [n]', 'bounds minZ (default 0)'),
|
|
flag('--max-z [n]', 'bounds maxZ (default 9999)'),
|
|
flag('--offset-x [n]', 'region offset x'),
|
|
flag('--offset-z [n]', 'region offset z'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
if (cmd.flags.invite || cmd.flags.key) {
|
|
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
|
|
}
|
|
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
|
|
const region = await app.enrollWorld({
|
|
world: cmd.args.world,
|
|
minX: cmd.flags.minX,
|
|
maxX: cmd.flags.maxX,
|
|
minZ: cmd.flags.minZ,
|
|
maxZ: cmd.flags.maxZ,
|
|
offsetX: cmd.flags.offsetX,
|
|
offsetZ: cmd.flags.offsetZ
|
|
})
|
|
console.log('Enrolled region:')
|
|
console.log(JSON.stringify(region, null, 2))
|
|
await app.stopMesh()
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[mesh-enroll failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const meshListCmd = command(
|
|
'mesh-list',
|
|
summary('List regions in a mesh'),
|
|
flag('--invite [invite]', 'mesh invite'),
|
|
flag('--key [key]', 'mesh key'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
await app.ready()
|
|
try {
|
|
if (cmd.flags.invite || cmd.flags.key) {
|
|
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
|
|
}
|
|
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
|
|
const regions = await app.listMeshRegions()
|
|
if (!regions.length) {
|
|
console.log('(no regions enrolled yet)')
|
|
} else {
|
|
for (const r of regions) {
|
|
console.log(
|
|
`- ${r.regionId} ${r.name || ''} bounds=${JSON.stringify(r.bounds)} worldKey=${r.worldKey.slice(0, 12)}…`
|
|
)
|
|
}
|
|
}
|
|
await app.stopMesh()
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[mesh-list failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const inviteCmd = command(
|
|
'invite',
|
|
summary('Mint a private-world fj1. invite (default TTL 7d) without starting Squid'),
|
|
arg('<name>', 'world name / id'),
|
|
flag('--ttl [duration]', 'invite TTL (default 7d; e.g. 24h, 30m)'),
|
|
flag('--no-expire', 'no expiry (discouraged)'),
|
|
flag('--role [role]', 'player|viewer (default player)'),
|
|
flag('--port-hint [port]', 'portHint field (default 25565)'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
app.on('message', (m) => console.log(m))
|
|
await app.ready()
|
|
try {
|
|
const result = app.mintInvite({
|
|
world: cmd.args.name,
|
|
ttl: cmd.flags.ttl,
|
|
noExpire: !!cmd.flags.noExpire,
|
|
role: cmd.flags.role || 'player',
|
|
portHint: cmd.flags.portHint ? Number(cmd.flags.portHint) : 25565
|
|
})
|
|
console.log('')
|
|
console.log('Invite (secret — share carefully):')
|
|
console.log(result.invite)
|
|
console.log('')
|
|
console.log(
|
|
`role=${result.inviteMeta.role || 'player'}` +
|
|
(result.inviteMeta.expires
|
|
? ` expires=${new Date(result.inviteMeta.expires).toISOString()}`
|
|
: ' expires=never')
|
|
)
|
|
console.log('Invalidate all invites for this world: flying-jib rotate-cap ' + cmd.args.name)
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[invite failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const rotateCapCmd = command(
|
|
'rotate-cap',
|
|
summary('Rotate tunnel capability — invalidates all outstanding private invites'),
|
|
arg('<name>', 'world name / id'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
app.on('message', (m) => console.log(m))
|
|
await app.ready()
|
|
try {
|
|
const r = await app.rotateWorldCap({ world: cmd.args.name })
|
|
console.log('')
|
|
console.log('Capability rotated. Previous invites no longer work.')
|
|
console.log(` worldKey unchanged (DHT identity kept)`)
|
|
console.log(` revoked fingerprint: ${r.revokedFp.slice(0, 16)}…`)
|
|
console.log('')
|
|
console.log('Mint a new invite:')
|
|
console.log(` flying-jib invite ${cmd.args.name}`)
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[rotate-cap failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const revokeCmd = command(
|
|
'revoke',
|
|
summary('Record a cap/invite fingerprint on the local revoke ledger (audit)'),
|
|
arg('<name>', 'world name / id'),
|
|
arg('<cap-or-invite>', 'z32 cap or full fj1. invite'),
|
|
flag('--reason [text]', 'reason tag'),
|
|
async (cmd) => {
|
|
const storage = resolveStorage(root.flags.storage)
|
|
const app = makeApp(storage, false)
|
|
app.on('message', (m) => console.log(m))
|
|
await app.ready()
|
|
try {
|
|
const r = app.revokeWorldCap({
|
|
world: cmd.args.name,
|
|
capOrInvite: cmd.args.capOrInvite,
|
|
reason: cmd.flags.reason
|
|
})
|
|
console.log(r.already ? 'Already on revoke ledger.' : 'Recorded on revoke ledger.')
|
|
console.log(` fingerprint: ${r.fp}`)
|
|
console.log('Note: live access is invalidated by rotate-cap (new active cap).')
|
|
await app.close()
|
|
} catch (err) {
|
|
console.error('[revoke failed]', err)
|
|
await app.close()
|
|
exit(1)
|
|
}
|
|
}
|
|
)
|
|
|
|
const root = command(
|
|
appName,
|
|
header(`${appName} — decentralized P2P Minecraft (Bare/Pear)`),
|
|
summary(pkg.description),
|
|
flag('--version|-v', 'Print version'),
|
|
flag('--storage <dir>', 'App storage directory'),
|
|
footer('Docs: living_docs/ | agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md'),
|
|
createCmd,
|
|
listCmd,
|
|
startCmd,
|
|
hostCmd,
|
|
joinCmd,
|
|
inviteCmd,
|
|
rotateCapCmd,
|
|
revokeCmd,
|
|
statusCmd,
|
|
meshCreateCmd,
|
|
meshOpenCmd,
|
|
meshAdmitCmd,
|
|
meshEnrollCmd,
|
|
meshListCmd
|
|
)
|
|
|
|
root.parse(rawArgv)
|
|
if (root.flags.version) {
|
|
console.log(`${appName} v${pkg.version}`)
|
|
exit(0)
|
|
}
|