@ciphore/radiocli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
@@ -0,0 +1,250 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+ import { nextOccurrenceForAlarm } from './schedule.js';
8
+ import { AlarmRuntimeHealthStore } from './runtime-health.js';
9
+ import { AlarmGuardService } from './guard.js';
10
+ import { AlarmPowerGuardStore } from './power-guard-store.js';
11
+ import { detectAlarmTerminal } from './terminal-launcher.js';
12
+ export class SchedulerService {
13
+ adapter;
14
+ now;
15
+ health;
16
+ guard;
17
+ constructor(adapter, now = () => new Date(), health = new AlarmRuntimeHealthStore(), guard) {
18
+ this.adapter = adapter;
19
+ this.now = now;
20
+ this.health = health;
21
+ this.guard = guard;
22
+ }
23
+ async sync(alarm) {
24
+ const occurrence = nextOccurrenceForAlarm(alarm, this.now());
25
+ try {
26
+ if (!occurrence) {
27
+ const native = await settledOperation(() => this.adapter.remove(alarm.id));
28
+ const guard = await settledOperation(() => this.reconcileGuard(alarm));
29
+ if (native.error)
30
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: false, message: `Native job removal failed: ${native.error}` });
31
+ else
32
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: true, message: 'No enabled future occurrence; native job removed.' });
33
+ const failures = [native.error, guard.error].filter((value) => Boolean(value));
34
+ if (failures.length)
35
+ throw new Error(failures.join(' '));
36
+ return null;
37
+ }
38
+ const capability = this.adapter.capabilities();
39
+ if (!capability.supported)
40
+ throw new Error(capability.message);
41
+ await this.adapter.install(alarm, occurrence);
42
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: true, message: 'Native job registered.', nextOccurrence: occurrence.toISOString() });
43
+ await this.reconcileGuard(alarm);
44
+ return occurrence;
45
+ }
46
+ catch (error) {
47
+ if (!(error instanceof GuardReconcileError))
48
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: false, message: errorMessage(error), ...(occurrence ? { nextOccurrence: occurrence.toISOString() } : {}) });
49
+ throw error;
50
+ }
51
+ }
52
+ async syncAll(alarms) {
53
+ return mapConcurrent(alarms, 4, async (alarm) => {
54
+ try {
55
+ return { id: alarm.id, occurrence: await this.sync(alarm) };
56
+ }
57
+ catch (error) {
58
+ return { id: alarm.id, occurrence: null, error: errorMessage(error) };
59
+ }
60
+ });
61
+ }
62
+ doctor() { return this.adapter.capabilities(); }
63
+ async syncClaimed(alarm, currentOccurrence) { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); try {
64
+ if (!occurrence) {
65
+ if (this.adapter.removeFromRunner)
66
+ await this.adapter.removeFromRunner(alarm.id, currentOccurrence);
67
+ else
68
+ await this.adapter.remove(alarm.id);
69
+ await this.ensureGuardAbsent(alarm.id);
70
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: true, message: 'Claimed occurrence has no enabled future native job.' });
71
+ return null;
72
+ }
73
+ const capability = this.adapter.capabilities();
74
+ if (!capability.supported)
75
+ throw new Error(capability.message);
76
+ if (this.adapter.installFromRunner)
77
+ await this.adapter.installFromRunner(alarm, occurrence, currentOccurrence);
78
+ else
79
+ await this.adapter.install(alarm, occurrence);
80
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: true, message: 'Next native occurrence registered by active runner.', nextOccurrence: occurrence.toISOString() });
81
+ await this.reconcileGuard(alarm);
82
+ return occurrence;
83
+ }
84
+ catch (error) {
85
+ this.health.record({ alarmId: alarm.id, component: 'scheduler', healthy: false, message: errorMessage(error), ...(occurrence ? { nextOccurrence: occurrence.toISOString() } : {}) });
86
+ throw error;
87
+ } }
88
+ async completeOccurrence(alarmId, occurrence) { await this.adapter.completeOccurrence?.(alarmId, occurrence); }
89
+ async remove(alarmId) { await this.ensureGuardAbsent(alarmId); try {
90
+ await this.adapter.remove(alarmId);
91
+ }
92
+ catch (error) {
93
+ this.health.record({ alarmId, component: 'scheduler', healthy: false, message: `Native job removal failed; the alarm definition was retained: ${errorMessage(error)}` });
94
+ throw error;
95
+ } this.health.remove(alarmId); }
96
+ async statusAll(alarms) { return mapConcurrent(alarms, 4, async (alarm) => { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); return { alarmId: alarm.id, nextOccurrence: occurrence?.toISOString() ?? null, native: occurrence ? await this.adapter.status(alarm.id) : { installed: false, healthy: true, message: 'No enabled future occurrence; no native job is expected.' }, health: this.health.get(alarm.id) }; }); }
97
+ async runtimeStatus(alarms = []) { return { capabilities: this.doctor(), entries: this.health.list(), alarms: await this.statusAll(alarms) }; }
98
+ async reconcileGuard(alarm) { if (!this.guard)
99
+ return; try {
100
+ const requested = alarm.enabled && alarm.reliability.keepAwakeUntilAlarm;
101
+ if (requested)
102
+ await this.guard.start(alarm);
103
+ else
104
+ await this.ensureGuardAbsent(alarm.id);
105
+ this.health.record({ alarmId: alarm.id, component: 'power', healthy: true, active: requested, message: requested ? 'Alarm Guard reconciled.' : 'Alarm Guard verified absent.' });
106
+ }
107
+ catch (error) {
108
+ const message = `Alarm Guard reconciliation failed: ${errorMessage(error)}`;
109
+ this.health.record({ alarmId: alarm.id, component: 'power', healthy: false, active: true, message });
110
+ throw new GuardReconcileError(message);
111
+ } }
112
+ async ensureGuardAbsent(alarmId) { if (!this.guard)
113
+ return; const stopped = await this.guard.stop(alarmId); if (stopped)
114
+ return; const status = await this.guard.status?.(); if (status && !status.guards.some(item => item.alarmId === alarmId))
115
+ return; const message = 'Alarm Guard teardown could not be verified; the alarm definition was retained for repair.'; this.health.record({ alarmId, component: 'power', healthy: false, active: true, message }); throw new GuardReconcileError(message); }
116
+ }
117
+ class GuardReconcileError extends Error {
118
+ }
119
+ async function settledOperation(work) { try {
120
+ await work();
121
+ return {};
122
+ }
123
+ catch (error) {
124
+ return { error: errorMessage(error) };
125
+ } }
126
+ export function createSchedulerService(deps = {}) {
127
+ return new SchedulerService(createSchedulerAdapter(deps), () => new Date(), new AlarmRuntimeHealthStore(), new AlarmGuardService(new AlarmPowerGuardStore()));
128
+ }
129
+ export function createSchedulerAdapter(deps = {}) {
130
+ const platform = deps.platform ?? process.platform;
131
+ const home = deps.home ?? homedir();
132
+ const nodePath = deps.nodePath ?? process.execPath;
133
+ const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
134
+ const env = deps.env ?? process.env;
135
+ const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }); });
136
+ const removeFile = deps.removeFile ?? (path => rmSync(path, { force: true }));
137
+ const run = deps.run ?? runCommand;
138
+ const commandExists = deps.commandExists ?? (command => existsSync(command) || Boolean(process.env.PATH?.split(platform === 'win32' ? ';' : ':').some(path => existsSync(join(path, command)))));
139
+ const common = { home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run };
140
+ if (platform === 'darwin')
141
+ return launchdAdapter(common);
142
+ if (platform === 'win32')
143
+ return windowsAdapter(common);
144
+ if (platform === 'linux' && commandExists('systemctl'))
145
+ return systemdAdapter(common);
146
+ return unsupportedAdapter(platform === 'linux'
147
+ ? 'User scheduling requires systemd. This Linux session is unsupported; RadioCLI will not claim the alarm is registered.'
148
+ : `Alarm scheduling is not supported on ${platform}.`);
149
+ }
150
+ function runtimeEnvironment(common) {
151
+ const result = { RADIOCLI_ALARM_TERMINAL: common.terminal };
152
+ for (const key of ['RADIOCLI_HOME', 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR']) {
153
+ const value = common.env[key];
154
+ if (value)
155
+ result[key] = value;
156
+ }
157
+ return result;
158
+ }
159
+ const jobName = (id) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`;
160
+ const invocationArgs = (common, id, occurrence, internalCommand = 'internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()];
161
+ export function shouldRunLaunchdOccurrence(scheduledAt, now) { if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(scheduledAt))
162
+ throw new Error('Launchd occurrence gate requires an absolute instant.'); const scheduled = new Date(scheduledAt); if (!Number.isFinite(scheduled.getTime()) || !Number.isFinite(now.getTime()))
163
+ throw new Error('Invalid launchd occurrence gate time.'); return now.getTime() >= scheduled.getTime(); }
164
+ function launchdAdapter(common) {
165
+ const directory = join(common.home, 'Library', 'LaunchAgents');
166
+ const pathFor = (id) => join(directory, `${jobName(id)}.plist`);
167
+ const occurrenceLabel = (id, occurrence) => `${jobName(id)}.${createHash('sha256').update(occurrence.toISOString()).digest('hex').slice(0, 12)}`;
168
+ const occurrencePath = (id, occurrence) => join(directory, `${occurrenceLabel(id, occurrence)}.plist`);
169
+ const pathsFor = (id) => { const prefix = `${jobName(id)}.`; let extras = []; try {
170
+ extras = readdirSync(directory).filter(name => name.startsWith(prefix) && name.endsWith('.plist')).map(name => join(directory, name));
171
+ }
172
+ catch { } return [pathFor(id), ...extras]; };
173
+ const installJob = async (alarm, occurrence, label, path, replace) => { const local = localParts(occurrence); const entries = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join(''); const envArgs = `<key>EnvironmentVariables</key><dict>${entries}</dict>`; const args = [common.nodePath, ...invocationArgs(common, alarm.id, occurrence, 'internal-launchd')].map(value => `<string>${xml(value)}</string>`).join(''); common.write(path, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args}</array>${envArgs}<key>StartCalendarInterval</key><dict><key>Month</key><integer>${local.month}</integer><key>Day</key><integer>${local.day}</integer><key>Hour</key><integer>${local.hour}</integer><key>Minute</key><integer>${local.minute}</integer></dict><key>ProcessType</key><string>Interactive</string><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string><!-- launchd repeats this calendar pattern annually; the idempotent runner gates, removes, or reschedules it. --></dict></plist>\n`); if (replace)
174
+ await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]).catch(() => undefined); ensureSuccess(await common.run('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootstrap'); };
175
+ return {
176
+ capabilities: () => ({ supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.' }),
177
+ async install(alarm, occurrence) { await installJob(alarm, occurrence, jobName(alarm.id), pathFor(alarm.id), true); },
178
+ async installFromRunner(alarm, occurrence) { const label = occurrenceLabel(alarm.id, occurrence); const existing = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]); if (existing.code === 0)
179
+ return; await installJob(alarm, occurrence, label, occurrencePath(alarm.id, occurrence), false); },
180
+ async removeFromRunner() { },
181
+ async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); const path = existsSync(specific) ? specific : pathFor(id); const label = path.split('/').at(-1).slice(0, -6); let failure; try {
182
+ const result = await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]);
183
+ if (result.code !== 0)
184
+ failure = new Error(`launchctl bootout failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
185
+ }
186
+ catch (error) {
187
+ failure = error;
188
+ } if (failure) {
189
+ let status;
190
+ try {
191
+ status = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]);
192
+ }
193
+ catch (error) {
194
+ throw new Error(`Unable to verify completed launchd job removal: ${errorMessage(error)}`);
195
+ }
196
+ if (status.code === 0)
197
+ throw new Error(`Completed launchd job is still loaded: ${errorMessage(failure)}`);
198
+ if (!isLaunchdServiceNotFound(status, label))
199
+ throw new Error(`Unable to verify completed launchd job absence: ${(status.stderr || status.stdout || `exit ${status.code}`).trim()}`);
200
+ } common.removeFile(path); },
201
+ async remove(id) { for (const path of pathsFor(id)) {
202
+ await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]).catch(() => undefined);
203
+ common.removeFile(path);
204
+ } },
205
+ async status(id) { const paths = pathsFor(id).filter(existsSync); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); if (!paths.length)
206
+ return { installed: false, healthy: false, message: 'LaunchAgent artifact missing' }; const results = await Promise.all(paths.map(path => common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${path.split('/').at(-1).slice(0, -6)}`]))); const registered = results.some(result => result.code === 0); return { installed: registered, healthy: registered && executable, message: !registered ? 'launchd registration missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
207
+ };
208
+ }
209
+ function systemdAdapter(common) {
210
+ const base = join(common.home, '.config', 'systemd', 'user');
211
+ const names = (id) => ({ service: `${jobName(id)}.service`, timer: `${jobName(id)}.timer` });
212
+ return {
213
+ capabilities: () => ({ supported: true, exactWake: false, catchUpAfterWake: true, message: 'systemd user timers catch up after login/wake; WakeSystem is not claimed without system privileges.' }),
214
+ async install(alarm, occurrence) { const n = names(alarm.id); const env = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd = [common.nodePath, ...invocationArgs(common, alarm.id, occurrence)].map(systemdQuote).join(' '); common.write(join(base, n.service), `[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base, n.timer), `[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl', ['--user', 'enable', '--now', n.timer]), 'systemctl enable'); },
215
+ async remove(id) { const n = names(id); await common.run('systemctl', ['--user', 'disable', '--now', n.timer]).catch(() => undefined); common.removeFile(join(base, n.timer)); common.removeFile(join(base, n.service)); await common.run('systemctl', ['--user', 'daemon-reload']).catch(() => undefined); },
216
+ async status(id) { const n = names(id); const result = await common.run('systemctl', ['--user', 'is-enabled', n.timer]); const artifact = existsSync(join(base, n.timer)) && existsSync(join(base, n.service)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: result.code === 0 && artifact, healthy: result.code === 0 && artifact && executable, message: result.code !== 0 ? 'user timer not enabled' : !artifact ? 'systemd unit artifact missing' : !executable ? 'RadioCLI executable missing' : 'enabled and executable' }; }
217
+ };
218
+ }
219
+ function windowsAdapter(common) {
220
+ const xmlPath = (id) => join(common.env.LOCALAPPDATA ?? join(common.home, 'AppData', 'Local'), 'RadioCLI', 'scheduler', `${jobName(id)}.xml`);
221
+ return {
222
+ capabilities: () => ({ supported: true, exactWake: false, catchUpAfterWake: true, message: 'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.' }),
223
+ async install(alarm, occurrence) { const name = `\\RadioCLI\\${jobName(alarm.id)}`; const command = windowsCommandLine(common.nodePath, invocationArgs(common, alarm.id, occurrence)); const environment = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `set "${key}=${cmdEscape(value)}"`).join(' && '); const raw = `${environment} && ${command}`; const body = `<?xml version="1.0" encoding="UTF-8"?><Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><RegistrationInfo><Description>${xml(alarm.label)}</Description></RegistrationInfo><Triggers><TimeTrigger><StartBoundary>${windowsLocalBoundary(occurrence)}</StartBoundary><Enabled>true</Enabled></TimeTrigger></Triggers><Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><StartWhenAvailable>true</StartWhenAvailable><WakeToRun>${alarm.reliability.wakeIfSupported}</WakeToRun><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy></Settings><Actions Context="Author"><Exec><Command>cmd.exe</Command><Arguments>/d /s /c &quot;${xml(raw)}&quot;</Arguments></Exec></Actions></Task>`; const path = xmlPath(alarm.id); common.write(path, body); ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create'); },
224
+ async remove(id) { await common.run('schtasks.exe', ['/Delete', '/TN', `\\RadioCLI\\${jobName(id)}`, '/F']).catch(() => undefined); common.removeFile(xmlPath(id)); },
225
+ async status(id) { const result = await common.run('schtasks.exe', ['/Query', '/TN', `\\RadioCLI\\${jobName(id)}`]); const artifact = existsSync(xmlPath(id)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: result.code === 0 && artifact, healthy: result.code === 0 && artifact && executable, message: result.code !== 0 ? 'task not registered' : !artifact ? 'task XML artifact missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
226
+ };
227
+ }
228
+ function unsupportedAdapter(message) { return { capabilities: () => ({ supported: false, exactWake: false, catchUpAfterWake: false, message }), install: async () => { throw new Error(message); }, remove: async () => { }, status: async () => ({ installed: false, healthy: false, message }) }; }
229
+ function runCommand(command, args) { return new Promise(resolve => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', v => stdout += String(v)); child.stderr.on('data', v => stderr += String(v)); child.on('error', e => resolve({ code: 127, stdout, stderr: e.message })); child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); }); }
230
+ function ensureSuccess(result, label) { if (result.code !== 0)
231
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); }
232
+ function xml(value) { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;'); }
233
+ function systemdQuote(value) { if (/[\r\n\0]/.test(value))
234
+ throw new Error('Scheduler arguments cannot contain control characters.'); return `"${value.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; }
235
+ function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
236
+ function windowsCommandLine(command, args) { return [command, ...args].map(windowsArg).join(' '); }
237
+ function windowsArg(value) { return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`; }
238
+ function localParts(date) { return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds() }; }
239
+ function systemdCalendar(date) { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; }
240
+ function windowsLocalBoundary(date) { const p = localParts(date); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}T${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}`; }
241
+ function unitText(value) { return value.replace(/[\r\n\0]/g, ' ').replaceAll('%', '%%').slice(0, 200); }
242
+ function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
243
+ function isLaunchdServiceNotFound(result, label) { const output = `${result.stderr}\n${result.stdout}`; return result.code !== 0 && output.includes(label) && /could not find service\b/i.test(output) && /\bin domain for\b/i.test(output); }
244
+ async function mapConcurrent(items, limit, work) { const results = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (true) {
245
+ const index = next;
246
+ next += 1;
247
+ if (index >= items.length)
248
+ return;
249
+ results[index] = await work(items[index]);
250
+ } })); return results; }
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { PlayerController } from '../player/player-controller.js';
6
+ import { detectPlaybackBackends } from '../player/backend-install.js';
7
+ import { ProviderManager } from '../providers/provider-manager.js';
8
+ import { connectActiveAlarm, startActiveAlarmSession } from './active-session.js';
9
+ import { createPowerInhibitor } from './inhibitor.js';
10
+ import { createSystemVolumeController } from './system-volume.js';
11
+ import { verifyAlarmTerminalLaunch } from './terminal-launcher.js';
12
+ const definitions = [
13
+ { id: 'scheduler', label: 'Native scheduler', critical: true },
14
+ { id: 'wake', label: 'Wake and catch-up policy', critical: false },
15
+ { id: 'terminal', label: 'Ringing terminal', critical: true },
16
+ { id: 'controls', label: 'Dismiss and snooze controls', critical: true },
17
+ { id: 'playback', label: 'Station and audio backend', critical: true },
18
+ { id: 'volume', label: 'System output volume', critical: false },
19
+ { id: 'power', label: 'Sleep protection', critical: false },
20
+ { id: 'cleanup', label: 'Verification cleanup', critical: true }
21
+ ];
22
+ export async function verifyAlarmSetup(scheduler, alarm, settings, onUpdate = () => { }, deps = {}) {
23
+ const now = deps.now ?? (() => new Date());
24
+ const startedAt = now().toISOString();
25
+ let report = { state: 'running', alarmLabel: alarm?.label, startedAt, steps: definitions.map(item => ({ ...item, state: 'pending', detail: 'Waiting…' })) };
26
+ const publish = () => { report = { ...report, steps: report.steps.map(step => ({ ...step })) }; onUpdate(report); };
27
+ const set = (id, state, detail) => { const step = report.steps.find(item => item.id === id); if (step) {
28
+ step.state = state;
29
+ step.detail = detail;
30
+ } publish(); };
31
+ publish();
32
+ const target = alarm?.playback.volume ?? settings.volume;
33
+ let temporary;
34
+ let schedulerInstalled = false;
35
+ let powerLease;
36
+ let volumeLease;
37
+ try {
38
+ set('scheduler', 'running', 'Installing a disposable native job…');
39
+ try {
40
+ const capability = scheduler.doctor();
41
+ if (!capability.supported)
42
+ throw new Error(capability.message);
43
+ temporary = temporaryAlarm(alarm, now(), deps.id?.() ?? randomUUID());
44
+ const occurrence = await scheduler.sync(temporary);
45
+ if (!occurrence)
46
+ throw new Error('The disposable alarm did not receive a future occurrence.');
47
+ schedulerInstalled = true;
48
+ const [status] = await scheduler.statusAll([temporary]);
49
+ if (!status?.native.installed || !status.native.healthy)
50
+ throw new Error(status?.native.message ?? 'The native job could not be verified.');
51
+ set('scheduler', 'passed', `Registered and queried a disposable ${platformSchedulerName()} job.`);
52
+ if (alarm?.reliability.wakeIfSupported)
53
+ set('wake', capability.exactWake ? 'passed' : 'warning', capability.exactWake ? 'This scheduler reports exact wake support.' : `The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`);
54
+ else
55
+ set('wake', 'passed', capability.catchUpAfterWake ? 'Wake was not requested; the native scheduler supports catch-up after the machine wakes.' : 'Wake was not requested for this alarm.');
56
+ }
57
+ catch (error) {
58
+ set('scheduler', 'failed', messageOf(error));
59
+ set('wake', 'warning', 'Wake behavior could not be assessed because native scheduler verification failed.');
60
+ }
61
+ set('terminal', 'running', 'Opening the saved terminal and waiting for its authenticated response…');
62
+ try {
63
+ const terminal = await (deps.terminalProbe ?? (() => verifyAlarmTerminalLaunch()))();
64
+ set('terminal', 'passed', `Opened ${friendlyTerminal(terminal)} and received its private loopback response.`);
65
+ }
66
+ catch (error) {
67
+ set('terminal', 'failed', messageOf(error));
68
+ }
69
+ set('controls', 'running', 'Testing the same authenticated local channel used by dismiss and snooze…');
70
+ try {
71
+ await (deps.controlProbe ?? probeControlChannel)();
72
+ set('controls', 'passed', 'Authenticated local ringing controls connected successfully.');
73
+ }
74
+ catch (error) {
75
+ set('controls', 'failed', messageOf(error));
76
+ }
77
+ const inhibitor = deps.inhibitor ?? createPowerInhibitor();
78
+ set('power', 'running', 'Acquiring a real sleep-inhibition lease…');
79
+ try {
80
+ const capability = inhibitor.status();
81
+ if (!capability.supported)
82
+ throw new Error(capability.message);
83
+ powerLease = await inhibitor.acquire('RadioCLI alarm setup verification');
84
+ set('power', 'passed', capability.message);
85
+ void powerLease.unexpectedExit?.then(error => set('power', 'warning', `Sleep protection exited during the rehearsal: ${error.message}`)).catch(() => { });
86
+ }
87
+ catch (error) {
88
+ set('power', 'warning', `Playback can continue, but sleep protection was not verified: ${messageOf(error)}`);
89
+ }
90
+ const volume = deps.systemVolume ?? createSystemVolumeController();
91
+ set('volume', 'running', `Raising and unmuting local output to at least ${target}%…`);
92
+ try {
93
+ volumeLease = await volume.acquireMinimum(target);
94
+ set('volume', 'passed', `${volumeLease.message} The previous setting will be restored after the sample.`);
95
+ }
96
+ catch (error) {
97
+ set('volume', 'warning', `Player volume will still be applied, but OS output control was not verified: ${messageOf(error)}`);
98
+ }
99
+ set('playback', 'running', alarm ? `Playing a short sample of ${alarm.station.name}…` : 'Waiting for a configured alarm station…');
100
+ if (!alarm)
101
+ set('playback', 'failed', 'Create at least one alarm so verification can test its real station, backend, and configured volume.');
102
+ else
103
+ try {
104
+ const backends = (deps.backends ?? (() => detectPlaybackBackends()))();
105
+ if (!backends.length)
106
+ throw new Error('No local playback backend is installed. Install mpv, ffplay, or VLC.');
107
+ const runtimeSettings = { ...settings, volume: target, preferredBackend: 'auto', preferredAirPlayDevice: undefined, tuneTimeoutSeconds: Math.min(5, settings.tuneTimeoutSeconds) };
108
+ const player = (deps.player ?? (value => new PlayerController(() => value)))(runtimeSettings);
109
+ player.refreshDetectedBackends();
110
+ const resolver = deps.resolve ?? (station => new ProviderManager().resolve(station));
111
+ try {
112
+ const stream = await bounded(resolver(alarm.station), 8_000, 'Station resolution timed out.');
113
+ await bounded(player.play(alarm.station, stream.url), 8_000, 'Audio backend did not become ready.');
114
+ await (deps.wait ?? wait)(3_000);
115
+ set('playback', 'passed', `Played a 3-second sample using an available local backend at the configured ${target}% alarm volume.`);
116
+ }
117
+ finally {
118
+ try {
119
+ await bounded(player.stop(), 2_000, 'Audio preview cleanup timed out.');
120
+ }
121
+ catch (error) {
122
+ set('playback', 'failed', `The sample started, but audio cleanup failed: ${messageOf(error)}`);
123
+ }
124
+ }
125
+ }
126
+ catch (error) {
127
+ set('playback', 'failed', messageOf(error));
128
+ }
129
+ }
130
+ finally {
131
+ try {
132
+ await volumeLease?.release();
133
+ }
134
+ catch (error) {
135
+ set('volume', 'warning', `The sample ran, but the previous system volume could not be restored: ${messageOf(error)}`);
136
+ }
137
+ try {
138
+ await powerLease?.release();
139
+ }
140
+ catch (error) {
141
+ set('power', 'warning', `Sleep protection was acquired, but release could not be verified: ${messageOf(error)}`);
142
+ }
143
+ set('cleanup', 'running', 'Removing disposable native scheduler artifacts…');
144
+ if (temporary)
145
+ try {
146
+ await scheduler.remove(temporary.id);
147
+ schedulerInstalled = false;
148
+ set('cleanup', 'passed', 'Disposable scheduler job and local verification artifacts were removed.');
149
+ }
150
+ catch (error) {
151
+ set('cleanup', 'failed', `Manual repair is required because disposable job cleanup failed: ${messageOf(error)}`);
152
+ }
153
+ else
154
+ set('cleanup', 'passed', 'No disposable native job was created.');
155
+ if (schedulerInstalled)
156
+ set('cleanup', 'failed', 'The disposable native job may still be installed; run Alarm Repair before relying on alarms.');
157
+ }
158
+ const failed = report.steps.some(step => step.state === 'failed' && step.critical);
159
+ const warning = report.steps.some(step => step.state === 'warning' || step.state === 'failed');
160
+ report = { ...report, state: failed ? 'failed' : warning ? 'warning' : 'passed', finishedAt: now().toISOString() };
161
+ publish();
162
+ return report;
163
+ }
164
+ function temporaryAlarm(source, now, id) { const at = new Date(Math.ceil((now.getTime() + 10 * 60_000) / 60_000) * 60_000); return { id: `setup-verification-${id}`, label: 'RadioCLI setup verification', enabled: true, station: source?.station ?? { id: 'verification', provider: 'playlist', name: 'RadioCLI verification', tags: [], streamUrl: 'https://127.0.0.1/' }, schedule: { type: 'once', at: at.toISOString() }, playback: { volume: source?.playback.volume ?? 40, fadeSeconds: 0, stopAfterMinutes: 1 }, reliability: { missedRunGraceMinutes: 1, wakeIfSupported: false }, createdAt: now.toISOString(), updatedAt: now.toISOString() }; }
165
+ async function probeControlChannel() { const root = mkdtempSync(join(tmpdir(), 'radiocli-alarm-verify-')); const file = join(root, 'probe.json'); const server = await startActiveAlarmSession({ alarmId: 'setup-verification', scheduledAt: new Date().toISOString(), stationName: 'RadioCLI verification', startedAt: new Date().toISOString() }, { filePath: file, onDismiss: () => { }, onSnooze: () => { }, onKeepPlaying: () => { } }); try {
166
+ const client = await connectActiveAlarm(file);
167
+ if (!client)
168
+ throw new Error('The ringing control channel was not discoverable.');
169
+ const status = await client.status();
170
+ if (status.alarmId !== 'setup-verification')
171
+ throw new Error('The ringing control channel returned the wrong alarm identity.');
172
+ }
173
+ finally {
174
+ await server.close();
175
+ rmSync(root, { recursive: true, force: true });
176
+ } }
177
+ function platformSchedulerName() { return process.platform === 'darwin' ? 'launchd' : process.platform === 'win32' ? 'Task Scheduler' : 'systemd'; }
178
+ function friendlyTerminal(value) { return value.replace(/^darwin:/, '').replace(/^win32:/, '').replace(/^linux:/, ''); }
179
+ function messageOf(error) { return error instanceof Error ? error.message : String(error); }
180
+ function wait(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); }
181
+ async function bounded(promise, milliseconds, message) { let timer; try {
182
+ return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]);
183
+ }
184
+ finally {
185
+ if (timer)
186
+ clearTimeout(timer);
187
+ } }
@@ -0,0 +1,43 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveCommand } from '../player/command.js';
3
+ export function createSystemVolumeController(platform = process.platform, run = runCommand, resolve = resolveCommand) {
4
+ const backend = platform === 'darwin' ? macBackend(run) : platform === 'linux' ? linuxBackend(run, resolve) : platform === 'win32' ? windowsBackend(run, resolve) : undefined;
5
+ return { async acquireMinimum(volume) { if (!backend)
6
+ throw new Error(`System output volume control is unavailable on ${platform}.`); const target = clamp(volume); const before = await backend.read(); const applied = Math.max(before.volume, target); if (before.muted || before.volume < target)
7
+ await backend.write({ volume: applied, muted: false }); let released = false; return { message: before.muted || before.volume < target ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`, async release() { if (released)
8
+ return; released = true; if (before.muted || before.volume < target)
9
+ await backend.write(before); } }; } };
10
+ }
11
+ function macBackend(run) { return { async read() { const result = await checked(run('/usr/bin/osascript', ['-e', 'set s to get volume settings', '-e', 'return (output volume of s as text) & "," & (output muted of s as text)']), 'read macOS output volume'); const match = /^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim()); if (!match)
12
+ throw new Error('macOS returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value) { await checked(run('/usr/bin/osascript', ['-e', `set volume output volume ${clamp(value.volume)} ${value.muted ? 'with' : 'without'} output muted`]), 'set macOS output volume'); } }; }
13
+ function linuxBackend(run, resolve) {
14
+ const wpctl = resolve('wpctl');
15
+ if (wpctl)
16
+ return { async read() { const result = await checked(run(wpctl, ['get-volume', '@DEFAULT_AUDIO_SINK@']), 'read PipeWire output volume'); const match = /Volume:\s*([\d.]+)/i.exec(result.stdout); if (!match)
17
+ throw new Error('wpctl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1]) * 100), muted: /\[MUTED\]/i.test(result.stdout) }; }, async write(value) { await checked(run(wpctl, ['set-volume', '@DEFAULT_AUDIO_SINK@', `${clamp(value.volume)}%`]), 'set PipeWire output volume'); await checked(run(wpctl, ['set-mute', '@DEFAULT_AUDIO_SINK@', value.muted ? '1' : '0']), 'set PipeWire mute state'); } };
18
+ const pactl = resolve('pactl');
19
+ if (pactl)
20
+ return { async read() { const [volume, mute] = await Promise.all([checked(run(pactl, ['get-sink-volume', '@DEFAULT_SINK@']), 'read PulseAudio output volume'), checked(run(pactl, ['get-sink-mute', '@DEFAULT_SINK@']), 'read PulseAudio mute state')]); const match = /(\d+)%/.exec(volume.stdout); if (!match)
21
+ throw new Error('pactl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: /yes/i.test(mute.stdout) }; }, async write(value) { await checked(run(pactl, ['set-sink-volume', '@DEFAULT_SINK@', `${clamp(value.volume)}%`]), 'set PulseAudio output volume'); await checked(run(pactl, ['set-sink-mute', '@DEFAULT_SINK@', value.muted ? '1' : '0']), 'set PulseAudio mute state'); } };
22
+ const amixer = resolve('amixer');
23
+ if (amixer)
24
+ return { async read() { const result = await checked(run(amixer, ['get', 'Master']), 'read ALSA output volume'); const matches = [...result.stdout.matchAll(/\[(\d+)%\]/g)]; const last = matches.at(-1); if (!last)
25
+ throw new Error('amixer returned an unreadable output-volume state.'); return { volume: clamp(Number(last[1])), muted: /\[off\]/i.test(result.stdout) }; }, async write(value) { await checked(run(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); } };
26
+ return undefined;
27
+ }
28
+ function windowsBackend(run, resolve) { const powershell = resolve('powershell.exe') ?? resolve('pwsh.exe'); if (!powershell)
29
+ return undefined; return { async read() { const result = await checked(run(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Get()`]), 'read Windows output volume'); const match = /^(\d+)\s*,\s*(true|false)/im.exec(result.stdout); if (!match)
30
+ throw new Error('Windows returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value) { await checked(run(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted ? 'true' : 'false'})`]), 'set Windows output volume'); } }; }
31
+ const windowsCoreAudio = String.raw `Add-Type -TypeDefinition @'
32
+ using System; using System.Runtime.InteropServices;
33
+ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, eMultimedia, eCommunications }
34
+ [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class MMDeviceEnumerator {}
35
+ [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] interface IMMDeviceEnumerator { int EnumAudioEndpoints(EDataFlow a,uint b,out object c); int GetDefaultAudioEndpoint(EDataFlow a,ERole b,out IMMDevice c); }
36
+ [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] interface IMMDevice { int Activate(ref Guid id,uint context,IntPtr parameters,[MarshalAs(UnmanagedType.IUnknown)] out object result); }
37
+ [ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] interface IAudioEndpointVolume { int RegisterControlChangeNotify(IntPtr p); int UnregisterControlChangeNotify(IntPtr p); int GetChannelCount(out uint c); int SetMasterVolumeLevel(float l,Guid g); int SetMasterVolumeLevelScalar(float l,Guid g); int GetMasterVolumeLevel(out float l); int GetMasterVolumeLevelScalar(out float l); int SetChannelVolumeLevel(uint c,float l,Guid g); int SetChannelVolumeLevelScalar(uint c,float l,Guid g); int GetChannelVolumeLevel(uint c,out float l); int GetChannelVolumeLevelScalar(uint c,out float l); int SetMute([MarshalAs(UnmanagedType.Bool)] bool m,Guid g); int GetMute(out bool m); }
38
+ public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMDevice d;((IMMDeviceEnumerator)new MMDeviceEnumerator()).GetDefaultAudioEndpoint(EDataFlow.eRender,ERole.eMultimedia,out d);object o;Guid id=typeof(IAudioEndpointVolume).GUID;d.Activate(ref id,23,IntPtr.Zero,out o);return(IAudioEndpointVolume)o;} public static string Get(){float v;bool m;var e=Endpoint();e.GetMasterVolumeLevelScalar(out v);e.GetMute(out m);return Math.Round(v*100)+","+m.ToString().ToLower();} public static void Set(int v,bool m){var e=Endpoint();e.SetMasterVolumeLevelScalar(Math.Max(0,Math.Min(100,v))/100f,Guid.Empty);e.SetMute(m,Guid.Empty);} }
39
+ '@`;
40
+ function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); }
41
+ async function checked(promise, label) { const result = await promise; if (result.code !== 0)
42
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); return result; }
43
+ function runCommand(command, args) { return new Promise(resolve => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', value => stdout += String(value)); child.stderr.on('data', value => stderr += String(value)); child.on('error', error => resolve({ code: 127, stdout, stderr: error.message })); child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); }); }