@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure helpers behind `oar setup` and `oar doctor` — everything here is
2
+ * Pure helpers behind the `oar` command line — everything here is
3
3
  * deterministic and unit-testable; the interactive glue stays in bin/oar.js.
4
4
  */
5
5
 
@@ -41,6 +41,135 @@ export function relayUrl({ config, lanAddress = null } = {}) {
41
41
  return `http://${host}:${config?.port || 3333}/?token=${encodeURIComponent(token)}`;
42
42
  }
43
43
 
44
+ /**
45
+ * What `oar setup` and `oar url` print about the relay's address. `qrUrl` is
46
+ * set only for an address a phone can reach: the LAN address when LAN access
47
+ * is on, and never one that is internal to WSL. `tunnelUrl` is the address a
48
+ * running relay reports for its tunnel, with the token added.
49
+ */
50
+ export function describeRelayAddress({ config, lanAddress = null, wslNat = false, tunnelBase = '' } = {}) {
51
+ const lan = config?.localhostOnly === false;
52
+ const url = relayUrl({ config: { ...config, localhostOnly: !lan }, lanAddress });
53
+ const lines = [`[oar] Relay URL: ${url}`];
54
+ let qrUrl = null;
55
+ if (!lan) {
56
+ lines.push('[oar] This machine only. For a phone, switch on LAN access (oar setup --lan) or use a tunnel (README: Remote access).');
57
+ } else if (!lanAddress) {
58
+ lines.push('[oar] LAN access is on, but this machine has no network address.');
59
+ } else if (wslNat) {
60
+ lines.push('[oar] That address is internal to WSL; other devices cannot reach it. Ways out: a tunnel (README: Remote access), or WSL\'s mirrored networking (networkingMode=mirrored in .wslconfig).');
61
+ } else {
62
+ qrUrl = url;
63
+ }
64
+ const base = String(tunnelBase || '').trim().replace(/\/+$/, '');
65
+ const tunnelUrl = base ? `${base}/?token=${encodeURIComponent(String(config?.authToken || '').trim())}` : null;
66
+ return { url, lines, qrUrl, tunnelUrl };
67
+ }
68
+
69
+ const VALUE_OPTIONS = ['--port', '--migrate-from', '--to'];
70
+ const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
71
+ const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
72
+ const CLI_COMMANDS = {
73
+ menu: {},
74
+ start: {},
75
+ stop: {},
76
+ restart: {},
77
+ status: {},
78
+ url: {},
79
+ doctor: {},
80
+ help: {},
81
+ version: {},
82
+ setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from'] },
83
+ service: { words: ['install', 'remove', 'status'] },
84
+ update: { flags: ['--beta'], values: ['--to'] },
85
+ copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
86
+ };
87
+
88
+ export function usageText() {
89
+ return [
90
+ 'Usage: oar [command] [options]',
91
+ '',
92
+ ' oar Menu in a terminal; status and this text elsewhere',
93
+ ' oar start Start the relay in the background',
94
+ ' oar stop Stop the relay (waits for running turns)',
95
+ ' oar restart Restart the relay (waits for running turns)',
96
+ ' oar status Is the relay running, on which port, as a service',
97
+ ' oar url Relay URL, with a QR code when a phone can reach it',
98
+ ' oar setup Auth token, access and port; asks, or takes',
99
+ ' [--port <port>] [--lan | --local] [--new-token] [--defaults] [--start]',
100
+ ' oar service install | remove | status',
101
+ ' Start the relay at login',
102
+ ' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
103
+ ' Relay plus a Copilot terminal session',
104
+ ' oar doctor Check the install and the provider CLIs',
105
+ ' oar update [--beta] [--to <version>]',
106
+ ' oar help This text',
107
+ ' oar --version',
108
+ ].join('\n');
109
+ }
110
+
111
+ /**
112
+ * Reads the command line into `{ command, args }`, or `{ error }` for anything
113
+ * `oar` does not know, so nothing starts by accident. The command may follow
114
+ * options (`oar --port 3339 setup`), and `--setup`, `--start`, `--stop` and
115
+ * `--status` name it too. Without a command the launcher's own flags (or a
116
+ * `--` part) mean the Copilot session, and nothing at all means the menu.
117
+ */
118
+ export function parseCliArgs(argv = []) {
119
+ const all = (Array.isArray(argv) ? argv : []).map((arg) => String(arg));
120
+ const separator = all.indexOf('--');
121
+ const head = separator === -1 ? all : all.slice(0, separator);
122
+ const forwarded = separator === -1 ? [] : all.slice(separator);
123
+ if (head.includes('--help') || head.includes('-h')) return { command: 'help', args: [] };
124
+
125
+ let command = null;
126
+ let args = [];
127
+ for (let index = 0; index < head.length; index += 1) {
128
+ const arg = head[index];
129
+ if (VALUE_OPTIONS.includes(arg)) {
130
+ if (index + 1 >= head.length) return { error: `${arg} needs a value.` };
131
+ args.push(arg, head[index += 1]);
132
+ } else if (arg.startsWith('-') || command) {
133
+ args.push(arg);
134
+ } else {
135
+ command = arg;
136
+ }
137
+ }
138
+ if (!command) {
139
+ const named = args.includes('--setup') ? '--setup' : args.find((arg) => COMMAND_OPTIONS[arg]);
140
+ if (args.includes('--version') || args.includes('-v')) return { command: 'version', args: [] };
141
+ if (named) {
142
+ command = COMMAND_OPTIONS[named];
143
+ args.splice(args.indexOf(named), 1);
144
+ } else if (forwarded.length || args.some((arg) => LAUNCHER_FLAGS.includes(arg))) {
145
+ command = 'copilot';
146
+ } else if (!args.length) {
147
+ command = 'menu';
148
+ } else {
149
+ // An option this command line knows needs its command; any other is unknown.
150
+ const known = [...VALUE_OPTIONS, '--lan', '--local', '--new-token', '--defaults', '--beta'];
151
+ return { error: known.includes(args[0]) ? `${args[0]} needs a command.` : `Unknown option: ${args[0]}` };
152
+ }
153
+ }
154
+
155
+ const spec = Object.hasOwn(CLI_COMMANDS, command) ? CLI_COMMANDS[command] : null;
156
+ if (!spec) return { error: `Unknown command: ${command}` };
157
+ if (forwarded.length && !spec.forwards) return { error: `oar ${command} takes no "--" part.` };
158
+ let words = 0;
159
+ for (let index = 0; index < args.length; index += 1) {
160
+ const arg = args[index];
161
+ const name = arg.startsWith('--') ? arg.split('=')[0] : arg;
162
+ if (spec.values?.includes(name)) {
163
+ if (arg === name) index += 1;
164
+ } else if (spec.words?.includes(arg) && words === 0) {
165
+ words += 1;
166
+ } else if (!spec.flags?.includes(arg)) {
167
+ return { error: arg.startsWith('-') ? `Unknown option for oar ${command}: ${arg}` : `Unexpected argument: ${arg}` };
168
+ }
169
+ }
170
+ return { command, args: [...args, ...forwarded] };
171
+ }
172
+
44
173
  /**
45
174
  * A systemd *user* unit for the relay server. The launcher is not used here —
46
175
  * the service runs server.js directly with the same env the launcher would set,
@@ -102,7 +231,7 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
102
231
  * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
103
232
  * holds this list to the lockfile.
104
233
  */
