@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,36 @@
1
+ import { identifyPlatform } from './runtime.js';
2
+ /** Command plans only. Callers must check the session and observe execution. */
3
+ export function browserCommands(host = identifyPlatform()) {
4
+ // termux-tools opens one URL through Android's VIEW intent.
5
+ if (host.id === 'termux')
6
+ return [{ command: 'termux-open-url', args: [] }];
7
+ if (host.id === 'darwin')
8
+ return [{ command: 'open', args: [] }];
9
+ if (host.id === 'win32')
10
+ return [{ command: 'explorer', args: [] }];
11
+ if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
12
+ return [{ command: 'xdg-open', args: [] }];
13
+ return [];
14
+ }
15
+ export function clipboardCandidates(host = identifyPlatform()) {
16
+ // No arguments makes termux-clipboard-set read stdin. Its matching Android
17
+ // API app and permissions are still required; the caller checks execution.
18
+ if (host.id === 'termux')
19
+ return [{ command: 'termux-clipboard-set', args: [] }];
20
+ if (host.id === 'darwin')
21
+ return [{ command: 'pbcopy', args: [] }];
22
+ if (host.id === 'win32')
23
+ return [{ command: 'clip', args: [] }];
24
+ if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
25
+ return [
26
+ { command: 'wl-copy', args: [] },
27
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
28
+ { command: 'xsel', args: ['--clipboard', '--input'] }
29
+ ];
30
+ return [];
31
+ }
32
+ export function hasGraphicalSession(host, env = process.env) {
33
+ if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux')
34
+ return !env.SSH_CONNECTION && !env.SSH_TTY;
35
+ return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
36
+ }
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { accessSync, constants, existsSync } from 'node:fs';
2
+ import { accessSync, constants, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { posix, win32 } from 'node:path';
5
+ import { identifyPlatform } from './runtime.js';
5
6
  export function commandExists(command) {
6
7
  return resolveCommand(command) !== null;
7
8
  }
@@ -12,8 +13,10 @@ export function resolveCommand(command) {
12
13
  return resolveCommandDetails(command).path;
13
14
  }
14
15
  export function resolveCommandDetails(command, overrides) {
16
+ const variable = overrideVariable(command);
17
+ const cacheKey = [command, process.env.PATH, process.env.PATHEXT, process.env.PREFIX, process.env.TERMUX_VERSION, variable ? process.env[variable] : undefined].join('\0');
15
18
  if (!overrides) {
16
- const cached = commandCache.get(command);
19
+ const cached = commandCache.get(cacheKey);
17
20
  if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs)
18
21
  return cached.resolution;
19
22
  }
@@ -26,11 +29,24 @@ export function resolveCommandDetails(command, overrides) {
26
29
  registryPaths: overrides?.registryPaths ?? (name => windowsRegistryPaths(name, platform))
27
30
  };
28
31
  const resolution = resolveUncached(command, options);
29
- if (!overrides)
30
- commandCache.set(command, { resolution, checkedAt: Date.now() });
32
+ if (!overrides) {
33
+ if (commandCache.size >= 256)
34
+ commandCache.clear();
35
+ commandCache.set(cacheKey, { resolution, checkedAt: Date.now() });
36
+ }
31
37
  return resolution;
32
38
  }
33
39
  function resolveUncached(command, options) {
40
+ const variable = overrideVariable(command);
41
+ const configured = variable ? options.env[variable] : undefined;
42
+ if (configured !== undefined) {
43
+ const pathApi = options.platform === 'win32' ? win32 : posix;
44
+ if (!configured || /[\0\r\n]/.test(configured) || !pathApi.isAbsolute(configured) || (options.platform === 'win32' && !/\.(exe|com)$/i.test(configured))) {
45
+ return { path: null, discovery: 'configured-path', error: `${variable} must be an absolute native executable path, without shell arguments.` };
46
+ }
47
+ return options.isRunnable(configured) ? { path: configured, discovery: 'configured-path' }
48
+ : { path: null, discovery: 'configured-path', error: `${variable} does not name a runnable file.` };
49
+ }
34
50
  const pathMatch = lookupOnPath(command, options);
35
51
  if (pathMatch)
36
52
  return { path: pathMatch, discovery: 'path' };
@@ -47,6 +63,9 @@ function resolveUncached(command, options) {
47
63
  }
48
64
  return { path: null, discovery: 'missing' };
49
65
  }
