@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
@@ -0,0 +1,216 @@
1
+ import { commandExists } from './executables.js';
2
+ import { powershellCommand } from './shell.js';
3
+ import { identifyPlatform, readLinuxOsRelease } from './runtime.js';
4
+ export const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman'];
5
+ /** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */
6
+ export function packageManagerProgram(manager) {
7
+ return manager === 'termux-pkg' ? 'pkg' : manager;
8
+ }
9
+ /** Package names/options are selected from the recipes below, never shell input. */
10
+ export function packageCommandInvocation(command, platform, resolve = () => null) {
11
+ const program = resolve(command.program) ?? command.program;
12
+ const args = (command.program === 'sudo' || command.program === 'doas') && command.args[0]
13
+ ? [resolve(command.args[0]) ?? command.args[0], ...command.args.slice(1)]
14
+ : command.args;
15
+ return platform === 'win32'
16
+ ? { program: resolve('powershell.exe') ?? 'powershell.exe', args: powershellCommand([program, ...args]) }
17
+ : { program, args };
18
+ }
19
+ export function detectPackageManager(platform, osRelease, hasCommand = commandExists, env = process.env) {
20
+ const host = identifyPlatform({ platform, osRelease, env });
21
+ if (host.id === 'termux')
22
+ return hasCommand('pkg') ? 'termux-pkg' : null;
23
+ if (platform === 'darwin')
24
+ return hasCommand('brew') ? 'brew' : null;
25
+ if (platform === 'win32')
26
+ return firstAvailable(['winget', 'scoop', 'choco'], hasCommand);
27
+ if (platform === 'freebsd')
28
+ return hasCommand('pkg') ? 'pkg' : null;
29
+ if (platform === 'openbsd')
30
+ return hasCommand('pkg_add') ? 'pkg_add' : null;
31
+ // pkg on Solaris/illumos is IPS, not the FreeBSD installer.
32
+ if (platform === 'netbsd' || platform === 'sunos')
33
+ return hasCommand('pkgin') ? 'pkgin' : null;
34
+ if (platform === 'haiku')
35
+ return hasCommand('pkgman') ? 'pkgman' : null;
36
+ if (platform !== 'linux')
37
+ return null;
38
+ const ids = linuxReleaseIds(osRelease);
39
+ const preferred = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])
40
+ ? ['apt']
41
+ : hasAny(ids, ['fedora', 'rhel', 'centos'])
42
+ ? ['dnf']
43
+ : hasAny(ids, ['arch', 'manjaro'])
44
+ ? ['pacman']
45
+ : hasAny(ids, ['alpine'])
46
+ ? ['apk']
47
+ : hasAny(ids, ['opensuse', 'suse'])
48
+ ? ['zypper']
49
+ : ['apt', 'dnf', 'pacman', 'apk', 'zypper'];
50
+ return firstAvailable(preferred, hasCommand);
51
+ }
52
+ export function packageInstallCommand(manager, component, { elevation = 'sudo' } = {}) {
53
+ const packages = {
54
+ winget: { mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC' },
55
+ // pkgsrc FFmpeg/ffplay packages and executables carry a major version.
56
+ // Choose an available matching pair manually instead of guessing a catalog.
57
+ pkgin: { mpv: 'mpv', vlc: 'vlc' },
58
+ // Termux's main ffmpeg package omits ffplay. Enabling x11-repo and its
59
+ // display/audio services is an explicit manual step, not a setup side effect.
60
+ 'termux-pkg': { mpv: 'mpv' },
61
+ pkgman: { mpv: 'mpv', ffmpeg: 'ffmpeg8_tools' }
62
+ };
63
+ const packageName = packages[manager] ? packages[manager][component] : component;
64
+ if (!packageName)
65
+ return null;
66
+ let program = packageManagerProgram(manager);
67
+ let args;
68
+ if (manager === 'brew')
69
+ args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])];
70
+ else if (manager === 'winget')
71
+ args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements'];
72
+ else if (manager === 'scoop')
73
+ args = ['install', packageName];
74
+ else if (manager === 'choco')
75
+ args = ['install', packageName, '-y'];
76
+ else if (manager === 'apt') {
77
+ program = 'apt-get';
78
+ args = ['install', '-y', packageName];
79
+ }
80
+ else if (manager === 'pacman')
81
+ args = ['-S', '--needed', '--noconfirm', packageName];
82
+ else if (manager === 'apk')
83
+ args = ['add', packageName];
84
+ else if (manager === 'zypper')
85
+ args = ['--non-interactive', 'install', packageName];
86
+ else if (manager === 'dnf' || manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman')
87
+ args = ['install', '-y', packageName];
88
+ else if (manager === 'pkg_add')
89
+ args = ['-I', packageName];
90
+ else
91
+ args = ['-y', 'install', packageName];
92
+ if (packageManagerNeedsRoot(manager) && elevation) {
93
+ args = [program, ...args];
94
+ program = elevation;
95
+ }
96
+ return { component, label: componentLabel(component), program, args, display: [program, ...args].join(' ') };
97
+ }
98
+ export function packageManagerNeedsRoot(manager) {
99
+ return !['brew', 'winget', 'scoop', 'choco', 'termux-pkg', 'pkgman'].includes(manager);
100
+ }
101
+ export function packageManagerNotes(manager, host = identifyPlatform()) {
102
+ if (manager === 'termux-pkg' || (!manager && host.id === 'termux'))
103
+ return [
104
+ 'Termux pkg runs as the normal app user, without sudo or root. Use --package-manager=termux-pkg to select this recipe explicitly.',
105
+ 'The Termux ffmpeg package does not include ffplay. The separate ffplay package needs x11-repo and a configured display/audio environment; mpv is the recommended audio player.',
106
+ 'Android clipboard integration requires the termux-api package and a compatible Termux:API app; helper execution can still fail if Android denies access.'
107
+ ];
108
+ if (manager === 'pkgman' || (!manager && host.id === 'haiku'))
109
+ return [
110
+ 'The verified HaikuPorts runtime recipe provides Node 20, below RadioCLI\'s Node 22 minimum. These playback recipes do not resolve that runtime blocker; a separate working Node 22+ port is required.',
111
+ 'Haiku ffmpeg8_tools provides ffmpeg and ffplay. A secondary-architecture install may require explicit RADIOCLI_FFMPEG_PATH and RADIOCLI_FFPLAY_PATH values.'
112
+ ];
113
+ if (host.id === 'aix')
114
+ return ['There is no verified AIX mpv or ffplay package recipe. Provision a working native player manually and set RADIOCLI_MPV_PATH or RADIOCLI_FFPLAY_PATH; IBM Toolbox DNF availability does not establish a playback package.'];
115
+ if (manager === 'pkg')
116
+ return ['FreeBSD ffmpeg provides ffplay only when built with SDL; verify the executable separately.'];
117
+ if (manager === 'pkgin' || host.id === 'sunos')
118
+ return [
119
+ ...(host.id === 'sunos' ? ['Solaris/illumos setup uses an existing pkgsrc/pkgin installation. IPS pkg is a different tool and is not used by this recipe.'] : []),
120
+ 'pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.'
121
+ ];
122
+ return [];
123
+ }
124
+ export function platformLabel(platform, osRelease, env = process.env) {
125
+ if (identifyPlatform({ platform, osRelease, env }).id === 'termux')
126
+ return 'Android / Termux';
127
+ if (platform === 'darwin')
128
+ return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`;
129
+ if (platform === 'win32')
130
+ return 'Windows';
131
+ if (platform === 'haiku')
132
+ return 'Haiku';
133
+ if (platform === 'sunos')
134
+ return 'Solaris / illumos';
135
+ if (platform === 'aix')
136
+ return 'AIX';
137
+ if (platform === 'android')
138
+ return 'Android';
139
+ if (platform === 'linux')
140
+ return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux';
141
+ return platform;
142
+ }
143
+ export function componentLabel(component) {
144
+ if (component === 'ffmpeg')
145
+ return 'FFmpeg / ffplay';
146
+ if (component === 'vlc')
147
+ return 'VLC fallback';
148
+ return 'mpv';
149
+ }
150
+ function firstAvailable(values, hasCommand) {
151
+ return values.find(hasCommand) ?? null;
152
+ }
153
+ function linuxReleaseIds(osRelease) {
154
+ const ids = new Set();
155
+ for (const line of osRelease.split('\n')) {
156
+ const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
157
+ if (!match)
158
+ continue;
159
+ for (const value of match[2].replaceAll('"', '').split(/\s+/))
160
+ if (value.trim())
161
+ ids.add(value.trim().toLowerCase());
162
+ }
163
+ return ids;
164
+ }
165
+ function osReleaseValue(osRelease, key) {
166
+ const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`));
167
+ return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? '';
168
+ }
169
+ function hasAny(values, candidates) {
170
+ return candidates.some(candidate => values.has(candidate));
171
+ }
172
+ export function packageManagerElevation(hasCommand, isRoot) {
173
+ return isRoot ? null : firstAvailable(['sudo', 'doas'], hasCommand);
174
+ }
175
+ export function packageInstallPrerequisites(manager, components) {
176
+ if (manager !== 'scoop' || !components.some(component => component === 'mpv' || component === 'vlc'))
177
+ return [];
178
+ return [{ component: null, label: 'Scoop extras bucket', program: 'scoop', args: ['bucket', 'add', 'extras'], display: 'scoop bucket add extras' }];
179
+ }
180
+ export function mpvInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease(platform), env = process.env, options = {}) {
181
+ return playbackInstallHint('mpv', platform, osRelease, env, options);
182
+ }
183
+ export function ffplayInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease(platform), env = process.env, options = {}) {
184
+ return playbackInstallHint('ffmpeg', platform, osRelease, env, options);
185
+ }
186
+ function playbackInstallHint(component, platform, osRelease, env, { hasCommand = commandExists, getUid = process.getuid }) {
187
+ const host = identifyPlatform({ platform, osRelease, env });
188
+ const player = component === 'mpv' ? 'mpv' : 'ffplay';
189
+ if (host.id === 'aix')
190
+ return `manual native player required; no verified AIX ${player} package recipe (set RADIOCLI_${player.toUpperCase()}_PATH)`;
191
+ const manager = detectPackageManager(platform, osRelease, hasCommand, env);
192
+ if (component === 'ffmpeg' && host.id === 'termux')
193
+ return `enable x11-repo manually, then ${manager ? 'pkg install -y ffplay' : 'install the separate ffplay package manually'} (requires configured display/audio); use mpv for audio`;
194
+ if (component === 'ffmpeg' && (platform === 'netbsd' || platform === 'sunos'))
195
+ return 'manually install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH';
196
+ const notes = [];
197
+ if (component === 'ffmpeg' && platform === 'freebsd')
198
+ notes.push('ffplay requires the SDL build option');
199
+ if (!manager)
200
+ return [`install ${component === 'mpv' ? 'mpv' : 'FFmpeg'} manually; no supported package manager was found`, ...notes].join('; ');
201
+ const isRoot = getUid?.() === 0;
202
+ const elevation = packageManagerElevation(hasCommand, isRoot);
203
+ const command = packageInstallCommand(manager, component, { elevation });
204
+ if (!command)
205
+ return `install ${componentLabel(component)} manually`;
206
+ if (packageManagerNeedsRoot(manager) && !isRoot && !elevation)
207
+ notes.unshift('run as root; sudo/doas unavailable');
208
+ if (manager === 'pkgin')
209
+ notes.push('requires configured pkgsrc');
210
+ if (manager === 'termux-pkg' && isRoot)
211
+ notes.push('run as the normal Termux app user, without root');
212
+ const prerequisites = packageInstallPrerequisites(manager, [component]);
213
+ for (const prerequisite of prerequisites)
214
+ notes.push(`first run ${prerequisite.display}`);
215
+ return command.display + (notes.length ? ` (${notes.join('; ')})` : '');
216
+ }
@@ -0,0 +1,40 @@
1
+ import { homedir } from 'node:os';
2
+ import { posix, win32 } from 'node:path';
3
+ /** Path selectors form one launch identity, including selectors that are absent. */
4
+ export const pathEnvironmentKeys = [
5
+ 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
6
+ 'RADIOCLI_HOME', 'RADIO_ATLAS_HOME',
7
+ 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'
8
+ ];
9
+ /**
10
+ * Keep historical namespaces stable. In particular, macOS alarm-control/Guard
11
+ * files and general agent/occurrence runtime files already use different roots.
12
+ * Moving either would strand active authenticated sessions during an upgrade.
13
+ */
14
+ export function platformPaths({ platform = process.platform, home = homedir(), env = process.env } = {}) {
15
+ const path = platform === 'win32' ? win32 : posix;
16
+ const dataRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
17
+ : platform === 'win32' ? env.APPDATA ?? path.join(home, 'AppData', 'Roaming')
18
+ : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share');
19
+ const cacheRoot = platform === 'darwin' ? path.join(home, 'Library', 'Caches')
20
+ : platform === 'win32' ? env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local')
21
+ : env.XDG_CACHE_HOME ?? path.join(home, '.cache');
22
+ const appName = platform === 'win32' ? 'RadioCLI' : 'radiocli';
23
+ const legacyRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share');
24
+ const legacyCacheRoot = platform === 'darwin' ? legacyRoot : env.XDG_CACHE_HOME ?? path.join(home, '.cache');
25
+ const override = env.RADIOCLI_HOME || env.RADIO_ATLAS_HOME;
26
+ const overrideName = env.RADIOCLI_HOME ? 'radiocli' : 'radio-atlas';
27
+ const library = override ? path.join(override, `${overrideName}.json`) : path.join(dataRoot, appName, 'radiocli.json');
28
+ const cache = override ? path.join(override, `${overrideName}-cache.json`) : path.join(cacheRoot, appName, 'radiocli-cache.json');
29
+ const runtime = env.RADIOCLI_HOME ? path.join(env.RADIOCLI_HOME, 'runtime')
30
+ : platform === 'win32' ? path.join(cacheRoot, appName, 'runtime')
31
+ : path.join(env.XDG_RUNTIME_DIR ?? path.join(home, '.local', 'state'), 'radiocli');
32
+ return {
33
+ library,
34
+ legacyLibrary: override ? library : path.join(legacyRoot, 'radio-atlas', 'radio-atlas.json'),
35
+ cache,
36
+ legacyCache: override ? cache : path.join(legacyCacheRoot, 'radio-atlas', 'radio-atlas-cache.json'),
37
+ runtime,
38
+ alarmRuntime: !env.RADIOCLI_HOME && platform === 'darwin' ? path.join(dataRoot, 'radiocli', 'runtime') : runtime
39
+ };
40
+ }
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { endianness, release } from 'node:os';
3
+ const knownPlatforms = new Set(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix']);
4
+ /** Host facts are independent from service readiness and project support tiers. */
5
+ export function identifyPlatform(input = {}) {
6
+ const platform = input.platform ?? process.platform;
7
+ const env = input.env ?? process.env;
8
+ const termux = (platform === 'android' || platform === 'linux') && Boolean(env.TERMUX_VERSION || env.PREFIX?.match(/^\/data\/(?:data|user\/\d+)\/com\.termux(?:\.[^/]+)?\/files\/usr\/?$/));
9
+ const id = termux ? 'termux' : knownPlatforms.has(platform) ? platform : 'unknown';
10
+ const kernelRelease = input.release ?? release();
11
+ const nodeVersion = (input.nodeVersion ?? process.versions.node).replace(/^v/, '');
12
+ const arch = input.arch ?? process.arch;
13
+ const armVersion = input.armVersion !== undefined ? input.armVersion
14
+ : platform === process.platform && arch === process.arch ? Number(Reflect.get(process.config.variables, 'arm_version')) : null;
15
+ return {
16
+ id,
17
+ platform,
18
+ arch,
19
+ armVersion: arch === 'arm' && armVersion !== null && Number.isInteger(armVersion) && armVersion > 0 ? armVersion : null,
20
+ endianness: input.endianness ?? endianness(),
21
+ release: kernelRelease,
22
+ nodeVersion,
23
+ nodeMajor: Number.parseInt(nodeVersion, 10),
24
+ libc: platform === 'android' || termux ? 'bionic' : input.libc ?? (platform === 'linux' ? nativeLibc(platform) : 'none'),
25
+ isWsl: id === 'linux' && Boolean(env.WSL_INTEROP || env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(kernelRelease)),
26
+ osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readLinuxOsRelease() : '')
27
+ };
28
+ }
29
+ /** Selecting a native adapter does not establish that its service is running. */
30
+ export function nativeAdapters(host = identifyPlatform()) {
31
+ const portable = { scheduler: null, inhibitor: null, volume: null, terminal: null, ipc: 'unix-socket', posixPermissions: true, airPlay: false };
32
+ if (host.id === 'darwin')
33
+ return { ...portable, scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true };
34
+ if (host.id === 'win32')
35
+ return { ...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false };
36
+ if (host.id === 'linux')
37
+ return { ...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix' };
38
+ if (['freebsd', 'openbsd', 'netbsd'].includes(host.id))
39
+ return { ...portable, terminal: 'unix' };
40
+ return portable;
41
+ }
42
+ export function readLinuxOsRelease(platform = process.platform) {
43
+ if (platform !== 'linux')
44
+ return '';
45
+ try {
46
+ return readFileSync('/etc/os-release', 'utf8');
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }
52
+ let detectedLibc;
53
+ function nativeLibc(platform) {
54
+ if (platform !== process.platform)
55
+ return 'unknown';
56
+ if (detectedLibc)
57
+ return detectedLibc;
58
+ try {
59
+ const report = process.report.getReport();
60
+ detectedLibc = report.header?.glibcVersionRuntime ? 'glibc'
61
+ : report.sharedObjects?.some(path => /(?:ld-musl|libc\.musl)/.test(path)) ? 'musl' : 'unknown';
62
+ }
63
+ catch {
64
+ detectedLibc = 'unknown';
65
+ }
66
+ return detectedLibc;
67
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Keep values out of PowerShell source when a native tool or script shim must
3
+ * run there. Native argument conversion still belongs to the target command;
4
+ * terminal launches use an encoded Node bootstrap for arbitrary argv values.
5
+ */
6
+ export function powershellCommand(values, environment = {}, options = {}) {
7
+ if (!values[0])
8
+ throw new Error('A PowerShell command is required.');
9
+ if ([...values, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) {
10
+ throw new Error('PowerShell command values cannot contain NUL bytes.');
11
+ }
12
+ const payload = Buffer.from(JSON.stringify({ command: values[0], args: values.slice(1), environment }), 'utf8').toString('base64');
13
+ const script = [
14
+ "$ErrorActionPreference='Stop'",
15
+ "$ProgressPreference='SilentlyContinue'",
16
+ `$radiocliInvocation=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`,
17
+ "foreach($radiocliVariable in $radiocliInvocation.environment.PSObject.Properties){[Environment]::SetEnvironmentVariable($radiocliVariable.Name,[string]$radiocliVariable.Value,'Process')}",
18
+ '$radiocliArguments=@($radiocliInvocation.args | ForEach-Object {[string]$_})',
19
+ '$global:LASTEXITCODE=0',
20
+ '& ([string]$radiocliInvocation.command) @radiocliArguments',
21
+ ...(options.keepOpen ? [] : ['$radiocliSucceeded=$?', 'if(-not $radiocliSucceeded -and $LASTEXITCODE -eq 0){exit 1}', 'exit $LASTEXITCODE'])
22
+ ].join(';');
23
+ return ['-NoLogo', '-NoProfile', ...(options.keepOpen ? ['-NoExit'] : ['-NonInteractive']), '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
24
+ }
@@ -0,0 +1,48 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ /** Access checks are advisory: ACLs, mounts, and permissions can change before a write. */
4
+ export function storageReadiness(filePath) {
5
+ try {
6
+ assertStorageWritable(filePath);
7
+ return {
8
+ status: 'available',
9
+ message: 'Filesystem access checks passed for the library location; each operation still verifies its write.'
10
+ };
11
+ }
12
+ catch {
13
+ return {
14
+ status: 'unavailable',
15
+ message: 'The library location is not accessible for writes. Check permissions or set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.'
16
+ };
17
+ }
18
+ }
19
+ /** Does not create a probe file or missing directories. The actual write remains authoritative. */
20
+ export function assertStorageWritable(filePath) {
21
+ try {
22
+ if (!statSync(filePath).isFile()) {
23
+ throw Object.assign(new Error('The library destination is not a regular file.'), { code: 'EINVAL' });
24
+ }
25
+ // An atomic rename could otherwise replace a deliberately read-only file on POSIX.
26
+ accessSync(filePath, constants.R_OK | constants.W_OK);
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 'ENOENT')
30
+ throw error;
31
+ }
32
+ let parent = dirname(resolve(filePath));
33
+ while (true) {
34
+ try {
35
+ if (!statSync(parent).isDirectory()) {
36
+ throw Object.assign(new Error('The library parent is not a directory.'), { code: 'ENOTDIR' });
37
+ }
38
+ accessSync(parent, constants.W_OK | constants.X_OK);
39
+ return;
40
+ }
41
+ catch (error) {
42
+ const ancestor = dirname(parent);
43
+ if (error.code !== 'ENOENT' || ancestor === parent)
44
+ throw error;
45
+ parent = ancestor;
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,214 @@
1
+ import { identifyPlatform } from './runtime.js';
2
+ /**
3
+ * Runtime policy and project evidence are deliberately independent. Catalogs,
4
+ * platform mocks, containers, and downloaded artifacts never supply evidence.
5
+ * This checks the Node 22/24 reference matrix; other newer lines remain unverified.
6
+ * OS vendor lifecycle, CPU instruction levels, libc versions, and audio services
7
+ * require separate checks when the profile does not contain those facts.
8
+ *
9
+ * Reference policy (checked 2026-09-07):
10
+ * https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list
11
+ * https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list
12
+ */
13
+ export function assessPlatformSupport(host = identifyPlatform(), suppliedEvidence = {}) {
14
+ const evidence = {
15
+ nativeRuntimeVerified: suppliedEvidence.nativeRuntimeVerified === true,
16
+ packedInstallVerified: suppliedEvidence.packedInstallVerified === true,
17
+ playbackVerified: suppliedEvidence.playbackVerified === true,
18
+ requiredCi: suppliedEvidence.requiredCi === true
19
+ };
20
+ const runtime = assessRuntime(host);
21
+ const reasons = [...runtime.reasons];
22
+ let tier = 'experimental';
23
+ if (runtime.status === 'unsupported') {
24
+ tier = 'unsupported';
25
+ }
26
+ else if (evidence.nativeRuntimeVerified && evidence.packedInstallVerified && evidence.playbackVerified) {
27
+ tier = evidence.requiredCi ? 'first-class' : 'supported';
28
+ reasons.push(evidence.requiredCi
29
+ ? 'Native runtime, packed installation, and playback are verified with required CI coverage.'
30
+ : 'Native runtime, packed installation, and playback are verified; required CI coverage is not established.');
31
+ }
32
+ else {
33
+ if (!evidence.nativeRuntimeVerified)
34
+ reasons.push('Native runtime execution has not been verified.');
35
+ if (!evidence.packedInstallVerified)
36
+ reasons.push('Packed npm installation has not been verified.');
37
+ if (!evidence.playbackVerified)
38
+ reasons.push('Playback has not been verified.');
39
+ }
40
+ return { tier, runtime, reasons, evidence };
41
+ }
42
+ function assessRuntime(host) {
43
+ const node = /^(\d+)\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(host.nodeVersion);
44
+ if (!node)
45
+ return assessment('unverified', 'unverified', `Cannot assess the Node.js version ${JSON.stringify(host.nodeVersion)}.`);
46
+ const major = Number(node[1]);
47
+ if (!Number.isSafeInteger(major))
48
+ return assessment('unverified', 'unverified', 'The Node.js major version is not recognized.');
49
+ if (major < 22)
50
+ return assessment('unsupported', 'unsupported', `RadioCLI requires Node.js 22 or newer; this runtime is ${host.nodeVersion}.`);
51
+ if (major !== 22 && major !== 24) {
52
+ return assessment('unverified', 'unverified', `Node.js ${major} meets the package minimum, but its platform requirements are outside the verified Node.js 22/24 reference matrix.`);
53
+ }
54
+ const arch = host.arch === 'arm' && host.armVersion === 7 ? 'armv7' : architecture(host.arch);
55
+ if (host.id === 'darwin' && arch === 'ia32')
56
+ return assessment('unsupported', 'unsupported', 'Node.js 22/24 do not provide a 32-bit macOS runtime; use Intel x64 or Apple Silicon arm64.');
57
+ if (host.id === 'win32' && major === 24 && arch === 'ia32')
58
+ return assessment('unsupported', 'unsupported', 'Node.js 24 has no Windows x86 runtime; use Node.js 22 for ia32 or a native x64/arm64 runtime.');
59
+ if (host.id === 'aix' && (arch === 'ppc64' || arch === 'ppc64le') && (host.endianness !== 'BE' || arch === 'ppc64le')) {
60
+ return assessment('unsupported', 'unsupported', 'The AIX Node.js runtime requires big-endian ppc64; a little-endian POWER profile is incompatible.');
61
+ }
62
+ const version = releaseVersion(host.release);
63
+ if (!version) {
64
+ return assessment('unverified', 'unverified', `${host.id === 'darwin' ? 'Darwin' : host.platform} release is unavailable or unrecognized; runtime eligibility needs the OS version.`);
65
+ }
66
+ switch (host.id) {
67
+ case 'darwin':
68
+ if (!['x64', 'arm64'].includes(arch))
69
+ return unknownArchitecture(host);
70
+ // os.release() is the Darwin kernel: Darwin 20 = macOS 11,
71
+ // Darwin 22.6 = macOS 13.5. It is not a macOS product-version string.
72
+ if (version.length < 2)
73
+ return assessment('unverified', 'tier-1', 'A complete Darwin kernel release is needed to check the macOS runtime minimum.');
74
+ if (olderThan(version, major === 22 ? [20, 0] : [22, 6])) {
75
+ return assessment('unsupported', 'unsupported', `Node.js ${major} requires ${major === 22 ? 'macOS 11 (Darwin 20)' : 'macOS 13.5 (Darwin 22.6)'} or newer.`);
76
+ }
77
+ return assessment('eligible', 'tier-1', `The Darwin kernel meets the Node.js ${major} macOS binary minimum for ${arch}.`);
78
+ case 'win32':
79
+ if (!['x64', 'arm64', 'ia32'].includes(arch))
80
+ return unknownArchitecture(host);
81
+ if (version.length < 2)
82
+ return assessment('unverified', 'unverified', 'A complete Windows kernel version is needed to check the Windows 10 minimum.');
83
+ if (olderThan(version, [10, 0]))
84
+ return assessment('unsupported', 'unsupported', 'The standard Node.js 22/24 Windows runtime requires Windows 10 / Server 2016 or newer.');
85
+ return assessment('eligible', arch === 'arm64' ? 'tier-2' : 'tier-1', arch === 'ia32'
86
+ ? 'Node.js 22 provides Windows x86 binaries; upstream tests run under WoW64, which does not establish native 32-bit Windows verification.'
87
+ : `Node.js ${major} provides a Windows ${arch} runtime; the kernel version alone does not distinguish Windows desktop and Server editions.`);
88
+ case 'linux':
89
+ return linuxRuntime(host, arch, major, version);
90
+ case 'freebsd':
91
+ // Port allowlists are not evidence that every release/CPU binary exists.
92
+ // https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile
93
+ // https://github.com/freebsd/freebsd-ports/blob/main/www/node24/Makefile
94
+ if (!['x64', 'arm64', 'ia32', 'armv7', 'ppc64', 'ppc64le'].includes(arch))
95
+ return unknownArchitecture(host);
96
+ if (olderThan(version, [13, 2]))
97
+ return assessment('unverified', 'experimental', 'FreeBSD is below the upstream experimental 13.2 baseline; a compatible Node.js port needs separate verification.');
98
+ return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', 'FreeBSD has Node.js 22/24 ports; use the matching npm-node package and verify availability for this release and architecture.');
99
+ case 'openbsd':
100
+ // https://github.com/openbsd/ports/blob/master/lang/node/Makefile
101
+ if (!['x64', 'arm64', 'ia32', 'ppc64', 'riscv64'].includes(arch))
102
+ return unknownArchitecture(host);
103
+ return assessment('eligible', 'community', 'OpenBSD uses its community Node.js port; the selected OS release and architecture must provide Node.js 22 or newer.');
104
+ case 'netbsd':
105
+ // https://github.com/NetBSD/pkgsrc/tree/trunk/lang/nodejs22
106
+ if (!['x64', 'arm64'].includes(arch))
107
+ return unknownArchitecture(host);
108
+ return assessment('eligible', 'community', 'NetBSD has pkgsrc Node.js 22/24 ports; binary packages are named nodejs and depend on the repository release and architecture.');
109
+ case 'termux':
110
+ // https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh
111
+ if (!['x64', 'arm64', 'ia32', 'arm', 'armv7'].includes(arch))
112
+ return unknownArchitecture(host);
113
+ return assessment('eligible', 'community', 'Termux needs its Android Bionic Node.js package and Android 7 or newer; the Linux kernel release does not establish the Android API version.');
114
+ case 'android':
115
+ return assessment('unverified', 'unverified', 'Android outside Termux has no established RadioCLI runtime installation route; verify a compatible Bionic Node.js build.');
116
+ case 'haiku':
117
+ // https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs
118
+ return assessment('unverified', 'community', 'HaikuPorts currently provides Node.js 20; this newer runtime requires a separately verified custom Node.js port.');
119
+ case 'sunos':
120
+ return assessment('unverified', 'community', 'SmartOS x64 has upstream Tier 2 status; generic SunOS kernel 5.11 does not identify SmartOS, illumos distribution, or its version. Verify the selected Node.js port and playback backend.');
121
+ case 'aix':
122
+ if (arch !== 'ppc64')
123
+ return unknownArchitecture(host);
124
+ return assessment('eligible', 'tier-2', `AIX requires big-endian POWER8 or newer and AIX 7.2 TL04 or newer${major === 24 ? ', plus libstdc++12' : ''}; uname release alone does not establish these prerequisites. Playback backend availability needs separate verification.`);
125
+ case 'unknown':
126
+ return unknownArchitecture(host);
127
+ }
128
+ }
129
+ function linuxRuntime(host, arch, major, version) {
130
+ if (host.isWsl)
131
+ return assessment('unverified', 'community', 'WSL uses a Linux Node.js runtime, but upstream support requires reproducing issues on native Linux; verify this WSL environment separately.');
132
+ if (arch === 'arm')
133
+ return assessment('unverified', 'unverified', 'The generic arm architecture does not identify its ISA revision. Node.js 22 Tier 1 applies to ARMv7; older ARM and Node.js 24 ARMv7 need separate community verification.');
134
+ if ((arch === 'ppc64' || arch === 'ppc64le') && host.endianness !== 'LE') {
135
+ return assessment('unverified', 'unverified', 'Upstream Linux POWER support applies to ppc64le, not big-endian ppc64. A compatible big-endian Node.js 22+ community port must be verified separately.');
136
+ }
137
+ if (host.libc === 'musl') {
138
+ // Node 24 now publishes x64-musl files while its matrix remains Experimental.
139
+ // Other known routes are distributor builds, not glibc binary compatibility.
140
+ // https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt
141
+ // https://github.com/nodejs/docker-node/blob/main/versions.json
142
+ const knownRoute = ['x64', 'arm64', 's390x'].includes(arch) || (arch === 'armv7' && major === 22);
143
+ if (!knownRoute)
144
+ return assessment('unverified', 'community', `A Node.js ${major} musl build for ${host.arch} requires separate package and runtime verification.`);
145
+ if (olderThan(version, [3, 10]))
146
+ return assessment('unverified', 'experimental', 'This kernel is below the Node.js musl reference baseline; compatibility is unverified.');
147
+ return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', `Use a musl-specific Node.js ${major} build and its libstdc++ dependencies; libc family detection does not verify the musl version.`);
148
+ }
149
+ if (host.libc !== 'glibc')
150
+ return assessment('unverified', 'unverified', `Linux libc is ${host.libc}; select a compatible Node.js build before declaring runtime eligibility.`);
151
+ let upstream;
152
+ let minimumKernel = [4, 18];
153
+ let minimumGlibc = '2.28';
154
+ let detail = '';
155
+ if (arch === 'x64' || arch === 'arm64')
156
+ upstream = 'tier-1';
157
+ else if (arch === 'armv7') {
158
+ upstream = major === 22 ? 'tier-1' : 'experimental';
159
+ detail = major === 22 ? ' ARMv7 binaries also require GLIBCXX_3.4.28.' : ' Node.js 24 ARMv7 needs a community build; no upstream release binary is provided.';
160
+ }
161
+ else if (arch === 'ppc64' || arch === 'ppc64le' || arch === 's390x') {
162
+ upstream = 'tier-2';
163
+ if (arch !== 's390x')
164
+ detail = ' POWER8 or newer is required.';
165
+ }
166
+ else if (arch === 'riscv64' && major === 24) {
167
+ upstream = 'experimental';
168
+ minimumKernel = [5, 19];
169
+ minimumGlibc = '2.36';
170
+ }
171
+ else if (arch === 'ia32') {
172
+ upstream = 'experimental';
173
+ minimumKernel = [3, 10];
174
+ minimumGlibc = '2.17';
175
+ }
176
+ else
177
+ return unknownArchitecture(host);
178
+ if (version.length < 2 || olderThan(version, minimumKernel)) {
179
+ return assessment('unverified', upstream, `The glibc Linux kernel does not establish the ${minimumKernel.join('.')} reference minimum; older kernels may work but require separate verification.`);
180
+ }
181
+ return assessment('eligible', upstream, `Node.js ${major} requires glibc >= ${minimumGlibc} and a compatible libstdc++; the profile records only the libc family.${detail}`);
182
+ }
183
+ function assessment(status, upstream, ...reasons) {
184
+ return { status, upstream, reasons };
185
+ }
186
+ function unknownArchitecture(host) {
187
+ return assessment('unverified', 'unverified', `No runtime eligibility is established for ${host.platform}/${host.arch}; verify a compatible Node.js port instead of inferring support from another target.`);
188
+ }
189
+ function architecture(arch) {
190
+ if (['amd64', 'x86_64'].includes(arch))
191
+ return 'x64';
192
+ if (arch === 'aarch64')
193
+ return 'arm64';
194
+ if (['x86', 'i386', 'i686'].includes(arch))
195
+ return 'ia32';
196
+ if (arch === 'armv7l')
197
+ return 'armv7';
198
+ return arch;
199
+ }
200
+ function releaseVersion(release) {
201
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[.+_-][\w.+-]+)?$/.exec(release);
202
+ if (!match)
203
+ return null;
204
+ const parts = match.slice(1).filter(part => part !== undefined).map(Number);
205
+ return parts.every(Number.isSafeInteger) ? parts : null;
206
+ }
207
+ function olderThan(actual, minimum) {
208
+ for (let index = 0; index < minimum.length; index++) {
209
+ const difference = (actual[index] ?? 0) - minimum[index];
210
+ if (difference !== 0)
211
+ return difference < 0;
212
+ }
213
+ return false;
214
+ }