@oar-sh/oar 0.9.22 → 0.9.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/README.md +9 -9
- package/bin/oar.js +28 -0
- package/package.json +13 -14
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/routes/messages-routes.mjs +11 -20
- package/server/routes/sessions-routes.mjs +1 -1
- package/server/server-runtime.mjs +21 -40
- package/server/services/earlier-database-service.mjs +1 -1
- package/server/services/file-serving.mjs +13 -0
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +7 -4
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/update-install-service.mjs +32 -1
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,99 @@
|
|
|
3
3
|
All notable changes to OAR are documented here. The format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [0.9.23] — 2026-11-07
|
|
7
|
+
|
|
8
|
+
Highlights: a dependency round. The Copilot, Claude and Cursor SDKs, zod,
|
|
9
|
+
express, socket.io, ws and better-sqlite3 move to newer releases:
|
|
10
|
+
the tree holds one zod, without CVE-2023-54404, and one express, and
|
|
11
|
+
prebuild-install, which is no longer maintained, is gone. OAR's package has
|
|
12
|
+
no install script any more, which package scanners flag. On Termux, 32-bit
|
|
13
|
+
ARM and FreeBSD, run **`oar build-native`** once after updating to this
|
|
14
|
+
version: it builds the SQLite binding, and later updates build it
|
|
15
|
+
themselves.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`oar build-native`.** better-sqlite3 13 ships its SQLite binding for
|
|
20
|
+
Linux, macOS and Windows on x64 and arm64. On Android under Termux, 32-bit
|
|
21
|
+
ARM and FreeBSD, `oar build-native` compiles it in the installed package
|
|
22
|
+
(Python 3, make and a C++ compiler; on Termux `pkg install python clang
|
|
23
|
+
make`). The **Update** button and `oar update` run it there themselves
|
|
24
|
+
before the relay restarts; a build that fails ends the update in an error
|
|
25
|
+
and leaves the running relay alone. Elsewhere it has nothing to do, so an
|
|
26
|
+
installer may run it everywhere. A relay started without the binding says
|
|
27
|
+
which command to run and exits instead of printing a stack trace.
|
|
28
|
+
- **What the agent SDKs contact.** docs/providers.md lists the services the
|
|
29
|
+
Copilot, Claude and Cursor SDKs and their CLIs talk to, and what the
|
|
30
|
+
Statsig client inside the Cursor SDK does.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **No install script.** OAR's package has no `preinstall` any more. From
|
|
35
|
+
0.9.14 to 0.9.22 it copied the data a relay before 0.9.14 kept inside the
|
|
36
|
+
package out before npm deleted it. npm 12 never ran it without
|
|
37
|
+
`--allow-scripts`, and package scanners flag every package that has one.
|
|
38
|
+
A copy those versions made is still taken over at the next start.
|
|
39
|
+
- **`--allow-scripts=koffi`.** better-sqlite3 13 has no install script, so
|
|
40
|
+
a manual install with npm 12 is now
|
|
41
|
+
`npm install -g --allow-scripts=koffi @oar-sh/oar`; the installers, the
|
|
42
|
+
**Update** button and `oar update` pass it themselves.
|
|
43
|
+
- **No terminal console.** The optional `tty-console` dependency is gone,
|
|
44
|
+
and neo-blessed with it. A relay started in a terminal prints its log
|
|
45
|
+
lines, each with its time, as under a service; routine worker lines are
|
|
46
|
+
no longer held back.
|
|
47
|
+
- **Express 5.** The file routes take express 5's syntax and serve the
|
|
48
|
+
same paths. Static JavaScript is served as `text/javascript`.
|
|
49
|
+
- `better-sqlite3` 13.0.3 (was 12.11.1); `prebuild-install`, which is no
|
|
50
|
+
longer maintained, and its subtree are gone. New in the tree:
|
|
51
|
+
`node-addon-api` 8.9.2, the headers of the source build.
|
|
52
|
+
- `express` 5.3.0 (was 4.22.3), the major the MCP SDK already used: one
|
|
53
|
+
express instead of two, and array-flatten, mime 1 and the rest of express
|
|
54
|
+
4's subtree are gone.
|
|
55
|
+
- `@anthropic-ai/claude-agent-sdk` 0.3.319 (was 0.3.304; bundled Claude
|
|
56
|
+
Code 2.1.319). The key chats (DeepSeek, Claude API) also pin the
|
|
57
|
+
cloud-sessions token and the two new switches that would let a folder's
|
|
58
|
+
settings widen which certificates Claude Code trusts.
|
|
59
|
+
- `@cursor/sdk` 1.3.1 (was 1.0.38). Its context-compaction summaries are
|
|
60
|
+
not shown yet.
|
|
61
|
+
- `@github/copilot-sdk` 1.0.29 (was 1.0.23; bundled Copilot CLI 1.0.107)
|
|
62
|
+
and `zod` 4.6.7 (was 4.4.3): one zod in the tree, without
|
|
63
|
+
CVE-2023-54404 (GHSA-mrhx-c7xc-m3w5).
|
|
64
|
+
- `socket.io` 4.8.4 (was 4.8.3) and `ws` 8.22.1 (was 8.21.3). `uuid` is
|
|
65
|
+
gone: Node's `crypto.randomUUID()` makes the same ids.
|
|
66
|
+
- package.json names its author and its bug tracker.
|
|
67
|
+
|
|
68
|
+
### Upgrade notes
|
|
69
|
+
|
|
70
|
+
- **From 0.9.13 or older, copy the data out of the package first.** A relay
|
|
71
|
+
before 0.9.14 could keep its database, uploads and config inside the
|
|
72
|
+
package folder, which `npm install -g` deletes, and this version no longer
|
|
73
|
+
copies them out. Copy `data/`, `uploads/` and `config.json` from the
|
|
74
|
+
installed package's `server` folder into a new folder under
|
|
75
|
+
`rescued-package-data` in the data folder (for example
|
|
76
|
+
`~/.oar/data/rescued-package-data/before-update/`); the first start takes
|
|
77
|
+
them over. From 0.9.14 on there is nothing to do.
|
|
78
|
+
- **Termux, 32-bit ARM, FreeBSD: the update from 0.9.22 needs
|
|
79
|
+
`oar build-native` once.** The Update button and `oar update` of 0.9.22
|
|
80
|
+
install 0.9.23 but do not build the binding, so the relay stops at its
|
|
81
|
+
first start on 0.9.23: its log names the command, and the page cannot
|
|
82
|
+
reach the relay. In Termux run:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pkg install python clang make
|
|
86
|
+
oar build-native
|
|
87
|
+
oar start
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Later updates build the binding themselves, before the relay restarts.
|
|
91
|
+
- **Linux needs glibc 2.34 or newer,** for example Debian 12, Ubuntu 22.04
|
|
92
|
+
or RHEL 9. The SQLite binding better-sqlite3 13 ships for Linux on x64
|
|
93
|
+
and arm64 needs it, and a binding built with `oar build-native` would not
|
|
94
|
+
be used there. On an older system a relay that updates to 0.9.23 stays
|
|
95
|
+
down at its first start and says to update the operating system; stay on
|
|
96
|
+
0.9.22 until the system is updated.
|
|
97
|
+
- **No migration.** Going back to 0.9.22 needs no database step.
|
|
98
|
+
|
|
6
99
|
## [0.9.22] — 2026-11-05
|
|
7
100
|
|
|
8
101
|
Highlights: questions that don't block move into the chat header. A **📓**
|
package/README.md
CHANGED
|
@@ -45,29 +45,29 @@ flowchart LR
|
|
|
45
45
|
<table>
|
|
46
46
|
<tr>
|
|
47
47
|
<td width="42%" rowspan="4" valign="top">
|
|
48
|
-
<a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.
|
|
48
|
+
<a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.23" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
|
|
49
49
|
<br><sub>On a phone: the same conversations, installable as an app.</sub>
|
|
50
50
|
</td>
|
|
51
51
|
<td width="58%" valign="top">
|
|
52
|
-
<a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.
|
|
52
|
+
<a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.23" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
|
|
53
53
|
<br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
|
|
54
54
|
</td>
|
|
55
55
|
</tr>
|
|
56
56
|
<tr>
|
|
57
57
|
<td valign="top">
|
|
58
|
-
<a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.
|
|
58
|
+
<a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.23" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
|
|
59
59
|
<br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
|
|
60
60
|
</td>
|
|
61
61
|
</tr>
|
|
62
62
|
<tr>
|
|
63
63
|
<td valign="top">
|
|
64
|
-
<a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.
|
|
64
|
+
<a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.23" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
|
|
65
65
|
<br><sub>Read the reasoning, not just the answer.</sub>
|
|
66
66
|
</td>
|
|
67
67
|
</tr>
|
|
68
68
|
<tr>
|
|
69
69
|
<td valign="top">
|
|
70
|
-
<a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.
|
|
70
|
+
<a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.23" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
|
|
71
71
|
<br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
|
|
72
72
|
</td>
|
|
73
73
|
</tr>
|
|
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
|
|
|
118
118
|
<table>
|
|
119
119
|
<tr>
|
|
120
120
|
<td width="62%" valign="top">
|
|
121
|
-
<a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.
|
|
121
|
+
<a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.23" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
|
|
122
122
|
<br><sub>Send a message while the agent works, and it steers the running turn.</sub>
|
|
123
123
|
</td>
|
|
124
124
|
<td width="38%" valign="top">
|
|
125
|
-
<a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.
|
|
125
|
+
<a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.23" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
|
|
126
126
|
<br><sub>What each plan has left, in one place.</sub>
|
|
127
127
|
</td>
|
|
128
128
|
</tr>
|
|
@@ -139,7 +139,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
|
|
|
139
139
|
- **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
|
|
140
140
|
- **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
|
|
141
141
|
|
|
142
|
-
The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.
|
|
142
|
+
The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.23](CHANGELOG.md#0923--2026-11-07) as the latest.
|
|
143
143
|
|
|
144
144
|
## Remote access
|
|
145
145
|
|
|
@@ -190,7 +190,7 @@ flowchart LR
|
|
|
190
190
|
|
|
191
191
|
You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
|
|
192
192
|
|
|
193
|
-
<p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.
|
|
193
|
+
<p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.23" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
|
|
194
194
|
|
|
195
195
|
How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
|
|
196
196
|
|
package/bin/oar.js
CHANGED
|
@@ -14,6 +14,7 @@ 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 { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from '../server/services/native-addon-build-service.mjs';
|
|
17
18
|
import { DB_FILE_NAME, DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile } from '../server/services/database-file.mjs';
|
|
18
19
|
import { BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE, backupDatabase, configWarnings, describeBackups, sanitizeReason } from '../server/services/db-backup-service.mjs';
|
|
19
20
|
import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
|
|
@@ -2407,6 +2408,15 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
2407
2408
|
return { code: 1, reason: 'npm-failed' };
|
|
2408
2409
|
}
|
|
2409
2410
|
logger.log(`[oar] Installed @oar-sh/oar@${target}.`);
|
|
2411
|
+
// No prebuilt SQLite binding here: build it before the relay restarts on
|
|
2412
|
+
// the new version (native-addon-build-service.mjs).
|
|
2413
|
+
if (needsSourceBuild()) {
|
|
2414
|
+
const build = await buildBetterSqlite3({ packageRoot, env: npm.env, spawnImpl, logger, onOutput: (text) => process.stdout.write(text) });
|
|
2415
|
+
if (!build.ok) {
|
|
2416
|
+
logger.error(`[oar] The relay keeps running the version it has loaded. Run ${NATIVE_BUILD_COMMAND}, then restart it.`);
|
|
2417
|
+
return { code: 1, reason: 'native-build-failed' };
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2410
2420
|
|
|
2411
2421
|
// A running relay picks the new code up on restart; ask for the queue-idle
|
|
2412
2422
|
// deferred one so no in-flight turn is cut off.
|
|
@@ -2446,6 +2456,23 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
|
|
|
2446
2456
|
return { code: 0, reason: 'updated' };
|
|
2447
2457
|
}
|
|
2448
2458
|
|
|
2459
|
+
/**
|
|
2460
|
+
* `oar build-native [--force]`: the SQLite binding of better-sqlite3 where no
|
|
2461
|
+
* prebuilt one ships (Termux, 32-bit ARM, FreeBSD); elsewhere nothing to do,
|
|
2462
|
+
* so an installer may run it everywhere. `--force` builds anyway, which only
|
|
2463
|
+
* tests the toolchain where a prebuilt binding ships: better-sqlite3 loads
|
|
2464
|
+
* that one first.
|
|
2465
|
+
*/
|
|
2466
|
+
async function runBuildNative({ argv = [], env = process.env, logger = console, spawnImpl = spawn } = {}) {
|
|
2467
|
+
const force = argv.includes('--force');
|
|
2468
|
+
if (!force && !needsSourceBuild()) {
|
|
2469
|
+
logger.log(`[oar] better-sqlite3 ships a prebuilt SQLite binding for ${process.platform}-${process.arch}; nothing to build.`);
|
|
2470
|
+
return { code: 0, reason: 'prebuilt' };
|
|
2471
|
+
}
|
|
2472
|
+
const result = await buildBetterSqlite3({ packageRoot: resolvePackageRoot(), force, env, spawnImpl, logger, onOutput: (text) => process.stdout.write(text) });
|
|
2473
|
+
return result.ok ? { code: 0, reason: 'built' } : { code: 1, reason: 'build-failed' };
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2449
2476
|
/** `oar` without a command: the menu in a terminal, the status and the usage elsewhere. */
|
|
2450
2477
|
async function runMenuCommand(options = {}) {
|
|
2451
2478
|
const ctx = cliContext(options);
|
|
@@ -2495,6 +2522,7 @@ const COMMANDS = {
|
|
|
2495
2522
|
doctor: runDoctor,
|
|
2496
2523
|
backup: runBackup,
|
|
2497
2524
|
update: runUpdate,
|
|
2525
|
+
'build-native': runBuildNative,
|
|
2498
2526
|
help: async ({ logger = console } = {}) => {
|
|
2499
2527
|
logger.log(usageText());
|
|
2500
2528
|
return { code: 0, reason: 'help' };
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"author": "Simon",
|
|
7
8
|
"homepage": "https://oar.sh",
|
|
8
9
|
"repository": {
|
|
9
10
|
"type": "git",
|
|
10
11
|
"url": "git+https://github.com/oar-sh/oar.git"
|
|
11
12
|
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/oar-sh/oar/issues"
|
|
15
|
+
},
|
|
12
16
|
"engines": {
|
|
13
17
|
"node": ">=22.13"
|
|
14
18
|
},
|
|
@@ -41,7 +45,6 @@
|
|
|
41
45
|
"LICENSE"
|
|
42
46
|
],
|
|
43
47
|
"scripts": {
|
|
44
|
-
"preinstall": "node server/services/package-data-rescue-preinstall.mjs",
|
|
45
48
|
"start": "node server/server.js",
|
|
46
49
|
"copilot:relay": "gh copilot -- --allow-all",
|
|
47
50
|
"test": "node --import ./scripts/node-test-keep-alive.mjs --import ./scripts/node-test-worktree-guard.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
@@ -55,20 +58,16 @@
|
|
|
55
58
|
"test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
|
|
56
59
|
},
|
|
57
60
|
"dependencies": {
|
|
58
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
59
|
-
"@cursor/sdk": "1.
|
|
60
|
-
"@github/copilot-sdk": "1.0.
|
|
61
|
-
"better-sqlite3": "
|
|
62
|
-
"express": "
|
|
61
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.319",
|
|
62
|
+
"@cursor/sdk": "1.3.1",
|
|
63
|
+
"@github/copilot-sdk": "1.0.29",
|
|
64
|
+
"better-sqlite3": "13.0.3",
|
|
65
|
+
"express": "5.3.0",
|
|
63
66
|
"qrcode-terminal": "0.12.0",
|
|
64
|
-
"socket.io": "4.8.
|
|
65
|
-
"uuid": "14.0.1",
|
|
67
|
+
"socket.io": "4.8.4",
|
|
66
68
|
"web-push": "3.6.7",
|
|
67
|
-
"ws": "8.
|
|
68
|
-
"zod": "4.
|
|
69
|
-
},
|
|
70
|
-
"optionalDependencies": {
|
|
71
|
-
"tty-console": "1.0.4"
|
|
69
|
+
"ws": "8.22.1",
|
|
70
|
+
"zod": "4.6.7"
|
|
72
71
|
},
|
|
73
72
|
"devDependencies": {
|
|
74
73
|
"@playwright/test": "^1.60.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* SSL_CERT_FILE and SSL_CERT_DIR: '' empties OpenSSL's and Node's system
|
|
23
23
|
* store, so those two are pinned only when the worker sets them.
|
|
24
24
|
*
|
|
25
|
-
* Source: the names Claude Code 2.1.
|
|
25
|
+
* Source: the names Claude Code 2.1.319 (the bundled CLI) reads, by a grep
|
|
26
26
|
* of the binary; the CLI's own lists of endpoints (with their selection
|
|
27
27
|
* switches and companions) and credentials are in it as they are there. A
|
|
28
28
|
* deny-list, because a settings layer can only pin names known in advance.
|
|
@@ -82,6 +82,7 @@ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
|
|
|
82
82
|
'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
|
|
83
83
|
'CLAUDE_CODE_OAUTH_SCOPES',
|
|
84
84
|
'CLAUDE_CODE_OAUTH_CLIENT_ID',
|
|
85
|
+
'CLAUDE_CODE_SESSIONS_OAUTH_TOKEN',
|
|
85
86
|
'ANTHROPIC_PROFILE',
|
|
86
87
|
'ANTHROPIC_IDENTITY_TOKEN',
|
|
87
88
|
'ANTHROPIC_IDENTITY_TOKEN_FILE',
|
|
@@ -106,6 +107,8 @@ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
|
|
|
106
107
|
'NODE_TLS_REJECT_UNAUTHORIZED',
|
|
107
108
|
'NODE_EXTRA_CA_CERTS',
|
|
108
109
|
'CLAUDE_CODE_CERT_STORE',
|
|
110
|
+
'CLAUDE_CODE_DISABLE_LOOPED_CROSS_CERT_DROP',
|
|
111
|
+
'CLAUDE_CODE_DISABLE_SELF_NAMED_CERT_DROP',
|
|
109
112
|
'CLAUDE_CODE_CLIENT_CERT',
|
|
110
113
|
'CLAUDE_CODE_CLIENT_KEY',
|
|
111
114
|
'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
|
|
@@ -163,6 +163,13 @@ export function shouldRequireNewOpenAIConversation({
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// The path a `{*splat}` route matched: express 5 hands it over as its
|
|
167
|
+
// decoded segments, or not at all when the path ends at the slash.
|
|
168
|
+
export function splatPath(req) {
|
|
169
|
+
const splat = req.params?.splat;
|
|
170
|
+
return Array.isArray(splat) ? splat.join('/') : String(splat || '');
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
function normalizeRequestedProviderType(value = '') {
|
|
167
174
|
const normalized = String(value || '').trim().toLowerCase();
|
|
168
175
|
if (normalized === 'openai' || normalized === 'openai-byok' || normalized === 'openai-image') return 'openai';
|
|
@@ -2142,8 +2149,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2142
2149
|
deepseekClaudeConfigDir = '',
|
|
2143
2150
|
} = deps;
|
|
2144
2151
|
|
|
2145
|
-
const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
|
|
2146
|
-
|
|
2147
2152
|
// ─── Sub-agent runs: header, timeline, live events ─────────────────────────
|
|
2148
2153
|
const subagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
2149
2154
|
|
|
@@ -2255,18 +2260,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2255
2260
|
LIMIT 1
|
|
2256
2261
|
`);
|
|
2257
2262
|
|
|
2258
|
-
function isAbnormalWorkerTelemetry(payload = {}, level = 'log') {
|
|
2259
|
-
const normalizedLevel = String(level || 'log').trim().toLowerCase();
|
|
2260
|
-
if (normalizedLevel === 'warn' || normalizedLevel === 'error') return true;
|
|
2261
|
-
const event = String(payload?.event || '').trim().toLowerCase();
|
|
2262
|
-
const state = String(payload?.state || '').trim().toLowerCase();
|
|
2263
|
-
if (state === 'error') return true;
|
|
2264
|
-
return event.includes('error')
|
|
2265
|
-
|| event.includes('failed')
|
|
2266
|
-
|| event.includes('failure')
|
|
2267
|
-
|| event.includes('blocked');
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
2263
|
function readBridgeIdentity(req) {
|
|
2271
2264
|
return relayBridgeOwnerService?.normalizeIdentity?.({
|
|
2272
2265
|
pid: req.headers['x-relay-process-pid'],
|
|
@@ -2340,8 +2333,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2340
2333
|
const payload = extra && typeof extra === 'object'
|
|
2341
2334
|
? { ...envelope, ...extra }
|
|
2342
2335
|
: envelope;
|
|
2343
|
-
const shouldLog = !ttyConsoleActive || isAbnormalWorkerTelemetry(payload, level);
|
|
2344
|
-
if (!shouldLog) return;
|
|
2345
2336
|
const method = typeof console[level] === 'function' ? level : 'log';
|
|
2346
2337
|
console[method](`[${ts()}] WORKER ${JSON.stringify(payload)}`);
|
|
2347
2338
|
}
|
|
@@ -4021,8 +4012,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4021
4012
|
});
|
|
4022
4013
|
});
|
|
4023
4014
|
|
|
4024
|
-
app.get('/api/files
|
|
4025
|
-
const requestedPath =
|
|
4015
|
+
app.get('/api/files/{*splat}', auth, (req, res) => {
|
|
4016
|
+
const requestedPath = splatPath(req).trim();
|
|
4026
4017
|
const rootOverride = resolveScopedWorkspaceRootPath(req);
|
|
4027
4018
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
4028
4019
|
if (!filePath) return res.status(400).json({ error: 'Invalid file path' });
|
|
@@ -4043,8 +4034,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4043
4034
|
});
|
|
4044
4035
|
});
|
|
4045
4036
|
|
|
4046
|
-
app.get('/api/files-preview
|
|
4047
|
-
const requestedPath =
|
|
4037
|
+
app.get('/api/files-preview/{*splat}', auth, (req, res) => {
|
|
4038
|
+
const requestedPath = splatPath(req).trim();
|
|
4048
4039
|
const normalizedPath = normalizeWorkspaceRelativePath(requestedPath);
|
|
4049
4040
|
const rootOverride = resolveScopedWorkspaceRootPath(req);
|
|
4050
4041
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
@@ -161,7 +161,7 @@ export { mapUsageSnapshotRow };
|
|
|
161
161
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
162
162
|
const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing', 'parked']);
|
|
163
163
|
// Registry statuses that mean "this worker is currently running" (same triple
|
|
164
|
-
// as
|
|
164
|
+
// as ACTIVE_SESSION_CWD_STATUSES in server-runtime.mjs).
|
|
165
165
|
const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
|
|
166
166
|
// A shared attachment's URL names its bytes (sha256 or a fixed generated-image
|
|
167
167
|
// path), so a kept answer stays right.
|
|
@@ -4,7 +4,7 @@ import express from 'express';
|
|
|
4
4
|
import http from 'http';
|
|
5
5
|
import { Server } from 'socket.io';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
-
import {
|
|
7
|
+
import { randomUUID as uuidv4 } from 'crypto';
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { execFile, execFileSync, spawn } from 'child_process';
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from './services/workspace-root-path-policy.mjs';
|
|
26
26
|
import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
|
|
27
27
|
import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
|
|
28
|
-
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
28
|
+
import { applyCdnNoStoreHeaders, sendOwnFile } from './services/file-serving.mjs';
|
|
29
29
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
30
30
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
31
31
|
import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
|
|
@@ -196,8 +196,8 @@ import {
|
|
|
196
196
|
resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
|
|
197
197
|
resolveLaunchWorkspaceRootPath,
|
|
198
198
|
} from './services/workspace-root-defaults-service.mjs';
|
|
199
|
-
import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
|
|
200
199
|
import { installRelayConsoleLog, resolveRelayConsoleLogPath } from './services/relay-console-log.mjs';
|
|
200
|
+
import { isMissingSqliteBindingError, missingSqliteBindingLines } from './services/native-addon-build-service.mjs';
|
|
201
201
|
import {
|
|
202
202
|
computeFeatureFlagState,
|
|
203
203
|
FEATURE_REGISTRY,
|
|
@@ -327,13 +327,15 @@ const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
|
|
|
327
327
|
// after a restart or an incident.
|
|
328
328
|
const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
|
|
329
329
|
const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
|
|
330
|
+
// Where better-sqlite3 ships no binary (Termux, 32-bit ARM) the binding is
|
|
331
|
+
// built by `oar build-native`; until then say so instead of a stack trace.
|
|
332
|
+
try {
|
|
333
|
+
new Database(':memory:').close();
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (!isMissingSqliteBindingError(error)) throw error;
|
|
336
|
+
for (const line of missingSqliteBindingLines({ error })) console.error(`[startup] ${line}`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
337
339
|
// TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
|
|
338
340
|
for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
|
|
339
341
|
|
|
@@ -2112,7 +2114,6 @@ function workspaceRootPayload() {
|
|
|
2112
2114
|
}
|
|
2113
2115
|
|
|
2114
2116
|
const ACTIVE_SESSION_CWD_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
2115
|
-
const ACTIVE_TTY_CLI_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
2116
2117
|
|
|
2117
2118
|
function normalizeConversationWorkspaceRootPath(candidatePath) {
|
|
2118
2119
|
const normalized = normalizeWorkspaceRootPath(candidatePath);
|
|
@@ -3760,27 +3761,6 @@ function queueCounts() {
|
|
|
3760
3761
|
return { pendingCount: map.pending || 0, processingCount: map.processing || 0, parkedCount: map.parked || 0 };
|
|
3761
3762
|
}
|
|
3762
3763
|
|
|
3763
|
-
function countActiveCliWorkers() {
|
|
3764
|
-
const workers = sessionWorkerRegistry?.listWorkers?.() || [];
|
|
3765
|
-
return workers.reduce((acc, worker) => {
|
|
3766
|
-
const status = String(worker?.status || '').trim().toLowerCase();
|
|
3767
|
-
return ACTIVE_TTY_CLI_WORKER_STATUSES.has(status) ? acc + 1 : acc;
|
|
3768
|
-
}, 0);
|
|
3769
|
-
}
|
|
3770
|
-
|
|
3771
|
-
function attachTtyConsoleRuntimeTitle(runtime) {
|
|
3772
|
-
const addConsoleTitle = runtime?.tty?.addConsoleTitle;
|
|
3773
|
-
if (typeof addConsoleTitle !== 'function') return;
|
|
3774
|
-
addConsoleTitle(() => {
|
|
3775
|
-
const counts = queueCounts();
|
|
3776
|
-
const connectedClients = Number(io?.sockets?.sockets?.size || 0);
|
|
3777
|
-
const activeCliWorkers = countActiveCliWorkers();
|
|
3778
|
-
const cliState = cliOnline ? 'online' : 'offline';
|
|
3779
|
-
return `Clients: ${connectedClients} :: Pen: ${counts.pendingCount} :: Proc: ${counts.processingCount} :: parked: ${counts.parkedCount} :: CLIs: ${activeCliWorkers} :: CLI: ${cliState}`;
|
|
3780
|
-
});
|
|
3781
|
-
}
|
|
3782
|
-
attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
|
|
3783
|
-
|
|
3784
3764
|
// ─── Queued relay restart / shutdown ─────────────────────────────────────────
|
|
3785
3765
|
// A request waits until no turn is processing and holds new turns meanwhile
|
|
3786
3766
|
// (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
|
|
@@ -7430,6 +7410,12 @@ app.use(tunnelWorkerPathGuard.requestMiddleware);
|
|
|
7430
7410
|
// Uploads travel as raw bodies on POST /api/upload (express.raw, capped by
|
|
7431
7411
|
// MAX_UPLOAD_BYTES), so the JSON limit only bounds inline image data URLs.
|
|
7432
7412
|
app.use(express.json({ limit: '20mb' }));
|
|
7413
|
+
// Express 5 leaves req.body undefined when no parser read a body (a GET, a
|
|
7414
|
+
// POST without a JSON body); the routes read it as express 4 gave it, {}.
|
|
7415
|
+
app.use((req, _res, next) => {
|
|
7416
|
+
if (req.body === undefined) req.body = {};
|
|
7417
|
+
next();
|
|
7418
|
+
});
|
|
7433
7419
|
app.use((req, _res, next) => {
|
|
7434
7420
|
stripRequestPathPrefix(req, remotePath);
|
|
7435
7421
|
next();
|
|
@@ -7461,9 +7447,7 @@ app.use((req, res, next) => {
|
|
|
7461
7447
|
});
|
|
7462
7448
|
app.get('/socket.io/socket.io.js', (req, res, next) => {
|
|
7463
7449
|
res.setHeader('Cache-Control', 'no-store');
|
|
7464
|
-
res
|
|
7465
|
-
if (error) next(error);
|
|
7466
|
-
});
|
|
7450
|
+
sendOwnFile(res, SOCKET_IO_CLIENT_JS_PATH, next);
|
|
7467
7451
|
});
|
|
7468
7452
|
app.get('/manifest.webmanifest', (req, res, next) => {
|
|
7469
7453
|
try {
|
|
@@ -7477,9 +7461,7 @@ app.get('/manifest.webmanifest', (req, res, next) => {
|
|
|
7477
7461
|
});
|
|
7478
7462
|
app.get('/sw.js', (req, res, next) => {
|
|
7479
7463
|
res.setHeader('Cache-Control', 'no-store');
|
|
7480
|
-
res
|
|
7481
|
-
if (error) next(error);
|
|
7482
|
-
});
|
|
7464
|
+
sendOwnFile(res, PWA_SW_PATH, next);
|
|
7483
7465
|
});
|
|
7484
7466
|
app.get(['/', '/index.html'], (req, res, next) => {
|
|
7485
7467
|
try {
|
|
@@ -7554,7 +7536,6 @@ const runtimeState = {
|
|
|
7554
7536
|
// Resumed: what was queued while paused goes out now.
|
|
7555
7537
|
if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
|
|
7556
7538
|
},
|
|
7557
|
-
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
7558
7539
|
get relayShutdown() { return relayRestartQueue.getState(); },
|
|
7559
7540
|
// A shutdown or restart waits for the processing turns: no pending row
|
|
7560
7541
|
// goes out meanwhile (services/relay-restart-queue.mjs).
|
|
@@ -8219,7 +8200,7 @@ if (runtimeBindingsBootstrapped > 0) {
|
|
|
8219
8200
|
|
|
8220
8201
|
function ts() { return new Date().toISOString().slice(11, 23); }
|
|
8221
8202
|
function runtimeLogPrefix() {
|
|
8222
|
-
return
|
|
8203
|
+
return `[${ts()}] `;
|
|
8223
8204
|
}
|
|
8224
8205
|
|
|
8225
8206
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
|
@@ -12,7 +12,7 @@ import { resolveStateLayout } from './oar-state-layout.mjs';
|
|
|
12
12
|
import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
|
|
13
13
|
|
|
14
14
|
const SHA256_NAME = /^[a-f0-9]{64}$/;
|
|
15
|
-
// The install-time rescue
|
|
15
|
+
// The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
|
|
16
16
|
export const RESCUE_DIR_NAME = 'rescued-package-data';
|
|
17
17
|
// What was looked at already: never offered again unless it changed.
|
|
18
18
|
export const CHECKED_FILE = 'earlier-databases.json';
|
|
@@ -60,6 +60,19 @@ export function withFileVersion(url, version) {
|
|
|
60
60
|
return appendUrlParam(String(url || ''), 'v', String(version || ''));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* One of the relay's own files (sw.js, the socket.io client) through
|
|
65
|
+
* res.sendFile, rooted at its folder. Given a bare absolute path, express 5's
|
|
66
|
+
* send refuses any path with a dot folder in it (dotfiles 'ignore' looks at
|
|
67
|
+
* every segment without a root): a 404 for an install under ~/.oar, nvm or
|
|
68
|
+
* ~/.npm-global. With the folder as root only the file name is looked at.
|
|
69
|
+
*/
|
|
70
|
+
export function sendOwnFile(res, absolutePath, next) {
|
|
71
|
+
res.sendFile(path.basename(absolutePath), { root: path.dirname(absolutePath) }, (error) => {
|
|
72
|
+
if (error) next(error);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* `cacheControl` replaces the no-store default for content whose URL names
|
|
65
78
|
* its bytes (an upload addressed by its hash): such an answer may be kept.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* better-sqlite3 13 ships its binary for linux (glibc and musl), darwin and
|
|
3
|
+
* win32 on x64 and arm64, and runs no install script. Anywhere else (Android
|
|
4
|
+
* under Termux, 32-bit ARM, FreeBSD) the first database open finds no binary
|
|
5
|
+
* and throws: the binding has to be compiled from the sources the package
|
|
6
|
+
* ships. OAR runs that build itself, after an update (the Update button,
|
|
7
|
+
* `oar update`) and as `oar build-native`, never as an install script of its
|
|
8
|
+
* own package: one would bring back the install-script warning every package
|
|
9
|
+
* scanner shows for OAR, and npm 12 skips it unless the user allows it.
|
|
10
|
+
*
|
|
11
|
+
* The build is node-gyp's, the one npm bundles (npx's as the fallback), in the
|
|
12
|
+
* package's own folder with `--force_build=1`, as the package's
|
|
13
|
+
* `build-release` script runs it. It needs Python 3, make and a C++ compiler.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn as defaultSpawn } from 'child_process';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
import { killTree, runToCompletion, tailOf } from './cli-process-runner.mjs';
|
|
21
|
+
|
|
22
|
+
export const BETTER_SQLITE3_PREBUILD_PLATFORMS = Object.freeze(['linux', 'darwin', 'win32']);
|
|
23
|
+
export const BETTER_SQLITE3_PREBUILD_ARCHS = Object.freeze(['x64', 'arm64']);
|
|
24
|
+
export const NATIVE_BUILD_COMMAND = 'oar build-native';
|
|
25
|
+
// sqlite3.c is one large file: a phone takes minutes for it.
|
|
26
|
+
export const NATIVE_BUILD_TIMEOUT_MS = 30 * 60_000;
|
|
27
|
+
export const TERMUX_BUILD_TOOLS = 'pkg install python clang make';
|
|
28
|
+
|
|
29
|
+
const BINDING_FILE = path.join('build', 'Release', 'better_sqlite3.node');
|
|
30
|
+
|
|
31
|
+
/** True where better-sqlite3 has no prebuilt binary and must be compiled. */
|
|
32
|
+
export function needsSourceBuild({ platform = process.platform, arch = process.arch } = {}) {
|
|
33
|
+
return !(BETTER_SQLITE3_PREBUILD_PLATFORMS.includes(platform) && BETTER_SQLITE3_PREBUILD_ARCHS.includes(arch));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The folder of the better-sqlite3 this package loads, or null. */
|
|
37
|
+
export function resolveBetterSqlite3Dir({ packageRoot, requireFrom = createRequire } = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const require = requireFrom(path.join(path.resolve(String(packageRoot || '.')), 'package.json'));
|
|
40
|
+
return path.dirname(require.resolve('better-sqlite3/package.json'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The error better-sqlite3 throws when it finds neither a prebuilt nor a built binary. */
|
|
47
|
+
export function isMissingSqliteBindingError(error) {
|
|
48
|
+
const code = String(error?.code || '');
|
|
49
|
+
return (code === 'MODULE_NOT_FOUND' || code === 'ERR_DLOPEN_FAILED')
|
|
50
|
+
&& /better_sqlite3\.node|better-sqlite3[\\/]prebuilds[\\/][^\\/]+\.node/.test(String(error?.message || ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The lines a relay prints, instead of a stack trace, when it cannot open a
|
|
55
|
+
* database because the binding is missing.
|
|
56
|
+
*/
|
|
57
|
+
export function missingSqliteBindingLines({ platform = process.platform, arch = process.arch, error = null } = {}) {
|
|
58
|
+
const target = `${platform}-${arch}`;
|
|
59
|
+
if (!needsSourceBuild({ platform, arch })) {
|
|
60
|
+
// There, but not loadable on this system. better-sqlite3 loads its
|
|
61
|
+
// prebuilt binding before a built one, so no build here would be used.
|
|
62
|
+
if (String(error?.code || '') === 'ERR_DLOPEN_FAILED') {
|
|
63
|
+
return [
|
|
64
|
+
`The SQLite binding that better-sqlite3 ships for ${target} does not load on this system (${String(error?.message || '').split('\n')[0]}).`,
|
|
65
|
+
'Its C library is too old for it, or the system is not one the binding supports.',
|
|
66
|
+
'Update the operating system, or run the relay on a supported one.',
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
`The SQLite binding of better-sqlite3 for ${target} is missing from the installed package (${String(error?.message || '').split('\n')[0]}).`,
|
|
71
|
+
'Reinstall OAR: npm install -g @oar-sh/oar',
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return [
|
|
75
|
+
`better-sqlite3 has no prebuilt SQLite binding for ${target}, and it has not been built here yet.`,
|
|
76
|
+
`Build it with: ${NATIVE_BUILD_COMMAND}`,
|
|
77
|
+
platform === 'android'
|
|
78
|
+
? `It needs Python 3, make and a C++ compiler: ${TERMUX_BUILD_TOOLS}`
|
|
79
|
+
: 'It needs Python 3, make and a C++ compiler.',
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The node-gyp that ships inside npm, beside this Node; null when there is none.
|
|
84
|
+
function bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl }) {
|
|
85
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
86
|
+
const nodeDir = pathImpl.dirname(String(execPath || ''));
|
|
87
|
+
const npmRoots = [];
|
|
88
|
+
if (platform !== 'win32') {
|
|
89
|
+
// <prefix>/bin/npm is a link to <prefix>/lib/node_modules/npm/bin/npm-cli.js.
|
|
90
|
+
try { npmRoots.push(pathImpl.dirname(pathImpl.dirname(realpathImpl(pathImpl.join(nodeDir, 'npm'))))); } catch {}
|
|
91
|
+
npmRoots.push(pathImpl.join(nodeDir, '..', 'lib', 'node_modules', 'npm'));
|
|
92
|
+
} else {
|
|
93
|
+
npmRoots.push(pathImpl.join(nodeDir, 'node_modules', 'npm'));
|
|
94
|
+
}
|
|
95
|
+
for (const root of npmRoots) {
|
|
96
|
+
const file = pathImpl.join(root, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
|
|
97
|
+
if (existsImpl(file)) return file;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How to run node-gyp: npm's own (npm_config_node_gyp when npm set it), else
|
|
104
|
+
* `npx --yes node-gyp`. Returns { command, args, env }.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveNodeGypInvocation({
|
|
107
|
+
execPath = process.execPath,
|
|
108
|
+
platform = process.platform,
|
|
109
|
+
env = process.env,
|
|
110
|
+
existsImpl = fs.existsSync,
|
|
111
|
+
realpathImpl = fs.realpathSync,
|
|
112
|
+
} = {}) {
|
|
113
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
114
|
+
const nodeDir = pathImpl.dirname(String(execPath || ''));
|
|
115
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
116
|
+
const pathKey = Object.keys(env || {}).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
|
|
117
|
+
// node-gyp runs `node` from the gyp files: this Node first on its PATH.
|
|
118
|
+
const childEnv = { ...env, [pathKey]: [nodeDir, String(env?.[pathKey] || '')].filter(Boolean).join(separator) };
|
|
119
|
+
// Node for Termux has no android_ndk_path in its common.gypi, which gyp
|
|
120
|
+
// then refuses as undefined; an empty one builds with Termux's own clang.
|
|
121
|
+
if (platform === 'android' && !/\bandroid_ndk_path=/.test(String(env?.GYP_DEFINES || ''))) {
|
|
122
|
+
childEnv.GYP_DEFINES = [String(env?.GYP_DEFINES || '').trim(), "android_ndk_path=''"].filter(Boolean).join(' ');
|
|
123
|
+
}
|
|
124
|
+
const fromNpm = String(env?.npm_config_node_gyp || '').trim();
|
|
125
|
+
const gyp = (fromNpm && existsImpl(fromNpm) ? fromNpm : null) || bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl });
|
|
126
|
+
if (gyp) return { command: execPath, args: [gyp], env: childEnv };
|
|
127
|
+
if (platform === 'win32') {
|
|
128
|
+
// npx.cmd runs through a shell, which splits a path with a space in it
|
|
129
|
+
// (C:\Program Files): this Node with npm's npx-cli.js, else npx.cmd by name.
|
|
130
|
+
const npxCli = pathImpl.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js');
|
|
131
|
+
if (existsImpl(npxCli)) return { command: execPath, args: [npxCli, '--yes', 'node-gyp'], env: childEnv };
|
|
132
|
+
return { command: 'npx.cmd', args: ['--yes', 'node-gyp'], env: childEnv };
|
|
133
|
+
}
|
|
134
|
+
const npx = pathImpl.join(nodeDir, 'npx');
|
|
135
|
+
return { command: existsImpl(npx) ? npx : 'npx', args: ['--yes', 'node-gyp'], env: childEnv };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// What a failed build's output says is missing, as one line to act on.
|
|
139
|
+
function missingToolHint(output, platform) {
|
|
140
|
+
const text = String(output || '');
|
|
141
|
+
const missing = /find Python|Could not find any Python|python3?: (?:not found|command not found)/i.test(text)
|
|
142
|
+
|| /make: (?:not found|command not found)|not found: make|spawn make ENOENT/i.test(text)
|
|
143
|
+
|| /(?:c\+\+|g\+\+|clang\+\+|cc|clang): (?:not found|command not found)|No such file or directory.*(?:c\+\+|g\+\+|clang)/i.test(text);
|
|
144
|
+
if (!missing) return null;
|
|
145
|
+
return platform === 'android'
|
|
146
|
+
? `Install the build tools first: ${TERMUX_BUILD_TOOLS}, then run ${NATIVE_BUILD_COMMAND} again.`
|
|
147
|
+
: `Install Python 3, make and a C++ compiler (build-essential on Debian and Ubuntu), then run ${NATIVE_BUILD_COMMAND} again.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Opens a database in a child Node with the binding just built, named by its
|
|
151
|
+
// path: the default loader would take a prebuilt one first where one ships.
|
|
152
|
+
function bindingLoads({ dir, execPath, spawnImpl, env }) {
|
|
153
|
+
const binding = path.join(dir, BINDING_FILE);
|
|
154
|
+
const script = `const Database = require(${JSON.stringify(dir)}); new Database(':memory:', { nativeBinding: ${JSON.stringify(binding)} }).close();`;
|
|
155
|
+
return runToCompletion(
|
|
156
|
+
() => spawnImpl(execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true }),
|
|
157
|
+
{ timeoutMs: 60_000 },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds the binding when this platform has no prebuilt one. `force` builds
|
|
163
|
+
* where one ships too, to check the toolchain; better-sqlite3 still loads its
|
|
164
|
+
* prebuilt binding first there. Resolves (never rejects) with { ok, status, error?, hint? }:
|
|
165
|
+
* status 'prebuilt' (nothing to do), 'built', 'failed'. Every line of the
|
|
166
|
+
* build goes to `onOutput`; what it says about itself goes to `logger.log`.
|
|
167
|
+
*/
|
|
168
|
+
export async function buildBetterSqlite3({
|
|
169
|
+
packageRoot,
|
|
170
|
+
force = false,
|
|
171
|
+
platform = process.platform,
|
|
172
|
+
arch = process.arch,
|
|
173
|
+
execPath = process.execPath,
|
|
174
|
+
env = process.env,
|
|
175
|
+
spawnImpl = defaultSpawn,
|
|
176
|
+
existsImpl = fs.existsSync,
|
|
177
|
+
realpathImpl = fs.realpathSync,
|
|
178
|
+
requireFrom = createRequire,
|
|
179
|
+
timeoutMs = NATIVE_BUILD_TIMEOUT_MS,
|
|
180
|
+
onOutput = null,
|
|
181
|
+
onChild = null,
|
|
182
|
+
logger = console,
|
|
183
|
+
} = {}) {
|
|
184
|
+
if (!force && !needsSourceBuild({ platform, arch })) return { ok: true, status: 'prebuilt' };
|
|
185
|
+
const dir = resolveBetterSqlite3Dir({ packageRoot, requireFrom });
|
|
186
|
+
if (!dir) {
|
|
187
|
+
const error = `better-sqlite3 is not installed under ${packageRoot}`;
|
|
188
|
+
logger.error?.(`[oar] ${error}.`);
|
|
189
|
+
return { ok: false, status: 'failed', error };
|
|
190
|
+
}
|
|
191
|
+
const gyp = resolveNodeGypInvocation({ execPath, platform, env, existsImpl, realpathImpl });
|
|
192
|
+
const args = [...gyp.args, 'rebuild', '--release', '--force_build=1'];
|
|
193
|
+
logger.log?.(`[oar] Building the SQLite binding of better-sqlite3 for ${platform}-${arch} in ${dir}; this takes a few minutes.`);
|
|
194
|
+
const result = await runToCompletion(
|
|
195
|
+
() => spawnImpl(gyp.command, args, {
|
|
196
|
+
cwd: dir,
|
|
197
|
+
env: gyp.env,
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
+
...(platform === 'win32' ? { windowsHide: true, shell: /\.cmd$/i.test(gyp.command) } : { detached: true }),
|
|
200
|
+
}),
|
|
201
|
+
{
|
|
202
|
+
timeoutMs,
|
|
203
|
+
killChild: (child, signal) => killTree(child, signal, { platform }),
|
|
204
|
+
onChild,
|
|
205
|
+
onOutput,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
if (!result.ok || !existsImpl(path.join(dir, BINDING_FILE))) {
|
|
209
|
+
const error = result.error || `node-gyp exited with code ${result.code}`;
|
|
210
|
+
const hint = missingToolHint(result.output, platform);
|
|
211
|
+
logger.error?.(`[oar] The build of the SQLite binding failed: ${error}.`);
|
|
212
|
+
if (hint) logger.error?.(`[oar] ${hint}`);
|
|
213
|
+
else logger.error?.(`[oar] The end of its output:\n${tailOf(result.output, 2_000)}`);
|
|
214
|
+
return { ok: false, status: 'failed', error, ...(hint ? { hint } : {}) };
|
|
215
|
+
}
|
|
216
|
+
const check = await bindingLoads({ dir, execPath, spawnImpl, env: gyp.env });
|
|
217
|
+
if (!check.ok) {
|
|
218
|
+
const error = `the built binding does not load: ${tailOf(check.output || check.error || '', 500).trim()}`;
|
|
219
|
+
logger.error?.(`[oar] ${error}`);
|
|
220
|
+
return { ok: false, status: 'failed', error };
|
|
221
|
+
}
|
|
222
|
+
logger.log?.('[oar] The SQLite binding is built.');
|
|
223
|
+
return { ok: true, status: 'built' };
|
|
224
|
+
}
|
|
@@ -86,6 +86,7 @@ const CLI_COMMANDS = {
|
|
|
86
86
|
autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
|
|
87
87
|
uninstall: { flags: ['--purge'] },
|
|
88
88
|
update: { flags: ['--beta'], values: ['--to'] },
|
|
89
|
+
'build-native': { flags: ['--force'] },
|
|
89
90
|
copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
|
|
90
91
|
};
|
|
91
92
|
|
|
@@ -116,6 +117,9 @@ export function usageText() {
|
|
|
116
117
|
' oar backup [--reason <text>]',
|
|
117
118
|
' A copy of the database into the backup folder (safe while the relay runs)',
|
|
118
119
|
' oar update [--beta] [--to <version>]',
|
|
120
|
+
' oar build-native [--force]',
|
|
121
|
+
' Build the SQLite binding where no prebuilt one ships (Termux, 32-bit ARM);',
|
|
122
|
+
' --force builds where one ships too, which only tests the build tools',
|
|
119
123
|
' oar help This text',
|
|
120
124
|
' oar --version',
|
|
121
125
|
].join('\n');
|
|
@@ -401,11 +405,10 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
|
|
|
401
405
|
* native binary). npm 12 runs no install script it was not told to allow, and
|
|
402
406
|
* a global install has no package.json of its own to say so; npm 10 and 11
|
|
403
407
|
* accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
|
|
404
|
-
* holds this list to the lockfile.
|
|
408
|
+
* holds this list to the lockfile. better-sqlite3 13 has no install script:
|
|
409
|
+
* where it ships no binary, OAR builds it (native-addon-build-service.mjs).
|
|
405
410
|
*/
|
|
406
|
-
|
|
407
|
-
// kept inside the package before npm deletes it (package-data-rescue.mjs).
|
|
408
|
-
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['@oar-sh/oar', 'better-sqlite3', 'koffi']);
|
|
411
|
+
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['koffi']);
|
|
409
412
|
export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
|
|
410
413
|
|
|
411
414
|
/**
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// read, so the runtime copies it into `<log dir>/relay-console.log`, with a
|
|
9
9
|
// time stamp per line and a size limit.
|
|
10
10
|
//
|
|
11
|
-
// The copy is taken from the console's methods rather than from stdout
|
|
12
|
-
//
|
|
13
|
-
// that belongs in a log.
|
|
11
|
+
// The copy is taken from the console's methods rather than from stdout, so
|
|
12
|
+
// what a library writes to stdout directly stays out of it.
|
|
14
13
|
|
|
15
14
|
import fs from 'fs';
|
|
16
15
|
import path from 'path';
|
|
@@ -100,10 +100,10 @@ export function createRemoteRelayLoopbackClient({
|
|
|
100
100
|
req.httpVersionMinor = 1;
|
|
101
101
|
req.httpVersion = '1.1';
|
|
102
102
|
if (hasBody) {
|
|
103
|
-
// What the wire would deliver: JSON, parsed.
|
|
104
|
-
//
|
|
103
|
+
// What the wire would deliver: JSON, parsed. The headers carry no
|
|
104
|
+
// content-length or transfer-encoding, so the JSON body parser finds
|
|
105
|
+
// no body to read and leaves this one in place.
|
|
105
106
|
req.body = JSON.parse(JSON.stringify(body));
|
|
106
|
-
req._body = true;
|
|
107
107
|
}
|
|
108
108
|
req.complete = true;
|
|
109
109
|
req.push(null);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
stripTerminalEscapes,
|
|
12
12
|
tailOf,
|
|
13
13
|
} from './cli-process-runner.mjs';
|
|
14
|
+
import { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from './native-addon-build-service.mjs';
|
|
14
15
|
import { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } from './oar-cli-helpers.mjs';
|
|
15
16
|
import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
|
|
16
17
|
|
|
@@ -107,6 +108,8 @@ export function createUpdateInstallService({
|
|
|
107
108
|
spawnImpl = defaultSpawn,
|
|
108
109
|
env = process.env,
|
|
109
110
|
platform = process.platform,
|
|
111
|
+
arch = process.arch,
|
|
112
|
+
buildNativeAddons = buildBetterSqlite3,
|
|
110
113
|
setTimeoutImpl = setTimeout,
|
|
111
114
|
clearTimeoutImpl = clearTimeout,
|
|
112
115
|
nowImpl = Date.now,
|
|
@@ -242,15 +245,43 @@ export function createUpdateInstallService({
|
|
|
242
245
|
);
|
|
243
246
|
activeChild = null;
|
|
244
247
|
flushLogNow();
|
|
245
|
-
finishedAt = new Date(nowImpl()).toISOString();
|
|
246
248
|
|
|
247
249
|
if (!result.ok) {
|
|
250
|
+
finishedAt = new Date(nowImpl()).toISOString();
|
|
248
251
|
state = UPDATE_INSTALL_STATES.ERROR;
|
|
249
252
|
lastError = result.error || `npm exited with code ${result.code}`;
|
|
250
253
|
broadcast();
|
|
251
254
|
return { ok: false, statusCode: 500, error: lastError };
|
|
252
255
|
}
|
|
253
256
|
|
|
257
|
+
// No prebuilt SQLite binding here: build it before the restart, which
|
|
258
|
+
// would start on none (native-addon-build-service.mjs). A failed build
|
|
259
|
+
// keeps this relay running on the binding it has loaded.
|
|
260
|
+
if (needsSourceBuild({ platform, arch })) {
|
|
261
|
+
const toLog = (line) => appendLog(`${line}\n`);
|
|
262
|
+
const build = await buildNativeAddons({
|
|
263
|
+
packageRoot,
|
|
264
|
+
platform,
|
|
265
|
+
arch,
|
|
266
|
+
execPath,
|
|
267
|
+
env: npm.env,
|
|
268
|
+
spawnImpl,
|
|
269
|
+
onOutput: (text) => appendLog(text),
|
|
270
|
+
onChild: (child) => { activeChild = child; },
|
|
271
|
+
logger: { log: toLog, error: toLog },
|
|
272
|
+
});
|
|
273
|
+
activeChild = null;
|
|
274
|
+
flushLogNow();
|
|
275
|
+
if (!build.ok) {
|
|
276
|
+
finishedAt = new Date(nowImpl()).toISOString();
|
|
277
|
+
state = UPDATE_INSTALL_STATES.ERROR;
|
|
278
|
+
lastError = `${UPDATE_PACKAGE_NAME}@${target} is installed, but its SQLite binding did not build (${build.error}). Run ${NATIVE_BUILD_COMMAND}, then restart the relay.`;
|
|
279
|
+
broadcast();
|
|
280
|
+
return { ok: false, statusCode: 500, error: lastError };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finishedAt = new Date(nowImpl()).toISOString();
|
|
284
|
+
|
|
254
285
|
// Persist the attempt BEFORE the restart request: the next boot compares
|
|
255
286
|
// the running version against it and turns "nothing changed" into a
|
|
256
287
|
// visible failure carrying this log tail.
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
|
|
3
|
-
* a module of this package that cannot load (a broken file, a missing one)
|
|
4
|
-
* must not fail the install on any platform. Only a copy of an old
|
|
5
|
-
* database that failed (RescueCopyError) exits 1, so npm stops the update
|
|
6
|
-
* and puts the old package, data and all, back in place; anything else is
|
|
7
|
-
* one line and exit 0, as this script only ever copies. See
|
|
8
|
-
* package-data-rescue.mjs.
|
|
9
|
-
*/
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
const { rescuePackageData } = await import('./package-data-rescue.mjs');
|
|
14
|
-
rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
|
|
15
|
-
} catch (error) {
|
|
16
|
-
if (error?.rescueCopyFailed === true) {
|
|
17
|
-
console.error(`[oar] ${error.message}`);
|
|
18
|
-
process.exitCode = 1;
|
|
19
|
-
} else {
|
|
20
|
-
console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The preinstall's work (package-data-rescue-preinstall.mjs), on every
|
|
3
|
-
* platform. Relays before 0.9.14 could run on the database and uploads
|
|
4
|
-
* inside the package (Windows autostart, a plain `gh copilot`), and
|
|
5
|
-
* `npm i -g` deletes the old package. While install scripts run, npm keeps
|
|
6
|
-
* it as its retired folder (npmRetiredPath; a scan for npm-shaped names only
|
|
7
|
-
* when that is missing): its database (-wal, -shm), Cursor stores, uploads
|
|
8
|
-
* and config.json are copied, copy only, to
|
|
9
|
-
* `<data folder>/rescued-package-data/<time>/` (written as `.partial`, then
|
|
10
|
-
* renamed), and the relay takes them over at its next start. Any of them
|
|
11
|
-
* makes the old package a source (its uploads beyond the README.md and
|
|
12
|
-
* image_input.md it ships). Nothing to copy, or not an installed package:
|
|
13
|
-
* nothing happens, nothing is printed. A failed copy throws
|
|
14
|
-
* RescueCopyError: exit 1, npm puts the old package back. npm hides a successful script's output, hence the README.txt.
|
|
15
|
-
*/
|
|
16
|
-
import crypto from 'crypto';
|
|
17
|
-
import fs from 'fs';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
|
|
20
|
-
import { DB_FILE_NAMES, sideFiles } from './database-file.mjs';
|
|
21
|
-
import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
|
|
22
|
-
import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
|
|
23
|
-
|
|
24
|
-
// Old packages only ever held the name before 0.9.20; a later one may hold oar.db.
|
|
25
|
-
const DB_FILES = DB_FILE_NAMES.flatMap(sideFiles);
|
|
26
|
-
|
|
27
|
-
// What a relay left in a package's server folder; a folder that cannot be
|
|
28
|
-
// read throws (no copy failure: nothing was detected yet).
|
|
29
|
-
function hasRelayData(fsImpl, serverDir) {
|
|
30
|
-
const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
|
|
31
|
-
const anyUpload = (...parts) => has(...parts) && fsImpl.readdirSync(path.join(serverDir, ...parts)).some((name) => /^[a-f0-9]{64}$/.test(name));
|
|
32
|
-
return DB_FILE_NAMES.some((name) => has('data', name)) || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
|
|
36
|
-
export function npmRetiredPath(packageDir) {
|
|
37
|
-
const hash = crypto.createHash('sha1').update(packageDir).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
|
|
38
|
-
return path.resolve(path.dirname(packageDir), `.${path.basename(packageDir)}-${hash}`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** A copy of an old database failed: the one error that stops the install. */
|
|
42
|
-
export class RescueCopyError extends Error {
|
|
43
|
-
constructor(message, options) {
|
|
44
|
-
super(message, options);
|
|
45
|
-
this.name = 'RescueCopyError';
|
|
46
|
-
this.rescueCopyFailed = true;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Returns { status: 'not-installed' | 'nothing' | 'rescued', rescued: [{ from,
|
|
52
|
-
* to }] }; throws a RescueCopyError, naming what to save, when a copy fails.
|
|
53
|
-
*/
|
|
54
|
-
export function rescuePackageData({
|
|
55
|
-
packageRoot,
|
|
56
|
-
env = process.env,
|
|
57
|
-
platform = process.platform,
|
|
58
|
-
now = () => new Date(),
|
|
59
|
-
fsImpl = fs,
|
|
60
|
-
logger = console,
|
|
61
|
-
} = {}) {
|
|
62
|
-
const resolvedRoot = path.resolve(String(packageRoot || ''));
|
|
63
|
-
const scope = path.dirname(resolvedRoot);
|
|
64
|
-
if (path.basename(scope) !== '@oar-sh' || path.basename(path.dirname(scope)) !== 'node_modules') {
|
|
65
|
-
return { status: 'not-installed', rescued: [] };
|
|
66
|
-
}
|
|
67
|
-
// Detection: npm's retired name, else a scan for npm-shaped names (a path
|
|
68
|
-
// in another case hashes differently). Failing here is no copy failure.
|
|
69
|
-
let sources;
|
|
70
|
-
try {
|
|
71
|
-
const exact = npmRetiredPath(resolvedRoot);
|
|
72
|
-
const retired = fsImpl.existsSync(exact)
|
|
73
|
-
? [exact]
|
|
74
|
-
: fsImpl.readdirSync(scope).filter((name) => isRetiredName(name, path.basename(resolvedRoot))).map((name) => path.join(scope, name));
|
|
75
|
-
sources = retired.filter((dir) => hasRelayData(fsImpl, path.join(dir, 'server')));
|
|
76
|
-
} catch (error) {
|
|
77
|
-
throw new Error(`Could not look for the data of the version being replaced: ${error?.message || error}`, { cause: error });
|
|
78
|
-
}
|
|
79
|
-
if (!sources.length) return { status: 'nothing', rescued: [] };
|
|
80
|
-
|
|
81
|
-
// Old data is there: any failure from here stops the install.
|
|
82
|
-
const exists = (file) => fsImpl.existsSync(file);
|
|
83
|
-
const kept = path.join(resolvedRoot, 'server');
|
|
84
|
-
const copyFailed = (from, to, error) => new RescueCopyError(
|
|
85
|
-
`Could not copy the data of the version being replaced (${from}) to ${to}: ${error?.message || error}. `
|
|
86
|
-
+ `The update stops and the version installed stays as it is; copy ${path.join(kept, 'data')} and ${path.join(kept, 'uploads')} somewhere safe by hand, then update again.`,
|
|
87
|
-
{ cause: error },
|
|
88
|
-
);
|
|
89
|
-
let rescueRoot;
|
|
90
|
-
try {
|
|
91
|
-
const layout = resolveStateLayout({ packageRoot: resolvedRoot, env, platform });
|
|
92
|
-
rescueRoot = path.join(resolveRelayStateDirs({ packageRoot: resolvedRoot, env, platform, layout }).dataDir, RESCUE_DIR_NAME);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
throw copyFailed(path.join(sources[0], 'server'), 'the OAR data folder', error);
|
|
95
|
-
}
|
|
96
|
-
const stamp = now().toISOString().replace(/[:.]/g, '-');
|
|
97
|
-
const rescued = [];
|
|
98
|
-
for (const [index, source] of sources.entries()) {
|
|
99
|
-
const serverDir = path.join(source, 'server');
|
|
100
|
-
let target = path.join(rescueRoot, sources.length > 1 ? `${stamp}-${index}` : stamp);
|
|
101
|
-
// Written under a .partial name and renamed when complete: a half copy
|
|
102
|
-
// is never taken for a rescue.
|
|
103
|
-
try {
|
|
104
|
-
for (let n = 1; exists(target) || exists(`${target}.partial`); n += 1) target = `${path.join(rescueRoot, stamp)}-${n}`;
|
|
105
|
-
const partial = `${target}.partial`;
|
|
106
|
-
fsImpl.mkdirSync(rescueRoot, { recursive: true });
|
|
107
|
-
fsImpl.mkdirSync(partial);
|
|
108
|
-
fsImpl.mkdirSync(path.join(partial, 'data'));
|
|
109
|
-
for (const name of DB_FILES) {
|
|
110
|
-
const from = path.join(serverDir, 'data', name);
|
|
111
|
-
if (exists(from)) fsImpl.copyFileSync(from, path.join(partial, 'data', name), fs.constants.COPYFILE_EXCL);
|
|
112
|
-
}
|
|
113
|
-
if (exists(path.join(serverDir, 'data', 'cursor-agents'))) {
|
|
114
|
-
copyMissing(fsImpl, path.join(serverDir, 'data', 'cursor-agents'), path.join(partial, 'data', 'cursor-agents'));
|
|
115
|
-
}
|
|
116
|
-
// Uploads of server/uploads and server/data/uploads, content-addressed.
|
|
117
|
-
for (const uploads of [path.join(serverDir, 'uploads'), path.join(serverDir, 'data', 'uploads')]) {
|
|
118
|
-
if (exists(uploads)) copyMissing(fsImpl, uploads, path.join(partial, 'uploads'));
|
|
119
|
-
}
|
|
120
|
-
if (exists(path.join(serverDir, 'config.json'))) {
|
|
121
|
-
fsImpl.copyFileSync(path.join(serverDir, 'config.json'), path.join(partial, 'config.json'), fs.constants.COPYFILE_EXCL);
|
|
122
|
-
}
|
|
123
|
-
fsImpl.writeFileSync(path.join(partial, 'README.txt'), [
|
|
124
|
-
`Copied by the @oar-sh/oar update on ${now().toISOString()} from`,
|
|
125
|
-
`${serverDir}`,
|
|
126
|
-
'before npm deleted the version it replaced. The relay takes the',
|
|
127
|
-
'database in data\\ over at its next start if it is newer than the',
|
|
128
|
-
'one it has. Nothing here is deleted by OAR.',
|
|
129
|
-
'',
|
|
130
|
-
].join('\r\n'));
|
|
131
|
-
fsImpl.renameSync(partial, target);
|
|
132
|
-
} catch (error) {
|
|
133
|
-
throw copyFailed(serverDir, target, error);
|
|
134
|
-
}
|
|
135
|
-
rescued.push({ from: serverDir, to: target });
|
|
136
|
-
logger.log?.(`[oar] Copied the data of the version being replaced from ${serverDir} to ${target}; the relay takes it over at its next start.`);
|
|
137
|
-
}
|
|
138
|
-
return { status: 'rescued', rescued };
|
|
139
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { createRequire } from 'module';
|
|
4
|
-
|
|
5
|
-
function existingDirectories(paths = []) {
|
|
6
|
-
const out = [];
|
|
7
|
-
for (const candidate of paths) {
|
|
8
|
-
const normalized = String(candidate || '').trim();
|
|
9
|
-
if (!normalized) continue;
|
|
10
|
-
try {
|
|
11
|
-
if (fs.statSync(normalized).isDirectory()) out.push(normalized);
|
|
12
|
-
} catch {}
|
|
13
|
-
}
|
|
14
|
-
return out;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function resolveTtyConsoleRoot() {
|
|
18
|
-
const require = createRequire(import.meta.url);
|
|
19
|
-
return path.dirname(require.resolve('tty-console'));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function maybeStartTtyConsole({
|
|
23
|
-
serverDir,
|
|
24
|
-
logsDir,
|
|
25
|
-
logger = console,
|
|
26
|
-
} = {}) {
|
|
27
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
28
|
-
|
|
29
|
-
const resolvedServerDir = path.resolve(String(serverDir || process.cwd()).trim() || process.cwd());
|
|
30
|
-
const resolvedLogsDir = path.resolve(String(logsDir || path.join(resolvedServerDir, 'logs')).trim() || path.join(resolvedServerDir, 'logs'));
|
|
31
|
-
fs.mkdirSync(resolvedLogsDir, { recursive: true });
|
|
32
|
-
|
|
33
|
-
let ttyConsoleModule;
|
|
34
|
-
try {
|
|
35
|
-
ttyConsoleModule = await import('tty-console');
|
|
36
|
-
} catch (error) {
|
|
37
|
-
const code = String(error?.code || '').trim();
|
|
38
|
-
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
|
|
39
|
-
logger?.log?.('[server] tty-console not installed; continuing without terminal console.');
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
logger?.warn?.(`[server] tty-console load failed: ${error?.message || error}`);
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const ttyConsoleApi = ttyConsoleModule?.default || ttyConsoleModule;
|
|
47
|
-
const ConsoleCtor = ttyConsoleApi?.Console;
|
|
48
|
-
if (typeof ConsoleCtor !== 'function') {
|
|
49
|
-
logger?.warn?.('[server] tty-console loaded but Console export was unavailable.');
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const ttyConsoleRoot = resolveTtyConsoleRoot();
|
|
54
|
-
const bundledCommandsDir = path.join(ttyConsoleRoot, 'commands');
|
|
55
|
-
const localCommandsDir = path.join(resolvedServerDir, 'commands');
|
|
56
|
-
const commandsDirs = existingDirectories([bundledCommandsDir, localCommandsDir]);
|
|
57
|
-
const configPath = path.join(resolvedLogsDir, 'console_config.json');
|
|
58
|
-
const historyPath = path.join(resolvedLogsDir, 'console_history.json');
|
|
59
|
-
|
|
60
|
-
const consoleOptions = {
|
|
61
|
-
configPath,
|
|
62
|
-
historyPath,
|
|
63
|
-
exitOnStop: false,
|
|
64
|
-
};
|
|
65
|
-
if (commandsDirs.length === 1) {
|
|
66
|
-
consoleOptions.commandsDir = commandsDirs[0];
|
|
67
|
-
} else if (commandsDirs.length > 1) {
|
|
68
|
-
consoleOptions.commandsDir = commandsDirs;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const runtime = new ConsoleCtor(consoleOptions);
|
|
72
|
-
runtime.start?.();
|
|
73
|
-
logger?.log?.(`[server] tty-console started (${commandsDirs.join(', ')})`);
|
|
74
|
-
return runtime;
|
|
75
|
-
}
|