66
+ function overrideVariable(command) {
67
+ return { mpv: 'RADIOCLI_MPV_PATH', ffplay: 'RADIOCLI_FFPLAY_PATH', vlc: 'RADIOCLI_VLC_PATH', cvlc: 'RADIOCLI_VLC_PATH', ffmpeg: 'RADIOCLI_FFMPEG_PATH' }[command];
68
+ }
50
69
  function lookupOnPath(command, options) {
51
70
  if (command.includes('/') || command.includes('\\'))
52
71
  return options.isRunnable(command) ? command : null;
@@ -85,12 +104,11 @@ export function clearCommandCache() {
85
104
  commandCache.clear();
86
105
  }
87
106
  function isRunnable(path, platform) {
88
- if (!existsSync(path))
89
- return false;
90
- if (platform === 'win32')
91
- return true;
92
107
  try {
93
- accessSync(path, constants.X_OK);
108
+ if (!statSync(path).isFile())
109
+ return false;
110
+ if (platform !== 'win32')
111
+ accessSync(path, constants.X_OK);
94
112
  return true;
95
113
  }
96
114
  catch {
@@ -98,9 +116,25 @@ function isRunnable(path, platform) {
98
116
  }
99
117
  }
100
118
  function knownBinaryDirs({ platform, env, home }) {
119
+ if (identifyPlatform({ platform, env }).id === 'termux') {
120
+ // Use the running app's prefix, including alternate Android user installs.
121
+ // Generic /usr paths can refer to a different libc inside a proot environment.
122
+ const prefix = env.PREFIX;
123
+ return prefix && posix.isAbsolute(prefix) && !/[\0\r\n]/.test(prefix) ? [posix.join(prefix, 'bin')] : [];
124
+ }
101
125
  if (platform === 'darwin') {
102
126
  return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')];
103
127
  }
128
+ if (platform === 'freebsd' || platform === 'openbsd')
129
+ return ['/usr/local/bin', '/usr/bin'];
130
+ if (platform === 'netbsd')
131
+ return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin'];
132
+ if (platform === 'sunos')
133
+ return ['/opt/local/bin', '/usr/pkg/bin', '/usr/local/bin', '/usr/bin'];
134
+ if (platform === 'haiku')
135
+ return [posix.join(home, 'config', 'non-packaged', 'bin'), posix.join(home, 'config', 'bin'), '/boot/system/non-packaged/bin', '/boot/system/bin'];
136
+ if (platform === 'aix')
137
+ return ['/opt/freeware/bin', '/usr/local/bin', '/usr/bin'];
104
138
  if (platform === 'win32') {
105
139
  const dirs = [];
106
140
  if (env.LOCALAPPDATA)
@@ -0,0 +1,14 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { posix } from 'node:path';
3
+ import { identifyPlatform, nativeAdapters } from './runtime.js';
4
+ export function mpvIpcPath({ platform = process.platform, directory = tmpdir(), pid = process.pid, timestamp = Date.now() } = {}) {
5
+ if (nativeAdapters(identifyPlatform({ platform })).ipc === 'named-pipe')
6
+ return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
7
+ const path = posix.join(directory, `radiocli-${pid}-${timestamp}.sock`);
8
+ // BSD/macOS sockaddr_un is smaller than Linux's. Count encoded bytes and
9
+ // leave room for the terminator; JavaScript string length is not sufficient.
10
+ if (Buffer.byteLength(path) > 100) {
11
+ throw new Error('The mpv socket path is too long. Select a shorter private TMPDIR and restart RadioCLI.');
12
+ }
13
+ return path;
14
+ }
@@ -0,0 +1,135 @@
1
+ import { pathEnvironmentKeys } from './paths.js';
2
+ import { homedir } from 'node:os';
3
+ import { posix, win32 } from 'node:path';
4
+ const applicationEnvironmentKeys = [
5
+ 'RADIOCLI_MPV_PATH',
6
+ 'RADIOCLI_FFPLAY_PATH',
7
+ 'RADIOCLI_VLC_PATH',
8
+ 'RADIOCLI_FFMPEG_PATH',
9
+ 'RADIOCLI_OFFLINE',
10
+ 'RADIOCLI_LOW_BANDWIDTH'
11
+ ];
12
+ const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'];
13
+ /** Persist only application settings and the desktop connection a job needs. */
14
+ export function launchEnvironment(env, options = {}) {
15
+ const result = {};
16
+ const platform = options.platform ?? process.platform;
17
+ const path = platform === 'win32' ? win32 : posix;
18
+ const add = (key, value) => {
19
+ if (value === undefined)
20
+ return;
21
+ if (/[\r\n\0]/.test(value))
22
+ throw new Error(`${key} cannot contain control characters in a launch environment.`);
23
+ result[key] = value;
24
+ };
25
+ const capturePath = (key, value) => {
26
+ add(key, value);
27
+ if (value === undefined)
28
+ return;
29
+ // Empty overrides fall back; empty roots select the invoking directory.
30
+ // Windows rejects an empty USERPROFILE, while HOME is unused there.
31
+ if (value === '' && (key === 'RADIOCLI_HOME' || key === 'RADIO_ATLAS_HOME' || key === 'USERPROFILE' || key === 'HOME' && platform === 'win32'))
32
+ return;
33
+ // Windows root-relative paths also depend on the invoking drive. Fully
34
+ // qualified values need no cwd lookup (which can fail after cwd removal).
35
+ const absolute = path.isAbsolute(value) && (platform !== 'win32' || /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(value));
36
+ if (!absolute)
37
+ add(key, path.resolve(options.cwd ?? process.cwd(), value));
38
+ };
39
+ for (const key of pathEnvironmentKeys)
40
+ capturePath(key, env[key]);
41
+ // On Windows, removing USERPROFILE from a child environment does not
42
+ // reliably erase a differently-cased inherited value. Persist the effective
43
+ // account home so an absent selector cannot be replaced by the launcher.
44
+ if (platform === 'win32' && process.platform === 'win32' && env.USERPROFILE === undefined) {
45
+ capturePath('USERPROFILE', homedir());
46
+ }
47
+ for (const key of applicationEnvironmentKeys)
48
+ add(key, env[key]);
49
+ if (options.includeDesktop)
50
+ for (const key of desktopEnvironmentKeys)
51
+ add(key, env[key]);
52
+ add('RADIOCLI_ALARM_TERMINAL', options.terminal);
53
+ return result;
54
+ }
55
+ /** Keep application argv and approved environment out of native shell syntax. */
56
+ export function nodeLaunchCommand(nodePath, args, environment) {
57
+ if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) {
58
+ throw new Error('Launch command values cannot contain NUL bytes.');
59
+ }
60
+ // This fixed program contains no double quotes, including after expansion.
61
+ // PowerShell 5 and Task Scheduler only receive this program and encoded data.
62
+ // A terminal server or scheduler may have unrelated path overrides. Clear the
63
+ // complete identity before restoring this launch's snapshot, preserving absent
64
+ // selectors and the path layer's existing empty-value/default semantics.
65
+ const keys = [...pathEnvironmentKeys, ...applicationEnvironmentKeys, 'RADIOCLI_ALARM_TERMINAL'].map(key => `'${key}'`).join(',');
66
+ const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const s=new Set([${keys}]);const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>!s.has(process.platform==='win32'?k.toUpperCase():k)));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`;
67
+ return [nodePath, '-e', program, Buffer.from(JSON.stringify({ args, environment }), 'utf8').toString('base64url')];
68
+ }
69
+ /** Process acceptance is separate from application/session readiness. */
70
+ export function waitForLaunch(child, options = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ let settled = false;
73
+ let acceptedTimer;
74
+ let forceTimer;
75
+ let cleanupTimer;
76
+ let stopError;
77
+ const finish = (error) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(startupTimer);
82
+ if (acceptedTimer)
83
+ clearTimeout(acceptedTimer);
84
+ clearTimeout(forceTimer);
85
+ clearTimeout(cleanupTimer);
86
+ if (error)
87
+ reject(error);
88
+ else
89
+ resolve();
90
+ };
91
+ const signal = (value) => {
92
+ try {
93
+ child.kill(value);
94
+ }
95
+ catch { /* Retain the launch failure and bounded cleanup. */ }
96
+ };
97
+ const stop = (error) => {
98
+ if (settled || stopError)
99
+ return;
100
+ stopError = error;
101
+ clearTimeout(startupTimer);
102
+ if (acceptedTimer)
103
+ clearTimeout(acceptedTimer);
104
+ forceTimer = setTimeout(() => signal('SIGKILL'), 250);
105
+ cleanupTimer = setTimeout(() => { child.unref(); finish(error); }, 1_000);
106
+ signal('SIGTERM');
107
+ };
108
+ const startupTimer = setTimeout(() => {
109
+ const error = new Error('Process launcher did not report process startup.');
110
+ if (child.pid)
111
+ stop(error);
112
+ else
113
+ finish(error);
114
+ }, 3_000);
115
+ // Keep a listener after acceptance: a late native error must not become an
116
+ // unhandled EventEmitter error in the application that requested the launch.
117
+ child.on('error', error => {
118
+ if (!settled && child.pid)
119
+ stop(error);
120
+ else
121
+ finish(error);
122
+ });
123
+ child.once('close', (code, signal) => finish(stopError ?? (code === 0 ? undefined : new Error(`Process launcher exited with ${code ?? signal ?? 'unknown status'}.`))));
124
+ child.once('spawn', () => {
125
+ if (options.waitForExit) {
126
+ clearTimeout(startupTimer);
127
+ acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000);
128
+ }
129
+ else {
130
+ child.unref();
131
+ acceptedTimer = setTimeout(() => finish(), 100);
132
+ }
133
+ });
134
+ });
135
+ }
@@ -0,0 +1,44 @@
1
+ export function isLoopbackHost(value) {
2
+ return value === '127.0.0.1' || value === '::1';
3
+ }
4
+ /** Bind only literal loopback addresses, including hosts without an IPv4 stack. */
5
+ export async function listenLoopback(server) {
6
+ try {
7
+ return await listen(server, '127.0.0.1');
8
+ }
9
+ catch (error) {
10
+ const code = error.code;
11
+ if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL')
12
+ throw error;
13
+ return listen(server, '::1');
14
+ }
15
+ }
16
+ function listen(server, host) {
17
+ return new Promise((resolve, reject) => {
18
+ const cleanup = () => {
19
+ server.removeListener('error', failed);
20
+ server.removeListener('listening', ready);
21
+ };
22
+ const failed = (error) => {
23
+ cleanup();
24
+ reject(error);
25
+ };
26
+ const ready = () => {
27
+ cleanup();
28
+ const address = server.address();
29
+ if (!address || typeof address === 'string') {
30
+ reject(new Error('Unable to create a local control endpoint.'));
31
+ return;
32
+ }
33
+ resolve({ host, port: address.port });
34
+ };
35
+ server.once('error', failed);
36
+ server.once('listening', ready);
37
+ try {
38
+ server.listen(0, host);
39
+ }
40
+ catch (error) {
41
+ failed(error);
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,312 @@
1
+ import { resolve4, resolve6 } from 'node:dns/promises';
2
+ import { request as requestHttps } from 'node:https';
3
+ const maxResolvedResponseBytes = 128 * 1024 * 1024;
4
+ export function networkPolicy(env = process.env) {
5
+ return { offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1' };
6
+ }
7
+ /** A direct connection must never silently bypass an operator-configured proxy. */
8
+ export function allowsDirectDnsFallback(env = process.env, url) {
9
+ if (url && bypassesProxy(new URL(url), env))
10
+ return true;
11
+ return !['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']
12
+ .some(name => Boolean(env[name]?.trim()));
13
+ }
14
+ /** Configuration preflight only: no network request or proxy credentials. */
15
+ export function networkDiagnostic() {
16
+ const policy = networkPolicy();
17
+ if (policy.offline)
18
+ return { ...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.' };
19
+ try {
20
+ validateProxyConfiguration(new URL('http://example.invalid'));
21
+ validateProxyConfiguration(new URL('https://example.invalid'));
22
+ return { ...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.' };
23
+ }
24
+ catch (error) {
25
+ return { ...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.' };
26
+ }
27
+ }
28
+ /** Keep the request deadline active through body consumption, including errors. */
29
+ export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImpl = fetch }, consume) {
30
+ if (networkPolicy().offline)
31
+ throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).');
32
+ validateProxyConfiguration(new URL(url));
33
+ init.signal?.throwIfAborted();
34
+ const controller = new AbortController();
35
+ const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
36
+ let onAbort;
37
+ const aborted = new Promise((_resolve, reject) => {
38
+ onAbort = () => reject(signal.reason);
39
+ signal.addEventListener('abort', onAbort, { once: true });
40
+ });
41
+ const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs);
42
+ let response;
43
+ const work = async () => {
44
+ response = await fetchImpl(url, { ...init, signal });
45
+ try {
46
+ // A custom fetch can resolve after the deadline without observing abort.
47
+ signal.throwIfAborted();
48
+ return await consume(response);
49
+ }
50
+ finally {
51
+ cancelBody(response);
52
+ }
53
+ };
54
+ try {
55
+ return await Promise.race([work(), aborted]);
56
+ }
57
+ finally {
58
+ clearTimeout(timeout);
59
+ signal.removeEventListener('abort', onAbort);
60
+ controller.abort();
61
+ if (response)
62
+ cancelBody(response);
63
+ }
64
+ }
65
+ /**
66
+ * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN
67
+ * but Node's DNS-protocol resolver can still reach the configured nameserver.
68
+ * The original hostname remains the HTTP Host and TLS SNI identity, so normal
69
+ * certificate-chain and hostname verification remain enabled.
70
+ */
71
+ export async function fetchHttpsWithSystemResolver(input, init = {}, runtime = {}) {
72
+ const url = new URL(input);
73
+ if (url.protocol !== 'https:')
74
+ throw new Error('The DNS fallback only supports HTTPS requests.');
75
+ if (url.username || url.password)
76
+ throw new Error('The DNS fallback does not accept URL credentials.');
77
+ const method = (init.method ?? 'GET').toUpperCase();
78
+ if (method !== 'GET' && method !== 'HEAD')
79
+ throw new Error('The DNS fallback only supports GET and HEAD requests.');
80
+ if (init.body)
81
+ throw new Error('The DNS fallback does not accept a request body.');
82
+ init.signal?.throwIfAborted();
83
+ const addresses = await resolveHttpsAddresses(url.hostname, init.signal, runtime);
84
+ let lastError = null;
85
+ for (const address of addresses) {
86
+ try {
87
+ return await requestResolvedHttps(url, address, init, runtime, method);
88
+ }
89
+ catch (error) {
90
+ if (init.signal?.aborted)
91
+ throw init.signal.reason;
92
+ lastError = error instanceof Error ? error : new Error(String(error));
93
+ }
94
+ }
95
+ throw lastError ?? new Error(`DNS did not return an address for ${url.hostname}.`);
96
+ }
97
+ async function resolveHttpsAddresses(hostname, signal, runtime) {
98
+ let lastErrors = [];
99
+ for (let attempt = 0; attempt < 2; attempt += 1) {
100
+ const results = await raceAbort(Promise.allSettled([
101
+ (runtime.resolve4 ?? resolve4)(hostname),
102
+ (runtime.resolve6 ?? resolve6)(hostname)
103
+ ]), signal);
104
+ const addresses = [
105
+ ...(results[0].status === 'fulfilled' ? results[0].value.map(address => ({ address, family: 4 })) : []),
106
+ ...(results[1].status === 'fulfilled' ? results[1].value.map(address => ({ address, family: 6 })) : [])
107
+ ];
108
+ if (addresses.length > 0)
109
+ return dedupeAddresses(addresses);
110
+ lastErrors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
111
+ if (attempt === 1 || !lastErrors.some(isTemporaryDnsError))
112
+ break;
113
+ await abortableDelay(100, signal);
114
+ }
115
+ const lastError = lastErrors.find(value => value instanceof Error);
116
+ throw lastError instanceof Error ? lastError : new Error(`DNS did not return an address for ${hostname}.`);
117
+ }
118
+ function requestResolvedHttps(url, address, init, runtime, method) {
119
+ return new Promise((resolve, reject) => {
120
+ const headers = new Headers(init.headers);
121
+ if (!headers.has('host'))
122
+ headers.set('host', url.host);
123
+ if (!headers.has('accept-encoding'))
124
+ headers.set('accept-encoding', 'identity');
125
+ const request = (runtime.request ?? requestHttps)({
126
+ hostname: address.address,
127
+ family: address.family,
128
+ port: url.port ? Number(url.port) : 443,
129
+ path: `${url.pathname}${url.search}`,
130
+ method,
131
+ headers: Object.fromEntries(headers.entries()),
132
+ servername: url.hostname,
133
+ signal: init.signal ?? undefined,
134
+ ca: runtime.ca,
135
+ agent: false
136
+ }, response => {
137
+ const chunks = [];
138
+ let bytes = 0;
139
+ response.on('data', (chunk) => {
140
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
141
+ bytes += buffer.length;
142
+ if (bytes > maxResolvedResponseBytes) {
143
+ response.destroy(new Error('HTTPS response exceeded the 128 MiB safety limit.'));
144
+ return;
145
+ }
146
+ chunks.push(buffer);
147
+ });
148
+ response.once('error', reject);
149
+ response.once('aborted', () => reject(new Error('HTTPS response ended before completion.')));
150
+ response.once('end', () => {
151
+ const responseHeaders = new Headers();
152
+ for (const [name, value] of Object.entries(response.headers)) {
153
+ if (Array.isArray(value)) {
154
+ for (const item of value)
155
+ responseHeaders.append(name, item);
156
+ }
157
+ else if (value !== undefined) {
158
+ responseHeaders.set(name, value);
159
+ }
160
+ }
161
+ const status = response.statusCode ?? 500;
162
+ const body = method === 'HEAD' || status === 204 || status === 205 || status === 304
163
+ ? null
164
+ : Buffer.concat(chunks);
165
+ resolve(new Response(body, {
166
+ status,
167
+ statusText: response.statusMessage,
168
+ headers: responseHeaders
169
+ }));
170
+ });
171
+ });
172
+ request.once('error', reject);
173
+ request.end();
174
+ });
175
+ }
176
+ function dedupeAddresses(addresses) {
177
+ const seen = new Set();
178
+ return addresses.filter(({ address, family }) => {
179
+ const key = `${family}:${address}`;
180
+ if (seen.has(key))
181
+ return false;
182
+ seen.add(key);
183
+ return true;
184
+ });
185
+ }
186
+ function isTemporaryDnsError(error) {
187
+ return error instanceof Error && 'code' in error && error.code === 'EAI_AGAIN';
188
+ }
189
+ function raceAbort(promise, signal) {
190
+ if (!signal)
191
+ return promise;
192
+ signal.throwIfAborted();
193
+ return new Promise((resolve, reject) => {
194
+ const onAbort = () => reject(signal.reason);
195
+ signal.addEventListener('abort', onAbort, { once: true });
196
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
197
+ });
198
+ }
199
+ function abortableDelay(ms, signal) {
200
+ if (!signal)
201
+ return new Promise(resolve => setTimeout(resolve, ms));
202
+ signal.throwIfAborted();
203
+ return new Promise((resolve, reject) => {
204
+ const timeout = setTimeout(done, ms);
205
+ const onAbort = () => done(signal.reason);
206
+ signal.addEventListener('abort', onAbort, { once: true });
207
+ function done(error) {
208
+ clearTimeout(timeout);
209
+ signal.removeEventListener('abort', onAbort);
210
+ if (error)
211
+ reject(error);
212
+ else
213
+ resolve();
214
+ }
215
+ });
216
+ }
217
+ function cancelBody(response) {
218
+ // Cancellation is best-effort and must not extend the request deadline. A
219
+ // locked native body is interrupted by the request's AbortController instead.
220
+ if (response.body && !response.body.locked)
221
+ void response.body.cancel().catch(() => { });
222
+ }
223
+ function validateProxyConfiguration(url) {
224
+ if (bypassesProxy(url, process.env))
225
+ return;
226
+ const http = proxyVariable('http_proxy', 'HTTP_PROXY');
227
+ const https = proxyVariable('https_proxy', 'HTTPS_PROXY');
228
+ for (const proxy of [http, https]) {
229
+ if (!proxy.value)
230
+ continue;
231
+ let parsed;
232
+ try {
233
+ if (/[\r\n]/.test(proxy.value))
234
+ throw new Error();
235
+ parsed = new URL(proxy.value);
236
+ decodeURIComponent(parsed.username);
237
+ decodeURIComponent(parsed.password);
238
+ }
239
+ catch {
240
+ // URL parse errors can contain credentials. Do not attach the raw cause.
241
+ throw new Error(`${proxy.name} contains an invalid proxy URL.`);
242
+ }
243
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
244
+ throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`);
245
+ }
246
+ }
247
+ // Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset.
248
+ const selected = url.protocol === 'https:' && https.value ? https : http;
249
+ if (!selected.value) {
250
+ const all = proxyVariable('all_proxy', 'ALL_PROXY');
251
+ if (all.value)
252
+ throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`);
253
+ return;
254
+ }
255
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
256
+ if (major < 24 && !(major === 22 && minor >= 21)) {
257
+ throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.');
258
+ }
259
+ let enabled = process.env.NODE_USE_ENV_PROXY === '1';
260
+ const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
261
+ for (const raw of [...environmentFlags, ...process.execArgv]) {
262
+ const flag = raw.replace(/^["']|["']$/g, '');
263
+ if (flag === '--use-env-proxy')
264
+ enabled = true;
265
+ if (flag === '--no-use-env-proxy')
266
+ enabled = false;
267
+ }
268
+ if (!enabled) {
269
+ throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`);
270
+ }
271
+ // Node configures its dispatcher at startup. Do not mutate proxy environment
272
+ // variables or install a global dispatcher here; local IPC has a direct agent.
273
+ }
274
+ function bypassesProxy(url, env) {
275
+ const value = env.no_proxy !== undefined ? env.no_proxy : env.NO_PROXY;
276
+ if (!value?.trim())
277
+ return false;
278
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
279
+ const port = url.port || (url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '');
280
+ return value.split(',').some(raw => {
281
+ let entry = raw.trim().toLowerCase();
282
+ if (!entry)
283
+ return false;
284
+ if (entry === '*')
285
+ return true;
286
+ let entryPort = '';
287
+ if (entry.startsWith('[')) {
288
+ const end = entry.indexOf(']');
289
+ if (end < 0)
290
+ return false;
291
+ entryPort = entry.slice(end + 1).replace(/^:/, '');
292
+ entry = entry.slice(1, end);
293
+ }
294
+ else {
295
+ const colon = entry.lastIndexOf(':');
296
+ if (colon > -1 && /^\d+$/.test(entry.slice(colon + 1))) {
297
+ entryPort = entry.slice(colon + 1);
298
+ entry = entry.slice(0, colon);
299
+ }
300
+ }
301
+ if (entryPort && entryPort !== port)
302
+ return false;
303
+ const suffix = entry.replace(/^\*\./, '.');
304
+ return suffix.startsWith('.')
305
+ ? hostname === suffix.slice(1) || hostname.endsWith(suffix)
306
+ : hostname === suffix;
307
+ });
308
+ }
309
+ function proxyVariable(lower, upper) {
310
+ const name = process.env[lower] !== undefined ? lower : upper;
311
+ return { name, value: process.env[name] };
312
+ }