@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.
- package/CHANGELOG.md +231 -0
- package/README.md +53 -32
- package/bin/oar.js +814 -228
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/api-client.js +30 -0
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +46 -2
- package/server/public/app/conversation-view.js +110 -25
- 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 +1 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/socket-handlers.js +6 -0
- package/server/public/app/store.js +6 -0
- 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/index.html +304 -54
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +33 -61
- package/server/routes/sessions-routes.mjs +92 -24
- package/server/server-runtime.mjs +29 -0
- 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 +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
package/bin/oar.js
CHANGED
|
@@ -13,6 +13,15 @@ import {
|
|
|
13
13
|
migrateStateToOarRoot,
|
|
14
14
|
resolveOarRoot,
|
|
15
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';
|
|
16
25
|
|
|
17
26
|
function resolvePackageRoot(metaUrl = import.meta.url) {
|
|
18
27
|
return path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
|
|
@@ -223,6 +232,17 @@ function resolveLauncherConfig({ packageRoot, env = process.env, relayToken = ''
|
|
|
223
232
|
};
|
|
224
233
|
}
|
|
225
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
|
+
|
|
226
246
|
export async function detectRunningRelay({
|
|
227
247
|
lockPath,
|
|
228
248
|
statusUrl,
|
|
@@ -254,7 +274,10 @@ export async function launchRelay({
|
|
|
254
274
|
packageRoot = resolvePackageRoot(),
|
|
255
275
|
nodeBin = process.execPath,
|
|
256
276
|
spawnImpl = spawn,
|
|
277
|
+
spawnSyncImpl = spawnSync,
|
|
257
278
|
fetchImpl = globalThis.fetch,
|
|
279
|
+
isProcessAliveImpl = isProcessAlive,
|
|
280
|
+
lockGraceMs = 5000,
|
|
258
281
|
env = process.env,
|
|
259
282
|
logger = console,
|
|
260
283
|
} = {}) {
|
|
@@ -271,17 +294,22 @@ export async function launchRelay({
|
|
|
271
294
|
const forwardedArgs = separatorIdx === -1 ? [] : args.slice(separatorIdx + 1);
|
|
272
295
|
if (args.includes('--help') || args.includes('-h')) {
|
|
273
296
|
logger.log([
|
|
274
|
-
'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...]]',
|
|
275
298
|
'',
|
|
276
299
|
'Starts the web relay server if needed, then launches gh copilot in the current shell.',
|
|
277
300
|
'--install-extension installs/updates a user-global web-relay wrapper extension and exits.',
|
|
278
301
|
'--no-install-extension skips automatic wrapper setup for this run.',
|
|
279
|
-
'oar
|
|
280
|
-
'Install globally with: npm i -g @oar-sh/oar (or from a checkout: npm link)',
|
|
302
|
+
'oar help lists every command.',
|
|
281
303
|
].join('\n'));
|
|
282
304
|
return { code: 0, reason: 'help' };
|
|
283
305
|
}
|
|
284
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
|
+
}
|
|
285
313
|
if (!skipExtensionInstall || installExtensionOnly) {
|
|
286
314
|
try {
|
|
287
315
|
ensureGlobalExtensionWrapper({ packageRoot, env, logger });
|
|
@@ -326,22 +354,28 @@ export async function launchRelay({
|
|
|
326
354
|
// while the server started on the config's port, so any mismatch waited out
|
|
327
355
|
// the readiness deadline and killed the relay it had just started.
|
|
328
356
|
const { seedPath } = resolveConfigPaths({ packageRoot, env, layout });
|
|
329
|
-
const
|
|
357
|
+
const configPort = normalizePort(readJsonFile(seedPath)?.port);
|
|
358
|
+
const port = cliPort ?? configPort ?? 3333;
|
|
330
359
|
const statusUrl = `http://localhost:${port}/api/status`;
|
|
331
|
-
const running = await detectRunningRelay({
|
|
332
|
-
lockPath,
|
|
333
|
-
statusUrl,
|
|
334
|
-
fetchImpl,
|
|
335
|
-
});
|
|
336
|
-
|
|
337
360
|
const runtimeConfig = resolveLauncherConfig({
|
|
338
361
|
packageRoot,
|
|
339
362
|
env,
|
|
340
|
-
relayToken: running?.lock?.token || '',
|
|
341
363
|
relayPort: port,
|
|
342
364
|
portOverride: cliPort,
|
|
343
365
|
layout,
|
|
344
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
|
+
}
|
|
345
379
|
if (runtimeConfig.managed || !fs.existsSync(runtimeConfig.configPath)) {
|
|
346
380
|
writeJsonFile(runtimeConfig.configPath, runtimeConfig.config);
|
|
347
381
|
}
|
|
@@ -363,13 +397,8 @@ export async function launchRelay({
|
|
|
363
397
|
|
|
364
398
|
let serverProc = null;
|
|
365
399
|
let relayStartedByThisCommand = false;
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
logger.log(
|
|
369
|
-
pid
|
|
370
|
-
? `Copilot relay is already running (pid=${pid}) at ${statusUrl}.`
|
|
371
|
-
: `Copilot relay is already running at ${statusUrl}.`,
|
|
372
|
-
);
|
|
400
|
+
if (answer === 'oar') {
|
|
401
|
+
logger.log(`Copilot relay is already running at ${statusUrl}.`);
|
|
373
402
|
} else {
|
|
374
403
|
logger.log(`Starting Copilot relay from ${cwd}...`);
|
|
375
404
|
const serverArgs = [path.join(serverDir, 'server.js'), '--token', runtimeConfig.config.authToken, '--port', String(runtimeConfig.config.port)];
|
|
@@ -458,7 +487,7 @@ export async function launchRelay({
|
|
|
458
487
|
windowsHide: false,
|
|
459
488
|
});
|
|
460
489
|
} catch (error) {
|
|
461
|
-
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}`);
|
|
462
491
|
try {
|
|
463
492
|
if (relayStartedByThisCommand && serverProc && typeof serverProc.kill === 'function') serverProc.kill();
|
|
464
493
|
} catch {}
|
|
@@ -477,7 +506,7 @@ export async function launchRelay({
|
|
|
477
506
|
resolve(code ?? (signal ? 1 : 0));
|
|
478
507
|
});
|
|
479
508
|
child.on('error', (error) => {
|
|
480
|
-
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}`);
|
|
481
510
|
resolve(1);
|
|
482
511
|
});
|
|
483
512
|
});
|
|
@@ -499,20 +528,6 @@ function readPackageVersion(packageRoot = resolvePackageRoot()) {
|
|
|
499
528
|
}
|
|
500
529
|
}
|
|
501
530
|
|
|
502
|
-
async function promptYesNo(question, { defaultYes = false, interactive = true } = {}) {
|
|
503
|
-
if (!interactive) return defaultYes;
|
|
504
|
-
const readline = await import('node:readline/promises');
|
|
505
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
506
|
-
try {
|
|
507
|
-
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
|
|
508
|
-
const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
|
|
509
|
-
if (!answer) return defaultYes;
|
|
510
|
-
return answer === 'y' || answer === 'yes';
|
|
511
|
-
} finally {
|
|
512
|
-
rl.close();
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
531
|
function canBind(port, host) {
|
|
517
532
|
return new Promise((resolve) => {
|
|
518
533
|
const probe = net.createServer();
|
|
@@ -580,12 +595,12 @@ function runQuiet(command, args, { spawnSyncImpl = spawnSync } = {}) {
|
|
|
580
595
|
|
|
581
596
|
// A systemd *user* manager this process can talk to: absent in containers, on
|
|
582
597
|
// macOS, and in a WSL distro without systemd.
|
|
583
|
-
function systemdUserAvailable(
|
|
584
|
-
return
|
|
598
|
+
function systemdUserAvailable({ platform = process.platform, spawnSyncImpl } = {}) {
|
|
599
|
+
return platform === 'linux' && runQuiet('systemctl', ['--user', 'show-environment'], { spawnSyncImpl });
|
|
585
600
|
}
|
|
586
601
|
|
|
587
|
-
function systemdUnitPath() {
|
|
588
|
-
return path.join(
|
|
602
|
+
function systemdUnitPath(homeDir = os.homedir()) {
|
|
603
|
+
return path.join(homeDir, '.config', 'systemd', 'user', 'oar.service');
|
|
589
604
|
}
|
|
590
605
|
|
|
591
606
|
async function waitForRelay({ statusUrl, token, fetchImpl = globalThis.fetch, deadlineMs = 20_000, stillStarting = () => true } = {}) {
|
|
@@ -604,27 +619,65 @@ async function waitForRelay({ statusUrl, token, fetchImpl = globalThis.fetch, de
|
|
|
604
619
|
|
|
605
620
|
/**
|
|
606
621
|
* What answers on the relay's port: 'oar' (this relay: its status call accepts
|
|
607
|
-
* the configured token and has the relay's shape
|
|
608
|
-
* answers, or holds the port without answering), or
|
|
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'.
|
|
609
625
|
*/
|
|
610
|
-
|
|
611
|
-
if (typeof fetchImpl !== 'function' || !statusUrl) return 'none';
|
|
626
|
+
async function readRelayStatus({ statusUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 2500 } = {}) {
|
|
627
|
+
if (typeof fetchImpl !== 'function' || !statusUrl) return { answer: 'none', status: null };
|
|
612
628
|
try {
|
|
613
629
|
const response = await fetchImpl(statusUrl, {
|
|
614
630
|
method: 'GET',
|
|
615
631
|
headers: { Authorization: `Bearer ${token}` },
|
|
616
632
|
signal: AbortSignal.timeout(timeoutMs),
|
|
617
633
|
});
|
|
618
|
-
if (!response) return 'none';
|
|
619
|
-
if (!response.ok) return 'taken';
|
|
634
|
+
if (!response) return { answer: 'none', status: null };
|
|
635
|
+
if (!response.ok) return { answer: 'taken', status: null };
|
|
620
636
|
const body = await Promise.resolve(response.json?.()).catch(() => null);
|
|
621
|
-
return body && typeof body === 'object' && 'cliOnline' in body
|
|
637
|
+
return body && typeof body === 'object' && 'cliOnline' in body
|
|
638
|
+
? { answer: 'oar', status: body }
|
|
639
|
+
: { answer: 'taken', status: null };
|
|
622
640
|
} catch (error) {
|
|
623
641
|
// A port that accepts the connection and then stays silent is held too.
|
|
624
|
-
return error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'taken' : 'none';
|
|
642
|
+
return { answer: error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'taken' : 'none', status: null };
|
|
625
643
|
}
|
|
626
644
|
}
|
|
627
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
|
+
|
|
628
681
|
/**
|
|
629
682
|
* Makes sure the relay of a global install runs, without a Copilot session
|
|
630
683
|
* tied to it: an already running relay is left alone, an installed systemd
|
|
@@ -644,36 +697,18 @@ export async function ensureRelayRunning({
|
|
|
644
697
|
fetchImpl = globalThis.fetch,
|
|
645
698
|
isProcessAliveImpl = isProcessAlive,
|
|
646
699
|
lockGraceMs = 5000,
|
|
700
|
+
homeDir = os.homedir(),
|
|
701
|
+
platform = process.platform,
|
|
647
702
|
logger = console,
|
|
648
703
|
} = {}) {
|
|
649
704
|
const port = normalizePort(config?.port) ?? 3333;
|
|
650
705
|
const token = String(config?.authToken || '').trim();
|
|
651
706
|
const statusUrl = `http://localhost:${port}/api/status`;
|
|
652
707
|
const logDir = getLauncherLogDir(env, layout);
|
|
653
|
-
// The relay itself says whether it runs: a lock file can outlive its relay
|
|
654
|
-
// (and its process id can pass to another program), and anything can answer
|
|
655
|
-
// on a port.
|
|
656
708
|
const lockPath = path.join(layout.dataDir, 'relay-server.lock');
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
return Number.isInteger(lock?.pid) && isProcessAliveImpl(lock.pid);
|
|
661
|
-
};
|
|
662
|
-
if (answer === 'none' && lockedByLiveProcess()) {
|
|
663
|
-
// A relay that is still starting holds the lock before it listens.
|
|
664
|
-
const deadline = Date.now() + lockGraceMs;
|
|
665
|
-
while (answer === 'none' && Date.now() < deadline) {
|
|
666
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
667
|
-
answer = await probeRelayPort({ statusUrl, token, fetchImpl });
|
|
668
|
-
}
|
|
669
|
-
if (answer === 'none' && lockedByLiveProcess()) {
|
|
670
|
-
// Nothing listens: the lock is left over from a relay that did not
|
|
671
|
-
// stop cleanly. The server refuses to start beside a lock whose process
|
|
672
|
-
// id is alive, so it goes first.
|
|
673
|
-
logger.log?.(`[oar] Removing a leftover relay lock (${lockPath}); no relay answers on port ${port}.`);
|
|
674
|
-
try { fs.unlinkSync(lockPath); } catch {}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
709
|
+
const answer = await probeRelayBehindLock({
|
|
710
|
+
statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
|
|
711
|
+
});
|
|
677
712
|
if (answer === 'oar') return { ok: true, how: 'already', logDir };
|
|
678
713
|
if (answer === 'taken') {
|
|
679
714
|
logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
|
|
@@ -681,7 +716,7 @@ export async function ensureRelayRunning({
|
|
|
681
716
|
return { ok: false, how: 'port-taken', logDir };
|
|
682
717
|
}
|
|
683
718
|
|
|
684
|
-
if (fs.existsSync(systemdUnitPath()) && systemdUserAvailable({ spawnSyncImpl })) {
|
|
719
|
+
if (fs.existsSync(systemdUnitPath(homeDir)) && systemdUserAvailable({ platform, spawnSyncImpl })) {
|
|
685
720
|
runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
|
|
686
721
|
if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
|
|
687
722
|
logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
|
|
@@ -722,14 +757,272 @@ export async function ensureRelayRunning({
|
|
|
722
757
|
return { ok, how: 'detached', logDir };
|
|
723
758
|
}
|
|
724
759
|
|
|
725
|
-
|
|
726
|
-
|
|
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
|
+
}
|
|
794
|
+
|
|
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
|
+
}
|
|
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
|
+
};
|
|
727
1013
|
logger.log('');
|
|
728
|
-
logger.log(
|
|
729
|
-
|
|
730
|
-
|
|
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);
|
|
731
1020
|
}
|
|
732
|
-
|
|
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)}`);
|
|
733
1026
|
}
|
|
734
1027
|
|
|
735
1028
|
function reportRelayStart(result, logger) {
|
|
@@ -740,7 +1033,7 @@ function reportRelayStart(result, logger) {
|
|
|
740
1033
|
return;
|
|
741
1034
|
}
|
|
742
1035
|
if (result.how === 'already') {
|
|
743
|
-
logger.log('[oar] The relay is already running. To load a new version, restart it
|
|
1036
|
+
logger.log('[oar] The relay is already running. To load a new version, restart it: oar restart');
|
|
744
1037
|
} else if (result.how === 'service') {
|
|
745
1038
|
logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
|
|
746
1039
|
} else {
|
|
@@ -748,43 +1041,276 @@ function reportRelayStart(result, logger) {
|
|
|
748
1041
|
}
|
|
749
1042
|
}
|
|
750
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
|
+
|
|
751
1061
|
/** `oar start` — the relay alone, in the background, without a Copilot session. */
|
|
752
|
-
async function runStart(
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
if (layout.checkout) {
|
|
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) {
|
|
757
1067
|
logger.error('[oar] This is a git checkout — start the relay with: npm start');
|
|
758
1068
|
return { code: 1, reason: 'git-checkout' };
|
|
759
1069
|
}
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
if (
|
|
763
|
-
logger.
|
|
764
|
-
return { code:
|
|
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' };
|
|
765
1075
|
}
|
|
766
|
-
const result = await
|
|
1076
|
+
const result = await startRelay(ctx, paths);
|
|
767
1077
|
reportRelayStart(result, logger);
|
|
768
|
-
if (result.ok)
|
|
1078
|
+
if (result.ok) await showAddress(ctx, paths);
|
|
1079
|
+
warnWindowsPort(ctx, endpointOf(paths.config).port);
|
|
769
1080
|
return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
|
|
770
1081
|
}
|
|
771
1082
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
+
}
|
|
776
1115
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
const
|
|
785
|
-
|
|
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
|
+
}
|
|
786
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;
|
|
787
1312
|
if (!layout.checkout) {
|
|
1313
|
+
const migrateFromRoot = String(optionValue(argv, '--migrate-from') || '').trim();
|
|
788
1314
|
const migration = await migrateStateToOarRoot({
|
|
789
1315
|
targetRoot: layout.root,
|
|
790
1316
|
repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
|
|
@@ -797,143 +1323,123 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
|
|
|
797
1323
|
}
|
|
798
1324
|
if (migration.status === 'migrated') {
|
|
799
1325
|
logger.log(`[oar] Migrated existing relay state into ${layout.root} (sources untouched).`);
|
|
1326
|
+
paths = installPaths(ctx);
|
|
800
1327
|
}
|
|
801
1328
|
}
|
|
802
1329
|
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
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
|
+
};
|
|
807
1343
|
|
|
808
1344
|
let token = String(existing?.authToken || '').trim();
|
|
809
|
-
if (token)
|
|
810
|
-
|
|
811
|
-
defaultYes: false,
|
|
812
|
-
interactive: !useDefaults,
|
|
813
|
-
});
|
|
814
|
-
if (regenerate) token = generateAuthToken();
|
|
815
|
-
} else {
|
|
1345
|
+
if (!token || has('--new-token')
|
|
1346
|
+
|| (ask && await prompter.yesNo('New auth token? It signs every device out.', { defaultYes: false }))) {
|
|
816
1347
|
token = generateAuthToken();
|
|
817
1348
|
}
|
|
818
1349
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
config.authToken = token;
|
|
828
|
-
config.localhostOnly = !lanAccess;
|
|
829
|
-
if (portOverride) {
|
|
830
|
-
config.port = portOverride;
|
|
831
|
-
} else if (!existing) {
|
|
832
|
-
// A fresh config takes the first free port; an existing one keeps its own,
|
|
833
|
-
// which its running relay may be the one holding.
|
|
834
|
-
const freePort = await findFreePort(config.port);
|
|
835
|
-
if (freePort !== config.port) {
|
|
836
|
-
logger.log(`[oar] Port ${config.port} is in use — using ${freePort}.`);
|
|
837
|
-
config.port = freePort;
|
|
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;
|
|
838
1358
|
}
|
|
839
1359
|
}
|
|
840
1360
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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}`);
|
|
1382
|
+
}
|
|
846
1383
|
}
|
|
847
1384
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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 {}
|
|
852
1394
|
}
|
|
853
|
-
logger.log(`[oar] Wrote ${configPath}`);
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
? hasSystemd
|
|
860
|
-
: await promptYesNo('Install a systemd user service so the relay starts on login?', {
|
|
861
|
-
defaultYes: false,
|
|
862
|
-
interactive: !useDefaults,
|
|
863
|
-
});
|
|
864
|
-
if (wantService) {
|
|
865
|
-
const unitPath = systemdUnitPath();
|
|
866
|
-
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
|
|
867
|
-
fs.writeFileSync(unitPath, buildSystemdUnit({
|
|
868
|
-
nodeBin: process.execPath,
|
|
869
|
-
packageRoot,
|
|
870
|
-
configPath,
|
|
871
|
-
dataDir: layout.dataDir,
|
|
872
|
-
logDir: getLauncherLogDir(env, layout),
|
|
873
|
-
pathEnv: buildServicePath({ nodeBin: process.execPath, envPath: env.PATH, homeDir: os.homedir() }),
|
|
874
|
-
}));
|
|
875
|
-
serviceInstalled = true;
|
|
876
|
-
if (startRequested) {
|
|
877
|
-
logger.log(`[oar] Wrote ${unitPath}`);
|
|
878
|
-
// Lingering keeps the user's services up while nobody is logged in;
|
|
879
|
-
// without it the relay of a headless host stops at logout.
|
|
880
|
-
const lingering = runQuiet('loginctl', ['enable-linger', os.userInfo().username]);
|
|
881
|
-
if (!lingering) {
|
|
882
|
-
logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
|
|
883
|
-
}
|
|
884
|
-
} else {
|
|
885
|
-
logger.log(`[oar] Wrote ${unitPath} — enable it with: systemctl --user enable --now oar`);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
} else if (process.platform === 'win32') {
|
|
889
|
-
logger.log('[oar] Autostart on Windows: enable it in the web UI under Settings → Autostart.');
|
|
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 });
|
|
890
1401
|
}
|
|
891
1402
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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);
|
|
895
1416
|
reportRelayStart(started, logger);
|
|
1417
|
+
outcome = { ok: started.ok, reason: started.ok ? 'setup-complete' : 'server-not-ready' };
|
|
896
1418
|
}
|
|
897
1419
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
qrcode.generate(url, { small: true }, (qr) => logger.log(qr));
|
|
902
|
-
} catch {
|
|
903
|
-
// QR is a convenience — the URL above is the contract.
|
|
904
|
-
}
|
|
905
|
-
if (started) {
|
|
906
|
-
return { code: started.ok ? 0 : 1, reason: started.ok ? 'setup-complete' : 'server-not-ready' };
|
|
907
|
-
}
|
|
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 };
|
|
908
1423
|
if (layout.checkout) {
|
|
909
1424
|
logger.log('[oar] Start the relay with: npm start');
|
|
910
|
-
} else if (
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
logger.log('[oar] Start the relay with: oar start (or `oar` to open a Copilot session with it)');
|
|
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}`);
|
|
914
1428
|
}
|
|
915
1429
|
return { code: 0, reason: 'setup-complete' };
|
|
916
1430
|
}
|
|
917
1431
|
|
|
918
|
-
async function runDoctor(
|
|
919
|
-
const
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
const packageRoot = resolvePackageRoot();
|
|
923
|
-
const layout = resolveStateLayout({ packageRoot, env });
|
|
924
|
-
const configPath = layout.checkout
|
|
925
|
-
? path.join(packageRoot, 'server', 'config.json')
|
|
926
|
-
: path.join(layout.configDir, 'config.json');
|
|
927
|
-
const dbPath = layout.checkout
|
|
928
|
-
? path.join(packageRoot, 'server', 'data', 'copilot.db')
|
|
929
|
-
: 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');
|
|
930
1436
|
|
|
931
1437
|
let dbSizeBytes = null;
|
|
932
1438
|
try { dbSizeBytes = fs.statSync(dbPath).size; } catch {}
|
|
933
1439
|
|
|
934
1440
|
const probes = DOCTOR_PROBES.map((probe) => {
|
|
935
1441
|
try {
|
|
936
|
-
const result =
|
|
1442
|
+
const result = ctx.spawnSyncImpl(probe.binary, probe.args, { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
937
1443
|
const ok = result.status === 0;
|
|
938
1444
|
const version = ok ? String(result.stdout || '').trim().split('\n')[0].slice(0, 60) : '';
|
|
939
1445
|
return { id: probe.id, ok, version };
|
|
@@ -943,16 +1449,21 @@ async function runDoctor({ env = process.env, logger = console } = {}) {
|
|
|
943
1449
|
});
|
|
944
1450
|
probes.push({ id: 'cursor', ok: true, version: 'bundled (@cursor/sdk)' });
|
|
945
1451
|
|
|
946
|
-
|
|
947
|
-
|
|
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),
|
|
948
1456
|
nodeVersion: process.version,
|
|
949
|
-
platform:
|
|
950
|
-
layout,
|
|
951
|
-
configPath,
|
|
952
|
-
config:
|
|
1457
|
+
platform: ctx.platform,
|
|
1458
|
+
layout: paths.layout,
|
|
1459
|
+
configPath: paths.configPath,
|
|
1460
|
+
config: paths.config,
|
|
953
1461
|
dbPath,
|
|
954
1462
|
dbSizeBytes,
|
|
955
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') }),
|
|
956
1467
|
}));
|
|
957
1468
|
return { code: 0, reason: 'doctor' };
|
|
958
1469
|
}
|
|
@@ -1013,7 +1524,6 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
1013
1524
|
}
|
|
1014
1525
|
|
|
1015
1526
|
logger.log(`[oar] Updating @oar-sh/oar ${runningVersion} -> ${target} ...`);
|
|
1016
|
-
const { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } = await import('../server/services/oar-cli-helpers.mjs');
|
|
1017
1527
|
const npm = resolveNpmInvocation({ packageRoot, env });
|
|
1018
1528
|
const exitCode = await new Promise((resolve) => {
|
|
1019
1529
|
const child = spawnImpl(npm.command, ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `@oar-sh/oar@${target}`], {
|
|
@@ -1071,18 +1581,94 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
1071
1581
|
return { code: 0, reason: 'updated' };
|
|
1072
1582
|
}
|
|
1073
1583
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1079
|
-
|
|
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' };
|
|
1080
1592
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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;
|
|
1086
1672
|
}
|
|
1087
1673
|
|
|
1088
1674
|
// Run main() when this file is the process entrypoint. npm bin shims make
|
|
@@ -1098,10 +1684,10 @@ const invokedPath = (() => {
|
|
|
1098
1684
|
const invokedName = invokedPath ? path.basename(invokedPath) : '';
|
|
1099
1685
|
if (invokedPath === selfPath || invokedName === 'oar.js' || invokedName === 'oar') {
|
|
1100
1686
|
main().then(({ code }) => {
|
|
1101
|
-
|
|
1687
|
+
exitWhenIdle(code ?? 0);
|
|
1102
1688
|
}).catch((error) => {
|
|
1103
1689
|
console.error(`[oar] Unhandled error: ${error?.message || error}`);
|
|
1104
|
-
|
|
1690
|
+
exitWhenIdle(1);
|
|
1105
1691
|
});
|
|
1106
1692
|
}
|
|
1107
1693
|
|