105
- export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'cloudflared', 'koffi']);
234
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'koffi']);
106
235
  export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
107
236
 
108
237
  /**
@@ -155,6 +284,8 @@ export function renderDoctorReport({
155
284
  dbPath,
156
285
  dbSizeBytes,
157
286
  probes = [],
287
+ warnings = [],
288
+ tunnel = '',
158
289
  }) {
159
290
  const yesNo = (v) => (v ? 'yes' : 'no');
160
291
  const lines = [
@@ -167,13 +298,17 @@ export function renderDoctorReport({
167
298
  if (config) {
168
299
  lines.push(` port : ${config.port ?? 3333} (localhostOnly: ${yesNo(config.localhostOnly !== false)})`);
169
300
  lines.push(` auth token : ${String(config.authToken || '').trim() ? 'set' : 'MISSING'}`);
170
- const tunnel = config.cloudflaredTunnel || {};
171
- lines.push(` tunnel : ${tunnel.enabled === true || tunnel.mode === 'managed' ? 'managed' : 'disabled'}`);
301
+ // `tunnel` is the caller's finding (describeConfiguredTunnel: is the tunnel
302
+ // on, and is there a cloudflared to run it); without one, the config's word.
303
+ const tunnelConfig = config.cloudflaredTunnel || {};
304
+ const configured = tunnelConfig.enabled === true || tunnelConfig.mode === 'managed' ? 'managed' : 'disabled';
305
+ lines.push(` tunnel : ${tunnel || configured}`);
172
306
  }
173
307
  lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
174
308
  lines.push(' provider CLIs :');
175
309
  for (const probe of probes) {
176
310
  lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
177
311
  }
312
+ for (const warning of warnings) lines.push(` warning : ${warning}`);
178
313
  return lines.join('\n');
179
314
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The terminal side of `oar`: line prompts and the menu. Everything reads
3
+ * from and writes to the stream pair it is given, so tests drive it with
4
+ * fake streams.
5
+ */
6
+
7
+ import readline from 'node:readline';
8
+
9
+ /** Ctrl+C (or a closed input) in a prompt or in the menu; `oar` exits 130 on it, without a message. */
10
+ export class PromptAborted extends Error {
11
+ constructor() {
12
+ super('Aborted');
13
+ this.code = 'OAR_PROMPT_ABORTED';
14
+ }
15
+ }
16
+
17
+ export function askLine(question, { input = process.stdin, output = process.stdout } = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ let rl;
20
+ try {
21
+ rl = readline.createInterface({ input, output, terminal: Boolean(input.isTTY) });
22
+ } catch {
23
+ // A terminal that refuses raw mode still reads whole lines.
24
+ rl = readline.createInterface({ input, output, terminal: false });
25
+ }
26
+ let settled = false;
27
+ const abort = () => {
28
+ if (settled) return;
29
+ settled = true;
30
+ rl.close();
31
+ output.write('\n');
32
+ reject(new PromptAborted());
33
+ };
34
+ rl.once('SIGINT', abort);
35
+ rl.once('close', abort);
36
+ rl.question(question, (answer) => {
37
+ settled = true;
38
+ rl.close();
39
+ resolve(String(answer).trim());
40
+ });
41
+ });
42
+ }
43
+
44
+ export async function askYesNo(question, { defaultYes = false, input, output } = {}) {
45
+ const answer = (await askLine(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, { input, output })).toLowerCase();
46
+ return answer ? answer === 'y' || answer === 'yes' : defaultYes;
47
+ }
48
+
49
+ export function createPrompter({ input = process.stdin, output = process.stdout } = {}) {
50
+ return {
51
+ line: (question) => askLine(question, { input, output }),
52
+ yesNo: (question, options = {}) => askYesNo(question, { ...options, input, output }),
53
+ };
54
+ }
55
+
56
+ export const MENU_ENTRIES = Object.freeze([
57
+ { id: 'start', label: 'Start' },
58
+ { id: 'stop', label: 'Stop' },
59
+ { id: 'restart', label: 'Restart' },
60
+ { id: 'url', label: 'Show URL and QR code' },
61
+ { id: 'setup', label: 'Settings' },
62
+ { id: 'service', label: 'Install or remove the service' },
63
+ { id: 'update', label: 'Update' },
64
+ { id: 'doctor', label: 'Doctor' },
65
+ { id: 'copilot', label: 'Copilot session' },
66
+ { id: 'quit', label: 'Quit' },
67
+ ]);
68
+
69
+ const RELAY_TEXT = {
70
+ running: 'relay running',
71
+ stopped: 'relay stopped',
72
+ taken: 'relay not reachable (its port is held by another program, or by a relay with other settings)',
73
+ unconfigured: 'not set up yet (choose Settings)',
74
+ };
75
+ const SERVICE_TEXT = {
76
+ active: 'installed, running',
77
+ installed: 'installed',
78
+ none: 'not installed',
79
+ unavailable: 'not available here',
80
+ };
81
+
82
+ export function menuUsesColor({ env = process.env, output = process.stdout } = {}) {
83
+ return Boolean(output?.isTTY) && !String(env?.NO_COLOR || '');
84
+ }
85
+
86
+ /** The menu as lines of text: the status header, then the entries, the selected one marked. */
87
+ export function renderMenu({ status = {}, selected = 0, color = false } = {}) {
88
+ const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
89
+ const relay = RELAY_TEXT[status.relay] || RELAY_TEXT.stopped;
90
+ const lines = [
91
+ `${paint(1, `OAR ${status.version || ''}`.trim())} · ${paint(status.relay === 'running' ? 32 : 31, relay)}`,
92
+ ];
93
+ if (status.url) lines.push(` URL ${status.url}`);
94
+ if (status.port) lines.push(` Port ${status.port}`);
95
+ if (status.access) lines.push(` Access ${status.access === 'lan' ? 'LAN' : 'this machine only'}`);
96
+ if (SERVICE_TEXT[status.service]) lines.push(` Service ${SERVICE_TEXT[status.service]}`);
97
+ for (const warning of status.warnings || []) lines.push(paint(33, ` ! ${warning}`));
98
+ lines.push('');
99
+ MENU_ENTRIES.forEach((entry, index) => {
100
+ const key = entry.id === 'quit' ? 'q' : String(index + 1);
101
+ const text = `${index === selected ? '>' : ' '} ${key} ${entry.label}`;
102
+ lines.push(index === selected ? paint(7, text) : text);
103
+ });
104
+ lines.push('', 'Arrow keys and Enter, or a digit, to choose; q to quit.');
105
+ return lines;
106
+ }
107
+
108
+ /** One chunk of raw input as a menu key: 'up', 'down', 'enter', 'quit', 'interrupt', an entry index, or null. */
109
+ export function readMenuKey(chunk) {
110
+ const key = String(chunk);
111
+ if (key === '\x03' || key === '\x04') return 'interrupt';
112
+ if (key === '\x1b[A' || key === '\x1bOA' || key === 'k') return 'up';
113
+ if (key === '\x1b[B' || key === '\x1bOB' || key === 'j') return 'down';
114
+ if (key === '\r' || key === '\n') return 'enter';
115
+ if (key === 'q' || key === 'Q') return 'quit';
116
+ if (/^[1-9]$/.test(key)) return Number(key) - 1;
117
+ return null;
118
+ }
119
+
120
+ /** Reads raw key presses until `onKey` returns a value; the terminal is back in line mode on every way out. */
121
+ function readKeys({ input, onKey }) {
122
+ return new Promise((resolve, reject) => {
123
+ const finish = (settle, value) => {
124
+ input.removeListener('data', onData);
125
+ input.removeListener('end', onEnd);
126
+ try { input.setRawMode(false); } catch {}
127
+ input.pause();
128
+ settle(value);
129
+ };
130
+ const onEnd = () => finish(reject, new PromptAborted());
131
+ const onData = (chunk) => {
132
+ let result;
133
+ try { result = onKey(readMenuKey(chunk)); } catch (error) { finish(reject, error); return; }
134
+ if (result !== undefined) finish(resolve, result);
135
+ };
136
+ input.on('data', onData);
137
+ input.once('end', onEnd);
138
+ try {
139
+ input.setRawMode(true);
140
+ input.resume();
141
+ } catch (error) {
142
+ finish(reject, error);
143
+ }
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Runs the menu until Quit or Ctrl+C. `loadStatus()` returns the header's
149
+ * status before each drawing; `actions[id](status)` runs an entry and may
150
+ * return `{ exit: true }` to leave the menu. An input without raw mode gets
151
+ * the same list with a numbered prompt.
152
+ */
153
+ export async function runMenu({
154
+ input = process.stdin,
155
+ output = process.stdout,
156
+ env = process.env,
157
+ loadStatus = async () => ({}),
158
+ actions = {},
159
+ } = {}) {
160
+ const color = menuUsesColor({ env, output });
161
+ let rawMode = typeof input.setRawMode === 'function';
162
+ let selected = 0;
163
+ const draw = (status) => {
164
+ output.write(`${output.isTTY ? '\x1b[2J\x1b[H' : ''}${renderMenu({ status, selected, color }).join('\n')}\n`);
165
+ };
166
+ const pickByKey = (status) => readKeys({
167
+ input,
168
+ onKey: (key) => {
169
+ if (key === 'interrupt') throw new PromptAborted();
170
+ if (key === 'quit') return MENU_ENTRIES.length - 1;
171
+ if (key === 'enter') return selected;
172
+ if (typeof key === 'number') {
173
+ selected = key;
174
+ return selected;
175
+ }
176
+ if (key === 'up' || key === 'down') {
177
+ selected = (selected + (key === 'up' ? MENU_ENTRIES.length - 1 : 1)) % MENU_ENTRIES.length;
178
+ draw(status);
179
+ }
180
+ return undefined;
181
+ },
182
+ });
183
+ const pickByNumber = async () => {
184
+ for (;;) {
185
+ const answer = (await askLine('Choose (1-9, q): ', { input, output })).toLowerCase();
186
+ if (answer === 'q') return MENU_ENTRIES.length - 1;
187
+ if (/^[1-9]$/.test(answer)) return Number(answer) - 1;
188
+ }
189
+ };
190
+
191
+ try {
192
+ for (;;) {
193
+ const status = await loadStatus();
194
+ draw(status);
195
+ let choice;
196
+ try {
197
+ choice = rawMode ? await pickByKey(status) : await pickByNumber();
198
+ } catch (error) {
199
+ if (error instanceof PromptAborted || !rawMode) throw error;
200
+ // The terminal refused raw mode.
201
+ rawMode = false;
202
+ choice = await pickByNumber();
203
+ }
204
+ selected = choice;
205
+ const entry = MENU_ENTRIES[choice];
206
+ if (entry.id === 'quit') return { code: 0, reason: 'menu-quit' };
207
+ output.write('\n');
208
+ let result = null;
209
+ try {
210
+ result = await actions[entry.id]?.(status);
211
+ } catch (error) {
212
+ // Ctrl+C inside an entry (a setup question) leaves that entry, not
213
+ // the menu.
214
+ if (error instanceof PromptAborted) {
215
+ output.write('\n');
216
+ continue;
217
+ }
218
+ output.write(`[oar] ${error?.message || error}\n`);
219
+ }
220
+ if (result?.exit) return { code: result.code ?? 0, reason: 'menu-exit' };
221
+ output.write('\n');
222
+ if (rawMode) {
223
+ output.write('Press a key to go back to the menu. ');
224
+ await readKeys({
225
+ input,
226
+ onKey: (key) => {
227
+ if (key === 'interrupt') throw new PromptAborted();
228
+ return true;
229
+ },
230
+ });
231
+ } else {
232
+ await askLine('Press Enter to go back to the menu. ', { input, output });
233
+ }
234
+ }
235
+ } catch (error) {
236
+ if (!(error instanceof PromptAborted)) throw error;
237
+ output.write('\n');
238
+ return { code: 130, reason: 'interrupted' };
239
+ }
240
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What `oar` needs to know about WSL. A distro in WSL's default (NAT)
3
+ * networking has its own network namespace: a port a Windows program listens
4
+ * on looks free inside the distro, and a Windows browser's `localhost:<port>`
5
+ * then reaches the Windows program, not the relay in the distro. Windows is
6
+ * asked through interop; every failure means "unknown" and is treated like a
7
+ * plain Linux host.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
13
+
14
+ const INTEROP_TIMEOUT_MS = 4000;
15
+ // WSL's own localhost forwarders listen on Windows for every port a distro
16
+ // listens on, so they stand for the distro's program, not for a Windows one.
17
+ const WSL_FORWARDER_RE = /^wsl(relay|host)\.exe$/i;
18
+
19
+ export function isWsl({ env = process.env, platform = process.platform, readFileImpl = fs.readFileSync } = {}) {
20
+ if (platform !== 'linux') return false;
21
+ if (String(env?.WSL_DISTRO_NAME || env?.WSL_INTEROP || '').trim()) return true;
22
+ try {
23
+ return /microsoft|wsl/i.test(String(readFileImpl('/proc/sys/kernel/osrelease', 'utf8')));
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** The Windows system folder as the distro sees it: `[automount] root` of /etc/wsl.conf, else /mnt/. */
30
+ export function windowsSystemDir({ readFileImpl = fs.readFileSync } = {}) {
31
+ let root = '/mnt/';
32
+ try {
33
+ let section = '';
34
+ for (const raw of String(readFileImpl('/etc/wsl.conf', 'utf8')).split(/\r?\n/)) {
35
+ const line = raw.trim();
36
+ const header = line.match(/^\[(.+)\]$/);
37
+ if (header) section = header[1].trim().toLowerCase();
38
+ const pair = section === 'automount' && line.match(/^root\s*=\s*(.+)$/i);
39
+ if (pair) root = pair[1].trim().replace(/^["']|["']$/g, '') || root;
40
+ }
41
+ } catch {
42
+ // No wsl.conf: the default mount root.
43
+ }
44
+ return path.posix.join(root, 'c', 'Windows', 'System32');
45
+ }
46
+
47
+ function runWindowsTool(name, args, { spawnSyncImpl = spawnSync, readFileImpl = fs.readFileSync } = {}) {
48
+ try {
49
+ const result = spawnSyncImpl(path.posix.join(windowsSystemDir({ readFileImpl }), name), args, {
50
+ encoding: 'utf8',
51
+ timeout: INTEROP_TIMEOUT_MS,
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ });
54
+ return result && !result.error && result.status === 0 ? String(result.stdout || '') : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Listening TCP ports in `netstat -ano` output, each with the process ids
62
+ * that hold it. Windows prints the state word in the system language, so a
63
+ * listener is recognised by its shape instead: a TCP row whose remote address
64
+ * is the all-zero one (`0.0.0.0:0` or `[::]:0`). Only listeners a browser's
65
+ * `localhost` reaches count: the loopback and the any-address ones.
66
+ */
67
+ export function parseNetstatListeners(text) {
68
+ const listeners = new Map();
69
+ for (const line of String(text || '').split(/\r?\n/)) {
70
+ const row = line.match(/^\s*TCP\s+(\S+):(\d+)\s+(?:0\.0\.0\.0|\[::\]):0\s+.*?(\d+)\s*$/);
71
+ if (!row || !['0.0.0.0', '127.0.0.1', '[::]', '[::1]'].includes(row[1])) continue;
72
+ const port = Number(row[2]);
73
+ const pids = listeners.get(port) || [];
74
+ if (!pids.includes(Number(row[3]))) pids.push(Number(row[3]));
75
+ listeners.set(port, pids);
76
+ }
77
+ return listeners;
78
+ }
79
+
80
+ /** Port → process ids of the Windows listeners, or null when Windows cannot be asked. */
81
+ export function windowsListeningPorts(options = {}) {
82
+ const output = runWindowsTool('netstat.exe', ['-ano'], options);
83
+ return output === null ? null : parseNetstatListeners(output);
84
+ }
85
+
86
+ function windowsProcessName(pid, options) {
87
+ const output = runWindowsTool('tasklist.exe', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], options);
88
+ return output?.match(/^"([^"]+)"/m)?.[1] || '';
89
+ }
90
+
91
+ /**
92
+ * A function `(port) => name | null`: the Windows program that holds the port,
93
+ * or null when none does, when this is not WSL, or when Windows cannot be
94
+ * asked. The listener list is read once, on the first call.
95
+ */
96
+ export function createWindowsPortCheck(options = {}) {
97
+ if (!isWsl(options)) return () => null;
98
+ let listeners;
99
+ return (port) => {
100
+ if (listeners === undefined) listeners = windowsListeningPorts(options);
101
+ for (const pid of listeners?.get(Number(port)) || []) {
102
+ const name = windowsProcessName(pid, options);
103
+ if (!WSL_FORWARDER_RE.test(name)) return name || 'a Windows program';
104
+ }
105
+ return null;
106
+ };
107
+ }
108
+
109
+ /**
110
+ * True in a WSL distro whose network is its own (NAT): its LAN address is
111
+ * internal to WSL. Mirrored networking shares the addresses of Windows.
112
+ */
113
+ export function isWslNat(options = {}) {
114
+ if (!isWsl(options)) return false;
115
+ try {
116
+ const result = (options.spawnSyncImpl || spawnSync)('wslinfo', ['--networking-mode'], {
117
+ encoding: 'utf8',
118
+ timeout: INTEROP_TIMEOUT_MS,
119
+ stdio: ['ignore', 'pipe', 'ignore'],
120
+ });
121
+ return String(result?.stdout || '').trim().toLowerCase() !== 'mirrored';
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+
127
+ export function windowsPortWarning(port, holder) {
128
+ return `Windows holds port ${port} (${holder}): a browser on Windows reaches that program, not this relay. Pick another port: oar setup --port N`;
129
+ }
@@ -399,13 +399,33 @@ export function createPreviewProxyServer({
399
399
 
400
400
  // WebSocket upgrades (Vite HMR, an app's own socket) ride the same prefix.
401
401
  function handleUpgrade(req, socket, head) {
402
+ // Node removes its own error listener from a socket the moment it emits
403
+ // 'upgrade', and the upstream socket handed to 'upgrade' comes bare too. A
404
+ // browser that drops the connection while the dev server is still
405
+ // answering would otherwise raise an uncaught ECONNRESET and end the
406
+ // whole relay. Whatever fails on either side takes both down.
407
+ let upstreamReq = null;
408
+ let upstreamSocket = null;
409
+ const teardown = () => {
410
+ try { socket.destroy(); } catch {}
411
+ try { upstreamSocket?.destroy(); } catch {}
412
+ try { upstreamReq?.destroy(); } catch {}
413
+ };
414
+ socket.on('error', teardown);
415
+ // A client that leaves before the dev server answers abandons the
416
+ // handshake. Once both sides are piped, an orderly close travels through
417
+ // the pipes, so the pending writes still reach the other side.
418
+ socket.once('close', teardown);
419
+ const reply = (text) => {
420
+ if (socket.destroyed) return;
421
+ try { socket.write(text); } catch {}
422
+ };
423
+
402
424
  const resolved = resolveRequest(req);
403
425
  // Static previews have nothing to upgrade to; refuse like an unknown token.
404
426
  if (resolved.kind !== 'proxy' || resolved.entry?.mode === 'static') {
405
- try {
406
- socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
407
- } catch {}
408
- try { socket.destroy(); } catch {}
427
+ reply('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
428
+ teardown();
409
429
  return;
410
430
  }
411
431
 
@@ -426,7 +446,7 @@ export function createPreviewProxyServer({
426
446
  headers.connection = 'Upgrade';
427
447
  headers.upgrade = String(req.headers.upgrade || 'websocket');
428
448
 
429
- const upstreamReq = httpImpl.request({
449
+ upstreamReq = httpImpl.request({
430
450
  host: entry.targetHost,
431
451
  port: entry.targetPort,
432
452
  method: req.method,
@@ -436,25 +456,26 @@ export function createPreviewProxyServer({
436
456
  });
437
457
  guardConnectTimeout(upstreamReq, connectTimeoutMs, () => {});
438
458
 
439
- upstreamReq.on('upgrade', (upstreamRes, upstreamSocket, upstreamHead) => {
459
+ upstreamReq.on('upgrade', (upstreamRes, answeredSocket, upstreamHead) => {
460
+ upstreamSocket = answeredSocket;
461
+ upstreamSocket.on('error', teardown);
462
+ // The client left while the dev server was answering.
463
+ if (socket.destroyed) {
464
+ teardown();
465
+ return;
466
+ }
440
467
  const lines = [`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}`];
441
468
  for (const [name, value] of Object.entries(upstreamRes.headers)) {
442
469
  for (const entryValue of Array.isArray(value) ? value : [value]) {
443
470
  lines.push(`${name}: ${entryValue}`);
444
471
  }
445
472
  }
446
- try {
447
- socket.write(`${lines.join('\r\n')}\r\n\r\n`);
448
- } catch {
449
- try { upstreamSocket.destroy(); } catch {}
450
- return;
451
- }
452
- if (upstreamHead?.length) socket.write(upstreamHead);
473
+ reply(`${lines.join('\r\n')}\r\n\r\n`);
474
+ if (upstreamHead?.length) reply(upstreamHead);
453
475
  if (head?.length) upstreamSocket.write(head);
476
+ socket.removeListener('close', teardown);
454
477
  trackUpgradedSocket(socket);
455
478
  trackUpgradedSocket(upstreamSocket);
456
- upstreamSocket.on('error', () => { try { socket.destroy(); } catch {} });
457
- socket.on('error', () => { try { upstreamSocket.destroy(); } catch {} });
458
479
  upstreamSocket.pipe(socket);
459
480
  socket.pipe(upstreamSocket);
460
481
  });
@@ -462,18 +483,15 @@ export function createPreviewProxyServer({
462
483
  // A dev server that answers an upgrade with a plain response (404 on an
463
484
  // unknown HMR path) must not leave the client hanging.
464
485
  upstreamReq.on('response', (upstreamRes) => {
465
- try {
466
- socket.write(`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\nConnection: close\r\n\r\n`);
467
- } catch {}
486
+ reply(`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\nConnection: close\r\n\r\n`);
487
+ upstreamRes.on('error', () => {});
468
488
  upstreamRes.resume();
469
- try { socket.destroy(); } catch {}
489
+ teardown();
470
490
  });
471
491
 
472
492
  upstreamReq.on('error', () => {
473
- try {
474
- socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
475
- } catch {}
476
- try { socket.destroy(); } catch {}
493
+ reply('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
494
+ teardown();
477
495
  });
478
496
 
479
497
  upstreamReq.end();