nodebb-plugin-safe-updater 0.0.0-stage → 1.1.0
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 +95 -2
- package/package.json +36 -4
- package/plugin.json +19 -0
- package/src/index.js +22 -0
- package/src/nodebbPlugin.js +51 -0
- package/src/safeUpdater.js +200 -0
- package/src/webUpdater.js +162 -0
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NodeBB Safe Updater
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A conservative utility that keeps a NodeBB forum up to date safely.
|
|
4
|
+
|
|
5
|
+
This repository now includes both:
|
|
6
|
+
- a CLI update tool
|
|
7
|
+
- a NodeBB plugin-style integration that exposes a one-click update action from the admin area
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
- Validates that the target directory is a Git repository
|
|
11
|
+
- Refuses to run on a dirty working tree unless explicitly allowed
|
|
12
|
+
- Creates a backup before updating
|
|
13
|
+
- Fetches and fast-forwards the selected branch
|
|
14
|
+
- Runs `npm install`, `yarn install`, or `pnpm install` when a project uses dependencies
|
|
15
|
+
- Exposes an admin route and an API endpoint suitable for a NodeBB plugin integration
|
|
16
|
+
|
|
17
|
+
## Install as a NodeBB plugin
|
|
18
|
+
|
|
19
|
+
1. Copy the repository contents into your NodeBB plugin directory, for example:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
plugins/nodebb-safe-updater/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
2. Make sure the NodeBB server has environment variables configured, for example:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export NODEBB_DIRECTORY=/srv/nodebb
|
|
29
|
+
export NODEBB_BRANCH=main
|
|
30
|
+
export NODEBB_REMOTE=origin
|
|
31
|
+
export NODEBB_ALLOW_DIRTY=false
|
|
32
|
+
export NODEBB_SKIP_INSTALL=false
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. Restart NodeBB.
|
|
36
|
+
|
|
37
|
+
4. Open the admin area and use the new "NodeBB Safe Updater" item.
|
|
38
|
+
|
|
39
|
+
5. Trigger the update from the browser by clicking the button in the plugin page.
|
|
40
|
+
|
|
41
|
+
## Browser update flow
|
|
42
|
+
|
|
43
|
+
The plugin registers:
|
|
44
|
+
- `/admin/plugins/nodebb-safe-updater` as the admin page
|
|
45
|
+
- `/api/v3/plugins/nodebb-safe-updater/update` as the POST endpoint used by the button
|
|
46
|
+
|
|
47
|
+
The page executes a safe update using the configured repo directory and then prints the JSON result.
|
|
48
|
+
|
|
49
|
+
## CLI usage
|
|
50
|
+
|
|
51
|
+
Update the current directory:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
node src/index.js
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Update a specific installation:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
node src/index.js --directory /srv/nodebb --branch main --remote origin
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Allow dirty working trees:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
node src/index.js --directory /srv/nodebb --allow-dirty
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Skip post-update install:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
node src/index.js --directory /srv/nodebb --skip-install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Safety checks
|
|
76
|
+
|
|
77
|
+
This updater intentionally prefers a cautious workflow:
|
|
78
|
+
- it aborts if the repo is dirty,
|
|
79
|
+
- it creates a backup before the change,
|
|
80
|
+
- it uses a fast-forward pull strategy,
|
|
81
|
+
- and it keeps the update process explicit and inspectable.
|
|
82
|
+
|
|
83
|
+
## Example response
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"ok": true,
|
|
88
|
+
"result": {
|
|
89
|
+
"directory": "/srv/nodebb",
|
|
90
|
+
"branch": "main",
|
|
91
|
+
"remote": "origin",
|
|
92
|
+
"backupDir": "/tmp/nodebb-safe-updater/nodebb-backup-2026-10-24T12-00-00.000Z",
|
|
93
|
+
"statusAfter": ""
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebb-plugin-safe-updater",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Safe NodeBB updater plugin with a one-click update endpoint and CLI fallback.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/amer-raba/nodebb-plugin-safe-updater",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/amer-raba/nodebb-plugin-safe-updater.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/amer-raba/nodebb-plugin-safe-updater/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"nodebb",
|
|
16
|
+
"plugin",
|
|
17
|
+
"updater",
|
|
18
|
+
"admin",
|
|
19
|
+
"safe-update"
|
|
20
|
+
],
|
|
21
|
+
"main": "src/nodebbPlugin.js",
|
|
22
|
+
"bin": {
|
|
23
|
+
"nodebb-safe-updater": "src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"plugin.json",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "node src/webUpdater.js",
|
|
32
|
+
"update": "node src/index.js",
|
|
33
|
+
"test": "node --test"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nodebb-safe-updater",
|
|
3
|
+
"name": "NodeBB Safe Updater",
|
|
4
|
+
"description": "Adds a safe one-click update flow for a NodeBB installation.",
|
|
5
|
+
"library": "./src/nodebbPlugin.js",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"hook": "static:app.load",
|
|
9
|
+
"method": "init"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"hook": "filter:admin.header.build",
|
|
13
|
+
"method": "addAdminNavigation"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"staticDirs": {
|
|
17
|
+
"public": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { safeUpdate, parseArgs } = require('./safeUpdater');
|
|
4
|
+
|
|
5
|
+
function main() {
|
|
6
|
+
try {
|
|
7
|
+
const options = parseArgs(process.argv.slice(2));
|
|
8
|
+
|
|
9
|
+
if (options.help) {
|
|
10
|
+
console.log('Usage: nodebb-safe-updater [--directory /path/to/nodebb] [--branch main] [--remote origin] [--allow-dirty] [--skip-install]');
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const result = safeUpdate(options);
|
|
15
|
+
console.log(JSON.stringify(result, null, 2));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(error.message || error);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
main();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { safeUpdate } = require('./safeUpdater');
|
|
4
|
+
|
|
5
|
+
const plugin = {
|
|
6
|
+
async init(params) {
|
|
7
|
+
const { router, middleware } = params;
|
|
8
|
+
|
|
9
|
+
const adminRoute = '/admin/plugins/nodebb-safe-updater';
|
|
10
|
+
const apiRoute = '/api/v3/plugins/nodebb-safe-updater/update';
|
|
11
|
+
|
|
12
|
+
router.get(adminRoute, middleware.admin.build, async (req, res) => {
|
|
13
|
+
const context = {
|
|
14
|
+
title: 'NodeBB Safe Updater',
|
|
15
|
+
directory: process.env.NODEBB_DIRECTORY || process.cwd(),
|
|
16
|
+
branch: process.env.NODEBB_BRANCH || 'main',
|
|
17
|
+
remote: process.env.NODEBB_REMOTE || 'origin'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
res.render('admin/plugins/nodebb-safe-updater', context);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.post(apiRoute, middleware.ensureLoggedIn, async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const result = safeUpdate({
|
|
26
|
+
directory: process.env.NODEBB_DIRECTORY || process.cwd(),
|
|
27
|
+
branch: process.env.NODEBB_BRANCH || 'main',
|
|
28
|
+
remote: process.env.NODEBB_REMOTE || 'origin',
|
|
29
|
+
allowDirty: Boolean(process.env.NODEBB_ALLOW_DIRTY === 'true'),
|
|
30
|
+
skipInstall: Boolean(process.env.NODEBB_SKIP_INSTALL === 'true')
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
res.json({ ok: true, result });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
res.status(500).json({ ok: false, error: error.message || String(error) });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
addAdminNavigation(header, callback) {
|
|
41
|
+
header.plugins.push({
|
|
42
|
+
route: '/admin/plugins/nodebb-safe-updater',
|
|
43
|
+
icon: 'fa-rocket',
|
|
44
|
+
name: 'NodeBB Safe Updater'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
callback(null, header);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
module.exports = plugin;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function runCommand(command, args, options = {}) {
|
|
7
|
+
const result = spawnSync(command, args, {
|
|
8
|
+
cwd: options.cwd || process.cwd(),
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
stdio: ['inherit', 'pipe', 'pipe']
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (result.error) {
|
|
14
|
+
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
19
|
+
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}. ${output}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (result.stdout || '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureGitRepository(directory) {
|
|
26
|
+
const gitDir = path.join(directory, '.git');
|
|
27
|
+
if (!fs.existsSync(directory)) {
|
|
28
|
+
throw new Error(`Directory does not exist: ${directory}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(gitDir)) {
|
|
32
|
+
throw new Error(`Not a git repository: ${directory}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getGitStatus(directory) {
|
|
37
|
+
return runCommand('git', ['-C', directory, 'status', '--porcelain']);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createBackup(directory, backupRoot) {
|
|
41
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
42
|
+
const backupDir = path.join(backupRoot, `nodebb-backup-${timestamp}`);
|
|
43
|
+
|
|
44
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
const entries = fs.readdirSync(directory);
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (['.git', 'node_modules', '.cache', 'tmp'].includes(entry)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sourcePath = path.join(directory, entry);
|
|
53
|
+
const targetPath = path.join(backupDir, entry);
|
|
54
|
+
|
|
55
|
+
if (fs.existsSync(sourcePath)) {
|
|
56
|
+
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return backupDir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detectPackageManager(directory) {
|
|
64
|
+
if (fs.existsSync(path.join(directory, 'pnpm-lock.yaml'))) {
|
|
65
|
+
return 'pnpm';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fs.existsSync(path.join(directory, 'yarn.lock'))) {
|
|
69
|
+
return 'yarn';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 'npm';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseArgs(argv = []) {
|
|
76
|
+
const options = {
|
|
77
|
+
directory: process.cwd(),
|
|
78
|
+
branch: 'main',
|
|
79
|
+
remote: 'origin',
|
|
80
|
+
allowDirty: false,
|
|
81
|
+
skipInstall: false,
|
|
82
|
+
backupRoot: path.join(os.tmpdir(), 'nodebb-safe-updater')
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
86
|
+
const arg = argv[index];
|
|
87
|
+
|
|
88
|
+
if (arg === '--directory' || arg === '-d') {
|
|
89
|
+
options.directory = argv[index + 1];
|
|
90
|
+
index += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (arg === '--branch' || arg === '-b') {
|
|
95
|
+
options.branch = argv[index + 1];
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (arg === '--remote' || arg === '-r') {
|
|
101
|
+
options.remote = argv[index + 1];
|
|
102
|
+
index += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (arg === '--backup-root') {
|
|
107
|
+
options.backupRoot = argv[index + 1];
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (arg === '--allow-dirty') {
|
|
113
|
+
options.allowDirty = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (arg === '--skip-install') {
|
|
118
|
+
options.skipInstall = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (arg === '--help' || arg === '-h') {
|
|
123
|
+
options.help = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (arg.startsWith('-')) {
|
|
128
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!options.directory || options.directory === process.cwd()) {
|
|
132
|
+
options.directory = arg;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (options.directory && options.directory !== process.cwd() && !path.isAbsolute(options.directory)) {
|
|
137
|
+
options.directory = path.resolve(options.directory);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return options;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function safeUpdate(options = {}) {
|
|
144
|
+
const finalOptions = {
|
|
145
|
+
directory: options.directory || process.cwd(),
|
|
146
|
+
branch: options.branch || 'main',
|
|
147
|
+
remote: options.remote || 'origin',
|
|
148
|
+
allowDirty: Boolean(options.allowDirty),
|
|
149
|
+
skipInstall: Boolean(options.skipInstall),
|
|
150
|
+
backupRoot: options.backupRoot || path.join(os.tmpdir(), 'nodebb-safe-updater')
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
ensureGitRepository(finalOptions.directory);
|
|
154
|
+
|
|
155
|
+
const statusBefore = getGitStatus(finalOptions.directory);
|
|
156
|
+
if (statusBefore && !finalOptions.allowDirty) {
|
|
157
|
+
throw new Error(`Working tree is not clean. Commit or stash your changes before updating, or use --allow-dirty.\n${statusBefore}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fs.mkdirSync(finalOptions.backupRoot, { recursive: true });
|
|
161
|
+
const backupDir = createBackup(finalOptions.directory, finalOptions.backupRoot);
|
|
162
|
+
|
|
163
|
+
runCommand('git', ['-C', finalOptions.directory, 'fetch', finalOptions.remote, finalOptions.branch]);
|
|
164
|
+
runCommand('git', ['-C', finalOptions.directory, 'checkout', finalOptions.branch]);
|
|
165
|
+
runCommand('git', ['-C', finalOptions.directory, 'pull', '--ff-only', finalOptions.remote, finalOptions.branch]);
|
|
166
|
+
|
|
167
|
+
if (!finalOptions.skipInstall && fs.existsSync(path.join(finalOptions.directory, 'package.json'))) {
|
|
168
|
+
const packageManager = detectPackageManager(finalOptions.directory);
|
|
169
|
+
const installCommand =
|
|
170
|
+
packageManager === 'pnpm'
|
|
171
|
+
? ['pnpm', 'install']
|
|
172
|
+
: packageManager === 'yarn'
|
|
173
|
+
? ['yarn', 'install']
|
|
174
|
+
: ['npm', 'install'];
|
|
175
|
+
|
|
176
|
+
runCommand(installCommand[0], installCommand.slice(1), { cwd: finalOptions.directory });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const statusAfter = getGitStatus(finalOptions.directory);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
directory: finalOptions.directory,
|
|
184
|
+
branch: finalOptions.branch,
|
|
185
|
+
remote: finalOptions.remote,
|
|
186
|
+
backupDir,
|
|
187
|
+
statusBefore,
|
|
188
|
+
statusAfter,
|
|
189
|
+
packageManager: fs.existsSync(path.join(finalOptions.directory, 'package.json')) ? detectPackageManager(finalOptions.directory) : null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
parseArgs,
|
|
195
|
+
safeUpdate,
|
|
196
|
+
createBackup,
|
|
197
|
+
ensureGitRepository,
|
|
198
|
+
getGitStatus,
|
|
199
|
+
runCommand
|
|
200
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const { safeUpdate, parseArgs } = require('./safeUpdater');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const HTML_PAGE = `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<title>NodeBB Safe Updater</title>
|
|
12
|
+
<style>
|
|
13
|
+
body {
|
|
14
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
15
|
+
background: #0f172a;
|
|
16
|
+
color: #e2e8f0;
|
|
17
|
+
display: grid;
|
|
18
|
+
place-items: center;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.panel {
|
|
24
|
+
width: min(520px, 90vw);
|
|
25
|
+
background: rgba(15, 23, 42, 0.8);
|
|
26
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
27
|
+
border-radius: 14px;
|
|
28
|
+
padding: 28px;
|
|
29
|
+
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.5);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
h1 {
|
|
33
|
+
margin-top: 0;
|
|
34
|
+
font-size: 1.8rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
button {
|
|
38
|
+
background: #22c55e;
|
|
39
|
+
color: #052e16;
|
|
40
|
+
border: none;
|
|
41
|
+
border-radius: 10px;
|
|
42
|
+
padding: 14px 22px;
|
|
43
|
+
font-size: 1rem;
|
|
44
|
+
font-weight: 700;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition: transform 0.15s ease;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
button:hover {
|
|
50
|
+
transform: translateY(-1px);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
button:disabled {
|
|
54
|
+
opacity: 0.6;
|
|
55
|
+
cursor: wait;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#result {
|
|
59
|
+
margin-top: 20px;
|
|
60
|
+
min-height: 80px;
|
|
61
|
+
background: rgba(15, 118, 110, 0.1);
|
|
62
|
+
border: 1px solid rgba(45, 212, 191, 0.2);
|
|
63
|
+
border-radius: 10px;
|
|
64
|
+
padding: 12px 14px;
|
|
65
|
+
white-space: pre-wrap;
|
|
66
|
+
word-break: break-word;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<div class="panel">
|
|
72
|
+
<h1>NodeBB Safe Updater</h1>
|
|
73
|
+
<p>Click the button below to update the forum to the latest version safely.</p>
|
|
74
|
+
<button id="updateButton">Update to latest version</button>
|
|
75
|
+
<div id="result">Waiting for action...</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<script>
|
|
79
|
+
const button = document.getElementById('updateButton');
|
|
80
|
+
const result = document.getElementById('result');
|
|
81
|
+
|
|
82
|
+
button.addEventListener('click', async () => {
|
|
83
|
+
button.disabled = true;
|
|
84
|
+
result.textContent = 'Updating...';
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch('/api/update', { method: 'POST' });
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(data.error || 'Update failed');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
result.textContent = JSON.stringify(data, null, 2);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
result.textContent = 'ERROR: ' + (error.message || String(error));
|
|
97
|
+
} finally {
|
|
98
|
+
button.disabled = false;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
</script>
|
|
102
|
+
</body>
|
|
103
|
+
</html>`;
|
|
104
|
+
|
|
105
|
+
function parseOptionsFromEnv() {
|
|
106
|
+
return {
|
|
107
|
+
directory: process.env.NODEBB_DIRECTORY || process.cwd(),
|
|
108
|
+
branch: process.env.NODEBB_BRANCH || 'main',
|
|
109
|
+
remote: process.env.NODEBB_REMOTE || 'origin',
|
|
110
|
+
allowDirty: (process.env.NODEBB_ALLOW_DIRTY || '').toLowerCase() === 'true',
|
|
111
|
+
skipInstall: (process.env.NODEBB_SKIP_INSTALL || '').toLowerCase() === 'true',
|
|
112
|
+
port: Number(process.env.NODEBB_UPDATER_PORT || 3000),
|
|
113
|
+
backupRoot: process.env.NODEBB_BACKUP_ROOT || path.join(process.cwd(), '.nodebb-safe-updater-backups')
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function createServer(options) {
|
|
118
|
+
const server = http.createServer(async (req, res) => {
|
|
119
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
121
|
+
res.end(HTML_PAGE);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (req.method === 'POST' && req.url === '/api/update') {
|
|
126
|
+
try {
|
|
127
|
+
const result = safeUpdate(options);
|
|
128
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
129
|
+
res.end(JSON.stringify({ ok: true, result }, null, 2));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
132
|
+
res.end(JSON.stringify({ ok: false, error: error.message || String(error) }, null, 2));
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
138
|
+
res.end(JSON.stringify({ ok: false, error: 'Not found' }, null, 2));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return server;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function main() {
|
|
145
|
+
const options = parseOptionsFromEnv();
|
|
146
|
+
const server = createServer(options);
|
|
147
|
+
|
|
148
|
+
server.listen(options.port, () => {
|
|
149
|
+
console.log(JSON.stringify({
|
|
150
|
+
status: 'ready',
|
|
151
|
+
port: options.port,
|
|
152
|
+
directory: options.directory,
|
|
153
|
+
endpoint: `http://localhost:${options.port}/`
|
|
154
|
+
}, null, 2));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (require.main === module) {
|
|
159
|
+
main();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { parseOptionsFromEnv, createServer, main };
|