feat: add native Windows broadcaster and live score modules

- 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>
This commit is contained in:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

716
streamer/windows/cli.ts Normal file
View File

@@ -0,0 +1,716 @@
#!/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);
}