@ciphore/radiocli 0.2.2 → 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 (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
package/dist/setup.js CHANGED
@@ -1,47 +1,86 @@
1
+ import { componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel } from './platform/packages.js';
1
2
  import { spawn } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
+ import { realpathSync } from 'node:fs';
3
4
  import { createInterface } from 'node:readline/promises';
4
- import { clearCommandCache, commandExists } from './player/command.js';
5
+ import { clearCommandCache, commandExists, resolveCommand } from './platform/executables.js';
5
6
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
7
+ import { configureMcpIntegrations } from './agent/mcp-install.js';
8
+ import { JsonLibraryStore } from './storage/store.js';
9
+ import { defaultAgentControlSettings } from './types.js';
10
+ import { resolveTerminalCapabilities } from './platform/terminal.js';
11
+ import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
6
12
  const components = ['mpv', 'ffmpeg', 'vlc'];
7
- const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
8
13
  export async function runSetup(options = {}) {
9
14
  const platform = options.platform ?? process.platform;
10
15
  const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
16
+ const env = options.env ?? process.env;
17
+ const host = identifyPlatform({ platform, osRelease, env });
11
18
  const input = options.input ?? process.stdin;
12
19
  const output = options.output ?? process.stdout;
13
20
  const hasCommand = options.hasCommand ?? commandExists;
14
21
  const parsed = parseSetupArgs(options.args ?? []);
22
+ if (parsed.packageManager)
23
+ validateNativePackageManager(parsed.packageManager, host);
15
24
  const installed = detectInstalledComponents(hasCommand);
16
- const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
25
+ const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env);
17
26
  writeHeader(output);
18
- output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`);
27
+ output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`);
19
28
  output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
20
29
  let selected = parsed.only ?? defaultComponents(platform, parsed.all);
21
30
  if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
22
31
  selected = await promptForComponents({ platform, installed, input, output });
23
32
  }
24
- const plan = createSetupPlan({ platform, osRelease, packageManager, installed, selected });
33
+ let mcp = parsed.mcp;
34
+ if (mcp === null && !parsed.yes && isInteractive(input, output)) {
35
+ mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true);
36
+ }
37
+ let agentUi = parsed.agentUi;
38
+ if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
39
+ const note = platform === 'darwin'
40
+ ? ' (macOS will ask the agent app to control Terminal on first use)'
41
+ : ' (opens a separate terminal window)';
42
+ agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
43
+ }
44
+ const isRoot = (options.getUid ?? process.getuid)?.() === 0;
45
+ const elevation = packageManagerElevation(hasCommand, isRoot);
46
+ const plan = createSetupPlan({ platform, osRelease, env, packageManager, installed, selected, elevation });
25
47
  printPlan(plan, output);
48
+ if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) {
49
+ output.write(' Note: Run package commands as root; sudo/doas unavailable.\n');
50
+ }
51
+ for (const note of packageManagerNotes(packageManager, host))
52
+ output.write(` Note: ${note}\n`);
26
53
  const missing = plan.selected.filter(component => !plan.installed[component]);
27
54
  if (missing.length === 0) {
28
55
  output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
56
+ await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
29
57
  printVerification(output);
30
58
  return;
31
59
  }
32
60
  if (!plan.packageManager) {
33
61
  if (parsed.dryRun) {
34
62
  output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
63
+ await finishMcpSetup(mcp, agentUi, true, output);
35
64
  return;
36
65
  }
37
66
  throw new Error('No supported system package manager was found. Install mpv manually, then run radiocli doctor.');
38
67
  }
39
68
  if (parsed.dryRun) {
40
69
  output.write('\nDry run complete. No system packages were changed.\n');
70
+ await finishMcpSetup(mcp, agentUi, true, output);
41
71
  return;
42
72
  }
