Files
esports/streamer/windows/cli.ts
Stan Runge 54444a77f0 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>
2026-10-18 22:02:25 +00:00

717 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}