- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor) with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room selection and OBS scene reconciliation; built and tested on Linux only - streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging - worker status validation and live score helpers for the site - STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
717 lines
26 KiB
TypeScript
717 lines
26 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* vash-broadcast: sets up and runs an unattended Vash broadcast on a Windows
|
||
* PC, one instance per Vash site (dev, staging, prod). Everything is driven
|
||
* from an SSH session; the worker itself runs in the signed-in desktop.
|
||
*
|
||
* Build with `bun run broadcast:build` (dist/vash-broadcast.exe).
|
||
*/
|
||
import { existsSync } from 'node:fs';
|
||
import { copyFile, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||
import { hostname } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { parseArgs } from 'node:util';
|
||
import { VashApi, VashApiError } from '../vash-api';
|
||
import {
|
||
accessHeaders,
|
||
allocatePorts,
|
||
layout,
|
||
listInstances,
|
||
loadConfig,
|
||
readJson,
|
||
saveConfig,
|
||
secret,
|
||
validateServer,
|
||
VERSION,
|
||
writeJson,
|
||
type InstanceConfig
|
||
} from './config';
|
||
import { callWorker, type Runtime } from './control';
|
||
import { WindowsDesktop } from './desktop';
|
||
import {
|
||
findOsu,
|
||
installFfmpeg,
|
||
installObs,
|
||
installSelf,
|
||
installTosu,
|
||
PINS,
|
||
prepareOsu,
|
||
writeTosuEnv
|
||
} from './install';
|
||
import { lockOwner } from './lock';
|
||
import { writeObsSettings } from './obs-files';
|
||
import {
|
||
consoleUser,
|
||
endTask,
|
||
gpuNames,
|
||
registerTask,
|
||
runTask,
|
||
taskInfo,
|
||
unregisterTask
|
||
} from './tasks';
|
||
import { readSavedLogin } from './tourney';
|
||
import {
|
||
runTask as runWatchdog,
|
||
runWorker,
|
||
stopInstanceProcesses,
|
||
type SignInState,
|
||
type WorkerState
|
||
} from './worker';
|
||
|
||
const HELP = `vash-broadcast ${VERSION}: an unattended Vash broadcast on this Windows PC.
|
||
|
||
One-time setup, per instance (dev, staging, prod…):
|
||
vash-broadcast setup --instance prod --server https://esports.vash.software --channel <id> --token <token>
|
||
Installs OBS, tosu and FFmpeg for this instance, a separate osu!tourney folder,
|
||
and a scheduled task that runs the worker in your signed-in desktop.
|
||
--osu-exe PATH your osu! stable osu!.exe to copy (found automatically otherwise)
|
||
--token-stdin read the agent token from standard input instead of --token
|
||
--access-id ID --access-secret SECRET
|
||
Cloudflare Access service token, for dev/staging sites behind Access
|
||
--tourney-height N osu!tourney streamable height (default 720; the window is ~200 px taller)
|
||
--encoder amd|x264 amd (default) uses the Radeon's hardware encoder
|
||
--no-autostart do not start the worker when you sign in to Windows
|
||
vash-broadcast login --instance prod
|
||
One-time sign-in of the spectator osu! account, shown to you through an SSH tunnel.
|
||
vash-broadcast login --instance dev --copy-from prod
|
||
Reuse another instance's remembered login instead (same Windows user).
|
||
|
||
Every day, nothing: the worker follows the site. Go live and End stream on the channel's
|
||
Stream page start and stop the broadcast. To manage it:
|
||
vash-broadcast start|stop|restart --instance prod (stop/restart also take --all)
|
||
vash-broadcast status --instance prod [--json]
|
||
vash-broadcast logs --instance prod [-n 100] [-f]
|
||
vash-broadcast snapshot --instance prod save what OBS and osu!tourney show
|
||
vash-broadcast doctor --instance prod check the setup and the desktop
|
||
vash-broadcast list
|
||
vash-broadcast remove --instance prod --yes unregister (keeps files unless --delete-files)
|
||
`;
|
||
|
||
const { values: args, positionals } = parseArgs({
|
||
allowPositionals: true,
|
||
options: {
|
||
instance: { type: 'string', short: 'i' },
|
||
server: { type: 'string' },
|
||
channel: { type: 'string' },
|
||
token: { type: 'string' },
|
||
'token-stdin': { type: 'boolean', default: false },
|
||
'access-id': { type: 'string' },
|
||
'access-secret': { type: 'string' },
|
||
'osu-exe': { type: 'string' },
|
||
'tourney-height': { type: 'string' },
|
||
encoder: { type: 'string' },
|
||
'no-autostart': { type: 'boolean', default: false },
|
||
autostart: { type: 'boolean', default: false },
|
||
all: { type: 'boolean', default: false },
|
||
json: { type: 'boolean', default: false },
|
||
follow: { type: 'boolean', short: 'f', default: false },
|
||
lines: { type: 'string', short: 'n' },
|
||
yes: { type: 'boolean', default: false },
|
||
'delete-files': { type: 'boolean', default: false },
|
||
'copy-from': { type: 'string' },
|
||
help: { type: 'boolean', short: 'h', default: false },
|
||
version: { type: 'boolean', short: 'V', default: false }
|
||
}
|
||
});
|
||
|
||
const command = positionals[0];
|
||
const say = (line = '') => console.log(line);
|
||
|
||
class UsageError extends Error {}
|
||
|
||
function instanceName() {
|
||
if (!args.instance)
|
||
throw new UsageError('Say which instance: --instance dev|staging|prod (or your own name)');
|
||
return args.instance;
|
||
}
|
||
|
||
async function running(name: string) {
|
||
return callWorker<WorkerState>(layout(name).runtime, '/status');
|
||
}
|
||
|
||
function requireWindows(what: string) {
|
||
if (process.platform !== 'win32') throw new Error(`${what} works only on Windows`);
|
||
}
|
||
|
||
// ── setup ───────────────────────────────────────────────────────────────
|
||
|
||
async function setup() {
|
||
const name = instanceName();
|
||
const paths = layout(name);
|
||
const existing = await loadConfig(name).catch(() => null);
|
||
const others = (await listInstances()).filter((config) => config.name !== name);
|
||
const serverArg = args.server ?? existing?.server;
|
||
if (!serverArg)
|
||
throw new UsageError('--server is required, e.g. --server https://esports.vash.software');
|
||
const channel = args.channel ?? existing?.channel;
|
||
if (!channel)
|
||
throw new UsageError('--channel is required (the channel id from its Broadcast page link)');
|
||
let token = args.token ?? process.env.VASH_TOKEN ?? existing?.token;
|
||
if (args['token-stdin']) token = (await new Response(Bun.stdin.stream()).text()).trim();
|
||
if (!token) throw new UsageError('--token is required (Broadcast page → Agent token)');
|
||
const accessId = args['access-id'] ?? process.env.VASH_ACCESS_CLIENT_ID;
|
||
const accessSecret = args['access-secret'] ?? process.env.VASH_ACCESS_CLIENT_SECRET;
|
||
if (!!accessId !== !!accessSecret)
|
||
throw new UsageError('--access-id and --access-secret go together');
|
||
const height = Number(args['tourney-height'] ?? existing?.tourneyHeight ?? 720);
|
||
const encoder = (args.encoder ?? existing?.encoder ?? 'amd') as InstanceConfig['encoder'];
|
||
|
||
const config: InstanceConfig = {
|
||
version: 1,
|
||
name,
|
||
server: validateServer(serverArg),
|
||
channel,
|
||
token,
|
||
access: accessId
|
||
? { clientId: accessId, clientSecret: accessSecret! }
|
||
: (existing?.access ?? null),
|
||
ports: existing?.ports ?? allocatePorts(others.map((other) => other.ports)),
|
||
obsPassword: existing?.obsPassword ?? secret(),
|
||
ingestKey: existing?.ingestKey ?? secret(),
|
||
osuSource: existing?.osuSource ?? null,
|
||
tourneyHeight: height,
|
||
encoder,
|
||
autostart: args['no-autostart'] ? false : args.autostart ? true : (existing?.autostart ?? true)
|
||
};
|
||
for (const other of others)
|
||
if (other.server === config.server && other.channel === config.channel)
|
||
say(`Note: instance "${other.name}" follows the same channel.`);
|
||
|
||
if (await running(name))
|
||
throw new Error(
|
||
`The "${name}" worker is running. Stop it first: vash-broadcast stop --instance ${name}`
|
||
);
|
||
|
||
say(`Checking ${config.server} …`);
|
||
const api = new VashApi(config.server, config.channel, config.token, accessHeaders(config));
|
||
const stream = await api.stream().catch((err) => {
|
||
if (err instanceof VashApiError && err.status === 401)
|
||
throw new Error(
|
||
'Vash refused the channel id or agent token. Copy them again from the Broadcast page.'
|
||
);
|
||
throw err;
|
||
});
|
||
say(
|
||
` Channel "${stream.name ?? channel}": ${stream.width}×${stream.height} ${stream.fps} fps, ${stream.bitrateKbps} kbps, ` +
|
||
`${stream.destinations.length} destination(s), recording ${stream.record ? 'on' : 'off'}`
|
||
);
|
||
|
||
const progress = (line: string) => say(` ${line}`);
|
||
if (process.platform === 'win32') {
|
||
say('Installing programs (pinned versions, checksums verified)…');
|
||
say(` vash-broadcast: ${await installSelf(paths)}`);
|
||
await WindowsDesktop.writeScript(paths.helperScript);
|
||
say(` FFmpeg ${PINS.ffmpeg.version}: ${await installFfmpeg(paths, progress)}`);
|
||
say(` tosu ${PINS.tosu.version}: ${await installTosu(paths, progress)}`);
|
||
await writeTosuEnv(paths, config.ports.tosu);
|
||
say(
|
||
` OBS ${PINS.obs.version} (portable, this instance only): ${await installObs(paths, progress)}`
|
||
);
|
||
await writeObsSettings(paths.obsConfig, {
|
||
websocketPort: config.ports.obs,
|
||
websocketPassword: config.obsPassword,
|
||
width: stream.width,
|
||
height: stream.height,
|
||
fps: stream.fps,
|
||
bitrateKbps: stream.bitrateKbps,
|
||
encoder: config.encoder,
|
||
ingestUrl: `rtmp://127.0.0.1:${config.ports.ingest}/live`,
|
||
ingestKey: config.ingestKey
|
||
});
|
||
const source = await findOsu(args['osu-exe'] ?? config.osuSource);
|
||
if (!source)
|
||
throw new Error(
|
||
'Could not find osu! stable. Pass the path of your osu!.exe with --osu-exe; it is only copied, never changed.'
|
||
);
|
||
config.osuSource = source;
|
||
say(
|
||
` osu!tourney folder (osu!.exe copied from ${source}): ${await prepareOsu(paths, source)}`
|
||
);
|
||
} else say('Not on Windows: saving the configuration only.');
|
||
|
||
await saveConfig(config);
|
||
if (process.platform === 'win32') {
|
||
await registerTask({
|
||
instance: name,
|
||
exe: paths.workerExe,
|
||
cwd: paths.root,
|
||
autostart: config.autostart
|
||
});
|
||
say(
|
||
` Scheduled task: registered${config.autostart ? ', starts when you sign in to Windows' : ''}`
|
||
);
|
||
}
|
||
say();
|
||
say(`Instance "${name}" is set up in ${paths.dir}`);
|
||
say(
|
||
` Ports: control ${config.ports.control}, OBS ${config.ports.obs}, tosu ${config.ports.tosu}, relay ${config.ports.ingest}`
|
||
);
|
||
const login = await readSavedLogin(paths.osu);
|
||
say();
|
||
if (!login.saved)
|
||
say(`Next, sign in the spectator account once: vash-broadcast login --instance ${name}`);
|
||
else
|
||
say(
|
||
`Spectator login saved (${login.username}). Start it: vash-broadcast start --instance ${name}`
|
||
);
|
||
}
|
||
|
||
// ── start / stop ────────────────────────────────────────────────────────
|
||
|
||
async function requireDesktop() {
|
||
const user = await consoleUser();
|
||
if (!user)
|
||
throw new Error(
|
||
'Nobody is signed in to Windows at the PC. The worker runs in the signed-in desktop; sign in once (or turn on automatic sign-in) and try again.'
|
||
);
|
||
return user;
|
||
}
|
||
|
||
async function start(name: string) {
|
||
requireWindows('start');
|
||
const paths = layout(name);
|
||
await loadConfig(name);
|
||
const status = await running(name);
|
||
if (status) {
|
||
say(
|
||
`"${name}" is already running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`
|
||
);
|
||
return;
|
||
}
|
||
await requireDesktop();
|
||
const task = await taskInfo(name);
|
||
if (!task.exists) throw new Error(`No scheduled task for "${name}". Run setup again.`);
|
||
await writeJson(paths.request, { mode: 'run', at: Date.now() });
|
||
await runTask(name);
|
||
say(`Starting "${name}" in the desktop session…`);
|
||
for (let waited = 0; waited < 45; waited++) {
|
||
await Bun.sleep(1000);
|
||
const status = await running(name);
|
||
if (status) {
|
||
say(`Running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`);
|
||
say(
|
||
`Follow it with: vash-broadcast status --instance ${name} or vash-broadcast logs --instance ${name} -f`
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
const info = await taskInfo(name);
|
||
throw new Error(
|
||
`The worker did not come up in 45 s (task ${info.state}, last result ${info.lastTaskResult}). See: vash-broadcast logs --instance ${name}`
|
||
);
|
||
}
|
||
|
||
async function stop(name: string) {
|
||
const paths = layout(name);
|
||
const runtime = await readJson<Runtime>(paths.runtime);
|
||
const status = await running(name);
|
||
if (status) {
|
||
say(`Stopping "${name}"${status.onAir ? ' (going off air and finishing the recording)' : ''}…`);
|
||
await callWorker(paths.runtime, '/stop', { method: 'POST' });
|
||
for (let waited = 0; waited < 120; waited++) {
|
||
await Bun.sleep(1000);
|
||
if (!(await running(name))) {
|
||
say('Stopped.');
|
||
return;
|
||
}
|
||
}
|
||
say('It did not stop in 2 minutes; stopping it forcibly.');
|
||
} else if (!runtime) {
|
||
say(`"${name}" is not running.`);
|
||
}
|
||
// Forcibly: the task (and its watchdog), the worker, then everything in the instance folder.
|
||
if (process.platform === 'win32') {
|
||
await endTask(name);
|
||
const desktop = new WindowsDesktop(paths.helperScript);
|
||
try {
|
||
const pids = [runtime?.watchdogPid, runtime?.pid].filter(
|
||
(pid): pid is number => !!pid && pid !== process.pid
|
||
);
|
||
await desktop.kill(pids).catch(() => 0);
|
||
const killed = await stopInstanceProcesses(desktop, paths, () => {});
|
||
if (killed) say(`Stopped ${killed} process(es) of "${name}".`);
|
||
} finally {
|
||
desktop.stop();
|
||
}
|
||
}
|
||
await rm(paths.runtime, { force: true });
|
||
}
|
||
|
||
// ── status ──────────────────────────────────────────────────────────────
|
||
|
||
function age(iso: string | null | undefined) {
|
||
if (!iso) return 'never';
|
||
const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
|
||
return seconds < 90
|
||
? `${seconds}s ago`
|
||
: seconds < 5400
|
||
? `${Math.round(seconds / 60)} min ago`
|
||
: `${Math.round(seconds / 3600)} h ago`;
|
||
}
|
||
|
||
function printState(state: WorkerState) {
|
||
say(`State: ${state.state}${state.detail ? ` — ${state.detail}` : ''}`);
|
||
if (state.match?.title)
|
||
say(
|
||
`Match: ${state.match.title}${state.match.lobbyId ? ` (mp/${state.match.lobbyId})` : ' (no osu! lobby)'}`
|
||
);
|
||
else say('Match: none; the channel is idle');
|
||
say(
|
||
`Tourney: ${state.tourney.state}${state.tourney.detail ? ` — ${state.tourney.detail}` : ''}${state.tourney.verified ? ' ✓ confirmed' : ''}`
|
||
);
|
||
say(
|
||
`Broadcast: ${state.live ? 'Go live is on' : 'off air'}${state.onAir ? `; OBS ${state.obsStreaming ? 'streaming' : 'NOT streaming'}, relay ${state.relayReceiving ? 'receiving' : 'waiting'}` : ''}`
|
||
);
|
||
if (state.encoder) say(`Encoder: ${state.encoder}`);
|
||
for (const destination of state.destinations)
|
||
say(
|
||
` → ${destination.label}: ${destination.state}${destination.error ? ` (${destination.error})` : ''}`
|
||
);
|
||
const components = state.components as Record<string, Record<string, unknown>>;
|
||
for (const [label, component] of Object.entries(components))
|
||
say(
|
||
`${`${label}:`.padEnd(11)}${Object.entries(component)
|
||
.filter(([, value]) => value !== null && value !== undefined)
|
||
.map(([key, value]) => `${key} ${value}`)
|
||
.join(', ')}`
|
||
);
|
||
say(
|
||
`Vash: ${state.vash.connected ? `connected (channel "${state.vash.channel}")` : 'not connected'}${state.vash.lastError ? `; ${state.vash.lastError}` : ''}`
|
||
);
|
||
if (state.desktop)
|
||
say(
|
||
`Desktop: ${state.desktop.inputDesktop === 'Default' ? 'unlocked' : 'LOCKED'}, ${state.desktop.screenWidth}×${state.desktop.screenHeight}`
|
||
);
|
||
for (const warning of state.warnings) say(`Warning: ${warning}`);
|
||
say(`Worker: pid ${state.pid}, up since ${state.startedAt} (v${state.version})`);
|
||
}
|
||
|
||
async function status(name: string) {
|
||
const paths = layout(name);
|
||
const live = await running(name);
|
||
if (args.json) {
|
||
say(JSON.stringify(live ?? { running: false, last: await readJson(paths.state) }, null, 2));
|
||
return;
|
||
}
|
||
say(`Instance "${name}"`);
|
||
if (live) {
|
||
printState(live);
|
||
return;
|
||
}
|
||
say('Worker: not running');
|
||
const last = await readJson<WorkerState>(paths.state);
|
||
if (last)
|
||
say(
|
||
`Last seen: ${last.state}${last.detail ? ` — ${last.detail}` : ''} (${age(last.updatedAt)})`
|
||
);
|
||
const login = await readSavedLogin(paths.osu);
|
||
say(
|
||
`Login: ${login.saved ? `saved (${login.username})` : `not saved; run vash-broadcast login --instance ${name}`}`
|
||
);
|
||
if (process.platform === 'win32') {
|
||
const task = await taskInfo(name).catch(() => null);
|
||
if (task)
|
||
say(
|
||
`Task: ${task.exists ? `${task.state}, last run ${age(task.lastRunTime)} (result ${task.lastTaskResult})${task.autostart ? ', starts at sign-in' : ''}` : 'not registered; run setup'}`
|
||
);
|
||
}
|
||
const owner = await lockOwner(paths.lock);
|
||
if (owner && owner.instance !== name)
|
||
say(`Tourney: instance "${owner.instance}" is running osu!tourney on this PC`);
|
||
}
|
||
|
||
// ── logs, snapshot, doctor, list, remove ──────────────────────────────────
|
||
|
||
async function logs(name: string) {
|
||
const path = layout(name).log;
|
||
if (!existsSync(path)) {
|
||
say('No log yet.');
|
||
return;
|
||
}
|
||
const count = Number(args.lines ?? 60);
|
||
const text = await readFile(path, 'utf8');
|
||
say(text.trimEnd().split('\n').slice(-count).join('\n'));
|
||
if (!args.follow) return;
|
||
let offset = (await stat(path)).size;
|
||
for (;;) {
|
||
await Bun.sleep(1000);
|
||
const size = (await stat(path).catch(() => null))?.size ?? 0;
|
||
if (size < offset) offset = 0;
|
||
if (size > offset) {
|
||
const file = Bun.file(path).slice(offset, size);
|
||
process.stdout.write(await file.text());
|
||
offset = size;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function snapshot(name: string) {
|
||
const result = await callWorker<{ files: string[]; errors: string[] }>(
|
||
layout(name).runtime,
|
||
'/snapshot',
|
||
{
|
||
method: 'POST',
|
||
timeoutMs: 30_000
|
||
}
|
||
);
|
||
if (!result) throw new Error(`"${name}" is not running.`);
|
||
for (const file of result.files) say(file);
|
||
for (const error of result.errors) say(`Could not capture: ${error}`);
|
||
if (result.files.length)
|
||
say(
|
||
`\nCopy them to your Mac with, for example: scp "${process.env.USERNAME ?? 'you'}@${hostname()}:${result.files[0].replace(/\\/g, '/')}" .`
|
||
);
|
||
}
|
||
|
||
async function doctor(name: string) {
|
||
const paths = layout(name);
|
||
const results: [boolean | null, string, string][] = [];
|
||
const check = async (label: string, run: () => Promise<string | [boolean | null, string]>) => {
|
||
try {
|
||
const outcome = await run();
|
||
results.push(
|
||
typeof outcome === 'string' ? [true, label, outcome] : [outcome[0], label, outcome[1]]
|
||
);
|
||
} catch (err) {
|
||
results.push([false, label, (err as Error).message]);
|
||
}
|
||
};
|
||
const config = await loadConfig(name);
|
||
await check('Vash', async () => {
|
||
const stream = await new VashApi(
|
||
config.server,
|
||
config.channel,
|
||
config.token,
|
||
accessHeaders(config)
|
||
).stream();
|
||
return `${config.server}: channel "${stream.name}" answers; Go live is ${stream.live ? 'on' : 'off'}`;
|
||
});
|
||
for (const [label, path] of [
|
||
['OBS', paths.obsExe],
|
||
['tosu', paths.tosuExe],
|
||
['FFmpeg', paths.ffmpeg],
|
||
['osu!tourney folder', paths.osuExe],
|
||
['Worker program', paths.workerExe]
|
||
] as const)
|
||
await check(label, async () =>
|
||
existsSync(path) ? path : [false, `missing ${path}; run setup`]
|
||
);
|
||
await check('Spectator login', async () => {
|
||
const login = await readSavedLogin(paths.osu);
|
||
return login.saved
|
||
? `saved (${login.username})`
|
||
: [false, `not saved; run vash-broadcast login --instance ${name}`];
|
||
});
|
||
if (process.platform === 'win32') {
|
||
await check('Scheduled task', async () => {
|
||
const task = await taskInfo(name);
|
||
return task.exists
|
||
? `${task.state}, last result ${task.lastTaskResult}`
|
||
: [false, 'not registered; run setup'];
|
||
});
|
||
await check('Signed-in desktop', async () => `${await requireDesktop()} is signed in`);
|
||
await check('GPU', async () => {
|
||
const gpus = await gpuNames();
|
||
const amd = gpus.some((gpu) => /radeon|amd/i.test(gpu));
|
||
return [amd || config.encoder !== 'amd' ? true : null, gpus.join(', ') || 'unknown'];
|
||
});
|
||
}
|
||
await check('Tourney lock', async () => {
|
||
const owner = await lockOwner(paths.lock);
|
||
return !owner
|
||
? 'free'
|
||
: owner.instance === name
|
||
? 'held by this instance'
|
||
: [null, `held by "${owner.instance}"`];
|
||
});
|
||
const live = await running(name);
|
||
if (live) {
|
||
results.push([
|
||
live.state === 'error' ? false : true,
|
||
'Worker',
|
||
`${live.state}${live.detail ? ` — ${live.detail}` : ''}`
|
||
]);
|
||
if (live.desktop)
|
||
results.push([
|
||
live.desktop.inputDesktop === 'Default',
|
||
'Desktop',
|
||
live.desktop.inputDesktop === 'Default'
|
||
? 'unlocked'
|
||
: 'locked: rooms cannot be selected while Windows is locked'
|
||
]);
|
||
} else if (process.platform === 'win32') {
|
||
// Checks that only mean something inside the desktop session run there, through the task.
|
||
await rm(paths.doctor, { force: true });
|
||
await writeJson(paths.request, { mode: 'doctor', at: Date.now() });
|
||
await runTask(name);
|
||
let report: { checks: { name: string; ok: boolean; detail: string }[] } | null = null;
|
||
for (let waited = 0; waited < 60 && !report; waited++) {
|
||
await Bun.sleep(1000);
|
||
report = await readJson(paths.doctor);
|
||
}
|
||
if (!report) results.push([false, 'Desktop checks', 'the task did not report back in 60 s']);
|
||
else for (const each of report.checks) results.push([each.ok, each.name, each.detail]);
|
||
}
|
||
for (const [ok, label, detail] of results)
|
||
say(`${ok === true ? ' ok ' : ok === false ? 'FAIL' : ' ?? '} ${label}: ${detail}`);
|
||
if (results.some(([ok]) => ok === false)) process.exitCode = 1;
|
||
}
|
||
|
||
async function list() {
|
||
const configs = await listInstances();
|
||
if (!configs.length) {
|
||
say('No instances. Set one up with: vash-broadcast setup --instance prod …');
|
||
return;
|
||
}
|
||
for (const config of configs) {
|
||
const live = await running(config.name);
|
||
say(
|
||
`${config.name.padEnd(10)} ${config.server} ${live ? `${live.state}${live.onAir ? ', on air' : ''}` : 'stopped'}`
|
||
);
|
||
}
|
||
}
|
||
|
||
async function remove(name: string) {
|
||
if (!args.yes) throw new UsageError(`This unregisters "${name}". Add --yes to confirm.`);
|
||
await stop(name);
|
||
if (process.platform === 'win32') await unregisterTask(name);
|
||
if (args['delete-files']) {
|
||
await rm(layout(name).dir, { recursive: true, force: true });
|
||
say(`Removed "${name}" and its files.`);
|
||
} else say(`Unregistered "${name}"; its files are still in ${layout(name).dir}.`);
|
||
}
|
||
|
||
/** Copies osu!'s remembered login from another instance's tourney folder. */
|
||
async function copyLogin(name: string, from: string) {
|
||
const source = layout(from);
|
||
const target = layout(name);
|
||
await loadConfig(from);
|
||
const login = await readSavedLogin(source.osu);
|
||
if (!login.saved) throw new Error(`Instance "${from}" has no saved login to copy.`);
|
||
if (await running(name))
|
||
throw new Error(`Stop "${name}" first: vash-broadcast stop --instance ${name}`);
|
||
for (const file of (await readdir(source.osu)).filter((each) => /^osu!\..+\.cfg$/i.test(each)))
|
||
await copyFile(join(source.osu, file), join(target.osu, file));
|
||
const copied = await readSavedLogin(target.osu);
|
||
if (!copied.saved)
|
||
throw new Error('The login did not copy; use vash-broadcast login without --copy-from.');
|
||
say(`Copied the remembered login (${copied.username}) from "${from}" to "${name}".`);
|
||
say('If osu!tourney still asks to sign in, run vash-broadcast login without --copy-from.');
|
||
}
|
||
|
||
async function login(name: string) {
|
||
if (args['copy-from']) return copyLogin(name, args['copy-from']);
|
||
requireWindows('login');
|
||
const paths = layout(name);
|
||
await loadConfig(name);
|
||
if (await running(name))
|
||
throw new Error(
|
||
`Stop the worker first (signing in closes osu!tourney): vash-broadcast stop --instance ${name}`
|
||
);
|
||
await requireDesktop();
|
||
await rm(paths.login, { force: true });
|
||
await writeJson(paths.request, { mode: 'login', at: Date.now() });
|
||
await runTask(name);
|
||
let state: SignInState | null = null;
|
||
for (let waited = 0; waited < 60 && state?.state !== 'waiting'; waited++) {
|
||
await Bun.sleep(1000);
|
||
state = await readJson<SignInState>(paths.login);
|
||
if (state?.state === 'failed') throw new Error(state.message);
|
||
}
|
||
if (!state)
|
||
throw new Error(`The sign-in did not start. See: vash-broadcast logs --instance ${name}`);
|
||
const user = process.env.USERNAME ?? 'you';
|
||
say(`osu! is open on ${hostname()}'s screen for the one-time spectator sign-in.
|
||
|
||
On your Mac, open a tunnel (leave it running):
|
||
ssh -N -L ${state.port}:127.0.0.1:${state.port} ${user}@${hostname().toLowerCase()}
|
||
|
||
Then open this link in your browser:
|
||
http://127.0.0.1:${state.port}/#${state.token}
|
||
|
||
Sign in with the spectator account (it needs osu!supporter for osu!tourney), tick
|
||
Remember username and Remember password, then press "I have signed in: finish".
|
||
`);
|
||
let last = '';
|
||
for (;;) {
|
||
await Bun.sleep(2000);
|
||
const next = await readJson<SignInState>(paths.login);
|
||
if (!next) continue;
|
||
if (next.message !== last) say(next.message);
|
||
last = next.message;
|
||
if (next.state === 'done') {
|
||
say(`\nNext: vash-broadcast start --instance ${name}`);
|
||
return;
|
||
}
|
||
if (next.state === 'failed') {
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── main ────────────────────────────────────────────────────────────────
|
||
|
||
async function main() {
|
||
if (args.version) return say(VERSION);
|
||
if (args.help || !command) return say(HELP);
|
||
switch (command) {
|
||
case 'setup':
|
||
return setup();
|
||
case 'login':
|
||
return login(instanceName());
|
||
case 'start':
|
||
return start(instanceName());
|
||
case 'stop':
|
||
if (args.all) {
|
||
for (const config of await listInstances()) await stop(config.name);
|
||
return;
|
||
}
|
||
return stop(instanceName());
|
||
case 'restart':
|
||
if (args.all) {
|
||
const configs = await listInstances();
|
||
for (const config of configs)
|
||
if (await running(config.name)) {
|
||
await stop(config.name);
|
||
await start(config.name);
|
||
}
|
||
return;
|
||
}
|
||
await stop(instanceName());
|
||
return start(instanceName());
|
||
case 'status':
|
||
return status(instanceName());
|
||
case 'logs':
|
||
return logs(instanceName());
|
||
case 'snapshot':
|
||
return snapshot(instanceName());
|
||
case 'doctor':
|
||
return doctor(instanceName());
|
||
case 'list':
|
||
return list();
|
||
case 'remove':
|
||
return remove(instanceName());
|
||
// Started by the scheduled task, inside the desktop session:
|
||
case 'task':
|
||
process.exitCode = await runWatchdog(instanceName());
|
||
return;
|
||
case 'worker':
|
||
process.exit(await runWorker(instanceName()));
|
||
return;
|
||
default:
|
||
throw new UsageError(`Unknown command "${command}"`);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await main();
|
||
} catch (err) {
|
||
console.error(
|
||
err instanceof UsageError
|
||
? `${err.message}\n\nRun vash-broadcast --help for usage.`
|
||
: `Error: ${(err as Error).message}`
|
||
);
|
||
process.exit(1);
|
||
}
|