43
- if (parsed.packageManager && !hasCommand(parsed.packageManager)) {
44
- throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`);
73
+ const manual = missing.filter(component => !plan.commands.some(command => command.component === component));
74
+ if (manual.length)
75
+ throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`);
76
+ if (plan.packageManager === 'termux-pkg' && isRoot)
77
+ throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.');
78
+ if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) {
79
+ throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.');
80
+ }
81
+ if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
82
+ const program = packageManagerProgram(parsed.packageManager);
83
+ throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`);
45
84
  }
46
85
  if (!parsed.yes && isInteractive(input, output)) {
47
86
  const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
@@ -55,59 +94,44 @@ export async function runSetup(options = {}) {
55
94
  }
56
95
  if (plan.commands.some(command => command.program === 'sudo')) {
57
96
  output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
58
- await runVisibleCommand('sudo', ['-v']);
97
+ await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
59
98
  }
60
- const execute = options.runCommand ?? runInstallCommand;
99
+ const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
61
100
  output.write('\nInstalling\n');
62
101
  for (const command of plan.commands) {
63
102
  await execute(command, output);
64
103
  }
65
104
  clearCommandCache();
66
105
  output.write('\nSetup complete.\n');
106
+ await finishMcpSetup(mcp, agentUi, false, output);
67
107
  printVerification(output);
68
108
  }
69
- export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
109
+ export function createSetupPlan({ platform, osRelease = '', env = process.env, packageManager, installed, selected, elevation = 'sudo' }) {
110
+ if (packageManager)
111
+ validateNativePackageManager(packageManager, identifyPlatform({ platform, osRelease, env }));
70
112
  const uniqueSelected = components.filter(component => selected.includes(component));
71
113
  const missing = uniqueSelected.filter(component => !installed[component]);
72
- const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : [];
73
- if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) {
74
- commands.unshift({
75
- component: null,
76
- label: 'Scoop extras bucket',
77
- program: 'scoop',
78
- args: ['bucket', 'add', 'extras'],
79
- display: 'scoop bucket add extras'
80
- });
81
- }
114
+ const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, { elevation })).filter((command) => command !== null) : [];
115
+ if (packageManager)
116
+ commands.unshift(...packageInstallPrerequisites(packageManager, missing));
82
117
  return {
83
118
  platform,
84
- platformLabel: platformLabel(platform, osRelease),
119
+ platformLabel: platformLabel(platform, osRelease, env),
85
120
  packageManager,
86
121
  installed,
87
122
  selected: uniqueSelected,
88
123
  commands
89
124
  };
90
125
  }
91
- export function detectPackageManager(platform, osRelease, hasCommand = commandExists) {
92
- if (platform === 'darwin')
93
- return hasCommand('brew') ? 'brew' : null;
94
- if (platform === 'win32')
95
- return firstAvailable(['winget', 'scoop', 'choco'], hasCommand);
96
- if (platform !== 'linux')
97
- return null;
98
- const ids = linuxReleaseIds(osRelease);
99
- const preferred = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])
100
- ? ['apt']
101
- : hasAny(ids, ['fedora', 'rhel', 'centos'])
102
- ? ['dnf']
103
- : hasAny(ids, ['arch', 'manjaro'])
104
- ? ['pacman']
105
- : hasAny(ids, ['alpine'])
106
- ? ['apk']
107
- : hasAny(ids, ['opensuse', 'suse'])
108
- ? ['zypper']
109
- : ['apt', 'dnf', 'pacman', 'apk', 'zypper'];
110
- return firstAvailable(preferred, hasCommand);
126
+ function validateNativePackageManager(manager, host) {
127
+ const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null;
128
+ if ((required && manager !== required)
129
+ || (manager === 'termux-pkg' && host.id !== 'termux')
130
+ || (manager === 'pkgman' && host.id !== 'haiku')
131
+ || (manager === 'pkg' && host.id !== 'freebsd')
132
+ || ['aix', 'android', 'unknown'].includes(host.id)) {
133
+ throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`);
134
+ }
111
135
  }
