@oar-sh/oar 0.9.7 → 0.9.8
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 +113 -0
- package/README.md +46 -18
- package/bin/oar.js +320 -18
- package/package.json +1 -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/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +6 -2
- package/server/public/app/bootstrap.js +124 -57
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +62 -7
- package/server/public/app/pwa-install.js +5 -0
- 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 +2 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- 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 +183 -13
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/routes/messages-routes.mjs +3 -1
- package/server/routes/sessions-routes.mjs +15 -2
- package/server/server-runtime.mjs +10 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/pwa-manifest-theme.mjs +30 -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,7 +6,8 @@ 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,
|
|
@@ -275,6 +276,7 @@ export async function launchRelay({
|
|
|
275
276
|
'Starts the web relay server if needed, then launches gh copilot in the current shell.',
|
|
276
277
|
'--install-extension installs/updates a user-global web-relay wrapper extension and exits.',
|
|
277
278
|
'--no-install-extension skips automatic wrapper setup for this run.',
|
|
279
|
+
'oar start starts only the relay, in the background; oar setup writes the config; oar doctor and oar update are described in the README.',
|
|
278
280
|
'Install globally with: npm i -g @oar-sh/oar (or from a checkout: npm link)',
|
|
279
281
|
].join('\n'));
|
|
280
282
|
return { code: 0, reason: 'help' };
|
|
@@ -511,14 +513,274 @@ async function promptYesNo(question, { defaultYes = false, interactive = true }
|
|
|
511
513
|
}
|
|
512
514
|
}
|
|
513
515
|
|
|
516
|
+
function canBind(port, host) {
|
|
517
|
+
return new Promise((resolve) => {
|
|
518
|
+
const probe = net.createServer();
|
|
519
|
+
probe.once('error', (error) => {
|
|
520
|
+
// A host this machine does not have (no IPv6) says nothing about the port.
|
|
521
|
+
resolve(error?.code === 'EADDRNOTAVAIL' || error?.code === 'EAFNOSUPPORT');
|
|
522
|
+
});
|
|
523
|
+
probe.once('listening', () => probe.close(() => resolve(true)));
|
|
524
|
+
if (host) probe.listen(port, host);
|
|
525
|
+
else probe.listen(port);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function acceptsConnection(port, host, timeoutMs = 400) {
|
|
530
|
+
return new Promise((resolve) => {
|
|
531
|
+
const socket = net.connect({ port, host });
|
|
532
|
+
let settled = false;
|
|
533
|
+
const finish = (accepted) => {
|
|
534
|
+
if (settled) return;
|
|
535
|
+
settled = true;
|
|
536
|
+
// An accepted connection is closed politely: destroying it reaches the
|
|
537
|
+
// listener as a reset, which a program without an error handler on its
|
|
538
|
+
// sockets would take as a fault.
|
|
539
|
+
if (accepted) socket.end();
|
|
540
|
+
else socket.destroy();
|
|
541
|
+
resolve(accepted);
|
|
542
|
+
};
|
|
543
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
544
|
+
socket.once('connect', () => finish(true));
|
|
545
|
+
socket.on('error', () => finish(false));
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* A port is free when nothing accepts a connection on it and it can be bound
|
|
551
|
+
* on the loopback addresses and on all interfaces. One wildcard bind is not
|
|
552
|
+
* enough: Windows allows it beside a listener on 127.0.0.1 alone.
|
|
553
|
+
*/
|
|
554
|
+
export async function isPortFree(port) {
|
|
555
|
+
for (const host of ['127.0.0.1', '::1']) {
|
|
556
|
+
if (await acceptsConnection(port, host)) return false;
|
|
557
|
+
}
|
|
558
|
+
for (const host of ['127.0.0.1', '::1', '']) {
|
|
559
|
+
if (!(await canBind(port, host))) return false;
|
|
560
|
+
}
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The first free port from `start` on, so a fresh config does not name a port something else holds. */
|
|
565
|
+
export async function findFreePort(start = 3333, { attempts = 20, isPortFreeImpl = isPortFree } = {}) {
|
|
566
|
+
for (let port = start; port < start + attempts && port <= 65535; port += 1) {
|
|
567
|
+
if (await isPortFreeImpl(port)) return port;
|
|
568
|
+
}
|
|
569
|
+
return start;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function runQuiet(command, args, { spawnSyncImpl = spawnSync } = {}) {
|
|
573
|
+
try {
|
|
574
|
+
const result = spawnSyncImpl(command, args, { stdio: 'ignore', timeout: 15_000 });
|
|
575
|
+
return result.status === 0;
|
|
576
|
+
} catch {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// A systemd *user* manager this process can talk to: absent in containers, on
|
|
582
|
+
// macOS, and in a WSL distro without systemd.
|
|
583
|
+
function systemdUserAvailable(options = {}) {
|
|
584
|
+
return process.platform === 'linux' && runQuiet('systemctl', ['--user', 'show-environment'], options);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function systemdUnitPath() {
|
|
588
|
+
return path.join(os.homedir(), '.config', 'systemd', 'user', 'oar.service');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function waitForRelay({ statusUrl, token, fetchImpl = globalThis.fetch, deadlineMs = 20_000, stillStarting = () => true } = {}) {
|
|
592
|
+
const deadline = Date.now() + deadlineMs;
|
|
593
|
+
for (;;) {
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetchImpl(statusUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
596
|
+
if (res && res.ok) return true;
|
|
597
|
+
} catch {
|
|
598
|
+
// retry
|
|
599
|
+
}
|
|
600
|
+
if (Date.now() >= deadline || !stillStarting()) return false;
|
|
601
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* What answers on the relay's port: 'oar' (this relay: its status call accepts
|
|
607
|
+
* the configured token and has the relay's shape), 'taken' (something else
|
|
608
|
+
* answers, or holds the port without answering), or 'none'.
|
|
609
|
+
*/
|
|
610
|
+
export async function probeRelayPort({ statusUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 2500 } = {}) {
|
|
611
|
+
if (typeof fetchImpl !== 'function' || !statusUrl) return 'none';
|
|
612
|
+
try {
|
|
613
|
+
const response = await fetchImpl(statusUrl, {
|
|
614
|
+
method: 'GET',
|
|
615
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
616
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
617
|
+
});
|
|
618
|
+
if (!response) return 'none';
|
|
619
|
+
if (!response.ok) return 'taken';
|
|
620
|
+
const body = await Promise.resolve(response.json?.()).catch(() => null);
|
|
621
|
+
return body && typeof body === 'object' && 'cliOnline' in body ? 'oar' : 'taken';
|
|
622
|
+
} catch (error) {
|
|
623
|
+
// A port that accepts the connection and then stays silent is held too.
|
|
624
|
+
return error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'taken' : 'none';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Makes sure the relay of a global install runs, without a Copilot session
|
|
630
|
+
* tied to it: an already running relay is left alone, an installed systemd
|
|
631
|
+
* user service is started, and anywhere else the server is started detached,
|
|
632
|
+
* so it outlives this command.
|
|
633
|
+
*/
|
|
634
|
+
export async function ensureRelayRunning({
|
|
635
|
+
packageRoot = resolvePackageRoot(),
|
|
636
|
+
layout,
|
|
637
|
+
configPath,
|
|
638
|
+
config,
|
|
639
|
+
cwd = os.homedir(),
|
|
640
|
+
env = process.env,
|
|
641
|
+
nodeBin = process.execPath,
|
|
642
|
+
spawnImpl = spawn,
|
|
643
|
+
spawnSyncImpl = spawnSync,
|
|
644
|
+
fetchImpl = globalThis.fetch,
|
|
645
|
+
isProcessAliveImpl = isProcessAlive,
|
|
646
|
+
lockGraceMs = 5000,
|
|
647
|
+
logger = console,
|
|
648
|
+
} = {}) {
|
|
649
|
+
const port = normalizePort(config?.port) ?? 3333;
|
|
650
|
+
const token = String(config?.authToken || '').trim();
|
|
651
|
+
const statusUrl = `http://localhost:${port}/api/status`;
|
|
652
|
+
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
|
+
const lockPath = path.join(layout.dataDir, 'relay-server.lock');
|
|
657
|
+
let answer = await probeRelayPort({ statusUrl, token, fetchImpl });
|
|
658
|
+
const lockedByLiveProcess = () => {
|
|
659
|
+
const lock = readRelayLock(lockPath);
|
|
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
|
+
}
|
|
677
|
+
if (answer === 'oar') return { ok: true, how: 'already', logDir };
|
|
678
|
+
if (answer === 'taken') {
|
|
679
|
+
logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
|
|
680
|
+
logger.error?.('[oar] Stop that program, or pick another port: oar setup --port <port>');
|
|
681
|
+
return { ok: false, how: 'port-taken', logDir };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (fs.existsSync(systemdUnitPath()) && systemdUserAvailable({ spawnSyncImpl })) {
|
|
685
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
|
|
686
|
+
if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
|
|
687
|
+
logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
|
|
688
|
+
return { ok: false, how: 'service', logDir };
|
|
689
|
+
}
|
|
690
|
+
return { ok: await waitForRelay({ statusUrl, token, fetchImpl }), how: 'service', logDir };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const serverDir = path.join(packageRoot, 'server');
|
|
694
|
+
const outFd = openAppendFileDescriptor(path.join(logDir, 'server.log'));
|
|
695
|
+
const errFd = openAppendFileDescriptor(path.join(logDir, 'server-err.log'));
|
|
696
|
+
let child = null;
|
|
697
|
+
try {
|
|
698
|
+
child = spawnImpl(nodeBin, [path.join(serverDir, 'server.js')], {
|
|
699
|
+
cwd: layout.root,
|
|
700
|
+
env: {
|
|
701
|
+
...env,
|
|
702
|
+
COPILOT_WORKSPACE_ROOT: cwd,
|
|
703
|
+
COPILOT_WEB_RELAY_ROOT: packageRoot,
|
|
704
|
+
COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
|
|
705
|
+
COPILOT_WEB_RELAY_CONFIG: configPath,
|
|
706
|
+
COPILOT_WEB_RELAY_LOG_DIR: logDir,
|
|
707
|
+
COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
|
|
708
|
+
},
|
|
709
|
+
detached: true,
|
|
710
|
+
stdio: ['ignore', outFd, errFd],
|
|
711
|
+
windowsHide: true,
|
|
712
|
+
});
|
|
713
|
+
child.unref?.();
|
|
714
|
+
} catch (error) {
|
|
715
|
+
logger.error?.(`[oar] Failed to launch relay: ${error?.message || error}`);
|
|
716
|
+
return { ok: false, how: 'detached', logDir };
|
|
717
|
+
} finally {
|
|
718
|
+
try { fs.closeSync(outFd); } catch {}
|
|
719
|
+
try { fs.closeSync(errFd); } catch {}
|
|
720
|
+
}
|
|
721
|
+
const ok = await waitForRelay({ statusUrl, token, fetchImpl, stillStarting: () => child.exitCode === null });
|
|
722
|
+
return { ok, how: 'detached', logDir };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function printRelayUrl({ config, logger, relayUrl, primaryLanAddress }) {
|
|
726
|
+
const url = relayUrl({ config, lanAddress: primaryLanAddress() });
|
|
727
|
+
logger.log('');
|
|
728
|
+
logger.log(`[oar] Relay URL: ${url}`);
|
|
729
|
+
if (config.localhostOnly) {
|
|
730
|
+
logger.log('[oar] localhostOnly is on — reach it from other devices via the tunnel, or rerun `oar setup` for LAN access.');
|
|
731
|
+
}
|
|
732
|
+
return url;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function reportRelayStart(result, logger) {
|
|
736
|
+
// A port held by something else was already explained.
|
|
737
|
+
if (result.how === 'port-taken') return;
|
|
738
|
+
if (!result.ok) {
|
|
739
|
+
logger.error?.(`[oar] The relay did not come up — see ${path.join(result.logDir, 'server-err.log')}`);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (result.how === 'already') {
|
|
743
|
+
logger.log('[oar] The relay is already running. To load a new version, restart it from the web UI (⋯ → Restart web relay).');
|
|
744
|
+
} else if (result.how === 'service') {
|
|
745
|
+
logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
|
|
746
|
+
} else {
|
|
747
|
+
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`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** `oar start` — the relay alone, in the background, without a Copilot session. */
|
|
752
|
+
async function runStart({ env = process.env, cwd = process.cwd(), logger = console } = {}) {
|
|
753
|
+
const { primaryLanAddress, relayUrl } = await import('../server/services/oar-cli-helpers.mjs');
|
|
754
|
+
const packageRoot = resolvePackageRoot();
|
|
755
|
+
const layout = resolveStateLayout({ packageRoot, env });
|
|
756
|
+
if (layout.checkout) {
|
|
757
|
+
logger.error('[oar] This is a git checkout — start the relay with: npm start');
|
|
758
|
+
return { code: 1, reason: 'git-checkout' };
|
|
759
|
+
}
|
|
760
|
+
const configPath = path.join(layout.configDir, 'config.json');
|
|
761
|
+
const config = readJsonFile(configPath);
|
|
762
|
+
if (!config) {
|
|
763
|
+
logger.error('[oar] No config yet — run: oar setup');
|
|
764
|
+
return { code: 1, reason: 'no-config' };
|
|
765
|
+
}
|
|
766
|
+
const result = await ensureRelayRunning({ packageRoot, layout, configPath, config, cwd, env, logger });
|
|
767
|
+
reportRelayStart(result, logger);
|
|
768
|
+
if (result.ok) printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
|
|
769
|
+
return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
|
|
770
|
+
}
|
|
771
|
+
|
|
514
772
|
async function runSetup({ argv = [], env = process.env, logger = console } = {}) {
|
|
515
773
|
const {
|
|
516
|
-
buildDefaultConfig, buildSystemdUnit, generateAuthToken, primaryLanAddress, relayUrl,
|
|
774
|
+
buildDefaultConfig, buildServicePath, buildSystemdUnit, generateAuthToken, primaryLanAddress, relayUrl,
|
|
517
775
|
} = await import('../server/services/oar-cli-helpers.mjs');
|
|
518
776
|
|
|
519
777
|
const packageRoot = resolvePackageRoot();
|
|
520
778
|
const layout = resolveStateLayout({ packageRoot, env });
|
|
521
779
|
const useDefaults = argv.includes('--defaults') || !process.stdin.isTTY;
|
|
780
|
+
// `--start` is the installer's mode: beyond the config it sets up whatever
|
|
781
|
+
// keeps the relay running on this host and starts it, without a question.
|
|
782
|
+
const startRequested = argv.includes('--start') && !layout.checkout;
|
|
783
|
+
const portOverride = parsePort(argv, null);
|
|
522
784
|
const migrateFromIdx = argv.indexOf('--migrate-from');
|
|
523
785
|
const migrateFromRoot = migrateFromIdx !== -1 ? String(argv[migrateFromIdx + 1] || '').trim() : '';
|
|
524
786
|
|
|
@@ -564,6 +826,17 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
|
|
|
564
826
|
const config = { ...buildDefaultConfig({ token }), ...(existing || {}) };
|
|
565
827
|
config.authToken = token;
|
|
566
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;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
567
840
|
|
|
568
841
|
if (await promptYesNo('Enable the managed cloudflared tunnel (public URL for remote access)?', {
|
|
569
842
|
defaultYes: false,
|
|
@@ -579,40 +852,66 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
|
|
|
579
852
|
}
|
|
580
853
|
logger.log(`[oar] Wrote ${configPath}`);
|
|
581
854
|
|
|
855
|
+
let serviceInstalled = false;
|
|
582
856
|
if (process.platform === 'linux' && !layout.checkout) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
857
|
+
const hasSystemd = systemdUserAvailable();
|
|
858
|
+
const wantService = startRequested
|
|
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 });
|
|
590
867
|
fs.writeFileSync(unitPath, buildSystemdUnit({
|
|
591
868
|
nodeBin: process.execPath,
|
|
592
869
|
packageRoot,
|
|
593
870
|
configPath,
|
|
594
871
|
dataDir: layout.dataDir,
|
|
595
872
|
logDir: getLauncherLogDir(env, layout),
|
|
873
|
+
pathEnv: buildServicePath({ nodeBin: process.execPath, envPath: env.PATH, homeDir: os.homedir() }),
|
|
596
874
|
}));
|
|
597
|
-
|
|
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
|
+
}
|
|
598
887
|
}
|
|
599
888
|
} else if (process.platform === 'win32') {
|
|
600
889
|
logger.log('[oar] Autostart on Windows: enable it in the web UI under Settings → Autostart.');
|
|
601
890
|
}
|
|
602
891
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
logger.log('[oar] localhostOnly is on — reach it from other devices via the tunnel, or rerun `oar setup` for LAN access.');
|
|
892
|
+
let started = null;
|
|
893
|
+
if (startRequested) {
|
|
894
|
+
started = await ensureRelayRunning({ packageRoot, layout, configPath, config, env, logger });
|
|
895
|
+
reportRelayStart(started, logger);
|
|
608
896
|
}
|
|
897
|
+
|
|
898
|
+
const url = printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
|
|
609
899
|
try {
|
|
610
900
|
const qrcode = (await import('qrcode-terminal')).default;
|
|
611
901
|
qrcode.generate(url, { small: true }, (qr) => logger.log(qr));
|
|
612
902
|
} catch {
|
|
613
903
|
// QR is a convenience — the URL above is the contract.
|
|
614
904
|
}
|
|
615
|
-
|
|
905
|
+
if (started) {
|
|
906
|
+
return { code: started.ok ? 0 : 1, reason: started.ok ? 'setup-complete' : 'server-not-ready' };
|
|
907
|
+
}
|
|
908
|
+
if (layout.checkout) {
|
|
909
|
+
logger.log('[oar] Start the relay with: npm start');
|
|
910
|
+
} else if (serviceInstalled) {
|
|
911
|
+
logger.log('[oar] Start the relay with: systemctl --user enable --now oar');
|
|
912
|
+
} else {
|
|
913
|
+
logger.log('[oar] Start the relay with: oar start (or `oar` to open a Copilot session with it)');
|
|
914
|
+
}
|
|
616
915
|
return { code: 0, reason: 'setup-complete' };
|
|
617
916
|
}
|
|
618
917
|
|
|
@@ -714,10 +1013,12 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
714
1013
|
}
|
|
715
1014
|
|
|
716
1015
|
logger.log(`[oar] Updating @oar-sh/oar ${runningVersion} -> ${target} ...`);
|
|
717
|
-
const
|
|
1016
|
+
const { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } = await import('../server/services/oar-cli-helpers.mjs');
|
|
1017
|
+
const npm = resolveNpmInvocation({ packageRoot, env });
|
|
718
1018
|
const exitCode = await new Promise((resolve) => {
|
|
719
|
-
const child = spawnImpl(
|
|
1019
|
+
const child = spawnImpl(npm.command, ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `@oar-sh/oar@${target}`], {
|
|
720
1020
|
stdio: 'inherit',
|
|
1021
|
+
env: npm.env,
|
|
721
1022
|
...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}),
|
|
722
1023
|
});
|
|
723
1024
|
child.on('error', (error) => {
|
|
@@ -778,6 +1079,7 @@ function main() {
|
|
|
778
1079
|
return Promise.resolve({ code: 0, reason: 'version' });
|
|
779
1080
|
}
|
|
780
1081
|
if (command === 'setup') return runSetup({ argv: argv.slice(1) });
|
|
1082
|
+
if (command === 'start') return runStart({});
|
|
781
1083
|
if (command === 'doctor') return runDoctor({});
|
|
782
1084
|
if (command === 'update') return runUpdate({ argv: argv.slice(1) });
|
|
783
1085
|
return launchRelay();
|
package/package.json
CHANGED
|
@@ -55,17 +55,6 @@ export function claudeUltracodeFlagSettings(effort) {
|
|
|
55
55
|
return { ultracode: null, enableWorkflows: null, effortLevel: effort || null };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
* The flag-settings payload that moves a live session onto another auto-compact
|
|
60
|
-
* window. `null` clears the flag layer back to the CLI's model-tuned default,
|
|
61
|
-
* which is exactly what "Auto" means — leaving the previous window pinned would
|
|
62
|
-
* make the setting one-way.
|
|
63
|
-
*/
|
|
64
|
-
export function claudeAutoCompactFlagSettings(autoCompactWindow) {
|
|
65
|
-
const window = normalizeAutoCompactWindow(autoCompactWindow);
|
|
66
|
-
return { autoCompactWindow: window };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
58
|
/**
|
|
70
59
|
* The flag-settings payload that pins (or clears) `alwaysThinkingEnabled` on a
|
|
71
60
|
* live session.
|