@ciphore/radiocli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
package/dist/setup.js CHANGED
@@ -1,24 +1,30 @@
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
8
  import { JsonLibraryStore } from './storage/store.js';
8
9
  import { defaultAgentControlSettings } from './types.js';
10
+ import { resolveTerminalCapabilities } from './platform/terminal.js';
11
+ import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
9
12
  const components = ['mpv', 'ffmpeg', 'vlc'];
10
- const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
11
13
  export async function runSetup(options = {}) {
12
14
  const platform = options.platform ?? process.platform;
13
15
  const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
16
+ const env = options.env ?? process.env;
17
+ const host = identifyPlatform({ platform, osRelease, env });
14
18
  const input = options.input ?? process.stdin;
15
19
  const output = options.output ?? process.stdout;
16
20
  const hasCommand = options.hasCommand ?? commandExists;
17
21
  const parsed = parseSetupArgs(options.args ?? []);
22
+ if (parsed.packageManager)
23
+ validateNativePackageManager(parsed.packageManager, host);
18
24
  const installed = detectInstalledComponents(hasCommand);
19
- const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
25
+ const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env);
20
26
  writeHeader(output);
21
- output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`);
27
+ output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`);
22
28
  output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
23
29
  let selected = parsed.only ?? defaultComponents(platform, parsed.all);
24
30
  if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
@@ -35,8 +41,15 @@ export async function runSetup(options = {}) {
35
41
  : ' (opens a separate terminal window)';
36
42
  agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
37
43
  }
38
- const plan = createSetupPlan({ platform, osRelease, packageManager, installed, selected });
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 });
39
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`);
40
53
  const missing = plan.selected.filter(component => !plan.installed[component]);
41
54
  if (missing.length === 0) {
42
55
  output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
@@ -57,8 +70,17 @@ export async function runSetup(options = {}) {
57
70
  await finishMcpSetup(mcp, agentUi, true, output);
58
71
  return;
59
72
  }
60
- if (parsed.packageManager && !hasCommand(parsed.packageManager)) {
61
- 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})` : ''}.`);
62
84
  }
63
85
  if (!parsed.yes && isInteractive(input, output)) {
64
86
  const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
@@ -72,9 +94,9 @@ export async function runSetup(options = {}) {
72
94
  }
73
95
  if (plan.commands.some(command => command.program === 'sudo')) {
74
96
  output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
75
- await runVisibleCommand('sudo', ['-v']);
97
+ await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
76
98
  }
77
- const execute = options.runCommand ?? runInstallCommand;
99
+ const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
78
100
  output.write('\nInstalling\n');
79
101
  for (const command of plan.commands) {
80
102
  await execute(command, output);
@@ -84,48 +106,32 @@ export async function runSetup(options = {}) {
84
106
  await finishMcpSetup(mcp, agentUi, false, output);
85
107
  printVerification(output);
86
108
  }
87
- 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 }));
88
112
  const uniqueSelected = components.filter(component => selected.includes(component));
89
113
  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
- }
114
+ const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, { elevation })).filter((command) => command !== null) : [];
115
+ if (packageManager)
116
+ commands.unshift(...packageInstallPrerequisites(packageManager, missing));
100
117
  return {
101
118
  platform,
102
- platformLabel: platformLabel(platform, osRelease),
119
+ platformLabel: platformLabel(platform, osRelease, env),
103
120
  packageManager,
104
121
  installed,
105
122
  selected: uniqueSelected,
106
123
  commands
107
124
  };
108
125
  }
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);
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
+ }
129
135
  }
130
136
  export function parseSetupArgs(args) {
131
137
  let all = false;
@@ -220,51 +226,6 @@ function parsePackageManager(value) {
220
226
  }
221
227
  return value;
222
228
  }
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
229
  function defaultComponents(platform, all) {
269
230
  if (all)
270
231
  return [...components];
@@ -280,11 +241,11 @@ function detectInstalledComponents(hasCommand) {
280
241
  async function promptForComponents({ platform, installed, input, output }) {
281
242
  output.write('Choose components (installed items will be skipped):\n');
282
243
  const selected = [];
283
- 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))
284
245
  selected.push('mpv');
285
- 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'))
286
247
  selected.push('ffmpeg');
287
- 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))
288
249
  selected.push('vlc');
289
250
  return selected;
290
251
  }
@@ -302,19 +263,23 @@ async function promptYesNo(input, output, question, defaultValue) {
302
263
  }
303
264
  function printPlan(plan, output) {
304
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
+ }
305
269
  for (const component of plan.selected) {
306
270
  if (plan.installed[component])
307
271
  output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
308
272
  else {
309
273
  const command = plan.commands.find(candidate => candidate.component === component);
310
- 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`);
311
275
  }
