chore(version): single-source VERSION, env-configurable data paths, bump tooling

- server/version.js: shared version helper that reads the root VERSION file once
  (fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
  in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
  db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
  legacy in-repo paths, so existing installs (including production) are
  byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
  android (versionName and versionCode + 1), and the tizen widget version; makes
  one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
  (including .db.devbak backups, at any path) can be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-10 12:56:03 -05:00
parent 26cd29c530
commit 52b10408be
9 changed files with 146 additions and 26 deletions

10
.gitignore vendored
View file

@ -1,10 +1,12 @@
# Dependencies
node_modules/
# Database
server/db/*.db
server/db/*.db-wal
server/db/*.db-shm
# Databases: SQLite files, WAL/SHM sidecars, and any .db.<suffix> backups
# (e.g. .db.devbak), anywhere in the tree - never commit a database.
*.db
*.db-wal
*.db-shm
*.db.*
# Uploads (user content)
server/uploads/

57
scripts/bump-version.sh Executable file
View file

@ -0,0 +1,57 @@
#!/bin/bash
# Bump the ScreenTinker version across every source of truth in one commit + tag.
#
# scripts/bump-version.sh major|minor|patch|X.Y.Z
#
# Updates (and commits together): VERSION (root, the value the server reads at
# runtime), server/package.json + package-lock.json, android versionName
# (+versionCode by 1), tizen/config.xml widget version. Then creates an annotated
# tag vX.Y.Z. Does NOT push - prints the push command, so a release fires
# deliberately (pushing the tag is what triggers the release workflow).
set -euo pipefail
cd "$(dirname "$0")/.."
# Require a clean tree so the version commit can't sweep up unrelated changes.
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: working tree is dirty - commit or stash before bumping." >&2
exit 1
fi
CURRENT="$(cat VERSION)"
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
case "${1:-}" in
major) NEW="$((MAJ + 1)).0.0" ;;
minor) NEW="${MAJ}.$((MIN + 1)).0" ;;
patch) NEW="${MAJ}.${MIN}.$((PAT + 1))" ;;
[0-9]*.[0-9]*.[0-9]*) NEW="$1" ;;
*) echo "usage: $0 major|minor|patch|X.Y.Z (current: $CURRENT)" >&2; exit 1 ;;
esac
echo "Bumping $CURRENT -> $NEW"
# 1) VERSION (source of truth)
printf '%s\n' "$NEW" > VERSION
# 2) server/package.json version + lockfile (only the top-level "version" key;
# dependency entries are "name": "^x.y.z" and won't match "version": "x.y.z")
sed -i -E "s/(\"version\"[[:space:]]*:[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW\"/" server/package.json
( cd server && npm install --package-lock-only >/dev/null )
# 3) android versionName + versionCode (+1)
sed -i -E "s/(versionName[[:space:]]*=[[:space:]]*)\"[0-9.]+\"/\1\"$NEW\"/" android/app/build.gradle.kts
CODE="$(grep -oE 'versionCode[[:space:]]*=[[:space:]]*[0-9]+' android/app/build.gradle.kts | grep -oE '[0-9]+$')"
sed -i -E "s/(versionCode[[:space:]]*=[[:space:]]*)[0-9]+/\1$((CODE + 1))/" android/app/build.gradle.kts
# 4) tizen widget version. Leading-space guard targets the widget's version="..."
# attribute and NOT tizen:application required_version="..." (no space before
# "version" there - it's "...d_version").
sed -i -E "s/([[:space:]]version=\")[0-9][^\"]*(\")/\1${NEW}\2/" tizen/config.xml
# 5) commit + annotated tag (no push)
git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml
git commit -q -m "chore(release): v$NEW"
git tag -a "v$NEW" -m "ScreenTinker v$NEW"
echo
echo "Committed + tagged v$NEW (nothing pushed). To release:"
echo " git push origin main && git push origin v$NEW"

View file

@ -1,12 +1,23 @@
const path = require('path');
// Data locations. Everything defaults to the in-repo layout, so existing installs
// (including production) are byte-for-byte unchanged when these are unset. Set
// DATA_DIR - or the individual *_PATH / *_DIR vars - to relocate state onto a
// mounted volume (used by the Docker image). UNSET resolves to exactly the legacy
// paths: server/db/remote_display.db, server/uploads/, server/certs/.
const DATA_DIR = process.env.DATA_DIR || __dirname;
const uploadsDir = process.env.UPLOADS_DIR || path.join(DATA_DIR, 'uploads');
const certsDir = process.env.CERTS_DIR || path.join(DATA_DIR, 'certs');
module.exports = {
port: process.env.PORT || 3001,
httpsPort: process.env.HTTPS_PORT || 3443,
dbPath: path.join(__dirname, 'db', 'remote_display.db'),
uploadsDir: path.join(__dirname, 'uploads'),
contentDir: path.join(__dirname, 'uploads', 'content'),
screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'),
dataDir: DATA_DIR,
dbPath: process.env.DB_PATH || path.join(DATA_DIR, 'db', 'remote_display.db'),
uploadsDir,
contentDir: path.join(uploadsDir, 'content'),
screenshotsDir: path.join(uploadsDir, 'screenshots'),
certsDir,
frontendDir: path.join(__dirname, '..', 'frontend'),
// App-level heartbeat. Checker runs every heartbeatInterval and marks
// devices offline if last_heartbeat is older than heartbeatTimeout.
@ -29,11 +40,11 @@ module.exports = {
screenshotQuality: 70,
// SSL: drop your Cloudflare Origin cert + key in certs/ folder
// or set env vars SSL_CERT and SSL_KEY to custom paths
sslCert: process.env.SSL_CERT || path.join(__dirname, 'certs', 'cert.pem'),
sslKey: process.env.SSL_KEY || path.join(__dirname, 'certs', 'key.pem'),
sslCert: process.env.SSL_CERT || path.join(certsDir, 'cert.pem'),
sslKey: process.env.SSL_KEY || path.join(certsDir, 'key.pem'),
// Auth
jwtSecret: process.env.JWT_SECRET || (() => {
const secretFile = path.join(__dirname, 'certs', '.jwt_secret');
const secretFile = path.join(certsDir, '.jwt_secret');
const fs = require('fs');
if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, 'utf8').trim();
const secret = require('crypto').randomBytes(64).toString('hex');

View file

@ -1,11 +1,11 @@
{
"name": "remote-display-server",
"name": "screentinker",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remote-display-server",
"name": "screentinker",
"version": "1.0.0",
"dependencies": {
"@azure/msal-node": "^5.2.1",

View file

@ -1,5 +1,5 @@
{
"name": "remote-display-server",
"name": "screentinker",
"version": "1.0.0",
"description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js",

View file

@ -5,6 +5,7 @@ const os = require('os');
const path = require('path');
const fs = require('fs');
const config = require('../config');
const VERSION = require('../version');
const { PLATFORM_ROLES } = require('../middleware/auth');
// Public status page
@ -16,8 +17,7 @@ router.get('/', (req, res) => {
const uptime = process.uptime();
// Public status - minimal info only (no user counts, no server internals)
let version = '1.5.1';
try { version = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); } catch {}
const version = VERSION;
res.json({
status: 'ok',

View file

@ -6,6 +6,7 @@ const cors = require('cors');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const VERSION = require('./version');
// Ensure upload directories exist
[config.contentDir, config.screenshotsDir].forEach(dir => {
@ -469,9 +470,7 @@ updateFrontendHash();
// Recheck every 30 seconds
setInterval(updateFrontendHash, 30000);
app.get('/api/version', (req, res) => {
let version = '1.2.0';
try { version = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(); } catch {}
res.json({ hash: frontendHash, version });
res.json({ hash: frontendHash, version: VERSION });
});
// Public status page
@ -488,13 +487,7 @@ app.get('/api/update/check', (req, res) => {
const apkSize = apkExists ? fs.statSync(apkPath).size : 0;
const apkModified = apkExists ? fs.statSync(apkPath).mtimeMs : 0;
// Read version from a version file, or use the APK modification time as a version indicator
const versionFile = path.join(__dirname, '..', 'VERSION');
let latestVersion = '1.0.0';
try {
if (fs.existsSync(versionFile)) latestVersion = fs.readFileSync(versionFile, 'utf8').trim();
} catch {}
const latestVersion = VERSION;
const updateAvailable = currentVersion && currentVersion !== latestVersion;
res.json({

View file

@ -0,0 +1,44 @@
// Hard backward-compat guarantee: with DATA_DIR (and the per-path overrides)
// UNSET, config must resolve to exactly the legacy in-repo locations, so existing
// installs - including production - see zero behavior change. Also verifies the
// overrides actually relocate state (the Docker /data case).
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const PATH_ENV = ['DATA_DIR', 'DB_PATH', 'UPLOADS_DIR', 'CERTS_DIR'];
const serverDir = path.join(__dirname, '..'); // config.js lives in server/
function loadConfig(overrides) {
PATH_ENV.forEach((k) => delete process.env[k]);
process.env.JWT_SECRET = 'test-secret'; // short-circuits the secret-file-writing IIFE (no FS side effects)
Object.assign(process.env, overrides || {});
delete require.cache[require.resolve('../config')];
return require('../config');
}
test('UNSET -> exactly the legacy in-repo paths (zero change for existing installs)', () => {
const c = loadConfig();
assert.strictEqual(c.dataDir, serverDir);
assert.strictEqual(c.dbPath, path.join(serverDir, 'db', 'remote_display.db'));
assert.strictEqual(c.uploadsDir, path.join(serverDir, 'uploads'));
assert.strictEqual(c.contentDir, path.join(serverDir, 'uploads', 'content'));
assert.strictEqual(c.screenshotsDir, path.join(serverDir, 'uploads', 'screenshots'));
assert.strictEqual(c.certsDir, path.join(serverDir, 'certs'));
});
test('DATA_DIR relocates db / uploads / certs onto the volume', () => {
const c = loadConfig({ DATA_DIR: '/data' });
assert.strictEqual(c.dbPath, path.join('/data', 'db', 'remote_display.db'));
assert.strictEqual(c.uploadsDir, path.join('/data', 'uploads'));
assert.strictEqual(c.contentDir, path.join('/data', 'uploads', 'content'));
assert.strictEqual(c.screenshotsDir, path.join('/data', 'uploads', 'screenshots'));
assert.strictEqual(c.certsDir, path.join('/data', 'certs'));
});
test('individual overrides win over DATA_DIR', () => {
const c = loadConfig({ DATA_DIR: '/data', DB_PATH: '/custom/app.db', UPLOADS_DIR: '/media' });
assert.strictEqual(c.dbPath, '/custom/app.db');
assert.strictEqual(c.uploadsDir, '/media');
assert.strictEqual(c.contentDir, path.join('/media', 'content'));
});

13
server/version.js Normal file
View file

@ -0,0 +1,13 @@
// Single source of truth for the running version string. Reads the root VERSION
// file once at load (the version only changes across a deploy, which restarts the
// process). Fallback '0.0.0' so a stale literal can never masquerade as a real
// release - replaces the old hardcoded '1.2.0' / '1.5.1' fallbacks.
const fs = require('fs');
const path = require('path');
let version = '0.0.0';
try {
version = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim() || '0.0.0';
} catch { /* keep the 0.0.0 fallback */ }
module.exports = version;