@ciphore/radiocli 0.2.3 → 0.2.5

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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,81 +1,32 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
2
  import { createServer } from 'node:net';
4
3
  import { randomBytes } from 'node:crypto';
5
- import { basename, delimiter, join } from 'node:path';
6
- const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']);
7
- export function detectAlarmTerminal(platform = process.platform, env = process.env) {
8
- const configured = env.RADIOCLI_ALARM_TERMINAL?.trim();
9
- if (configured && validDescriptor(configured, platform))
10
- return configured;
11
- if (platform === 'darwin') {
12
- const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase();
13
- if (program.includes('iterm'))
14
- return 'darwin:iterm';
15
- if (program.includes('wezterm'))
16
- return 'darwin:wezterm';
17
- if (program.includes('ghostty'))
18
- return 'darwin:ghostty';
19
- if (program.includes('kitty'))
20
- return 'darwin:kitty';
21
- return 'darwin:apple-terminal';
22
- }
23
- if (platform === 'win32')
24
- return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console';
25
- if (platform === 'linux') {
26
- const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim();
27
- const resolved = requested ? resolveExecutable(requested, env) : undefined;
28
- if (resolved && linuxTerminals.has(basename(resolved)))
29
- return `linux:${resolved}`;
30
- for (const command of ['ghostty', 'wezterm', 'kitty', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator']) {
31
- const path = resolveExecutable(command, env);
32
- if (path)
33
- return `linux:${path}`;
34
- }
35
- }
36
- return `${platform}:unsupported`;
37
- }
4
+ import { listenLoopback } from '../platform/loopback.js';
5
+ import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
6
+ import { detectGraphicalTerminal, launchTerminalCommand } from '../platform/terminals.js';
38
7
  export async function openAlarmControls(options) {
39
8
  if (options.hasLiveTui?.())
40
9
  return { opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.' };
41
- const platform = options.platform ?? process.platform;
42
- const env = options.env ?? process.env;
43
- const terminal = detectAlarmTerminal(platform, env);
44
- const command = shellCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME);
45
- const directArgs = environmentCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME);
46
- const launch = options.spawn ?? spawnDetached;
47
- if (terminal === 'darwin:apple-terminal')
48
- await launched(launch('/usr/bin/osascript', appleTerminalScript(command)));
49
- else if (terminal === 'darwin:iterm')
50
- await launched(launch('/usr/bin/osascript', iTermScript(command)));
51
- else if (terminal === 'darwin:wezterm')
52
- await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...directArgs]));
53
- else if (terminal === 'darwin:ghostty')
54
- await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...directArgs]));
55
- else if (terminal === 'darwin:kitty')
56
- await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...directArgs]));
57
- else if (terminal === 'win32:windows-terminal')
58
- await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm', options.nodePath, options.cliPath]));
59
- else if (terminal === 'win32:console')
60
- await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm', 'cmd.exe', '/k', windowsCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME)]));
61
- else if (terminal.startsWith('linux:')) {
62
- const executable = terminal.slice('linux:'.length);
63
- if (!linuxTerminals.has(basename(executable)))
64
- throw new Error('Saved Linux terminal is not supported.');
65
- const name = basename(executable);
66
- const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
67
- await launched(launch(executable, [...prefix, ...directArgs]));
10
+ const terminal = await launchTerminalCommand({ ...options, args: [options.cliPath], title: 'RadioCLI Alarm' });
11
+ if (!options.hasLiveTui)
12
+ return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.' };
13
+ const deadline = Date.now() + (options.timeoutMs ?? 8_000);
14
+ while (true) {
15
+ if (options.hasLiveTui())
16
+ return { opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.' };
17
+ const remaining = deadline - Date.now();
18
+ if (remaining <= 0)
19
+ return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.' };
20
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
68
21
  }
69
- else
70
- throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.');
71
- return { opened: true, terminal, message: 'Opened RadioCLI alarm controls in the saved terminal.' };
72
22
  }
73
23
  /** Ask for macOS Automation access while the user is configuring the alarm. */
74
24
  export async function prepareAlarmTerminalAccess(options = {}) {
75
25
  const platform = options.platform ?? process.platform;
76
- if (platform !== 'darwin')
26
+ const env = options.env ?? process.env;
27
+ if (nativeAdapters(identifyPlatform({ platform, env })).terminal !== 'macos')
77
28
  return;
78
- const terminal = detectAlarmTerminal(platform, options.env ?? process.env);
29
+ const terminal = detectGraphicalTerminal(platform, env, options.resolve);
79
30
  const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined;
80
31
  if (!application)
81
32
  return;
@@ -94,88 +45,77 @@ export async function verifyAlarmTerminalLaunch(options = {}) {
94
45
  const platform = options.platform ?? process.platform;
95
46
  const env = options.env ?? process.env;
96
47
  await prepareAlarmTerminalAccess(options);
97
- const terminal = detectAlarmTerminal(platform, env);
48
+ const terminal = detectGraphicalTerminal(platform, env, options.resolve);
98
49
  if (terminal.endsWith(':unsupported'))
99
50
  throw new Error('No supported graphical terminal was found for automatic alarm controls.');
100
51
  const token = randomBytes(24).toString('base64url');
101
52
  const server = createServer();
102
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); });
103
- const address = server.address();
104
- if (!address || typeof address === 'string') {
105
- server.close();
106
- throw new Error('Unable to create the terminal verification channel.');
107
- }
108
- const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),'127.0.0.1',()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));";
109
- const direct = [options.nodePath ?? process.execPath, '-e', script, String(address.port), token];
110
- const command = `${direct.map(shellQuote).join(' ')}; exit`;
111
- const launch = options.spawn ?? spawnDetached;
112
- const received = new Promise((resolve, reject) => { server.once('connection', socket => { let value = ''; socket.setEncoding('utf8'); socket.on('data', chunk => value += chunk); socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.'))); socket.on('error', reject); }); });
53
+ const address = await listenLoopback(server);
54
+ const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));";
55
+ const nodePath = options.nodePath ?? process.execPath;
56
+ const args = ['-e', script, String(address.port), token, address.host];
57
+ const sockets = new Set();
58
+ const received = new Promise((resolve, reject) => {
59
+ server.on('connection', socket => {
60
+ sockets.add(socket);
61
+ socket.once('close', () => sockets.delete(socket));
62
+ let value = '';
63
+ socket.setEncoding('utf8');
64
+ socket.on('data', chunk => {
65
+ value += chunk;
66
+ if (value.length > token.length) {
67
+ socket.destroy();
68
+ reject(new Error('The terminal verification response was not authentic.'));
69
+ }
70
+ });
71
+ socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.')));
72
+ socket.on('error', reject);
73
+ });
74
+ });
75
+ // A callback can fail while the launcher is still reporting its own result.
76
+ // Keep the rejection observed until it is awaited below.
77
+ void received.catch(() => undefined);
113
78
  try {
114
- if (terminal === 'darwin:apple-terminal')
115
- await launched(launch('/usr/bin/osascript', appleTerminalScript(command)));
116
- else if (terminal === 'darwin:iterm')
117
- await launched(launch('/usr/bin/osascript', iTermScript(command)));
118
- else if (terminal === 'darwin:wezterm')
119
- await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
120
- else if (terminal === 'darwin:ghostty')
121
- await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
122
- else if (terminal === 'darwin:kitty')
123
- await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
124
- else if (terminal === 'win32:windows-terminal')
125
- await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm Verification', ...direct]));
126
- else if (terminal === 'win32:console')
127
- await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm Verification', 'cmd.exe', '/c', windowsCommandArgs(direct)]));
128
- else if (terminal.startsWith('linux:')) {
129
- const executable = terminal.slice(6);
130
- const name = basename(executable);
131
- const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
132
- await launched(launch(executable, [...prefix, ...direct]));
133
- }
134
- else
135
- throw new Error('The saved terminal is not supported for alarm controls.');
79
+ await launchTerminalCommand({ ...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true });
136
80
  await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.');
137
81
  return terminal;
138
82
  }
139
83
  finally {
84
+ for (const socket of sockets)
85
+ socket.destroy();
140
86
  await new Promise(resolve => server.close(() => resolve()));
141
87
  }
142
88
  }
143
- function validDescriptor(value, platform) {
144
- if (platform === 'darwin')
145
- return ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'].includes(value);
146
- if (platform === 'win32')
147
- return value === 'win32:windows-terminal' || value === 'win32:console';
148
- if (platform === 'linux' && value.startsWith('linux:'))
149
- return linuxTerminals.has(basename(value.slice(6)));
150
- return false;
89
+ function spawnAttached(command, args) {
90
+ return spawn(command, [...args], { stdio: 'ignore', windowsHide: true });
151
91
  }
152
- function resolveExecutable(input, env) {
153
- if (input.includes('/') && existsSync(input))
154
- return input;
155
- for (const directory of (env.PATH ?? '').split(delimiter)) {
156
- const path = join(directory, input);
157
- if (existsSync(path))
158
- return path;
159
- }
160
- return undefined;
92
+ function completed(child, timeoutMs) {
93
+ return new Promise((resolve, reject) => {
94
+ let settled = false;
95
+ const finish = (work) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ work();
101
+ };
102
+ const timer = setTimeout(() => {
103
+ child.kill('SIGTERM');
104
+ finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.')));
105
+ }, timeoutMs);
106
+ child.once('error', error => finish(() => reject(error)));
107
+ child.once('close', code => finish(() => resolve(code ?? 1)));
108
+ });
161
109
  }
162
- function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
163
- function shellCommand(nodePath, cliPath, home) { return `${home ? `RADIOCLI_HOME=${shellQuote(home)} ` : ''}${shellQuote(nodePath)} ${shellQuote(cliPath)}`; }
164
- function environmentCommand(nodePath, cliPath, home) { return home ? ['/usr/bin/env', `RADIOCLI_HOME=${home}`, nodePath, cliPath] : [nodePath, cliPath]; }
165
- function windowsCommand(nodePath, cliPath, home) { return `${home ? `set "RADIOCLI_HOME=${cmdEscape(home)}" && ` : ''}"${nodePath.replaceAll('"', '""')}" "${cliPath.replaceAll('"', '""')}"`; }
166
- function windowsCommandArgs(values) { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); }
167
- function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
168
- function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
169
- function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
170
- function spawnDetached(command, args) { return spawn(command, [...args], { detached: true, stdio: 'ignore', windowsHide: false }); }
171
- function spawnAttached(command, args) { return spawn(command, [...args], { stdio: 'ignore', windowsHide: true }); }
172
- function launched(child) { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); }
173
- function completed(child, timeoutMs) { return new Promise((resolve, reject) => { let settled = false; const finish = (work) => { if (settled)
174
- return; settled = true; clearTimeout(timer); work(); }; const timer = setTimeout(() => { child.kill('SIGTERM'); finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); }, timeoutMs); child.once('error', error => finish(() => reject(error))); child.once('close', code => finish(() => resolve(code ?? 1))); }); }
175
- async function withTimeout(promise, milliseconds, message) { let timer; try {
176
- return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]);
110
+ async function withTimeout(promise, milliseconds, message) {
111
+ let timer;
112
+ try {
113
+ return await Promise.race([promise, new Promise((_, reject) => {
114
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
115
+ })]);
116
+ }
117
+ finally {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ }
177
121
  }
