@metric-im/administrate 1.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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//home/msprague/workspace/sitewell/**)",
5
+ "Read(//home/msprague/workspace/rootz/data-wallet-demos/**)"
6
+ ],
7
+ "deny": [],
8
+ "ask": []
9
+ }
10
+ }
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # administrate
2
+ Tools for site administration
package/certify.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import express from 'express';
2
+ import Acme from 'acme-client';
3
+ import tls from 'tls';
4
+ import { Config } from './config.mjs';
5
+ import moment from 'moment';
6
+
7
+ const MAX_AGE = 75; // days
8
+ const MAX_WAIT_TIME = 60; // seconds
9
+
10
+ export class Certify {
11
+ constructor(app,options) {
12
+ this.app = app;
13
+ this.options = options || {};
14
+ this.pending = {};
15
+ this.challenges = {};
16
+ this.contactEmail = undefined;
17
+ }
18
+ static async attach(app,options) {
19
+ const instance = new Certify(app,options);
20
+ instance.config = new Config();
21
+ if (!instance.config.data.ssl) instance.config.data.ssl = {};
22
+ instance.contactEmail = instance.config.profile?.email || instance.options.contactEmail;
23
+ instance.acme = new Acme.Client({
24
+ directoryUrl: Acme.directory.letsencrypt[process.env.PROFILE==='DEV'?'staging':'production'],
25
+ accountKey: await Acme.crypto.createPrivateKey(),
26
+ });
27
+ if (process.env.PROFILE==='DEV'){
28
+ Acme.setLogger((message) => {
29
+ console.log(message);
30
+ });
31
+ }
32
+ app.use('/',instance.routes());
33
+ return instance;
34
+ }
35
+ get SNI() {
36
+ return {SNICallback: async (hostname, cb) => {
37
+ try {
38
+ console.log(`SNL get keys for ${hostname}`)
39
+ const siteKeys = await this.getSiteKeys(hostname);
40
+ cb(null, tls.createSecureContext(siteKeys));
41
+ } catch (error) {
42
+ console.error(`Error loading certificate for ${hostname}:`, error);
43
+ cb(error);
44
+ }
45
+ }}
46
+ }
47
+ routes() {
48
+ const router = express.Router();
49
+
50
+ router.get(/^\/\.well-known\/acme-challenge\/([^\/]+)$/,(req,res)=>{
51
+ const token = req.params[0];
52
+ if (token in this.challenges) {
53
+ res.writeHead(200);
54
+ res.end(this.challenges[token]);
55
+ return;
56
+ }
57
+ // Don't redirect ACME challenges - return 404 instead (says Claude, used to be 302)
58
+ res.writeHead(404);
59
+ res.end('Challenge not found');
60
+ });
61
+ // router.use('/',(req, res, next) => {
62
+ // if (!req.secure && process.env.FORCE_HTTPS?.toLowerCase() !== 'false') {
63
+ // return res.redirect(`https://${req.hostname}${req.url}`);
64
+ // }
65
+ // next();
66
+ // });
67
+ return router;
68
+ }
69
+ async getSiteKeys(sitename) {
70
+ // For localhost and local domains, don't try to get Let's Encrypt certificates
71
+ if (sitename === 'localhost' || sitename.includes('.local') || sitename.match(/^\d+\.\d+\.\d+\.\d+$/)) {
72
+ return null; // Use default certificate
73
+ }
74
+
75
+ const site = this.config.data.ssl ? this.config.data.ssl[sitename] : undefined;
76
+ if (!site?.certified || moment().isAfter(moment(site.certified).add(MAX_AGE, 'days'))) {
77
+ if (!this.pending[sitename] || moment().isAfter(moment(this.pending[sitename]).add(MAX_WAIT_TIME, 'seconds'))) {
78
+ try {
79
+ this.pending[sitename] = moment();
80
+ setTimeout(async () => {
81
+ try {
82
+ await this.renewCert(sitename);
83
+ } catch (e) {
84
+ console.error(`Failed to renew certificate for ${sitename}:`, e);
85
+ delete this.pending[sitename];
86
+ }
87
+ },10);
88
+ } catch(e) {
89
+ console.error(e);
90
+ delete this.pending[sitename];
91
+ }
92
+ }
93
+ }
94
+ if (site) {
95
+ const key = this.config.readFile(site.key).toString();
96
+ const cert = this.config.readFile(site.cert).toString();
97
+ return {key: key, cert: cert};
98
+ } else {
99
+ return null;
100
+ }
101
+ }
102
+ async renewCert(sitename) {
103
+ const site = this.config.data.ssl ? this.config.data.ssl[sitename] : undefined;
104
+ if (site?.certified && moment().isBefore(moment(site.certified).add(MAX_AGE, 'days'))) {
105
+ return;
106
+ } else {
107
+ if (!this.contactEmail) throw new Error(`cannot request certificate without CONTACT_EMAIL set`);
108
+ this.pending[sitename] = true;
109
+
110
+ try {
111
+ // Add timeout wrapper for the entire certificate renewal process
112
+ await Promise.race([
113
+ this.doRenewCert(sitename),
114
+ new Promise((_, reject) =>
115
+ setTimeout(() => reject(new Error(`Certificate renewal timeout for ${sitename}`)), 120000) // 2 minute timeout
116
+ )
117
+ ]);
118
+ } catch (error) {
119
+ console.error(`Certificate renewal failed for ${sitename}:`, error);
120
+ delete this.pending[sitename];
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+
126
+ async doRenewCert(sitename) {
127
+ // create CSR
128
+ const [key, csr] = await Acme.crypto.createCsr({
129
+ altNames: [sitename],
130
+ });
131
+
132
+ // order certificate with timeout
133
+ const cert = await this.acme.auto({
134
+ csr,
135
+ email: this.contactEmail,
136
+ termsOfServiceAgreed: true,
137
+ challengePriority: ['http-01'],
138
+ challengeCreateFn: (authz, challenge, keyAuthorization) => {
139
+ this.challenges[challenge.token] = keyAuthorization;
140
+ },
141
+ challengeRemoveFn: (authz, challenge) => {
142
+ delete this.challenges[challenge.token];
143
+ },
144
+ });
145
+
146
+ // save certificate
147
+ this.config.writeFile(sitename+'.cert', cert);
148
+ this.config.writeFile(sitename+'.key', key.toString());
149
+ this.config.data.ssl[sitename] = {key:sitename+'.key', cert:sitename+'.cert',certified:moment().format("YYYY-MM-DD")};
150
+ delete this.pending[sitename];
151
+ this.config.save();
152
+
153
+ console.log(`Certificate successfully renewed for ${sitename}`);
154
+ }
155
+ }
package/config.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import fs from 'fs';
2
+ import ini from 'ini';
3
+ import {dirname, join, resolve} from "path";
4
+ import * as domain from "node:domain";
5
+
6
+ export class Config {
7
+ constructor(rootName) {
8
+ this.rootName = rootName || 'metric-im';
9
+ this.homeDir = (process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME);
10
+ this.configDir = join(this.homeDir, '.' + this.rootName);
11
+ this.configFile = join(this.configDir, 'config.ini');
12
+ if (!fs.existsSync(this.configFile)) {
13
+ this.initialize();
14
+ } else {
15
+ this.load();
16
+ }
17
+ }
18
+
19
+ initialize() {
20
+ this.data = ini.decode("");
21
+ if (!fs.existsSync(this.configDir)) {
22
+ fs.mkdirSync(this.configDir);
23
+ }
24
+ this.save()
25
+ }
26
+
27
+ load() {
28
+ let fileData = fs.readFileSync(this.configFile);
29
+ this.data = ini.decode(fileData.toString());
30
+ }
31
+
32
+ save() {
33
+ let text = ini.stringify(this.data, {});
34
+ fs.writeFileSync(this.configFile, text);
35
+ }
36
+
37
+ readFile(filename) {
38
+ return fs.readFileSync(join(this.configDir, filename));
39
+ }
40
+
41
+ writeFile(filename, data) {
42
+ fs.writeFileSync(join(this.configDir, filename), data);
43
+ }
44
+ }
45
+
46
+ export class DomainConfig extends Config {
47
+ constructor(rootName,domain) {
48
+ super(rootName);
49
+ this.domain = domain;
50
+ this.domainData = {};
51
+ }
52
+
53
+ setDomain(domain) {
54
+ this.domain = domain;
55
+ this.domainConfigDir = join(this.configDir, domain);
56
+ this.domainConfigFile = join(this.domainConfigDir, 'config.ini');
57
+ if (!fs.existsSync(this.domainConfigDir)) {
58
+ fs.mkdirSync(this.domainConfigDir);
59
+ fs.writeFileSync(this.domainConfigFile,JSON.stringify([]));
60
+ }
61
+ this.load();
62
+ }
63
+ getDomain(domain) {
64
+ let filename = join(this.configDir, domain, 'config.ini');
65
+ if (fs.existsSync(filename)) {
66
+ const text = fs.readFileSync(filename);
67
+ return ini.decode(text.toString());
68
+ } else {
69
+ return null;
70
+ }
71
+ }
72
+ load() {
73
+ if (this.domain) {
74
+ const text = fs.readFileSync(this.domainConfigFile);
75
+ this.domainData = ini.decode(text.toString());
76
+ }
77
+ super.load();
78
+ }
79
+ save() {
80
+ if (this.domain) {
81
+ const text = ini.stringify(this.domainData);
82
+ fs.writeFileSync(this.domainConfigFile, text);
83
+ }
84
+ super.save();
85
+ }
86
+ readFile(filename) {
87
+ if (this.domain) return fs.readFileSync(join(this.domainConfigDir, filename));
88
+ else return super.readFile(filename);
89
+ }
90
+
91
+ writeFile(filename, data) {
92
+ if (this.domain) fs.writeFileSync(join(this.domainConfigDir, filename), data);
93
+ else return super.writeFile(filename);
94
+ }
95
+ }
package/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ export { Certify } from './certify.mjs';
2
+ export { Config, DomainConfig } from './config.mjs';
3
+ export { Synchronize } from './synchronize.mjs';
4
+ export { MultiSite, Site } from './multisite.mjs';
package/multisite.mjs ADDED
@@ -0,0 +1,327 @@
1
+ import express from 'express';
2
+ import tls from 'tls';
3
+ import { Config } from './config.mjs';
4
+ import moment from 'moment';
5
+ import fs from "fs";
6
+ import {resolve} from "path";
7
+ import child_process from "child_process";
8
+ import axios from 'axios';
9
+
10
+ const MAX_AGE = 75; // days
11
+ const MAX_WAIT_TIME = 60; // seconds
12
+
13
+ export class MultiSite {
14
+ constructor(app,options) {
15
+ this.app = app;
16
+ this.options = options || {};
17
+ this.sites = {};
18
+ this._spawnPort = (parseInt(this.options.spawnPort||0) || 53874);
19
+ this.usedPorts = new Set();
20
+ this.healthCheckIntervals = new Map();
21
+ this.setupCleanup();
22
+ }
23
+ get spawnPort() {
24
+ const port = this._spawnPort++;
25
+ this.usedPorts.add(port);
26
+ return port;
27
+ }
28
+
29
+ setupCleanup() {
30
+ process.on('SIGTERM', () => this.cleanup());
31
+ process.on('SIGINT', () => this.cleanup());
32
+ process.on('exit', () => this.cleanup());
33
+ }
34
+
35
+ async cleanup() {
36
+ console.log('Cleaning up spawned processes...');
37
+ const promises = Object.values(this.sites).map(async (site) => {
38
+ if (site.proc && !site.proc.killed) {
39
+ return new Promise((resolve) => {
40
+ const timeout = setTimeout(() => {
41
+ if (!site.proc.killed) {
42
+ console.log(`Force killing ${site.name} after timeout`);
43
+ site.proc.kill('SIGKILL');
44
+ }
45
+ resolve();
46
+ }, 10000); // 10 second timeout for graceful shutdown
47
+
48
+ site.proc.kill('SIGTERM');
49
+
50
+ site.proc.on('exit', () => {
51
+ console.log(`${site.name} process terminated gracefully`);
52
+ clearTimeout(timeout);
53
+ resolve();
54
+ });
55
+ });
56
+ }
57
+ });
58
+
59
+ await Promise.all(promises.filter(Boolean));
60
+
61
+ // Clear all health check intervals
62
+ this.healthCheckIntervals.forEach(interval => clearInterval(interval));
63
+ this.healthCheckIntervals.clear();
64
+
65
+ console.log('All spawned processes cleaned up');
66
+ }
67
+
68
+ startHealthCheck(site) {
69
+ const intervalId = setInterval(async () => {
70
+ if (!site.proc || site.proc.killed) {
71
+ clearInterval(intervalId);
72
+ this.healthCheckIntervals.delete(site.name);
73
+ return;
74
+ }
75
+
76
+ try {
77
+ await axios.get(`http://127.0.0.1:${site.options.env.PORT}/health`, {
78
+ timeout: 5000,
79
+ validateStatus: () => true
80
+ });
81
+ } catch (error) {
82
+ if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
83
+ console.log(`Health check failed for ${site.name}, marking for restart...`);
84
+ clearInterval(intervalId);
85
+ this.healthCheckIntervals.delete(site.name);
86
+
87
+ // Mark the process as failed for cleanup
88
+ if (site.proc && !site.proc.killed) {
89
+ site.proc.kill('SIGTERM');
90
+ }
91
+ }
92
+ }
93
+ }, 30000); // Check every 30 seconds
94
+
95
+ this.healthCheckIntervals.set(site.name, intervalId);
96
+ }
97
+
98
+ restartSite(domain) {
99
+ const oldSite = this.sites[domain];
100
+ if (oldSite) {
101
+ // Clear health check
102
+ const intervalId = this.healthCheckIntervals.get(oldSite.name);
103
+ if (intervalId) {
104
+ clearInterval(intervalId);
105
+ this.healthCheckIntervals.delete(oldSite.name);
106
+ }
107
+
108
+ // Kill old process
109
+ if (oldSite.proc && !oldSite.proc.killed) {
110
+ oldSite.proc.kill('SIGTERM');
111
+ }
112
+
113
+ // Remove from used ports
114
+ this.usedPorts.delete(oldSite.options.env.PORT);
115
+ }
116
+
117
+ // Create new site
118
+ this.sites[domain] = Site.Clone(domain, this);
119
+ console.log(`Restarted site for domain: ${domain}`);
120
+ }
121
+
122
+ removeDeadSites() {
123
+ Object.keys(this.sites).forEach(domain => {
124
+ const site = this.sites[domain];
125
+ if (site.proc && site.proc.killed) {
126
+ this.usedPorts.delete(site.options.env.PORT);
127
+ delete this.sites[domain];
128
+ console.log(`Removed dead site: ${domain}`);
129
+ }
130
+ });
131
+ }
132
+ static async attach(app,options) {
133
+ const instance = new MultiSite(app,options);
134
+ instance.config = new Config();
135
+ // spawn declared sites
136
+ if (fs.existsSync(resolve('./sites'))) {
137
+ instance.sites = (fs.readdirSync(resolve('./sites'))).reduce((result,hostName)=>{
138
+ const domainName = Site.GetId(hostName);
139
+ const options = {
140
+ cwd: resolve(`./sites/${hostName}`),
141
+ env: {PORT:instance.spawnPort,meta:instance.options}
142
+ };
143
+ result[domainName] = Site.Spawn(domainName,options,instance);
144
+ return result;
145
+ },{});
146
+ }
147
+ app.use('/',instance.routes());
148
+ return instance;
149
+ }
150
+ routes() {
151
+ const router = express.Router();
152
+
153
+ router.all(/.*/, async (req, res) => {
154
+ const domain = Site.GetId(req.hostname);
155
+ const site = this.sites[domain];
156
+ if (site) {
157
+ let target = `http://127.0.0.1:${site.options.env.PORT}${req.url}`;
158
+ const method = req.method;
159
+ const isBodyMethod = ['POST', 'PUT', 'PATCH'].includes(method);
160
+ const payload = isBodyMethod ? req.body : null;
161
+
162
+ // Debug logging for POST requests
163
+ if (method === 'POST') {
164
+ console.log(`[Proxy] ${method} ${target}`);
165
+ console.log(`[Proxy] Request body:`, req.body);
166
+ console.log(`[Proxy] Payload:`, payload);
167
+ }
168
+
169
+ const headersToForward = {
170
+ ...req.headers,
171
+ 'content-length': undefined // Let Axios compute
172
+ };
173
+
174
+
175
+ try {
176
+ // Determine if this is a binary file request
177
+ const isBinaryRequest = /\.(woff2?|ttf|eot|otf|ico|png|jpe?g|gif|svg|pdf|zip|exe)(\?.*)?$/i.test(req.url);
178
+
179
+ if (isBinaryRequest) {
180
+ // Use streaming for binary files to avoid corruption
181
+ const response = await axios({
182
+ method,
183
+ url: target,
184
+ headers: headersToForward,
185
+ data: payload,
186
+ params: req.query,
187
+ validateStatus: () => true,
188
+ timeout: 30000,
189
+ responseType: 'stream'
190
+ });
191
+
192
+ console.log(`${response.status} ${target} (binary stream)`);
193
+
194
+ // Set headers without the problematic ones
195
+ const cleanHeaders = {...response.headers};
196
+ delete cleanHeaders['transfer-encoding'];
197
+ delete cleanHeaders['content-encoding'];
198
+
199
+ res.status(response.status).set(cleanHeaders);
200
+ response.data.pipe(res);
201
+ } else {
202
+ // Keep existing text/json handling
203
+ const response = await axios({
204
+ method,
205
+ url: target,
206
+ headers: headersToForward,
207
+ data: payload,
208
+ params: req.query,
209
+ validateStatus: () => true,
210
+ timeout: 30000,
211
+ responseType: 'text'
212
+ });
213
+
214
+ console.log(`${response.status} ${target}`);
215
+
216
+ const cleanHeaders = {...response.headers};
217
+ delete cleanHeaders['transfer-encoding'];
218
+ delete cleanHeaders['content-encoding'];
219
+
220
+ res.status(response.status).set(cleanHeaders).send(response.data);
221
+ }
222
+ } catch (error) {
223
+ console.error(`[Proxy] Error connecting to ${target}:`, error.message);
224
+
225
+ // // Clean up dead processes
226
+ // this.removeDeadSites();
227
+ //
228
+ // // Try to respawn the site if it's down
229
+ // if (error.code === 'ECONNREFUSED') {
230
+ // console.log(`[Proxy] Attempting to respawn site for ${domain}`);
231
+ // this.sites[domain] = Site.Clone(domain, this);
232
+ // return res.status(503).send('Service temporarily unavailable. Please try again in a few seconds.');
233
+ // }
234
+
235
+ res.status(502).send('Bad Gateway. Try reloading.');
236
+ }
237
+ } else {
238
+ this.sites[domain] = Site.Clone(domain,this);
239
+ // Retry the request after spawning
240
+ setTimeout(() => {
241
+ res.redirect(`//${req.hostname}${req.url}`);
242
+ }, 2000);
243
+ }
244
+ });
245
+ return router;
246
+ }
247
+ }
248
+ export class Site {
249
+ constructor(name,options={},parent) {
250
+ this.name = name;
251
+ this.options = options;
252
+ this.parent = parent;
253
+ }
254
+ static Spawn(name, options, parent) {
255
+ const instance = new Site(name, options, parent);
256
+ instance.spawn();
257
+ return instance;
258
+ }
259
+ static Clone(name, multisite) {
260
+ const options = {
261
+ cwd: process.cwd(),
262
+ env: {PORT:multisite.spawnPort,meta:multisite.options}
263
+ };
264
+ const instance = new Site(name,options);
265
+ instance.spawn();
266
+ return instance;
267
+ }
268
+ spawn() {
269
+ let commands = (['run', 'start', this.name]);
270
+ console.log(`Spawning site ${this.name} on port ${this.options.env.PORT}`);
271
+
272
+ try {
273
+ this.proc = child_process.spawn('npm', commands, this.options);
274
+
275
+ this.proc.stdout.on('data', (data) => {
276
+ process.stdout.write(`${this.name}: ${data.toString()}`);
277
+ });
278
+
279
+ this.proc.stderr.on('data', (data) => {
280
+ process.stdout.write(`${this.name}:E: ${data.toString()}`);
281
+ });
282
+
283
+ this.proc.on('close', (code) => {
284
+ console.log(`${this.name}: process exited with code ${code}`);
285
+ this.proc = null;
286
+
287
+ // Clear health check when process exits
288
+ if (this.healthCheckIntervals) {
289
+ const intervalId = this.healthCheckIntervals.get(this.name);
290
+ if (intervalId) {
291
+ clearInterval(intervalId);
292
+ this.healthCheckIntervals.delete(this.name);
293
+ }
294
+ }
295
+ });
296
+
297
+ this.proc.on('error', (err) => {
298
+ console.error(`${this.name}: Failed to start process:`, err);
299
+ this.proc = null;
300
+ });
301
+
302
+ // Give the process time to start, then begin health checks
303
+ setTimeout(() => {
304
+ if (this.proc && !this.proc.killed) {
305
+ console.log(`${this.name}: Successfully started on port ${this.options.env.PORT}`);
306
+
307
+ // Start health monitoring after a delay to allow process to fully start
308
+ if (this.startHealthCheck) {
309
+ setTimeout(() => {
310
+ if (this.proc && !this.proc.killed) {
311
+ this.startHealthCheck(this);
312
+ }
313
+ }, 5000); // Wait 5 seconds before starting health checks
314
+ }
315
+ }
316
+ }, 1000);
317
+
318
+ } catch (err) {
319
+ console.error(`${this.name}: Exception while spawning:`, err);
320
+ this.proc = null;
321
+ }
322
+ }
323
+ static GetId(hostName="") {
324
+ if (hostName.match(/^[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}$/)) return null
325
+ return hostName.toLowerCase().replace(/[^a-z0-9-]+/g,'_');
326
+ }
327
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@metric-im/administrate",
3
+ "version": "1.0.0",
4
+ "description": "Tools for site administration",
5
+ "homepage": "https://github.com/metric-im/administrate#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/metric-im/administrate/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/metric-im/administrate.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Michael Sprague",
15
+ "type": "commonjs",
16
+ "main": "index.mjs",
17
+ "scripts": {
18
+ "test": "echo \"Error: no test specified\" && exit 1"
19
+ },
20
+ "dependencies": {
21
+ "@metric-im/componentry": "^1.4.4",
22
+ "acme-client": "^5.4.0",
23
+ "axios": "^1.7.0",
24
+ "express": "^5.1.0",
25
+ "ini": "^5.0.0",
26
+ "moment": "^2.30.1"
27
+ }
28
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Synchronize
3
+ *
4
+ * Open a web hook, or monitor a branch, so that the app running this
5
+ * code will automatically remain in sync with the given branch of the
6
+ * given repository on Github.
7
+ *
8
+ */
9
+ import fs from 'fs';
10
+ import express from 'express';
11
+ import {resolve} from "path";
12
+ import {spawn, exec} from 'child_process';
13
+
14
+ export class Synchronize {
15
+ constructor(branch) {
16
+ this.repositoryPath = this.discoverRepositoryPath();
17
+ this.branch = branch || 'main';
18
+ this.appName = this.getAppName();
19
+ }
20
+
21
+ static attach(app,branch) {
22
+ const instance = new Synchronize(branch);
23
+ app.use('/',instance.routes());
24
+ }
25
+
26
+ discoverRepositoryPath() {
27
+ try {
28
+ const packageJsonPath = resolve(process.cwd(), 'package.json');
29
+ if (fs.existsSync(packageJsonPath)) {
30
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
31
+ if (packageJson.repository) {
32
+ return process.cwd();
33
+ }
34
+ }
35
+ } catch (error) {
36
+ console.warn('Could not find repository path:', error.message);
37
+ }
38
+ return process.cwd(); // fallback to current directory
39
+ }
40
+
41
+ getAppName() {
42
+ try {
43
+ const packageJsonPath = resolve(this.repositoryPath || process.cwd(), 'package.json');
44
+ if (fs.existsSync(packageJsonPath)) {
45
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
46
+ return packageJson.name;
47
+ }
48
+ } catch (error) {
49
+ console.warn('Could not extract app name from package.json:', error.message);
50
+ }
51
+
52
+ // Fallback: extract from repository path or current directory
53
+ const repoPath = this.repositoryPath || process.cwd();
54
+ return repoPath.split('/').pop();
55
+ }
56
+
57
+ routes() {
58
+ const router = new express.Router();
59
+ router.get(/^\/_update/, async (req, res) => {
60
+ try {
61
+ await this.update();
62
+ res.json({success: true, message: 'Update completed successfully'});
63
+ } catch (error) {
64
+ res.status(500).json({success: false, error: error.message});
65
+ }
66
+ });
67
+ return router;
68
+ }
69
+ async update() {
70
+ console.log(`Starting deployment update for ${this.repositoryPath} on branch ${this.branch}`);
71
+
72
+ try {
73
+ // Pull latest changes from GitHub
74
+ await this.executeCommand('git', ['fetch', 'origin', this.branch]);
75
+ await this.executeCommand('git', ['reset', '--hard', `origin/${this.branch}`]);
76
+
77
+ // Install/update dependencies
78
+ await this.executeCommand('npm', ['install']);
79
+
80
+ // Restart the service using systemctl
81
+ console.log(`Restarting service: ${this.appName}`);
82
+ await this.executeCommand('sudo', ['systemctl', 'restart', this.appName]);
83
+
84
+ console.log('Deployment update completed successfully');
85
+ } catch (error) {
86
+ console.error('Deployment update failed:', error.message);
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ executeCommand(command, args) {
92
+ return new Promise((resolve, reject) => {
93
+ console.log(`Executing: ${command} ${args.join(' ')}`);
94
+ const process = spawn(command, args, {
95
+ stdio: 'inherit',
96
+ cwd: this.repositoryPath || process.cwd()
97
+ });
98
+
99
+ process.on('close', (code) => {
100
+ if (code === 0) {
101
+ resolve();
102
+ } else {
103
+ reject(new Error(`Command "${command} ${args.join(' ')}" failed with exit code ${code}`));
104
+ }
105
+ });
106
+
107
+ process.on('error', (error) => {
108
+ reject(error);
109
+ });
110
+ });
111
+ }
112
+ static async ActiveBranch() {
113
+ return new Promise((resolve, reject) => {
114
+ exec('git branch --show-current', (error, stdout, stderr) => {
115
+ if (error) {
116
+ console.error(`Error executing git command: ${error.message}`);
117
+ resolve()
118
+ }
119
+ if (stderr) {
120
+ console.error(`Git stderr: ${stderr}`);
121
+ resolve()
122
+ }
123
+ resolve(stdout.trim());
124
+ });
125
+ });
126
+ }
127
+ static get Package() {
128
+ if (!Syncrhonize._Package) {
129
+ let text = fs.readFileSync(resolve('./package.json'), 'utf8');
130
+ Syncrhonize._Package = JSON.parse(text.toString())
131
+ }
132
+ return Syncrhonize._Package;
133
+ }
134
+ static get Version() {
135
+ return Synchronize.Package.version;
136
+ }
137
+ }