@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.
- package/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- 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 +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
+
}
|