@oar-sh/oar 0.9.8 → 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 (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
@@ -2,16 +2,17 @@
2
2
 
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
- import { createRequire } from 'module';
6
5
  import { spawn } from 'child_process';
7
6
 
8
- const requireFromHere = createRequire(import.meta.url);
9
-
10
7
  const BACKOFF_STEPS = [5_000, 10_000, 20_000, 40_000, 60_000];
11
8
  const READINESS_WINDOW_MS = 5_000;
12
9
  const STABLE_CONNECTION_MS = 30_000;
13
10
  const FAST_EXIT_MS = 10_000;
14
11
  const MAX_CONSECUTIVE_FAST_EXITS = 3;
12
+ const BINARY_RECHECK_MS = 60_000;
13
+ // What Windows starts without a shell. A `.cmd`/`.bat` launcher would need
14
+ // cmd.exe, and the tunnel token does not go through a shell command line.
15
+ const WINDOWS_DIRECT_EXTENSIONS = ['.exe', '.com'];
15
16
 
16
17
  function toText(value) {
17
18
  return String(value || '').trim();
@@ -29,28 +30,91 @@ function normalizeExtraArgs(rawValue) {
29
30
  return rawValue.map((entry) => toText(entry)).filter(Boolean);
30
31
  }
31
32
 
32
- // The npm `cloudflared` package downloads the official binary on demand; it is an
33
- // optional dependency, so absence must degrade to a config error, never a crash.
34
- // `requireImpl` is a seam: the package is optional, so a suite running under
35
- // `npm ci --omit=optional` (or after a failed postinstall download) must still be
36
- // able to exercise this logic instead of silently testing the host's install state.
37
- export function resolveCloudflaredBinaryFromPackage({
38
- existsSync = fs.existsSync,
39
- requireImpl = requireFromHere,
33
+ function hasDirectoryPart(value) {
34
+ return value.includes('/') || value.includes('\\');
35
+ }
36
+
37
+ /**
38
+ * Where the cloudflared the tunnel would run really is, or null. OAR ships no
39
+ * copy: a name is looked up on PATH (on Windows with the `.exe`/`.com`
40
+ * suffixes PATHEXT lists), a path must be an executable file.
41
+ */
42
+ export function locateCloudflaredBinary(binary, {
43
+ platform = process.platform,
44
+ env = process.env,
45
+ fsImpl = fs,
46
+ pathImpl = platform === 'win32' ? path.win32 : path.posix,
40
47
  } = {}) {
41
- try {
42
- const mod = requireImpl('cloudflared');
43
- const bin = toText(mod?.bin);
44
- // `bin` is where the package *will* place the binary; it only exists after
45
- // the on-demand download has run, so an un-downloaded path is not usable.
46
- if (bin && existsSync(bin)) return bin;
47
- } catch {}
48
+ const name = toText(binary);
49
+ if (!name) return null;
50
+
51
+ let names = [name];
52
+ if (platform === 'win32' && !WINDOWS_DIRECT_EXTENSIONS.includes(pathImpl.extname(name).toLowerCase())) {
53
+ const listed = String(env?.PATHEXT || '')
54
+ .split(';')
55
+ .map((ext) => toText(ext).toLowerCase())
56
+ .filter((ext) => WINDOWS_DIRECT_EXTENSIONS.includes(ext));
57
+ names = (listed.length ? listed : WINDOWS_DIRECT_EXTENSIONS).map((ext) => `${name}${ext}`);
58
+ }
59
+
60
+ const isExecutableFile = (candidate) => {
61
+ try {
62
+ if (!fsImpl.statSync(candidate).isFile()) return false;
63
+ // Windows has no execute bit; the suffix above decided.
64
+ if (platform !== 'win32') fsImpl.accessSync(candidate, fs.constants.X_OK);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+
71
+ const dirs = hasDirectoryPart(name)
72
+ ? [null]
73
+ : String(env?.PATH ?? env?.Path ?? '').split(pathImpl.delimiter).map(toText).filter(Boolean);
74
+ for (const dir of dirs) {
75
+ for (const candidate of names) {
76
+ const full = dir === null ? candidate : pathImpl.join(dir, candidate);
77
+ if (isExecutableFile(full)) return full;
78
+ }
79
+ }
48
80
  return null;
49
81
  }
50
82
 
83
+ export function cloudflaredInstallPointer(platform = process.platform) {
84
+ if (platform === 'win32') return 'install it with: winget install --id Cloudflare.cloudflared';
85
+ if (platform === 'darwin') return 'install it with: brew install cloudflared';
86
+ return "install it from Cloudflare's package repository: https://pkg.cloudflare.com/";
87
+ }
88
+
89
+ /** The one sentence the tunnel state, the status dot and the CLI show. */
90
+ export function describeMissingCloudflaredBinary({ binary = '', binarySource = 'path', platform = process.platform } = {}) {
91
+ const pointer = cloudflaredInstallPointer(platform);
92
+ if (binarySource === 'config') {
93
+ return `cloudflared was not found as configured (${binary}) — correct cloudflaredTunnel.binary, or ${pointer}`;
94
+ }
95
+ return `cloudflared is not installed — ${pointer}`;
96
+ }
97
+
98
+ /**
99
+ * The tunnel in one line for a CLI status row. Takes the manager's state or
100
+ * the `cloudflaredTunnel` object of `/api/status`; the public address is the
101
+ * caller's to pass, the relay does not know it (the route lives in Cloudflare).
102
+ */
103
+ export function describeTunnelState(state, { url = '' } = {}) {
104
+ const tunnel = state && typeof state === 'object' ? state : {};
105
+ const managed = toText(tunnel.mode).toLowerCase() === 'managed' || tunnel.enabled === true;
106
+ if (!managed) return 'off';
107
+ const reason = toText(tunnel.lastError);
108
+ if (tunnel.connected === true) {
109
+ const address = toText(url);
110
+ return address ? `running (${address})` : 'running';
111
+ }
112
+ if (tunnel.binaryMissing === true || tunnel.valid === false) return reason ? `off: ${reason}` : 'off';
113
+ return reason ? `not connected (${reason})` : 'not connected';
114
+ }
115
+
51
116
  export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
52
117
  env = process.env,
53
- resolveBinary = resolveCloudflaredBinaryFromPackage,
54
118
  configBaseDir = process.cwd(),
55
119
  pathImpl = path,
56
120
  } = {}) {
@@ -64,33 +128,20 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
64
128
  const binaryInput = toText(env?.COPILOT_CLOUDFLARED_BINARY) || toText(raw.binary);
65
129
  const extraArgs = normalizeExtraArgs(raw.extraArgs);
66
130
 
67
- let binary = '';
68
- let binarySource = null;
131
+ // The configured binary, else `cloudflared` from PATH. Whether it is really
132
+ // there is checked when the tunnel starts (locateCloudflaredBinary).
133
+ let binary = 'cloudflared';
134
+ let binarySource = 'path';
69
135
  if (binaryInput) {
70
- binary = binaryInput.includes('/') || binaryInput.includes('\\')
136
+ binary = hasDirectoryPart(binaryInput)
71
137
  ? pathImpl.resolve(configBaseDir, binaryInput)
72
138
  : binaryInput;
73
139
  binarySource = 'config';
74
- } else {
75
- let packageBinary = null;
76
- try {
77
- packageBinary = toText(typeof resolveBinary === 'function' ? resolveBinary() : '');
78
- } catch {
79
- packageBinary = null;
80
- }
81
- if (packageBinary) {
82
- binary = packageBinary;
83
- binarySource = 'package';
84
- } else {
85
- binary = 'cloudflared';
86
- binarySource = 'path';
87
- }
88
140
  }
89
141
 
90
142
  const errors = [];
91
143
  if (mode === 'managed') {
92
144
  if (!token) errors.push('cloudflaredTunnel.token is required when cloudflaredTunnel.mode is "managed"');
93
- if (!binary) errors.push('cloudflaredTunnel.binary could not be resolved (install the "cloudflared" package or set cloudflaredTunnel.binary)');
94
145
  }
95
146
 
96
147
  return {
@@ -106,6 +157,28 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
106
157
  };
107
158
  }
108
159
 
160
+ /**
161
+ * The tunnel as `oar doctor` reports it, from the config alone (no relay
162
+ * needed): whether the built-in tunnel is on and whether it could start.
163
+ */
164
+ export function describeConfiguredTunnel(rawConfig = {}, {
165
+ env = process.env,
166
+ platform = process.platform,
167
+ configBaseDir = process.cwd(),
168
+ pathImpl = path,
169
+ locateBinary = locateCloudflaredBinary,
170
+ } = {}) {
171
+ const tunnelConfig = normalizeCloudflaredTunnelConfig(rawConfig, { env, configBaseDir, pathImpl });
172
+ if (tunnelConfig.mode !== 'managed') return 'disabled';
173
+ if (!tunnelConfig.valid) return `managed, cannot start: ${tunnelConfig.errors[0]}`;
174
+ const located = locateBinary(tunnelConfig.binary, { platform, env });
175
+ if (!located) {
176
+ const { binary, binarySource } = tunnelConfig;
177
+ return `managed, cannot start: ${describeMissingCloudflaredBinary({ binary, binarySource, platform })}`;
178
+ }
179
+ return `managed (cloudflared: ${located})`;
180
+ }
181
+
109
182
  export function buildCloudflaredArgs(tunnelConfig) {
110
183
  return ['tunnel', 'run', '--token', tunnelConfig.token, ...tunnelConfig.extraArgs];
111
184
  }
@@ -145,12 +218,11 @@ export function createCloudflaredTunnelManager({
145
218
  clearTimeoutImpl = clearTimeout,
146
219
  configBaseDir = process.cwd(),
147
220
  env = process.env,
148
- resolveBinary = resolveCloudflaredBinaryFromPackage,
221
+ locateBinary = locateCloudflaredBinary,
149
222
  pathImpl = path,
150
223
  } = {}) {
151
224
  const tunnelConfig = normalizeCloudflaredTunnelConfig(rawTunnelConfig, {
152
225
  env,
153
- resolveBinary,
154
226
  configBaseDir,
155
227
  pathImpl,
156
228
  });
@@ -174,6 +246,7 @@ export function createCloudflaredTunnelManager({
174
246
  lastError: tunnelConfig.errors[0] || null,
175
247
  lastEventAt: nowIso(),
176
248
  binary: tunnelConfig.binary || null,
249
+ binaryMissing: false,
177
250
  proc: null,
178
251
  backoffTimer: null,
179
252
  };
@@ -189,6 +262,7 @@ export function createCloudflaredTunnelManager({
189
262
  reconnectAttempts: state.reconnectAttempts,
190
263
  connectedSince: state.connectedSince,
191
264
  lastError: state.lastError,
265
+ binaryMissing: state.binaryMissing,
192
266
  });
193
267
  };
194
268
 
@@ -220,9 +294,33 @@ export function createCloudflaredTunnelManager({
220
294
  return;
221
295
  }
222
296
 
297
+ // OAR ships no cloudflared. Without one the tunnel stays off, the state
298
+ // says how to install it, and a look every minute starts the tunnel once
299
+ // it is there (no relay restart, as long as it lands on the relay's PATH).
300
+ let located = null;
301
+ try { located = toText(locateBinary(tunnelConfig.binary, { platform, env })); } catch {}
302
+ if (!located) {
303
+ if (!state.binaryMissing) {
304
+ state.binaryMissing = true;
305
+ state.lastError = describeMissingCloudflaredBinary({
306
+ binary: tunnelConfig.binary,
307
+ binarySource: tunnelConfig.binarySource,
308
+ platform,
309
+ });
310
+ warn(`Tunnel not started: ${state.lastError}`);
311
+ updateBlockingState();
312
+ emitStatus();
313
+ }
314
+ if (state.backoffTimer) clearTimeoutImpl(state.backoffTimer);
315
+ state.backoffTimer = setTimeoutImpl(spawnTunnel, BINARY_RECHECK_MS);
316
+ if (typeof state.backoffTimer?.unref === 'function') state.backoffTimer.unref();
317
+ return;
318
+ }
319
+ state.binaryMissing = false;
320
+
223
321
  const args = buildCloudflaredArgs(tunnelConfig);
224
- log(`Spawning: ${tunnelConfig.binary} ${redactCloudflaredArgs(args).join(' ')}`);
225
- const proc = spawnImpl(tunnelConfig.binary, args, buildSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
322
+ log(`Spawning: ${located} ${redactCloudflaredArgs(args).join(' ')}`);
323
+ const proc = spawnImpl(located, args, buildSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
226
324
  state.proc = proc;
227
325
  state.lastError = null;
228
326
  updateBlockingState();
@@ -0,0 +1,155 @@
1
+ // Serving a file from disk so that what arrives is the file as it is now:
2
+ // one open, one fstat, and length, validators and bytes all from that handle.
3
+ // The headers tell every cache on the way, the browser's and a CDN's alike,
4
+ // to keep nothing.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { applySafeServedContentHeaders } from './safe-served-content.mjs';
10
+
11
+ /**
12
+ * A short token that changes whenever the file's bytes may have changed
13
+ * (modification time or size). It goes into download URLs as `v=…`: the
14
+ * server ignores it, and a changed file gets a URL nothing has seen before.
15
+ */
16
+ export function fileVersionToken({ mtimeMs, size } = {}) {
17
+ const modified = Math.floor(Number(mtimeMs));
18
+ const bytes = Number(size);
19
+ if (!Number.isFinite(modified) || !Number.isFinite(bytes) || modified < 0 || bytes < 0) return '';
20
+ return `${modified.toString(36)}-${Math.floor(bytes).toString(36)}`;
21
+ }
22
+
23
+ /**
24
+ * `Cache-Control` speaks to browsers and to proxies that honour it. The two
25
+ * CDN headers bind the edge itself: Cloudflare obeys them ahead of
26
+ * `Cache-Control` and of a "cache everything" rule that would otherwise
27
+ * override the origin's wish.
28
+ */
29
+ export function applyNoStoreHeaders(res) {
30
+ res.setHeader('Cache-Control', 'no-store');
31
+ applyCdnNoStoreHeaders(res);
32
+ }
33
+
34
+ export function applyCdnNoStoreHeaders(res) {
35
+ res.setHeader('CDN-Cache-Control', 'no-store');
36
+ res.setHeader('Cloudflare-CDN-Cache-Control', 'no-store');
37
+ }
38
+
39
+ /**
40
+ * The name a download is saved under, when the client asks for one (`as=…`).
41
+ * Accepted only as a plain file name with the real file's extension, so the
42
+ * request cannot change what kind of file the browser believes it gets.
43
+ */
44
+ export function downloadNameOverride(requested, realName) {
45
+ const wanted = String(requested || '').trim();
46
+ if (!wanted || wanted.length > 200) return '';
47
+ if (/[\\/:*?"<>|\u0000-\u001f\u007f]/.test(wanted) || wanted === '.' || wanted === '..') return '';
48
+ const realExt = path.extname(String(realName || '')).toLowerCase();
49
+ if (path.extname(wanted).toLowerCase() !== realExt) return '';
50
+ return wanted;
51
+ }
52
+
53
+ function appendUrlParam(url, key, value) {
54
+ if (!value) return url;
55
+ return `${url}${url.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}`;
56
+ }
57
+
58
+ /** `url` with the file's version token appended, or unchanged when there is none. */
59
+ export function withFileVersion(url, version) {
60
+ return appendUrlParam(String(url || ''), 'v', String(version || ''));
61
+ }
62
+
63
+ /**
64
+ * `cacheControl` replaces the no-store default for content whose URL names
65
+ * its bytes (an upload addressed by its hash): such an answer may be kept.
66
+ */
67
+ export function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null, fsImpl = fs, cacheControl = '' } = {}) {
68
+ const realName = safeName || path.basename(filePath).replace(/"/g, '');
69
+ const name = downloadNameOverride(req.query?.as, realName) || realName;
70
+ const rangeHeader = req.headers['range'];
71
+
72
+ const fail = (error) => {
73
+ if (cacheDelete) cacheDelete(filePath);
74
+ if (res.headersSent) { res.destroy(error); return; }
75
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') {
76
+ res.status(404).json({ error: 'File not found' });
77
+ return;
78
+ }
79
+ res.status(500).json({ error: 'Failed to read file' });
80
+ };
81
+
82
+ // The caller's `meta` may be a moment old (it is cached). Size, validators
83
+ // and bytes all come from this one handle instead: a length taken from an
84
+ // older stat cut a file that had just grown down to its previous size.
85
+ let fd = null;
86
+ let stat = null;
87
+ try {
88
+ fd = fsImpl.openSync(filePath, 'r');
89
+ stat = fsImpl.fstatSync(fd);
90
+ } catch (error) {
91
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch {} }
92
+ fail(error);
93
+ return;
94
+ }
95
+ const closeFd = () => { try { fsImpl.closeSync(fd); } catch {} };
96
+ if (!stat.isFile()) {
97
+ closeFd();
98
+ fail(Object.assign(new Error('Not a file'), { code: 'EISDIR' }));
99
+ return;
100
+ }
101
+ const fileSize = Number(stat.size || 0);
102
+
103
+ // From here until the stream owns the handle, any throw (a header value
104
+ // `setHeader` refuses, say) must close the handle, or every such request
105
+ // would leak one.
106
+ let start = 0;
107
+ let end = fileSize - 1;
108
+ try {
109
+ res.setHeader('Accept-Ranges', 'bytes');
110
+ if (cacheControl) res.setHeader('Cache-Control', cacheControl);
111
+ else applyNoStoreHeaders(res);
112
+ // Validators cost nothing next to no-store, and they let a client or a
113
+ // person with curl see which version of the file an answer carries.
114
+ const version = fileVersionToken({ mtimeMs: stat.mtimeMs, size: fileSize });
115
+ if (version) res.setHeader('ETag', `W/"${version}"`);
116
+ if (Number.isFinite(stat.mtimeMs)) res.setHeader('Last-Modified', new Date(stat.mtimeMs).toUTCString());
117
+ // Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
118
+ // sandbox the response so a worker-written workspace file can't run as script
119
+ // on this origin. Media stays inline for preview; everything else downloads.
120
+ applySafeServedContentHeaders(res, meta?.contentType, { fileName: name });
121
+
122
+ if (rangeHeader) {
123
+ const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
124
+ const from = match ? parseInt(match[1], 10) : NaN;
125
+ const to = match && match[2] ? parseInt(match[2], 10) : fileSize - 1;
126
+ if (!match || from > to || from >= fileSize || to >= fileSize) {
127
+ closeFd();
128
+ res.setHeader('Content-Range', `bytes */${fileSize}`);
129
+ res.status(416).end();
130
+ return;
131
+ }
132
+ start = from;
133
+ end = to;
134
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
135
+ res.status(206);
136
+ }
137
+ res.setHeader('Content-Length', String(Math.max(0, end - start + 1)));
138
+ } catch (error) {
139
+ closeFd();
140
+ fail(error);
141
+ return;
142
+ }
143
+ if (end - start + 1 <= 0) {
144
+ closeFd();
145
+ res.end();
146
+ return;
147
+ }
148
+ // Bounded to the size just read, so a file that grows while it is sent
149
+ // still yields exactly the announced number of bytes.
150
+ const stream = fsImpl.createReadStream(null, { fd, start, end, autoClose: true });
151
+ stream.on('error', fail);
152
+ // A client that goes away mid-download would otherwise leave the handle open.
153
+ res.on?.('close', () => stream.destroy());
154
+ stream.pipe(res);
155
+ }
@@ -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,5 +1,5 @@
1
1
  /**
2
- * Pure helpers behind `oar setup` and `oar doctor` — everything here is
2
+ * Pure helpers behind the `oar` command line — everything here is
3
3
  * deterministic and unit-testable; the interactive glue stays in bin/oar.js.
4
4
  */
5
5
 
@@ -41,6 +41,135 @@ export function relayUrl({ config, lanAddress = null } = {}) {
41
41
  return `http://${host}:${config?.port || 3333}/?token=${encodeURIComponent(token)}`;
42
42
  }
43
43
 
44
+ /**
45
+ * What `oar setup` and `oar url` print about the relay's address. `qrUrl` is
46
+ * set only for an address a phone can reach: the LAN address when LAN access
47
+ * is on, and never one that is internal to WSL. `tunnelUrl` is the address a
48
+ * running relay reports for its tunnel, with the token added.
49
+ */
50
+ export function describeRelayAddress({ config, lanAddress = null, wslNat = false, tunnelBase = '' } = {}) {
51
+ const lan = config?.localhostOnly === false;
52
+ const url = relayUrl({ config: { ...config, localhostOnly: !lan }, lanAddress });
53
+ const lines = [`[oar] Relay URL: ${url}`];
54
+ let qrUrl = null;
55
+ if (!lan) {
56
+ lines.push('[oar] This machine only. For a phone, switch on LAN access (oar setup --lan) or use a tunnel (README: Remote access).');
57
+ } else if (!lanAddress) {
58
+ lines.push('[oar] LAN access is on, but this machine has no network address.');
59
+ } else if (wslNat) {
60
+ lines.push('[oar] That address is internal to WSL; other devices cannot reach it. Ways out: a tunnel (README: Remote access), or WSL\'s mirrored networking (networkingMode=mirrored in .wslconfig).');
61
+ } else {
62
+ qrUrl = url;
63
+ }
64
+ const base = String(tunnelBase || '').trim().replace(/\/+$/, '');
65
+ const tunnelUrl = base ? `${base}/?token=${encodeURIComponent(String(config?.authToken || '').trim())}` : null;
66
+ return { url, lines, qrUrl, tunnelUrl };
67
+ }
68
+
69
+ const VALUE_OPTIONS = ['--port', '--migrate-from', '--to'];
70
+ const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
71
+ const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
72
+ const CLI_COMMANDS = {
73
+ menu: {},
74
+ start: {},
75
+ stop: {},
76
+ restart: {},
77
+ status: {},
78
+ url: {},
79
+ doctor: {},
80
+ help: {},
81
+ version: {},
82
+ setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from'] },
83
+ service: { words: ['install', 'remove', 'status'] },
84
+ update: { flags: ['--beta'], values: ['--to'] },
85
+ copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
86
+ };
87
+
88
+ export function usageText() {
89
+ return [
90
+ 'Usage: oar [command] [options]',
91
+ '',
92
+ ' oar Menu in a terminal; status and this text elsewhere',
93
+ ' oar start Start the relay in the background',
94
+ ' oar stop Stop the relay (waits for running turns)',
95
+ ' oar restart Restart the relay (waits for running turns)',
96
+ ' oar status Is the relay running, on which port, as a service',
97
+ ' oar url Relay URL, with a QR code when a phone can reach it',
98
+ ' oar setup Auth token, access and port; asks, or takes',
99
+ ' [--port <port>] [--lan | --local] [--new-token] [--defaults] [--start]',
100
+ ' oar service install | remove | status',
101
+ ' Start the relay at login',
102
+ ' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
103
+ ' Relay plus a Copilot terminal session',
104
+ ' oar doctor Check the install and the provider CLIs',
105
+ ' oar update [--beta] [--to <version>]',
106
+ ' oar help This text',
107
+ ' oar --version',
108
+ ].join('\n');
109
+ }
110
+
111
+ /**
112
+ * Reads the command line into `{ command, args }`, or `{ error }` for anything
113
+ * `oar` does not know, so nothing starts by accident. The command may follow
114
+ * options (`oar --port 3339 setup`), and `--setup`, `--start`, `--stop` and
115
+ * `--status` name it too. Without a command the launcher's own flags (or a
116
+ * `--` part) mean the Copilot session, and nothing at all means the menu.
117
+ */
118
+ export function parseCliArgs(argv = []) {
119
+ const all = (Array.isArray(argv) ? argv : []).map((arg) => String(arg));
120
+ const separator = all.indexOf('--');
121
+ const head = separator === -1 ? all : all.slice(0, separator);
122
+ const forwarded = separator === -1 ? [] : all.slice(separator);
123
+ if (head.includes('--help') || head.includes('-h')) return { command: 'help', args: [] };
124
+
125
+ let command = null;
126
+ let args = [];
127
+ for (let index = 0; index < head.length; index += 1) {
128
+ const arg = head[index];
129
+ if (VALUE_OPTIONS.includes(arg)) {
130
+ if (index + 1 >= head.length) return { error: `${arg} needs a value.` };
131
+ args.push(arg, head[index += 1]);
132
+ } else if (arg.startsWith('-') || command) {
133
+ args.push(arg);
134
+ } else {
135
+ command = arg;
136
+ }
137
+ }
138
+ if (!command) {
139
+ const named = args.includes('--setup') ? '--setup' : args.find((arg) => COMMAND_OPTIONS[arg]);
140
+ if (args.includes('--version') || args.includes('-v')) return { command: 'version', args: [] };
141
+ if (named) {
142
+ command = COMMAND_OPTIONS[named];
143
+ args.splice(args.indexOf(named), 1);
144
+ } else if (forwarded.length || args.some((arg) => LAUNCHER_FLAGS.includes(arg))) {
145
+ command = 'copilot';
146
+ } else if (!args.length) {
147
+ command = 'menu';
148
+ } else {
149
+ // An option this command line knows needs its command; any other is unknown.
150
+ const known = [...VALUE_OPTIONS, '--lan', '--local', '--new-token', '--defaults', '--beta'];
151
+ return { error: known.includes(args[0]) ? `${args[0]} needs a command.` : `Unknown option: ${args[0]}` };
152
+ }
153
+ }
154
+
155
+ const spec = Object.hasOwn(CLI_COMMANDS, command) ? CLI_COMMANDS[command] : null;
156
+ if (!spec) return { error: `Unknown command: ${command}` };
157
+ if (forwarded.length && !spec.forwards) return { error: `oar ${command} takes no "--" part.` };
158
+ let words = 0;
159
+ for (let index = 0; index < args.length; index += 1) {
160
+ const arg = args[index];
161
+ const name = arg.startsWith('--') ? arg.split('=')[0] : arg;
162
+ if (spec.values?.includes(name)) {
163
+ if (arg === name) index += 1;
164
+ } else if (spec.words?.includes(arg) && words === 0) {
165
+ words += 1;
166
+ } else if (!spec.flags?.includes(arg)) {
167
+ return { error: arg.startsWith('-') ? `Unknown option for oar ${command}: ${arg}` : `Unexpected argument: ${arg}` };
168
+ }
169
+ }
170
+ return { command, args: [...args, ...forwarded] };
171
+ }
172
+
44
173
  /**
45
174
  * A systemd *user* unit for the relay server. The launcher is not used here —
46
175
  * the service runs server.js directly with the same env the launcher would set,
@@ -102,7 +231,7 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
102
231
  * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
103
232
  * holds this list to the lockfile.
104
233
  */
105
- export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'cloudflared', 'koffi']);
234
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'koffi']);
106
235
  export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
107
236
 
108
237
  /**
@@ -155,6 +284,8 @@ export function renderDoctorReport({
155
284
  dbPath,
156
285
  dbSizeBytes,
157
286
  probes = [],
287
+ warnings = [],
288
+ tunnel = '',
158
289
  }) {
159
290
  const yesNo = (v) => (v ? 'yes' : 'no');
160
291
  const lines = [
@@ -167,13 +298,17 @@ export function renderDoctorReport({
167
298
  if (config) {
168
299
  lines.push(` port : ${config.port ?? 3333} (localhostOnly: ${yesNo(config.localhostOnly !== false)})`);
169
300
  lines.push(` auth token : ${String(config.authToken || '').trim() ? 'set' : 'MISSING'}`);
170
- const tunnel = config.cloudflaredTunnel || {};
171
- lines.push(` tunnel : ${tunnel.enabled === true || tunnel.mode === 'managed' ? 'managed' : 'disabled'}`);
301
+ // `tunnel` is the caller's finding (describeConfiguredTunnel: is the tunnel
302
+ // on, and is there a cloudflared to run it); without one, the config's word.
303
+ const tunnelConfig = config.cloudflaredTunnel || {};
304
+ const configured = tunnelConfig.enabled === true || tunnelConfig.mode === 'managed' ? 'managed' : 'disabled';
305
+ lines.push(` tunnel : ${tunnel || configured}`);
172
306
  }
173
307
  lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
174
308
  lines.push(' provider CLIs :');
175
309
  for (const probe of probes) {
176
310
  lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
177
311
  }
312
+ for (const warning of warnings) lines.push(` warning : ${warning}`);
178
313
  return lines.join('\n');
179
314
  }