@oar-sh/oar 0.9.8 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
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 start starts only the relay, in the background; oar setup writes the config; oar doctor and oar update are described in the README.',
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 port = cliPort ?? normalizePort(readJsonFile(seedPath)?.port) ?? 3333;
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 (running.running) {
367
- const pid = Number.isInteger(running?.lock?.pid) ? running.lock.pid : null;
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(options = {}) {
584
- return process.platform === 'linux' && runQuiet('systemctl', ['--user', 'show-environment'], options);
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(os.homedir(), '.config', 'systemd', 'user', 'oar.service');
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), 'taken' (something else
608
- * answers, or holds the port without answering), or 'none'.
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
- export async function probeRelayPort({ statusUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 2500 } = {}) {
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 ? 'oar' : 'taken';
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
- 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
- }
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
- function printRelayUrl({ config, logger, relayUrl, primaryLanAddress }) {
726
- const url = relayUrl({ config, lanAddress: primaryLanAddress() });
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(`[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.');
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
- return url;
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 from the web UI (⋯ → Restart web relay).');
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({ 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) {
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 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' };
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 ensureRelayRunning({ packageRoot, layout, configPath, config, cwd, env, logger });
1076
+ const result = await startRelay(ctx, paths);
767
1077
  reportRelayStart(result, logger);
768
- if (result.ok) printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
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
- async function runSetup({ argv = [], env = process.env, logger = console } = {}) {
773
- const {
774
- buildDefaultConfig, buildServicePath, buildSystemdUnit, generateAuthToken, primaryLanAddress, relayUrl,
775
- } = await import('../server/services/oar-cli-helpers.mjs');
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
- const packageRoot = resolvePackageRoot();
778
- const layout = resolveStateLayout({ packageRoot, env });
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);
784
- const migrateFromIdx = argv.indexOf('--migrate-from');
785
- const migrateFromRoot = migrateFromIdx !== -1 ? String(argv[migrateFromIdx + 1] || '').trim() : '';
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 configPath = layout.checkout
804
- ? path.join(packageRoot, 'server', 'config.json')
805
- : path.join(layout.configDir, 'config.json');
806
- const existing = readJsonFile(configPath);
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
- const regenerate = await promptYesNo('A config already exists. Generate a new auth token (logs every device out)?', {
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
- const lanAccess = existing
820
- ? existing.localhostOnly === false
821
- : await promptYesNo('Allow LAN access so your phone can connect directly (otherwise localhost + tunnel only)?', {
822
- defaultYes: false,
823
- interactive: !useDefaults,
824
- });
825
-
826
- const config = { ...buildDefaultConfig({ token }), ...(existing || {}) };
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
- if (await promptYesNo('Enable the managed cloudflared tunnel (public URL for remote access)?', {
842
- defaultYes: false,
843
- interactive: !useDefaults,
844
- })) {
845
- config.cloudflaredTunnel = { ...(config.cloudflaredTunnel || {}), enabled: true };
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
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
849
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
850
- if (process.platform !== 'win32') {
851
- try { fs.chmodSync(configPath, 0o600); } catch {}
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
- let serviceInstalled = false;
856
- if (process.platform === 'linux' && !layout.checkout) {
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 });
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
- let started = null;
893
- if (startRequested) {
894
- started = await ensureRelayRunning({ packageRoot, layout, configPath, config, env, logger });
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
- const url = printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
899
- try {
900
- const qrcode = (await import('qrcode-terminal')).default;
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 (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)');
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({ env = process.env, logger = console } = {}) {
919
- const { DOCTOR_PROBES, renderDoctorReport } = await import('../server/services/oar-cli-helpers.mjs');
920
- const { spawnSync } = await import('node:child_process');
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 = spawnSync(probe.binary, probe.args, { encoding: 'utf8', timeout: 5000, windowsHide: true });
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
- logger.log(renderDoctorReport({
947
- version: readPackageVersion(packageRoot),
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: process.platform,
950
- layout,
951
- configPath,
952
- config: readJsonFile(configPath),
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
- function main() {
1075
- const argv = process.argv.slice(2);
1076
- const command = String(argv[0] || '').trim();
1077
- if (command === '--version' || command === '-v') {
1078
- console.log(readPackageVersion());
1079
- return Promise.resolve({ code: 0, reason: 'version' });
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
- if (command === 'setup') return runSetup({ argv: argv.slice(1) });
1082
- if (command === 'start') return runStart({});
1083
- if (command === 'doctor') return runDoctor({});
1084
- if (command === 'update') return runUpdate({ argv: argv.slice(1) });
1085
- return launchRelay();
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
- process.exit(code ?? 0);
1687
+ exitWhenIdle(code ?? 0);
1102
1688
  }).catch((error) => {
1103
1689
  console.error(`[oar] Unhandled error: ${error?.message || error}`);
1104
- process.exit(1);
1690
+ exitWhenIdle(1);
1105
1691
  });
1106
1692
  }
1107
1693