@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
|
@@ -1502,6 +1502,11 @@ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
|
|
|
1502
1502
|
'boot-delay': 'boot delay',
|
|
1503
1503
|
launcher: 'launcher edited or from an older OAR',
|
|
1504
1504
|
'launcher-missing': 'launcher missing',
|
|
1505
|
+
user: 'account',
|
|
1506
|
+
'run-level': 'run level',
|
|
1507
|
+
settings: 'settings',
|
|
1508
|
+
other: 'other parts',
|
|
1509
|
+
unreadable: 'unreadable',
|
|
1505
1510
|
});
|
|
1506
1511
|
|
|
1507
1512
|
function windowsAutostartStatusText() {
|
|
@@ -1511,21 +1516,30 @@ function windowsAutostartStatusText() {
|
|
|
1511
1516
|
}
|
|
1512
1517
|
if (boot?.lastError) return boot.lastError;
|
|
1513
1518
|
if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
|
|
1519
|
+
const [startupEntry] = windowsAutostartState?.signinHandMade || [];
|
|
1520
|
+
if (startupEntry) {
|
|
1521
|
+
const starts = startupEntry.otherInstall ? ` (it starts the OAR install in ${startupEntry.otherInstall})` : '';
|
|
1522
|
+
return `A Startup entry OAR did not make is there: ${startupEntry.path}${starts}. OAR leaves it as it is; to replace it, keeping a copy: oar autostart signin --replace.`;
|
|
1523
|
+
}
|
|
1514
1524
|
if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
|
|
1515
1525
|
return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
|
|
1516
1526
|
}
|
|
1527
|
+
// OAR never deletes or writes over an entry it did not make (as `oar autostart`).
|
|
1528
|
+
const replaceHint = 'OAR leaves it as it is; to replace it, keeping a copy: oar autostart boot --replace';
|
|
1517
1529
|
if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
|
|
1518
1530
|
const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
|
|
1519
|
-
return
|
|
1531
|
+
return boot.handMade?.length
|
|
1532
|
+
? `The boot task or its launcher was changed by hand (${labels.join(', ')}). ${replaceHint}.`
|
|
1533
|
+
: `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates it.`;
|
|
1520
1534
|
}
|
|
1521
1535
|
if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
|
|
1522
1536
|
return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
|
|
1523
1537
|
}
|
|
1524
1538
|
if (boot?.taskStatus === 'foreign') {
|
|
1525
|
-
return `A task named "${boot.taskName}" exists but was not created by OAR.
|
|
1539
|
+
return `A task named "${boot.taskName}" exists but was not created by OAR. ${replaceHint}.`;
|
|
1526
1540
|
}
|
|
1527
1541
|
if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
|
|
1528
|
-
return
|
|
1542
|
+
return `A pre-rebrand boot task exists. ${replaceHint}.`;
|
|
1529
1543
|
}
|
|
1530
1544
|
return '';
|
|
1531
1545
|
}
|
|
@@ -9,6 +9,7 @@ import { randomUUID } from 'crypto';
|
|
|
9
9
|
import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
|
|
10
10
|
import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
|
+
import { describeWindowsAutostart, handMadeLines, switchBlockers } from '../services/windows-boot-autostart-service.mjs';
|
|
12
13
|
import {
|
|
13
14
|
buildConversationHistoryPageInfo,
|
|
14
15
|
normalizeConversationHistoryLimit,
|
|
@@ -7299,6 +7300,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7299
7300
|
legacyTaskPresent: boot.legacyTaskPresent,
|
|
7300
7301
|
customised: boot.customised === true,
|
|
7301
7302
|
customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
|
|
7303
|
+
handMade: Array.isArray(boot.handMade) ? boot.handMade : [],
|
|
7302
7304
|
launcherBackupPath: boot.launcherBackupPath || null,
|
|
7303
7305
|
readError: boot.readError || null,
|
|
7304
7306
|
pendingElevation: boot.pendingElevation || null,
|
|
@@ -7306,6 +7308,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7306
7308
|
manualCommand: boot.manualCommand || null,
|
|
7307
7309
|
} : null,
|
|
7308
7310
|
signinEnabled: signin.enabled,
|
|
7311
|
+
// The Startup entries OAR did not make, which it leaves as they are.
|
|
7312
|
+
signinHandMade: (windowsAutostartService.listEntries?.() || []).filter((entry) => !entry.generated)
|
|
7313
|
+
.map(({ path: entryPath, otherInstall }) => ({ path: entryPath, otherInstall: otherInstall || null })),
|
|
7309
7314
|
};
|
|
7310
7315
|
}
|
|
7311
7316
|
|
|
@@ -7342,9 +7347,31 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7342
7347
|
if (!windowsAutostartService.getState().supported) {
|
|
7343
7348
|
return res.status(400).json({ error: 'Windows autostart is only available on Windows' });
|
|
7344
7349
|
}
|
|
7350
|
+
if (mode === 'boot' && !windowsBootAutostartService) {
|
|
7351
|
+
return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
|
|
7352
|
+
}
|
|
7353
|
+
// The rules of `oar autostart`: only entries OAR generated are deleted
|
|
7354
|
+
// or written over. A hand-made one (a Startup entry, our task or its
|
|
7355
|
+
// launcher changed by hand, a task someone else made) that would stay
|
|
7356
|
+
// beside the new mode, or be replaced by it, stops the switch with the
|
|
7357
|
+
// same message; replacing it is `oar autostart <mode> --replace`.
|
|
7358
|
+
const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
|
|
7359
|
+
const now = describeWindowsAutostart({
|
|
7360
|
+
signinEntries: windowsAutostartService.listEntries?.() || [],
|
|
7361
|
+
boot: boot?.supported ? boot : {},
|
|
7362
|
+
});
|
|
7363
|
+
if (now.mode === 'unknown') {
|
|
7364
|
+
return res.status(409).json({ error: 'The boot task could not be read, so nothing was changed; the relay log has the details.' });
|
|
7365
|
+
}
|
|
7366
|
+
const blocking = switchBlockers(now, mode);
|
|
7367
|
+
if (blocking.length) return res.status(409).json({ error: handMadeLines(blocking, mode).join(' ') });
|
|
7345
7368
|
if (mode === 'boot') {
|
|
7346
|
-
|
|
7347
|
-
|
|
7369
|
+
// A generated launcher that is missing or of an earlier version is
|
|
7370
|
+
// regenerated by enabling again; a hand-made one stays.
|
|
7371
|
+
const regenerate = boot.customised && !boot.handMade?.length;
|
|
7372
|
+
if (now.mode === 'boot' && !regenerate) {
|
|
7373
|
+
windowsAutostartService.setEnabled(false);
|
|
7374
|
+
return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
|
|
7348
7375
|
}
|
|
7349
7376
|
// The sweep's completion also removes the Startup .cmd entries; the
|
|
7350
7377
|
// sign-in service is not touched here so a declined UAC leaves the
|
|
@@ -7352,19 +7379,23 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7352
7379
|
const result = await windowsBootAutostartService.requestEnable();
|
|
7353
7380
|
return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
|
|
7354
7381
|
}
|
|
7355
|
-
// Leaving boot mode needs its own elevated sweep
|
|
7356
|
-
//
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7382
|
+
// Leaving boot mode needs its own elevated sweep; the sign-in half
|
|
7383
|
+
// applies once the prompt was confirmed and the task is gone, so a
|
|
7384
|
+
// declined or failed one leaves the earlier mode as it was.
|
|
7385
|
+
if (now.mode === 'boot') {
|
|
7386
|
+
const result = await windowsBootAutostartService.requestDisable({
|
|
7387
|
+
onSettled: (ok) => {
|
|
7388
|
+
if (!ok) return;
|
|
7389
|
+
try {
|
|
7390
|
+
windowsAutostartService.setEnabled(mode === 'signin');
|
|
7391
|
+
} catch (error) {
|
|
7392
|
+
console.warn(`[autostart] the boot task is gone, but the sign-in entry could not be changed: ${error?.message || error}`);
|
|
7393
|
+
}
|
|
7394
|
+
},
|
|
7395
|
+
});
|
|
7396
|
+
return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
|
|
7367
7397
|
}
|
|
7398
|
+
const accepted = true;
|
|
7368
7399
|
windowsAutostartService.setEnabled(mode === 'signin');
|
|
7369
7400
|
return res.json({ ...(await windowsAutostartMergedState()), accepted });
|
|
7370
7401
|
} catch (error) {
|
|
@@ -66,7 +66,7 @@ export function describeRelayAddress({ config, lanAddress = null, wslNat = false
|
|
|
66
66
|
return { url, lines, qrUrl, tunnelUrl };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const VALUE_OPTIONS = ['--port', '--migrate-from', '--to'];
|
|
69
|
+
const VALUE_OPTIONS = ['--port', '--migrate-from', '--to', '--autostart'];
|
|
70
70
|
const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
|
|
71
71
|
const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
|
|
72
72
|
const CLI_COMMANDS = {
|
|
@@ -79,8 +79,10 @@ const CLI_COMMANDS = {
|
|
|
79
79
|
doctor: {},
|
|
80
80
|
help: {},
|
|
81
81
|
version: {},
|
|
82
|
-
setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from'] },
|
|
82
|
+
setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from', '--autostart'] },
|
|
83
83
|
service: { words: ['install', 'remove', 'status'] },
|
|
84
|
+
autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
|
|
85
|
+
uninstall: { flags: ['--purge'] },
|
|
84
86
|
update: { flags: ['--beta'], values: ['--to'] },
|
|
85
87
|
copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
|
|
86
88
|
};
|
|
@@ -95,10 +97,16 @@ export function usageText() {
|
|
|
95
97
|
' oar restart Restart the relay (waits for running turns)',
|
|
96
98
|
' oar status Is the relay running, on which port, as a service',
|
|
97
99
|
' oar url Relay URL, with a QR code when a phone can reach it',
|
|
98
|
-
' oar setup Auth token, access and
|
|
99
|
-
' [--port <port>] [--lan | --local] [--new-token] [--
|
|
100
|
+
' oar setup Auth token, access, port and autostart; asks, or takes',
|
|
101
|
+
' [--port <port>] [--lan | --local] [--new-token] [--autostart signin|boot|off]',
|
|
102
|
+
' [--defaults] [--start]',
|
|
103
|
+
' oar autostart signin | boot | off | status [--replace]',
|
|
104
|
+
' Start the relay at sign-in or at boot (Windows: boot asks for an admin once);',
|
|
105
|
+
' --replace replaces hand-made entries, keeping a copy',
|
|
100
106
|
' oar service install | remove | status',
|
|
101
107
|
' Start the relay at login',
|
|
108
|
+
' oar uninstall [--purge]',
|
|
109
|
+
' Remove autostart and the package; the data stays (--purge deletes it)',
|
|
102
110
|
' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
|
|
103
111
|
' Relay plus a Copilot terminal session',
|
|
104
112
|
' oar doctor Check the install and the provider CLIs',
|
|
@@ -191,6 +191,31 @@ export function readLauncherNodePath(script, serverPath) {
|
|
|
191
191
|
return null;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The package folder of another OAR install a launcher starts (its Node line
|
|
196
|
+
* runs a `server\server.js` other than `packageRoot`'s), or null.
|
|
197
|
+
*/
|
|
198
|
+
export function otherInstallOf(script, packageRoot, pathImpl = path) {
|
|
199
|
+
const own = pathImpl.resolve(String(packageRoot || '')).toLowerCase();
|
|
200
|
+
for (const line of String(script == null ? '' : script).split(/\r?\n/)) {
|
|
201
|
+
const match = line.trimEnd().match(/^"[^"]+" "([^"]+)[\\/]server[\\/]server\.js"(?: & exit(?: \/b)?)?$/i);
|
|
202
|
+
if (match && pathImpl.resolve(match[1].replaceAll('%%', '%')).toLowerCase() !== own) return match[1].replaceAll('%%', '%');
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Where a copy of `target` goes before `--replace` writes over or removes
|
|
209
|
+
* it: `<name>.bak` in `dir`, numbered when one is there already, so no
|
|
210
|
+
* earlier copy is lost.
|
|
211
|
+
*/
|
|
212
|
+
export function backupPathFor(target, dir, fsImpl = fs, pathImpl = path) {
|
|
213
|
+
const base = pathImpl.join(dir, `${pathImpl.basename(target)}.bak`);
|
|
214
|
+
let candidate = base;
|
|
215
|
+
for (let index = 2; fsImpl.existsSync(candidate); index += 1) candidate = `${base}-${index}`;
|
|
216
|
+
return candidate;
|
|
217
|
+
}
|
|
218
|
+
|
|
194
219
|
/**
|
|
195
220
|
* Same launcher, whatever the line endings, trailing blanks and the length
|
|
196
221
|
* of the padding. `ignoreCase` for telling whether a launcher is one an
|
|
@@ -346,13 +371,6 @@ export function createWindowsAutostartService({
|
|
|
346
371
|
};
|
|
347
372
|
}
|
|
348
373
|
|
|
349
|
-
function removeLegacyEntry() {
|
|
350
|
-
const target = legacyEntryPath();
|
|
351
|
-
if (!fsImpl.existsSync(target)) return false;
|
|
352
|
-
fsImpl.unlinkSync(target);
|
|
353
|
-
return true;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
374
|
const workingDirectory = () => resolveOarRoot(env, platform);
|
|
357
375
|
const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
|
|
358
376
|
|
|
@@ -418,7 +436,46 @@ export function createWindowsAutostartService({
|
|
|
418
436
|
return changed;
|
|
419
437
|
}
|
|
420
438
|
|
|
421
|
-
|
|
439
|
+
/**
|
|
440
|
+
* Whether `script` is an entry OAR generates or generated for this install
|
|
441
|
+
* and its config (with whatever Node it runs). Anything else, an entry
|
|
442
|
+
* edited by hand or one of another install, is hand-made.
|
|
443
|
+
*/
|
|
444
|
+
function isGenerated(script) {
|
|
445
|
+
const entryNodePath = readLauncherNodePath(script, serverPath());
|
|
446
|
+
if (!entryNodePath) return false;
|
|
447
|
+
const stateDirs = resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
|
|
448
|
+
return launcherScriptsMatch(script, buildScript({ stateDirs, nodePathOverride: entryNodePath }))
|
|
449
|
+
|| buildEarlierWindowsAutostartScripts({
|
|
450
|
+
packageRoot, nodePath: entryNodePath, configPath, ...stateDirs, workingDirectory: workingDirectory(), pathImpl,
|
|
451
|
+
}).some((earlier) => launcherScriptsMatch(script, earlier, { ignoreCase: true }));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The sign-in entries there are, each `{ path, generated, otherInstall }`:
|
|
456
|
+
* `otherInstall` is the package folder of the other OAR install a
|
|
457
|
+
* hand-made entry starts (an entry left from a moved or second install).
|
|
458
|
+
*/
|
|
459
|
+
function listEntries() {
|
|
460
|
+
if (!supported) return [];
|
|
461
|
+
return [entryPath(), legacyEntryPath()]
|
|
462
|
+
.filter((target) => fsImpl.existsSync(target))
|
|
463
|
+
.map((target) => {
|
|
464
|
+
const script = String(fsImpl.readFileSync(target, 'utf8'));
|
|
465
|
+
const generated = isGenerated(script);
|
|
466
|
+
return { path: target, generated, otherInstall: generated ? null : otherInstallOf(script, packageRoot, pathImpl) };
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Switches the sign-in entry on or off. Only entries OAR generated are
|
|
472
|
+
* written over or removed: a hand-made one stays byte-identical and is
|
|
473
|
+
* returned in `kept`, and while one is there no second entry is added.
|
|
474
|
+
* `replace` (with `backupDir`, never inside the Startup folder, which
|
|
475
|
+
* would open the copy at sign-in) first moves each hand-made entry there
|
|
476
|
+
* as `<name>.bak`, listed in `backedUp`.
|
|
477
|
+
*/
|
|
478
|
+
function setEnabled(enabled, { replace = false, backupDir = '' } = {}) {
|
|
422
479
|
if (!supported) {
|
|
423
480
|
throw new Error('Windows autostart is only available on Windows.');
|
|
424
481
|
}
|
|
@@ -426,28 +483,43 @@ export function createWindowsAutostartService({
|
|
|
426
483
|
throw new TypeError('Windows autostart enabled state must be a boolean.');
|
|
427
484
|
}
|
|
428
485
|
|
|
429
|
-
const
|
|
430
|
-
|
|
486
|
+
const backedUp = [];
|
|
487
|
+
if (replace) {
|
|
488
|
+
if (!String(backupDir || '').trim()) throw new Error('Replacing a hand-made entry needs a folder for its copy.');
|
|
489
|
+
for (const entry of listEntries().filter((listed) => !listed.generated)) {
|
|
490
|
+
fsImpl.mkdirSync(backupDir, { recursive: true });
|
|
491
|
+
const copy = backupPathFor(entry.path, backupDir, fsImpl, pathImpl);
|
|
492
|
+
fsImpl.copyFileSync(entry.path, copy);
|
|
493
|
+
fsImpl.unlinkSync(entry.path);
|
|
494
|
+
backedUp.push({ path: entry.path, backup: copy });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const entries = listEntries();
|
|
498
|
+
const kept = entries.filter((entry) => !entry.generated).map((entry) => entry.path);
|
|
499
|
+
const remove = (entry) => { fsImpl.unlinkSync(entry.path); return true; };
|
|
431
500
|
if (!enabled) {
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
return { ...getState(), changed: existed || removedLegacy };
|
|
501
|
+
const removed = entries.filter((entry) => entry.generated).map(remove);
|
|
502
|
+
return { ...getState(), changed: removed.length + backedUp.length > 0, kept, backedUp };
|
|
435
503
|
}
|
|
504
|
+
if (kept.length) return { ...getState(), changed: false, kept, backedUp };
|
|
436
505
|
// Enabling always converges on the one current filename; if only the
|
|
437
506
|
// legacy entry existed, the current-name write below reports the change.
|
|
438
|
-
|
|
507
|
+
entries.filter((entry) => entry.path === legacyEntryPath()).forEach(remove);
|
|
439
508
|
|
|
509
|
+
const targetPath = entryPath();
|
|
510
|
+
const existed = fsImpl.existsSync(targetPath);
|
|
440
511
|
const current = existed ? String(fsImpl.readFileSync(targetPath, 'utf8')) : '';
|
|
441
512
|
if (existed && launcherScriptsMatch(current, buildScript())) {
|
|
442
|
-
return { ...getState(), changed:
|
|
513
|
+
return { ...getState(), changed: backedUp.length > 0, kept, backedUp };
|
|
443
514
|
}
|
|
444
515
|
|
|
445
516
|
writeEntry(targetPath, buildScript({ coverBytes: Buffer.byteLength(current, 'utf8') }));
|
|
446
|
-
return { ...getState(), changed: true };
|
|
517
|
+
return { ...getState(), changed: true, kept, backedUp };
|
|
447
518
|
}
|
|
448
519
|
|
|
449
520
|
return {
|
|
450
521
|
getState,
|
|
522
|
+
listEntries,
|
|
451
523
|
setEnabled,
|
|
452
524
|
refreshInstalledEntry,
|
|
453
525
|
};
|