@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.
@@ -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 `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
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. Selecting "At system startup" replaces it.`;
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 'A pre-rebrand boot task exists. Selecting "At system startup" migrates it (one admin confirmation).';
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
- if (!windowsBootAutostartService) {
7347
- return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
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 before the sign-in
7356
- // half applies; a declined UAC must not leave both modes armed.
7357
- const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
7358
- let accepted = true;
7359
- if (boot?.supported && (boot.taskStatus === 'ready' || boot.taskStatus === 'foreign' || boot.legacyTaskPresent)) {
7360
- if (boot.taskStatus === 'foreign' && mode === 'signin') {
7361
- // Someone else's task under our name: never delete it as a side
7362
- // effect of picking sign-in mode.
7363
- windowsAutostartService.setEnabled(true);
7364
- return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
7365
- }
7366
- accepted = (await windowsBootAutostartService.requestDisable()).accepted;
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 port; asks, or takes',
99
- ' [--port <port>] [--lan | --local] [--new-token] [--defaults] [--start]',
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
- function setEnabled(enabled) {
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 targetPath = entryPath();
430
- const existed = fsImpl.existsSync(targetPath);
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 removedLegacy = removeLegacyEntry();
433
- if (existed) fsImpl.unlinkSync(targetPath);
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
- removeLegacyEntry();
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: false };
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
  };