@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/CHANGELOG.md +94 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/routes/sessions-routes.mjs +45 -14
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
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 {
|
|
33
|
-
import {
|
|
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
|
|
991
|
-
//
|
|
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
|
-
|
|
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.
|
|
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"
|