#!/usr/bin/env node /* GiteaHubSync .fpk installer for fnOS via the app-center web API. * 用法: * FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \ * node fnos-install.js [path/to/GiteaHubSync.fpk] * 流程(已在 192.168.31.10 随 AgentCheckin 验证): * WebSocket 登录 -> ticket -> ost cookie -> 上传 fpk -> 轮询 -> 安装 -> 轮询 */ const fs = require("fs"); const path = require("path"); process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; const HOST = process.env.FNOS_HOST || "192.168.31.10"; const PORT = process.env.FNOS_PORT || "5667"; const BASE = `https://${HOST}:${PORT}`; const USER = process.env.FNOS_USER; const PASS = process.env.FNOS_PASS; const APPNAME = "GiteaHubSync"; const VERSION = "1.0.0"; const FPK = process.argv[2] || path.join(__dirname, "GiteaHubSync", "GiteaHubSync.fpk"); const log = (...a) => console.log(...a); if (!USER || !PASS) { console.error("请设置环境变量 FNOS_USER 和 FNOS_PASS(NAS 管理员账号密码)"); process.exit(1); } if (!fs.existsSync(FPK)) { console.error("找不到 fpk 文件:", FPK); process.exit(1); } async function wsLogin() { const ws = new WebSocket(`wss://${HOST}:${PORT}/websocket?type=main`); let reqId = 1000; const pending = new Map(); const send = (obj) => ws.send(JSON.stringify(obj)); const rpc = (req, data, opts = {}) => new Promise((resolve, reject) => { const id = String(reqId++); const frame = Object.assign({ req, reqid: id }, data || {}); pending.set(id, { resolve, reject }); send(opts.raw ? frame : { data: frame, config: {} }); setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error("timeout " + req)); } }, opts.timeout || 15000); }); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = (e) => rej(new Error("ws error: " + (e.message || e.type))); }); ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } const p = pending.get(msg.reqid); if (p) { pending.delete(msg.reqid); p.resolve(msg); } }; await rpc("util.getSI", {}, { raw: true }); await rpc("util.crypto.getRSAPub", {}, { raw: true }); const loginId = String(reqId++); const loginResp = await new Promise((resolve, reject) => { pending.set(loginId, { resolve, reject }); send({ req: "user.login", reqid: loginId, user: USER, password: PASS, stay: 0, deviceType: "Browser", deviceName: "Linux-Chrome", ver: 2, did: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 15)}`, }); setTimeout(() => { if (pending.has(loginId)) { pending.delete(loginId); reject(new Error("login timeout")); } }, 20000); }); ws.close(); const d = loginResp.data || loginResp; const ticket = d.ticket || d.secret || d.token; if (!ticket) { if (loginResp.errno === 131072 || loginResp.result === "fail") { throw new Error("用户名或密码错误(请检查 FNOS_USER / FNOS_PASS)"); } throw new Error("登录失败: " + JSON.stringify(loginResp).slice(0, 300)); } return ticket; } async function ticketToCookie(ticket) { const r = await fetch(`${BASE}/app/ticket`, { method: "POST", headers: { "Content-Type": "application/json", Origin: BASE }, body: JSON.stringify({ ticket }), }); const setCookie = r.headers.getSetCookie ? r.headers.getSetCookie().join("; ") : (r.headers.get("set-cookie") || ""); const m = /ost=([^;]+)/.exec(setCookie); if (!m) throw new Error("未能从 /app/ticket 获取 ost cookie: " + setCookie.slice(0, 200)); return `ost=${m[1]}`; } async function uploadFpk(cookie) { const taskId = `app-upload-${Date.now()}-${Math.floor(Math.random() * 10000)}`; const buf = fs.readFileSync(FPK); const fd = new FormData(); fd.append("taskID", taskId); fd.append("file", new Blob([buf]), path.basename(FPK)); fd.append("volumeID", "1"); const r = await fetch(`${BASE}/app-center/v1/download/upload`, { method: "POST", headers: { Cookie: cookie, Origin: BASE }, body: fd, }); const j = await r.json().catch(() => ({})); if (j.code !== 0 && j.code !== 200) throw new Error("上传被拒绝: " + JSON.stringify(j).slice(0, 300)); log("upload accepted, taskId =", taskId); return taskId; } async function poll(cookie, url, opts, isDone, isFail, label, timeoutMs = 300000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const r = await fetch(url, { ...opts, headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE }, }); const j = await r.json().catch(() => ({})); const st = j.data && j.data.status; log(` [${label}] status=${st}`); if (isDone(j)) return j; if (isFail(j)) throw new Error(`${label} 失败: ` + JSON.stringify(j).slice(0, 400)); await new Promise((res) => setTimeout(res, 2000)); } throw new Error(`${label} 超时`); } async function uninstallIfInstalled(cookie) { const r = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`, { headers: { Cookie: cookie, Origin: BASE } }); const j = await r.json().catch(() => ({})); const apps = (j.data || {}).list || []; const existing = apps.find((a) => a.appName === APPNAME); if (!existing) { log(`== ${APPNAME} not installed yet, skipping uninstall ==`); return; } log(`== ${APPNAME} v${existing.version} already installed -> uninstalling first (data in @appdata is kept) ==`); const ur = await fetch(`${BASE}/app-center/v1/uninstall/start`, { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE }, body: JSON.stringify({ appName: APPNAME, language: "zh-CN", customParameters: [] }), }); const uj = await ur.json().catch(() => ({})); if (uj.code !== 0) throw new Error("卸载启动失败: " + JSON.stringify(uj).slice(0, 300)); const start = Date.now(); while (Date.now() - start < 120000) { await new Promise((res) => setTimeout(res, 2500)); const r2 = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`, { headers: { Cookie: cookie, Origin: BASE } }); const j2 = await r2.json().catch(() => ({})); const still = ((j2.data || {}).list || []).find((a) => a.appName === APPNAME); if (!still) { log(" uninstall done (data preserved)"); return; } } throw new Error("卸载超时,应用仍在列表中"); } async function main() { log("== 1. login (%s:%s)", HOST, PORT); const ticket = await wsLogin(); log(" ticket ok"); const cookie = await ticketToCookie(ticket); log(" ost cookie ok"); await uninstallIfInstalled(cookie); log("== 2. upload fpk:", FPK, `(${(fs.statSync(FPK).size / 1024).toFixed(1)} KB)`); const taskId = await uploadFpk(cookie); await poll(cookie, `${BASE}/app-center/v1/download/status?downloadTaskId=${taskId}&language=zh-CN`, { method: "GET" }, (j) => j.data && j.data.status === 2, (j) => j.data && (j.data.status === 3 || j.data.status === 4), "upload", 300000); log(" upload done"); log("== 3. query install info (registers the package) =="); const infoResp = await fetch( `${BASE}/app-center/v1/install/info?appName=${APPNAME}&version=${VERSION}&packageType=local&language=zh-CN`, { headers: { Cookie: cookie, Origin: BASE } }); const info = await infoResp.json().catch(() => ({})); log(" install info code:", info.code, "| deps:", JSON.stringify(((info.data || {}).relation || {}).dependencyApps || [])); log("== 4. create install task"); const r = await fetch(`${BASE}/app-center/v1/install/task`, { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE }, body: JSON.stringify({ appName: APPNAME, version: VERSION, packageType: "local", systemParameters: { agreedToProtocol: true, installVolumeID: 1, dataVolumeId: 1, immediateStart: true }, customParameters: [], language: "zh-CN", }), }); const j = await r.json().catch(() => ({})); const installTaskId = j.data && j.data.taskId; if (!installTaskId) throw new Error("创建安装任务失败: " + JSON.stringify(j).slice(0, 300)); log(" install task =", installTaskId); log("== 5. poll install status"); await poll(cookie, `${BASE}/app-center/v1/install/status`, { method: "POST", body: JSON.stringify({ taskId: installTaskId, language: "zh-CN" }), }, (jj) => jj.data && jj.data.status === 2, (jj) => jj.data && (jj.data.status === 3 || jj.data.status === 4), "install", 600000); log(""); log(`✅ ${APPNAME} 安装完成!`); log(` 桌面/启动台会出现「Gitea→GitHub 同步」图标`); log(` 控制面板: http://${HOST}:8899`); } main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });