@ciphore/radiocli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/setup.js CHANGED
@@ -1,50 +1,102 @@
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, realpathSync } 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';
6
7
  import { configureMcpIntegrations } from './agent/mcp-install.js';
7
- import { JsonLibraryStore } from './storage/store.js';
8
+ import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
8
9
  import { defaultAgentControlSettings } from './types.js';
10
+ import { resolveTerminalCapabilities } from './platform/terminal.js';
11
+ import { isAirPlayPlatformSupported } from './player/airplay-policy.js';
12
+ import { probeAirPlayReadiness } from './player/airplay-readiness.js';
13
+ import { airPlaySetupVerification, hasSystemdSystemManager, linuxAirPlaySetupPlan } from './player/airplay-setup.js';
14
+ import { readSettingsWithoutMigration } from './storage/read-settings.js';
15
+ import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
16
+ import { verifyLocalPlayback } from './setup-playback.js';
9
17
  const components = ['mpv', 'ffmpeg', 'vlc'];
10
- const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
11
18
  export async function runSetup(options = {}) {
12
19
  const platform = options.platform ?? process.platform;
13
20
  const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
21
+ const env = options.env ?? process.env;
22
+ const host = identifyPlatform({ platform, osRelease, env });
14
23
  const input = options.input ?? process.stdin;
15
24
  const output = options.output ?? process.stdout;
16
25
  const hasCommand = options.hasCommand ?? commandExists;
17
26
  const parsed = parseSetupArgs(options.args ?? []);
27
+ const linuxAirPlayEligible = platform === 'linux' && isAirPlayPlatformSupported(platform, env, host.release);
28
+ if (parsed.airplay !== null && !linuxAirPlayEligible)
29
+ throw new Error('--airplay and --no-airplay configure experimental native Linux AirPlay only. macOS keeps its existing AirPlay setup; this environment has no Linux opt-in.');
30
+ const prompt = options.prompt ?? ((question, defaultValue) => promptYesNo(input, output, question, defaultValue));
31
+ const libraryPath = () => options.libraryPath ?? defaultLibraryPath({ platform, env, home: env.HOME ?? env.USERPROFILE });
32
+ const probe = options.probeAirPlay ?? ((loadSender) => probeAirPlayReadiness({ platform, env, release: host.release, hasCommand, ...(loadSender ? {} : { sender: async () => ({ loaded: false, message: 'Not probed before approval.' }) }) }));
33
+ if (parsed.packageManager)
34
+ validateNativePackageManager(parsed.packageManager, host);
18
35
  const installed = detectInstalledComponents(hasCommand);
19
- const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
36
+ const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env);
20
37
  writeHeader(output);
21
- output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`);
38
+ output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`);
22
39
  output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
23
40
  let selected = parsed.only ?? defaultComponents(platform, parsed.all);
24
41
  if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
25
- selected = await promptForComponents({ platform, installed, input, output });
42
+ selected = await promptForComponents({ platform, installed, output, prompt });
43
+ }
44
+ let airplay = parsed.airplay;
45
+ if (linuxAirPlayEligible && airplay === null && !parsed.yes && !parsed.only && isInteractive(input, output)) {
46
+ const enabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
47
+ const choice = await prompt(' AirPlay — experimental (FFmpeg, shared Avahi network discovery)', enabled);
48
+ // Declining the default on a new install leaves the library untouched.
49
+ if (choice || enabled)
50
+ airplay = choice;
26
51
  }
27
52
  let mcp = parsed.mcp;
28
53
  if (mcp === null && !parsed.yes && isInteractive(input, output)) {
29
- mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true);
54
+ mcp = await prompt(' Agent control via MCP (detected coding agents)', true);
30
55
  }
31
56
  let agentUi = parsed.agentUi;
32
57
  if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
33
58
  const note = platform === 'darwin'
34
59
  ? ' (macOS will ask the agent app to control Terminal on first use)'
35
60
  : ' (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 });
