@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,343 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { createServer, request } from 'node:http';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AlarmPowerGuardStore } from './power-guard-store.js';
8
+ import { nextOccurrenceForAlarm } from './schedule.js';
9
+ import { defaultAlarmRuntimeDirectory } from './runner.js';
10
+ export class AlarmGuardService {
11
+ store;
12
+ nodePath;
13
+ cliPath;
14
+ spawn;
15
+ now;
16
+ directory;
17
+ verify;
18
+ requestStop;
19
+ constructor(store, nodePath = process.execPath, cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'), spawn = spawnGuard, now = () => new Date(), directory = join(defaultAlarmRuntimeDirectory(), 'alarm-guards'), verify = challengeGuard, requestStop = requestGuardStop) {
20
+ this.store = store;
21
+ this.nodePath = nodePath;
22
+ this.cliPath = cliPath;
23
+ this.spawn = spawn;
24
+ this.now = now;
25
+ this.directory = directory;
26
+ this.verify = verify;
27
+ this.requestStop = requestStop;
28
+ }
29
+ async start(alarm) {
30
+ return this.withOwnership(alarm.id, () => this.startOwned(alarm));
31
+ }
32
+ async startOwned(alarm) {
33
+ const occurrence = nextOccurrenceForAlarm(alarm, this.now());
34
+ if (!occurrence)
35
+ throw new Error('This alarm has no future enabled occurrence.');
36
+ const occurrenceAt = occurrence.toISOString();
37
+ const path = this.path(alarm.id, occurrenceAt);
38
+ const existing = readPid(path);
39
+ if (existing && await this.verify(existing))
40
+ return { occurrenceAt, pid: existing.pid };
41
+ // A recurring alarm may have been edited/rescheduled while its old guard is
42
+ // alive. Stop that exact process tree before replacing its occurrence state.
43
+ await this.stopOwned(alarm.id);
44
+ this.store.request(alarm.id, occurrenceAt);
45
+ const token = randomBytes(32).toString('hex');
46
+ const child = this.spawn(this.nodePath, [this.cliPath, 'alarm', 'internal-guard-run', alarm.id, occurrenceAt, path, token], process.env);
47
+ if (!child.pid) {
48
+ this.store.markFailed(alarm.id, 'Unable to start alarm guard.', occurrenceAt);
49
+ throw new Error('Unable to start alarm guard.');
50
+ }
51
+ child.unref();
52
+ for (let attempt = 0; attempt < 40; attempt += 1) {
53
+ const state = this.store.get(alarm.id);
54
+ const ownership = readPid(path);
55
+ if (state?.occurrenceAt === occurrenceAt && state.status === 'active' && ownership?.token === token && await this.verify(ownership))
56
+ return { occurrenceAt, pid: child.pid };
57
+ if (state?.occurrenceAt === occurrenceAt && state.status === 'failed') {
58
+ await terminateGuardTree(child.pid);
59
+ rmSync(path, { force: true });
60
+ throw new Error(state.message ?? 'Alarm guard failed to start.');
61
+ }
62
+ if (!isAlive(child.pid))
63
+ break;
64
+ await delay(50);
65
+ }
66
+ await terminateGuardTree(child.pid);
67
+ this.store.markFailed(alarm.id, 'Alarm guard did not confirm sleep inhibition.', occurrenceAt);
68
+ rmSync(path, { force: true });
69
+ throw new Error('Alarm guard did not confirm sleep inhibition.');
70
+ }
71
+ async stop(alarmId) {
72
+ if (alarmId)
73
+ return this.withOwnership(alarmId, () => this.stopOwned(alarmId));
74
+ const ids = [...new Set(this.pidFiles().map(readPid).filter((item) => Boolean(item)).map(item => item.alarmId))];
75
+ const results = await Promise.all(ids.map(id => this.withOwnership(id, () => this.stopOwned(id))));
76
+ return results.every(Boolean);
77
+ }
78
+ async stopOwned(alarmId) {
79
+ const targets = this.pidFiles()
80
+ .map(readPid)
81
+ .filter((item) => Boolean(item))
82
+ .filter(item => !alarmId || item.alarmId === alarmId);
83
+ if (!targets.length)
84
+ return false;
85
+ for (const current of targets) {
86
+ const path = this.path(current.alarmId, current.occurrenceAt);
87
+ if (!(await this.verify(current))) {
88
+ rmSync(path, { force: true });
89
+ markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.');
90
+ continue;
91
+ }
92
+ if (!(await this.requestStop(current))) {
93
+ markIfCurrent(this.store, current, 'Guard rejected its authenticated stop request; the PID was not signaled.');
94
+ continue;
95
+ }
96
+ for (let attempt = 0; attempt < 20 && isAlive(current.pid); attempt += 1)
97
+ await delay(50);
98
+ if (isAlive(current.pid) && await this.verify(current))
99
+ await terminateGuardTree(current.pid);
100
+ const alive = isAlive(current.pid);
101
+ if (alive) {
102
+ markIfCurrent(this.store, current, 'Unable to stop the guard helper; sleep inhibition may still be active.');
103
+ continue;
104
+ }
105
+ rmSync(path, { force: true });
106
+ const state = this.store.get(current.alarmId);
107
+ if (state?.occurrenceAt === current.occurrenceAt && state.status !== 'released') {
108
+ this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt);
109
+ }
110
+ }
111
+ return targets.every(item => !isAlive(item.pid));
112
+ }
113
+ async status() {
114
+ const guards = [];
115
+ for (const path of this.pidFiles()) {
116
+ const pid = readPid(path);
117
+ if (!pid)
118
+ continue;
119
+ if (!(await this.verify(pid))) {
120
+ rmSync(path, { force: true });
121
+ markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.');
122
+ continue;
123
+ }
124
+ guards.push({ active: true, alarmId: pid.alarmId, occurrenceAt: pid.occurrenceAt, pid: pid.pid });
125
+ }
126
+ return {
127
+ active: guards.length > 0,
128
+ guards,
129
+ message: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.'
130
+ };
131
+ }
132
+ path(alarmId, occurrenceAt) {
133
+ return join(this.directory, `${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);
134
+ }
135
+ pidFiles() {
136
+ if (!existsSync(this.directory))
137
+ return [];
138
+ return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name));
139
+ }
140
+ async withOwnership(alarmId, work) { const lock = join(this.directory, `.lock-${Buffer.from(alarmId).toString('base64url')}`); mkdirSync(this.directory, { recursive: true, mode: 0o700 }); const deadline = Date.now() + 3000; while (true) {
141
+ try {
142
+ mkdirSync(lock, { mode: 0o700 });
143
+ writeFileSync(join(lock, 'created'), String(Date.now()), { mode: 0o600 });
144
+ break;
145
+ }
146
+ catch (error) {
147
+ if (error.code !== 'EEXIST')
148
+ throw error;
149
+ let stale = false;
150
+ try {
151
+ const created = Number(readFileSync(join(lock, 'created'), 'utf8'));
152
+ stale = Number.isFinite(created) ? Date.now() - created > 10_000 : Date.now() - statSync(lock).mtimeMs > 10_000;
153
+ }
154
+ catch {
155
+ try {
156
+ stale = Date.now() - statSync(lock).mtimeMs > 10_000;
157
+ }
158
+ catch { }
159
+ }
160
+ if (stale) {
161
+ rmSync(lock, { recursive: true, force: true });
162
+ continue;
163
+ }
164
+ if (Date.now() > deadline)
165
+ throw new Error('Alarm Guard ownership is busy.');
166
+ await delay(20);
167
+ }
168
+ } try {
169
+ return await work();
170
+ }
171
+ finally {
172
+ rmSync(lock, { recursive: true, force: true });
173
+ } }
174
+ }
175
+ export async function runAlarmGuard(alarmId, occurrenceAtText, inhibitor, store = new AlarmPowerGuardStore(), now = () => new Date(), wait = delay, pidPath, ownershipToken) {
176
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(occurrenceAtText))
177
+ throw new Error('Guard occurrence must be an absolute ISO-8601 instant.');
178
+ const occurrence = new Date(occurrenceAtText);
179
+ if (!Number.isFinite(occurrence.getTime()))
180
+ throw new Error('Invalid guard occurrence.');
181
+ if (occurrence.getTime() <= now().getTime())
182
+ throw new Error('The guard occurrence has already passed.');
183
+ const occurrenceAt = occurrence.toISOString();
184
+ let lease;
185
+ let released = false;
186
+ let control;
187
+ let stopped = false;
188
+ let resolveSignal = () => { };
189
+ const signal = new Promise(resolve => { resolveSignal = resolve; });
190
+ const handler = () => { stopped = true; resolveSignal(); };
191
+ process.once('SIGTERM', handler);
192
+ process.once('SIGHUP', handler);
193
+ process.once('SIGINT', handler);
194
+ try {
195
+ lease = await inhibitor.acquire('Keep awake until RadioCLI alarm');
196
+ if (pidPath && ownershipToken)
197
+ control = await startGuardControl(pidPath, ownershipToken, alarmId, occurrenceAt, handler);
198
+ if (!store.get(alarmId))
199
+ store.request(alarmId, occurrenceAt);
200
+ store.markActive(alarmId, now(), occurrenceAt);
201
+ const deadline = occurrence.getTime() + 60_000;
202
+ const inhibitorExit = lease.unexpectedExit?.then(error => { throw error; });
203
+ while (!stopped && now().getTime() < deadline) {
204
+ await Promise.race([wait(Math.min(30_000, 2_000_000_000, deadline - now().getTime())), signal, ...(inhibitorExit ? [inhibitorExit] : [])]);
205
+ }
206
+ await lease.release();
207
+ released = true;
208
+ store.markReleased(alarmId, now(), occurrenceAt);
209
+ }
210
+ catch (error) {
211
+ const state = store.get(alarmId);
212
+ const stillOwnsArtifact = !pidPath || !ownershipToken || ownsArtifact(pidPath, ownershipToken);
213
+ if (!released && stillOwnsArtifact && state?.occurrenceAt === occurrenceAt && state.status !== 'released') {
214
+ store.markFailed(alarmId, error instanceof Error ? error.message : String(error), occurrenceAt);
215
+ }
216
+ throw error;
217
+ }
218
+ finally {
219
+ process.off('SIGTERM', handler);
220
+ process.off('SIGHUP', handler);
221
+ process.off('SIGINT', handler);
222
+ if (!released)
223
+ await lease?.release().catch(() => undefined);
224
+ await control?.close();
225
+ if (pidPath && (!ownershipToken || ownsArtifact(pidPath, ownershipToken)))
226
+ rmSync(pidPath, { force: true });
227
+ }
228
+ }
229
+ function markIfCurrent(store, pid, message) {
230
+ const state = store.get(pid.alarmId);
231
+ if (state?.occurrenceAt === pid.occurrenceAt && state.status !== 'released' && state.status !== 'failed') {
232
+ store.markFailed(pid.alarmId, message, pid.occurrenceAt);
233
+ }
234
+ }
235
+ function spawnGuard(command, args, env) {
236
+ return nodeSpawn(command, args, { env, detached: true, stdio: 'ignore', windowsHide: true });
237
+ }
238
+ function readPid(path) {
239
+ if (!existsSync(path))
240
+ return undefined;
241
+ try {
242
+ const value = JSON.parse(readFileSync(path, 'utf8'));
243
+ if (!Number.isInteger(value.pid) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !value.alarmId || !/^[a-f0-9]{64}$/.test(value.token) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt)))
244
+ throw new Error();
245
+ return value;
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ }
251
+ function writePrivate(path, value) {
252
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
253
+ const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
254
+ writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
255
+ renameSync(temp, path);
256
+ if (process.platform !== 'win32')
257
+ chmodSync(path, 0o600);
258
+ }
259
+ function isAlive(pid) {
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ function ownsArtifact(path, token) {
269
+ return readPid(path)?.token === token;
270
+ }
271
+ async function startGuardControl(path, token, alarmId, occurrenceAt, onStop) { const server = createServer((req, res) => { if (req.headers.authorization !== `Bearer ${token}`) {
272
+ res.statusCode = 401;
273
+ res.end('{}');
274
+ return;
275
+ } if (req.method === 'POST' && req.url === '/stop') {
276
+ onStop();
277
+ res.end('{}');
278
+ return;
279
+ } if (req.method !== 'GET' || req.url !== '/challenge') {
280
+ res.statusCode = 404;
281
+ res.end('{}');
282
+ return;
283
+ } res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ alarmId, occurrenceAt, pid: process.pid })); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); if (!address || typeof address === 'string') {
284
+ server.close();
285
+ throw new Error('Unable to create Guard ownership endpoint.');
286
+ } writePrivate(path, { alarmId, occurrenceAt, pid: process.pid, port: address.port, token }); return { close: () => new Promise(resolve => server.close(() => resolve())) }; }
287
+ function challengeGuard(guard) { return callGuard(guard, 'GET', '/challenge', true); }
288
+ function requestGuardStop(guard) { return callGuard(guard, 'POST', '/stop', false); }
289
+ function callGuard(guard, method, path, validateIdentity) { return new Promise(resolve => { const req = request({ host: '127.0.0.1', port: guard.port, path, method, headers: { authorization: `Bearer ${guard.token}` } }, res => { let body = ''; res.on('data', value => body += String(value)); res.on('end', () => { if (!validateIdentity) {
290
+ resolve(res.statusCode === 200);
291
+ return;
292
+ } try {
293
+ const reply = JSON.parse(body);
294
+ resolve(res.statusCode === 200 && reply.alarmId === guard.alarmId && reply.occurrenceAt === guard.occurrenceAt && reply.pid === guard.pid);
295
+ }
296
+ catch {
297
+ resolve(false);
298
+ } }); }); req.once('error', () => resolve(false)); req.setTimeout(500, () => req.destroy()); req.end(); }); }
299
+ export async function terminateGuardTree(pid, ops = { platform: process.platform, isAlive, kill: (target, signal) => process.kill(target, signal), taskkill: taskkillTree, wait: delay }) {
300
+ // Kill the entire detached tree first on Windows so a dying parent cannot
301
+ // orphan its PowerShell execution-state helper.
302
+ if (ops.platform === 'win32') {
303
+ await ops.taskkill(pid, false);
304
+ }
305
+ else {
306
+ try {
307
+ ops.kill(pid, 'SIGTERM');
308
+ }
309
+ catch {
310
+ return;
311
+ }
312
+ }
313
+ for (let index = 0; index < 20 && ops.isAlive(pid); index += 1)
314
+ await ops.wait(50);
315
+ if (!ops.isAlive(pid))
316
+ return;
317
+ if (ops.platform === 'win32')
318
+ await ops.taskkill(pid, true);
319
+ else {
320
+ // The guard is a detached process-group leader; force-killing the negative
321
+ // PID guarantees its inhibitor helper cannot survive an unresponsive parent.
322
+ try {
323
+ ops.kill(-pid, 'SIGKILL');
324
+ }
325
+ catch {
326
+ try {
327
+ ops.kill(pid, 'SIGKILL');
328
+ }
329
+ catch { }
330
+ }
331
+ }
332
+ }
333
+ function taskkillTree(pid, force) {
334
+ return new Promise(resolve => {
335
+ const args = ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])];
336
+ const child = nodeSpawn('taskkill.exe', args, { stdio: 'ignore', windowsHide: true });
337
+ child.once('close', () => resolve());
338
+ child.once('error', () => resolve());
339
+ });
340
+ }
341
+ function delay(milliseconds) {
342
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
343
+ }
@@ -0,0 +1,48 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ // The script contains no interpolated data. It keeps one process-level Windows
5
+ // execution-state request alive until the parent terminates it.
6
+ const windowsInhibitorScript = (parentPid) => `$s='[DllImport("kernel32.dll")]public static extern uint SetThreadExecutionState(uint e);';Add-Type -MemberDefinition $s -Name P -Namespace R;[R.P]::SetThreadExecutionState(0x80000001)|Out-Null;try{while(Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue){Start-Sleep -Seconds 2}}finally{[R.P]::SetThreadExecutionState(0x80000000)|Out-Null}`;
7
+ export function createPowerInhibitor(deps = {}) {
8
+ const platform = deps.platform ?? process.platform;
9
+ const spawn = deps.spawn ?? spawnDetached;
10
+ const exists = deps.commandExists ?? executableExists;
11
+ const spec = platform === 'darwin'
12
+ ? { command: 'caffeinate', args: ['-i', '-w', String(process.pid)], message: 'Prevents idle system sleep; the display may sleep.' }
13
+ : platform === 'linux'
14
+ ? { command: 'systemd-inhibit', args: ['--what=sleep', '--who=RadioCLI', '--why=Scheduled radio', '--mode=block', 'sh', '-c', 'while kill -0 "$1" 2>/dev/null; do sleep 2; done', 'radiocli-inhibitor', String(process.pid)], message: 'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.' }
15
+ : platform === 'win32'
16
+ ? { command: 'powershell.exe', args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsInhibitorScript(process.pid)], message: 'Prevents idle system sleep; explicit sleep and lid policy may override it.' }
17
+ : null;
18
+ let active = false;
19
+ const supported = Boolean(spec && exists(spec.command));
20
+ return {
21
+ status: () => ({ supported, active, message: spec?.message ?? `Power inhibition is unsupported on ${platform}.` }),
22
+ async acquire(_reason) {
23
+ if (!spec || !supported)
24
+ throw new Error(spec?.message ?? `Power inhibition is unsupported on ${platform}.`);
25
+ const child = spawn(spec.command, spec.args);
26
+ const exitedEarly = await Promise.race([child.exited.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 50))]);
27
+ if (exitedEarly)
28
+ throw new Error(`${spec.command} exited before sleep inhibition became active.`);
29
+ active = true;
30
+ let released = false;
31
+ const unexpectedExit = child.exited.then(() => released ? new Promise(() => { }) : new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`));
32
+ return { unexpectedExit, async release() { if (released)
33
+ return; released = true; active = false; child.kill('SIGTERM'); if (await exitedWithin(child.exited, 1000))
34
+ return; child.kill('SIGKILL'); if (!(await exitedWithin(child.exited, 500)))
35
+ throw new Error(`${spec.command} did not terminate; sleep protection state is unknown.`); } };
36
+ }
37
+ };
38
+ }
39
+ function executableExists(command) {
40
+ if (existsSync(command))
41
+ return true;
42
+ return Boolean(process.env.PATH?.split(delimiter).some(path => existsSync(join(path, command)) || (process.platform === 'win32' && existsSync(join(path, `${command}.exe`)))));
43
+ }
44
+ function spawnDetached(command, args) {
45
+ const child = nodeSpawn(command, args, { stdio: 'ignore', windowsHide: true });
46
+ return { pid: child.pid, kill: signal => child.kill(signal), exited: new Promise(resolve => { child.once('exit', resolve); child.once('error', resolve); }) };
47
+ }
48
+ async function exitedWithin(exited, milliseconds) { return Promise.race([exited.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), milliseconds))]); }
@@ -0,0 +1,169 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ const instantSchema = z.string()
6
+ .refine(value => /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)), { message: 'Expected an absolute ISO-8601 alarm occurrence instant with Z or an offset.' })
7
+ .transform(value => new Date(value).toISOString());
8
+ const guardSchema = z.object({
9
+ alarmId: z.string().min(1),
10
+ occurrenceAt: instantSchema,
11
+ status: z.enum(['requested', 'active', 'released', 'failed']),
12
+ acquiredAt: instantSchema.optional(),
13
+ releasedAt: instantSchema.optional(),
14
+ message: z.string().max(1000).optional()
15
+ });
16
+ const fileSchema = z.object({ version: z.literal(1), guards: z.array(guardSchema) });
17
+ /** Machine-local, non-portable state for one-shot power-inhibition requests. */
18
+ export class AlarmPowerGuardStore {
19
+ filePath;
20
+ now;
21
+ constructor(filePath = defaultAlarmPowerGuardPath(), options = {}) {
22
+ this.filePath = filePath;
23
+ this.now = options.now ?? (() => new Date());
24
+ }
25
+ request(alarmId, occurrenceAt) {
26
+ const guard = guardSchema.parse({ alarmId, occurrenceAt, status: 'requested' });
27
+ return this.mutate(guards => [guard, ...guards.filter(item => item.alarmId !== alarmId)], guard.alarmId);
28
+ }
29
+ get(alarmId) {
30
+ const guard = this.read().find(item => item.alarmId === alarmId);
31
+ return guard ? structuredClone(guard) : undefined;
32
+ }
33
+ list() {
34
+ return structuredClone(this.read());
35
+ }
36
+ markActive(alarmId, acquiredAt = this.now(), occurrenceAt) {
37
+ return this.update(alarmId, guard => ({
38
+ ...assertGuardOccurrence(guard, occurrenceAt),
39
+ status: 'active',
40
+ acquiredAt: validDate(acquiredAt, 'power guard acquisition').toISOString(),
41
+ releasedAt: undefined,
42
+ message: undefined
43
+ }));
44
+ }
45
+ markFailed(alarmId, message, occurrenceAt) {
46
+ return this.update(alarmId, guard => ({ ...assertGuardOccurrence(guard, occurrenceAt), status: 'failed', message: message.trim() }));
47
+ }
48
+ markReleased(alarmId, releasedAt = this.now(), occurrenceAt) {
49
+ return this.update(alarmId, guard => ({
50
+ ...assertGuardOccurrence(guard, occurrenceAt),
51
+ status: 'released',
52
+ releasedAt: validDate(releasedAt, 'power guard release').toISOString()
53
+ }));
54
+ }
55
+ clear(alarmId) {
56
+ const release = acquireFileLock(this.filePath);
57
+ try {
58
+ const guards = this.read();
59
+ if (!guards.some(guard => guard.alarmId === alarmId))
60
+ return false;
61
+ this.write(guards.filter(guard => guard.alarmId !== alarmId));
62
+ return true;
63
+ }
64
+ finally {
65
+ release();
66
+ }
67
+ }
68
+ update(alarmId, change) {
69
+ const release = acquireFileLock(this.filePath);
70
+ try {
71
+ const guards = this.read();
72
+ const existing = guards.find(guard => guard.alarmId === alarmId);
73
+ if (!existing)
74
+ throw new Error(`Alarm power guard not found: ${alarmId}`);
75
+ const updated = guardSchema.parse(change(existing));
76
+ this.write(guards.map(guard => guard.alarmId === alarmId ? updated : guard));
77
+ return structuredClone(updated);
78
+ }
79
+ finally {
80
+ release();
81
+ }
82
+ }
83
+ mutate(change, resultId) {
84
+ const release = acquireFileLock(this.filePath);
85
+ try {
86
+ const next = change(this.read());
87
+ this.write(next);
88
+ const result = next.find(guard => guard.alarmId === resultId);
89
+ if (!result)
90
+ throw new Error(`Alarm power guard not found after update: ${resultId}`);
91
+ return structuredClone(result);
92
+ }
93
+ finally {
94
+ release();
95
+ }
96
+ }
97
+ read() {
98
+ if (!existsSync(this.filePath))
99
+ return [];
100
+ try {
101
+ return fileSchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8'))).guards;
102
+ }
103
+ catch {
104
+ const badPath = `${this.filePath}.bad`;
105
+ rmSync(badPath, { force: true });
106
+ renameSync(this.filePath, badPath);
107
+ return [];
108
+ }
109
+ }
110
+ write(guards) {
111
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
112
+ const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
113
+ try {
114
+ writeFileSync(tempPath, `${JSON.stringify({ version: 1, guards }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
115
+ renameSync(tempPath, this.filePath);
116
+ if (process.platform !== 'win32')
117
+ chmodSync(this.filePath, 0o600);
118
+ }
119
+ catch (error) {
120
+ rmSync(tempPath, { force: true });
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+ function defaultAlarmPowerGuardPath() {
126
+ if (process.env.RADIOCLI_HOME)
127
+ return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json');
128
+ if (process.platform === 'darwin') {
129
+ return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json');
130
+ }
131
+ if (process.platform === 'win32') {
132
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json');
133
+ }
134
+ return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json');
135
+ }
136
+ function validDate(date, label) {
137
+ if (!Number.isFinite(date.getTime()))
138
+ throw new Error(`Invalid ${label} time.`);
139
+ return date;
140
+ }
141
+ function assertGuardOccurrence(guard, occurrenceAt) { if (occurrenceAt && guard.occurrenceAt !== new Date(occurrenceAt).toISOString())
142
+ throw new Error('Alarm power guard occurrence was superseded.'); return guard; }
143
+ function acquireFileLock(filePath) {
144
+ const lockPath = `${filePath}.lock`;
145
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
146
+ const deadline = Date.now() + 1000;
147
+ while (true) {
148
+ try {
149
+ mkdirSync(lockPath, { mode: 0o700 });
150
+ return () => rmSync(lockPath, { recursive: true, force: true });
151
+ }
152
+ catch (error) {
153
+ if (error.code !== 'EEXIST')
154
+ throw error;
155
+ try {
156
+ if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
157
+ rmSync(lockPath, { recursive: true, force: true });
158
+ continue;
159
+ }
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (Date.now() >= deadline)
165
+ throw new Error(`Alarm power guard state is busy: ${filePath}`);
166
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
167
+ }
168
+ }
169
+ }