@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.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers behind `oar
|
|
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', '
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
489
|
+
teardown();
|
|
470
490
|
});
|
|
471
491
|
|
|
472
492
|
upstreamReq.on('error', () => {
|
|
473
|
-
|
|
474
|
-
|
|
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();
|