112
136
  export function parseSetupArgs(args) {
113
137
  let all = false;
@@ -115,6 +139,8 @@ export function parseSetupArgs(args) {
115
139
  let yes = false;
116
140
  let only = null;
117
141
  let packageManager = null;
142
+ let mcp = null;
143
+ let agentUi = null;
118
144
  for (let index = 0; index < args.length; index += 1) {
119
145
  const arg = args[index];
120
146
  if (arg === '--all')
@@ -123,6 +149,26 @@ export function parseSetupArgs(args) {
123
149
  dryRun = true;
124
150
  else if (arg === '--yes' || arg === '-y')
125
151
  yes = true;
152
+ else if (arg === '--mcp') {
153
+ if (mcp === false)
154
+ throw new Error('Use either --mcp or --no-mcp, not both.');
155
+ mcp = true;
156
+ }
157
+ else if (arg === '--no-mcp') {
158
+ if (mcp === true)
159
+ throw new Error('Use either --mcp or --no-mcp, not both.');
160
+ mcp = false;
161
+ }
162
+ else if (arg === '--agent-ui') {
163
+ if (agentUi === false)
164
+ throw new Error('Use either --agent-ui or --headless-agent, not both.');
165
+ agentUi = true;
166
+ }
167
+ else if (arg === '--headless-agent') {
168
+ if (agentUi === true)
169
+ throw new Error('Use either --agent-ui or --headless-agent, not both.');
170
+ agentUi = false;
171
+ }
126
172
  else if (arg === '--only')
127
173
  only = parseComponents(args[++index]);
128
174
  else if (arg.startsWith('--only='))
@@ -136,7 +182,36 @@ export function parseSetupArgs(args) {
136
182
  }
137
183
  if (all && only)
138
184
  throw new Error('Use either --all or --only, not both.');
139
- return { all, dryRun, yes, only, packageManager };
185
+ if (agentUi !== null && mcp !== true)
186
+ throw new Error('--agent-ui and --headless-agent require --mcp.');
187
+ return { all, dryRun, yes, only, packageManager, mcp, agentUi };
188
+ }
189
+ async function finishMcpSetup(enabled, agentUi, dryRun, output) {
190
+ if (enabled === null)
191
+ return;
192
+ if (dryRun) {
193
+ output.write(`\nAgent integration: would be ${enabled ? 'enabled and installed for detected MCP clients' : 'disabled and removed from detected MCP clients'}.\n`);
194
+ return;
195
+ }
196
+ const entry = process.argv[1];
197
+ if (!entry)
198
+ throw new Error('Could not locate the RadioCLI executable for MCP setup.');
199
+ const results = await configureMcpIntegrations(enabled, { nodePath: process.execPath, cliPath: realpathSync(entry) }, output);
200
+ const failed = results.filter(result => result.status === 'failed');
201
+ if (failed.length > 0) {
202
+ 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.`);
203
+ }
204
+ if (enabled) {
205
+ const store = new JsonLibraryStore();
206
+ const current = store.snapshot().settings.agentControl ?? defaultAgentControlSettings;
207
+ const openUiOnPlay = agentUi ?? current.openUiOnPlay;
208
+ if (openUiOnPlay !== current.openUiOnPlay) {
209
+ store.updateSettings({ agentControl: { ...current, openUiOnPlay } });
210
+ }
211
+ output.write(openUiOnPlay
212
+ ? '\nAgent playback: terminal TUI enabled (default). The host OS may request app-control permission on first use.\n'
213
+ : '\nAgent playback: headless; no separate terminal window or app-control permission is needed.\n');
214
+ }
140
215
  }
141
216
  function parseComponents(value) {
142
217
  const values = value?.split(',').map(item => item.trim().toLowerCase()).filter(Boolean) ?? [];
@@ -151,51 +226,6 @@ function parsePackageManager(value) {
151
226
  }
152
227
  return value;
153
228
  }
154
- function packageInstallCommand(manager, component) {
155
- const packages = {
156
- brew: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
157
- winget: { mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC' },
158
- scoop: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
159
- choco: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
160
- apt: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
161
- dnf: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
162
- pacman: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
163
- apk: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
164
- zypper: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' }
165
- };
166
- const packageName = packages[manager][component];
167
- let program = manager;
168
- let args;
169
- if (manager === 'brew')
170
- args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])];
171
- else if (manager === 'winget')
172
- args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements'];
173
- else if (manager === 'scoop')
174
- args = ['install', packageName];
175
- else if (manager === 'choco')
176
- args = ['install', packageName, '-y'];
177
- else if (manager === 'apt') {
178
- program = 'sudo';
179
- args = ['apt-get', 'install', '-y', packageName];
180
- }
181
- else if (manager === 'dnf') {
182
- program = 'sudo';
183
- args = ['dnf', 'install', '-y', packageName];
184
- }
185
- else if (manager === 'pacman') {
186
- program = 'sudo';
187
- args = ['pacman', '-S', '--needed', '--noconfirm', packageName];
188
- }
189
- else if (manager === 'apk') {
190
- program = 'sudo';
191
- args = ['apk', 'add', packageName];
192
- }
193
- else {
194
- program = 'sudo';
195
- args = ['zypper', '--non-interactive', 'install', packageName];
196
- }
197
- return { component, label: componentLabel(component), program, args, display: [program, ...args].join(' ') };
198
- }
199
229
  function defaultComponents(platform, all) {
200
230
  if (all)
201
231
  return [...components];
@@ -211,11 +241,11 @@ function detectInstalledComponents(hasCommand) {
211
241
  async function promptForComponents({ platform, installed, input, output }) {
212
242
  output.write('Choose components (installed items will be skipped):\n');
213
243
  const selected = [];
214
- if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true))
244
+ if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
215
245
  selected.push('mpv');
216
- if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin'))
246
+ if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
217
247
  selected.push('ffmpeg');
218
- if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false))
248
+ if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
219
249
  selected.push('vlc');
220
250
  return selected;
221
251
  }
@@ -233,19 +263,23 @@ async function promptYesNo(input, output, question, defaultValue) {
233
263
  }
234
264
  function printPlan(plan, output) {
235
265
  output.write('\nInstallation plan\n');
266
+ for (const command of plan.commands.filter(command => command.component === null)) {
267
+ output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`);
268
+ }
236
269
  for (const component of plan.selected) {
237
270
  if (plan.installed[component])
238
271
  output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
239
272
  else {
240
273
  const command = plan.commands.find(candidate => candidate.component === component);
241
- output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`);
274
+ output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`);
242
275
  }
243
276
  }
244
277
  if (plan.selected.length === 0)
245
278
  output.write(' No components selected\n');
246
279
  }
247
- async function runInstallCommand(command, output) {
248
- const interactive = Boolean(output.isTTY);
280
+ async function runInstallCommand(command, output, platform) {
281
+ const terminal = setupTerminal(output);
282
+ const interactive = terminal.interactive && !terminal.reduceMotion;
249
283
  const startedAt = Date.now();
250
284
  let timer;
251
285
  let frame = 0;
@@ -261,7 +295,8 @@ async function runInstallCommand(command, output) {
261
295
  }
262
296
  try {
263
297
  await new Promise((resolve, reject) => {
264
- const child = spawn(command.program, command.args, { stdio: ['inherit', 'pipe', 'pipe'] });
298
+ const invocation = packageCommandInvocation(command, platform, resolveCommand);
299
+ const child = spawn(invocation.program, invocation.args, { shell: false, stdio: ['inherit', 'pipe', 'pipe'] });
265
300
  child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
266
301
  child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
267
302
  child.once('error', reject);
@@ -289,8 +324,9 @@ function progressFrame(label, frame, elapsedMs, output) {
289
324
  const cycle = travel * 2;
290
325
  const offset = frame % cycle;
291
326
  const start = offset <= travel ? offset : cycle - offset;
292
- const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? '█' : '░').join('');
293
- return ` ${accent(output, '')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
327
+ const unicode = setupTerminal(output).unicode;
328
+ const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? (unicode ? '' : '#') : (unicode ? '░' : '.')).join('');
329
+ return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
294
330
  }
295
331
  function printVerification(output) {
296
332
  clearCommandCache();
@@ -303,41 +339,34 @@ function printVerification(output) {
303
339
  }
304
340
  function writeHeader(output) {
305
341
  output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`);
306
- output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`);
307
- }
308
- function platformLabel(platform, osRelease) {
309
- if (platform === 'darwin')
310
- return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`;
311
- if (platform === 'win32')
312
- return 'Windows';
313
- if (platform === 'linux')
314
- return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux';
315
- return platform;
316
- }
317
- function componentLabel(component) {
318
- if (component === 'ffmpeg')
319
- return 'FFmpeg / ffplay';
320
- if (component === 'vlc')
321
- return 'VLC fallback';
322
- return 'mpv';
342
+ output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`);
323
343
  }
324
344
  function isInteractive(input, output) {
325
345
  return Boolean(input.isTTY && output.isTTY);
326
346
  }
327
347
  function accent(output, value) {
328
- return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value;
348
+ const level = setupTerminal(output).colorLevel;
349
+ const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32';
350
+ return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
329
351
  }
330
352
  function successMark(output) {
331
- return accent(output, '✓');
353
+ return accent(output, setupTerminal(output).unicode ? '✓' : '+');
332
354
  }
333
355
  function pendingMark(output) {
334
- return accent(output, '◆');
356
+ return accent(output, setupTerminal(output).unicode ? '◆' : '*');
335
357
  }
336
358
  function failureMark(output) {
337
- return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗';
359
+ const terminal = setupTerminal(output);
360
+ const value = terminal.unicode ? '✗' : 'x';
361
+ const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31';
362
+ return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
363
+ }
364
+ function setupTerminal(output) {
365
+ const stream = output;
366
+ return resolveTerminalCapabilities(process.env, { isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.() });
338
367
  }
339
- function colorsEnabled(output) {
340
- return Boolean(output.isTTY) && !process.env.NO_COLOR;
368
+ function separator(output) {
369
+ return setupTerminal(output).unicode ? '·' : '-';
341
370
  }
342
371
  function clearLine() {
343
372
  return '\u001b[2K';
@@ -359,35 +388,3 @@ async function runVisibleCommand(program, args) {
359
388
  child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
360
389
  });
361
390
  }
362
- function firstAvailable(values, hasCommand) {
363
- return values.find(hasCommand) ?? null;
364
- }
365
- function readLinuxOsRelease(platform) {
366
- if (platform !== 'linux' || !existsSync('/etc/os-release'))
367
- return '';
368
- try {
369
- return readFileSync('/etc/os-release', 'utf8');
370
- }
371
- catch {
372
- return '';
373
- }
374
- }
375
- function linuxReleaseIds(osRelease) {
376
- const ids = new Set();
377
- for (const line of osRelease.split('\n')) {
378
- const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
379
- if (!match)
380
- continue;
381
- for (const value of match[2].replaceAll('"', '').split(/\s+/))
382
- if (value.trim())
383
- ids.add(value.trim().toLowerCase());
384
- }
385
- return ids;
386
- }
387
- function osReleaseValue(osRelease, key) {
388
- const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`));
389
- return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? '';
390
- }
391
- function hasAny(values, candidates) {
392
- return candidates.some(candidate => values.has(candidate));
393
- }