@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 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 install -g @andrian.yablonskyy/thub-coordinator
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). Or clone this repo directly and run it from source (see 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 a home-anchored `dataDir` and a freshly generated random `sessionSecret` (not the placeholder above) — a re-install never overwrites it or regenerates the secret. Set `publicUrl` and `clientJoinKey` yourself before relying on auto-registration.
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
- THUB_COORDINATOR_CONFIG=/etc/thub/coordinator.json \
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.4",
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: creates ~/.config/thub/coordinator.json on a real global install,
4
- * if it doesn't already exist, with a freshly generated sessionSecret rather than a
5
- * shared placeholder — so every real installation isn't using the same known secret
6
- * for its session cookies and artifact-download HMACs (README §12, §13)
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
- CONFIG_PATH = path.join(os.homedir(), '.config', 'thub', 'coordinator.json');
25
-
26
- // npm only sets this for an actual `npm install -g` — absent for a plain
27
- // local/workspace install (e.g. this monorepo's own `npm install`).
28
- function isGlobalInstall(){
29
- return process.env.npm_config_global === 'true';
30
- }
31
-
32
- // Deliberately minimal, not a copy of the bundled config.json (which uses
33
- // a relative dataDir and a fixed sessionSecret for zero-setup `npm run
34
- // coordinator` in the monorepo — fine there since it's never a real
35
- // deployment). loadConfig()'s own DEFAULTS already cover everything else
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
- dataDir: path.join(os.homedir(), '.local', 'share', 'thub'),
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 the
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() || fs.existsSync(CONFIG_PATH)){
77
+ if (!isGlobalInstall()){
62
78
  return;
63
79
  }
80
+ const user = resolveTargetUser(),
81
+ paths = targetPaths(user);
64
82
  try {
65
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
66
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(defaultContent(), null, 2) + '\n', { mode: 0o600 });
67
- console.log(`thub-coordinator: created ${CONFIG_PATH} with a freshly generated sessionSecret`);
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 ${CONFIG_PATH} automatically (${err.message}).`);
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
- fs.mkdirSync(config.dataDir, { recursive: true });
107
- fs.mkdirSync(config.artifactsDir, { recursive: true });
108
- fs.mkdirSync(config.avatarsDir, { recursive: true });
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 = new Database(dbPath);
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