178
- finally {
179
- if (timer)
180
- clearTimeout(timer);
181
- } }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
2
+ import { platformPaths } from '../platform/paths.js';
3
3
  import { join } from 'node:path';
4
4
  export function registerTuiPresence(root = runtimeDirectory(), pid = process.pid) {
5
5
  const directory = join(root, 'tui');
@@ -26,9 +26,7 @@ export function hasLiveTui(root = runtimeDirectory(), alive = processAlive) {
26
26
  }
27
27
  return false;
28
28
  }
29
- function runtimeDirectory() { if (process.env.RADIOCLI_HOME)
30
- return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
31
- return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
29
+ function runtimeDirectory() { return platformPaths().runtime; }
32
30
  function processAlive(pid) { try {
33
31
  process.kill(pid, 0);
34
32
  return true;
@@ -0,0 +1,212 @@
1
+ export const listenerCommands = ['play', 'pause', 'resume', 'stop', 'next', 'previous', 'volume', 'mute', 'unmute', 'now', 'status', 'search', 'countries', 'favorites', 'recent', 'favorite', 'unfavorite', 'preset', 'presets', 'stats', 'completion'];
2
+ const commonFlags = ['--json', '--plain', '--quiet', '-q', '--no-input', '--no-color'];
3
+ const filterFlags = ['--country', '--language', '--codec', '--min-bitrate', '--limit'];
4
+ const commandOptions = {
5
+ play: ['--favorites', '--recent', '--popular', '--pick', '--source', '--random', '--keep-playing', ...filterFlags],
6
+ search: ['--pick', ...filterFlags],
7
+ favorites: ['--pick', '--limit'], recent: ['--pick', '--limit'],
8
+ countries: ['--limit'], now: ['--watch'], status: ['--watch']
9
+ };
10
+ const booleans = new Set([...commonFlags, '--favorites', '--recent', '--popular', '--pick', '--watch', '--random', '--keep-playing']);
11
+ export function isListenerCommand(value) {
12
+ return listenerCommands.includes(value);
13
+ }
14
+ export function parseListenerArgs(command, args) {
15
+ const options = new Map();
16
+ const words = [];
17
+ const allowed = new Set([...commonFlags, ...commandOptions[command] ?? []]);
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ const arg = args[index];
20
+ if (arg === '--') {
21
+ words.push(...args.slice(index + 1));
22
+ break;
23
+ }
24
+ if (!arg.startsWith('-')) {
25
+ words.push(arg);
26
+ continue;
27
+ }
28
+ const equal = arg.indexOf('=');
29
+ const key = equal < 0 ? arg : arg.slice(0, equal);
30
+ if (!allowed.has(key))
31
+ throw new Error(`Unknown option: ${key}\nRun radiocli ${command} --help.`);
32
+ if (options.has(key))
33
+ throw new Error(`Option ${key} was supplied more than once.`);
34
+ if (booleans.has(key)) {
35
+ if (equal >= 0)
36
+ throw new Error(`${key} does not take a value.`);
37
+ options.set(key, true);
38
+ }
39
+ else {
40
+ const value = equal >= 0 ? arg.slice(equal + 1) : args[++index];
41
+ if (!value || value.startsWith('--'))
42
+ throw new Error(`${key} requires a value. Run radiocli ${command} --help.`);
43
+ options.set(key, value);
44
+ }
45
+ }
46
+ const flags = { json: options.has('--json'), plain: options.has('--plain'), quiet: options.has('--quiet') || options.has('-q'), noInput: options.has('--no-input'), noColor: options.has('--no-color') };
47
+ if (flags.json && flags.plain)
48
+ throw new Error('Choose either --json or --plain.');
49
+ if ((options.has('--pick') || options.has('--watch')) && (flags.json || flags.plain || flags.quiet || flags.noInput)) {
50
+ throw new Error('--pick and --watch require interactive output. Omit --json, --plain, --quiet, and --no-input.');
51
+ }
52
+ const sources = ['--favorites', '--recent', '--popular', '--source'].filter(option => options.has(option));
53
+ if (sources.length > 1)
54
+ throw new Error('Choose one station queue: --favorites, --recent, --popular, or --source.');
55
+ if (sources[0] && sources[0] !== '--source')
56
+ options.set('--source', sources[0] === '--favorites' ? 'favorite' : sources[0].slice(2));
57
+ if (options.get('--source') === 'favorites')
58
+ options.set('--source', 'favorite');
59
+ return { command, words, options, flags };
60
+ }
61
+ export function integerOption(value, fallback, label, minimum, maximum) {
62
+ if (value === undefined)
63
+ return fallback;
64
+ if (typeof value !== 'string' || !/^\d+$/.test(value) || Number(value) < minimum || Number(value) > maximum)
65
+ throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
66
+ return Number(value);
67
+ }
68
+ const help = {
69
+ play: `play [station name|provider:id|preset] [options]
70
+ Start background playback, or control the open TUI. With no station, continue
71
+ the current station or play your most recent station when nothing is selected.
72
+ Paused playback resumes; playing audio continues uninterrupted.
73
+ Exact saved names and presets work offline.
74
+ Ambiguous names open a numbered chooser only in an interactive terminal.
75
+
76
+ --favorites Play from your favorites
77
+ --recent Play from your recently played stations
78
+ --popular Play from the directory's popular stations
79
+ --random Pick randomly from the chosen queue or search
80
+ --pick Choose a station by number
81
+ --keep-playing Keep an already playing or paused station
82
+ --country CODE --language NAME --codec NAME --min-bitrate KBPS
83
+ --limit 1-100 Search result count (default 20)
84
+
85
+ --source recent|favorites|popular|country also works; favorite is accepted
86
+ for compatibility. Explicit queue or filter options select a station anew.
87
+
88
+ Examples:
89
+ radiocli play jazz
90
+ radiocli pause
91
+ radiocli play
92
+ radiocli play "Groove Salad"
93
+ radiocli play --favorites --random
94
+ radiocli play --country JP
95
+ radiocli play work`,
96
+ search: `search <query> [options]
97
+ Find stations. Terminal tables mark favorites with *; piped output is TSV.
98
+ --pick Choose a result and start background playback
99
+ --country CODE Filter by two-letter country code
100
+ --language NAME Filter by language
101
+ --codec NAME Filter by audio codec
102
+ --min-bitrate KBPS Minimum bitrate (0-10000)
103
+ --limit 1-100 Maximum results (default 20)
104
+
105
+ Examples:
106
+ radiocli search jazz --country US --pick
107
+ radiocli search jazz --json
108
+ radiocli search jazz --plain`,
109
+ now: `now [--watch]
110
+ Show station, track metadata, output, playback state, and volume.
111
+ --watch updates a single terminal line. Ctrl+C exits the view; audio continues.
112
+
113
+ Examples:
114
+ radiocli now
115
+ radiocli now --watch
116
+ radiocli now --json`,
117
+ status: `status [--watch]
118
+ Alias for radiocli now. Use --json for the current playback snapshot.`,
119
+ favorites: `favorites [--limit 1-100] [--pick]
120
+ List favorite stations, or select one to play with --pick.
121
+ Save the active station with radiocli favorite.`,
122
+ recent: `recent [--limit 1-100] [--pick]
123
+ List recently played stations, newest first. Use --pick to tune one again.`,
124
+ favorite: `favorite [station name|provider:id|preset]
125
+ Save a station to favorites. With no argument, save the active station.
126
+ Example: radiocli favorite "Groove Salad"`,
127
+ unfavorite: `unfavorite [station name|provider:id|preset]
128
+ Remove a saved favorite. With no argument, use the active station.`,
129
+ preset: `preset <save|list|remove> [name] [station name|provider:id|preset]
130
+ save NAME [STATION] saves a shortcut; omit STATION to save the active station.
131
+ Saving an existing name replaces its station. Names are case-insensitive,
132
+ 1-40 letters, numbers, hyphens, or underscores, starting with a letter/number.
133
+ remove NAME removes only the shortcut. Presets are included in library backups.
134
+
135
+ Examples:
136
+ radiocli preset save work
137
+ radiocli preset save jazz "Example FM"
138
+ radiocli play work
139
+ radiocli preset remove work`,
140
+ presets: `presets
141
+ List saved station shortcuts. Alias for radiocli preset list.`,
142
+ countries: `countries [--limit 1-100]
143
+ Show the most popular countries (default 30).
144
+ Example: radiocli play --country JP`,
145
+ volume: `volume <0-100>
146
+ Set playback volume, or save it for the next session when playback is closed.
147
+ Example: radiocli volume 40`,
148
+ pause: `pause
149
+ Pause the active station. Use radiocli play to continue.`,
150
+ resume: `resume
151
+ Resume paused playback. radiocli play also resumes, and can restart after stop.`,
152
+ stop: `stop
153
+ Stop ordinary playback and close its background player. Alarms are separate.`,
154
+ next: `next
155
+ Play the next station in the current search, favorites, or recent queue.`,
156
+ previous: `previous
157
+ Play the previous station in the current queue.`,
158
+ mute: `mute
159
+ Mute active playback. Use radiocli unmute to restore sound.`,
160
+ unmute: `unmute
161
+ Restore sound without changing the saved volume.`,
162
+ stats: `stats
163
+ Show listening time, sessions, streaks, and your most-listened station.
164
+ Example: radiocli stats --json`,
165
+ completion: `completion <bash|zsh|fish|powershell>
166
+ Print a shell completion script. No shell configuration is changed.
167
+
168
+ Bash: source <(radiocli completion bash)
169
+ Zsh: source <(radiocli completion zsh) # after compinit
170
+ Fish: radiocli completion fish | source
171
+ PowerShell: radiocli completion powershell | Out-String | Invoke-Expression
172
+
173
+ Add the matching line to your shell profile to enable it in future sessions.`
174
+ };
175
+ export function listenerHelp(command) {
176
+ return `RadioCLI · ${command}\n\nUsage: radiocli ${help[command]}\n\nOutput options:\n --json Structured JSON\n --plain Stable tab-separated text without decoration\n --quiet, -q Suppress successful output\n --no-input Never prompt; use an exact station ID for ambiguous matches\n --no-color Disable color\n --help, -h Show this help\n`;
177
+ }
178
+ export function completionCandidates(words, presets) {
179
+ const current = words.at(-1) ?? '';
180
+ const command = words[0] ?? '';
181
+ const previous = words.at(-2);
182
+ let candidates;
183
+ if (words.length <= 1)
184
+ candidates = [...listenerCommands, 'help', 'version', 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm'];
185
+ else if (command === 'help')
186
+ candidates = ['--all', ...listenerCommands, 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm', 'version'];
187
+ else if (command === '--help' || command === '-h')
188
+ candidates = ['--all'];
189
+ else if (previous === '--source')
190
+ candidates = ['recent', 'favorites', 'favorite', 'popular', 'country'];
191
+ else if (previous === '--country')
192
+ candidates = ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN'];
193
+ else if (previous === '--codec')
194
+ candidates = ['MP3', 'AAC', 'OGG', 'FLAC'];
195
+ else if (previous === '--language')
196
+ candidates = ['english', 'spanish', 'french', 'german', 'japanese'];
197
+ else if (previous === '--limit')
198
+ candidates = ['10', '20', '50', '100'];
199
+ else if (previous === '--min-bitrate')
200
+ candidates = ['64', '128', '192', '320'];
201
+ else if (command === 'completion')
202
+ candidates = ['bash', 'zsh', 'fish', 'powershell'];
203
+ else if (command === 'preset' && words.length === 2)
204
+ candidates = ['save', 'list', 'remove'];
205
+ else if (command === 'volume')
206
+ candidates = ['0', '20', '40', '60', '80', '100'];
207
+ else if (isListenerCommand(command))
208
+ candidates = [...commonFlags, '--help', ...commandOptions[command] ?? [], ...(['play', 'favorite', 'unfavorite', 'preset'].includes(command) ? presets : [])];
209
+ else
210
+ candidates = ['--help'];
211
+ return [...new Set(candidates)].filter(candidate => candidate.startsWith(current));
212
+ }
@@ -0,0 +1,44 @@
1
+ export function completionScript(shell) {
2
+ if (shell === 'bash')
3
+ return `# RadioCLI Bash completion
4
+ _radiocli_complete() {
5
+ local candidate
6
+ COMPREPLY=()
7
+ while IFS= read -r candidate; do
8
+ COMPREPLY+=("$candidate")
9
+ done < <(radiocli _complete "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)
10
+ }
11
+ complete -F _radiocli_complete radiocli
12
+ `;
13
+ if (shell === 'zsh')
14
+ return `# RadioCLI Zsh completion (load after compinit)
15
+ _radiocli_complete() {
16
+ local -a candidates
17
+ candidates=("\${(@f)$(radiocli _complete "\${words[@]:1:$((CURRENT - 1))}" 2>/dev/null)}")
18
+ compadd -- "\${candidates[@]}"
19
+ }
20
+ compdef _radiocli_complete radiocli
21
+ `;
22
+ if (shell === 'fish')
23
+ return `# RadioCLI Fish completion
24
+ function __radiocli_candidates
25
+ set -l words (commandline -opc)
26
+ set -e words[1]
27
+ set -l current (commandline -ct)
28
+ radiocli _complete $words "$current" 2>/dev/null
29
+ end
30
+ complete -c radiocli -f -a '(__radiocli_candidates)'
31
+ `;
32
+ if (shell === 'powershell')
33
+ return `# RadioCLI PowerShell completion
34
+ Register-ArgumentCompleter -Native -CommandName radiocli -ScriptBlock {
35
+ param($wordToComplete, $commandAst, $cursorPosition)
36
+ $words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text })
37
+ if (!$wordToComplete) { $words += '' }
38
+ & radiocli _complete @words 2>$null | ForEach-Object {
39
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
40
+ }
41
+ }
42
+ `;
43
+ throw new Error('Choose a completion shell: bash, zsh, fish, or powershell. Run radiocli completion --help.');
44
+ }