@ciphore/radiocli 0.2.3 → 0.2.4

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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -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;
package/dist/cli.js CHANGED
@@ -5,21 +5,31 @@ import { resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { ProviderManager } from './providers/provider-manager.js';
7
7
  import { PlayerController } from './player/player-controller.js';
8
- import { JsonLibraryStore } from './storage/store.js';
9
- import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
8
+ import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
9
+ import { parsePlaylistFile, writeM3u } from './playlists/playlist.js';
10
+ import { importStreamUrl } from './streams/import-stream.js';
10
11
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
11
- import { resolveCommand } from './player/command.js';
12
+ import { resolveCommand } from './platform/executables.js';
12
13
  import { diagnoseCommand } from './player/command-diagnostics.js';
13
14
  import { airPlaySenderHealth } from './player/airplay-sender-health.js';
14
15
  import { appVersion } from './version.js';
15
16
  import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
16
17
  import { runAlarmCommand } from './alarms/cli.js';
18
+ import { createSchedulerAdapter } from './alarms/scheduler.js';
17
19
  import { runSetup } from './setup.js';
18
20
  import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
19
21
  import { decodeAgentCommand } from './agent/service.js';
20
22
  import { runHeadlessAgentHost } from './agent/headless-host.js';
21
23
  import { configureMcpIntegrations } from './agent/mcp-install.js';
22
24
  import { defaultAgentControlSettings } from './types.js';
25
+ import { identifyPlatform } from './platform/runtime.js';
26
+ import { platformCapabilities } from './platform/capabilities.js';
27
+ import { hasGraphicalSession } from './platform/desktop.js';
28
+ import { detectPackageManager } from './platform/packages.js';
29
+ import { storageReadiness } from './platform/storage.js';
30
+ import { resolveTerminalCapabilities } from './platform/terminal.js';
31
+ import { networkDiagnostic } from './platform/network.js';
32
+ import { assessPlatformSupport } from './platform/support.js';
23
33
  const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
24
34
  if (isDirectRun(process.argv[1], import.meta.url)) {
25
35
  const args = process.argv.slice(2);
@@ -27,11 +37,7 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
27
37
  const encoded = args[1];
28
38
  if (!encoded)
29
39
  throw new Error('Missing RadioCLI agent startup request.');
30
- const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
31
- render(_jsx(App, { initialAgentCommand: decodeAgentCommand(encoded) }), {
32
- exitOnCtrlC: false,
33
- kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
34
- });
40
+ await renderTui(encoded);
35
41
  }
36
42
  else if (args[0] === 'agent-host') {
37
43
  await runHeadlessAgentHost();
@@ -43,16 +49,27 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
43
49
  });
44
50
  }
45
51
  else {
46
- const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
47
- render(_jsx(App, {}), {
48
- // App owns Ctrl+C so it can confirm before performing a clean shutdown.
49
- exitOnCtrlC: false,
50
- kittyKeyboard: {
51
- mode: 'auto',
52
- flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']
53
- }
54
- });
52
+ await renderTui();
53
+ }
54
+ }
55
+ async function renderTui(encodedCommand) {
56
+ if (!process.stdin.isTTY) {
57
+ console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).');
58
+ printHelp();
59
+ process.exitCode = 1;
60
+ return;
55
61
  }
62
+ const { configureTerminalRenderer } = await import('./ui/terminal-renderer.js');
63
+ const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
64
+ const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
65
+ render(_jsx(App, { initialAgentCommand: encodedCommand ? decodeAgentCommand(encodedCommand) : undefined }), {
66
+ // App owns Ctrl+C so it can confirm before performing a clean shutdown.
67
+ exitOnCtrlC: false,
68
+ isScreenReaderEnabled: terminal.screenReader,
69
+ interactive: terminal.interactive ? undefined : false,
70
+ debug: !terminal.interactive,
71
+ kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
72
+ });
56
73
  }
