@oar-sh/oar 0.9.13 → 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.
Files changed (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. package/server/uploads/image_input.md +0 -540
package/bin/oar.js CHANGED
@@ -11,8 +11,17 @@ import net from 'net';
11
11
 
12
12
  import {
13
13
  migrateStateToOarRoot,
14
- resolveOarRoot,
15
14
  } from '../server/services/oar-state-migration-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
+ import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
18
+ import {
19
+ getLauncherLogDir,
20
+ getLegacyManagedConfigDir,
21
+ isGitCheckout,
22
+ resolveRelayStateDirs,
23
+ resolveStateLayout,
24
+ } from '../server/services/oar-state-layout.mjs';
16
25
  import {
17
26
  buildDefaultConfig, buildServicePath, buildSystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
18
27
  NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, primaryLanAddress, renderDoctorReport, resolveNpmInvocation, usageText,
@@ -20,8 +29,13 @@ import {
20
29
  import { describeConfiguredTunnel, describeTunnelState } from '../server/services/cloudflared-tunnel-service.mjs';
21
30
  import { createPrompter, PromptAborted, runMenu } from '../server/services/oar-cli-menu.mjs';
22
31
  import { createWindowsPortCheck, isWsl, isWslNat, windowsPortWarning } from '../server/services/oar-cli-wsl.mjs';
23
- import { createWindowsAutostartService } from '../server/services/windows-autostart-service.mjs';
24
- import { createWindowsBootAutostartService } from '../server/services/windows-boot-autostart-service.mjs';
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';
25
39
 
26
40
  function resolvePackageRoot(metaUrl = import.meta.url) {
27
41
  return path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
@@ -56,20 +70,42 @@ function readRelayLock(lockPath) {
56
70
  pid: Number.parseInt(String(parsed?.pid ?? ''), 10),
57
71
  startedAt: typeof parsed?.startedAt === 'string' ? parsed.startedAt : null,
58
72
  token: typeof parsed?.token === 'string' ? parsed.token : '',
73
+ format: Number(parsed?.format) || 0,
59
74
  };
60
75
  } catch {
61
76
  return null;
62
77
  }
63
78
  }
64
79
 
80
+ // What a relay that holds its lock and does not answer yet is bringing over
81
+ // (earlier-database-service), or null: the progress file names the lock's
82
+ // pid, and that pid is the lock's holder still (lockHolderMayRun: a pid
83
+ // reused after a crash is not).
84
+ function readTakeover(lockPath, isProcessAliveImpl = isProcessAlive) {
85
+ const progress = readJsonFile(path.join(path.dirname(lockPath), TAKEOVER_PROGRESS_FILE));
86
+ const lock = readJsonFile(lockPath);
87
+ const pid = Number.parseInt(String(progress?.pid ?? ''), 10);
88
+ return Number.isInteger(pid) && Number(lock?.pid) === pid && lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl }) ? progress : null;
89
+ }
90
+
91
+ // The lock a relay that did not come up found held, and whether its holder
92
+ // is known to be a relay (its recorded identity matches): only when it is
93
+ // not may the user delete the lock. Null when no live process holds it.
94
+ function heldLock(lockPath, isProcessAliveImpl = isProcessAlive) {
95
+ const lock = readJsonFile(lockPath);
96
+ const pid = Number.parseInt(String(lock?.pid ?? ''), 10);
97
+ if (!Number.isInteger(pid) || !lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl })) return null;
98
+ return { pid, path: lockPath, startedAt: lock.startedAt || 'unknown', known: !!lock.holder && !!processIdentity(pid) };
99
+ }
100
+
101
+ function takeoverLine(takeover, logDir) {
102
+ const what = takeover ? ` (${takeover.from}, ${Math.max(1, Math.round(Number(takeover.bytes || 0) / 1048576))} MB)` : '';
103
+ return `[oar] The relay${takeover ? ` (process ${takeover.pid})` : ''} is bringing over the database it ran on until now${what} and is still running; it answers once that is done. Its log: ${path.join(String(logDir || ''), 'server.log')}`;
104
+ }
105
+
106
+ // As the server tells it: a process this user may not signal (EPERM) lives.
65
107
  function isProcessAlive(pid) {
66
- if (!Number.isInteger(pid) || pid <= 0) return false;
67
- try {
68
- process.kill(pid, 0);
69
- return true;
70
- } catch {
71
- return false;
72
- }
108
+ return defaultIsProcessAlive(pid);
73
109
  }
