nodebb-plugin-safe-updater 0.0.0-stage → 2.0.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 CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # NodeBB Safe Updater
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.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 };