57
74
  export async function runCommand(args) {
58
75
  const [command, ...rest] = args;
@@ -90,13 +107,23 @@ export async function runCommand(args) {
90
107
  if (command === 'doctor') {
91
108
  const backends = detectPlaybackBackends();
92
109
  const mpvDiagnostic = diagnoseCommand('mpv');
110
+ const report = await doctorReport(backends, mpvDiagnostic);
93
111
  if (rest.includes('--json')) {
94
- console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2));
112
+ console.log(JSON.stringify(report, null, 2));
95
113
  return;
96
114
  }
97
115
  console.log(`backends=${backends.join(',') || 'none'}`);
98
116
  printMpvDiagnostic(mpvDiagnostic);
99
117
  printPlaybackBackendStatus(backends);
118
+ for (const [name, capability] of Object.entries(report.capabilities)) {
119
+ console.log(`capability_${name}=${capability.status} ${capability.message}`);
120
+ }
121
+ console.log(`network=${report.network.status} ${report.network.message}`);
122
+ console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`);
123
+ console.log(`support_scope=${report.support.scope}`);
124
+ console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.');
125
+ for (const reason of report.support.reasons)
126
+ console.log(`support_note=${reason}`);
100
127
  return;
101
128
  }
102
129
  if (command === 'update') {
@@ -172,12 +199,20 @@ export async function runCommand(args) {
172
199
  return;
173
200
  }
174
201
  if (command === 'import') {
175
- const file = rest[0];
176
- if (!file) {
177
- throw new Error('Usage: radiocli import <playlist.m3u|playlist.pls|playlist.xspf>');
202
+ const target = rest[0];
203
+ if (!target) {
204
+ throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
178
205
  }
179
- const stations = parsePlaylistFile(file);
180
206
  const store = new JsonLibraryStore();
207
+ if (/^https?:\/\//i.test(target)) {
208
+ const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
209
+ store.addImported([result.station]);
210
+ console.log(`imported=${result.station.name}`);
211
+ if (result.warning)
212
+ console.log(`warning=${result.warning}`);
213
+ return;
214
+ }
215
+ const stations = parsePlaylistFile(target);
181
216
  store.addImported(stations);
182
217
  console.log(`imported=${stations.length}`);
183
218
  return;
@@ -190,17 +225,6 @@ export async function runCommand(args) {
190
225
  console.log(`exported=${file}`);
191
226
  return;
192
227
  }
193
- if (command === 'add-url') {
194
- const url = rest[0];
195
- if (!url || !/^https?:\/\//i.test(url)) {
196
- throw new Error('Usage: radiocli add-url <stream-url> [station name]');
197
- }
198
- const station = stationFromUrl(url, rest.slice(1).join(' ') || url);
199
- const store = new JsonLibraryStore();
200
- store.addImported([station]);
201
- console.log(`added=${station.name}`);
202
- return;
203
- }
204
228
  }
205
229
  export function printHelp() {
206
230
  console.log(`RadioCLI
@@ -217,9 +241,8 @@ Usage:
217
241
  radiocli update --install Install the latest release and repair enabled MCP entries
218
242
  radiocli countries Print top countries
219
243
  radiocli search <query> Search public stations
220
- radiocli import <file> Import .m3u, .pls, or .xspf streams
244
+ radiocli import <target> Import a stream URL or local playlist
221
245
  radiocli export [file] Export favorites/imports as .m3u
222
- radiocli add-url <url> [name]
223
246
  radiocli alarm <command> Manage alarms and scheduled radio
224
247
  `);
225
248
  }
@@ -235,7 +258,7 @@ export function isDirectRun(entryPath, moduleUrl) {
235
258
  }
236
259
  }
237
260
  function isKnownCommand(command) {
238
- return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm', 'mcp', 'agent'].includes(command);
261
+ return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
239
262
  }
240
263
  function printSetupHelp() {
241
264
  console.log(`RadioCLI Setup
@@ -251,7 +274,9 @@ Usage:
251
274
  radiocli setup --mcp --headless-agent Opt out of external terminal windows
252
275
  radiocli setup --no-mcp Disable and remove agent MCP entries
253
276
  radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
254
- apt, dnf, pacman, apk, or zypper
277
+ apt, dnf, pacman, apk, zypper,
278
+ pkg (FreeBSD), pkg_add, pkgin,
279
+ termux-pkg (Termux), or pkgman (Haiku)
255
280
  `);
256
281
  }
257
282
  function printPlaybackBackendStatus(backends) {
@@ -259,14 +284,46 @@ function printPlaybackBackendStatus(backends) {
259
284
  console.log(line);
260
285
  }
261
286
  }
262
- function doctorReport(backends, mpvDiagnostic) {
287
+ async function doctorReport(backends, mpvDiagnostic) {
263
288
  const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
264
289
  const airPlay = airPlaySenderHealth();
290
+ const host = identifyPlatform();
291
+ const terminal = resolveTerminalCapabilities(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
292
+ let scheduler;
293
+ try {
294
+ const adapter = createSchedulerAdapter();
295
+ scheduler = await adapter.probeCapabilities?.();
296
+ }
297
+ catch {
298
+ scheduler = { supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.' };
299
+ }
300
+ const integrationCommands = [
301
+ 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit',
302
+ 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer',
303
+ 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel',
304
+ 'termux-open-url', 'termux-clipboard-set'
305
+ ].filter(command => resolveCommand(command) !== null);
306
+ const capabilities = platformCapabilities(host, {
307
+ backends,
308
+ commands: integrationCommands,
309
+ graphicalSession: hasGraphicalSession(host),
310
+ packageManager: detectPackageManager(process.platform, host.osRelease),
311
+ storageWritable: libraryWritesAvailable(),
312
+ scheduler,
313
+ unicode: terminal.unicode,
314
+ color: terminal.colorLevel > 0,
315
+ screenReader: terminal.screenReader
316
+ });
265
317
  return {
266
318
  radioCliVersion: appVersion(),
267
319
  nodeVersion: process.version,
268
320
  platform: process.platform,
269
321
  architecture: process.arch,
322
+ host: { id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl },
323
+ support: { scope: 'current-installation', ...assessPlatformSupport(host) },
324
+ capabilities,
325
+ terminal,
326
+ network: networkDiagnostic(),
270
327
  backends,
271
328
  commands,
272
329
  mpv: redactDiagnostic(mpvDiagnostic),
@@ -281,6 +338,14 @@ function doctorReport(backends, mpvDiagnostic) {
281
338
  guidance: playbackBackendStatusLines(backends)
282
339
  };
283
340
  }
341
+ function libraryWritesAvailable() {
342
+ try {
343
+ return storageReadiness(defaultLibraryPath()).status === 'available';
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ }
284
349
  function printMpvDiagnostic(diagnostic) {
285
350
  console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
286
351
  console.log(`mpv_discovery=${diagnostic.discovery}`);
@@ -0,0 +1,53 @@
1
+ import { browserCommands, clipboardCandidates } from './desktop.js';
2
+ import { nativeAdapters } from './runtime.js';
3
+ const available = (message, adapter) => ({ status: 'available', message, ...(adapter ? { adapter } : {}) });
4
+ const unavailable = (message) => ({ status: 'unavailable', message });
5
+ const unverified = (message, adapter) => ({ status: 'unverified', message, ...(adapter ? { adapter } : {}) });
6
+ /**
7
+ * Availability is independent per feature. Tool discovery never substitutes for
8
+ * desktop, scheduler, audio-device, permission, or upstream runtime verification.
9
+ */
10
+ export function platformCapabilities(host, evidence) {
11
+ const adapters = nativeAdapters(host);
12
+ const commands = new Set(evidence.commands);
13
+ const has = (name) => commands.has(name);
14
+ const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name));
15
+ const desktop = evidence.graphicalSession;
16
+ const opener = browserCommands(host).find(item => has(item.command));
17
+ const clipboard = clipboardCandidates(host).find(item => has(item.command));
18
+ const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl'
19
+ : adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null;
20
+ const backgroundScheduling = evidence.scheduler
21
+ ? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message)
22
+ : schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined)
23
+ : unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`);
24
+ const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null;
25
+ const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null
26
+ : adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has)
27
+ : adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null;
28
+ const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.')
29
+ : evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.')
30
+ : unverified('Local JSON data uses the existing private user paths; writability is checked when used.');
31
+ return {
32
+ playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'),
33
+ playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv')
34
+ : unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'),
35
+ packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'),
36
+ storage,
37
+ filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'),
38
+ atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'),
39
+ externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'),
40
+ clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'),
41
+ terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'),
42
+ backgroundScheduling,
43
+ catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.')
44
+ : backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'),
45
+ wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'),
46
+ sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'),
47
+ systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'),
48
+ unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'),
49
+ color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'),
50
+ screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'),
51
+ airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.')
52
+ };
53
+ }
@@ -0,0 +1,36 @@
1
+ import { identifyPlatform } from './runtime.js';
2
+ /** Command plans only. Callers must check the session and observe execution. */
3
+ export function browserCommands(host = identifyPlatform()) {
4
+ // termux-tools opens one URL through Android's VIEW intent.
5
+ if (host.id === 'termux')
6
+ return [{ command: 'termux-open-url', args: [] }];
7
+ if (host.id === 'darwin')
8
+ return [{ command: 'open', args: [] }];
9
+ if (host.id === 'win32')
10
+ return [{ command: 'explorer', args: [] }];
11
+ if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
12
+ return [{ command: 'xdg-open', args: [] }];
13
+ return [];
14
+ }
15
+ export function clipboardCandidates(host = identifyPlatform()) {
16
+ // No arguments makes termux-clipboard-set read stdin. Its matching Android
17
+ // API app and permissions are still required; the caller checks execution.
18
+ if (host.id === 'termux')
19
+ return [{ command: 'termux-clipboard-set', args: [] }];
20
+ if (host.id === 'darwin')
21
+ return [{ command: 'pbcopy', args: [] }];
22
+ if (host.id === 'win32')
23
+ return [{ command: 'clip', args: [] }];
24
+ if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
25
+ return [
26
+ { command: 'wl-copy', args: [] },
27
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
28
+ { command: 'xsel', args: ['--clipboard', '--input'] }
29
+ ];
30
+ return [];
31
+ }
32
+ export function hasGraphicalSession(host, env = process.env) {
33
+ if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux')
34
+ return !env.SSH_CONNECTION && !env.SSH_TTY;
35
+ return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
36
+ }