74
110
 
75
111
  function readJsonFile(filePath) {
@@ -91,45 +127,66 @@ function openAppendFileDescriptor(filePath) {
91
127
  return fs.openSync(filePath, 'a');
92
128
  }
93
129
 
94
- // Pre-OAR managed config location — kept only as a migration source and as the
95
- // config home for git checkouts, where nothing may change underfoot.
96
- function getLegacyManagedConfigDir(env = process.env) {
97
- if (process.platform === 'win32') {
98
- return path.join(
99
- env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
100
- 'copilot-remote',
101
- );
102
- }
103
- return path.join(
104
- env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
105
- 'copilot-remote',
106
- );
107
- }
108
-
109
- function isGitCheckout(packageRoot) {
110
- try { return fs.existsSync(path.join(packageRoot, '.git')); } catch { return false; }
111
- }
112
-
113
130
  /**
114
- * Where launcher-managed state lives. A git checkout keeps every pre-OAR
115
- * default (repo-local server/data, legacy managed config) so development and
116
- * the two existing machines change nothing. A global install gets the OAR
117
- * state root: config, data, logs all under ~/.oar (APPDATA\oar on Windows),
118
- * which survives `npm i -g` updates.
131
+ * Brings pre-OAR state into a global install's state root. Without
132
+ * --migrate-from it runs quietly, once; with it, it runs unless that source
133
+ * was migrated already, never overwrites, and says what it did or why it did
134
+ * nothing. Database and uploads go where the relay `oar` starts will read
135
+ * them, resolved as the relay resolves them. Returns null to go on, or the
136
+ * result to stop with. A refusal stops only `oar setup` (`strict`): `oar
137
+ * copilot` warns and starts the session on the state root as it is.
119
138
  */
120
- function resolveStateLayout({ packageRoot, env = process.env } = {}) {
121
- if (isGitCheckout(packageRoot)) {
122
- return { checkout: true, root: null, configDir: getLegacyManagedConfigDir(env), dataDir: null };
139
+ async function migrateIntoStateRoot({
140
+ layout, env, packageRoot, migrateFrom = '', logger, isProcessAliveImpl = isProcessAlive, strict = true,
141
+ }) {
142
+ const serverDir = path.join(packageRoot, 'server');
143
+ const relayEnv = { COPILOT_WEB_RELAY_DATA_DIR: resolveRelayStateDirs({ env, layout }).dataDir };
144
+ const dataDir = resolveDataDir({ env: relayEnv, serverDir });
145
+ const source = migrateFrom ? path.resolve(migrateFrom) : '';
146
+ let migration;
147
+ try {
148
+ migration = await migrateStateToOarRoot({
149
+ targetRoot: layout.root,
150
+ targetDataDir: dataDir,
151
+ targetUploadDir: resolveUploadDir({ dataDir, env: relayEnv, serverDir }),
152
+ repoServerDir: source ? path.join(source, 'server') : null,
153
+ managedConfigDir: getLegacyManagedConfigDir(env),
154
+ isProcessAliveImpl,
155
+ });
156
+ } catch (error) {
157
+ logger.error?.(`[oar] State migration failed, nothing was changed at the source: ${error?.message || error}`);
158
+ return { code: 1, reason: 'migration-failed', error };
159
+ }
160
+ const refused = (message, reason) => {
161
+ logger.error?.(`[oar] ${message}`);
162
+ if (strict) return { code: 1, reason };
163
+ logger.error?.('[oar] Nothing was migrated; starting on the state root as it is.');
164
+ return null;
165
+ };
166
+ switch (migration.status) {
167
+ case 'blocked-live-relay': return refused(migration.error, 'migration-blocked');
168
+ case 'refused-relay-running': return refused(migration.error, 'migration-relay-running');
169
+ case 'refused-existing-database': return refused(migration.error, 'migration-refused');
170
+ case 'nothing-to-migrate':
171
+ return source ? refused(`Nothing to migrate from ${source}: ${migration.reason}.`, 'migration-nothing') : null;
172
+ case 'already-migrated':
173
+ if (source) logger.log?.(`[oar] ${layout.root} is already migrated from ${source}; nothing to do.`);
174
+ return null;
175
+ case 'migrated':
176
+ if (migration.movedAsideTo) {
177
+ logger.log?.(`[oar] The state root's database had no conversations yet (a fresh one); moved it aside to ${migration.movedAsideTo}.`);
178
+ }
179
+ logger.log?.(`[oar] Migrated ${source || 'earlier relay state'} into ${layout.root} (${migration.copied} items, sources untouched; receipt in backups/MIGRATION.md).`);
180
+ for (const kept of migration.kept || []) {
181
+ logger.log?.(`[oar] Kept the existing ${kept}; the one from the source was not copied.`);
182
+ }
183
+ for (const link of migration.skipped || []) {
184
+ logger.log?.(`[oar] Skipped the link ${link}: what it points to was not copied.`);
185
+ }
186
+ return null;
187
+ default:
188
+ return null;
123
189
  }
124
- const root = resolveOarRoot(env);
125
- return { checkout: false, root, configDir: root, dataDir: path.join(root, 'data') };
126
- }
127
-
128
- function getLauncherLogDir(env = process.env, layout = null) {
129
- const envLogDir = String(env.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
130
- if (envLogDir) return envLogDir;
131
- if (layout && !layout.checkout) return path.join(layout.root, 'logs');
132
- return path.join(getLegacyManagedConfigDir(env), 'logs');
133
190
  }
134
191
 
135
192
  function getCopilotHomeDir(env = process.env) {
@@ -278,6 +335,7 @@ export async function launchRelay({
278
335
  fetchImpl = globalThis.fetch,
279
336
  isProcessAliveImpl = isProcessAlive,
280
337
  lockGraceMs = 5000,
338
+ readyWaitMs = 20_000,
281
339
  env = process.env,
282
340
  logger = console,
283
341
  } = {}) {
@@ -328,22 +386,11 @@ export async function launchRelay({
328
386
 
329
387
  if (!layout.checkout) {
330
388
  // Global install: state lives in the OAR root. Migrate pre-OAR state in
331
- // exactly once (marker-guarded); a live source relay blocks the migration.
332
- try {
333
- const migration = await migrateStateToOarRoot({
334
- targetRoot: layout.root,
335
- repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
336
- managedConfigDir: getLegacyManagedConfigDir(env),
337
- logger,
338
- });
339
- if (migration.status === 'blocked-live-relay') {
340
- logger.error?.(`[oar] ${migration.error}`);
341
- return { code: 1, reason: 'migration-blocked' };
342
- }
343
- } catch (error) {
344
- logger.error?.(`[oar] State migration failed, nothing was changed at the source: ${error?.message || error}`);
345
- return { code: 1, reason: 'migration-failed', error };
346
- }
389
+ // (once, or as --migrate-from asks); a live source relay blocks it.
390
+ const stop = await migrateIntoStateRoot({
391
+ layout, env, packageRoot, migrateFrom: migrateFromRoot, logger, isProcessAliveImpl, strict: false,
392
+ });
393
+ if (stop) return stop;
347
394
  }
348
395
 
349
396
  const lockPath = layout.checkout
@@ -372,14 +419,21 @@ export async function launchRelay({
372
419
  ? await probeRelayPort(probe)
373
420
  : await probeRelayBehindLock({ ...probe, lockPath, port, isProcessAliveImpl, lockGraceMs, logger });
374
421
  if (answer === 'taken') {
375
- logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
422
+ logger.error?.(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
376
423
  logger.error?.('[oar] Stop that program, or pick another port: oar copilot --port <port>');
377
424
  return { code: 1, reason: 'port-taken' };
378
425
  }
426
+ if (answer === 'bringing-over') {
427
+ logger.log(takeoverLine(readTakeover(lockPath, isProcessAliveImpl), getLauncherLogDir(env, layout)));
428
+ return { code: 1, reason: 'bringing-over' };
429
+ }
379
430
  if (runtimeConfig.managed || !fs.existsSync(runtimeConfig.configPath)) {
380
431
  writeJsonFile(runtimeConfig.configPath, runtimeConfig.config);
381
432
  }
382
433
 
434
+ // Checkouts keep the repo-local server/data default; global installs point
435
+ // the server at the OAR root so updates cannot wipe the database.
436
+ const stateDirs = resolveRelayStateDirs({ env, layout });
383
437
  const baseEnv = {
384
438
  ...env,
385
439
  COPILOT_WORKSPACE_ROOT: cwd,
@@ -387,11 +441,7 @@ export async function launchRelay({
387
441
  COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
388
442
  COPILOT_WEB_RELAY_CONFIG: runtimeConfig.configPath,
389
443
  COPILOT_WEB_RELAY_LOG_DIR: getLauncherLogDir(env, layout),
390
- // Checkouts keep the repo-local server/data default; global installs point
391
- // the server at the OAR root so updates cannot wipe the database.
392
- ...(layout.checkout ? {} : {
393
- COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
394
- }),
444
+ ...(stateDirs.dataDir ? { COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir } : {}),
395
445
  GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS: String(env.GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS || 'true'),
396
446
  };
397
447
 
@@ -429,7 +479,7 @@ export async function launchRelay({
429
479
  return { code: 1, reason: 'spawn-invalid-child' };
430
480
  }
431
481
 
432
- const readyDeadlineMs = 20_000;
482
+ const readyDeadlineMs = readyWaitMs;
433
483
  const ready = await new Promise((resolve) => {
434
484
  const deadline = Date.now() + readyDeadlineMs;
435
485
  const probe = async () => {
@@ -457,6 +507,14 @@ export async function launchRelay({
457
507
  probe();
458
508
  });
459
509
 
510
+ const takeover = ready ? null : readTakeover(lockPath, isProcessAliveImpl);
511
+ if (takeover) {
512
+ // Left running: a kill would cut the copy short for nothing.
513
+ logger.log(takeoverLine(takeover, logDir));
514
+ try { fs.closeSync(serverOutFd); } catch {}
515
+ try { fs.closeSync(serverErrFd); } catch {}
516
+ return { code: 1, reason: 'bringing-over' };
517
+ }
460
518
  if (!ready) {
461
519
  logger.error?.(`[oar] Relay did not become ready at ${statusUrl}`);
462
520
  try {
@@ -667,7 +725,11 @@ async function probeRelayBehindLock({
667
725
  await new Promise((resolve) => setTimeout(resolve, 300));
668
726
  answer = await probeRelayPort({ statusUrl, token, fetchImpl });
669
727
  }
670
- if (answer === 'none' && lockedByLiveProcess()) {
728
+ if (answer === 'none' && readTakeover(lockPath, isProcessAliveImpl)) return 'bringing-over';
729
+ // A lock this version wrote is left to the server, which tells a reused
730
+ // process id itself (lockHolderMayRun): removed here, a second relay
731
+ // started beside one still bringing over its database.
732
+ if (answer === 'none' && lockedByLiveProcess() && !readRelayLock(lockPath)?.format) {
671
733
  // Nothing listens: the lock is left over from a relay that did not
672
734
  // stop cleanly. The server refuses to start beside a lock whose process
673
735
  // id is alive, so it goes first.
@@ -697,6 +759,7 @@ export async function ensureRelayRunning({
697
759
  fetchImpl = globalThis.fetch,
698
760
  isProcessAliveImpl = isProcessAlive,
699
761
  lockGraceMs = 5000,
762
+ readyWaitMs = 20_000,
700
763
  homeDir = os.homedir(),
701
764
  platform = process.platform,
702
765
  logger = console,
@@ -710,8 +773,9 @@ export async function ensureRelayRunning({
710
773
  statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
711
774
  });
712
775
  if (answer === 'oar') return { ok: true, how: 'already', logDir };
776
+ if (answer === 'bringing-over') return { ok: false, how: 'bringing-over', logDir, takeover: readTakeover(lockPath, isProcessAliveImpl) };
713
777
  if (answer === 'taken') {
714
- logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
778
+ logger.error?.(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
715
779
  logger.error?.('[oar] Stop that program, or pick another port: oar setup --port <port>');
716
780
  return { ok: false, how: 'port-taken', logDir };
717
781
  }
@@ -739,7 +803,7 @@ export async function ensureRelayRunning({
739
803
  COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
740
804
  COPILOT_WEB_RELAY_CONFIG: configPath,
741
805
  COPILOT_WEB_RELAY_LOG_DIR: logDir,
742
- COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
806
+ COPILOT_WEB_RELAY_DATA_DIR: resolveRelayStateDirs({ env, layout }).dataDir,
743
807
  },
744
808
  detached: true,
745
809
  stdio: ['ignore', outFd, errFd],
@@ -753,8 +817,10 @@ export async function ensureRelayRunning({
753
817
  try { fs.closeSync(outFd); } catch {}
754
818
  try { fs.closeSync(errFd); } catch {}
755
819
  }
756
- const ok = await waitForRelay({ statusUrl, token, fetchImpl, stillStarting: () => child.exitCode === null });
757
- return { ok, how: 'detached', logDir };
820
+ const ok = await waitForRelay({ statusUrl, token, fetchImpl, deadlineMs: readyWaitMs, stillStarting: () => child.exitCode === null });
821
+ const takeover = ok ? null : readTakeover(lockPath, isProcessAliveImpl);
822
+ if (takeover) return { ok: false, how: 'bringing-over', logDir, takeover };
823
+ return { ok, how: 'detached', logDir, ...(ok ? {} : { lock: heldLock(lockPath, isProcessAliveImpl) }) };
758
824
  }
759
825
 
760
826
  /**
@@ -780,6 +846,7 @@ function cliContext(options = {}) {
780
846
  isPortFreeImpl: isPortFree,
781
847
  readFileImpl: fs.readFileSync,
782
848
  qrImpl: renderQr,
849
+ pid: process.pid,
783
850
  restartWaitMs: 60_000,
784
851
  stopWaitMs: 30_000,
785
852
  pollMs: 300,
@@ -916,7 +983,7 @@ async function restartRelay(ctx, paths, { old, viaService }) {
916
983
  logger.error('[oar] The relay came back on its earlier port and token: it was started with fixed ones (oar copilot). End that session, then run: oar start');
917
984
  return { ok: false, reason: 'fixed-settings' };
918
985
  }
919
- logger.error(`[oar] The relay did not come back — see ${path.join(paths.logDir, 'server-err.log')}`);
986
+ logger.error(`[oar] The relay did not come back — see ${viaService ? 'journalctl --user -u oar' : path.join(paths.logDir, 'server-err.log')}`);
920
987
  return { ok: false, reason: 'server-not-ready' };
921
988
  }
922
989
 
@@ -926,19 +993,171 @@ function windowsAutostart(ctx, paths) {
926
993
  });
927
994
  }
928
995
 
929
- // Windows can also start the relay at boot, through a scheduled task the web
930
- // UI sets up (it needs an elevation); the command line only reports that one.
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
+
931
1004
  async function windowsBootTaskReady(ctx, paths) {
932
1005
  try {
933
- const service = ctx.windowsBootAutostartImpl || createWindowsBootAutostartService({
934
- platform: ctx.platform, env: ctx.env, packageRoot: ctx.packageRoot, nodePath: ctx.nodeBin, configPath: paths.configPath,
935
- });
936
- return (await service.getState())?.taskStatus === 'ready';
1006
+ return (await windowsBoot(ctx, paths).getState())?.taskStatus === 'ready';
937
1007
  } catch {
938
1008
  return false;
939
1009
  }
940
1010
  }
941
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
+
942
1161
  /** 'active' (systemd runs the relay), 'installed', 'none', or 'unavailable' on this host. */
943
1162
  async function serviceState(ctx, paths) {
944
1163
  if (paths.layout.checkout) return 'unavailable';
@@ -987,6 +1206,7 @@ function startRelay(ctx, paths) {
987
1206
  fetchImpl: ctx.fetchImpl,
988
1207
  isProcessAliveImpl: ctx.isProcessAliveImpl,
989
1208
  lockGraceMs: ctx.lockGraceMs,
1209
+ readyWaitMs: ctx.readyWaitMs,
990
1210
  homeDir: ctx.homeDir,
991
1211
  platform: ctx.platform,
992
1212
  logger: ctx.logger,
@@ -1028,12 +1248,18 @@ function warnWindowsPort(ctx, port, windowsHolder = createWindowsPortCheck(ctx))
1028
1248
  function reportRelayStart(result, logger) {
1029
1249
  // A port held by something else was already explained.
1030
1250
  if (result.how === 'port-taken') return;
1251
+ if (result.how === 'bringing-over') {
1252
+ logger.log(takeoverLine(result.takeover, result.logDir));
1253
+ return;
1254
+ }
1031
1255
  if (!result.ok) {
1032
- logger.error?.(`[oar] The relay did not come up — see ${path.join(result.logDir, 'server-err.log')}`);
1256
+ logger.error?.(`[oar] The relay did not come up — see ${result.how === 'service' ? 'journalctl --user -u oar' : path.join(result.logDir, 'server-err.log')}`);
1257
+ if (result.lock?.known) logger.error?.(`[oar] The lock ${result.lock.path} is held by relay process ${result.lock.pid} (started ${result.lock.startedAt}): oar stop, or end process ${result.lock.pid}.`);
1258
+ else if (result.lock) logger.error?.(`[oar] The lock ${result.lock.path} names process ${result.lock.pid}, which cannot be told to be a relay: if no relay is running, end process ${result.lock.pid} or delete the lock.`);
1033
1259
  return;
1034
1260
  }
1035
1261
  if (result.how === 'already') {
1036
- logger.log('[oar] The relay is already running. To load a new version, restart it: oar restart');
1262
+ logger.log('[oar] The relay is already running. It keeps the version and settings it started with until you restart it: oar restart');
1037
1263
  } else if (result.how === 'service') {
1038
1264
  logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
1039
1265
  } else {
@@ -1048,7 +1274,7 @@ function requireConfig(ctx, paths) {
1048
1274
  }
1049
1275
 
1050
1276
  function reportPortTaken(ctx, paths, port) {
1051
- ctx.logger.error(`[oar] Port ${port} is held by another program, or by a relay with another token.`);
1277
+ ctx.logger.error(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
1052
1278
  // A relay that was started before its token was changed in the config
1053
1279
  // cannot be asked to restart: only its lock tells that it is there.
1054
1280
  const pid = readRelayLock(paths.lockPath)?.pid;
@@ -1077,6 +1303,7 @@ export async function runStart(options = {}) {
1077
1303
  reportRelayStart(result, logger);
1078
1304
  if (result.ok) await showAddress(ctx, paths);
1079
1305
  warnWindowsPort(ctx, endpointOf(paths.config).port);
1306
+ if (result.how === 'bringing-over') return { code: 0, reason: 'bringing-over' };
1080
1307
  return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
1081
1308
  }
1082
1309
 
@@ -1092,7 +1319,7 @@ export async function runStop(options = {}) {
1092
1319
  logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
1093
1320
  return { code: 1, reason: 'service' };
1094
1321
  }
1095
- logger.log('[oar] The relay service is stopped. It starts again at the next login; to take it out: oar service remove');
1322
+ logger.log('[oar] The relay service is stopped. It starts again at the next boot (without lingering: at the next login); to take it out: oar service remove');
1096
1323
  return { code: 0, reason: 'stopped' };
1097
1324
  }
1098
1325
  const relay = await findRelay(ctx, paths);
@@ -1172,7 +1399,7 @@ export async function runStatus(options = {}) {
1172
1399
  const access = status.access === 'lan' ? 'LAN access' : 'this machine only';
1173
1400
  if (status.relay === 'running') logger.log(`[oar] The relay is running on port ${status.port} (${access}): ${status.url}`);
1174
1401
  else if (status.relay === 'stopped') logger.log(`[oar] The relay is stopped (port ${status.port}, ${access}). Start it with: oar start`);
1175
- else logger.log(`[oar] Port ${status.port} is held by another program, or by a relay with another token.`);
1402
+ else logger.log(`[oar] Port ${status.port} is in use by another program, or by a relay with another token.`);
1176
1403
  if (status.service !== 'unavailable') {
1177
1404
  logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed' }[status.service]}`);
1178
1405
  }
@@ -1205,7 +1432,7 @@ async function runWindowsService(ctx, paths, action) {
1205
1432
  return { code: 0, reason: 'service-status' };
1206
1433
  }
1207
1434
  if (boot) {
1208
- logger.error('[oar] The relay starts at boot through a scheduled task. Change that in the web UI: Settings → Autostart.');
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).');
1209
1436
  return { code: 1, reason: 'boot-task' };
1210
1437
  }
1211
1438
  if (action === 'install' && requireConfig(ctx, paths)) return { code: 1, reason: 'no-config' };
@@ -1261,7 +1488,7 @@ export async function runService(options = {}) {
1261
1488
  // A relay started by hand holds the port and the lock: it makes way first.
1262
1489
  if ((await runStop({ ...options, argv: [] })).reason !== 'stopped') {
1263
1490
  runQuiet('systemctl', ['--user', 'enable', 'oar'], ctx);
1264
- logger.log('[oar] The service is installed and takes over at the next login, or once the relay has stopped: oar start');
1491
+ logger.log('[oar] The service is installed and takes over at the next boot (without lingering: at the next login), or once the relay has stopped: oar start');
1265
1492
  return { code: 0, reason: 'service-install' };
1266
1493
  }
1267
1494
  }
@@ -1304,27 +1531,28 @@ export async function runSetup(options = {}) {
1304
1531
  logger.error('[oar] --lan and --local exclude each other.');
1305
1532
  return { code: 2, reason: 'bad-access' };
1306
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
+ }
1307
1539
  const terminal = Boolean(ctx.stdin?.isTTY) && !has('--defaults');
1308
- const ask = terminal && !portFlag && !has('--lan') && !has('--local') && !has('--new-token');
1540
+ const ask = terminal && !portFlag && !has('--lan') && !has('--local') && !has('--new-token') && !autostartFlag;
1309
1541
 
1310
1542
  let paths = installPaths(ctx);
1311
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
+ }
1312
1548
  if (!layout.checkout) {
1313
- const migrateFromRoot = String(optionValue(argv, '--migrate-from') || '').trim();
1314
- const migration = await migrateStateToOarRoot({
1315
- targetRoot: layout.root,
1316
- repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
1317
- managedConfigDir: getLegacyManagedConfigDir(env),
1318
- logger,
1549
+ const migrateFrom = String(optionValue(argv, '--migrate-from') || '').trim();
1550
+ const stop = await migrateIntoStateRoot({
1551
+ layout, env, packageRoot: ctx.packageRoot, migrateFrom, logger, isProcessAliveImpl: ctx.isProcessAliveImpl,
1319
1552
  });
1320
- if (migration.status === 'blocked-live-relay') {
1321
- logger.error?.(`[oar] ${migration.error}`);
1322
- return { code: 1, reason: 'migration-blocked' };
1323
- }
1324
- if (migration.status === 'migrated') {
1325
- logger.log(`[oar] Migrated existing relay state into ${layout.root} (sources untouched).`);
1326
- paths = installPaths(ctx);
1327
- }
1553
+ if (stop) return stop;
1554
+ // A migration may have brought a config over.
1555
+ paths = installPaths(ctx);
1328
1556
  }
1329
1557
 
1330
1558
  const existing = paths.config;
@@ -1400,6 +1628,14 @@ export async function runSetup(options = {}) {
1400
1628
  fs.writeFileSync(paths.previousPath, `${JSON.stringify({ port: old.port, authToken: old.token })}\n`, { mode: 0o600 });
1401
1629
  }
1402
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
+
1403
1639
  const startRequested = has('--start') && !layout.checkout;
1404
1640
  const viaService = running && (await serviceState(ctx, paths)) === 'active';
1405
1641
  if (startRequested && systemdUserAvailable(ctx)) writeSystemdUnit(ctx, paths);
@@ -1419,6 +1655,10 @@ export async function runSetup(options = {}) {
1419
1655
 
1420
1656
  await showAddress(ctx, paths, { qr: true });
1421
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
+ }
1422
1662
  if (outcome) return { code: outcome.ok ? 0 : 1, reason: outcome.ok ? 'setup-complete' : outcome.reason };
1423
1663
  if (layout.checkout) {
1424
1664
  logger.log('[oar] Start the relay with: npm start');
@@ -1429,6 +1669,338 @@ export async function runSetup(options = {}) {
1429
1669
  return { code: 0, reason: 'setup-complete' };
1430
1670
  }
1431
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
+
1432
2004
  export async function runDoctor(options = {}) {
1433
2005
  const ctx = cliContext(options);
1434
2006
  const paths = installPaths(ctx);
@@ -1624,6 +2196,8 @@ const COMMANDS = {
1624
2196
  url: runUrl,
1625
2197
  setup: runSetup,
1626
2198
  service: runService,
2199
+ autostart: runAutostart,
2200
+ uninstall: runUninstall,
1627
2201
  copilot: launchRelay,
1628
2202
  doctor: runDoctor,
1629
2203
  update: runUpdate,