61
+ agentUi = await prompt(` Open the RadioCLI TUI for agent playback${note}`, true);
62
+ }
63
+ const isRoot = (options.getUid ?? process.getuid)?.() === 0;
64
+ const elevation = packageManagerElevation(hasCommand, isRoot);
65
+ const plan = createSetupPlan({ platform, osRelease, env, packageManager, installed, selected, elevation });
66
+ const airPlayPlan = airplay === true ? linuxAirPlaySetupPlan({ manager: packageManager, elevation, hasCommand,
67
+ systemd: (options.isSystemd ?? hasSystemdSystemManager)(),
68
+ serviceReady: parsed.dryRun ? false : (await probe(false).catch(() => null))?.discoveryService === 'ready', existingCommands: plan.commands }) : null;
69
+ if (airPlayPlan)
70
+ plan.commands.push(...airPlayPlan.commands);
39
71
  printPlan(plan, output);
72
+ if (airplay !== null)
73
+ output.write(` Linux AirPlay: ${airplay ? 'enable for this library after accepted setup; verify sender and discovery' : 'disable for this library; keep installed packages and shared services'}\n`);
74
+ for (const note of airPlayPlan?.notes ?? [])
75
+ output.write(` Note: ${note}\n`);
76
+ if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) {
77
+ output.write(' Note: Run package commands as root; sudo/doas unavailable.\n');
78
+ }
79
+ for (const note of packageManagerNotes(packageManager, host))
80
+ output.write(` Note: ${note}\n`);
81
+ const verify = async () => {
82
+ let linuxAirPlayEnabled = airplay === true;
83
+ if (platform === 'linux' && airplay === null) {
84
+ try {
85
+ linuxAirPlayEnabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
86
+ }
87
+ catch { /* Unreadable consent stays disabled. */ }
88
+ }
89
+ await printVerification(output, { platform, osRelease, env, release: host.release, hasCommand, getUid: options.getUid, linuxAirPlayEnabled, verifyPlayback: options.verifyPlayback });
90
+ };
40
91
  const missing = plan.selected.filter(component => !plan.installed[component]);
