@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.
- package/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- 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 {
|
|
24
|
-
import {
|
|
32
|
+
import { readEnvValue, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
33
|
+
import { createProcessLister } from '../server/services/process-list-service.mjs';
|
|
34
|
+
import { createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
|
|
35
|
+
import {
|
|
36
|
+
BOOT_ELEVATION_SCRIPT_FILENAME, BOOT_TASK_XML_FILENAME,
|
|
37
|
+
bootTaskLabel, createWindowsBootAutostartService, describeHandMade, describeWindowsAutostart, handMadeLines, switchBlockers,
|
|
38
|
+
} from '../server/services/windows-boot-autostart-service.mjs';
|
|
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
|
-
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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' &&
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
930
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1314
|
-
const
|
|
1315
|
-
|
|
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 (
|
|
1321
|
-
|
|
1322
|
-
|
|
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,
|