@oar-sh/oar 0.9.19 → 0.9.21
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/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
package/bin/oar.js
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from '../server/services/oar-state-migration-service.mjs';
|
|
15
15
|
import { readConfiguredDataDir, resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
|
|
16
16
|
import { RESCUE_DIR_NAME, TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
|
|
17
|
+
import { DB_FILE_NAME, DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile } from '../server/services/database-file.mjs';
|
|
18
|
+
import { BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE, backupDatabase, configWarnings, describeBackups, sanitizeReason } from '../server/services/db-backup-service.mjs';
|
|
17
19
|
import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
|
|
18
20
|
import {
|
|
19
21
|
getLauncherLogDir,
|
|
@@ -23,15 +25,16 @@ import {
|
|
|
23
25
|
resolveStateLayout,
|
|
24
26
|
} from '../server/services/oar-state-layout.mjs';
|
|
25
27
|
import {
|
|
26
|
-
buildDefaultConfig, buildServicePath, buildSystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
|
|
27
|
-
NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, primaryLanAddress, renderDoctorReport,
|
|
28
|
+
buildDefaultConfig, buildServicePath, buildSystemdUnit, classifySystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
|
|
29
|
+
listSystemdDropIns, NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, parseSystemdVersion, primaryLanAddress, renderDoctorReport,
|
|
30
|
+
resolveNpmInvocation, systemdUnitMarkerRoot, systemdUnitRunsCheckout, usageText,
|
|
28
31
|
} from '../server/services/oar-cli-helpers.mjs';
|
|
29
32
|
import { describeConfiguredTunnel, describeTunnelState } from '../server/services/cloudflared-tunnel-service.mjs';
|
|
30
33
|
import { createPrompter, PromptAborted, runMenu } from '../server/services/oar-cli-menu.mjs';
|
|
31
34
|
import { createWindowsPortCheck, isWsl, isWslNat, windowsPortWarning } from '../server/services/oar-cli-wsl.mjs';
|
|
32
35
|
import { readEnvValue, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
33
36
|
import { createProcessLister } from '../server/services/process-list-service.mjs';
|
|
34
|
-
import { createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
|
|
37
|
+
import { backupPathFor, createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
|
|
35
38
|
import {
|
|
36
39
|
BOOT_ELEVATION_SCRIPT_FILENAME, BOOT_TASK_XML_FILENAME,
|
|
37
40
|
bootTaskLabel, createWindowsBootAutostartService, describeHandMade, describeWindowsAutostart, handMadeLines, switchBlockers,
|
|
@@ -82,10 +85,17 @@ function readRelayLock(lockPath) {
|
|
|
82
85
|
// pid, and that pid is the lock's holder still (lockHolderMayRun: a pid
|
|
83
86
|
// reused after a crash is not).
|
|
84
87
|
function readTakeover(lockPath, isProcessAliveImpl = isProcessAlive) {
|
|
85
|
-
const progress = readJsonFile(path.join(path.dirname(lockPath), TAKEOVER_PROGRESS_FILE));
|
|
86
88
|
const lock = readJsonFile(lockPath);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
if (!lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl })) return null;
|
|
90
|
+
// Also while it compares two database files, backs its database up, or
|
|
91
|
+
// waits for an `oar backup` to finish first; each file counts only with
|
|
92
|
+
// the lock's pid (one a crash left does not).
|
|
93
|
+
for (const name of [TAKEOVER_PROGRESS_FILE, BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE]) {
|
|
94
|
+
const progress = readJsonFile(path.join(path.dirname(lockPath), name));
|
|
95
|
+
const pid = Number.parseInt(String(progress?.pid ?? ''), 10);
|
|
96
|
+
if (Number.isInteger(pid) && Number(lock?.pid) === pid) return progress;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
// The lock a relay that did not come up found held, and whether its holder
|
|
@@ -100,7 +110,12 @@ function heldLock(lockPath, isProcessAliveImpl = isProcessAlive) {
|
|
|
100
110
|
|
|
101
111
|
function takeoverLine(takeover, logDir) {
|
|
102
112
|
const what = takeover ? ` (${takeover.from}, ${Math.max(1, Math.round(Number(takeover.bytes || 0) / 1048576))} MB)` : '';
|
|
103
|
-
|
|
113
|
+
const doing = {
|
|
114
|
+
backup: `is backing up its database${what} before this version's migrations`,
|
|
115
|
+
compare: `is comparing its two database files${what}`,
|
|
116
|
+
'wait-backup': `is waiting for \`oar backup\` (process ${takeover?.waitingFor}) to finish before its own backup`,
|
|
117
|
+
}[takeover?.task] || `is bringing over the database it ran on until now${what}`;
|
|
118
|
+
return `[oar] The relay${takeover ? ` (process ${takeover.pid})` : ''} ${doing} and is still running; it answers once that is done. Its log: ${path.join(String(logDir || ''), 'server.log')}`;
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
// As the server tells it: a process this user may not signal (EPERM) lives.
|
|
@@ -741,10 +756,12 @@ async function probeRelayBehindLock({
|
|
|
741
756
|
}
|
|
742
757
|
|
|
743
758
|
/**
|
|
744
|
-
* Makes sure the relay of
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
* so it outlives this command.
|
|
759
|
+
* Makes sure the relay of an install runs, without a Copilot session tied to
|
|
760
|
+
* it: an already running relay is left alone, an installed systemd user
|
|
761
|
+
* service is started, and anywhere else the server of a global install is
|
|
762
|
+
* started detached, so it outlives this command. `serviceInstalled` says
|
|
763
|
+
* whether the unit there is this install's (unset: any unit is); `lockPath`
|
|
764
|
+
* is the relay's lock (default: in the layout's data folder).
|
|
748
765
|
*/
|
|
749
766
|
export async function ensureRelayRunning({
|
|
750
767
|
packageRoot = resolvePackageRoot(),
|
|
@@ -763,12 +780,13 @@ export async function ensureRelayRunning({
|
|
|
763
780
|
homeDir = os.homedir(),
|
|
764
781
|
platform = process.platform,
|
|
765
782
|
logger = console,
|
|
783
|
+
serviceInstalled = null,
|
|
784
|
+
lockPath = path.join(layout.dataDir, 'relay-server.lock'),
|
|
766
785
|
} = {}) {
|
|
767
786
|
const port = normalizePort(config?.port) ?? 3333;
|
|
768
787
|
const token = String(config?.authToken || '').trim();
|
|
769
788
|
const statusUrl = `http://localhost:${port}/api/status`;
|
|
770
789
|
const logDir = getLauncherLogDir(env, layout);
|
|
771
|
-
const lockPath = path.join(layout.dataDir, 'relay-server.lock');
|
|
772
790
|
const answer = await probeRelayBehindLock({
|
|
773
791
|
statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
|
|
774
792
|
});
|
|
@@ -780,8 +798,12 @@ export async function ensureRelayRunning({
|
|
|
780
798
|
return { ok: false, how: 'port-taken', logDir };
|
|
781
799
|
}
|
|
782
800
|
|
|
783
|
-
|
|
801
|
+
const unitThere = serviceInstalled ?? fs.existsSync(systemdUnitPath(homeDir));
|
|
802
|
+
if (unitThere && systemdUserAvailable({ platform, spawnSyncImpl })) {
|
|
784
803
|
runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
|
|
804
|
+
// A unit that failed (a crash loop, a stop on purpose) is started afresh,
|
|
805
|
+
// with its restart backoff from the start.
|
|
806
|
+
runQuiet('systemctl', ['--user', 'reset-failed', 'oar'], { spawnSyncImpl });
|
|
785
807
|
if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
|
|
786
808
|
logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
|
|
787
809
|
return { ok: false, how: 'service', logDir };
|
|
@@ -886,6 +908,15 @@ function installPaths(ctx) {
|
|
|
886
908
|
};
|
|
887
909
|
}
|
|
888
910
|
|
|
911
|
+
// The data folder the relay of this install runs on: in a checkout as the
|
|
912
|
+
// relay resolves it (COPILOT_WEB_RELAY_DATA_DIR, the config's dataDir,
|
|
913
|
+
// server/data); a global install's relay is pinned to the state root's
|
|
914
|
+
// (COPILOT_WEB_RELAY_DATA_DIR wins there too).
|
|
915
|
+
function relayDataDir(ctx, paths) {
|
|
916
|
+
if (paths.layout.checkout) return resolveDataDir({ env: ctx.env, configPath: paths.configPath, serverDir: path.join(ctx.packageRoot, 'server') });
|
|
917
|
+
return resolveRelayStateDirs({ env: ctx.env, layout: paths.layout }).dataDir || paths.dataDir;
|
|
918
|
+
}
|
|
919
|
+
|
|
889
920
|
function endpointOf(config) {
|
|
890
921
|
const port = normalizePort(config?.port) ?? 3333;
|
|
891
922
|
return { port, token: String(config?.authToken || '').trim(), statusUrl: `http://localhost:${port}/api/status` };
|
|
@@ -925,13 +956,16 @@ async function pollUntil(check, { waitMs, pollMs }) {
|
|
|
925
956
|
}
|
|
926
957
|
}
|
|
927
958
|
|
|
928
|
-
/**
|
|
929
|
-
|
|
959
|
+
/**
|
|
960
|
+
* Asks the relay to stop or restart itself; it does so once no turn is
|
|
961
|
+
* running, or at once with `immediate` (`--now` of a relay without a service).
|
|
962
|
+
*/
|
|
963
|
+
async function requestShutdown(ctx, endpoint, restart, { immediate = false } = {}) {
|
|
930
964
|
try {
|
|
931
965
|
const response = await ctx.fetchImpl(`http://localhost:${endpoint.port}/api/relay/shutdown`, {
|
|
932
966
|
method: 'POST',
|
|
933
967
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${endpoint.token}` },
|
|
934
|
-
body: JSON.stringify({ restart, reason: 'oar-cli', requestedBy: 'oar-cli' }),
|
|
968
|
+
body: JSON.stringify({ restart, reason: 'oar-cli', requestedBy: 'oar-cli', ...(immediate ? { immediate: true } : {}) }),
|
|
935
969
|
signal: AbortSignal.timeout(5000),
|
|
936
970
|
});
|
|
937
971
|
if (!response?.ok) return null;
|
|
@@ -944,26 +978,37 @@ async function requestShutdown(ctx, endpoint, restart) {
|
|
|
944
978
|
}
|
|
945
979
|
}
|
|
946
980
|
|
|
981
|
+
// systemd restarts the service at once, its restart backoff cleared first.
|
|
982
|
+
function restartService(ctx) {
|
|
983
|
+
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
984
|
+
runQuiet('systemctl', ['--user', 'reset-failed', 'oar'], ctx);
|
|
985
|
+
if (runQuiet('systemctl', ['--user', 'restart', 'oar'], ctx)) return true;
|
|
986
|
+
ctx.logger.error('[oar] systemctl --user restart oar failed — see: systemctl --user status oar');
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
947
990
|
/**
|
|
948
991
|
* Restarts this install's running relay so that it reads the config again.
|
|
949
|
-
* The
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
992
|
+
* The relay is asked on the port and with the token it runs with (`old`):
|
|
993
|
+
* once no turn is running it exits for a restart, and a new runtime with the
|
|
994
|
+
* same arguments and environment reads the config file, so a new port, token
|
|
995
|
+
* or access setting applies. Under the service (`viaService`) its supervisor
|
|
996
|
+
* hands that restart to systemd, which starts the code on disk afresh; there
|
|
997
|
+
* systemd restarts it at once with `now`, or when the relay does not answer
|
|
998
|
+
* (`answered` false) or refuses. Done when the relay answers on the config's
|
|
999
|
+
* port with the config's token.
|
|
954
1000
|
*/
|
|
955
|
-
async function restartRelay(ctx, paths, { old, viaService }) {
|
|
1001
|
+
async function restartRelay(ctx, paths, { old, viaService, answered = true, now = false }) {
|
|
956
1002
|
const { logger, fetchImpl } = ctx;
|
|
957
1003
|
const next = endpointOf(paths.config);
|
|
958
|
-
if (viaService) {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1004
|
+
if (viaService && (now || !answered)) {
|
|
1005
|
+
if (!restartService(ctx)) return { ok: false, reason: 'service' };
|
|
1006
|
+
} else if (!(await requestShutdown(ctx, old, true, { immediate: now }))) {
|
|
1007
|
+
if (!viaService) {
|
|
1008
|
+
logger.error('[oar] The relay did not accept the restart request. Restart it from the web UI (︙ → Restart web relay).');
|
|
1009
|
+
return { ok: false, reason: 'restart-refused' };
|
|
963
1010
|
}
|
|
964
|
-
|
|
965
|
-
logger.error('[oar] The relay did not accept the restart request. Restart it from the web UI (⋯ → Restart web relay).');
|
|
966
|
-
return { ok: false, reason: 'restart-refused' };
|
|
1011
|
+
if (!restartService(ctx)) return { ok: false, reason: 'service' };
|
|
967
1012
|
}
|
|
968
1013
|
const up = await pollUntil(async () => {
|
|
969
1014
|
const { answer, status } = await readRelayStatus({ ...next, fetchImpl });
|
|
@@ -1122,13 +1167,14 @@ export async function runAutostart(options = {}) {
|
|
|
1122
1167
|
const paths = installPaths(ctx);
|
|
1123
1168
|
const { logger } = ctx;
|
|
1124
1169
|
const target = ctx.argv.find((arg) => [...AUTOSTART_MODES, 'status'].includes(arg)) || 'status';
|
|
1170
|
+
if (ctx.platform === 'linux') {
|
|
1171
|
+
const replace = ctx.argv.includes('--replace') ? ['--replace'] : [];
|
|
1172
|
+
return runService({ ...options, argv: [{ status: 'status', off: 'remove' }[target] || 'install', ...replace] });
|
|
1173
|
+
}
|
|
1125
1174
|
if (paths.layout.checkout) {
|
|
1126
1175
|
logger.error('[oar] This is a git checkout — autostart is for global installs.');
|
|
1127
1176
|
return { code: 1, reason: 'git-checkout' };
|
|
1128
1177
|
}
|
|
1129
|
-
if (ctx.platform === 'linux') {
|
|
1130
|
-
return runService({ ...options, argv: [{ status: 'status', off: 'remove' }[target] || 'install'] });
|
|
1131
|
-
}
|
|
1132
1178
|
if (ctx.platform !== 'win32') {
|
|
1133
1179
|
logger.error(`[oar] Autostart is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
|
|
1134
1180
|
return { code: 1, reason: 'unsupported' };
|
|
@@ -1158,9 +1204,75 @@ async function askAutostart(ctx, paths) {
|
|
|
1158
1204
|
}
|
|
1159
1205
|
}
|
|
1160
1206
|
|
|
1161
|
-
|
|
1207
|
+
// What a systemctl call printed, or null when it could not run.
|
|
1208
|
+
function systemctlOutput(ctx, args) {
|
|
1209
|
+
try {
|
|
1210
|
+
const result = ctx.spawnSyncImpl('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 });
|
|
1211
|
+
return result?.error ? null : result || null;
|
|
1212
|
+
} catch {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// States in which systemd runs the relay, starts it (a restart backoff
|
|
1218
|
+
// included) or stops it: `oar stop` and `oar restart` go to systemd then.
|
|
1219
|
+
const SYSTEMD_RUNNING_STATES = ['active', 'activating', 'reloading', 'refreshing', 'deactivating'];
|
|
1220
|
+
|
|
1221
|
+
function systemdUnitActive(ctx) {
|
|
1222
|
+
const result = systemctlOutput(ctx, ['is-active', 'oar']);
|
|
1223
|
+
if (!result) return false;
|
|
1224
|
+
const state = String(result.stdout ?? '').trim();
|
|
1225
|
+
return state ? SYSTEMD_RUNNING_STATES.includes(state) : result.status === 0;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// What this install's unit would be built from.
|
|
1229
|
+
function unitInstall(ctx, paths) {
|
|
1230
|
+
return {
|
|
1231
|
+
nodeBin: ctx.nodeBin,
|
|
1232
|
+
packageRoot: ctx.packageRoot,
|
|
1233
|
+
configPath: paths.configPath,
|
|
1234
|
+
dataDir: paths.dataDir,
|
|
1235
|
+
logDir: paths.logDir,
|
|
1236
|
+
checkout: Boolean(paths.layout.checkout),
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* The unit at ~/.config/systemd/user/oar.service: `text` (null when there is
|
|
1242
|
+
* none), `kind` (classifySystemdUnit: whose it is, for this install) and
|
|
1243
|
+
* `ours` (it is this install's service: start, stop and restart go through
|
|
1244
|
+
* it). A hand-made unit is a global install's service, the one it has; a
|
|
1245
|
+
* checkout's only when its ExecStart runs that checkout's server.js (running
|
|
1246
|
+
* in the checkout is not enough), so a worktree never takes over the unit of
|
|
1247
|
+
* another relay.
|
|
1248
|
+
*/
|
|
1249
|
+
function readSystemdUnit(ctx, paths) {
|
|
1250
|
+
const unitPath = systemdUnitPath(ctx.homeDir);
|
|
1251
|
+
const text = readText(unitPath);
|
|
1252
|
+
if (text === null) return { path: unitPath, text, kind: null, ours: false };
|
|
1253
|
+
const kind = classifySystemdUnit({ text, install: unitInstall(ctx, paths) });
|
|
1254
|
+
const ours = kind !== 'other-install'
|
|
1255
|
+
&& !(kind === 'hand-made' && paths.layout.checkout && !systemdUnitRunsCheckout(text, ctx.packageRoot));
|
|
1256
|
+
return { path: unitPath, text, kind, ours };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// The relay's own lock: a checkout's data folder may be the config's dataDir.
|
|
1260
|
+
function relayLockPath(ctx, paths) {
|
|
1261
|
+
return paths.layout.checkout ? path.join(relayDataDir(ctx, paths), 'relay-server.lock') : paths.lockPath;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// How a command of this install is typed: a checkout has no `oar` on PATH.
|
|
1265
|
+
function oarCommand(paths) {
|
|
1266
|
+
return paths.layout.checkout ? 'node bin/oar.js' : 'oar';
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* 'active' (systemd runs the relay, starts or stops it), 'installed',
|
|
1271
|
+
* 'none', 'other' (oar.service is another OAR install's), or 'unavailable'
|
|
1272
|
+
* on this host.
|
|
1273
|
+
*/
|
|
1162
1274
|
async function serviceState(ctx, paths) {
|
|
1163
|
-
if (paths.layout.checkout) return 'unavailable';
|
|
1275
|
+
if (paths.layout.checkout && ctx.platform !== 'linux') return 'unavailable';
|
|
1164
1276
|
if (ctx.platform === 'win32') {
|
|
1165
1277
|
try {
|
|
1166
1278
|
return windowsAutostart(ctx, paths).getState().enabled || await windowsBootTaskReady(ctx, paths) ? 'installed' : 'none';
|
|
@@ -1169,30 +1281,65 @@ async function serviceState(ctx, paths) {
|
|
|
1169
1281
|
}
|
|
1170
1282
|
}
|
|
1171
1283
|
if (!systemdUserAvailable(ctx)) return 'unavailable';
|
|
1172
|
-
|
|
1173
|
-
|
|
1284
|
+
const unit = readSystemdUnit(ctx, paths);
|
|
1285
|
+
if (unit.text === null) return 'none';
|
|
1286
|
+
if (!unit.ours) return 'other';
|
|
1287
|
+
return systemdUnitActive(ctx) ? 'active' : 'installed';
|
|
1174
1288
|
}
|
|
1175
1289
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1290
|
+
const UNIT_KIND_TEXT = {
|
|
1291
|
+
generated: 'written by OAR for this install',
|
|
1292
|
+
'generated-earlier': 'written by an earlier OAR version',
|
|
1293
|
+
'other-install': 'written by OAR for another install',
|
|
1294
|
+
'hand-made': 'hand-made',
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Writes this install's unit, unless the one there is hand-made or another
|
|
1299
|
+
* install's: that one stays as it is (`kept`), unless `replace`, which keeps
|
|
1300
|
+
* a copy of it first. `changed` when the text on disk is new.
|
|
1301
|
+
*/
|
|
1302
|
+
function writeSystemdUnit(ctx, paths, { replace = false } = {}) {
|
|
1303
|
+
const { logger } = ctx;
|
|
1304
|
+
const unit = readSystemdUnit(ctx, paths);
|
|
1305
|
+
if (unit.text !== null && ['hand-made', 'other-install'].includes(unit.kind) && !replace) {
|
|
1306
|
+
return { kept: unit };
|
|
1307
|
+
}
|
|
1308
|
+
let backup = null;
|
|
1309
|
+
if (unit.text !== null && ['hand-made', 'other-install'].includes(unit.kind)) {
|
|
1310
|
+
backup = backupPathFor(unit.path, path.dirname(unit.path));
|
|
1311
|
+
fs.copyFileSync(unit.path, backup);
|
|
1312
|
+
logger.log(`[oar] Kept a copy of ${unit.path} as ${backup}`);
|
|
1313
|
+
}
|
|
1314
|
+
const text = buildSystemdUnit({
|
|
1315
|
+
...unitInstall(ctx, paths),
|
|
1185
1316
|
pathEnv: buildServicePath({ nodeBin: ctx.nodeBin, envPath: ctx.env.PATH, homeDir: ctx.homeDir }),
|
|
1186
|
-
|
|
1187
|
-
|
|
1317
|
+
systemdVersion: parseSystemdVersion(systemctlOutput(ctx, ['--version'])?.stdout),
|
|
1318
|
+
});
|
|
1319
|
+
fs.mkdirSync(path.dirname(unit.path), { recursive: true });
|
|
1320
|
+
fs.writeFileSync(unit.path, text);
|
|
1321
|
+
logger.log(`[oar] Wrote ${unit.path}`);
|
|
1322
|
+
// A Node of a version manager is gone once another version replaces it.
|
|
1323
|
+
if (/\/(\.nvm|\.fnm|\.volta|\.asdf|versions?)\//.test(String(ctx.nodeBin))) {
|
|
1324
|
+
logger.log(`[oar] The service runs the Node at ${ctx.nodeBin}; after switching Node versions, run ${oarCommand(paths)} service install again.`);
|
|
1325
|
+
}
|
|
1188
1326
|
// Lingering keeps the user's services up while nobody is logged in;
|
|
1189
1327
|
// without it the relay of a headless host stops at logout.
|
|
1190
1328
|
if (!runQuiet('loginctl', ['enable-linger', ctx.userName || os.userInfo().username], ctx)) {
|
|
1191
|
-
|
|
1329
|
+
logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
|
|
1192
1330
|
}
|
|
1331
|
+
return { written: true, changed: unit.text !== text, backup };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// One line for a unit `oar service install` (or `remove`) leaves as it is.
|
|
1335
|
+
function keptUnitLine(ctx, paths, unit, action = 'install') {
|
|
1336
|
+
const whose = unit.kind === 'other-install' ? `written by OAR for another install (${systemdUnitMarkerRoot(unit.text)})` : 'hand-made';
|
|
1337
|
+
const what = action === 'remove' ? 'To remove it' : 'To replace it with this install\'s';
|
|
1338
|
+
return `[oar] Left as it is: ${unit.path} (${whose}). ${what}, keeping a copy: ${oarCommand(paths)} service ${action} --replace`;
|
|
1193
1339
|
}
|
|
1194
1340
|
|
|
1195
1341
|
function startRelay(ctx, paths) {
|
|
1342
|
+
const unit = ctx.platform === 'linux' ? readSystemdUnit(ctx, paths) : { text: null };
|
|
1196
1343
|
return ensureRelayRunning({
|
|
1197
1344
|
packageRoot: ctx.packageRoot,
|
|
1198
1345
|
layout: paths.layout,
|
|
@@ -1210,6 +1357,8 @@ function startRelay(ctx, paths) {
|
|
|
1210
1357
|
homeDir: ctx.homeDir,
|
|
1211
1358
|
platform: ctx.platform,
|
|
1212
1359
|
logger: ctx.logger,
|
|
1360
|
+
serviceInstalled: unit.text !== null && unit.ours,
|
|
1361
|
+
lockPath: relayLockPath(ctx, paths),
|
|
1213
1362
|
});
|
|
1214
1363
|
}
|
|
1215
1364
|
|
|
@@ -1279,7 +1428,7 @@ function reportPortTaken(ctx, paths, port) {
|
|
|
1279
1428
|
// cannot be asked to restart: only its lock tells that it is there.
|
|
1280
1429
|
const pid = readRelayLock(paths.lockPath)?.pid;
|
|
1281
1430
|
if (Number.isInteger(pid) && ctx.isProcessAliveImpl(pid)) {
|
|
1282
|
-
ctx.logger.error(`[oar] A relay of this install seems to run (process ${pid}). If its token is an earlier one, restart it from its web UI (
|
|
1431
|
+
ctx.logger.error(`[oar] A relay of this install seems to run (process ${pid}). If its token is an earlier one, restart it from its web UI (︙ → Restart web relay), or end that process and run: oar start`);
|
|
1283
1432
|
}
|
|
1284
1433
|
return { code: 1, reason: 'port-taken' };
|
|
1285
1434
|
}
|
|
@@ -1289,8 +1438,9 @@ export async function runStart(options = {}) {
|
|
|
1289
1438
|
const ctx = cliContext(options);
|
|
1290
1439
|
const paths = installPaths(ctx);
|
|
1291
1440
|
const { logger } = ctx;
|
|
1292
|
-
|
|
1293
|
-
|
|
1441
|
+
// A checkout starts only through its service; anywhere else it runs in a terminal.
|
|
1442
|
+
if (paths.layout.checkout && !['active', 'installed'].includes(await serviceState(ctx, paths))) {
|
|
1443
|
+
logger.error('[oar] This is a git checkout — start the relay with: npm start, or run it as a service: node bin/oar.js service install');
|
|
1294
1444
|
return { code: 1, reason: 'git-checkout' };
|
|
1295
1445
|
}
|
|
1296
1446
|
const missing = requireConfig(ctx, paths);
|
|
@@ -1307,28 +1457,40 @@ export async function runStart(options = {}) {
|
|
|
1307
1457
|
return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
|
|
1308
1458
|
}
|
|
1309
1459
|
|
|
1310
|
-
|
|
1460
|
+
// systemd stops the service at once; running turns end.
|
|
1461
|
+
function stopService(ctx, paths) {
|
|
1462
|
+
if (!runQuiet('systemctl', ['--user', 'stop', 'oar'], ctx)) {
|
|
1463
|
+
ctx.logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
|
|
1464
|
+
return { code: 1, reason: 'service' };
|
|
1465
|
+
}
|
|
1466
|
+
ctx.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: ${oarCommand(paths)} service remove`);
|
|
1467
|
+
return { code: 0, reason: 'stopped' };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* `oar stop` — the relay stops itself once no turn is running, under the
|
|
1472
|
+
* service too (its unit stays enabled). `--now` under the service, or a
|
|
1473
|
+
* service whose relay does not answer: systemd stops it at once. `--now` of
|
|
1474
|
+
* any other relay asks it to stop without waiting.
|
|
1475
|
+
*/
|
|
1311
1476
|
export async function runStop(options = {}) {
|
|
1312
1477
|
const ctx = cliContext(options);
|
|
1313
1478
|
const paths = installPaths(ctx);
|
|
1314
1479
|
const { logger } = ctx;
|
|
1315
1480
|
const missing = requireConfig(ctx, paths);
|
|
1316
1481
|
if (missing) return missing;
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
return { code: 1, reason: 'service' };
|
|
1321
|
-
}
|
|
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');
|
|
1323
|
-
return { code: 0, reason: 'stopped' };
|
|
1324
|
-
}
|
|
1482
|
+
const now = ctx.argv.includes('--now');
|
|
1483
|
+
const viaService = (await serviceState(ctx, paths)) === 'active';
|
|
1484
|
+
if (viaService && now) return stopService(ctx, paths);
|
|
1325
1485
|
const relay = await findRelay(ctx, paths);
|
|
1486
|
+
if (viaService && relay.answer !== 'oar') return stopService(ctx, paths);
|
|
1326
1487
|
if (relay.answer === 'none') {
|
|
1327
1488
|
logger.log('[oar] The relay is not running.');
|
|
1328
1489
|
return { code: 0, reason: 'not-running' };
|
|
1329
1490
|
}
|
|
1330
1491
|
if (relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
|
|
1331
|
-
if (!(await requestShutdown(ctx, relay.endpoint, false))) {
|
|
1492
|
+
if (!(await requestShutdown(ctx, relay.endpoint, false, { immediate: now }))) {
|
|
1493
|
+
if (viaService) return stopService(ctx, paths);
|
|
1332
1494
|
logger.error('[oar] The relay did not accept the stop request.');
|
|
1333
1495
|
return { code: 1, reason: 'stop-refused' };
|
|
1334
1496
|
}
|
|
@@ -1336,11 +1498,19 @@ export async function runStop(options = {}) {
|
|
|
1336
1498
|
async () => (await probeRelayPort({ ...relay.endpoint, fetchImpl: ctx.fetchImpl })) === 'none',
|
|
1337
1499
|
{ waitMs: ctx.stopWaitMs, pollMs: ctx.pollMs },
|
|
1338
1500
|
);
|
|
1339
|
-
|
|
1501
|
+
const command = oarCommand(paths);
|
|
1502
|
+
const enabled = viaService ? ` The service stays enabled: it starts again at the next boot, or with: ${command} start` : '';
|
|
1503
|
+
logger.log(closed
|
|
1504
|
+
? `[oar] The relay is stopped.${enabled}`
|
|
1505
|
+
: `[oar] The relay is still busy; it stops by itself when its running turns finish (at once: ${command} stop --now).${enabled}`);
|
|
1340
1506
|
return { code: 0, reason: closed ? 'stopped' : 'stop-queued' };
|
|
1341
1507
|
}
|
|
1342
1508
|
|
|
1343
|
-
/**
|
|
1509
|
+
/**
|
|
1510
|
+
* `oar restart` — restarts a running relay once no turn is running (`--now`:
|
|
1511
|
+
* at once), starts a stopped one. Under the service systemd restarts it at
|
|
1512
|
+
* once with `--now`, or when the relay does not answer.
|
|
1513
|
+
*/
|
|
1344
1514
|
export async function runRestart(options = {}) {
|
|
1345
1515
|
const ctx = cliContext(options);
|
|
1346
1516
|
const paths = installPaths(ctx);
|
|
@@ -1350,7 +1520,9 @@ export async function runRestart(options = {}) {
|
|
|
1350
1520
|
const relay = await findRelay(ctx, paths);
|
|
1351
1521
|
if (!viaService && relay.answer === 'none') return runStart(options);
|
|
1352
1522
|
if (!viaService && relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
|
|
1353
|
-
const outcome = await restartRelay(ctx, paths, {
|
|
1523
|
+
const outcome = await restartRelay(ctx, paths, {
|
|
1524
|
+
old: relay.endpoint, viaService, answered: relay.answer === 'oar', now: ctx.argv.includes('--now'),
|
|
1525
|
+
});
|
|
1354
1526
|
if (outcome.ok) await showAddress(ctx, paths);
|
|
1355
1527
|
return { code: outcome.ok ? 0 : 1, reason: outcome.reason };
|
|
1356
1528
|
}
|
|
@@ -1401,7 +1573,7 @@ export async function runStatus(options = {}) {
|
|
|
1401
1573
|
else if (status.relay === 'stopped') logger.log(`[oar] The relay is stopped (port ${status.port}, ${access}). Start it with: oar start`);
|
|
1402
1574
|
else logger.log(`[oar] Port ${status.port} is in use by another program, or by a relay with another token.`);
|
|
1403
1575
|
if (status.service !== 'unavailable') {
|
|
1404
|
-
logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed' }[status.service]}`);
|
|
1576
|
+
logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed', other: 'not installed (oar.service belongs to another OAR install)' }[status.service]}`);
|
|
1405
1577
|
}
|
|
1406
1578
|
if (status.tunnel) logger.log(`[oar] Tunnel: ${status.tunnel}`);
|
|
1407
1579
|
for (const warning of status.warnings) logger.log(`[oar] ${warning}`);
|
|
@@ -1443,14 +1615,47 @@ async function runWindowsService(ctx, paths, action) {
|
|
|
1443
1615
|
return { code: 0, reason: `service-${action}` };
|
|
1444
1616
|
}
|
|
1445
1617
|
|
|
1618
|
+
// The relay paths a checkout's service does not get: the relay it runs
|
|
1619
|
+
// finds its config, data and logs in the checkout.
|
|
1620
|
+
const CHECKOUT_UNPINNED_ENV = ['COPILOT_WEB_RELAY_CONFIG', 'COPILOT_WEB_RELAY_DATA_DIR', 'COPILOT_WEB_RELAY_LOG_DIR'];
|
|
1621
|
+
|
|
1622
|
+
// `oar service status` on Linux: the state, whose the unit is, its drop-ins, lingering.
|
|
1623
|
+
function reportSystemdService(ctx, paths, state) {
|
|
1624
|
+
const { logger } = ctx;
|
|
1625
|
+
const command = oarCommand(paths);
|
|
1626
|
+
const unit = readSystemdUnit(ctx, paths);
|
|
1627
|
+
if (unit.text === null) {
|
|
1628
|
+
logger.log('[oar] Service: not installed');
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed, not running', other: 'not installed for this install' }[state]}`);
|
|
1632
|
+
const whose = unit.kind === 'other-install'
|
|
1633
|
+
? `${UNIT_KIND_TEXT[unit.kind]} (${systemdUnitMarkerRoot(unit.text)})`
|
|
1634
|
+
: `${UNIT_KIND_TEXT[unit.kind]}${unit.ours ? '' : ', not for this checkout'}`;
|
|
1635
|
+
logger.log(`[oar] Unit: ${unit.path} (${whose})`);
|
|
1636
|
+
if (unit.kind === 'generated-earlier') logger.log(`[oar] It is from an earlier OAR version; write this version's with: ${command} service install`);
|
|
1637
|
+
if (['hand-made', 'other-install'].includes(unit.kind)) {
|
|
1638
|
+
logger.log(`[oar] OAR leaves it as it is. To replace it with this install's, keeping a copy: ${command} service install --replace`);
|
|
1639
|
+
}
|
|
1640
|
+
for (const dropIn of listSystemdDropIns(ctx.homeDir)) logger.log(`[oar] Drop-in: ${dropIn} (your own settings, kept)`);
|
|
1641
|
+
const user = ctx.userName || os.userInfo().username;
|
|
1642
|
+
let linger = '';
|
|
1643
|
+
try {
|
|
1644
|
+
const result = ctx.spawnSyncImpl('loginctl', ['show-user', user, '-p', 'Linger', '--value'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 });
|
|
1645
|
+
linger = String(result?.stdout ?? '').trim();
|
|
1646
|
+
} catch {}
|
|
1647
|
+
if (linger === 'no') logger.log('[oar] Lingering is off: the relay stops when you log out. Switch it on with: loginctl enable-linger "$USER"');
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1446
1650
|
/** `oar service install|remove|status` — the systemd user service on Linux, the autostart entry on Windows. */
|
|
1447
1651
|
export async function runService(options = {}) {
|
|
1448
1652
|
const ctx = cliContext(options);
|
|
1449
1653
|
const paths = installPaths(ctx);
|
|
1450
1654
|
const { logger } = ctx;
|
|
1451
1655
|
const action = ctx.argv.find((arg) => ['install', 'remove', 'status'].includes(arg)) || 'status';
|
|
1452
|
-
|
|
1453
|
-
|
|
1656
|
+
const replace = ctx.argv.includes('--replace');
|
|
1657
|
+
if (paths.layout.checkout && ctx.platform !== 'linux') {
|
|
1658
|
+
logger.error('[oar] This is a git checkout — the service is for global installs here.');
|
|
1454
1659
|
return { code: 1, reason: 'git-checkout' };
|
|
1455
1660
|
}
|
|
1456
1661
|
if (ctx.platform === 'win32') return runWindowsService(ctx, paths, action);
|
|
@@ -1458,37 +1663,60 @@ export async function runService(options = {}) {
|
|
|
1458
1663
|
logger.error(`[oar] The service is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
|
|
1459
1664
|
return { code: 1, reason: 'unsupported' };
|
|
1460
1665
|
}
|
|
1666
|
+
const command = oarCommand(paths);
|
|
1461
1667
|
if (!systemdUserAvailable(ctx)) {
|
|
1462
1668
|
const wslHint = isWsl(ctx) ? ' In WSL, switch systemd on: systemd=true under [boot] in /etc/wsl.conf, then restart the distro (from Windows: wsl --terminate <distro>).' : '';
|
|
1463
|
-
|
|
1669
|
+
const start = paths.layout.checkout ? 'npm start' : 'oar start';
|
|
1670
|
+
logger.error(`[oar] No systemd user session here, so there is no service to install.${wslHint} Start the relay with: ${start}`);
|
|
1464
1671
|
return { code: 1, reason: 'no-systemd' };
|
|
1465
1672
|
}
|
|
1466
|
-
const unitPath = systemdUnitPath(ctx.homeDir);
|
|
1467
1673
|
const state = await serviceState(ctx, paths);
|
|
1468
1674
|
if (action === 'status') {
|
|
1469
|
-
|
|
1675
|
+
reportSystemdService(ctx, paths, state);
|
|
1470
1676
|
return { code: 0, reason: 'service-status' };
|
|
1471
1677
|
}
|
|
1472
1678
|
if (action === 'remove') {
|
|
1473
|
-
|
|
1679
|
+
const unit = readSystemdUnit(ctx, paths);
|
|
1680
|
+
if (unit.text === null) {
|
|
1474
1681
|
logger.log('[oar] The service is not installed.');
|
|
1475
1682
|
return { code: 0, reason: 'service-remove' };
|
|
1476
1683
|
}
|
|
1684
|
+
if (['hand-made', 'other-install'].includes(unit.kind)) {
|
|
1685
|
+
if (!replace) {
|
|
1686
|
+
logger.error(keptUnitLine(ctx, paths, unit, 'remove'));
|
|
1687
|
+
return { code: 1, reason: 'hand-made-unit' };
|
|
1688
|
+
}
|
|
1689
|
+
const backup = backupPathFor(unit.path, path.dirname(unit.path));
|
|
1690
|
+
fs.copyFileSync(unit.path, backup);
|
|
1691
|
+
logger.log(`[oar] Kept a copy of ${unit.path} as ${backup}`);
|
|
1692
|
+
}
|
|
1477
1693
|
runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
|
|
1478
|
-
try { fs.unlinkSync(
|
|
1694
|
+
try { fs.unlinkSync(unit.path); } catch {}
|
|
1479
1695
|
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
1480
|
-
logger.log(`[oar] Removed ${
|
|
1696
|
+
logger.log(`[oar] Removed ${unit.path}; the relay is stopped. Start it by hand with: ${paths.layout.checkout ? 'npm start' : 'oar start'}`);
|
|
1481
1697
|
return { code: 0, reason: 'service-remove' };
|
|
1482
1698
|
}
|
|
1483
1699
|
const missing = requireConfig(ctx, paths);
|
|
1484
1700
|
if (missing) return missing;
|
|
1485
|
-
|
|
1701
|
+
if (paths.layout.checkout) {
|
|
1702
|
+
for (const name of CHECKOUT_UNPINNED_ENV.filter((key) => String(ctx.env[key] || '').trim())) {
|
|
1703
|
+
logger.log(`[oar] ${name} is set in this shell; the service does not get it. If the relay needs it, put it in ~/.config/systemd/user/oar.service.d/*.conf`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
const wasActive = state === 'active' || (state === 'other' && systemdUnitActive(ctx));
|
|
1707
|
+
const written = writeSystemdUnit(ctx, paths, { replace });
|
|
1708
|
+
if (written.kept) {
|
|
1709
|
+
logger.error(keptUnitLine(ctx, paths, written.kept));
|
|
1710
|
+
return { code: 1, reason: 'hand-made-unit' };
|
|
1711
|
+
}
|
|
1486
1712
|
runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
|
|
1487
|
-
if (
|
|
1713
|
+
if (wasActive) {
|
|
1714
|
+
if (written.changed) logger.log(`[oar] The new unit applies at the next restart: ${command} restart --now`);
|
|
1715
|
+
} else if ((await findRelay(ctx, paths)).answer === 'oar') {
|
|
1488
1716
|
// A relay started by hand holds the port and the lock: it makes way first.
|
|
1489
1717
|
if ((await runStop({ ...options, argv: [] })).reason !== 'stopped') {
|
|
1490
1718
|
runQuiet('systemctl', ['--user', 'enable', 'oar'], ctx);
|
|
1491
|
-
logger.log(
|
|
1719
|
+
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: ${command} start`);
|
|
1492
1720
|
return { code: 0, reason: 'service-install' };
|
|
1493
1721
|
}
|
|
1494
1722
|
}
|
|
@@ -1638,7 +1866,10 @@ export async function runSetup(options = {}) {
|
|
|
1638
1866
|
|
|
1639
1867
|
const startRequested = has('--start') && !layout.checkout;
|
|
1640
1868
|
const viaService = running && (await serviceState(ctx, paths)) === 'active';
|
|
1641
|
-
if (startRequested && systemdUserAvailable(ctx))
|
|
1869
|
+
if (startRequested && systemdUserAvailable(ctx)) {
|
|
1870
|
+
const written = writeSystemdUnit(ctx, paths);
|
|
1871
|
+
if (written.kept) logger.log(keptUnitLine(ctx, paths, written.kept));
|
|
1872
|
+
}
|
|
1642
1873
|
|
|
1643
1874
|
let outcome = null;
|
|
1644
1875
|
if (changed) {
|
|
@@ -1751,9 +1982,9 @@ function purgeRefusal(ctx, dir, marker = null) {
|
|
|
1751
1982
|
return '';
|
|
1752
1983
|
}
|
|
1753
1984
|
|
|
1754
|
-
// An OAR state root: its config, or a database in its data folder.
|
|
1755
|
-
const
|
|
1756
|
-
const
|
|
1985
|
+
// An OAR state root: its config, or a database (either name) in its data folder.
|
|
1986
|
+
const looksLikeDataDir = (dir) => DB_FILE_NAMES.some((name) => fs.existsSync(path.join(dir, name)));
|
|
1987
|
+
const looksLikeStateRoot = (dir) => fs.existsSync(path.join(dir, 'config.json')) || looksLikeDataDir(path.join(dir, 'data'));
|
|
1757
1988
|
|
|
1758
1989
|
/**
|
|
1759
1990
|
* What `oar uninstall --purge` deletes: the state root (`refused` when
|
|
@@ -1783,7 +2014,11 @@ function purgePlan(ctx, paths) {
|
|
|
1783
2014
|
const checkout = checkoutAround(dir);
|
|
1784
2015
|
add(dir, checkout ? `a data folder in the git checkout ${checkout}` : 'a data folder outside the state root', looksLikeDataDir);
|
|
1785
2016
|
}
|
|
1786
|
-
|
|
2017
|
+
// Backups the config keeps outside the state root are never deleted.
|
|
2018
|
+
const configuredBackups = paths.config?.dbBackups?.dir;
|
|
2019
|
+
const backupsElsewhere = typeof configuredBackups === 'string' && path.isAbsolute(configuredBackups)
|
|
2020
|
+
&& !isInside(path.resolve(configuredBackups), root) && path.resolve(configuredBackups) !== root ? path.resolve(configuredBackups) : null;
|
|
2021
|
+
return { root, refused, extra, skipped, backupsElsewhere };
|
|
1787
2022
|
}
|
|
1788
2023
|
// Deletes `target` except the paths in `keep` (and the folders they are in).
|
|
1789
2024
|
function deleteTree(target, keep) {
|
|
@@ -1795,16 +2030,6 @@ function deleteTree(target, keep) {
|
|
|
1795
2030
|
for (const name of fs.readdirSync(target)) deleteTree(path.join(target, name), keep);
|
|
1796
2031
|
}
|
|
1797
2032
|
|
|
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
2033
|
function readText(filePath) {
|
|
1809
2034
|
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
1810
2035
|
}
|
|
@@ -1840,9 +2065,9 @@ async function removeGeneratedEntries(ctx, paths, done) {
|
|
|
1840
2065
|
done.push(...signin.filter((entry) => entry.generated).map((entry) => `removed ${entry.path}`));
|
|
1841
2066
|
kept.push(...now.entries.filter((entry) => !entry.generated));
|
|
1842
2067
|
} else {
|
|
1843
|
-
|
|
1844
|
-
const unit =
|
|
1845
|
-
if (unit !== null && !
|
|
2068
|
+
// Only a unit OAR wrote for this install (this version or 0.9.19) goes.
|
|
2069
|
+
const { path: unitPath, text: unit, kind } = readSystemdUnit(ctx, paths);
|
|
2070
|
+
if (unit !== null && !['generated', 'generated-earlier'].includes(kind)) kept.push({ path: unitPath });
|
|
1846
2071
|
else if (unit !== null) {
|
|
1847
2072
|
runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
|
|
1848
2073
|
fs.rmSync(unitPath, { force: true });
|
|
@@ -1910,7 +2135,8 @@ export async function runUninstall(options = {}) {
|
|
|
1910
2135
|
return { code: 1, reason: 'purge-unsafe' };
|
|
1911
2136
|
}
|
|
1912
2137
|
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.`);
|
|
2138
|
+
logger.log(`[oar] --purge deletes ${plan.root} for good: the config, the database with every conversation, its backups, the uploads and the logs.`);
|
|
2139
|
+
if (plan.backupsElsewhere) logger.log(`[oar] Not deleted: the database backups in ${plan.backupsElsewhere} (dbBackups.dir in the config); delete them yourself if you want them gone.`);
|
|
1914
2140
|
if ((await prompter.line('Type "delete" to confirm: ')) !== 'delete') {
|
|
1915
2141
|
logger.log('[oar] Not confirmed. Nothing was changed.');
|
|
1916
2142
|
return { code: 1, reason: 'purge-not-confirmed' };
|
|
@@ -2004,7 +2230,17 @@ export async function runUninstall(options = {}) {
|
|
|
2004
2230
|
export async function runDoctor(options = {}) {
|
|
2005
2231
|
const ctx = cliContext(options);
|
|
2006
2232
|
const paths = installPaths(ctx);
|
|
2007
|
-
|
|
2233
|
+
// The file the relay runs on (read only: nothing is created).
|
|
2234
|
+
const dataDir = relayDataDir(ctx, paths);
|
|
2235
|
+
const databaseFile = resolveDatabaseFile(dataDir);
|
|
2236
|
+
const dbPath = databaseFile.path;
|
|
2237
|
+
const databaseNotes = [
|
|
2238
|
+
...(databaseFile.both ? [`${path.join(dataDir, LEGACY_DB_FILE_NAME)} is also there; the next start compares the two`] : []),
|
|
2239
|
+
...databaseFile.dangling.map((file) => `${file} is a link to a file that is not there`),
|
|
2240
|
+
...databaseFile.orphans.map((file) => (path.basename(file).startsWith(`${DB_FILE_NAME}-`)
|
|
2241
|
+
? `${file} is there without ${path.join(dataDir, DB_FILE_NAME)}: the relay does not start until it is moved away`
|
|
2242
|
+
: `${file} is there without its database (not used, never deleted); move it away before renaming a database to that name`)),
|
|
2243
|
+
];
|
|
2008
2244
|
|
|
2009
2245
|
let dbSizeBytes = null;
|
|
2010
2246
|
try { dbSizeBytes = fs.statSync(dbPath).size; } catch {}
|
|
@@ -2023,6 +2259,7 @@ export async function runDoctor(options = {}) {
|
|
|
2023
2259
|
|
|
2024
2260
|
const port = endpointOf(paths.config).port;
|
|
2025
2261
|
const holder = paths.config ? createWindowsPortCheck(ctx)(port) : null;
|
|
2262
|
+
const backups = describeBackups({ layout: paths.layout, serverDir: path.join(ctx.packageRoot, 'server'), dataDir, config: paths.config });
|
|
2026
2263
|
ctx.logger.log(renderDoctorReport({
|
|
2027
2264
|
version: readPackageVersion(ctx.packageRoot),
|
|
2028
2265
|
nodeVersion: process.version,
|
|
@@ -2032,6 +2269,9 @@ export async function runDoctor(options = {}) {
|
|
|
2032
2269
|
config: paths.config,
|
|
2033
2270
|
dbPath,
|
|
2034
2271
|
dbSizeBytes,
|
|
2272
|
+
databaseNotes,
|
|
2273
|
+
backups,
|
|
2274
|
+
now: Date.now(),
|
|
2035
2275
|
probes,
|
|
2036
2276
|
warnings: holder ? [windowsPortWarning(port, holder)] : [],
|
|
2037
2277
|
// The relay resolves a relative cloudflaredTunnel.binary against server/.
|
|
@@ -2040,6 +2280,59 @@ export async function runDoctor(options = {}) {
|
|
|
2040
2280
|
return { code: 0, reason: 'doctor' };
|
|
2041
2281
|
}
|
|
2042
2282
|
|
|
2283
|
+
/**
|
|
2284
|
+
* `oar backup [--reason <text>]`: a copy of the relay's database, online
|
|
2285
|
+
* (safe while the relay runs), into the backup folder with the start's
|
|
2286
|
+
* name, manifest and rotation (db-backup-service.mjs). Never creates a
|
|
2287
|
+
* database; never touches db_last_run. Exit 1 when no copy could be made,
|
|
2288
|
+
* 0 for a copy or a skip (with its warning).
|
|
2289
|
+
*/
|
|
2290
|
+
export async function runBackup(options = {}) {
|
|
2291
|
+
const ctx = cliContext(options);
|
|
2292
|
+
const paths = installPaths(ctx);
|
|
2293
|
+
const dataDir = relayDataDir(ctx, paths);
|
|
2294
|
+
const argv = ctx.argv || [];
|
|
2295
|
+
const reasonAt = argv.indexOf('--reason');
|
|
2296
|
+
const given = reasonAt === -1 ? argv.find((arg) => arg.startsWith('--reason='))?.slice('--reason='.length) : argv[reasonAt + 1];
|
|
2297
|
+
const reason = given === undefined ? 'manual' : sanitizeReason(given);
|
|
2298
|
+
const fail = (message, why = 'backup-failed') => {
|
|
2299
|
+
ctx.logger.error(`[oar] ${message}`);
|
|
2300
|
+
return { code: 1, reason: why };
|
|
2301
|
+
};
|
|
2302
|
+
const file = resolveDatabaseFile(dataDir);
|
|
2303
|
+
if (file.created || file.dangling.length) {
|
|
2304
|
+
return fail(`No database in ${dataDir} to back up (neither ${DB_FILE_NAME} nor ${LEGACY_DB_FILE_NAME}).`, 'no-database');
|
|
2305
|
+
}
|
|
2306
|
+
for (const warning of configWarnings(paths.config)) ctx.logger.error(warning);
|
|
2307
|
+
let source;
|
|
2308
|
+
try {
|
|
2309
|
+
const Database = (await import('better-sqlite3')).default;
|
|
2310
|
+
source = new Database(file.path, { readonly: true, fileMustExist: true });
|
|
2311
|
+
const say = { log: (line) => ctx.logger.log(String(line).replace(/^\[backup\]/, '[oar]')), warn: (line) => ctx.logger.error(String(line).replace(/^\[backup\]/, '[oar]')) };
|
|
2312
|
+
const result = await backupDatabase({
|
|
2313
|
+
source,
|
|
2314
|
+
Database,
|
|
2315
|
+
dbPath: file.path,
|
|
2316
|
+
dataDir,
|
|
2317
|
+
layout: paths.layout,
|
|
2318
|
+
serverDir: path.join(ctx.packageRoot, 'server'),
|
|
2319
|
+
config: paths.config,
|
|
2320
|
+
version: readPackageVersion(ctx.packageRoot),
|
|
2321
|
+
reason,
|
|
2322
|
+
logger: say,
|
|
2323
|
+
...(ctx.isProcessAliveImpl ? { isProcessAlive: ctx.isProcessAliveImpl } : {}),
|
|
2324
|
+
...(ctx.fsImpl ? { fsImpl: ctx.fsImpl } : {}),
|
|
2325
|
+
});
|
|
2326
|
+
if (result.status === 'busy') return fail(`Not backing up now: ${result.reason}; run oar backup again once it is done.`, 'backup-busy');
|
|
2327
|
+
if (result.status === 'failed') return { code: 1, reason: 'backup-failed' };
|
|
2328
|
+
return { code: 0, reason: result.status === 'written' ? 'backup-written' : 'backup-skipped', file: result.file ? path.join(result.dir, result.file) : null };
|
|
2329
|
+
} catch (error) {
|
|
2330
|
+
return fail(`The backup of ${file.path} failed: ${error?.message || error}.`);
|
|
2331
|
+
} finally {
|
|
2332
|
+
try { source?.close(); } catch {}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2043
2336
|
/**
|
|
2044
2337
|
* `oar update [--beta] [--to X.Y.Z]` — the terminal twin of the web UI's
|
|
2045
2338
|
* Update button. Fetching the manifest here is an explicit user action, so it
|
|
@@ -2177,7 +2470,7 @@ async function runMenuCommand(options = {}) {
|
|
|
2177
2470
|
doctor: run(runDoctor),
|
|
2178
2471
|
copilot: run(launchRelay),
|
|
2179
2472
|
service: async (status) => {
|
|
2180
|
-
const action = { none: 'install', unavailable: 'status' }[status.service] || 'remove';
|
|
2473
|
+
const action = { none: 'install', unavailable: 'status', other: 'status' }[status.service] || 'remove';
|
|
2181
2474
|
if (action === 'remove' && !(await ctx.prompter.yesNo('Remove the service?', { defaultYes: false }))) return null;
|
|
2182
2475
|
return runService({ ...options, argv: [action] });
|
|
2183
2476
|
},
|
|
@@ -2200,6 +2493,7 @@ const COMMANDS = {
|
|
|
2200
2493
|
uninstall: runUninstall,
|
|
2201
2494
|
copilot: launchRelay,
|
|
2202
2495
|
doctor: runDoctor,
|
|
2496
|
+
backup: runBackup,
|
|
2203
2497
|
update: runUpdate,
|
|
2204
2498
|
help: async ({ logger = console } = {}) => {
|
|
2205
2499
|
logger.log(usageText());
|