@ciphore/radiocli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
package/dist/setup.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { clearCommandCache, commandExists } from './player/command.js';
|
|
5
|
+
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
6
|
+
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
7
|
+
import { JsonLibraryStore } from './storage/store.js';
|
|
8
|
+
import { defaultAgentControlSettings } from './types.js';
|
|
9
|
+
const components = ['mpv', 'ffmpeg', 'vlc'];
|
|
10
|
+
const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
11
|
+
export async function runSetup(options = {}) {
|
|
12
|
+
const platform = options.platform ?? process.platform;
|
|
13
|
+
const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
|
|
14
|
+
const input = options.input ?? process.stdin;
|
|
15
|
+
const output = options.output ?? process.stdout;
|
|
16
|
+
const hasCommand = options.hasCommand ?? commandExists;
|
|
17
|
+
const parsed = parseSetupArgs(options.args ?? []);
|
|
18
|
+
const installed = detectInstalledComponents(hasCommand);
|
|
19
|
+
const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
|
|
20
|
+
writeHeader(output);
|
|
21
|
+
output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`);
|
|
22
|
+
output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
|
|
23
|
+
let selected = parsed.only ?? defaultComponents(platform, parsed.all);
|
|
24
|
+
if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
|
|
25
|
+
selected = await promptForComponents({ platform, installed, input, output });
|
|
26
|
+
}
|
|
27
|
+
let mcp = parsed.mcp;
|
|
28
|
+
if (mcp === null && !parsed.yes && isInteractive(input, output)) {
|
|
29
|
+
mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true);
|
|
30
|
+
}
|
|
31
|
+
let agentUi = parsed.agentUi;
|
|
32
|
+
if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
|
|
33
|
+
const note = platform === 'darwin'
|
|
34
|
+
? ' (macOS will ask the agent app to control Terminal on first use)'
|
|
35
|
+
: ' (opens a separate terminal window)';
|
|
36
|
+
agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
|
|
37
|
+
}
|
|
38
|
+
const plan = createSetupPlan({ platform, osRelease, packageManager, installed, selected });
|
|
39
|
+
printPlan(plan, output);
|
|
40
|
+
const missing = plan.selected.filter(component => !plan.installed[component]);
|
|
41
|
+
if (missing.length === 0) {
|
|
42
|
+
output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
|
|
43
|
+
await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
|
|
44
|
+
printVerification(output);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!plan.packageManager) {
|
|
48
|
+
if (parsed.dryRun) {
|
|
49
|
+
output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
|
|
50
|
+
await finishMcpSetup(mcp, agentUi, true, output);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
throw new Error('No supported system package manager was found. Install mpv manually, then run radiocli doctor.');
|
|
54
|
+
}
|
|
55
|
+
if (parsed.dryRun) {
|
|
56
|
+
output.write('\nDry run complete. No system packages were changed.\n');
|
|
57
|
+
await finishMcpSetup(mcp, agentUi, true, output);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (parsed.packageManager && !hasCommand(parsed.packageManager)) {
|
|
61
|
+
throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`);
|
|
62
|
+
}
|
|
63
|
+
if (!parsed.yes && isInteractive(input, output)) {
|
|
64
|
+
const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
|
|
65
|
+
if (!confirmed) {
|
|
66
|
+
output.write('Setup cancelled. No system packages were changed.\n');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (!parsed.yes) {
|
|
71
|
+
throw new Error('Setup needs an interactive terminal. Use --yes to approve the displayed commands or --dry-run to inspect them.');
|
|
72
|
+
}
|
|
73
|
+
if (plan.commands.some(command => command.program === 'sudo')) {
|
|
74
|
+
output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
|
|
75
|
+
await runVisibleCommand('sudo', ['-v']);
|
|
76
|
+
}
|
|
77
|
+
const execute = options.runCommand ?? runInstallCommand;
|
|
78
|
+
output.write('\nInstalling\n');
|
|
79
|
+
for (const command of plan.commands) {
|
|
80
|
+
await execute(command, output);
|
|
81
|
+
}
|
|
82
|
+
clearCommandCache();
|
|
83
|
+
output.write('\nSetup complete.\n');
|
|
84
|
+
await finishMcpSetup(mcp, agentUi, false, output);
|
|
85
|
+
printVerification(output);
|
|
86
|
+
}
|
|
87
|
+
export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
|
|
88
|
+
const uniqueSelected = components.filter(component => selected.includes(component));
|
|
89
|
+
const missing = uniqueSelected.filter(component => !installed[component]);
|
|
90
|
+
const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : [];
|
|
91
|
+
if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) {
|
|
92
|
+
commands.unshift({
|
|
93
|
+
component: null,
|
|
94
|
+
label: 'Scoop extras bucket',
|
|
95
|
+
program: 'scoop',
|
|
96
|
+
args: ['bucket', 'add', 'extras'],
|
|
97
|
+
display: 'scoop bucket add extras'
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
platform,
|
|
102
|
+
platformLabel: platformLabel(platform, osRelease),
|
|
103
|
+
packageManager,
|
|
104
|
+
installed,
|
|
105
|
+
selected: uniqueSelected,
|
|
106
|
+
commands
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function detectPackageManager(platform, osRelease, hasCommand = commandExists) {
|
|
110
|
+
if (platform === 'darwin')
|
|
111
|
+
return hasCommand('brew') ? 'brew' : null;
|
|
112
|
+
if (platform === 'win32')
|
|
113
|
+
return firstAvailable(['winget', 'scoop', 'choco'], hasCommand);
|
|
114
|
+
if (platform !== 'linux')
|
|
115
|
+
return null;
|
|
116
|
+
const ids = linuxReleaseIds(osRelease);
|
|
117
|
+
const preferred = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])
|
|
118
|
+
? ['apt']
|
|
119
|
+
: hasAny(ids, ['fedora', 'rhel', 'centos'])
|
|
120
|
+
? ['dnf']
|
|
121
|
+
: hasAny(ids, ['arch', 'manjaro'])
|
|
122
|
+
? ['pacman']
|
|
123
|
+
: hasAny(ids, ['alpine'])
|
|
124
|
+
? ['apk']
|
|
125
|
+
: hasAny(ids, ['opensuse', 'suse'])
|
|
126
|
+
? ['zypper']
|
|
127
|
+
: ['apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
128
|
+
return firstAvailable(preferred, hasCommand);
|
|
129
|
+
}
|
|
130
|
+
export function parseSetupArgs(args) {
|
|
131
|
+
let all = false;
|
|
132
|
+
let dryRun = false;
|
|
133
|
+
let yes = false;
|
|
134
|
+
let only = null;
|
|
135
|
+
let packageManager = null;
|
|
136
|
+
let mcp = null;
|
|
137
|
+
let agentUi = null;
|
|
138
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
139
|
+
const arg = args[index];
|
|
140
|
+
if (arg === '--all')
|
|
141
|
+
all = true;
|
|
142
|
+
else if (arg === '--dry-run')
|
|
143
|
+
dryRun = true;
|
|
144
|
+
else if (arg === '--yes' || arg === '-y')
|
|
145
|
+
yes = true;
|
|
146
|
+
else if (arg === '--mcp') {
|
|
147
|
+
if (mcp === false)
|
|
148
|
+
throw new Error('Use either --mcp or --no-mcp, not both.');
|
|
149
|
+
mcp = true;
|
|
150
|
+
}
|
|
151
|
+
else if (arg === '--no-mcp') {
|
|
152
|
+
if (mcp === true)
|
|
153
|
+
throw new Error('Use either --mcp or --no-mcp, not both.');
|
|
154
|
+
mcp = false;
|
|
155
|
+
}
|
|
156
|
+
else if (arg === '--agent-ui') {
|
|
157
|
+
if (agentUi === false)
|
|
158
|
+
throw new Error('Use either --agent-ui or --headless-agent, not both.');
|
|
159
|
+
agentUi = true;
|
|
160
|
+
}
|
|
161
|
+
else if (arg === '--headless-agent') {
|
|
162
|
+
if (agentUi === true)
|
|
163
|
+
throw new Error('Use either --agent-ui or --headless-agent, not both.');
|
|
164
|
+
agentUi = false;
|
|
165
|
+
}
|
|
166
|
+
else if (arg === '--only')
|
|
167
|
+
only = parseComponents(args[++index]);
|
|
168
|
+
else if (arg.startsWith('--only='))
|
|
169
|
+
only = parseComponents(arg.slice('--only='.length));
|
|
170
|
+
else if (arg === '--package-manager')
|
|
171
|
+
packageManager = parsePackageManager(args[++index]);
|
|
172
|
+
else if (arg.startsWith('--package-manager='))
|
|
173
|
+
packageManager = parsePackageManager(arg.slice('--package-manager='.length));
|
|
174
|
+
else
|
|
175
|
+
throw new Error(`Unknown setup option: ${arg}\nRun radiocli setup --help.`);
|
|
176
|
+
}
|
|
177
|
+
if (all && only)
|
|
178
|
+
throw new Error('Use either --all or --only, not both.');
|
|
179
|
+
if (agentUi !== null && mcp !== true)
|
|
180
|
+
throw new Error('--agent-ui and --headless-agent require --mcp.');
|
|
181
|
+
return { all, dryRun, yes, only, packageManager, mcp, agentUi };
|
|
182
|
+
}
|
|
183
|
+
async function finishMcpSetup(enabled, agentUi, dryRun, output) {
|
|
184
|
+
if (enabled === null)
|
|
185
|
+
return;
|
|
186
|
+
if (dryRun) {
|
|
187
|
+
output.write(`\nAgent integration: would be ${enabled ? 'enabled and installed for detected MCP clients' : 'disabled and removed from detected MCP clients'}.\n`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const entry = process.argv[1];
|
|
191
|
+
if (!entry)
|
|
192
|
+
throw new Error('Could not locate the RadioCLI executable for MCP setup.');
|
|
193
|
+
const results = await configureMcpIntegrations(enabled, { nodePath: process.execPath, cliPath: realpathSync(entry) }, output);
|
|
194
|
+
const failed = results.filter(result => result.status === 'failed');
|
|
195
|
+
if (failed.length > 0) {
|
|
196
|
+
throw new Error(`Playback setup finished, but ${failed.length} agent integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => result.client).join(', ')}. Run radiocli mcp status for details.`);
|
|
197
|
+
}
|
|
198
|
+
if (enabled) {
|
|
199
|
+
const store = new JsonLibraryStore();
|
|
200
|
+
const current = store.snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
201
|
+
const openUiOnPlay = agentUi ?? current.openUiOnPlay;
|
|
202
|
+
if (openUiOnPlay !== current.openUiOnPlay) {
|
|
203
|
+
store.updateSettings({ agentControl: { ...current, openUiOnPlay } });
|
|
204
|
+
}
|
|
205
|
+
output.write(openUiOnPlay
|
|
206
|
+
? '\nAgent playback: terminal TUI enabled (default). The host OS may request app-control permission on first use.\n'
|
|
207
|
+
: '\nAgent playback: headless; no separate terminal window or app-control permission is needed.\n');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function parseComponents(value) {
|
|
211
|
+
const values = value?.split(',').map(item => item.trim().toLowerCase()).filter(Boolean) ?? [];
|
|
212
|
+
if (values.length === 0 || values.some(value => !components.includes(value))) {
|
|
213
|
+
throw new Error('--only accepts a comma-separated list of: mpv, ffmpeg, vlc.');
|
|
214
|
+
}
|
|
215
|
+
return components.filter(component => values.includes(component));
|
|
216
|
+
}
|
|
217
|
+
function parsePackageManager(value) {
|
|
218
|
+
if (!value || !packageManagers.includes(value)) {
|
|
219
|
+
throw new Error(`--package-manager accepts: ${packageManagers.join(', ')}.`);
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
function packageInstallCommand(manager, component) {
|
|
224
|
+
const packages = {
|
|
225
|
+
brew: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
226
|
+
winget: { mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC' },
|
|
227
|
+
scoop: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
228
|
+
choco: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
229
|
+
apt: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
230
|
+
dnf: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
231
|
+
pacman: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
232
|
+
apk: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
233
|
+
zypper: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' }
|
|
234
|
+
};
|
|
235
|
+
const packageName = packages[manager][component];
|
|
236
|
+
let program = manager;
|
|
237
|
+
let args;
|
|
238
|
+
if (manager === 'brew')
|
|
239
|
+
args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])];
|
|
240
|
+
else if (manager === 'winget')
|
|
241
|
+
args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements'];
|
|
242
|
+
else if (manager === 'scoop')
|
|
243
|
+
args = ['install', packageName];
|
|
244
|
+
else if (manager === 'choco')
|
|
245
|
+
args = ['install', packageName, '-y'];
|
|
246
|
+
else if (manager === 'apt') {
|
|
247
|
+
program = 'sudo';
|
|
248
|
+
args = ['apt-get', 'install', '-y', packageName];
|
|
249
|
+
}
|
|
250
|
+
else if (manager === 'dnf') {
|
|
251
|
+
program = 'sudo';
|
|
252
|
+
args = ['dnf', 'install', '-y', packageName];
|
|
253
|
+
}
|
|
254
|
+
else if (manager === 'pacman') {
|
|
255
|
+
program = 'sudo';
|
|
256
|
+
args = ['pacman', '-S', '--needed', '--noconfirm', packageName];
|
|
257
|
+
}
|
|
258
|
+
else if (manager === 'apk') {
|
|
259
|
+
program = 'sudo';
|
|
260
|
+
args = ['apk', 'add', packageName];
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
program = 'sudo';
|
|
264
|
+
args = ['zypper', '--non-interactive', 'install', packageName];
|
|
265
|
+
}
|
|
266
|
+
return { component, label: componentLabel(component), program, args, display: [program, ...args].join(' ') };
|
|
267
|
+
}
|
|
268
|
+
function defaultComponents(platform, all) {
|
|
269
|
+
if (all)
|
|
270
|
+
return [...components];
|
|
271
|
+
return platform === 'darwin' ? ['mpv', 'ffmpeg'] : ['mpv'];
|
|
272
|
+
}
|
|
273
|
+
function detectInstalledComponents(hasCommand) {
|
|
274
|
+
return {
|
|
275
|
+
mpv: hasCommand('mpv'),
|
|
276
|
+
ffmpeg: hasCommand('ffmpeg') && hasCommand('ffplay'),
|
|
277
|
+
vlc: hasCommand('vlc') || hasCommand('cvlc')
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
async function promptForComponents({ platform, installed, input, output }) {
|
|
281
|
+
output.write('Choose components (installed items will be skipped):\n');
|
|
282
|
+
const selected = [];
|
|
283
|
+
if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true))
|
|
284
|
+
selected.push('mpv');
|
|
285
|
+
if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin'))
|
|
286
|
+
selected.push('ffmpeg');
|
|
287
|
+
if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false))
|
|
288
|
+
selected.push('vlc');
|
|
289
|
+
return selected;
|
|
290
|
+
}
|
|
291
|
+
async function promptYesNo(input, output, question, defaultValue) {
|
|
292
|
+
const reader = createInterface({ input, output, terminal: true });
|
|
293
|
+
try {
|
|
294
|
+
const answer = (await reader.question(`${question} ${defaultValue ? '(Y/n)' : '(y/N)'} `)).trim().toLowerCase();
|
|
295
|
+
if (!answer)
|
|
296
|
+
return defaultValue;
|
|
297
|
+
return answer === 'y' || answer === 'yes';
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
reader.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function printPlan(plan, output) {
|
|
304
|
+
output.write('\nInstallation plan\n');
|
|
305
|
+
for (const component of plan.selected) {
|
|
306
|
+
if (plan.installed[component])
|
|
307
|
+
output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
|
|
308
|
+
else {
|
|
309
|
+
const command = plan.commands.find(candidate => candidate.component === component);
|
|
310
|
+
output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (plan.selected.length === 0)
|
|
314
|
+
output.write(' No components selected\n');
|
|
315
|
+
}
|
|
316
|
+
async function runInstallCommand(command, output) {
|
|
317
|
+
const interactive = Boolean(output.isTTY);
|
|
318
|
+
const startedAt = Date.now();
|
|
319
|
+
let timer;
|
|
320
|
+
let frame = 0;
|
|
321
|
+
let stderr = '';
|
|
322
|
+
let stdout = '';
|
|
323
|
+
if (interactive) {
|
|
324
|
+
timer = setInterval(() => {
|
|
325
|
+
output.write(`\r${progressFrame(command.label, frame++, Date.now() - startedAt, output)}`);
|
|
326
|
+
}, 90);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
output.write(` installing ${command.label}...\n`);
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
const child = spawn(command.program, command.args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
|
334
|
+
child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
|
|
335
|
+
child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
|
|
336
|
+
child.once('error', reject);
|
|
337
|
+
child.once('close', code => code === 0 ? resolve() : reject(new Error(`${command.display} exited with code ${code}.${formatCommandDetail(stderr || stdout)}`)));
|
|
338
|
+
});
|
|
339
|
+
if (timer)
|
|
340
|
+
clearInterval(timer);
|
|
341
|
+
if (interactive)
|
|
342
|
+
output.write(`\r${clearLine()} ${successMark(output)} ${command.label} ready ${formatElapsed(Date.now() - startedAt)}\n`);
|
|
343
|
+
else
|
|
344
|
+
output.write(` ready ${command.label} ${formatElapsed(Date.now() - startedAt)}\n`);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (timer)
|
|
348
|
+
clearInterval(timer);
|
|
349
|
+
if (interactive)
|
|
350
|
+
output.write(`\r${clearLine()} ${failureMark(output)} ${command.label} failed ${formatElapsed(Date.now() - startedAt)}\n`);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function progressFrame(label, frame, elapsedMs, output) {
|
|
355
|
+
const width = 18;
|
|
356
|
+
const segment = 5;
|
|
357
|
+
const travel = width - segment;
|
|
358
|
+
const cycle = travel * 2;
|
|
359
|
+
const offset = frame % cycle;
|
|
360
|
+
const start = offset <= travel ? offset : cycle - offset;
|
|
361
|
+
const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? '█' : '░').join('');
|
|
362
|
+
return ` ${accent(output, '◒')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
|
|
363
|
+
}
|
|
364
|
+
function printVerification(output) {
|
|
365
|
+
clearCommandCache();
|
|
366
|
+
const backends = detectPlaybackBackends();
|
|
367
|
+
output.write('\nVerification\n');
|
|
368
|
+
output.write(` Playback backends: ${backends.join(', ') || 'none'}\n`);
|
|
369
|
+
for (const line of playbackBackendStatusLines(backends).slice(0, 3))
|
|
370
|
+
output.write(` ${line}\n`);
|
|
371
|
+
output.write('\nRun radiocli to start listening.\n');
|
|
372
|
+
}
|
|
373
|
+
function writeHeader(output) {
|
|
374
|
+
output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`);
|
|
375
|
+
output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`);
|
|
376
|
+
}
|
|
377
|
+
function platformLabel(platform, osRelease) {
|
|
378
|
+
if (platform === 'darwin')
|
|
379
|
+
return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`;
|
|
380
|
+
if (platform === 'win32')
|
|
381
|
+
return 'Windows';
|
|
382
|
+
if (platform === 'linux')
|
|
383
|
+
return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux';
|
|
384
|
+
return platform;
|
|
385
|
+
}
|
|
386
|
+
function componentLabel(component) {
|
|
387
|
+
if (component === 'ffmpeg')
|
|
388
|
+
return 'FFmpeg / ffplay';
|
|
389
|
+
if (component === 'vlc')
|
|
390
|
+
return 'VLC fallback';
|
|
391
|
+
return 'mpv';
|
|
392
|
+
}
|
|
393
|
+
function isInteractive(input, output) {
|
|
394
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
395
|
+
}
|
|
396
|
+
function accent(output, value) {
|
|
397
|
+
return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value;
|
|
398
|
+
}
|
|
399
|
+
function successMark(output) {
|
|
400
|
+
return accent(output, '✓');
|
|
401
|
+
}
|
|
402
|
+
function pendingMark(output) {
|
|
403
|
+
return accent(output, '◆');
|
|
404
|
+
}
|
|
405
|
+
function failureMark(output) {
|
|
406
|
+
return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗';
|
|
407
|
+
}
|
|
408
|
+
function colorsEnabled(output) {
|
|
409
|
+
return Boolean(output.isTTY) && !process.env.NO_COLOR;
|
|
410
|
+
}
|
|
411
|
+
function clearLine() {
|
|
412
|
+
return '\u001b[2K';
|
|
413
|
+
}
|
|
414
|
+
function formatElapsed(elapsedMs) {
|
|
415
|
+
return `${Math.max(0, Math.floor(elapsedMs / 1000))}s`;
|
|
416
|
+
}
|
|
417
|
+
function tail(value, length = 4000) {
|
|
418
|
+
return value.length > length ? value.slice(-length) : value;
|
|
419
|
+
}
|
|
420
|
+
function formatCommandDetail(value) {
|
|
421
|
+
const detail = value.trim().split('\n').slice(-3).join(' ').trim();
|
|
422
|
+
return detail ? ` ${detail}` : '';
|
|
423
|
+
}
|
|
424
|
+
async function runVisibleCommand(program, args) {
|
|
425
|
+
await new Promise((resolve, reject) => {
|
|
426
|
+
const child = spawn(program, args, { stdio: 'inherit' });
|
|
427
|
+
child.once('error', reject);
|
|
428
|
+
child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
function firstAvailable(values, hasCommand) {
|
|
432
|
+
return values.find(hasCommand) ?? null;
|
|
433
|
+
}
|
|
434
|
+
function readLinuxOsRelease(platform) {
|
|
435
|
+
if (platform !== 'linux' || !existsSync('/etc/os-release'))
|
|
436
|
+
return '';
|
|
437
|
+
try {
|
|
438
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return '';
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function linuxReleaseIds(osRelease) {
|
|
445
|
+
const ids = new Set();
|
|
446
|
+
for (const line of osRelease.split('\n')) {
|
|
447
|
+
const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
|
|
448
|
+
if (!match)
|
|
449
|
+
continue;
|
|
450
|
+
for (const value of match[2].replaceAll('"', '').split(/\s+/))
|
|
451
|
+
if (value.trim())
|
|
452
|
+
ids.add(value.trim().toLowerCase());
|
|
453
|
+
}
|
|
454
|
+
return ids;
|
|
455
|
+
}
|
|
456
|
+
function osReleaseValue(osRelease, key) {
|
|
457
|
+
const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`));
|
|
458
|
+
return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? '';
|
|
459
|
+
}
|
|
460
|
+
function hasAny(values, candidates) {
|
|
461
|
+
return candidates.some(candidate => values.has(candidate));
|
|
462
|
+
}
|