312
276
  }
313
277
  if (plan.selected.length === 0)
314
278
  output.write(' No components selected\n');
315
279
  }
316
- async function runInstallCommand(command, output) {
317
- const interactive = Boolean(output.isTTY);
280
+ async function runInstallCommand(command, output, platform) {
281
+ const terminal = setupTerminal(output);
282
+ const interactive = terminal.interactive && !terminal.reduceMotion;
318
283
  const startedAt = Date.now();
319
284
  let timer;
320
285
  let frame = 0;
@@ -330,7 +295,8 @@ async function runInstallCommand(command, output) {
330
295
  }
331
296
  try {
332
297
  await new Promise((resolve, reject) => {
333
- 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'] });
334
300
  child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
335
301
  child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
336
302
  child.once('error', reject);
@@ -358,8 +324,9 @@ function progressFrame(label, frame, elapsedMs, output) {
358
324
  const cycle = travel * 2;
359
325
  const offset = frame % cycle;
360
326
  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)}`;
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)}`;
363
330
  }
364
331
  function printVerification(output) {
365
332
  clearCommandCache();
@@ -372,41 +339,34 @@ function printVerification(output) {
372
339
  }
373
340
  function writeHeader(output) {
374
341
  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';
342
+ output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`);
392
343
  }
393
344
  function isInteractive(input, output) {
394
345
  return Boolean(input.isTTY && output.isTTY);
395
346
  }
396
347
  function accent(output, value) {
397
- 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;
398
351
  }
399
352
  function successMark(output) {
400
- return accent(output, '✓');
353
+ return accent(output, setupTerminal(output).unicode ? '✓' : '+');
401
354
  }
402
355
  function pendingMark(output) {
403
- return accent(output, '◆');
356
+ return accent(output, setupTerminal(output).unicode ? '◆' : '*');
404
357
  }
405
358
  function failureMark(output) {
406
- 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?.() });
407
367
  }
408
- function colorsEnabled(output) {
409
- return Boolean(output.isTTY) && !process.env.NO_COLOR;
368
+ function separator(output) {
369
+ return setupTerminal(output).unicode ? '·' : '-';
410
370
  }
411
371
  function clearLine() {
412
372
  return '\u001b[2K';
@@ -428,35 +388,3 @@ async function runVisibleCommand(program, args) {
428
388
  child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
429
389
  });
430
390
  }
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
- }
@@ -1,3 +1,5 @@
1
+ import { platformPaths } from '../platform/paths.js';
2
+ import { assertStorageWritable } from '../platform/storage.js';
1
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
4
  import { homedir } from 'node:os';
3
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
@@ -5,7 +7,6 @@ import { randomUUID } from 'node:crypto';
5
7
  import { z } from 'zod';
6
8
  import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
7
9
  import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
8
- import { backupBadFile } from '../providers/cache.js';
9
10
  import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
10
11
  const stationSchema = z
11
12
  .object({
@@ -234,7 +235,7 @@ export class JsonLibraryStore {
234
235
  state;
235
236
  idGenerator;
236
237
  now;
237
- constructor(filePath = defaultStorePath(), options = {}) {
238
+ constructor(filePath = defaultLibraryPath(), options = {}) {
238
239
  this.filePath = filePath;
239
240
  this.idGenerator = options.idGenerator ?? randomUUID;
240
241
  this.now = options.now ?? (() => new Date());
@@ -496,13 +497,21 @@ export class JsonLibraryStore {
496
497
  return this.commit({ ...this.state, favorites });
497
498
  }
498
499
  addImported(stations) {
499
- const existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
500
+ const incoming = [];
501
+ const incomingKeys = new Set();
500
502
  for (const station of stations) {
501
- existing.set(stationKey(station), station);
503
+ const key = stationKey(station);
504
+ if (incomingKeys.has(key))
505
+ continue;
506
+ incomingKeys.add(key);
507
+ incoming.push(station);
502
508
  }
503
509
  return this.commit({
504
510
  ...this.state,
505
- imported: [...existing.values()].slice(0, 1000)
511
+ imported: [
512
+ ...incoming,
513
+ ...this.state.imported.filter(station => !incomingKeys.has(stationKey(station)))
514
+ ].slice(0, 1000)
506
515
  });
507
516
  }
508
517
  isFavorite(station) {
@@ -535,14 +544,27 @@ export class JsonLibraryStore {
535
544
  }
536
545
  }
537
546
  read() {
538
- if (!existsSync(this.filePath)) {
539
- return defaultState();
547
+ let contents;
548
+ try {
549
+ contents = readFileSync(this.filePath, 'utf8');
550
+ }
551
+ catch (error) {
552
+ if (error.code === 'ENOENT')
553
+ return defaultState();
554
+ throw storageError('read the RadioCLI library', error);
540
555
  }
541
556
  try {
542
- return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8'))));
557
+ return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
543
558
  }
544
559
  catch {
545
- backupBadFile(this.filePath);
560
+ try {
561
+ assertStorageWritable(this.filePath);
562
+ renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
563
+ }
564
+ catch (error) {
565
+ // Reset only after preserving the original bytes. A failed read never reaches here.
566
+ throw storageError('preserve the corrupt RadioCLI library', error);
567
+ }
546
568
  return defaultState();
547
569
  }
548
570
  }
@@ -560,8 +582,14 @@ export class JsonLibraryStore {
560
582
  }
561
583
  }
562
584
  write(state) {
563
- mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
564
- writeJsonAtomically(this.filePath, libraryStateForDisk(state));
585
+ try {
586
+ assertStorageWritable(this.filePath);
587
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
588
+ writeJsonAtomically(this.filePath, libraryStateForDisk(state));
589
+ }
590
+ catch (error) {
591
+ throw storageError('write the RadioCLI library', error);
592
+ }
565
593
  }
566
594
  }
567
595
  export function stationKey(station) {
@@ -602,31 +630,21 @@ function recoverActiveListeningSessionInState(state) {
602
630
  : Date.now();
603
631
  return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
604
632
  }
605
- function defaultStorePath() {
606
- if (process.env.RADIOCLI_HOME) {
607
- return join(process.env.RADIOCLI_HOME, 'radiocli.json');
608
- }
609
- if (process.env.RADIO_ATLAS_HOME) {
610
- return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json');
611
- }
612
- const currentPath = currentDefaultStorePath();
613
- const legacyPath = legacyDefaultStorePath();
614
- return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
615
- }
616
- function currentDefaultStorePath() {
617
- if (process.platform === 'darwin') {
618
- return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
619
- }
620
- if (process.platform === 'win32') {
621
- return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
622
- }
623
- return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
624
- }
625
- function legacyDefaultStorePath() {
626
- if (process.platform === 'darwin') {
627
- return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
633
+ export function defaultLibraryPath() {
634
+ const { library, legacyLibrary } = platformPaths();
635
+ for (const filePath of [library, legacyLibrary]) {
636
+ try {
637
+ statSync(filePath);
638
+ return filePath;
639
+ }
640
+ catch (error) {
641
+ // A permission failure must not select an older library or empty defaults.
642
+ if (error.code !== 'ENOENT') {
643
+ throw storageError('read the RadioCLI library', error);
644
+ }
645
+ }
628
646
  }
629
- return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
647
+ return library;
630
648
  }
631
649
  function resolveUserPath(requestedPath) {
632
650
  const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
@@ -700,53 +718,87 @@ function libraryStateForDisk(state) {
700
718
  : state;
701
719
  }
702
720
  function writeJsonAtomically(filePath, value) {
703
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
721
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
704
722
  try {
705
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
706
- renameSync(tempPath, filePath);
723
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
707
724
  if (process.platform !== 'win32') {
708
- chmodSync(filePath, 0o600);
725
+ chmodSync(tempPath, 0o600);
709
726
  }
727
+ // No fallible persistence steps follow the atomic replacement.
728
+ renameSync(tempPath, filePath);
710
729
  }
711
730
  catch (error) {
712
- rmSync(tempPath, { force: true });
713
- throw error;
731
+ try {
732
+ rmSync(tempPath, { force: true });
733
+ }
734
+ catch { /* Preserve the original write failure. */ }
735
+ throw storageError('write RadioCLI data', error);
714
736
  }
715
737
  }
716
738
  function acquireStoreLock(filePath) {
717
739
  const lockPath = `${filePath}.lock`;
718
- mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
740
+ try {
741
+ assertStorageWritable(filePath);
742
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
743
+ }
744
+ catch (error) {
745
+ throw storageError('write the RadioCLI library', error);
746
+ }
719
747
  const deadline = Date.now() + 1000;
720
748
  while (true) {
721
749
  try {
722
750
  mkdirSync(lockPath, { mode: 0o700 });
723
- return () => rmSync(lockPath, { recursive: true, force: true });
751
+ return () => {
752
+ // The write has already succeeded or failed. Lock cleanup cannot reverse that result.
753
+ try {
754
+ rmSync(lockPath, { recursive: true, force: true });
755
+ }
756
+ catch { /* Stale locks expire below. */ }
757
+ };
724
758
  }
725
759
  catch (error) {
726
760
  const code = error.code;
727
761
  if (code !== 'EEXIST')
728
- throw error;
762
+ throw storageError('lock the RadioCLI library', error);
729
763
  try {
730
764
  if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
731
765
  rmSync(lockPath, { recursive: true, force: true });
732
- continue;
733
766
  }
734
767
  }
735
- catch {
736
- continue;
768
+ catch (lockError) {
769
+ if (lockError.code !== 'ENOENT') {
770
+ throw storageError('lock the RadioCLI library', lockError);
771
+ }
737
772
  }
738
773
  if (Date.now() >= deadline) {
739
- throw new Error(`RadioCLI library is busy: ${filePath}`);
774
+ throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), { code: 'EBUSY', cause: error }));
740
775
  }
741
776
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
742
777
  }
743
778
  }
744
779
  }
780
+ class LibraryStorageError extends Error {
781
+ code;
782
+ errno;
783
+ constructor(operation, cause) {
784
+ const code = cause?.code;
785
+ const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
786
+ super(code === 'EBUSY'
787
+ ? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.'
788
+ : `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, { cause });
789
+ this.name = 'LibraryStorageError';
790
+ this.code = code;
791
+ this.errno = cause?.errno;
792
+ }
793
+ }
794
+ function storageError(operation, cause) {
795
+ return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
796
+ }
745
797
  function mergeLibraryChanges(base, disk, next) {
746
798
  return {
747
799
  recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
748
800
  favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
749
- imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
801
+ imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
750
802
  trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
751
803
  searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
752
804
  alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
@@ -768,15 +820,16 @@ function normalizeAlarm(alarm) {
768
820
  };
769
821
  return { ...alarm, schedule };
770
822
  }
771
- function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
823
+ function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false, applyOrderChanges = false) {
772
824
  const baseByKey = new Map(base.map(item => [keyFor(item), item]));
825
+ const basePositions = new Map(base.map((item, index) => [keyFor(item), index]));
773
826
  const nextKeys = new Set(next.map(keyFor));
774
827
  const removedKeys = applyRemovals
775
828
  ? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
776
829
  : new Set();
777
- const localChanges = next.filter(item => {
830
+ const localChanges = next.filter((item, index) => {
778
831
  const prior = baseByKey.get(keyFor(item));
779
- return prior === undefined || !sameValue(prior, item);
832
+ return prior === undefined || !sameValue(prior, item) || applyOrderChanges && basePositions.get(keyFor(item)) !== index;
780
833
  });
781
834
  const changedKeys = new Set(localChanges.map(keyFor));
782
835
  return [