@andrian.yablonskyy/thub-coordinator 1.0.4 → 1.0.6
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/README.md +22 -5
- package/package.json +5 -2
- package/scripts/ensure-native-deps.js +85 -0
- package/scripts/install-default-config.js +64 -39
- package/scripts/install-systemd-unit.js +79 -0
- package/scripts/install-target.js +83 -0
- package/src/config.js +29 -3
- package/src/db/index.js +27 -1
- package/src/server.js +1 -0
- package/systemd/thub-coordinator.service +22 -0
package/README.md
CHANGED
|
@@ -7,10 +7,25 @@ See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the f
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm
|
|
10
|
+
sudo npm i -g @andrian.yablonskyy/thub-coordinator
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
This gives you two global commands: `thub-coordinator` (the server itself) and `thub-admin` (the local operator CLI, below)
|
|
13
|
+
This gives you two global commands: `thub-coordinator` (the server itself) and `thub-admin` (the local operator CLI, below), and sets up everything needed to run it:
|
|
14
|
+
|
|
15
|
+
- `~/.config/thub/coordinator.json` with every default option (see Configuration below), if it doesn't exist yet.
|
|
16
|
+
- `~/var/lib/thub` (the `dataDir`: SQLite DB, uploaded artifacts, avatars) with its `artifacts/`, `avatars/` and `work/` subdirectories.
|
|
17
|
+
- On Linux as root: `/etc/systemd/system/thub-coordinator.service`, enabled and started, so the Coordinator runs now and on every boot. A re-install/upgrade restarts it on the new code.
|
|
18
|
+
|
|
19
|
+
Under `sudo`, `~` means the home of the user who ran `sudo` (`SUDO_USER`), not `/root`. That user owns the config and data and the service runs as them. Without root the config and directories are still created; the systemd step is skipped.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
sudo systemctl status thub-coordinator
|
|
23
|
+
journalctl -u thub-coordinator -f
|
|
24
|
+
# after editing ~/.config/thub/coordinator.json:
|
|
25
|
+
sudo systemctl restart thub-coordinator
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or clone this repo directly and run it from source (see below).
|
|
14
29
|
|
|
15
30
|
## Configuration
|
|
16
31
|
|
|
@@ -33,17 +48,19 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
|
|
|
33
48
|
|
|
34
49
|
`sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
|
|
35
50
|
|
|
36
|
-
`npm install -g` creates `~/.config/thub/coordinator.json` for you if it doesn't already exist, with
|
|
51
|
+
`npm install -g` creates `~/.config/thub/coordinator.json` for you if it doesn't already exist, with all the options above, `dataDir` set to `~/var/lib/thub` and a freshly generated random `sessionSecret` (not the placeholder above), readable only by its owner — a re-install never overwrites it or regenerates the secret. Set `publicUrl` and `clientJoinKey` yourself before relying on auto-registration.
|
|
37
52
|
|
|
38
53
|
## Running it
|
|
39
54
|
|
|
40
55
|
```bash
|
|
41
56
|
# First run: no admin_users row exists yet, so either set a bootstrap
|
|
42
|
-
# password (creates user "admin") or use thub-admin afterwards.
|
|
43
|
-
|
|
57
|
+
# password (creates user "admin") or use thub-admin afterwards. Reads
|
|
58
|
+
# ~/.config/thub/coordinator.json; point THUB_COORDINATOR_CONFIG at another
|
|
59
|
+
# file only if it exists — a missing one is an error, not a fallback.
|
|
44
60
|
THUB_BOOTSTRAP_ADMIN_PASSWORD=correct-horse-battery-staple \
|
|
45
61
|
thub-coordinator
|
|
46
62
|
# -> Reset password for admin user "admin" from THUB_BOOTSTRAP_ADMIN_PASSWORD
|
|
63
|
+
# -> Config: /home/you/.config/thub/coordinator.json; data: /home/you/var/lib/thub
|
|
47
64
|
# -> TestHub Coordinator listening on http://127.0.0.1:8080
|
|
48
65
|
```
|
|
49
66
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
6
9
|
"bin": {
|
|
7
10
|
"thub-admin": "./bin/thub-admin.js",
|
|
8
11
|
"thub-coordinator": "./src/server.js"
|
|
@@ -11,7 +14,7 @@
|
|
|
11
14
|
"start": "node src/server.js",
|
|
12
15
|
"dev": "node --watch src/server.js",
|
|
13
16
|
"test": "node --test test/*.test.js",
|
|
14
|
-
"postinstall": "node scripts/install-default-config.js",
|
|
17
|
+
"postinstall": "node scripts/ensure-native-deps.js && node scripts/install-default-config.js && node scripts/install-systemd-unit.js",
|
|
15
18
|
"lint": "eslint .",
|
|
16
19
|
"lint:fix": "eslint . --fix"
|
|
17
20
|
},
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scripts/ensure-native-deps.js
|
|
3
|
+
* @description npm postinstall: makes sure better-sqlite3's native addon loads under the Node.js
|
|
4
|
+
* running this install, rebuilding it in place if not
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const path = require('node:path'),
|
|
18
|
+
{ execFileSync, spawnSync } = require('node:child_process'),
|
|
19
|
+
{ isGlobalInstall } = require('./install-target'),
|
|
20
|
+
|
|
21
|
+
PKG_DIR = path.join(__dirname, '..');
|
|
22
|
+
|
|
23
|
+
// Checked in a child process: once a mismatched addon has been dlopen'ed,
|
|
24
|
+
// this process keeps the old image mapped, so an in-process retry after
|
|
25
|
+
// the rebuild would still see the stale one.
|
|
26
|
+
function loads(){
|
|
27
|
+
const res = spawnSync(process.execPath, ['-e', 'new (require(\'better-sqlite3\'))(\':memory:\').close()'], {
|
|
28
|
+
cwd: PKG_DIR,
|
|
29
|
+
encoding: 'utf8'
|
|
30
|
+
});
|
|
31
|
+
if (res.status === 0){
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (/ERR_DLOPEN_FAILED|NODE_MODULE_VERSION/.test(res.stderr)){
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(res.stderr.trim().split('\n').find((l) => /Error/.test(l)) || `exit ${res.status}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Runs better-sqlite3's own install script (prebuild-install, falling back
|
|
41
|
+
// to node-gyp) in its own directory. Not `npm rebuild`: under `npm i -g`
|
|
42
|
+
// this script inherits npm_config_global=true, which points a nested
|
|
43
|
+
// `npm rebuild` at the global prefix instead of this package, where it
|
|
44
|
+
// "succeeds" without touching the addon at all.
|
|
45
|
+
function rebuild(){
|
|
46
|
+
const dir = path.dirname(require.resolve('better-sqlite3/package.json', { paths: [PKG_DIR] })),
|
|
47
|
+
npmCli = process.env.npm_execpath,
|
|
48
|
+
[cmd, args] = npmCli ? [process.execPath, [npmCli]] : ['npm', []];
|
|
49
|
+
execFileSync(cmd, [...args, 'run-script', 'install'], {
|
|
50
|
+
cwd: dir,
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
env: { ...process.env, npm_config_global: 'false' }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Re-running `npm i -g` for an already-installed version after a Node
|
|
57
|
+
// major upgrade leaves unchanged dependencies alone — including
|
|
58
|
+
// better-sqlite3's addon, still built for the old Node ABI — so the server
|
|
59
|
+
// then dies with NODE_MODULE_VERSION mismatch at startup. Rebuild it here
|
|
60
|
+
// with the same node/npm that's running this install. Best-effort: never
|
|
61
|
+
// fails the install itself.
|
|
62
|
+
function main(){
|
|
63
|
+
if (!isGlobalInstall()){
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (loads()){
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
console.log(`thub-coordinator: rebuilding better-sqlite3 for Node.js ${process.version} (ABI ${process.versions.modules})`);
|
|
71
|
+
rebuild();
|
|
72
|
+
if (!loads()){
|
|
73
|
+
throw new Error('still fails to load after rebuild');
|
|
74
|
+
}
|
|
75
|
+
console.log('thub-coordinator: better-sqlite3 rebuilt');
|
|
76
|
+
}
|
|
77
|
+
catch (err){
|
|
78
|
+
console.warn(
|
|
79
|
+
`thub-coordinator: better-sqlite3 doesn't load under ${process.execPath} (${process.version}): ${err.message}\n` +
|
|
80
|
+
`Rebuild it manually: cd ${PKG_DIR} && sudo env "PATH=$PATH" npm rebuild better-sqlite3`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file scripts/install-default-config.js
|
|
3
|
-
* @description npm postinstall:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @description npm postinstall: on a real global install, creates the Coordinator's directory layout
|
|
4
|
+
* (~/.config/thub, ~/var/lib/thub and its artifacts/avatars/work subdirs) and, if it
|
|
5
|
+
* doesn't already exist, ~/.config/thub/coordinator.json with every default option and
|
|
6
|
+
* a freshly generated sessionSecret (README §12, §13)
|
|
7
7
|
*
|
|
8
8
|
* @author Andrian Yablonskyy
|
|
9
9
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -17,57 +17,82 @@
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
19
|
const fs = require('node:fs'),
|
|
20
|
-
os = require('node:os'),
|
|
21
20
|
path = require('node:path'),
|
|
22
21
|
crypto = require('node:crypto'),
|
|
22
|
+
{ DEFAULTS } = require('../src/config'),
|
|
23
|
+
{ isGlobalInstall, isRoot, resolveTargetUser, targetPaths } = require('./install-target');
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
// (listen, publicUrl, heartbeat/scheduler/jobs/retention/artifacts), so
|
|
37
|
-
// only what genuinely needs to differ for a real standalone install goes
|
|
38
|
-
// here:
|
|
39
|
-
// - dataDir: home-anchored, not cwd-relative — a real install shouldn't
|
|
40
|
-
// silently get a different data directory depending on which
|
|
41
|
-
// directory you happened to launch thub-coordinator from.
|
|
42
|
-
// - sessionSecret: freshly random per install. The code's own default
|
|
43
|
-
// ("dev-only-change-me") is fine for local dev but would otherwise
|
|
44
|
-
// mean every real installation nobody got around to changing shares
|
|
45
|
-
// the exact same session-signing key.
|
|
46
|
-
// - clientJoinKey stays unset (null, the code's own default) — auto-
|
|
47
|
-
// registration is opt-in, and there's nothing sensible to
|
|
48
|
-
// auto-generate here since it also has to be copied to every Client.
|
|
49
|
-
function defaultContent(){
|
|
25
|
+
// Every option loadConfig() knows, written out so the file documents what
|
|
26
|
+
// can be changed — not a copy of the bundled config.json (which uses a
|
|
27
|
+
// relative dataDir and a fixed sessionSecret for zero-setup `npm run
|
|
28
|
+
// coordinator` in the monorepo). What differs from DEFAULTS:
|
|
29
|
+
// - dataDir: home-anchored (~/var/lib/thub), not cwd-relative — a real
|
|
30
|
+
// install shouldn't get a different data directory depending on which
|
|
31
|
+
// directory thub-coordinator happened to be launched from.
|
|
32
|
+
// - sessionSecret: freshly random per install, never the shared
|
|
33
|
+
// "dev-only-change-me" default.
|
|
34
|
+
// - clientJoinKey stays null (auto-registration is opt-in, and it also
|
|
35
|
+
// has to be copied to every Client, so there's nothing to generate).
|
|
36
|
+
function defaultContent(paths){
|
|
50
37
|
return {
|
|
51
|
-
|
|
38
|
+
...DEFAULTS,
|
|
39
|
+
dataDir: paths.defaultDataDir,
|
|
52
40
|
sessionSecret: crypto.randomBytes(32).toString('hex')
|
|
53
41
|
};
|
|
54
42
|
}
|
|
55
43
|
|
|
44
|
+
// Under `sudo npm i -g` everything is created by root in another user's
|
|
45
|
+
// home — hand it over, or the Coordinator (running as that user, see
|
|
46
|
+
// install-systemd-unit.js) couldn't read its config or write its data.
|
|
47
|
+
function chownToUser(p, user){
|
|
48
|
+
if (isRoot() && user.uid !== 0){
|
|
49
|
+
fs.chownSync(p, user.uid, user.gid);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Creates `dir` and chowns every directory this call created along the
|
|
54
|
+
// way (e.g. ~/.config, ~/var, ~/var/lib when they didn't exist yet),
|
|
55
|
+
// plus `dir` itself.
|
|
56
|
+
function mkdirOwned(dir, user){
|
|
57
|
+
const firstCreated = fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
if (firstCreated){
|
|
59
|
+
const rel = path.relative(firstCreated, dir).split(path.sep).filter(Boolean);
|
|
60
|
+
let current = firstCreated;
|
|
61
|
+
chownToUser(current, user);
|
|
62
|
+
for (const part of rel){
|
|
63
|
+
current = path.join(current, part);
|
|
64
|
+
chownToUser(current, user);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
chownToUser(dir, user);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
// Best-effort and never fails the `npm install` itself. Never overwrites
|
|
57
|
-
// an existing file — a re-install/upgrade must not clobber whatever
|
|
58
|
-
// user already configured, and must never regenerate sessionSecret out
|
|
73
|
+
// an existing config file — a re-install/upgrade must not clobber whatever
|
|
74
|
+
// the user already configured, and must never regenerate sessionSecret out
|
|
59
75
|
// from under a running deployment (that would invalidate every session).
|
|
60
76
|
function main(){
|
|
61
|
-
if (!isGlobalInstall()
|
|
77
|
+
if (!isGlobalInstall()){
|
|
62
78
|
return;
|
|
63
79
|
}
|
|
80
|
+
const user = resolveTargetUser(),
|
|
81
|
+
paths = targetPaths(user);
|
|
64
82
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
mkdirOwned(paths.configDir, user);
|
|
84
|
+
for (const dir of [paths.dataDir, ...paths.dataSubdirs]){
|
|
85
|
+
mkdirOwned(dir, user);
|
|
86
|
+
}
|
|
87
|
+
if (!fs.existsSync(paths.configPath)){
|
|
88
|
+
fs.writeFileSync(paths.configPath, JSON.stringify(defaultContent(paths), null, 2) + '\n', { mode: 0o600 });
|
|
89
|
+
chownToUser(paths.configPath, user);
|
|
90
|
+
console.log(`thub-coordinator: created ${paths.configPath} with a freshly generated sessionSecret`);
|
|
91
|
+
}
|
|
92
|
+
console.log(`thub-coordinator: data directory is ${paths.dataDir}`);
|
|
68
93
|
}
|
|
69
94
|
catch (err){
|
|
70
|
-
console.warn(`thub-coordinator: could not create ${
|
|
95
|
+
console.warn(`thub-coordinator: could not create ${paths.configPath} / ${paths.dataDir} automatically (${err.message}).`);
|
|
71
96
|
}
|
|
72
97
|
}
|
|
73
98
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scripts/install-systemd-unit.js
|
|
3
|
+
* @description npm postinstall: installs systemd/thub-coordinator.service to /etc/systemd/system on
|
|
4
|
+
* Linux when root, rendered for the user who ran `sudo npm i -g` and this install's real
|
|
5
|
+
* node/server.js paths, then enables and (re)starts it (README §13.1)
|
|
6
|
+
*
|
|
7
|
+
* @author Andrian Yablonskyy
|
|
8
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
9
|
+
*
|
|
10
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
11
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
12
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
13
|
+
* from AdSystem.PRO.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs'),
|
|
19
|
+
path = require('node:path'),
|
|
20
|
+
{ execFileSync } = require('node:child_process'),
|
|
21
|
+
{ isGlobalInstall, isRoot, resolveTargetUser, targetPaths } = require('./install-target'),
|
|
22
|
+
|
|
23
|
+
UNIT_NAME = 'thub-coordinator.service',
|
|
24
|
+
UNIT_SRC = path.join(__dirname, '..', 'systemd', UNIT_NAME),
|
|
25
|
+
SERVER_PATH = path.join(__dirname, '..', 'src', 'server.js'),
|
|
26
|
+
UNIT_DEST = `/etc/systemd/system/${UNIT_NAME}`,
|
|
27
|
+
MANUAL_HINT = 'sudo npm i -g @andrian.yablonskyy/thub-coordinator';
|
|
28
|
+
|
|
29
|
+
// The checked-in unit only has placeholders — fill in the target user and
|
|
30
|
+
// wherever *this* install's node and server.js actually are, so it works
|
|
31
|
+
// regardless of npm prefix or an nvm-managed Node.
|
|
32
|
+
function renderUnit(user, paths){
|
|
33
|
+
return fs.readFileSync(UNIT_SRC, 'utf8')
|
|
34
|
+
.replace(/^User=.*$/m, `User=${user.name}`)
|
|
35
|
+
.replace(/^Group=.*$/m, `Group=${user.gid}`)
|
|
36
|
+
.replace(/^Environment=THUB_COORDINATOR_CONFIG=.*$/m, `Environment=THUB_COORDINATOR_CONFIG=${paths.configPath}`)
|
|
37
|
+
.replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${SERVER_PATH}`)
|
|
38
|
+
.replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${paths.dataDir}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Best-effort, never fails the `npm install` itself. Runs after
|
|
42
|
+
// install-default-config.js, so the config and data dirs the unit points
|
|
43
|
+
// at already exist. `restart` (not `start`) so an upgrade picks up the
|
|
44
|
+
// new code immediately.
|
|
45
|
+
function main(){
|
|
46
|
+
if (process.platform !== 'linux' || !isGlobalInstall()){
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!isRoot()){
|
|
50
|
+
console.log(`\nthub-coordinator: skipping systemd service install (not root). To install it, run:\n ${MANUAL_HINT}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const user = resolveTargetUser();
|
|
55
|
+
let step = 'write';
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(UNIT_DEST, renderUnit(user, targetPaths(user)));
|
|
58
|
+
step = 'daemon-reload';
|
|
59
|
+
execFileSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
60
|
+
step = 'enable';
|
|
61
|
+
execFileSync('systemctl', ['enable', UNIT_NAME], { stdio: 'ignore' });
|
|
62
|
+
step = 'restart';
|
|
63
|
+
execFileSync('systemctl', ['restart', UNIT_NAME], { stdio: 'ignore' });
|
|
64
|
+
console.log(`thub-coordinator: installed and started ${UNIT_DEST} (runs as ${user.name}; logs: journalctl -u ${UNIT_NAME})`);
|
|
65
|
+
}
|
|
66
|
+
catch (err){
|
|
67
|
+
if (step === 'write'){
|
|
68
|
+
console.warn(`thub-coordinator: could not install ${UNIT_DEST} (${err.message}).`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.warn(
|
|
72
|
+
`thub-coordinator: installed ${UNIT_DEST} but 'systemctl ${step}' failed (${err.message}).\n` +
|
|
73
|
+
`Finish it yourself: sudo systemctl daemon-reload && sudo systemctl enable --now ${UNIT_NAME}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scripts/install-target.js
|
|
3
|
+
* @description Shared by the npm postinstall scripts: resolves which user a global install is for
|
|
4
|
+
* (the one who ran `sudo npm i -g`, not root) and the paths it gets (README §13)
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs'),
|
|
18
|
+
os = require('node:os'),
|
|
19
|
+
path = require('node:path'),
|
|
20
|
+
{ execFileSync } = require('node:child_process');
|
|
21
|
+
|
|
22
|
+
// npm only sets this for an actual `npm install -g` — absent for a plain
|
|
23
|
+
// local/workspace install (e.g. this monorepo's own `npm install`).
|
|
24
|
+
function isGlobalInstall(){
|
|
25
|
+
return process.env.npm_config_global === 'true';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRoot(){
|
|
29
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Under `sudo npm i -g`, npm runs postinstall as root with HOME=/root, but
|
|
33
|
+
// "~" in README §13 means the person who ran sudo — SUDO_USER. Their home
|
|
34
|
+
// comes from the passwd database, since HOME has already been reset.
|
|
35
|
+
function lookupUser(name){
|
|
36
|
+
const entry = execFileSync('getent', ['passwd', name], { encoding: 'utf8' }).trim().split(':');
|
|
37
|
+
return { name: entry[0], uid: Number(entry[2]), gid: Number(entry[3]), home: entry[5] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveTargetUser(){
|
|
41
|
+
const sudoUser = process.env.SUDO_USER;
|
|
42
|
+
if (isRoot() && sudoUser && sudoUser !== 'root' && process.platform === 'linux'){
|
|
43
|
+
try {
|
|
44
|
+
return lookupUser(sudoUser);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// fall through to the current user
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const info = os.userInfo();
|
|
51
|
+
return { name: info.username, uid: info.uid, gid: info.gid, home: os.homedir() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Default layout (README §13): config under ~/.config/thub, runtime data
|
|
55
|
+
// (SQLite DB, uploaded artifacts, avatars, work files) under ~/var/lib/thub.
|
|
56
|
+
// An existing coordinator.json's own dataDir wins over the default, so a
|
|
57
|
+
// re-install prepares (and the systemd unit allows writes to) the
|
|
58
|
+
// directory the Coordinator will really use.
|
|
59
|
+
function targetPaths(user){
|
|
60
|
+
const configDir = path.join(user.home, '.config', 'thub'),
|
|
61
|
+
configPath = path.join(configDir, 'coordinator.json'),
|
|
62
|
+
defaultDataDir = path.join(user.home, 'var', 'lib', 'thub'),
|
|
63
|
+
dataDir = configuredDataDir(configPath) || defaultDataDir;
|
|
64
|
+
return {
|
|
65
|
+
configDir,
|
|
66
|
+
configPath,
|
|
67
|
+
defaultDataDir,
|
|
68
|
+
dataDir,
|
|
69
|
+
dataSubdirs: ['artifacts', 'avatars', 'work'].map((d) => path.join(dataDir, d))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function configuredDataDir(configPath){
|
|
74
|
+
try {
|
|
75
|
+
const dataDir = JSON.parse(fs.readFileSync(configPath, 'utf8'))?.dataDir;
|
|
76
|
+
return typeof dataDir === 'string' && path.isAbsolute(dataDir) ? dataDir : null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { isGlobalInstall, isRoot, resolveTargetUser, targetPaths };
|
package/src/config.js
CHANGED
|
@@ -72,6 +72,13 @@ const USER_CONFIG_PATH = path.join(os.homedir(), '.config', 'thub', 'coordinator
|
|
|
72
72
|
PACKAGE_DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
|
73
73
|
|
|
74
74
|
function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
75
|
+
// An explicitly named config that doesn't exist is an error, not a cue to
|
|
76
|
+
// fall back: silently running on the bundled dev config instead (relative
|
|
77
|
+
// dataDir, placeholder secrets) only surfaces later as a confusing
|
|
78
|
+
// failure far from the real cause, e.g. SQLITE_CANTOPEN.
|
|
79
|
+
if (configPath && !fs.existsSync(configPath)){
|
|
80
|
+
throw new Error(`Coordinator config not found: ${configPath} (from THUB_COORDINATOR_CONFIG)`);
|
|
81
|
+
}
|
|
75
82
|
const candidate = [configPath, USER_CONFIG_PATH, PACKAGE_DEFAULT_CONFIG_PATH].find(
|
|
76
83
|
(p) => p && fs.existsSync(p)
|
|
77
84
|
),
|
|
@@ -95,6 +102,9 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
|
95
102
|
config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
config.configPath = candidate || null;
|
|
106
|
+
config.dataDir = path.resolve(config.dataDir);
|
|
107
|
+
|
|
98
108
|
const [host, port] = config.listen.split(':');
|
|
99
109
|
config.host = host;
|
|
100
110
|
config.port = Number(port);
|
|
@@ -103,11 +113,27 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
|
103
113
|
config.workDir = path.join(config.dataDir, 'work');
|
|
104
114
|
config.avatarsDir = path.join(config.dataDir, 'avatars');
|
|
105
115
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
ensureWritableDir(config.dataDir, config.configPath);
|
|
117
|
+
ensureWritableDir(config.artifactsDir, config.configPath);
|
|
118
|
+
ensureWritableDir(config.avatarsDir, config.configPath);
|
|
109
119
|
|
|
110
120
|
return config;
|
|
111
121
|
}
|
|
112
122
|
|
|
123
|
+
// SQLite opens thub.db (plus its -wal/-shm files) inside dataDir, so an
|
|
124
|
+
// existing but unwritable dataDir — typically one created by an earlier
|
|
125
|
+
// run under sudo — would otherwise fail later as a bare SQLITE_CANTOPEN.
|
|
126
|
+
function ensureWritableDir(dir, configPath){
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
130
|
+
}
|
|
131
|
+
catch (err){
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Coordinator data directory ${dir} is not writable by this user (${err.code || err.message}). ` +
|
|
134
|
+
`Fix its ownership, or set dataDir in ${configPath || 'the config'} / THUB_DATA_DIR to a writable path.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
module.exports = { loadConfig, DEFAULTS };
|
package/src/db/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const fs = require('node:fs'),
|
|
|
18
18
|
Database = require('better-sqlite3');
|
|
19
19
|
|
|
20
20
|
function openDb(dbPath){
|
|
21
|
-
const db =
|
|
21
|
+
const db = openDatabase(dbPath);
|
|
22
22
|
db.pragma('journal_mode = WAL');
|
|
23
23
|
db.pragma('synchronous = NORMAL');
|
|
24
24
|
db.pragma('foreign_keys = ON');
|
|
@@ -26,6 +26,32 @@ function openDb(dbPath){
|
|
|
26
26
|
return db;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// better-sqlite3 is a native addon built for one Node.js ABI at install
|
|
30
|
+
// time; after a Node major upgrade it must be rebuilt. Without this, the
|
|
31
|
+
// only hint is a raw NODE_MODULE_VERSION mismatch from deep in bindings.js.
|
|
32
|
+
// The rebuild command keeps the caller's PATH under sudo, since sudo's own
|
|
33
|
+
// secure_path often finds a different (older) node than the user's shell
|
|
34
|
+
// does — rebuilding with that one just reproduces the same mismatch.
|
|
35
|
+
function openDatabase(dbPath){
|
|
36
|
+
try {
|
|
37
|
+
return new Database(dbPath);
|
|
38
|
+
}
|
|
39
|
+
catch (err){
|
|
40
|
+
if (err.code === 'ERR_DLOPEN_FAILED'){
|
|
41
|
+
const builtFor = err.message.match(/NODE_MODULE_VERSION (\d+)/)?.[1],
|
|
42
|
+
pkgDir = path.join(__dirname, '..', '..');
|
|
43
|
+
throw new Error(
|
|
44
|
+
`better-sqlite3 was built for a different Node.js ABI (${builtFor || 'unknown'}) than this one: ` +
|
|
45
|
+
`${process.version}, ABI ${process.versions.modules}, at ${process.execPath}.\n` +
|
|
46
|
+
`Rebuild it with this same node: cd ${pkgDir} && sudo env "PATH=$PATH" npm rebuild better-sqlite3\n` +
|
|
47
|
+
'If `sudo node -v` and `node -v` differ, sudo was using another Node.js for earlier installs.',
|
|
48
|
+
{ cause: err }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
function migrate(db){
|
|
30
56
|
db.exec(`
|
|
31
57
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
package/src/server.js
CHANGED
|
@@ -145,6 +145,7 @@ function start(configPath){
|
|
|
145
145
|
ensureBootstrapAdmin(services);
|
|
146
146
|
|
|
147
147
|
const server = app.listen(config.port, config.host, () => {
|
|
148
|
+
console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
|
|
148
149
|
console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
|
|
149
150
|
});
|
|
150
151
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=TestHub Coordinator
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
# User, Group, THUB_COORDINATOR_CONFIG, ReadWritePaths and ExecStart are
|
|
7
|
+
# rewritten by scripts/install-systemd-unit.js at install time to the user
|
|
8
|
+
# who ran `sudo npm i -g` and this install's real node/server.js paths.
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
User=thub
|
|
12
|
+
Group=thub
|
|
13
|
+
Environment=THUB_COORDINATOR_CONFIG=/var/lib/thub/.config/thub/coordinator.json
|
|
14
|
+
ExecStart=/usr/bin/node /usr/lib/node_modules/@andrian.yablonskyy/thub-coordinator/src/server.js
|
|
15
|
+
Restart=always
|
|
16
|
+
RestartSec=2
|
|
17
|
+
NoNewPrivileges=yes
|
|
18
|
+
ProtectSystem=strict
|
|
19
|
+
ReadWritePaths=/var/lib/thub/var/lib/thub
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=multi-user.target
|