DSH-Arch-Setup/plugins/dsh-fleet-control/lib/index.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
2026-09-12 19:13:36 +03:00

190 lines
5.5 KiB
JavaScript

/**
* dsh-fleet-control — host half.
*
* Persists the set of session ids that "Stop All" gracefully cancelled so that
* "Resume All" can re-prompt exactly those sessions later — including after an
* app restart. State lives in ~/.dsh/fleet-control/stopped.json.
*
* Exposes one loopback-only route, GET/POST /fleet/stopped:
* GET -> { ok: true, value: { ids: string[], updatedAt: string } }
* POST { ids: string[] } -> replaces the set, returns the same envelope.
*
* @module dsh-fleet-control
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'fleet-control', 'stopped.json')
export const name = 'dsh-fleet-control'
export const inject = ['webServer']
const JSON_HEADERS = {
'content-type': 'application/json; charset=utf-8',
'referrer-policy': 'no-referrer',
}
function writeJson(response, status, body) {
response.writeHead(status, JSON_HEADERS)
response.end(JSON.stringify(body))
}
// ---- loopback guards (mirrors dsh-open-in-smerge) -------------------------
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const normalized = address.toLowerCase()
if (normalized === '::1') return true
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
return isIPv4Loopback(normalized)
}
function isLoopbackHostname(hostname) {
return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname)
}
export function isLoopbackRequest(request) {
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
const host = request.headers.host
if (typeof host !== 'string') return false
let hostUrl
try {
hostUrl = new URL(`http://${host}`)
} catch {
return false
}
if (!isLoopbackHostname(hostUrl.hostname)) return false
if (request.headers['sec-fetch-site'] === 'cross-site') return false
const origin = request.headers.origin
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
// --------------------------------------------------------------------------
async function readJsonBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 64 * 1024) {
request.destroy()
return null
}
chunks.push(chunk)
}
if (chunks.length === 0) return null
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function sanitizeIds(value) {
if (!Array.isArray(value)) return []
const out = []
for (const entry of value) {
if (typeof entry === 'string' && entry !== '' && out.indexOf(entry) === -1) out.push(entry)
}
return out
}
export class StoppedSessionStore {
constructor(filePath = STORE_FILE) {
this.filePath = filePath
this.ids = []
this.updatedAt = new Date(0).toISOString()
this.ready = this.load()
this.pendingWrite = Promise.resolve()
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
this.ids = sanitizeIds(parsed.ids)
if (typeof parsed.updatedAt === 'string') this.updatedAt = parsed.updatedAt
} catch {
this.ids = []
}
}
async get() {
await this.ready
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
}
async set(ids) {
await this.ready
this.ids = sanitizeIds(ids)
this.updatedAt = new Date().toISOString()
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
const body = JSON.stringify({ ids: this.ids, updatedAt: this.updatedAt }, null, 2)
await writeFile(temporaryPath, `${body}\n`, 'utf8')
await rename(temporaryPath, this.filePath)
}
}
export function apply(context) {
const store = new StoppedSessionStore()
const handler = async (request, response) => {
const method = (request.method ?? 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
if (method === 'GET') {
writeJson(response, 200, { ok: true, value: await store.get() })
return
}
const body = await readJsonBody(request)
const value = await store.set(body && body.ids)
writeJson(response, 200, { ok: true, value })
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/fleet/stopped', handler }),
]
return () => {
for (const dispose of disposers.reverse()) dispose()
}
}, 'fleet-control: stopped-session store route')
}