@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.
- package/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +36 -6
- package/README.md +54 -10
- package/dist/agent/headless-host.js +39 -17
- package/dist/agent/launcher.js +6 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/service.js +3 -3
- package/dist/agent/session.js +13 -29
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +56 -25
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +103 -38
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +203 -33
- package/dist/providers/cache.js +4 -26
- package/dist/providers/radio-browser.js +152 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +79 -151
- package/dist/storage/store.js +105 -52
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +33 -24
- package/dist/ui/App.js +173 -86
- package/dist/ui/AppContent.js +3 -3
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/screen-items.js +2 -2
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +14 -7
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +36 -21
- package/dist/ui/use-app-input.js +42 -22
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/update-check.js +8 -17
- 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 {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
if (nativeAdapters(identifyPlatform({ platform, env })).terminal !== 'macos')
|
|
77
28
|
return;
|
|
78
|
-
const terminal =
|
|
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 =
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 {
|
|
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() {
|
|
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,
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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
|
|
176
|
-
if (!
|
|
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 <
|
|
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', '
|
|
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,
|
|
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
|
+
}
|