@oar-sh/oar 0.9.14 → 0.9.15

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/bin/oar.js CHANGED
@@ -12,8 +12,8 @@ import net from 'net';
12
12
  import {
13
13
  migrateStateToOarRoot,
14
14
  } from '../server/services/oar-state-migration-service.mjs';
15
- import { resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
16
- import { TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
15
+ import { readConfiguredDataDir, resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
16
+ import { RESCUE_DIR_NAME, TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
17
17
  import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
18
18
  import {
19
19
  getLauncherLogDir,
@@ -29,8 +29,13 @@ import {
29
29
  import { describeConfiguredTunnel, describeTunnelState } from '../server/services/cloudflared-tunnel-service.mjs';
30
30
  import { createPrompter, PromptAborted, runMenu } from '../server/services/oar-cli-menu.mjs';
31
31
  import { createWindowsPortCheck, isWsl, isWslNat, windowsPortWarning } from '../server/services/oar-cli-wsl.mjs';
32
- import { createWindowsAutostartService } from '../server/services/windows-autostart-service.mjs';
33
- import { createWindowsBootAutostartService } from '../server/services/windows-boot-autostart-service.mjs';
32
+ import { readEnvValue, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
33
+ import { createProcessLister } from '../server/services/process-list-service.mjs';
34
+ import { createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
35
+ import {
36
+ BOOT_ELEVATION_SCRIPT_FILENAME, BOOT_TASK_XML_FILENAME,
37
+ bootTaskLabel, createWindowsBootAutostartService, describeHandMade, describeWindowsAutostart, handMadeLines, switchBlockers,
38
+ } from '../server/services/windows-boot-autostart-service.mjs';
34
39
 
35
40
  function resolvePackageRoot(metaUrl = import.meta.url) {
36
41
  return path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
@@ -841,6 +846,7 @@ function cliContext(options = {}) {
841
846
  isPortFreeImpl: isPortFree,
842
847
  readFileImpl: fs.readFileSync,
843
848
  qrImpl: renderQr,
849
+ pid: process.pid,
844
850
  restartWaitMs: 60_000,
845
851
  stopWaitMs: 30_000,
846
852
  pollMs: 300,
@@ -987,19 +993,171 @@ function windowsAutostart(ctx, paths) {
987
993
  });
988
994
  }
989
995
 
990
- // Windows can also start the relay at boot, through a scheduled task the web
991
- // UI sets up (it needs an elevation); the command line only reports that one.
996
+ // Windows can also start the relay at boot, through a scheduled task (setting
997
+ // it up or taking it out needs an elevation): `oar autostart` or the web UI.
998
+ function windowsBoot(ctx, paths) {
999
+ return ctx.windowsBootAutostartImpl || createWindowsBootAutostartService({
1000
+ platform: ctx.platform, env: ctx.env, packageRoot: ctx.packageRoot, nodePath: ctx.nodeBin, configPath: paths.configPath, logger: null,
1001
+ });
1002
+ }
1003
+
992
1004
  async function windowsBootTaskReady(ctx, paths) {
993
1005
  try {
994
- const service = ctx.windowsBootAutostartImpl || createWindowsBootAutostartService({
995
- platform: ctx.platform, env: ctx.env, packageRoot: ctx.packageRoot, nodePath: ctx.nodeBin, configPath: paths.configPath,
996
- });
997
- return (await service.getState())?.taskStatus === 'ready';
1006
+ return (await windowsBoot(ctx, paths).getState())?.taskStatus === 'ready';
998
1007
  } catch {
999
1008
  return false;
1000
1009
  }
1001
1010
  }
1002
1011
 
1012
+ const AUTOSTART_MODES = ['signin', 'boot', 'off'];
1013
+ const AUTOSTART_TEXT = {
1014
+ boot: 'at boot, before anyone signs in (scheduled task)',
1015
+ signin: 'at sign-in (Startup folder)',
1016
+ off: 'off',
1017
+ };
1018
+
1019
+ /**
1020
+ * This install's Windows autostart (describeWindowsAutostart): the mode
1021
+ * (`unknown` when the boot task cannot be read) and every entry there is.
1022
+ */
1023
+ async function readWindowsAutostart(ctx, paths) {
1024
+ const boot = await windowsBoot(ctx, paths).getState();
1025
+ return { ...describeWindowsAutostart({ signinEntries: windowsAutostart(ctx, paths).listEntries(), boot }), boot };
1026
+ }
1027
+
1028
+ /**
1029
+ * Runs one elevated request of the boot service (`requestEnable` or
1030
+ * `requestDisable`) and waits until the administrator prompt is answered:
1031
+ * `ok` when it was confirmed and the sweep ran.
1032
+ */
1033
+ async function elevated(request, options = {}) {
1034
+ let settle;
1035
+ const settled = new Promise((resolve) => { settle = resolve; });
1036
+ const state = await request({ ...options, onSettled: (ok, extra = {}) => settle({ ...extra, ok }) });
1037
+ if (!state.accepted) return { ok: false, refused: state.refused || state.lastError || 'the boot task cannot be changed now' };
1038
+ return settled;
1039
+ }
1040
+
1041
+ function reportCopies(ctx, copies = []) {
1042
+ for (const { path: original, backup } of copies) ctx.logger.log(`[oar] Kept a copy of ${original} as ${backup}`);
1043
+ }
1044
+
1045
+ /**
1046
+ * One line for an elevation that did not go through: a declined prompt (the
1047
+ * service put everything back), or a sweep that failed (it put back what it
1048
+ * had deleted; the copies stay, and are named).
1049
+ */
1050
+ function notElevated(ctx, result) {
1051
+ if (result.refused) ctx.logger.error(`[oar] Nothing changed: ${result.refused}.`);
1052
+ else if (result.declined) ctx.logger.error(`[oar] Nothing changed: ${result.reason || 'the administrator prompt was declined'}.`);
1053
+ else ctx.logger.error(`[oar] ${result.error || 'The boot task could not be changed.'}`);
1054
+ if (result.unverified) return { code: 1, reason: 'unverified' };
1055
+ return { code: 1, reason: result.declined || result.refused ? 'not-elevated' : 'elevation-failed' };
1056
+ }
1057
+
1058
+ /**
1059
+ * Switches Windows autostart to `target`. Boot mode on or off is one
1060
+ * administrator prompt (the elevated part is only the schtasks call); sign-in
1061
+ * mode needs none. The other mode's entry goes, but only one OAR generated: a
1062
+ * hand-made entry is never deleted or written over, and while one would stay
1063
+ * beside the new mode, or be replaced by it, nothing changes at all
1064
+ * (switchBlockers). `replace` (`--replace`) replaces every hand-made entry,
1065
+ * keeping a copy of each: a launcher's beside it, a task's exported XML and a
1066
+ * Startup entry's in the OAR folder (a copy in the Startup folder would be
1067
+ * opened at sign-in).
1068
+ */
1069
+ async function switchWindowsAutostart(ctx, paths, target, { replace = false } = {}) {
1070
+ const { logger } = ctx;
1071
+ const now = await readWindowsAutostart(ctx, paths);
1072
+ if (now.mode === 'unknown') {
1073
+ logger.error(`[oar] Nothing changed: the boot task could not be read (${now.boot.readError}).`);
1074
+ return { code: 1, reason: 'unknown' };
1075
+ }
1076
+ const blocking = switchBlockers(now, target);
1077
+ if (blocking.length && !replace) {
1078
+ for (const line of handMadeLines(blocking, target)) logger.error(`[oar] ${line}`);
1079
+ return { code: 1, reason: 'hand-made' };
1080
+ }
1081
+ const bootHandMade = replace && now.entries.some((entry) => entry.mode === 'boot' && !entry.generated);
1082
+ const root = paths.layout.root;
1083
+ // A generated launcher that is missing or of an earlier version is
1084
+ // regenerated too (customised, nothing hand-made).
1085
+ const regenerate = now.mode === 'boot' && now.boot.customised && !now.boot.handMade?.length;
1086
+ if (target === 'boot' && (now.mode !== 'boot' || bootHandMade || regenerate)) {
1087
+ logger.log('[oar] Confirm the administrator prompt to register the boot task …');
1088
+ const result = await elevated(windowsBoot(ctx, paths).requestEnable, { replace });
1089
+ if (!result.ok) return notElevated(ctx, result);
1090
+ reportCopies(ctx, result.copies);
1091
+ } else if (target !== 'boot' && (now.mode === 'boot' || bootHandMade)) {
1092
+ const tasks = ['ready', 'foreign'].includes(now.boot.taskStatus) || (replace && now.boot.legacyTaskPresent);
1093
+ if (tasks) logger.log('[oar] Confirm the administrator prompt to remove the boot task …');
1094
+ const result = await elevated(windowsBoot(ctx, paths).requestDisable, { replace });
1095
+ if (!result.ok) return notElevated(ctx, result);
1096
+ reportCopies(ctx, result.copies);
1097
+ }
1098
+ let signin;
1099
+ try {
1100
+ signin = windowsAutostart(ctx, paths).setEnabled(target === 'signin', replace ? { replace, backupDir: root } : {});
1101
+ } catch (error) {
1102
+ logger.error(`[oar] The sign-in entry could not be changed: ${error?.message || error}`);
1103
+ return { code: 1, reason: 'signin-failed' };
1104
+ }
1105
+ reportCopies(ctx, signin.backedUp);
1106
+ if (target === 'boot') logger.log(`[oar] The relay starts at boot, before anyone signs in (${bootTaskLabel(now.boot.taskName)}).`);
1107
+ else if (target === 'off') logger.log('[oar] Autostart is off. A running relay keeps running; stop it with: oar stop');
1108
+ else logger.log(`[oar] The relay starts when you sign in to Windows${signin.kept.length ? ', through a hand-made entry' : ''}.`);
1109
+ // Hand-made entries of the mode picked serve it, and stay.
1110
+ const serving = replace ? [] : now.entries.filter((entry) => !entry.generated && entry.mode === target);
1111
+ for (const entry of serving) logger.log(`[oar] Left as it is: ${describeHandMade(entry)}. To replace it, keeping a copy: oar autostart ${target} --replace`);
1112
+ return { code: 0, reason: `autostart-${target}` };
1113
+ }
1114
+
1115
+ /**
1116
+ * `oar autostart boot|signin|off|status`. On Windows the sign-in entry or the
1117
+ * boot task; on Linux the systemd user service (`oar service`), which starts
1118
+ * at boot with lingering; elsewhere not supported.
1119
+ */
1120
+ export async function runAutostart(options = {}) {
1121
+ const ctx = cliContext(options);
1122
+ const paths = installPaths(ctx);
1123
+ const { logger } = ctx;
1124
+ const target = ctx.argv.find((arg) => [...AUTOSTART_MODES, 'status'].includes(arg)) || 'status';
1125
+ if (paths.layout.checkout) {
1126
+ logger.error('[oar] This is a git checkout — autostart is for global installs.');
1127
+ return { code: 1, reason: 'git-checkout' };
1128
+ }
1129
+ if (ctx.platform === 'linux') {
1130
+ return runService({ ...options, argv: [{ status: 'status', off: 'remove' }[target] || 'install'] });
1131
+ }
1132
+ if (ctx.platform !== 'win32') {
1133
+ logger.error(`[oar] Autostart is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
1134
+ return { code: 1, reason: 'unsupported' };
1135
+ }
1136
+ if (target === 'status') {
1137
+ const now = await readWindowsAutostart(ctx, paths);
1138
+ logger.log(`[oar] Autostart: ${now.mode === 'unknown' ? `unknown, the boot task could not be read (${now.boot.readError})` : AUTOSTART_TEXT[now.mode]}`);
1139
+ for (const entry of now.entries) logger.log(`[oar] ${entry.generated ? `${entry.path} (generated by OAR)` : describeHandMade(entry)}`);
1140
+ if (now.entries.some((entry) => !entry.generated)) {
1141
+ logger.log('[oar] OAR leaves hand-made entries as they are. To replace them with its own, keeping a copy: oar autostart signin|boot|off --replace');
1142
+ }
1143
+ return { code: 0, reason: 'autostart-status', mode: now.mode };
1144
+ }
1145
+ if (target !== 'off' && requireConfig(ctx, paths)) return { code: 1, reason: 'no-config' };
1146
+ return switchWindowsAutostart(ctx, paths, target, { replace: ctx.argv.includes('--replace') });
1147
+ }
1148
+
1149
+ /** `oar setup`'s question; null keeps the mode there is. */
1150
+ async function askAutostart(ctx, paths) {
1151
+ const current = (await readWindowsAutostart(ctx, paths)).mode;
1152
+ if (current === 'unknown') return null;
1153
+ const fallback = AUTOSTART_MODES.indexOf(current) + 1;
1154
+ for (;;) {
1155
+ const answer = await ctx.prompter.line(`Autostart: 1 = at sign-in, 2 = at boot, before anyone signs in (one administrator prompt), 3 = off [${fallback}]: `);
1156
+ if (!answer) return null;
1157
+ if (['1', '2', '3'].includes(answer)) return AUTOSTART_MODES[Number(answer) - 1];
1158
+ }
1159
+ }
1160
+
1003
1161
  /** 'active' (systemd runs the relay), 'installed', 'none', or 'unavailable' on this host. */
1004
1162
  async function serviceState(ctx, paths) {
1005
1163
  if (paths.layout.checkout) return 'unavailable';
@@ -1274,7 +1432,7 @@ async function runWindowsService(ctx, paths, action) {
1274
1432
  return { code: 0, reason: 'service-status' };
1275
1433
  }
1276
1434
  if (boot) {
1277
- logger.error('[oar] The relay starts at boot through a scheduled task. Change that in the web UI: Settings → General → Autostart (Windows).');
1435
+ logger.error('[oar] The relay starts at boot through a scheduled task. Change that with oar autostart signin|off, or in the web UI: Settings → General → Autostart (Windows).');
1278
1436
  return { code: 1, reason: 'boot-task' };
1279
1437
  }
1280
1438
  if (action === 'install' && requireConfig(ctx, paths)) return { code: 1, reason: 'no-config' };
@@ -1373,11 +1531,20 @@ export async function runSetup(options = {}) {
1373
1531
  logger.error('[oar] --lan and --local exclude each other.');
1374
1532
  return { code: 2, reason: 'bad-access' };
1375
1533
  }
1534
+ const autostartFlag = optionValue(argv, '--autostart');
1535
+ if (autostartFlag !== undefined && !AUTOSTART_MODES.includes(autostartFlag)) {
1536
+ logger.error('[oar] --autostart expects signin, boot or off.');
1537
+ return { code: 2, reason: 'bad-autostart' };
1538
+ }
1376
1539
  const terminal = Boolean(ctx.stdin?.isTTY) && !has('--defaults');
1377
- const ask = terminal && !portFlag && !has('--lan') && !has('--local') && !has('--new-token');
1540
+ const ask = terminal && !portFlag && !has('--lan') && !has('--local') && !has('--new-token') && !autostartFlag;
1378
1541
 
1379
1542
  let paths = installPaths(ctx);
1380
1543
  const { layout } = paths;
1544
+ if (autostartFlag && layout.checkout) {
1545
+ logger.error('[oar] --autostart is for global installs; a git checkout has no autostart (start it with: npm start). Nothing was changed.');
1546
+ return { code: 1, reason: 'git-checkout' };
1547
+ }
1381
1548
  if (!layout.checkout) {
1382
1549
  const migrateFrom = String(optionValue(argv, '--migrate-from') || '').trim();
1383
1550
  const stop = await migrateIntoStateRoot({
@@ -1461,6 +1628,14 @@ export async function runSetup(options = {}) {
1461
1628
  fs.writeFileSync(paths.previousPath, `${JSON.stringify({ port: old.port, authToken: old.token })}\n`, { mode: 0o600 });
1462
1629
  }
1463
1630
 
1631
+ // Autostart: as --autostart says, else in a terminal on Windows the
1632
+ // question, with the mode there is as its default.
1633
+ let autostart = null;
1634
+ if (!layout.checkout) {
1635
+ const target = autostartFlag || (ask && ctx.platform === 'win32' ? await askAutostart(ctx, paths) : null);
1636
+ if (target) autostart = await runAutostart({ ...options, argv: [target] });
1637
+ }
1638
+
1464
1639
  const startRequested = has('--start') && !layout.checkout;
1465
1640
  const viaService = running && (await serviceState(ctx, paths)) === 'active';
1466
1641
  if (startRequested && systemdUserAvailable(ctx)) writeSystemdUnit(ctx, paths);
@@ -1480,6 +1655,10 @@ export async function runSetup(options = {}) {
1480
1655
 
1481
1656
  await showAddress(ctx, paths, { qr: true });
1482
1657
  warnWindowsPort(ctx, port, windowsHolder);
1658
+ if (autostart?.code) {
1659
+ logger.error('[oar] The config is written; the autostart step did not go through (see above).');
1660
+ return { code: 1, reason: autostart.reason };
1661
+ }
1483
1662
  if (outcome) return { code: outcome.ok ? 0 : 1, reason: outcome.ok ? 'setup-complete' : outcome.reason };
1484
1663
  if (layout.checkout) {
1485
1664
  logger.log('[oar] Start the relay with: npm start');
@@ -1490,6 +1669,338 @@ export async function runSetup(options = {}) {
1490
1669
  return { code: 0, reason: 'setup-complete' };
1491
1670
  }
1492
1671
 
1672
+ const PACKAGE_NAME = '@oar-sh/oar';
1673
+
1674
+ /**
1675
+ * The detached helper `oar uninstall` leaves behind (`node -e`, arguments:
1676
+ * the pid to outlive, the log, the npm command line): npm cannot remove a
1677
+ * package while it runs, so it waits for `oar` to end, then runs npm and
1678
+ * writes the outcome to the log.
1679
+ */
1680
+ export const UNINSTALL_HELPER_SOURCE = [
1681
+ 'const [pid, log, scope, command, ...args] = process.argv.slice(1);',
1682
+ 'const fs = require("fs"); const path = require("path");',
1683
+ 'const alive = () => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === "EPERM"; } };',
1684
+ 'const until = Date.now() + 120000;',
1685
+ 'const run = () => {',
1686
+ ' if (alive() && Date.now() < until) return setTimeout(run, 250);',
1687
+ ' const result = require("child_process").spawnSync(command, args, { encoding: "utf8", shell: process.platform === "win32", windowsHide: true });',
1688
+ ' let text = new Date().toISOString() + " " + [command, ...args].join(" ") + ": exit " + result.status + "\\n"',
1689
+ ' + (result.stdout || "") + (result.stderr || "") + (result.error ? result.error.message + "\\n" : "");',
1690
+ // npm moves the package to a hidden .oar-* folder in its scope and deletes
1691
+ // that; files a running program holds stay there.
1692
+ ' let left = []; try { left = fs.readdirSync(scope).filter((name) => name.startsWith(".oar-")).map((name) => path.join(scope, name)); } catch {}',
1693
+ ' for (const dir of left) text += "Left behind: " + dir + " holds files a program still ran from (a session worker, or a CLI it started). Delete it once nothing runs from it any more, at the latest after a reboot.\\n";',
1694
+ ' fs.appendFileSync(log, text);',
1695
+ '};',
1696
+ 'setTimeout(run, 500);',
1697
+ ].join('\n');
1698
+
1699
+ /**
1700
+ * The processes other than this one that run from dir (their command line
1701
+ * names it): { pid, commandLine }, or null when the list cannot be read.
1702
+ */
1703
+ async function processesRunningFrom(ctx, dir) {
1704
+ try {
1705
+ const lister = ctx.processListImpl || createProcessLister({ platform: ctx.platform, systemToolPath: (name) => windowsSystemToolPath(name, { env: ctx.env }) });
1706
+ const needle = path.resolve(dir).toLowerCase();
1707
+ return (await lister.list())
1708
+ .map((row) => {
1709
+ const pid = Number(row.processId ?? row.pid);
1710
+ return { pid, commandLine: String(row.commandLine ?? lister.describe?.(pid) ?? '') };
1711
+ })
1712
+ .filter((row) => row.pid && row.pid !== ctx.pid && row.commandLine.toLowerCase().includes(needle));
1713
+ } catch {
1714
+ return null;
1715
+ }
1716
+ }
1717
+
1718
+ function isInside(child, parent) {
1719
+ const relative = path.relative(parent, child);
1720
+ return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
1721
+ }
1722
+
1723
+ // The git checkout `target` lies in, or ''.
1724
+ function checkoutAround(target) {
1725
+ for (let dir = path.resolve(target); ; dir = path.dirname(dir)) {
1726
+ if (isGitCheckout(dir)) return dir;
1727
+ if (path.dirname(dir) === dir) return '';
1728
+ }
1729
+ }
1730
+
1731
+ /**
1732
+ * Why `dir` is no folder `--purge` may delete, or '': a drive or file-system
1733
+ * root, the home folder or a folder above it (`C:\Users`), a system folder or
1734
+ * one above it, a git checkout, or, with `marker`, a folder without it.
1735
+ */
1736
+ function purgeRefusal(ctx, dir, marker = null) {
1737
+ const resolved = path.resolve(dir);
1738
+ const same = (a, b) => (ctx.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
1739
+ const atOrAbove = (target) => target && (same(resolved, path.resolve(target)) || isInside(path.resolve(target).toLowerCase(), resolved.toLowerCase()));
1740
+ if (path.parse(resolved).root === resolved) return 'a drive or file-system root';
1741
+ const homes = [ctx.homeDir, ctx.env.USERPROFILE, ctx.env.HOME].filter(Boolean);
1742
+ if (homes.some(atOrAbove)) return 'the home folder or a folder above it';
1743
+ const system = ctx.platform === 'win32'
1744
+ ? ['SystemRoot', 'windir', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData', 'APPDATA', 'LOCALAPPDATA'].map((name) => readEnvValue(ctx.env, name))
1745
+ : ['/etc', '/usr', '/bin', '/sbin', '/lib', '/var', '/opt', '/home', '/Users', '/System', '/Library', '/Applications'];
1746
+ if (system.some(atOrAbove)) return 'a system folder or a folder above one';
1747
+ const windows = readEnvValue(ctx.env, 'SystemRoot');
1748
+ if (windows && isInside(resolved.toLowerCase(), path.resolve(windows).toLowerCase())) return 'inside the Windows folder';
1749
+ if (isGitCheckout(resolved)) return 'a git checkout';
1750
+ if (marker && !marker(resolved)) return 'no OAR config or database in it';
1751
+ return '';
1752
+ }
1753
+
1754
+ // An OAR state root: its config, or a database in its data folder.
1755
+ const looksLikeStateRoot = (dir) => fs.existsSync(path.join(dir, 'config.json')) || fs.existsSync(path.join(dir, 'data', 'copilot.db'));
1756
+ const looksLikeDataDir = (dir) => fs.existsSync(path.join(dir, 'copilot.db'));
1757
+
1758
+ /**
1759
+ * What `oar uninstall --purge` deletes: the state root (`refused` when
1760
+ * purgeRefusal names a reason), and, each only after a question of its own
1761
+ * (`extra`), the rescued package data, a data folder outside the state root,
1762
+ * and the state root itself when it lies in a git checkout. An extra folder
1763
+ * purgeRefusal refuses is not offered (`skipped`).
1764
+ */
1765
+ function purgePlan(ctx, paths) {
1766
+ const root = path.resolve(paths.layout.root);
1767
+ const refused = purgeRefusal(ctx, root, looksLikeStateRoot);
1768
+ const dataDir = resolveRelayStateDirs({ env: ctx.env, layout: paths.layout }).dataDir;
1769
+ const extra = [];
1770
+ const skipped = [];
1771
+ const add = (target, why, marker = null) => {
1772
+ const resolved = path.resolve(target);
1773
+ if (!fs.existsSync(resolved) || extra.some((item) => item.path === resolved)) return;
1774
+ const refusal = resolved === root ? '' : purgeRefusal(ctx, resolved, marker);
1775
+ if (refusal) skipped.push({ path: resolved, why: refusal });
1776
+ else extra.push({ path: resolved, why });
1777
+ };
1778
+ const rootCheckout = checkoutAround(root);
1779
+ if (rootCheckout) add(root, `the state root, inside the git checkout ${rootCheckout}`);
1780
+ add(path.join(dataDir, RESCUE_DIR_NAME), 'data an earlier version kept inside its package, saved at install time');
1781
+ for (const dir of [dataDir, readConfiguredDataDir({ configPath: paths.configPath })]) {
1782
+ if (!dir || path.resolve(dir) === root || isInside(path.resolve(dir), root)) continue;
1783
+ const checkout = checkoutAround(dir);
1784
+ add(dir, checkout ? `a data folder in the git checkout ${checkout}` : 'a data folder outside the state root', looksLikeDataDir);
1785
+ }
1786
+ return { root, refused, extra, skipped };
1787
+ }
1788
+ // Deletes `target` except the paths in `keep` (and the folders they are in).
1789
+ function deleteTree(target, keep) {
1790
+ if (keep.includes(target)) return;
1791
+ if (!keep.some((kept) => isInside(kept, target))) {
1792
+ fs.rmSync(target, { recursive: true, force: true });
1793
+ return;
1794
+ }
1795
+ for (const name of fs.readdirSync(target)) deleteTree(path.join(target, name), keep);
1796
+ }
1797
+
1798
+ // A unit `oar service install` wrote for this install: the same lines, with
1799
+ // whatever Node and PATH it was written with.
1800
+ function systemdUnitIsGenerated(ctx, paths, text) {
1801
+ const nodeBin = String(text).match(/^ExecStart=(\S+) /m)?.[1] || ctx.nodeBin;
1802
+ const strip = (unit) => String(unit).split('\n').filter((line) => !line.startsWith('Environment="PATH=')).join('\n').trim();
1803
+ return strip(text) === strip(buildSystemdUnit({
1804
+ nodeBin, packageRoot: ctx.packageRoot, configPath: paths.configPath, dataDir: paths.dataDir, logDir: paths.logDir,
1805
+ }));
1806
+ }
1807
+
1808
+ function readText(filePath) {
1809
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
1810
+ }
1811
+
1812
+ /**
1813
+ * Takes out the autostart entries and the Copilot wrapper OAR generated for
1814
+ * this install; returns the hand-made ones it left, or `{ failed }`.
1815
+ */
1816
+ async function removeGeneratedEntries(ctx, paths, done) {
1817
+ const kept = [];
1818
+ if (ctx.platform === 'win32') {
1819
+ const now = await readWindowsAutostart(ctx, paths);
1820
+ if (now.mode === 'unknown') return { failed: `the boot task could not be read (${now.boot.readError})`, left: 'autostart' };
1821
+ if (now.boot.generated) {
1822
+ ctx.logger.log('[oar] Confirm the administrator prompt to remove the boot task …');
1823
+ const result = await elevated(windowsBoot(ctx, paths).requestDisable);
1824
+ if (!result.ok) {
1825
+ if (result.unverified) return { failed: String(result.error).replace(/\.$/, ''), left: 'autostart' };
1826
+ const why = result.refused || (result.declined ? 'the administrator prompt was declined' : result.error || 'the sweep failed');
1827
+ return { failed: `the boot task was not removed: ${why}`, left: 'autostart' };
1828
+ }
1829
+ done.push(`removed the ${bootTaskLabel(now.boot.taskName)}`);
1830
+ }
1831
+ const root = paths.layout.root;
1832
+ const leftovers = [BOOT_TASK_XML_FILENAME, BOOT_ELEVATION_SCRIPT_FILENAME].map((name) => path.join(root, name));
1833
+ // A generated launcher no task can run (a task left as it is may run it).
1834
+ const stray = now.boot.taskStatus === 'missing' && !now.boot.legacyTaskPresent
1835
+ && now.entries.find((entry) => entry.generated && entry.path === now.boot.launcherPath);
1836
+ if (stray) leftovers.push(stray.path);
1837
+ for (const file of leftovers) fs.rmSync(file, { force: true });
1838
+ const signin = now.entries.filter((entry) => entry.mode === 'signin');
1839
+ windowsAutostart(ctx, paths).setEnabled(false);
1840
+ done.push(...signin.filter((entry) => entry.generated).map((entry) => `removed ${entry.path}`));
1841
+ kept.push(...now.entries.filter((entry) => !entry.generated));
1842
+ } else {
1843
+ const unitPath = systemdUnitPath(ctx.homeDir);
1844
+ const unit = readText(unitPath);
1845
+ if (unit !== null && !systemdUnitIsGenerated(ctx, paths, unit)) kept.push({ path: unitPath });
1846
+ else if (unit !== null) {
1847
+ runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
1848
+ fs.rmSync(unitPath, { force: true });
1849
+ runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
1850
+ done.push(`removed ${unitPath}`);
1851
+ }
1852
+ }
1853
+ const wrapper = path.join(getCopilotUserExtensionsDir(ctx.env), 'web-relay', 'extension.mjs');
1854
+ const source = readText(wrapper);
1855
+ if (source !== null && wrapperIsGenerated(ctx, source)) {
1856
+ fs.rmSync(wrapper, { force: true });
1857
+ try { fs.rmdirSync(path.dirname(wrapper)); } catch {}
1858
+ done.push(`removed ${wrapper}`);
1859
+ } else if (source !== null) {
1860
+ const otherInstall = source.match(/^const PACKAGE_ROOT = (".*");$/m)?.[1];
1861
+ const root = otherInstall ? JSON.parse(otherInstall) : '';
1862
+ kept.push({ path: wrapper, otherInstall: root && path.resolve(root) !== path.resolve(ctx.packageRoot) ? root : null });
1863
+ }
1864
+ return { kept };
1865
+ }
1866
+
1867
+ /**
1868
+ * The Copilot wrapper as this version or an earlier one (before the rename,
1869
+ * as copilot-remote) wrote it for this install; the same rule as the
1870
+ * autostart launchers: anything else is hand-made.
1871
+ */
1872
+ function wrapperIsGenerated(ctx, source) {
1873
+ const current = buildGlobalExtensionWrapperSource(path.join(ctx.packageRoot, '.github', 'extensions', 'web-relay', 'extension.mjs'));
1874
+ const earlier = current
1875
+ .replace('Auto-generated by oar ', 'Auto-generated by copilot-remote ')
1876
+ .replace('`oar --install-extension`', '`copilot-remote --install-extension`');
1877
+ return [current, earlier].some((generated) => launcherScriptsMatch(source, generated));
1878
+ }
1879
+
1880
+ /**
1881
+ * `oar uninstall [--purge]` — stops the relay, takes out the autostart
1882
+ * entries OAR generated (never hand-made ones) and has npm remove the
1883
+ * package once this command has ended. The data stays, unless `--purge` and
1884
+ * a typed confirmation say otherwise; folders outside the state root, in a
1885
+ * git checkout or the rescued package data each get a question of their own.
1886
+ * A step that fails ends the command with what is done and what is left.
1887
+ */
1888
+ export async function runUninstall(options = {}) {
1889
+ const ctx = cliContext(options);
1890
+ const paths = installPaths(ctx);
1891
+ const { logger, prompter } = ctx;
1892
+ if (paths.layout.checkout) {
1893
+ logger.error('[oar] This is a git checkout — there is no global package to uninstall.');
1894
+ return { code: 1, reason: 'git-checkout' };
1895
+ }
1896
+ const plan = ctx.argv.includes('--purge') ? purgePlan(ctx, paths) : null;
1897
+ const done = [];
1898
+ const left = ['the package'];
1899
+ if (plan) left.unshift('the data');
1900
+ const stop = (reason, message) => {
1901
+ logger.error(`[oar] ${message}`);
1902
+ if (done.length) logger.error(`[oar] Done: ${done.join('; ')}.`);
1903
+ logger.error(`[oar] Not done: ${left.join('; ')}. Run oar uninstall${plan ? ' --purge' : ''} again once that is sorted out.`);
1904
+ return { code: 1, reason };
1905
+ };
1906
+
1907
+ if (plan) {
1908
+ if (plan.refused) {
1909
+ logger.error(`[oar] --purge deletes nothing in ${plan.root} (${plan.refused}). Nothing was changed.`);
1910
+ return { code: 1, reason: 'purge-unsafe' };
1911
+ }
1912
+ for (const item of plan.skipped) logger.log(`[oar] Not deleted, and not offered: ${item.path} (${item.why}).`);
1913
+ logger.log(`[oar] --purge deletes ${plan.root} for good: the config, the database with every conversation, the uploads and the logs.`);
1914
+ if ((await prompter.line('Type "delete" to confirm: ')) !== 'delete') {
1915
+ logger.log('[oar] Not confirmed. Nothing was changed.');
1916
+ return { code: 1, reason: 'purge-not-confirmed' };
1917
+ }
1918
+ for (const item of plan.extra) {
1919
+ item.remove = await prompter.yesNo(`Also delete ${item.path} (${item.why})?`, { defaultYes: false });
1920
+ }
1921
+ }
1922
+
1923
+ left.unshift('the autostart entries');
1924
+ // A relay may run without answering yet (it holds its lock while it brings
1925
+ // over a database, or while it starts): nothing goes while one may run.
1926
+ // One that does not answer cannot be asked to stop, so that ends it here,
1927
+ // before `oar stop` would call it not running.
1928
+ const holder = () => heldLock(paths.lockPath, ctx.isProcessAliveImpl);
1929
+ const lockedOut = (lock) => {
1930
+ const takeover = readTakeover(paths.lockPath, ctx.isProcessAliveImpl);
1931
+ return stop('relay-running', takeover
1932
+ ? `The relay (process ${lock.pid}) is bringing over its database and does not answer yet; once oar status says it runs, run oar uninstall again.`
1933
+ : `A relay (process ${lock.pid}) holds ${paths.lockPath} without answering; stop it (oar stop, or end process ${lock.pid}).`);
1934
+ };
1935
+ const held = holder();
1936
+ if (held && (readTakeover(paths.lockPath, ctx.isProcessAliveImpl) || !paths.config || (await findRelay(ctx, paths)).answer !== 'oar')) {
1937
+ return lockedOut(held);
1938
+ }
1939
+ if (paths.config) {
1940
+ const stopped = await runStop({ ...options, argv: [] });
1941
+ if (!['stopped', 'not-running'].includes(stopped.reason)) {
1942
+ return stop('relay-running', stopped.reason === 'stop-queued'
1943
+ ? 'The relay finishes its running turns first; it stops by itself after them.'
1944
+ : 'The relay did not stop.');
1945
+ }
1946
+ if (stopped.reason === 'stopped') done.push('stopped the relay');
1947
+ }
1948
+ if (!(await pollUntil(() => !holder(), { waitMs: done.length ? ctx.stopWaitMs : 0, pollMs: ctx.pollMs }))) {
1949
+ return lockedOut(holder() || { pid: '?' });
1950
+ }
1951
+ // Session workers outlive the relay: npm cannot remove the files they hold.
1952
+ const users = await processesRunningFrom(ctx, ctx.packageRoot);
1953
+ let entries;
1954
+ try {
1955
+ entries = await removeGeneratedEntries(ctx, paths, done);
1956
+ } catch (error) {
1957
+ entries = { failed: error?.message || String(error) };
1958
+ }
1959
+ if (entries.failed) return stop('autostart', `Uninstall stopped: ${entries.failed}.`);
1960
+ left.shift();
1961
+
1962
+ if (plan) {
1963
+ const keep = plan.extra.filter((item) => !item.remove).map((item) => item.path);
1964
+ const targets = [plan.root, ...plan.extra.filter((item) => item.remove && !isInside(item.path, plan.root)).map((item) => item.path)];
1965
+ for (const target of targets) {
1966
+ if (keep.includes(target)) continue;
1967
+ try {
1968
+ deleteTree(target, keep);
1969
+ } catch (error) {
1970
+ return stop('purge-failed', `Could not delete all of ${target}: ${error?.message || error}`);
1971
+ }
1972
+ done.push(`deleted ${target}${keep.some((kept) => isInside(kept, target)) ? ' except what you kept' : ''}`);
1973
+ }
1974
+ left.shift();
1975
+ }
1976
+
1977
+ const npm = resolveNpmInvocation({ packageRoot: ctx.packageRoot, env: ctx.env, platform: ctx.platform, execPath: ctx.nodeBin });
1978
+ const logPath = path.join(plan ? os.tmpdir() : paths.logDir, 'oar-uninstall.log');
1979
+ try {
1980
+ if (!plan) fs.mkdirSync(paths.logDir, { recursive: true });
1981
+ const child = ctx.spawnImpl(ctx.nodeBin, [
1982
+ '-e', UNINSTALL_HELPER_SOURCE, String(ctx.pid), logPath, path.dirname(ctx.packageRoot), npm.command, 'uninstall', '-g', ...npm.prefixArgs, PACKAGE_NAME,
1983
+ ], { cwd: ctx.homeDir, env: npm.env, detached: true, stdio: 'ignore', windowsHide: true });
1984
+ child.unref?.();
1985
+ } catch (error) {
1986
+ return stop('npm-failed', `Could not start the package removal (${error?.message || error}); remove it yourself: npm uninstall -g ${PACKAGE_NAME}`);
1987
+ }
1988
+
1989
+ for (const line of done) logger.log(`[oar] Done: ${line}.`);
1990
+ for (const kept of entries.kept) logger.log(`[oar] Left as it is: ${describeHandMade(kept)}`);
1991
+ if (plan) {
1992
+ for (const item of plan.extra.filter((extra) => !extra.remove)) logger.log(`[oar] Kept: ${item.path}`);
1993
+ } else {
1994
+ const dataDir = resolveRelayStateDirs({ env: ctx.env, layout: paths.layout }).dataDir;
1995
+ logger.log(`[oar] Your data stays in ${paths.layout.root}${isInside(path.resolve(dataDir), path.resolve(paths.layout.root)) ? '' : ` and ${dataDir}`} (config, database, uploads, logs). A later install picks it up again; delete the folder yourself if you no longer need it.`);
1996
+ }
1997
+ if (users?.length) {
1998
+ logger.log(`[oar] ${users.length === 1 ? 'A program still runs' : `${users.length} programs still run`} from the package (process ${users.map((user) => user.pid).join(', ')}: a session worker, or a CLI it started). npm cannot remove the files they hold and leaves them in a hidden .oar-* folder in ${path.dirname(ctx.packageRoot)}; delete it once they have ended, at the latest after a reboot. The log names what was left.`);
1999
+ }
2000
+ logger.log(`[oar] npm removes ${PACKAGE_NAME} once this command has ended (log: ${logPath}).`);
2001
+ return { code: 0, reason: 'uninstalled' };
2002
+ }
2003
+
1493
2004
  export async function runDoctor(options = {}) {
1494
2005
  const ctx = cliContext(options);
1495
2006
  const paths = installPaths(ctx);
@@ -1685,6 +2196,8 @@ const COMMANDS = {
1685
2196
  url: runUrl,
1686
2197
  setup: runSetup,
1687
2198
  service: runService,
2199
+ autostart: runAutostart,
2200
+ uninstall: runUninstall,
1688
2201
  copilot: launchRelay,
1689
2202
  doctor: runDoctor,
1690
2203
  update: runUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "!server/**/*.test.mjs",
23
23
  "!server/**/*-test-harness.mjs",
24
24
  "!server/**/fixtures/",
25
+ "!server/README.md",
25
26
  "!server/data/",
26
27
  "!server/logs/",
27
28
  "!server/config.json",
@@ -33,6 +34,7 @@
33
34
  "!shared/**/fixtures/",
34
35
  ".github/extensions/web-relay/",
35
36
  "!.github/extensions/web-relay/**/*.test.mjs",
37
+ "!.github/extensions/web-relay/README.md",
36
38
  "README.md",
37
39
  "CHANGELOG.md",
38
40
  "LICENSE"