41
- if (missing.length === 0) {
92
+ if (missing.length === 0 && airplay === null) {
42
93
  output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
43
94
  await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
44
- printVerification(output);
95
+ if (!parsed.dryRun)
96
+ await verify();
45
97
  return;
46
98
  }
47
- if (!plan.packageManager) {
99
+ if (!plan.packageManager && missing.length > 0) {
48
100
  if (parsed.dryRun) {
49
101
  output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
50
102
  await finishMcpSetup(mcp, agentUi, true, output);
@@ -57,11 +109,20 @@ export async function runSetup(options = {}) {
57
109
  await finishMcpSetup(mcp, agentUi, true, output);
58
110
  return;
59
111
  }
60
- if (parsed.packageManager && !hasCommand(parsed.packageManager)) {
61
- throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`);
112
+ const manual = missing.filter(component => !plan.commands.some(command => command.component === component));
113
+ if (manual.length)
114
+ throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`);
115
+ if (plan.packageManager === 'termux-pkg' && isRoot)
116
+ throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.');
117
+ if ((plan.packageManager && packageManagerNeedsRoot(plan.packageManager) || airPlayPlan?.commands.length) && plan.commands.length > 0 && !isRoot && !elevation) {
118
+ throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.');
119
+ }
120
+ if (plan.commands.length > 0 && parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
121
+ const program = packageManagerProgram(parsed.packageManager);
122
+ throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`);
62
123
  }
63
124
  if (!parsed.yes && isInteractive(input, output)) {
64
- const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
125
+ const confirmed = await prompt(airplay === null ? '\nInstall these components?' : '\nApply this setup plan and Linux AirPlay preference?', true);
65
126
  if (!confirmed) {
66
127
  output.write('Setup cancelled. No system packages were changed.\n');
67
128
  return;
@@ -70,62 +131,59 @@ export async function runSetup(options = {}) {
70
131
  else if (!parsed.yes) {
71
132
  throw new Error('Setup needs an interactive terminal. Use --yes to approve the displayed commands or --dry-run to inspect them.');
72
133
  }
73
- if (plan.commands.some(command => command.program === 'sudo')) {
134
+ if (!options.runCommand && plan.commands.some(command => command.program === 'sudo')) {
74
135
  output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
75
- await runVisibleCommand('sudo', ['-v']);
136
+ await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
76
137
  }
77
- const execute = options.runCommand ?? runInstallCommand;
78
- output.write('\nInstalling\n');
138
+ const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
139
+ if (plan.commands.length)
140
+ output.write('\nInstalling\n');
79
141
  for (const command of plan.commands) {
80
142
  await execute(command, output);
81
143
  }
82
144
  clearCommandCache();
145
+ if (airplay !== null) {
146
+ if (options.saveLinuxAirPlayEnabled)
147
+ options.saveLinuxAirPlayEnabled(airplay);
148
+ else
149
+ new JsonLibraryStore(libraryPath()).updateSettings({ linuxAirPlayEnabled: airplay });
150
+ output.write(`\nLinux AirPlay ${airplay ? 'enabled' : 'disabled'} for this library. Restart active RadioCLI sessions to apply this preference.\n`);
151
+ if (airplay) {
152
+ const readiness = await probe(true).catch(() => ({ eligible: true, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false, message: 'Readiness could not be verified. Run radiocli doctor.' }));
153
+ for (const line of airPlaySetupVerification(readiness))
154
+ output.write(` ${line}\n`);
155
+ }
156
+ }
83
157
  output.write('\nSetup complete.\n');
84
158
  await finishMcpSetup(mcp, agentUi, false, output);
85
- printVerification(output);
159
+ await verify();
86
160
  }
87
- export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
161
+ export function createSetupPlan({ platform, osRelease = '', env = process.env, packageManager, installed, selected, elevation = 'sudo' }) {
162
+ if (packageManager)
163
+ validateNativePackageManager(packageManager, identifyPlatform({ platform, osRelease, env }));
88
164
  const uniqueSelected = components.filter(component => selected.includes(component));
89
165
  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
- }
166
+ const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, { elevation })).filter((command) => command !== null) : [];
167
+ if (packageManager)
168
+ commands.unshift(...packageInstallPrerequisites(packageManager, missing));
100
169
  return {
101
170
  platform,
102
- platformLabel: platformLabel(platform, osRelease),
171
+ platformLabel: platformLabel(platform, osRelease, env),
103
172
  packageManager,
104
173
  installed,
105
174
  selected: uniqueSelected,
106
175
  commands
107
176
  };
108
177
  }
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);
178
+ function validateNativePackageManager(manager, host) {
179
+ const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null;
180
+ if ((required && manager !== required)
181
+ || (manager === 'termux-pkg' && host.id !== 'termux')
182
+ || (manager === 'pkgman' && host.id !== 'haiku')
183
+ || (manager === 'pkg' && host.id !== 'freebsd')
184
+ || ['aix', 'android', 'unknown'].includes(host.id)) {
185
+ 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.'}`);
186
+ }
129
187
  }
130
188
  export function parseSetupArgs(args) {
131
189
  let all = false;
@@ -135,6 +193,7 @@ export function parseSetupArgs(args) {
135
193
  let packageManager = null;
136
194
  let mcp = null;
137
195
  let agentUi = null;
196
+ let airplay = null;
138
197
  for (let index = 0; index < args.length; index += 1) {
139
198
  const arg = args[index];
140
199
  if (arg === '--all')
@@ -143,6 +202,12 @@ export function parseSetupArgs(args) {
143
202
  dryRun = true;
144
203
  else if (arg === '--yes' || arg === '-y')
145
204
  yes = true;
205
+ else if (arg === '--airplay' || arg === '--no-airplay') {
206
+ const enabled = arg === '--airplay';
207
+ if (airplay !== null && airplay !== enabled)
208
+ throw new Error('Use either --airplay or --no-airplay, not both.');
209
+ airplay = enabled;
210
+ }
146
211
  else if (arg === '--mcp') {
147
212
  if (mcp === false)
148
213
  throw new Error('Use either --mcp or --no-mcp, not both.');
@@ -178,7 +243,7 @@ export function parseSetupArgs(args) {
178
243
  throw new Error('Use either --all or --only, not both.');
179
244
  if (agentUi !== null && mcp !== true)
180
245
  throw new Error('--agent-ui and --headless-agent require --mcp.');
181
- return { all, dryRun, yes, only, packageManager, mcp, agentUi };
246
+ return { all, dryRun, yes, only, packageManager, mcp, agentUi, airplay };
182
247
  }
183
248
  async function finishMcpSetup(enabled, agentUi, dryRun, output) {
184
249
  if (enabled === null)
@@ -220,51 +285,6 @@ function parsePackageManager(value) {
220
285
  }
221
286
  return value;
222
287
  }
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
288
  function defaultComponents(platform, all) {
269
289
  if (all)
270
290
  return [...components];
@@ -277,14 +297,14 @@ function detectInstalledComponents(hasCommand) {
277
297
  vlc: hasCommand('vlc') || hasCommand('cvlc')
278
298
  };
279
299
  }
280
- async function promptForComponents({ platform, installed, input, output }) {
300
+ async function promptForComponents({ platform, installed, output, prompt }) {
281
301
  output.write('Choose components (installed items will be skipped):\n');
282
302
  const selected = [];
283
- if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true))
303
+ if (await prompt(` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
284
304
  selected.push('mpv');
285
- if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin'))
305
+ if (await prompt(` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
286
306
  selected.push('ffmpeg');
287
- if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false))
307
+ if (await prompt(` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
288
308
  selected.push('vlc');
289
309
  return selected;
290
310
  }
@@ -302,19 +322,23 @@ async function promptYesNo(input, output, question, defaultValue) {
302
322
  }
303
323
  function printPlan(plan, output) {
304
324
  output.write('\nInstallation plan\n');
325
+ for (const command of plan.commands.filter(command => command.component === null)) {
326
+ output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`);
327
+ }
305
328
  for (const component of plan.selected) {
306
329
  if (plan.installed[component])
307
330
  output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
308
331
  else {
309
332
  const command = plan.commands.find(candidate => candidate.component === component);
310
- output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`);
333
+ output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`);
311
334
  }
312
335
  }
313
336
  if (plan.selected.length === 0)
314
337
  output.write(' No components selected\n');
315
338
  }
316
- async function runInstallCommand(command, output) {
317
- const interactive = Boolean(output.isTTY);
339
+ async function runInstallCommand(command, output, platform) {
340
+ const terminal = setupTerminal(output);
341
+ const interactive = terminal.interactive && !terminal.reduceMotion;
318
342
  const startedAt = Date.now();
319
343
  let timer;
320
344
  let frame = 0;
@@ -330,7 +354,8 @@ async function runInstallCommand(command, output) {
330
354
  }
331
355
  try {
332
356
  await new Promise((resolve, reject) => {
333
- const child = spawn(command.program, command.args, { stdio: ['inherit', 'pipe', 'pipe'] });
357
+ const invocation = packageCommandInvocation(command, platform, resolveCommand);
358
+ const child = spawn(invocation.program, invocation.args, { shell: false, stdio: ['inherit', 'pipe', 'pipe'] });
334
359
  child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
335
360
  child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
336
361
  child.once('error', reject);
@@ -358,55 +383,57 @@ function progressFrame(label, frame, elapsedMs, output) {
358
383
  const cycle = travel * 2;
359
384
  const offset = frame % cycle;
360
385
  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)}`;
386
+ const unicode = setupTerminal(output).unicode;
387
+ const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join('');
388
+ return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
363
389
  }
364
- function printVerification(output) {
390
+ async function printVerification(output, { platform, osRelease, env, release, hasCommand, getUid, linuxAirPlayEnabled, verifyPlayback = verifyLocalPlayback }) {
365
391
  clearCommandCache();
366
- const backends = detectPlaybackBackends();
392
+ const backends = detectPlaybackBackends({ platform, env, release, hasCommand, settings: { linuxAirPlayEnabled } });
367
393
  output.write('\nVerification\n');
368
394
  output.write(` Playback backends: ${backends.join(', ') || 'none'}\n`);
369
- for (const line of playbackBackendStatusLines(backends).slice(0, 3))
395
+ for (const line of playbackBackendStatusLines(backends, platform, osRelease, { hasCommand, getUid }).slice(0, 3))
370
396
  output.write(` ${line}\n`);
397
+ const rehearsals = await verifyPlayback(backends, { platform, env });
398
+ for (const rehearsal of rehearsals)
399
+ output.write(` decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}\n`);
400
+ if (rehearsals.length === 0)
401
+ output.write(' decoder=unverified No local playback backend is available for a rehearsal.\n');
402
+ output.write(' physical_output=unverified Play a station on your intended speakers, headphones, or AirPlay receiver to verify that path.\n');
403
+ if (rehearsals.some(item => item.status !== 'passed'))
404
+ output.write(' Playback needs attention. Run radiocli doctor --playback for a repeatable local check.\n');
371
405
  output.write('\nRun radiocli to start listening.\n');
372
406
  }
373
407
  function writeHeader(output) {
374
408
  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';
409
+ output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`);
392
410
  }
393
411
  function isInteractive(input, output) {
394
412
  return Boolean(input.isTTY && output.isTTY);
395
413
  }
396
414
  function accent(output, value) {
397
- return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value;
415
+ const level = setupTerminal(output).colorLevel;
416
+ const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32';
417
+ return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
398
418
  }
399
419
  function successMark(output) {
400
- return accent(output, '✓');
420
+ return accent(output, setupTerminal(output).unicode ? '✓' : '+');
401
421
  }
402
422
  function pendingMark(output) {
403
- return accent(output, '◆');
423
+ return accent(output, setupTerminal(output).unicode ? '◆' : '*');
404
424
  }
405
425
  function failureMark(output) {
406
- return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗';
426
+ const terminal = setupTerminal(output);
427
+ const value = terminal.unicode ? '✗' : 'x';
428
+ const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31';
429
+ return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
407
430
  }
408
- function colorsEnabled(output) {
409
- return Boolean(output.isTTY) && !process.env.NO_COLOR;
431
+ function setupTerminal(output) {
432
+ const stream = output;
433
+ return resolveTerminalCapabilities(process.env, { isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.() });
434
+ }
435
+ function separator(output) {
436
+ return setupTerminal(output).unicode ? '·' : '-';
410
437
  }
411
438
  function clearLine() {
412
439
  return '\u001b[2K';
@@ -428,35 +455,3 @@ async function runVisibleCommand(program, args) {
428
455
  child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
429
456
  });
430
457
  }
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
- }
@@ -0,0 +1,113 @@
1
+ import { mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { performance } from 'node:perf_hooks';
5
+ /** An owner is never evicted for being slow. Only a positively dead PID or an
6
+ * explicitly released generation can be reclaimed. Unknown/legacy locks fail
7
+ * closed; their age cannot distinguish a crashed writer from a suspended one. */
8
+ export function acquireFileLock(directory, timeoutMs = 1000) {
9
+ const marker = `owner-${process.pid}-${randomUUID()}`;
10
+ const deadline = performance.now() + timeoutMs;
11
+ while (true) {
12
+ let acquired = false;
13
+ try {
14
+ mkdirSync(directory, { mode: 0o700 });
15
+ acquired = true;
16
+ }
17
+ catch (error) {
18
+ if (codeOf(error) !== 'EEXIST')
19
+ throw error;
20
+ }
21
+ if (acquired) {
22
+ try {
23
+ writeFileSync(join(directory, marker), '', { mode: 0o600, flag: 'wx' });
24
+ }
25
+ catch (error) {
26
+ // A failed write can leave its newly created marker behind. Remove only
27
+ // our unique file; never clear a directory belonging to a later writer.
28
+ try {
29
+ unlinkSync(join(directory, marker));
30
+ }
31
+ catch { /* May not have been created. */ }
32
+ try {
33
+ rmdirSync(directory);
34
+ }
35
+ catch { /* Never remove another generation. */ }
36
+ throw error;
37
+ }
38
+ let released = false;
39
+ return () => { if (!released) {
40
+ released = true;
41
+ finishLock(directory, marker);
42
+ } };
43
+ }
44
+ try {
45
+ const entries = readdirSync(directory);
46
+ const owner = entries.length === 1 ? entries[0] : '';
47
+ const match = /^(owner|released)-([1-9]\d*)-([a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12})$/.exec(owner);
48
+ if (match && Number.isSafeInteger(Number(match[2])) && (match[1] === 'released' || isDead(Number(match[2])))) {
49
+ // Compare and claim the exact marker, never rename/delete the lock
50
+ // directory after an observation: a different writer may now own it.
51
+ const reaper = `owner-${process.pid}-${randomUUID()}`;
52
+ renameSync(join(directory, owner), join(directory, reaper));
53
+ finishLock(directory, reaper);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (codeOf(error) !== 'ENOENT')
58
+ throw error;
59
+ }
60
+ if (performance.now() >= deadline) {
61
+ throw Object.assign(new Error('The library lock has a live or unknown owner.'), { code: 'EBUSY' });
62
+ }
63
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
64
+ }
65
+ }
66
+ function isDead(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return false;
70
+ }
71
+ catch (error) {
72
+ return codeOf(error) === 'ESRCH';
73
+ }
74
+ }
75
+ function finishLock(directory, marker) {
76
+ const released = `released-${process.pid}-${randomUUID()}`;
77
+ try {
78
+ renameSync(join(directory, marker), join(directory, released));
79
+ }
80
+ catch {
81
+ try {
82
+ removeOwnedLock(directory, marker);
83
+ }
84
+ catch { /* Preserve the transaction result. */ }
85
+ return;
86
+ }
87
+ // Publish completion before cleanup so a failed unlink is recoverable even
88
+ // while this process is alive. Cleanup must not turn a commit into a failure.
89
+ try {
90
+ removeOwnedLock(directory, released);
91
+ }
92
+ catch { /* Recover on the next acquisition. */ }
93
+ }
94
+ function removeOwnedLock(directory, marker) {
95
+ try {
96
+ unlinkSync(join(directory, marker));
97
+ }
98
+ catch (error) {
99
+ if (codeOf(error) === 'ENOENT')
100
+ return;
101
+ throw error;
102
+ }
103
+ // An empty directory can be concurrently replaced only by a legacy writer.
104
+ // rmdir cannot remove a new nonempty generation, unlike recursive rm.
105
+ try {
106
+ rmdirSync(directory);
107
+ }
108
+ catch (error) {
109
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? ''))
110
+ throw error;
111
+ }
112
+ }
113
+ function codeOf(error) { return error?.code; }
@@ -0,0 +1,33 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
2
+ /** Bounded, read-only preference inspection. Never creates or repairs a library. */
3
+ export function readSettingsWithoutMigration(path) {
4
+ if (!path)
5
+ return undefined;
6
+ let descriptor;
7
+ const limit = 2 * 1024 * 1024;
8
+ try {
9
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
10
+ const info = fstatSync(descriptor);
11
+ if (!info.isFile() || info.size > limit)
12
+ return undefined;
13
+ const buffer = Buffer.alloc(limit + 1);
14
+ const size = readSync(descriptor, buffer, 0, buffer.length, 0);
15
+ if (size > limit)
16
+ return undefined;
17
+ const data = JSON.parse(buffer.toString('utf8', 0, size));
18
+ if (!record(data))
19
+ return undefined;
20
+ return data.settings === undefined ? {} : record(data.settings) ? data.settings : undefined;
21
+ }
22
+ catch (error) {
23
+ return error.code === 'ENOENT' ? {} : undefined;
24
+ }
25
+ finally {
26
+ if (descriptor !== undefined)
27
+ try {
28
+ closeSync(descriptor);
29
+ }
30
+ catch { /* Read-only best effort. */ }
31
+ }
32
+ }
33
+ function record(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }