@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // The list of pinned messages a conversation carries: one short plain-text
4
+ // preview per pin, so the browser can show the list without loading (or
5
+ // rendering markdown for) messages outside its history page.
6
+
7
+ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
8
+
9
+ export const MESSAGE_PIN_LIMIT = 100;
10
+ export const PIN_PREVIEW_MAX_LENGTH = 160;
11
+
12
+ function countAttachments(raw) {
13
+ if (Array.isArray(raw)) return raw.length;
14
+ if (typeof raw !== 'string' || !raw) return 0;
15
+ try {
16
+ const parsed = JSON.parse(raw);
17
+ return Array.isArray(parsed) ? parsed.length : 0;
18
+ } catch {
19
+ return 0;
20
+ }
21
+ }
22
+
23
+ // Light on purpose: enough that a heading, a list or a link reads as a line of
24
+ // text. Single * and _ stay, they are more often part of a name than emphasis.
25
+ export function buildPinPreview(text, mode = '') {
26
+ const plain = String(stripRelayPromptContext(String(text || ''), mode) || '')
27
+ .replace(/```[^\n]*\n?/g, ' ')
28
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt) => (alt ? `🖼 ${alt}` : '🖼'))
29
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
30
+ .replace(/^\s{0,3}(?:#{1,6}|>+|[-*+]|\d+[.)])\s+/gm, '')
31
+ .replace(/\*\*|__|~~|`/g, '')
32
+ .replace(/\s+/g, ' ')
33
+ .trim();
34
+ // By code point, so an emoji at the cut is dropped whole.
35
+ const points = Array.from(plain);
36
+ if (points.length <= PIN_PREVIEW_MAX_LENGTH) return plain;
37
+ return `${points.slice(0, PIN_PREVIEW_MAX_LENGTH - 1).join('').trimEnd()}…`;
38
+ }
39
+
40
+ export function buildPinList(rows) {
41
+ return (Array.isArray(rows) ? rows : [])
42
+ .filter((row) => String(row?.id || '').trim() && row?.pinned_at)
43
+ .map((row) => ({
44
+ messageId: String(row.id).trim(),
45
+ role: String(row.role || '').trim(),
46
+ preview: buildPinPreview(row.text, row.mode),
47
+ timestamp: row.timestamp || null,
48
+ pinnedAt: row.pinned_at,
49
+ attachmentCount: countAttachments(row.attachments),
50
+ hiddenFromShares: Number(row.hidden_from_shares || 0) === 1,
51
+ }));
52
+ }
@@ -1,9 +1,10 @@
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
 
6
6
  import { randomBytes } from 'node:crypto';
7
+ import fs from 'node:fs';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
 
@@ -40,12 +41,141 @@ export function relayUrl({ config, lanAddress = null } = {}) {
40
41
  return `http://${host}:${config?.port || 3333}/?token=${encodeURIComponent(token)}`;
41
42
  }
42
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
+
43
173
  /**
44
174
  * A systemd *user* unit for the relay server. The launcher is not used here —
45
175
  * the service runs server.js directly with the same env the launcher would set,
46
176
  * so a `gh copilot` session is not tied to the unit's lifetime.
47
177
  */
48
- export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
178
+ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
49
179
  // systemd units are Linux-only, so the path inside the unit is always
50
180
  // posix-joined — host-platform path.join would write backslashes when this
51
181
  // template is exercised on Windows (tests; the CLI never writes it there).
@@ -59,6 +189,10 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
59
189
  `Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
60
190
  `Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
61
191
  `Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
192
+ // A user service starts with systemd's bare PATH: without this line the
193
+ // relay's sessions find neither the provider CLIs in ~/.local/bin nor, on
194
+ // an install with a private Node, `node` and `npm` themselves.
195
+ ...(pathEnv ? [`Environment="PATH=${escapeSystemdValue(pathEnv)}"`] : []),
62
196
  'Restart=on-failure',
63
197
  'RestartSec=5',
64
198
  '',
@@ -68,6 +202,67 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
68
202
  ].join('\n');
69
203
  }
70
204
 
205
+ // Inside a quoted systemd assignment a backslash and a quote need escaping,
206
+ // and `%` opens a specifier.
207
+ function escapeSystemdValue(value) {
208
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
209
+ }
210
+
211
+ /**
212
+ * The PATH a relay service gets: the PATH `oar setup` ran with (the user's
213
+ * shell, so their tools), with two directories added when they are not on it:
214
+ * `~/.local/bin`, where the provider CLIs install themselves and which a
215
+ * shell only picks up once it exists, and the directory of the Node that runs
216
+ * the relay.
217
+ */
218
+ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
219
+ const entries = String(envPath || '').split(':').filter(Boolean);
220
+ const localBin = homeDir ? path.posix.join(String(homeDir), '.local', 'bin') : '';
221
+ if (localBin && !entries.includes(localBin)) entries.unshift(localBin);
222
+ const nodeDir = nodeBin ? path.posix.dirname(String(nodeBin)) : '';
223
+ if (nodeDir && !entries.includes(nodeDir)) entries.push(nodeDir);
224
+ return entries.join(':');
225
+ }
226
+
227
+ /**
228
+ * The dependencies whose install scripts OAR needs (they fetch or build a
229
+ * native binary). npm 12 runs no install script it was not told to allow, and
230
+ * a global install has no package.json of its own to say so; npm 10 and 11
231
+ * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
232
+ * holds this list to the lockfile.
233
+ */
234
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'koffi']);
235
+ export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
236
+
237
+ /**
238
+ * How to run `npm install -g` so the update lands in the install that is
239
+ * running. Away from Windows: the npm that belongs to this Node (a relay
240
+ * service or a private Node has no `npm` on PATH, and a version manager may
241
+ * put another Node's there), this Node first on the child's PATH because npm
242
+ * is a `node` script, and the prefix this package sits in, so an install made
243
+ * with `--prefix` is updated in place.
244
+ */
245
+ export function resolveNpmInvocation({
246
+ execPath = process.execPath,
247
+ packageRoot = '',
248
+ platform = process.platform,
249
+ env = process.env,
250
+ existsImpl = fs.existsSync,
251
+ } = {}) {
252
+ if (platform === 'win32') return { command: 'npm.cmd', prefixArgs: [], env: { ...env } };
253
+ const nodeDir = path.posix.dirname(String(execPath || ''));
254
+ const beside = path.posix.join(nodeDir, 'npm');
255
+ const command = nodeDir && existsImpl(beside) ? beside : 'npm';
256
+ const envPath = String(env?.PATH || '');
257
+ const childPath = envPath.split(':')[0] === nodeDir ? envPath : [nodeDir, envPath].filter(Boolean).join(':');
258
+ const suffix = '/lib/node_modules/@oar-sh/oar';
259
+ const root = String(packageRoot || '').replace(/\/+$/, '');
260
+ const prefixArgs = root.endsWith(suffix) && root.length > suffix.length
261
+ ? ['--prefix', root.slice(0, -suffix.length)]
262
+ : [];
263
+ return { command, prefixArgs, env: { ...env, PATH: childPath } };
264
+ }
265
+
71
266
  /**
72
267
  * Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
73
268
  * deliberately absent: the relay drives it through the bundled @cursor/sdk npm
@@ -89,6 +284,8 @@ export function renderDoctorReport({
89
284
  dbPath,
90
285
  dbSizeBytes,
91
286
  probes = [],
287
+ warnings = [],
288
+ tunnel = '',
92
289
  }) {
93
290
  const yesNo = (v) => (v ? 'yes' : 'no');
94
291
  const lines = [
@@ -101,13 +298,17 @@ export function renderDoctorReport({
101
298
  if (config) {
102
299
  lines.push(` port : ${config.port ?? 3333} (localhostOnly: ${yesNo(config.localhostOnly !== false)})`);
103
300
  lines.push(` auth token : ${String(config.authToken || '').trim() ? 'set' : 'MISSING'}`);
104
- const tunnel = config.cloudflaredTunnel || {};
105
- 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}`);
106
306
  }
107
307
  lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
108
308
  lines.push(' provider CLIs :');
109
309
  for (const probe of probes) {
110
310
  lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
111
311
  }
312
+ for (const warning of warnings) lines.push(` warning : ${warning}`);
112
313
  return lines.join('\n');
113
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
+ }