feat: stage 0, an empty RunicNPC that releases through CI
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
The repository RunicNPC is built in (docs/runicnpc/PLAN.md §9, stage 0): - plugin/RunicNPC.cs: `// Requires: Kits` (D217), `[Info]` with the 0.0.0 placeholder the release stamps, `RunicNpc_ApiVersion()` (API 1), and `rnpc.status`, which reports the version and which hooks have fired. It spawns nothing. - plugin.toml: the API version, the framework floors it was loaded on (Oxide 2.0.7726, Carbon 2.0.259) and requires_plugins = ["Kits"]. - scripts/checkPlugin.js, adapted from Rust-Plugins': every hook listed and void unless written down; chat-command signatures; every RunicNpc_ call reachable by Call (the HumanNPC trap, PLAN.md §1.2); ApiVersion, `// Requires:` and [Info] agreeing with plugin.toml. 23 self-tests, including the real plugin and a CRLF checkout. - PR Checks on PRs into main and edge; the release workflow on main, with Rust-Plugins' release engine unchanged and an adapter that ships runicnpc-<ver>.tar.gz (runicnpc/RunicNPC.cs + manifest.json) and SHA256SUMS. No bundle dispatch until stage 4. - tools/: the rig panel scripts, with the panel and server ids moved into a git-ignored tools/rigs.json. `con.js` became `console.js`: CON is a reserved device name on Windows, and git there cannot open the file. - README, CONTRIBUTING (edge-based flow, AI disclosure, borrow-not-copy), SECURITY, the code of conduct, issue and PR templates. `feat:` so the cutover to main cuts the first release, 0.1.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
60
tools/console.js
Normal file
60
tools/console.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Runs one console command on a rig through the panel's websocket and prints the console lines
|
||||
// that follow — the way to read a command's reply, which rig.js's `cmd` cannot.
|
||||
//
|
||||
// node tools/console.js <rig> "<command>" [seconds=6] [filter]
|
||||
//
|
||||
// node tools/console.js oxide "oxide.plugins" 6 RunicNPC
|
||||
// node tools/console.js carbon "rnpc.status"
|
||||
//
|
||||
// Needs Node 22+ for the global WebSocket.
|
||||
|
||||
const { load, serverId, unmsys } = require('./panel')
|
||||
|
||||
const config = load()
|
||||
const [rig, command, secs = '6', filter] = process.argv.slice(2).map(unmsys)
|
||||
const strip = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
||||
|
||||
async function main() {
|
||||
if (!command) {
|
||||
console.error('usage: node tools/console.js <rig> "<command>" [seconds=6] [filter]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const r = await fetch(`${config.panel}/api/client/servers/${serverId(config, rig)}/websocket`, {
|
||||
headers: { Authorization: 'Bearer ' + config.key, Accept: 'application/json' },
|
||||
})
|
||||
if (!r.ok) {
|
||||
console.error(r.status, await r.text())
|
||||
process.exit(1)
|
||||
}
|
||||
const { data } = await r.json()
|
||||
|
||||
const ws = new WebSocket(data.socket, { headers: { Origin: config.panel } })
|
||||
let sent = false
|
||||
|
||||
ws.onopen = () => ws.send(JSON.stringify({ event: 'auth', args: [data.token] }))
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data)
|
||||
if (msg.event === 'auth success' && !sent) {
|
||||
sent = true
|
||||
// The panel replays recent history on connect; let it pass, then send.
|
||||
setTimeout(() => ws.send(JSON.stringify({ event: 'send command', args: [command] })), 800)
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
process.exit(0)
|
||||
}, Number(secs) * 1000)
|
||||
} else if (msg.event === 'console output' && sent) {
|
||||
for (const line of strip(String(msg.args[0])).split('\n')) {
|
||||
if (!filter || line.toLowerCase().includes(filter.toLowerCase())) console.log(line)
|
||||
}
|
||||
} else if (msg.event === 'jwt error' || msg.event === 'token expired') {
|
||||
console.error('ws:', msg.event, msg.args)
|
||||
}
|
||||
}
|
||||
ws.onerror = (e) => console.error('ws error', e.message || e)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message || e)
|
||||
process.exit(1)
|
||||
})
|
||||
52
tools/panel.js
Normal file
52
tools/panel.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// The Pterodactyl panel the test rigs run on, shared by rig.js and console.js.
|
||||
//
|
||||
// Nothing here is shipped: tools/ is developer scaffolding (docs/runicnpc/PLAN.md §9, stage 0).
|
||||
// Where the panel is and which servers are the rigs is local knowledge, so it lives in
|
||||
// tools/rigs.json (git-ignored; copy tools/rigs.example.json). The panel's client API key is read
|
||||
// at call time from the file rigs.json names, and is never printed.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const CONFIG = path.join(__dirname, 'rigs.json')
|
||||
|
||||
function load() {
|
||||
if (!fs.existsSync(CONFIG)) {
|
||||
console.error(`No ${CONFIG}. Copy tools/rigs.example.json to tools/rigs.json and fill it in.`)
|
||||
process.exit(2)
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG, 'utf8'))
|
||||
|
||||
// The key file holds `name: value` lines; the panel's client key is `user`.
|
||||
const keys = Object.fromEntries(
|
||||
fs
|
||||
.readFileSync(config.tokenFile, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.includes(':'))
|
||||
.map((l) => [l.slice(0, l.indexOf(':')).trim(), l.slice(l.indexOf(':') + 1).trim()])
|
||||
)
|
||||
if (!keys.user) {
|
||||
console.error(`${config.tokenFile} has no "user:" line (the panel's client API key).`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
return { panel: config.panel.replace(/\/+$/, ''), rigs: config.rigs, key: keys.user }
|
||||
}
|
||||
|
||||
/** The server id of a rig by name, or exit with the names there are. */
|
||||
function serverId(config, rig) {
|
||||
const id = config.rigs[rig]
|
||||
if (!id) {
|
||||
console.error(`Unknown rig "${rig}". Known: ${Object.keys(config.rigs).join(', ')}`)
|
||||
process.exit(2)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Git Bash (MSYS) rewrites an argument starting with / into C:/Program Files/Git/...; undo it, so
|
||||
// a mangled path never reaches the server. On 2026-09-29 every rig file call failed this way and
|
||||
// looked like an outage of the panel's file API.
|
||||
const unmsys = (x) =>
|
||||
typeof x === 'string' ? x.replace(/^[A-Za-z]:\/Program Files\/Git(?=\/|$)/, '') || '/' : x
|
||||
|
||||
module.exports = { load, serverId, unmsys }
|
||||
85
tools/rig.js
Normal file
85
tools/rig.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// A rig's server through the panel's client API: state, power, a console command, and files.
|
||||
//
|
||||
// node tools/rig.js <rig> state
|
||||
// node tools/rig.js <rig> power <start|stop|restart|kill>
|
||||
// node tools/rig.js <rig> cmd "<console command>" (fire and forget; console.js reads the reply)
|
||||
// node tools/rig.js <rig> ls <dir>
|
||||
// node tools/rig.js <rig> read <path> (TAIL=n prints the last n lines)
|
||||
// node tools/rig.js <rig> write <path> <local file>
|
||||
// node tools/rig.js <rig> rm <path>
|
||||
//
|
||||
// Paths are the server's, from its root: /oxide/plugins/RunicNPC.cs, /carbon/plugins/RunicNPC.cs.
|
||||
//
|
||||
// A directory the panel's file API creates is NOT writable by the game (docs/runicnpc/PLAN.md
|
||||
// §1.5). Let the plugin create its own data directory, then write files into it.
|
||||
|
||||
const fs = require('fs')
|
||||
const { load, serverId, unmsys } = require('./panel')
|
||||
|
||||
const config = load()
|
||||
const [rig, op, a, b] = process.argv.slice(2).map(unmsys)
|
||||
const base = `${config.panel}/api/client/servers/${serverId(config, rig)}`
|
||||
const headers = (type = 'application/json') => ({
|
||||
Authorization: 'Bearer ' + config.key,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': type,
|
||||
})
|
||||
|
||||
async function main() {
|
||||
let r
|
||||
switch (op) {
|
||||
case 'state':
|
||||
r = await fetch(`${base}/resources`, { headers: headers() })
|
||||
break
|
||||
case 'power':
|
||||
r = await fetch(`${base}/power`, { method: 'POST', headers: headers(), body: JSON.stringify({ signal: a }) })
|
||||
break
|
||||
case 'cmd':
|
||||
r = await fetch(`${base}/command`, { method: 'POST', headers: headers(), body: JSON.stringify({ command: a }) })
|
||||
break
|
||||
case 'ls':
|
||||
r = await fetch(`${base}/files/list?directory=${encodeURIComponent(a)}`, { headers: headers() })
|
||||
break
|
||||
case 'read':
|
||||
r = await fetch(`${base}/files/contents?file=${encodeURIComponent(a)}`, { headers: headers() })
|
||||
break
|
||||
case 'write':
|
||||
r = await fetch(`${base}/files/write?file=${encodeURIComponent(a)}`, {
|
||||
method: 'POST',
|
||||
headers: headers('text/plain'),
|
||||
body: fs.readFileSync(b),
|
||||
})
|
||||
break
|
||||
case 'rm':
|
||||
r = await fetch(`${base}/files/delete`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ root: a.slice(0, a.lastIndexOf('/')) || '/', files: [a.slice(a.lastIndexOf('/') + 1)] }),
|
||||
})
|
||||
break
|
||||
default:
|
||||
console.error('usage: node tools/rig.js <rig> state|power|cmd|ls|read|write|rm ...')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const text = await r.text()
|
||||
if (!r.ok) {
|
||||
console.error(r.status, text)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (op === 'state') return console.log(JSON.parse(text).attributes.current_state)
|
||||
if (op === 'ls') {
|
||||
const rows = (JSON.parse(text).data || []).map(
|
||||
(f) => `${f.attributes.is_file ? 'f' : 'd'} ${f.attributes.size}\t${f.attributes.name}`
|
||||
)
|
||||
return console.log(rows.join('\n'))
|
||||
}
|
||||
if (op === 'read' && process.env.TAIL) return console.log(text.split('\n').slice(-Number(process.env.TAIL)).join('\n'))
|
||||
console.log(op === 'read' ? text : r.status)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message || e)
|
||||
process.exit(1)
|
||||
})
|
||||
8
tools/rigs.example.json
Normal file
8
tools/rigs.example.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"panel": "http://panel.example.lan",
|
||||
"tokenFile": "/path/to/pterodactyl_api_token",
|
||||
"rigs": {
|
||||
"oxide": "00000000",
|
||||
"carbon": "00000000"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user