@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
package/bin/oar.js
CHANGED
|
@@ -6,12 +6,22 @@ import os from 'os';
|
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
7
|
import { EventEmitter } from 'events';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
|
-
import { spawn } from 'child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'child_process';
|
|
10
|
+
import net from 'net';
|
|
10
11
|
|
|
11
12
|
import {
|
|
12
13
|
migrateStateToOarRoot,
|
|
13
14
|
resolveOarRoot,
|
|
14
15
|
} from '../server/services/oar-state-migration-service.mjs';
|
|
16
|
+
import {
|
|
17
|
+
buildDefaultConfig, buildServicePath, buildSystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
|
|
18
|
+
NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, primaryLanAddress, renderDoctorReport, resolveNpmInvocation, usageText,
|
|
19
|
+
} from '../server/services/oar-cli-helpers.mjs';
|
|
20
|
+
import { describeConfiguredTunnel, describeTunnelState } from '../server/services/cloudflared-tunnel-service.mjs';
|
|
21
|
+
import { createPrompter, PromptAborted, runMenu } from '../server/services/oar-cli-menu.mjs';
|
|
22
|
+
import { createWindowsPortCheck, isWsl, isWslNat, windowsPortWarning } from '../server/services/oar-cli-wsl.mjs';
|
|
23
|
+
import { createWindowsAutostartService } from '../server/services/windows-autostart-service.mjs';
|
|
24
|
+
import { createWindowsBootAutostartService } from '../server/services/windows-boot-autostart-service.mjs';
|
|
15
25
|
|
|
16
26
|
function resolvePackageRoot(metaUrl = import.meta.url) {
|
|
17
27
|
return path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
|
|
@@ -222,6 +232,17 @@ function resolveLauncherConfig({ packageRoot, env = process.env, relayToken = ''
|
|
|
222
232
|
};
|
|
223
233
|
}
|
|
224
234
|
|
|
235
|
+
const GH_MISSING_MESSAGE = '[oar] The Copilot session needs the GitHub CLI (gh), which is not installed or not on PATH: https://cli.github.com — the relay alone needs no gh: oar start';
|
|
236
|
+
|
|
237
|
+
function ghInstalled({ spawnSyncImpl = spawnSync } = {}) {
|
|
238
|
+
try {
|
|
239
|
+
const result = spawnSyncImpl('gh', ['--version'], { stdio: 'ignore', timeout: 15_000, windowsHide: true });
|
|
240
|
+
return !result?.error && result?.status === 0;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
225
246
|
export async function detectRunningRelay({
|
|
226
247
|
lockPath,
|
|
227
248
|
statusUrl,
|
|
@@ -253,7 +274,10 @@ export async function launchRelay({
|
|
|
253
274
|
packageRoot = resolvePackageRoot(),
|
|
254
275
|
nodeBin = process.execPath,
|
|
255
276
|
spawnImpl = spawn,
|
|
277
|
+
spawnSyncImpl = spawnSync,
|
|
256
278
|
fetchImpl = globalThis.fetch,
|
|
279
|
+
isProcessAliveImpl = isProcessAlive,
|
|
280
|
+
lockGraceMs = 5000,
|
|
257
281
|
env = process.env,
|
|
258
282
|
logger = console,
|
|
259
283
|
} = {}) {
|
|
@@ -270,16 +294,22 @@ export async function launchRelay({
|
|
|
270
294
|
const forwardedArgs = separatorIdx === -1 ? [] : args.slice(separatorIdx + 1);
|
|
271
295
|
if (args.includes('--help') || args.includes('-h')) {
|
|
272
296
|
logger.log([
|
|
273
|
-
'Usage: oar [--port <port>] [--migrate-from <old-checkout>] [--install-extension] [--no-install-extension] [-- [gh copilot args...]]',
|
|
297
|
+
'Usage: oar copilot [--port <port>] [--migrate-from <old-checkout>] [--install-extension] [--no-install-extension] [-- [gh copilot args...]]',
|
|
274
298
|
'',
|
|
275
299
|
'Starts the web relay server if needed, then launches gh copilot in the current shell.',
|
|
276
300
|
'--install-extension installs/updates a user-global web-relay wrapper extension and exits.',
|
|
277
301
|
'--no-install-extension skips automatic wrapper setup for this run.',
|
|
278
|
-
'
|
|
302
|
+
'oar help lists every command.',
|
|
279
303
|
].join('\n'));
|
|
280
304
|
return { code: 0, reason: 'help' };
|
|
281
305
|
}
|
|
282
306
|
|
|
307
|
+
// The session is `gh copilot`: without gh there is nothing to prepare or
|
|
308
|
+
// start. Installing the wrapper alone needs no gh.
|
|
309
|
+
if (!installExtensionOnly && !ghInstalled({ spawnSyncImpl })) {
|
|
310
|
+
logger.error?.(GH_MISSING_MESSAGE);
|
|
311
|
+
return { code: 1, reason: 'gh-missing' };
|
|
312
|
+
}
|
|
283
313
|
if (!skipExtensionInstall || installExtensionOnly) {
|
|
284
314
|
try {
|
|
285
315
|
ensureGlobalExtensionWrapper({ packageRoot, env, logger });
|
|
@@ -324,22 +354,28 @@ export async function launchRelay({
|
|
|
324
354
|
// while the server started on the config's port, so any mismatch waited out
|
|
325
355
|
// the readiness deadline and killed the relay it had just started.
|
|
326
356
|
const { seedPath } = resolveConfigPaths({ packageRoot, env, layout });
|
|
327
|
-
const
|
|
357
|
+
const configPort = normalizePort(readJsonFile(seedPath)?.port);
|
|
358
|
+
const port = cliPort ?? configPort ?? 3333;
|
|
328
359
|
const statusUrl = `http://localhost:${port}/api/status`;
|
|
329
|
-
const running = await detectRunningRelay({
|
|
330
|
-
lockPath,
|
|
331
|
-
statusUrl,
|
|
332
|
-
fetchImpl,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
360
|
const runtimeConfig = resolveLauncherConfig({
|
|
336
361
|
packageRoot,
|
|
337
362
|
env,
|
|
338
|
-
relayToken: running?.lock?.token || '',
|
|
339
363
|
relayPort: port,
|
|
340
364
|
portOverride: cliPort,
|
|
341
365
|
layout,
|
|
342
366
|
});
|
|
367
|
+
// The relay on that port says whether it runs: a lock file says nothing
|
|
368
|
+
// about the port, and can outlive its relay. On a --port other than the
|
|
369
|
+
// config's the lock is left alone: its relay may run on the config's port.
|
|
370
|
+
const probe = { statusUrl, token: runtimeConfig.config.authToken, fetchImpl };
|
|
371
|
+
const answer = configPort && port !== configPort
|
|
372
|
+
? await probeRelayPort(probe)
|
|
373
|
+
: await probeRelayBehindLock({ ...probe, lockPath, port, isProcessAliveImpl, lockGraceMs, logger });
|
|
374
|
+
if (answer === 'taken') {
|
|
375
|
+
logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
|
|
376
|
+
logger.error?.('[oar] Stop that program, or pick another port: oar copilot --port <port>');
|
|
377
|
+
return { code: 1, reason: 'port-taken' };
|
|
378
|
+
}
|
|
343
379
|
if (runtimeConfig.managed || !fs.existsSync(runtimeConfig.configPath)) {
|
|
344
380
|
writeJsonFile(runtimeConfig.configPath, runtimeConfig.config);
|
|
345
381
|
}
|
|
@@ -361,13 +397,8 @@ export async function launchRelay({
|
|
|
361
397
|
|
|
362
398
|
let serverProc = null;
|
|
363
399
|
let relayStartedByThisCommand = false;
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
logger.log(
|
|
367
|
-
pid
|
|
368
|
-
? `Copilot relay is already running (pid=${pid}) at ${statusUrl}.`
|
|
369
|
-
: `Copilot relay is already running at ${statusUrl}.`,
|
|
370
|
-
);
|
|
400
|
+
if (answer === 'oar') {
|
|
401
|
+
logger.log(`Copilot relay is already running at ${statusUrl}.`);
|
|
371
402
|
} else {
|
|
372
403
|
logger.log(`Starting Copilot relay from ${cwd}...`);
|
|
373
404
|
const serverArgs = [path.join(serverDir, 'server.js'), '--token', runtimeConfig.config.authToken, '--port', String(runtimeConfig.config.port)];
|
|
@@ -456,7 +487,7 @@ export async function launchRelay({
|
|
|
456
487
|
windowsHide: false,
|
|
457
488
|
});
|
|
458
489
|
} catch (error) {
|
|
459
|
-
logger.error?.(`[oar] Failed to launch Copilot CLI: ${error?.message || error}`);
|
|
490
|
+
logger.error?.(error?.code === 'ENOENT' ? GH_MISSING_MESSAGE : `[oar] Failed to launch Copilot CLI: ${error?.message || error}`);
|
|
460
491
|
try {
|
|
461
492
|
if (relayStartedByThisCommand && serverProc && typeof serverProc.kill === 'function') serverProc.kill();
|
|
462
493
|
} catch {}
|
|
@@ -475,7 +506,7 @@ export async function launchRelay({
|
|
|
475
506
|
resolve(code ?? (signal ? 1 : 0));
|
|
476
507
|
});
|
|
477
508
|
child.on('error', (error) => {
|
|
478
|
-
logger.error?.(`[oar] Copilot CLI process error: ${error?.message || error}`);
|
|
509
|
+
logger.error?.(error?.code === 'ENOENT' ? GH_MISSING_MESSAGE : `[oar] Copilot CLI process error: ${error?.message || error}`);
|
|
479
510
|
resolve(1);
|
|
480
511
|
});
|
|
481
512
|
});
|
|
@@ -497,32 +528,789 @@ function readPackageVersion(packageRoot = resolvePackageRoot()) {
|
|
|
497
528
|
}
|
|
498
529
|
}
|
|
499
530
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
531
|
+
function canBind(port, host) {
|
|
532
|
+
return new Promise((resolve) => {
|
|
533
|
+
const probe = net.createServer();
|
|
534
|
+
probe.once('error', (error) => {
|
|
535
|
+
// A host this machine does not have (no IPv6) says nothing about the port.
|
|
536
|
+
resolve(error?.code === 'EADDRNOTAVAIL' || error?.code === 'EAFNOSUPPORT');
|
|
537
|
+
});
|
|
538
|
+
probe.once('listening', () => probe.close(() => resolve(true)));
|
|
539
|
+
if (host) probe.listen(port, host);
|
|
540
|
+
else probe.listen(port);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function acceptsConnection(port, host, timeoutMs = 400) {
|
|
545
|
+
return new Promise((resolve) => {
|
|
546
|
+
const socket = net.connect({ port, host });
|
|
547
|
+
let settled = false;
|
|
548
|
+
const finish = (accepted) => {
|
|
549
|
+
if (settled) return;
|
|
550
|
+
settled = true;
|
|
551
|
+
// An accepted connection is closed politely: destroying it reaches the
|
|
552
|
+
// listener as a reset, which a program without an error handler on its
|
|
553
|
+
// sockets would take as a fault.
|
|
554
|
+
if (accepted) socket.end();
|
|
555
|
+
else socket.destroy();
|
|
556
|
+
resolve(accepted);
|
|
557
|
+
};
|
|
558
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
559
|
+
socket.once('connect', () => finish(true));
|
|
560
|
+
socket.on('error', () => finish(false));
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A port is free when nothing accepts a connection on it and it can be bound
|
|
566
|
+
* on the loopback addresses and on all interfaces. One wildcard bind is not
|
|
567
|
+
* enough: Windows allows it beside a listener on 127.0.0.1 alone.
|
|
568
|
+
*/
|
|
569
|
+
export async function isPortFree(port) {
|
|
570
|
+
for (const host of ['127.0.0.1', '::1']) {
|
|
571
|
+
if (await acceptsConnection(port, host)) return false;
|
|
572
|
+
}
|
|
573
|
+
for (const host of ['127.0.0.1', '::1', '']) {
|
|
574
|
+
if (!(await canBind(port, host))) return false;
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The first free port from `start` on, so a fresh config does not name a port something else holds. */
|
|
580
|
+
export async function findFreePort(start = 3333, { attempts = 20, isPortFreeImpl = isPortFree } = {}) {
|
|
581
|
+
for (let port = start; port < start + attempts && port <= 65535; port += 1) {
|
|
582
|
+
if (await isPortFreeImpl(port)) return port;
|
|
583
|
+
}
|
|
584
|
+
return start;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function runQuiet(command, args, { spawnSyncImpl = spawnSync } = {}) {
|
|
588
|
+
try {
|
|
589
|
+
const result = spawnSyncImpl(command, args, { stdio: 'ignore', timeout: 15_000 });
|
|
590
|
+
return result.status === 0;
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// A systemd *user* manager this process can talk to: absent in containers, on
|
|
597
|
+
// macOS, and in a WSL distro without systemd.
|
|
598
|
+
function systemdUserAvailable({ platform = process.platform, spawnSyncImpl } = {}) {
|
|
599
|
+
return platform === 'linux' && runQuiet('systemctl', ['--user', 'show-environment'], { spawnSyncImpl });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function systemdUnitPath(homeDir = os.homedir()) {
|
|
603
|
+
return path.join(homeDir, '.config', 'systemd', 'user', 'oar.service');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function waitForRelay({ statusUrl, token, fetchImpl = globalThis.fetch, deadlineMs = 20_000, stillStarting = () => true } = {}) {
|
|
607
|
+
const deadline = Date.now() + deadlineMs;
|
|
608
|
+
for (;;) {
|
|
609
|
+
try {
|
|
610
|
+
const res = await fetchImpl(statusUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
611
|
+
if (res && res.ok) return true;
|
|
612
|
+
} catch {
|
|
613
|
+
// retry
|
|
614
|
+
}
|
|
615
|
+
if (Date.now() >= deadline || !stillStarting()) return false;
|
|
616
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* What answers on the relay's port: 'oar' (this relay: its status call accepts
|
|
622
|
+
* the configured token and has the relay's shape; `status` is its answer),
|
|
623
|
+
* 'taken' (something else answers, or holds the port without answering), or
|
|
624
|
+
* 'none'.
|
|
625
|
+
*/
|
|
626
|
+
async function readRelayStatus({ statusUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 2500 } = {}) {
|
|
627
|
+
if (typeof fetchImpl !== 'function' || !statusUrl) return { answer: 'none', status: null };
|
|
628
|
+
try {
|
|
629
|
+
const response = await fetchImpl(statusUrl, {
|
|
630
|
+
method: 'GET',
|
|
631
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
632
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
633
|
+
});
|
|
634
|
+
if (!response) return { answer: 'none', status: null };
|
|
635
|
+
if (!response.ok) return { answer: 'taken', status: null };
|
|
636
|
+
const body = await Promise.resolve(response.json?.()).catch(() => null);
|
|
637
|
+
return body && typeof body === 'object' && 'cliOnline' in body
|
|
638
|
+
? { answer: 'oar', status: body }
|
|
639
|
+
: { answer: 'taken', status: null };
|
|
640
|
+
} catch (error) {
|
|
641
|
+
// A port that accepts the connection and then stays silent is held too.
|
|
642
|
+
return { answer: error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'taken' : 'none', status: null };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function probeRelayPort(options = {}) {
|
|
647
|
+
return (await readRelayStatus(options)).answer;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* probeRelayPort with patience for the lock file. The relay itself says
|
|
652
|
+
* whether it runs: a lock file can outlive its relay (and its process id can
|
|
653
|
+
* pass to another program), and anything can answer on a port.
|
|
654
|
+
*/
|
|
655
|
+
async function probeRelayBehindLock({
|
|
656
|
+
statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl = isProcessAlive, lockGraceMs = 5000, logger = console,
|
|
657
|
+
} = {}) {
|
|
658
|
+
let answer = await probeRelayPort({ statusUrl, token, fetchImpl });
|
|
659
|
+
const lockedByLiveProcess = () => {
|
|
660
|
+
const lock = readRelayLock(lockPath);
|
|
661
|
+
return Number.isInteger(lock?.pid) && isProcessAliveImpl(lock.pid);
|
|
662
|
+
};
|
|
663
|
+
if (answer === 'none' && lockedByLiveProcess()) {
|
|
664
|
+
// A relay that is still starting holds the lock before it listens.
|
|
665
|
+
const deadline = Date.now() + lockGraceMs;
|
|
666
|
+
while (answer === 'none' && Date.now() < deadline) {
|
|
667
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
668
|
+
answer = await probeRelayPort({ statusUrl, token, fetchImpl });
|
|
669
|
+
}
|
|
670
|
+
if (answer === 'none' && lockedByLiveProcess()) {
|
|
671
|
+
// Nothing listens: the lock is left over from a relay that did not
|
|
672
|
+
// stop cleanly. The server refuses to start beside a lock whose process
|
|
673
|
+
// id is alive, so it goes first.
|
|
674
|
+
logger.log?.(`[oar] Removing a leftover relay lock (${lockPath}); no relay answers on port ${port}.`);
|
|
675
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return answer;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Makes sure the relay of a global install runs, without a Copilot session
|
|
683
|
+
* tied to it: an already running relay is left alone, an installed systemd
|
|
684
|
+
* user service is started, and anywhere else the server is started detached,
|
|
685
|
+
* so it outlives this command.
|
|
686
|
+
*/
|
|
687
|
+
export async function ensureRelayRunning({
|
|
688
|
+
packageRoot = resolvePackageRoot(),
|
|
689
|
+
layout,
|
|
690
|
+
configPath,
|
|
691
|
+
config,
|
|
692
|
+
cwd = os.homedir(),
|
|
693
|
+
env = process.env,
|
|
694
|
+
nodeBin = process.execPath,
|
|
695
|
+
spawnImpl = spawn,
|
|
696
|
+
spawnSyncImpl = spawnSync,
|
|
697
|
+
fetchImpl = globalThis.fetch,
|
|
698
|
+
isProcessAliveImpl = isProcessAlive,
|
|
699
|
+
lockGraceMs = 5000,
|
|
700
|
+
homeDir = os.homedir(),
|
|
701
|
+
platform = process.platform,
|
|
702
|
+
logger = console,
|
|
703
|
+
} = {}) {
|
|
704
|
+
const port = normalizePort(config?.port) ?? 3333;
|
|
705
|
+
const token = String(config?.authToken || '').trim();
|
|
706
|
+
const statusUrl = `http://localhost:${port}/api/status`;
|
|
707
|
+
const logDir = getLauncherLogDir(env, layout);
|
|
708
|
+
const lockPath = path.join(layout.dataDir, 'relay-server.lock');
|
|
709
|
+
const answer = await probeRelayBehindLock({
|
|
710
|
+
statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
|
|
711
|
+
});
|
|
712
|
+
if (answer === 'oar') return { ok: true, how: 'already', logDir };
|
|
713
|
+
if (answer === 'taken') {
|
|
714
|
+
logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
|
|
715
|
+
logger.error?.('[oar] Stop that program, or pick another port: oar setup --port <port>');
|
|
716
|
+
return { ok: false, how: 'port-taken', logDir };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (fs.existsSync(systemdUnitPath(homeDir)) && systemdUserAvailable({ platform, spawnSyncImpl })) {
|
|
720
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
|
|
721
|
+
if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
|
|
722
|
+
logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
|
|
723
|
+
return { ok: false, how: 'service', logDir };
|
|
724
|
+
}
|
|
725
|
+
return { ok: await waitForRelay({ statusUrl, token, fetchImpl }), how: 'service', logDir };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const serverDir = path.join(packageRoot, 'server');
|
|
729
|
+
const outFd = openAppendFileDescriptor(path.join(logDir, 'server.log'));
|
|
730
|
+
const errFd = openAppendFileDescriptor(path.join(logDir, 'server-err.log'));
|
|
731
|
+
let child = null;
|
|
504
732
|
try {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
733
|
+
child = spawnImpl(nodeBin, [path.join(serverDir, 'server.js')], {
|
|
734
|
+
cwd: layout.root,
|
|
735
|
+
env: {
|
|
736
|
+
...env,
|
|
737
|
+
COPILOT_WORKSPACE_ROOT: cwd,
|
|
738
|
+
COPILOT_WEB_RELAY_ROOT: packageRoot,
|
|
739
|
+
COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
|
|
740
|
+
COPILOT_WEB_RELAY_CONFIG: configPath,
|
|
741
|
+
COPILOT_WEB_RELAY_LOG_DIR: logDir,
|
|
742
|
+
COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
|
|
743
|
+
},
|
|
744
|
+
detached: true,
|
|
745
|
+
stdio: ['ignore', outFd, errFd],
|
|
746
|
+
windowsHide: true,
|
|
747
|
+
});
|
|
748
|
+
child.unref?.();
|
|
749
|
+
} catch (error) {
|
|
750
|
+
logger.error?.(`[oar] Failed to launch relay: ${error?.message || error}`);
|
|
751
|
+
return { ok: false, how: 'detached', logDir };
|
|
509
752
|
} finally {
|
|
510
|
-
|
|
753
|
+
try { fs.closeSync(outFd); } catch {}
|
|
754
|
+
try { fs.closeSync(errFd); } catch {}
|
|
511
755
|
}
|
|
756
|
+
const ok = await waitForRelay({ statusUrl, token, fetchImpl, stillStarting: () => child.exitCode === null });
|
|
757
|
+
return { ok, how: 'detached', logDir };
|
|
512
758
|
}
|
|
513
759
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
760
|
+
/**
|
|
761
|
+
* The injectable surroundings of a command: every command builds one from its
|
|
762
|
+
* options, so tests replace the network, the processes and the terminal.
|
|
763
|
+
*/
|
|
764
|
+
function cliContext(options = {}) {
|
|
765
|
+
const defaults = {
|
|
766
|
+
argv: [],
|
|
767
|
+
env: process.env,
|
|
768
|
+
cwd: process.cwd(),
|
|
769
|
+
logger: console,
|
|
770
|
+
packageRoot: resolvePackageRoot(),
|
|
771
|
+
platform: process.platform,
|
|
772
|
+
homeDir: os.homedir(),
|
|
773
|
+
nodeBin: process.execPath,
|
|
774
|
+
stdin: process.stdin,
|
|
775
|
+
stdout: process.stdout,
|
|
776
|
+
fetchImpl: globalThis.fetch,
|
|
777
|
+
spawnImpl: spawn,
|
|
778
|
+
spawnSyncImpl: spawnSync,
|
|
779
|
+
isProcessAliveImpl: isProcessAlive,
|
|
780
|
+
isPortFreeImpl: isPortFree,
|
|
781
|
+
readFileImpl: fs.readFileSync,
|
|
782
|
+
qrImpl: renderQr,
|
|
783
|
+
restartWaitMs: 60_000,
|
|
784
|
+
stopWaitMs: 30_000,
|
|
785
|
+
pollMs: 300,
|
|
786
|
+
};
|
|
787
|
+
const ctx = { ...defaults };
|
|
788
|
+
for (const [key, value] of Object.entries(options)) {
|
|
789
|
+
if (value !== undefined) ctx[key] = value;
|
|
790
|
+
}
|
|
791
|
+
ctx.prompter = ctx.prompter || createPrompter({ input: ctx.stdin, output: ctx.stdout });
|
|
792
|
+
return ctx;
|
|
793
|
+
}
|
|
518
794
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
795
|
+
async function renderQr(url) {
|
|
796
|
+
try {
|
|
797
|
+
const qrcode = (await import('qrcode-terminal')).default;
|
|
798
|
+
return await new Promise((resolve) => qrcode.generate(url, { small: true }, resolve));
|
|
799
|
+
} catch {
|
|
800
|
+
// The QR code is a convenience; the URL is the contract.
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Where this install keeps its config and its relay's state. */
|
|
806
|
+
function installPaths(ctx) {
|
|
807
|
+
const layout = resolveStateLayout({ packageRoot: ctx.packageRoot, env: ctx.env });
|
|
808
|
+
const serverDir = path.join(ctx.packageRoot, 'server');
|
|
809
|
+
const configPath = path.join(layout.checkout ? serverDir : layout.configDir, 'config.json');
|
|
810
|
+
const dataDir = layout.checkout ? path.join(serverDir, 'data') : layout.dataDir;
|
|
811
|
+
return {
|
|
812
|
+
layout,
|
|
813
|
+
configPath,
|
|
814
|
+
dataDir,
|
|
815
|
+
logDir: getLauncherLogDir(ctx.env, layout),
|
|
816
|
+
lockPath: path.join(dataDir, 'relay-server.lock'),
|
|
817
|
+
previousPath: path.join(dataDir, 'relay-previous.json'),
|
|
818
|
+
config: readJsonFile(configPath),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function endpointOf(config) {
|
|
823
|
+
const port = normalizePort(config?.port) ?? 3333;
|
|
824
|
+
return { port, token: String(config?.authToken || '').trim(), statusUrl: `http://localhost:${port}/api/status` };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Finds this install's relay. It answers on the config's port with the
|
|
829
|
+
* config's token, except between a settings change and its restart: then it
|
|
830
|
+
* still answers on the port and token it was started with, which `oar setup`
|
|
831
|
+
* keeps in `relay-previous.json` until the relay has restarted (`stale`).
|
|
832
|
+
*/
|
|
833
|
+
async function findRelay(ctx, paths) {
|
|
834
|
+
const endpoint = endpointOf(paths.config);
|
|
835
|
+
const found = await readRelayStatus({ ...endpoint, fetchImpl: ctx.fetchImpl });
|
|
836
|
+
const previous = readJsonFile(paths.previousPath);
|
|
837
|
+
if (previous) {
|
|
838
|
+
if (found.answer !== 'oar') {
|
|
839
|
+
const before = endpointOf(previous);
|
|
840
|
+
const earlier = await readRelayStatus({ ...before, fetchImpl: ctx.fetchImpl });
|
|
841
|
+
if (earlier.answer === 'oar') return { ...earlier, endpoint: before, stale: true };
|
|
842
|
+
}
|
|
843
|
+
try { fs.unlinkSync(paths.previousPath); } catch {}
|
|
844
|
+
}
|
|
845
|
+
return { ...found, endpoint, stale: false };
|
|
846
|
+
}
|
|
524
847
|
|
|
848
|
+
function relayIsIdle(status) {
|
|
849
|
+
return (status?.relayShutdown?.status || 'idle') === 'idle';
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function pollUntil(check, { waitMs, pollMs }) {
|
|
853
|
+
const deadline = Date.now() + waitMs;
|
|
854
|
+
for (;;) {
|
|
855
|
+
if (await check()) return true;
|
|
856
|
+
if (Date.now() >= deadline) return false;
|
|
857
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Asks the relay to stop or restart itself; it does so once no turn is running. */
|
|
862
|
+
async function requestShutdown(ctx, endpoint, restart) {
|
|
863
|
+
try {
|
|
864
|
+
const response = await ctx.fetchImpl(`http://localhost:${endpoint.port}/api/relay/shutdown`, {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${endpoint.token}` },
|
|
867
|
+
body: JSON.stringify({ restart, reason: 'oar-cli', requestedBy: 'oar-cli' }),
|
|
868
|
+
signal: AbortSignal.timeout(5000),
|
|
869
|
+
});
|
|
870
|
+
if (!response?.ok) return null;
|
|
871
|
+
const queue = (await Promise.resolve(response.json?.()).catch(() => null))?.queue || {};
|
|
872
|
+
const busy = ['pendingCount', 'processingCount', 'parkedCount'].some((key) => Number(queue[key]) > 0);
|
|
873
|
+
if (busy) ctx.logger.log('[oar] Waiting for the relay\'s running turns to finish …');
|
|
874
|
+
return { busy };
|
|
875
|
+
} catch {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Restarts this install's running relay so that it reads the config again.
|
|
882
|
+
* The service is restarted by systemd. Any other relay is asked on the port
|
|
883
|
+
* and with the token it runs with (`old`): its supervisor starts a new runtime
|
|
884
|
+
* with the same arguments and environment, and that runtime reads the config
|
|
885
|
+
* file, so a new port, token or access setting applies. Done when the relay
|
|
886
|
+
* answers on the config's port with the config's token.
|
|
887
|
+
*/
|
|
888
|
+
async function restartRelay(ctx, paths, { old, viaService }) {
|
|
889
|
+
const { logger, fetchImpl } = ctx;
|
|
890
|
+
const next = endpointOf(paths.config);
|
|
891
|
+
if (viaService) {
|
|
892
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
893
|
+
if (!runQuiet('systemctl', ['--user', 'restart', 'oar'], ctx)) {
|
|
894
|
+
logger.error('[oar] systemctl --user restart oar failed — see: systemctl --user status oar');
|
|
895
|
+
return { ok: false, reason: 'service' };
|
|
896
|
+
}
|
|
897
|
+
} else if (!(await requestShutdown(ctx, old, true))) {
|
|
898
|
+
logger.error('[oar] The relay did not accept the restart request. Restart it from the web UI (⋯ → Restart web relay).');
|
|
899
|
+
return { ok: false, reason: 'restart-refused' };
|
|
900
|
+
}
|
|
901
|
+
const up = await pollUntil(async () => {
|
|
902
|
+
const { answer, status } = await readRelayStatus({ ...next, fetchImpl });
|
|
903
|
+
return answer === 'oar' && relayIsIdle(status);
|
|
904
|
+
}, { waitMs: ctx.restartWaitMs, pollMs: ctx.pollMs });
|
|
905
|
+
if (up) {
|
|
906
|
+
try { fs.unlinkSync(paths.previousPath); } catch {}
|
|
907
|
+
logger.log('[oar] The relay restarted.');
|
|
908
|
+
return { ok: true, reason: 'restarted' };
|
|
909
|
+
}
|
|
910
|
+
const before = await readRelayStatus({ ...old, fetchImpl });
|
|
911
|
+
if (before.answer === 'oar' && !relayIsIdle(before.status)) {
|
|
912
|
+
logger.log('[oar] The relay is still busy; it restarts by itself when its running turns finish.');
|
|
913
|
+
return { ok: true, reason: 'restart-queued' };
|
|
914
|
+
}
|
|
915
|
+
if (before.answer === 'oar' && (old.port !== next.port || old.token !== next.token)) {
|
|
916
|
+
logger.error('[oar] The relay came back on its earlier port and token: it was started with fixed ones (oar copilot). End that session, then run: oar start');
|
|
917
|
+
return { ok: false, reason: 'fixed-settings' };
|
|
918
|
+
}
|
|
919
|
+
logger.error(`[oar] The relay did not come back — see ${path.join(paths.logDir, 'server-err.log')}`);
|
|
920
|
+
return { ok: false, reason: 'server-not-ready' };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function windowsAutostart(ctx, paths) {
|
|
924
|
+
return ctx.windowsAutostartImpl || createWindowsAutostartService({
|
|
925
|
+
platform: ctx.platform, env: ctx.env, packageRoot: ctx.packageRoot, nodePath: ctx.nodeBin, configPath: paths.configPath,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Windows can also start the relay at boot, through a scheduled task the web
|
|
930
|
+
// UI sets up (it needs an elevation); the command line only reports that one.
|
|
931
|
+
async function windowsBootTaskReady(ctx, paths) {
|
|
932
|
+
try {
|
|
933
|
+
const service = ctx.windowsBootAutostartImpl || createWindowsBootAutostartService({
|
|
934
|
+
platform: ctx.platform, env: ctx.env, packageRoot: ctx.packageRoot, nodePath: ctx.nodeBin, configPath: paths.configPath,
|
|
935
|
+
});
|
|
936
|
+
return (await service.getState())?.taskStatus === 'ready';
|
|
937
|
+
} catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** 'active' (systemd runs the relay), 'installed', 'none', or 'unavailable' on this host. */
|
|
943
|
+
async function serviceState(ctx, paths) {
|
|
944
|
+
if (paths.layout.checkout) return 'unavailable';
|
|
945
|
+
if (ctx.platform === 'win32') {
|
|
946
|
+
try {
|
|
947
|
+
return windowsAutostart(ctx, paths).getState().enabled || await windowsBootTaskReady(ctx, paths) ? 'installed' : 'none';
|
|
948
|
+
} catch {
|
|
949
|
+
return 'unavailable';
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (!systemdUserAvailable(ctx)) return 'unavailable';
|
|
953
|
+
if (!fs.existsSync(systemdUnitPath(ctx.homeDir))) return 'none';
|
|
954
|
+
return runQuiet('systemctl', ['--user', 'is-active', '--quiet', 'oar'], ctx) ? 'active' : 'installed';
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function writeSystemdUnit(ctx, paths) {
|
|
958
|
+
const unitPath = systemdUnitPath(ctx.homeDir);
|
|
959
|
+
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
|
|
960
|
+
fs.writeFileSync(unitPath, buildSystemdUnit({
|
|
961
|
+
nodeBin: ctx.nodeBin,
|
|
962
|
+
packageRoot: ctx.packageRoot,
|
|
963
|
+
configPath: paths.configPath,
|
|
964
|
+
dataDir: paths.dataDir,
|
|
965
|
+
logDir: paths.logDir,
|
|
966
|
+
pathEnv: buildServicePath({ nodeBin: ctx.nodeBin, envPath: ctx.env.PATH, homeDir: ctx.homeDir }),
|
|
967
|
+
}));
|
|
968
|
+
ctx.logger.log(`[oar] Wrote ${unitPath}`);
|
|
969
|
+
// Lingering keeps the user's services up while nobody is logged in;
|
|
970
|
+
// without it the relay of a headless host stops at logout.
|
|
971
|
+
if (!runQuiet('loginctl', ['enable-linger', ctx.userName || os.userInfo().username], ctx)) {
|
|
972
|
+
ctx.logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function startRelay(ctx, paths) {
|
|
977
|
+
return ensureRelayRunning({
|
|
978
|
+
packageRoot: ctx.packageRoot,
|
|
979
|
+
layout: paths.layout,
|
|
980
|
+
configPath: paths.configPath,
|
|
981
|
+
config: paths.config,
|
|
982
|
+
cwd: ctx.cwd,
|
|
983
|
+
env: ctx.env,
|
|
984
|
+
nodeBin: ctx.nodeBin,
|
|
985
|
+
spawnImpl: ctx.spawnImpl,
|
|
986
|
+
spawnSyncImpl: ctx.spawnSyncImpl,
|
|
987
|
+
fetchImpl: ctx.fetchImpl,
|
|
988
|
+
isProcessAliveImpl: ctx.isProcessAliveImpl,
|
|
989
|
+
lockGraceMs: ctx.lockGraceMs,
|
|
990
|
+
homeDir: ctx.homeDir,
|
|
991
|
+
platform: ctx.platform,
|
|
992
|
+
logger: ctx.logger,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Prints the relay URL and what goes with it. A QR code is printed only for
|
|
998
|
+
* an address a phone can reach, and only where `qr` asks for one.
|
|
999
|
+
*/
|
|
1000
|
+
async function showAddress(ctx, paths, { qr = false, tunnelBase = '' } = {}) {
|
|
1001
|
+
const { logger } = ctx;
|
|
1002
|
+
const lan = paths.config.localhostOnly === false;
|
|
1003
|
+
const address = describeRelayAddress({
|
|
1004
|
+
config: paths.config,
|
|
1005
|
+
lanAddress: primaryLanAddress(ctx.interfaces),
|
|
1006
|
+
wslNat: lan && isWslNat(ctx),
|
|
1007
|
+
tunnelBase,
|
|
1008
|
+
});
|
|
1009
|
+
const printQr = async (url) => {
|
|
1010
|
+
const code = qr && url ? await ctx.qrImpl(url) : null;
|
|
1011
|
+
if (code) logger.log(code);
|
|
1012
|
+
};
|
|
1013
|
+
logger.log('');
|
|
1014
|
+
logger.log(address.lines[0]);
|
|
1015
|
+
await printQr(address.qrUrl);
|
|
1016
|
+
for (const line of address.lines.slice(1)) logger.log(line);
|
|
1017
|
+
if (address.tunnelUrl) {
|
|
1018
|
+
logger.log(`[oar] Tunnel URL: ${address.tunnelUrl}`);
|
|
1019
|
+
await printQr(address.tunnelUrl);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function warnWindowsPort(ctx, port, windowsHolder = createWindowsPortCheck(ctx)) {
|
|
1024
|
+
const holder = windowsHolder(port);
|
|
1025
|
+
if (holder) ctx.logger.log(`[oar] ${windowsPortWarning(port, holder)}`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function reportRelayStart(result, logger) {
|
|
1029
|
+
// A port held by something else was already explained.
|
|
1030
|
+
if (result.how === 'port-taken') return;
|
|
1031
|
+
if (!result.ok) {
|
|
1032
|
+
logger.error?.(`[oar] The relay did not come up — see ${path.join(result.logDir, 'server-err.log')}`);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (result.how === 'already') {
|
|
1036
|
+
logger.log('[oar] The relay is already running. To load a new version, restart it: oar restart');
|
|
1037
|
+
} else if (result.how === 'service') {
|
|
1038
|
+
logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
|
|
1039
|
+
} else {
|
|
1040
|
+
logger.log(`[oar] The relay is running in the background (log: ${path.join(result.logDir, 'server.log')}). After a reboot, start it again with: oar start`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function requireConfig(ctx, paths) {
|
|
1045
|
+
if (paths.config) return null;
|
|
1046
|
+
ctx.logger.error('[oar] No config yet — run: oar setup');
|
|
1047
|
+
return { code: 1, reason: 'no-config' };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function reportPortTaken(ctx, paths, port) {
|
|
1051
|
+
ctx.logger.error(`[oar] Port ${port} is held by another program, or by a relay with another token.`);
|
|
1052
|
+
// A relay that was started before its token was changed in the config
|
|
1053
|
+
// cannot be asked to restart: only its lock tells that it is there.
|
|
1054
|
+
const pid = readRelayLock(paths.lockPath)?.pid;
|
|
1055
|
+
if (Number.isInteger(pid) && ctx.isProcessAliveImpl(pid)) {
|
|
1056
|
+
ctx.logger.error(`[oar] A relay of this install seems to run (process ${pid}). If its token is an earlier one, restart it from its web UI (⋯ → Restart web relay), or end that process and run: oar start`);
|
|
1057
|
+
}
|
|
1058
|
+
return { code: 1, reason: 'port-taken' };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** `oar start` — the relay alone, in the background, without a Copilot session. */
|
|
1062
|
+
export async function runStart(options = {}) {
|
|
1063
|
+
const ctx = cliContext(options);
|
|
1064
|
+
const paths = installPaths(ctx);
|
|
1065
|
+
const { logger } = ctx;
|
|
1066
|
+
if (paths.layout.checkout) {
|
|
1067
|
+
logger.error('[oar] This is a git checkout — start the relay with: npm start');
|
|
1068
|
+
return { code: 1, reason: 'git-checkout' };
|
|
1069
|
+
}
|
|
1070
|
+
const missing = requireConfig(ctx, paths);
|
|
1071
|
+
if (missing) return missing;
|
|
1072
|
+
if ((await findRelay(ctx, paths)).stale) {
|
|
1073
|
+
logger.log('[oar] The relay is running with its earlier settings. Apply the new ones with: oar restart');
|
|
1074
|
+
return { code: 0, reason: 'started' };
|
|
1075
|
+
}
|
|
1076
|
+
const result = await startRelay(ctx, paths);
|
|
1077
|
+
reportRelayStart(result, logger);
|
|
1078
|
+
if (result.ok) await showAddress(ctx, paths);
|
|
1079
|
+
warnWindowsPort(ctx, endpointOf(paths.config).port);
|
|
1080
|
+
return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** `oar stop` — systemd stops the service; any other relay stops itself once no turn is running. */
|
|
1084
|
+
export async function runStop(options = {}) {
|
|
1085
|
+
const ctx = cliContext(options);
|
|
1086
|
+
const paths = installPaths(ctx);
|
|
1087
|
+
const { logger } = ctx;
|
|
1088
|
+
const missing = requireConfig(ctx, paths);
|
|
1089
|
+
if (missing) return missing;
|
|
1090
|
+
if ((await serviceState(ctx, paths)) === 'active') {
|
|
1091
|
+
if (!runQuiet('systemctl', ['--user', 'stop', 'oar'], ctx)) {
|
|
1092
|
+
logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
|
|
1093
|
+
return { code: 1, reason: 'service' };
|
|
1094
|
+
}
|
|
1095
|
+
logger.log('[oar] The relay service is stopped. It starts again at the next login; to take it out: oar service remove');
|
|
1096
|
+
return { code: 0, reason: 'stopped' };
|
|
1097
|
+
}
|
|
1098
|
+
const relay = await findRelay(ctx, paths);
|
|
1099
|
+
if (relay.answer === 'none') {
|
|
1100
|
+
logger.log('[oar] The relay is not running.');
|
|
1101
|
+
return { code: 0, reason: 'not-running' };
|
|
1102
|
+
}
|
|
1103
|
+
if (relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
|
|
1104
|
+
if (!(await requestShutdown(ctx, relay.endpoint, false))) {
|
|
1105
|
+
logger.error('[oar] The relay did not accept the stop request.');
|
|
1106
|
+
return { code: 1, reason: 'stop-refused' };
|
|
1107
|
+
}
|
|
1108
|
+
const closed = await pollUntil(
|
|
1109
|
+
async () => (await probeRelayPort({ ...relay.endpoint, fetchImpl: ctx.fetchImpl })) === 'none',
|
|
1110
|
+
{ waitMs: ctx.stopWaitMs, pollMs: ctx.pollMs },
|
|
1111
|
+
);
|
|
1112
|
+
logger.log(closed ? '[oar] The relay is stopped.' : '[oar] The relay is still busy; it stops by itself when its running turns finish.');
|
|
1113
|
+
return { code: 0, reason: closed ? 'stopped' : 'stop-queued' };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** `oar restart` — restarts a running relay, starts a stopped one. */
|
|
1117
|
+
export async function runRestart(options = {}) {
|
|
1118
|
+
const ctx = cliContext(options);
|
|
1119
|
+
const paths = installPaths(ctx);
|
|
1120
|
+
const missing = requireConfig(ctx, paths);
|
|
1121
|
+
if (missing) return missing;
|
|
1122
|
+
const viaService = (await serviceState(ctx, paths)) === 'active';
|
|
1123
|
+
const relay = await findRelay(ctx, paths);
|
|
1124
|
+
if (!viaService && relay.answer === 'none') return runStart(options);
|
|
1125
|
+
if (!viaService && relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
|
|
1126
|
+
const outcome = await restartRelay(ctx, paths, { old: relay.endpoint, viaService });
|
|
1127
|
+
if (outcome.ok) await showAddress(ctx, paths);
|
|
1128
|
+
return { code: outcome.ok ? 0 : 1, reason: outcome.reason };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** What `oar status` and the menu's header show. */
|
|
1132
|
+
async function collectStatus(ctx, paths = installPaths(ctx)) {
|
|
1133
|
+
const status = {
|
|
1134
|
+
version: readPackageVersion(ctx.packageRoot),
|
|
1135
|
+
relay: 'unconfigured',
|
|
1136
|
+
service: await serviceState(ctx, paths),
|
|
1137
|
+
warnings: [],
|
|
1138
|
+
};
|
|
1139
|
+
if (!paths.config) return status;
|
|
1140
|
+
const relay = await findRelay(ctx, paths);
|
|
1141
|
+
const lan = paths.config.localhostOnly === false;
|
|
1142
|
+
const { port } = relay.endpoint;
|
|
1143
|
+
status.relay = { oar: 'running', taken: 'taken', none: 'stopped' }[relay.answer];
|
|
1144
|
+
status.port = port;
|
|
1145
|
+
status.access = lan ? 'lan' : 'local';
|
|
1146
|
+
// Without the token: the full URL is `oar url`'s to print.
|
|
1147
|
+
status.url = `http://${(lan && primaryLanAddress(ctx.interfaces)) || 'localhost'}:${port}/`;
|
|
1148
|
+
if (relay.stale) status.warnings.push('The relay runs with its earlier settings; restart it to apply the new ones.');
|
|
1149
|
+
// The built-in tunnel, only when the config switches it on: a running
|
|
1150
|
+
// relay reports its state, a stopped one is judged from the config.
|
|
1151
|
+
const tunnel = relay.answer === 'oar' && relay.status?.cloudflaredTunnel
|
|
1152
|
+
? describeTunnelState(relay.status.cloudflaredTunnel)
|
|
1153
|
+
: describeConfiguredTunnel(paths.config.cloudflaredTunnel, {
|
|
1154
|
+
env: ctx.env, platform: ctx.platform, configBaseDir: path.join(ctx.packageRoot, 'server'),
|
|
1155
|
+
});
|
|
1156
|
+
status.tunnel = tunnel === 'off' || tunnel === 'disabled' ? '' : tunnel;
|
|
1157
|
+
const holder = createWindowsPortCheck(ctx)(port);
|
|
1158
|
+
if (holder) status.warnings.push(windowsPortWarning(port, holder));
|
|
1159
|
+
return status;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** `oar status` — exit code 0 when the relay runs, 3 when it does not. */
|
|
1163
|
+
export async function runStatus(options = {}) {
|
|
1164
|
+
const ctx = cliContext(options);
|
|
1165
|
+
const { logger } = ctx;
|
|
1166
|
+
const status = await collectStatus(ctx);
|
|
1167
|
+
logger.log(`[oar] OAR ${status.version}`);
|
|
1168
|
+
if (status.relay === 'unconfigured') {
|
|
1169
|
+
logger.log('[oar] Not set up yet — run: oar setup');
|
|
1170
|
+
return { code: 3, reason: 'no-config' };
|
|
1171
|
+
}
|
|
1172
|
+
const access = status.access === 'lan' ? 'LAN access' : 'this machine only';
|
|
1173
|
+
if (status.relay === 'running') logger.log(`[oar] The relay is running on port ${status.port} (${access}): ${status.url}`);
|
|
1174
|
+
else if (status.relay === 'stopped') logger.log(`[oar] The relay is stopped (port ${status.port}, ${access}). Start it with: oar start`);
|
|
1175
|
+
else logger.log(`[oar] Port ${status.port} is held by another program, or by a relay with another token.`);
|
|
1176
|
+
if (status.service !== 'unavailable') {
|
|
1177
|
+
logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed' }[status.service]}`);
|
|
1178
|
+
}
|
|
1179
|
+
if (status.tunnel) logger.log(`[oar] Tunnel: ${status.tunnel}`);
|
|
1180
|
+
for (const warning of status.warnings) logger.log(`[oar] ${warning}`);
|
|
1181
|
+
return { code: status.relay === 'running' ? 0 : 3, reason: `relay-${status.relay}` };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** `oar url` — the relay URL, a QR code where a phone can reach it, and the tunnel address a running relay reports. */
|
|
1185
|
+
export async function runUrl(options = {}) {
|
|
1186
|
+
const ctx = cliContext(options);
|
|
1187
|
+
const paths = installPaths(ctx);
|
|
1188
|
+
const missing = requireConfig(ctx, paths);
|
|
1189
|
+
if (missing) return missing;
|
|
1190
|
+
const relay = await findRelay(ctx, paths);
|
|
1191
|
+
const remoteUrl = String(relay.status?.readyBanner?.remoteUrl || '').replace(/\/+$/, '');
|
|
1192
|
+
await showAddress(ctx, paths, { qr: true, tunnelBase: remoteUrl ? `${remoteUrl}${relay.status.remotePath || ''}` : '' });
|
|
1193
|
+
if (relay.stale) ctx.logger.log('[oar] The relay runs with its earlier settings until it restarts: oar restart');
|
|
1194
|
+
else if (relay.answer !== 'oar') ctx.logger.log('[oar] The relay is not running. Start it with: oar start');
|
|
1195
|
+
return { code: 0, reason: 'url' };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function runWindowsService(ctx, paths, action) {
|
|
1199
|
+
const { logger } = ctx;
|
|
1200
|
+
const autostart = windowsAutostart(ctx, paths);
|
|
1201
|
+
const boot = await windowsBootTaskReady(ctx, paths);
|
|
1202
|
+
if (action === 'status') {
|
|
1203
|
+
const state = boot ? 'on, at boot (scheduled task)' : autostart.getState().enabled ? 'on, at sign-in' : 'off';
|
|
1204
|
+
logger.log(`[oar] Autostart: ${state}`);
|
|
1205
|
+
return { code: 0, reason: 'service-status' };
|
|
1206
|
+
}
|
|
1207
|
+
if (boot) {
|
|
1208
|
+
logger.error('[oar] The relay starts at boot through a scheduled task. Change that in the web UI: Settings → Autostart.');
|
|
1209
|
+
return { code: 1, reason: 'boot-task' };
|
|
1210
|
+
}
|
|
1211
|
+
if (action === 'install' && requireConfig(ctx, paths)) return { code: 1, reason: 'no-config' };
|
|
1212
|
+
autostart.setEnabled(action === 'install');
|
|
1213
|
+
logger.log(action === 'install'
|
|
1214
|
+
? '[oar] The relay now starts when you sign in to Windows. Start it now with: oar start'
|
|
1215
|
+
: '[oar] Autostart is off. A running relay keeps running; stop it with: oar stop');
|
|
1216
|
+
return { code: 0, reason: `service-${action}` };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** `oar service install|remove|status` — the systemd user service on Linux, the autostart entry on Windows. */
|
|
1220
|
+
export async function runService(options = {}) {
|
|
1221
|
+
const ctx = cliContext(options);
|
|
1222
|
+
const paths = installPaths(ctx);
|
|
1223
|
+
const { logger } = ctx;
|
|
1224
|
+
const action = ctx.argv.find((arg) => ['install', 'remove', 'status'].includes(arg)) || 'status';
|
|
1225
|
+
if (paths.layout.checkout) {
|
|
1226
|
+
logger.error('[oar] This is a git checkout — the service is for global installs.');
|
|
1227
|
+
return { code: 1, reason: 'git-checkout' };
|
|
1228
|
+
}
|
|
1229
|
+
if (ctx.platform === 'win32') return runWindowsService(ctx, paths, action);
|
|
1230
|
+
if (ctx.platform !== 'linux') {
|
|
1231
|
+
logger.error(`[oar] The service is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
|
|
1232
|
+
return { code: 1, reason: 'unsupported' };
|
|
1233
|
+
}
|
|
1234
|
+
if (!systemdUserAvailable(ctx)) {
|
|
1235
|
+
const wslHint = isWsl(ctx) ? ' In WSL, switch systemd on: systemd=true under [boot] in /etc/wsl.conf, then restart the distro (from Windows: wsl --terminate <distro>).' : '';
|
|
1236
|
+
logger.error(`[oar] No systemd user session here, so there is no service to install.${wslHint} Start the relay with: oar start`);
|
|
1237
|
+
return { code: 1, reason: 'no-systemd' };
|
|
1238
|
+
}
|
|
1239
|
+
const unitPath = systemdUnitPath(ctx.homeDir);
|
|
1240
|
+
const state = await serviceState(ctx, paths);
|
|
1241
|
+
if (action === 'status') {
|
|
1242
|
+
logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed, not running', none: 'not installed' }[state]}`);
|
|
1243
|
+
return { code: 0, reason: 'service-status' };
|
|
1244
|
+
}
|
|
1245
|
+
if (action === 'remove') {
|
|
1246
|
+
if (state === 'none') {
|
|
1247
|
+
logger.log('[oar] The service is not installed.');
|
|
1248
|
+
return { code: 0, reason: 'service-remove' };
|
|
1249
|
+
}
|
|
1250
|
+
runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
|
|
1251
|
+
try { fs.unlinkSync(unitPath); } catch {}
|
|
1252
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
1253
|
+
logger.log(`[oar] Removed ${unitPath}; the relay is stopped. Start it by hand with: oar start`);
|
|
1254
|
+
return { code: 0, reason: 'service-remove' };
|
|
1255
|
+
}
|
|
1256
|
+
const missing = requireConfig(ctx, paths);
|
|
1257
|
+
if (missing) return missing;
|
|
1258
|
+
writeSystemdUnit(ctx, paths);
|
|
1259
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
1260
|
+
if (state !== 'active' && (await findRelay(ctx, paths)).answer === 'oar') {
|
|
1261
|
+
// A relay started by hand holds the port and the lock: it makes way first.
|
|
1262
|
+
if ((await runStop({ ...options, argv: [] })).reason !== 'stopped') {
|
|
1263
|
+
runQuiet('systemctl', ['--user', 'enable', 'oar'], ctx);
|
|
1264
|
+
logger.log('[oar] The service is installed and takes over at the next login, or once the relay has stopped: oar start');
|
|
1265
|
+
return { code: 0, reason: 'service-install' };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const result = await startRelay(ctx, paths);
|
|
1269
|
+
reportRelayStart(result, logger);
|
|
1270
|
+
return { code: result.ok ? 0 : 1, reason: result.ok ? 'service-install' : 'server-not-ready' };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function optionValue(argv, name) {
|
|
1274
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1275
|
+
if (argv[index] === name) return String(argv[index + 1] ?? '');
|
|
1276
|
+
if (String(argv[index]).startsWith(`${name}=`)) return String(argv[index]).slice(name.length + 1);
|
|
1277
|
+
}
|
|
1278
|
+
return undefined;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function strictPort(text) {
|
|
1282
|
+
return /^\d+$/.test(String(text).trim()) ? normalizePort(text) : null;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* `oar setup` — writes the config: auth token, access (this machine only or
|
|
1287
|
+
* LAN) and port. In a terminal it asks, showing the current value; `--port`,
|
|
1288
|
+
* `--lan`, `--local` and `--new-token` set a value without a question, and
|
|
1289
|
+
* `--defaults` keeps (or, for a new config, picks) everything. A running relay
|
|
1290
|
+
* is restarted when one of the three changed. `--start` is the installer's
|
|
1291
|
+
* mode: it also installs the service where it can and starts the relay.
|
|
1292
|
+
*/
|
|
1293
|
+
export async function runSetup(options = {}) {
|
|
1294
|
+
const ctx = cliContext(options);
|
|
1295
|
+
const { argv, env, logger, prompter } = ctx;
|
|
1296
|
+
const has = (flag) => argv.includes(flag);
|
|
1297
|
+
const portText = optionValue(argv, '--port');
|
|
1298
|
+
const portFlag = portText === undefined ? null : strictPort(portText);
|
|
1299
|
+
if (portText !== undefined && !portFlag) {
|
|
1300
|
+
logger.error('[oar] --port expects a number from 1 to 65535.');
|
|
1301
|
+
return { code: 2, reason: 'bad-port' };
|
|
1302
|
+
}
|
|
1303
|
+
if (has('--lan') && has('--local')) {
|
|
1304
|
+
logger.error('[oar] --lan and --local exclude each other.');
|
|
1305
|
+
return { code: 2, reason: 'bad-access' };
|
|
1306
|
+
}
|
|
1307
|
+
const terminal = Boolean(ctx.stdin?.isTTY) && !has('--defaults');
|
|
1308
|
+
const ask = terminal && !portFlag && !has('--lan') && !has('--local') && !has('--new-token');
|
|
1309
|
+
|
|
1310
|
+
let paths = installPaths(ctx);
|
|
1311
|
+
const { layout } = paths;
|
|
525
1312
|
if (!layout.checkout) {
|
|
1313
|
+
const migrateFromRoot = String(optionValue(argv, '--migrate-from') || '').trim();
|
|
526
1314
|
const migration = await migrateStateToOarRoot({
|
|
527
1315
|
targetRoot: layout.root,
|
|
528
1316
|
repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
|
|
@@ -535,106 +1323,123 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
|
|
|
535
1323
|
}
|
|
536
1324
|
if (migration.status === 'migrated') {
|
|
537
1325
|
logger.log(`[oar] Migrated existing relay state into ${layout.root} (sources untouched).`);
|
|
1326
|
+
paths = installPaths(ctx);
|
|
538
1327
|
}
|
|
539
1328
|
}
|
|
540
1329
|
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
1330
|
+
const existing = paths.config;
|
|
1331
|
+
const relay = existing ? await findRelay(ctx, paths) : null;
|
|
1332
|
+
const running = relay?.answer === 'oar';
|
|
1333
|
+
const old = relay?.endpoint || null;
|
|
1334
|
+
const windowsHolder = createWindowsPortCheck(ctx);
|
|
1335
|
+
// A port must be free here and, in WSL, on Windows; the one this install's
|
|
1336
|
+
// own running relay holds is fine.
|
|
1337
|
+
const portProblem = async (port) => {
|
|
1338
|
+
if (running && port === old.port) return null;
|
|
1339
|
+
if (!(await ctx.isPortFreeImpl(port))) return `Port ${port} is in use by another program.`;
|
|
1340
|
+
const holder = windowsHolder(port);
|
|
1341
|
+
return holder ? `Port ${port} is in use on Windows (${holder}).` : null;
|
|
1342
|
+
};
|
|
545
1343
|
|
|
546
1344
|
let token = String(existing?.authToken || '').trim();
|
|
547
|
-
if (token)
|
|
548
|
-
|
|
549
|
-
defaultYes: false,
|
|
550
|
-
interactive: !useDefaults,
|
|
551
|
-
});
|
|
552
|
-
if (regenerate) token = generateAuthToken();
|
|
553
|
-
} else {
|
|
1345
|
+
if (!token || has('--new-token')
|
|
1346
|
+
|| (ask && await prompter.yesNo('New auth token? It signs every device out.', { defaultYes: false }))) {
|
|
554
1347
|
token = generateAuthToken();
|
|
555
1348
|
}
|
|
556
1349
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1350
|
+
let lan = existing?.localhostOnly === false;
|
|
1351
|
+
if (has('--lan') || has('--local')) {
|
|
1352
|
+
lan = has('--lan');
|
|
1353
|
+
} else if (ask) {
|
|
1354
|
+
for (;;) {
|
|
1355
|
+
const answer = await prompter.line(`Access: 1 = this machine only, 2 = LAN (other devices on your network) [${lan ? 2 : 1}]: `);
|
|
1356
|
+
if (answer === '1' || answer === '2') lan = answer === '2';
|
|
1357
|
+
if (['', '1', '2'].includes(answer)) break;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
563
1360
|
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
interactive: !useDefaults,
|
|
586
|
-
})) {
|
|
587
|
-
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
588
|
-
const unitPath = path.join(unitDir, 'oar.service');
|
|
589
|
-
fs.mkdirSync(unitDir, { recursive: true });
|
|
590
|
-
fs.writeFileSync(unitPath, buildSystemdUnit({
|
|
591
|
-
nodeBin: process.execPath,
|
|
592
|
-
packageRoot,
|
|
593
|
-
configPath,
|
|
594
|
-
dataDir: layout.dataDir,
|
|
595
|
-
logDir: getLauncherLogDir(env, layout),
|
|
596
|
-
}));
|
|
597
|
-
logger.log(`[oar] Wrote ${unitPath} — enable it with: systemctl --user enable --now oar`);
|
|
1361
|
+
const currentPort = existing ? normalizePort(existing.port) : null;
|
|
1362
|
+
let port = portFlag;
|
|
1363
|
+
if (port) {
|
|
1364
|
+
const problem = await portProblem(port);
|
|
1365
|
+
if (problem) {
|
|
1366
|
+
logger.error(`[oar] ${problem} Pick another port.`);
|
|
1367
|
+
return { code: 1, reason: 'port-taken' };
|
|
1368
|
+
}
|
|
1369
|
+
} else {
|
|
1370
|
+
port = currentPort ?? await findFreePort(3333, { isPortFreeImpl: async (candidate) => !(await portProblem(candidate)) });
|
|
1371
|
+
if (!currentPort && port !== 3333) logger.log(`[oar] Port 3333 is in use${windowsHolder(3333) ? ' on Windows' : ''} — using ${port}.`);
|
|
1372
|
+
while (ask) {
|
|
1373
|
+
const answer = await prompter.line(`Port [${port}]: `);
|
|
1374
|
+
if (!answer) break;
|
|
1375
|
+
const chosen = strictPort(answer);
|
|
1376
|
+
const problem = !chosen ? 'A port is a number from 1 to 65535.' : chosen === currentPort ? null : await portProblem(chosen);
|
|
1377
|
+
if (!problem) {
|
|
1378
|
+
port = chosen;
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
logger.log(`[oar] ${problem}`);
|
|
598
1382
|
}
|
|
599
|
-
} else if (process.platform === 'win32') {
|
|
600
|
-
logger.log('[oar] Autostart on Windows: enable it in the web UI under Settings → Autostart.');
|
|
601
1383
|
}
|
|
602
1384
|
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1385
|
+
const config = { ...buildDefaultConfig({ token }), ...(existing || {}), authToken: token, localhostOnly: !lan, port };
|
|
1386
|
+
// What the running relay would have to pick up. Its own status says how it
|
|
1387
|
+
// listens, whatever the config said when it started.
|
|
1388
|
+
const endpointChanged = running && (token !== old.token || port !== old.port);
|
|
1389
|
+
const changed = endpointChanged || (running && lan !== (relay.status.localhostOnly === false));
|
|
1390
|
+
fs.mkdirSync(path.dirname(paths.configPath), { recursive: true });
|
|
1391
|
+
fs.writeFileSync(paths.configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1392
|
+
if (ctx.platform !== 'win32') {
|
|
1393
|
+
try { fs.chmodSync(paths.configPath, 0o600); } catch {}
|
|
608
1394
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1395
|
+
logger.log(`[oar] Wrote ${paths.configPath}`);
|
|
1396
|
+
paths = { ...paths, config };
|
|
1397
|
+
if (endpointChanged) {
|
|
1398
|
+
// Until the relay has restarted it answers only on its earlier port and token.
|
|
1399
|
+
fs.mkdirSync(paths.dataDir, { recursive: true });
|
|
1400
|
+
fs.writeFileSync(paths.previousPath, `${JSON.stringify({ port: old.port, authToken: old.token })}\n`, { mode: 0o600 });
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const startRequested = has('--start') && !layout.checkout;
|
|
1404
|
+
const viaService = running && (await serviceState(ctx, paths)) === 'active';
|
|
1405
|
+
if (startRequested && systemdUserAvailable(ctx)) writeSystemdUnit(ctx, paths);
|
|
1406
|
+
|
|
1407
|
+
let outcome = null;
|
|
1408
|
+
if (changed) {
|
|
1409
|
+
if (!terminal || await prompter.yesNo('The relay is running. Restart it now to apply the change?', { defaultYes: true })) {
|
|
1410
|
+
outcome = await restartRelay(ctx, paths, { old, viaService });
|
|
1411
|
+
} else {
|
|
1412
|
+
logger.log('[oar] The running relay keeps its earlier settings until it restarts: oar restart');
|
|
1413
|
+
}
|
|
1414
|
+
} else if (startRequested) {
|
|
1415
|
+
const started = await startRelay(ctx, paths);
|
|
1416
|
+
reportRelayStart(started, logger);
|
|
1417
|
+
outcome = { ok: started.ok, reason: started.ok ? 'setup-complete' : 'server-not-ready' };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
await showAddress(ctx, paths, { qr: true });
|
|
1421
|
+
warnWindowsPort(ctx, port, windowsHolder);
|
|
1422
|
+
if (outcome) return { code: outcome.ok ? 0 : 1, reason: outcome.ok ? 'setup-complete' : outcome.reason };
|
|
1423
|
+
if (layout.checkout) {
|
|
1424
|
+
logger.log('[oar] Start the relay with: npm start');
|
|
1425
|
+
} else if (!running) {
|
|
1426
|
+
const atLogin = (await serviceState(ctx, paths)) === 'none' ? ' (at every login: oar service install)' : '';
|
|
1427
|
+
logger.log(`[oar] Start the relay with: oar start${atLogin}`);
|
|
614
1428
|
}
|
|
615
|
-
logger.log('[oar] Start the relay with: oar');
|
|
616
1429
|
return { code: 0, reason: 'setup-complete' };
|
|
617
1430
|
}
|
|
618
1431
|
|
|
619
|
-
async function runDoctor(
|
|
620
|
-
const
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
const packageRoot = resolvePackageRoot();
|
|
624
|
-
const layout = resolveStateLayout({ packageRoot, env });
|
|
625
|
-
const configPath = layout.checkout
|
|
626
|
-
? path.join(packageRoot, 'server', 'config.json')
|
|
627
|
-
: path.join(layout.configDir, 'config.json');
|
|
628
|
-
const dbPath = layout.checkout
|
|
629
|
-
? path.join(packageRoot, 'server', 'data', 'copilot.db')
|
|
630
|
-
: path.join(layout.dataDir, 'copilot.db');
|
|
1432
|
+
export async function runDoctor(options = {}) {
|
|
1433
|
+
const ctx = cliContext(options);
|
|
1434
|
+
const paths = installPaths(ctx);
|
|
1435
|
+
const dbPath = path.join(paths.dataDir, 'copilot.db');
|
|
631
1436
|
|
|
632
1437
|
let dbSizeBytes = null;
|
|
633
1438
|
try { dbSizeBytes = fs.statSync(dbPath).size; } catch {}
|
|
634
1439
|
|
|
635
1440
|
const probes = DOCTOR_PROBES.map((probe) => {
|
|
636
1441
|
try {
|
|
637
|
-
const result =
|
|
1442
|
+
const result = ctx.spawnSyncImpl(probe.binary, probe.args, { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
638
1443
|
const ok = result.status === 0;
|
|
639
1444
|
const version = ok ? String(result.stdout || '').trim().split('\n')[0].slice(0, 60) : '';
|
|
640
1445
|
return { id: probe.id, ok, version };
|
|
@@ -644,16 +1449,21 @@ async function runDoctor({ env = process.env, logger = console } = {}) {
|
|
|
644
1449
|
});
|
|
645
1450
|
probes.push({ id: 'cursor', ok: true, version: 'bundled (@cursor/sdk)' });
|
|
646
1451
|
|
|
647
|
-
|
|
648
|
-
|
|
1452
|
+
const port = endpointOf(paths.config).port;
|
|
1453
|
+
const holder = paths.config ? createWindowsPortCheck(ctx)(port) : null;
|
|
1454
|
+
ctx.logger.log(renderDoctorReport({
|
|
1455
|
+
version: readPackageVersion(ctx.packageRoot),
|
|
649
1456
|
nodeVersion: process.version,
|
|
650
|
-
platform:
|
|
651
|
-
layout,
|
|
652
|
-
configPath,
|
|
653
|
-
config:
|
|
1457
|
+
platform: ctx.platform,
|
|
1458
|
+
layout: paths.layout,
|
|
1459
|
+
configPath: paths.configPath,
|
|
1460
|
+
config: paths.config,
|
|
654
1461
|
dbPath,
|
|
655
1462
|
dbSizeBytes,
|
|
656
1463
|
probes,
|
|
1464
|
+
warnings: holder ? [windowsPortWarning(port, holder)] : [],
|
|
1465
|
+
// The relay resolves a relative cloudflaredTunnel.binary against server/.
|
|
1466
|
+
tunnel: describeConfiguredTunnel(paths.config?.cloudflaredTunnel, { env: ctx.env, configBaseDir: path.join(ctx.packageRoot, 'server') }),
|
|
657
1467
|
}));
|
|
658
1468
|
return { code: 0, reason: 'doctor' };
|
|
659
1469
|
}
|
|
@@ -714,10 +1524,11 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
714
1524
|
}
|
|
715
1525
|
|
|
716
1526
|
logger.log(`[oar] Updating @oar-sh/oar ${runningVersion} -> ${target} ...`);
|
|
717
|
-
const
|
|
1527
|
+
const npm = resolveNpmInvocation({ packageRoot, env });
|
|
718
1528
|
const exitCode = await new Promise((resolve) => {
|
|
719
|
-
const child = spawnImpl(
|
|
1529
|
+
const child = spawnImpl(npm.command, ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `@oar-sh/oar@${target}`], {
|
|
720
1530
|
stdio: 'inherit',
|
|
1531
|
+
env: npm.env,
|
|
721
1532
|
...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}),
|
|
722
1533
|
});
|
|
723
1534
|
child.on('error', (error) => {
|
|
@@ -770,17 +1581,94 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
770
1581
|
return { code: 0, reason: 'updated' };
|
|
771
1582
|
}
|
|
772
1583
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
|
|
1584
|
+
/** `oar` without a command: the menu in a terminal, the status and the usage elsewhere. */
|
|
1585
|
+
async function runMenuCommand(options = {}) {
|
|
1586
|
+
const ctx = cliContext(options);
|
|
1587
|
+
if (!ctx.stdin?.isTTY || !ctx.stdout?.isTTY) {
|
|
1588
|
+
await runStatus(options);
|
|
1589
|
+
ctx.logger.log('');
|
|
1590
|
+
ctx.logger.log(usageText());
|
|
1591
|
+
return { code: 0, reason: 'status' };
|
|
779
1592
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1593
|
+
const run = (command) => () => command({ ...options, argv: [] });
|
|
1594
|
+
return runMenu({
|
|
1595
|
+
input: ctx.stdin,
|
|
1596
|
+
output: ctx.stdout,
|
|
1597
|
+
env: ctx.env,
|
|
1598
|
+
loadStatus: () => collectStatus(ctx),
|
|
1599
|
+
actions: {
|
|
1600
|
+
start: run(runStart),
|
|
1601
|
+
stop: run(runStop),
|
|
1602
|
+
restart: run(runRestart),
|
|
1603
|
+
url: run(runUrl),
|
|
1604
|
+
setup: run(runSetup),
|
|
1605
|
+
doctor: run(runDoctor),
|
|
1606
|
+
copilot: run(launchRelay),
|
|
1607
|
+
service: async (status) => {
|
|
1608
|
+
const action = { none: 'install', unavailable: 'status' }[status.service] || 'remove';
|
|
1609
|
+
if (action === 'remove' && !(await ctx.prompter.yesNo('Remove the service?', { defaultYes: false }))) return null;
|
|
1610
|
+
return runService({ ...options, argv: [action] });
|
|
1611
|
+
},
|
|
1612
|
+
// The files of the running menu were just replaced.
|
|
1613
|
+
update: async () => ((await runUpdate({ ...options, argv: [] })).reason === 'updated' ? { exit: true } : null),
|
|
1614
|
+
},
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const COMMANDS = {
|
|
1619
|
+
menu: runMenuCommand,
|
|
1620
|
+
start: runStart,
|
|
1621
|
+
stop: runStop,
|
|
1622
|
+
restart: runRestart,
|
|
1623
|
+
status: runStatus,
|
|
1624
|
+
url: runUrl,
|
|
1625
|
+
setup: runSetup,
|
|
1626
|
+
service: runService,
|
|
1627
|
+
copilot: launchRelay,
|
|
1628
|
+
doctor: runDoctor,
|
|
1629
|
+
update: runUpdate,
|
|
1630
|
+
help: async ({ logger = console } = {}) => {
|
|
1631
|
+
logger.log(usageText());
|
|
1632
|
+
return { code: 0, reason: 'help' };
|
|
1633
|
+
},
|
|
1634
|
+
version: async ({ logger = console } = {}) => {
|
|
1635
|
+
logger.log(readPackageVersion());
|
|
1636
|
+
return { code: 0, reason: 'version' };
|
|
1637
|
+
},
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Runs the command the arguments name. Anything `oar` does not know prints
|
|
1642
|
+
* the usage and exits 2; Ctrl+C in a prompt or in the menu exits 130.
|
|
1643
|
+
*/
|
|
1644
|
+
async function main(options = {}) {
|
|
1645
|
+
const logger = options.logger || console;
|
|
1646
|
+
const parsed = parseCliArgs(options.argv || process.argv.slice(2));
|
|
1647
|
+
if (parsed.error) {
|
|
1648
|
+
logger.error(`[oar] ${parsed.error}`);
|
|
1649
|
+
logger.error(usageText());
|
|
1650
|
+
return { code: 2, reason: 'usage' };
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
return await { ...COMMANDS, ...options.commands }[parsed.command]({ ...options, argv: parsed.args });
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if (error instanceof PromptAborted) return { code: 130, reason: 'interrupted' };
|
|
1656
|
+
throw error;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Ends the command with `code` once the event loop has nothing left to do.
|
|
1662
|
+
* `process.exit()` right after a request to the relay cuts into the
|
|
1663
|
+
* connection's teardown, and on Windows that aborts the process with a libuv
|
|
1664
|
+
* assertion instead of the exit code. The timer is unreferenced, so it only
|
|
1665
|
+
* fires when something else still holds the loop.
|
|
1666
|
+
*/
|
|
1667
|
+
export function exitWhenIdle(code, { processImpl = process, setTimeoutImpl = setTimeout, graceMs = 3000 } = {}) {
|
|
1668
|
+
processImpl.exitCode = code;
|
|
1669
|
+
const timer = setTimeoutImpl(() => processImpl.exit(code), graceMs);
|
|
1670
|
+
timer?.unref?.();
|
|
1671
|
+
return timer;
|
|
784
1672
|
}
|
|
785
1673
|
|
|
786
1674
|
// Run main() when this file is the process entrypoint. npm bin shims make
|
|
@@ -796,10 +1684,10 @@ const invokedPath = (() => {
|
|
|
796
1684
|
const invokedName = invokedPath ? path.basename(invokedPath) : '';
|
|
797
1685
|
if (invokedPath === selfPath || invokedName === 'oar.js' || invokedName === 'oar') {
|
|
798
1686
|
main().then(({ code }) => {
|
|
799
|
-
|
|
1687
|
+
exitWhenIdle(code ?? 0);
|
|
800
1688
|
}).catch((error) => {
|
|
801
1689
|
console.error(`[oar] Unhandled error: ${error?.message || error}`);
|
|
802
|
-
|
|
1690
|
+
exitWhenIdle(1);
|
|
803
1691
|
});
|
|
804
1692
|
}
|
|
805
1693
|
|