fa-mcp-sdk 0.2.87 → 0.2.92

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/bin/fa-mcp.js CHANGED
@@ -13,6 +13,7 @@ const PRINT_FILLED = true;
13
13
 
14
14
  const hl = (v) => chalk.bgGreen.black(v);
15
15
  const hly = (v) => chalk.bgYellow.black(v);
16
+ const hp = (paramName) => ` [${chalk.magenta(paramName)}]`;
16
17
  const formatDefaultValue = (v) => (v ? ` (default: ${hl(v)})` : '');
17
18
  const OPTIONAL = chalk.gray(' (optional)');
18
19
  const FROM_CONFIG = chalk.gray(' (from config)');
@@ -20,7 +21,7 @@ const trim = (s) => String(s || '').trim();
20
21
 
21
22
  const printFilled = (paramName, paramValue) => {
22
23
  if (PRINT_FILLED) {
23
- console.log(` ${paramName}: ${hl(paramValue)}`);
24
+ console.log(` ${hp(paramName)}: ${hl(paramValue)}`);
24
25
  }
25
26
  };
26
27
 
@@ -46,7 +47,7 @@ const getAsk = () => {
46
47
  optional: (title, paramName, defaultValue, example = undefined) => new Promise(resolve => {
47
48
  const defaultText = formatDefaultValue(defaultValue);
48
49
  example = example ? ` (example: ${example})` : '';
49
- const prompt = `${title} [${paramName}]${defaultText}${example}${OPTIONAL}: `;
50
+ const prompt = `${title}${hp(paramName)}${defaultText}${example}${OPTIONAL}: `;
50
51
  rl.question(prompt, (v) => {
51
52
  resolve(trim(v) || trim(defaultValue));
52
53
  });
@@ -57,8 +58,8 @@ const getAsk = () => {
57
58
  const y = isTrue ? `${hl('y')}` : 'y';
58
59
  const n = isTrue ? 'n' : `${hl('n')}`;
59
60
 
60
- paramName = paramName ? ` [${paramName}]` : '';
61
- const prompt = `${title}${paramName} (${y}/${n}): `;
61
+ const hpn = paramName ? hp(paramName) : '';
62
+ const prompt = `${title}${hpn} (${y}/${n}): `;
62
63
  while (true) {
63
64
  const answer = await yn_(prompt, defaultValue === 'true' ? 'y' : 'n');
64
65
  if (answer === 'y' || answer === 'n') {
@@ -171,6 +172,13 @@ class MCPGenerator {
171
172
  defaultValue: '',
172
173
  title: 'Domain name for nginx configuration',
173
174
  },
175
+ {
176
+ name: 'ssl-wildcard.conf.rel.path',
177
+ defaultValue: 'snippets/ssl-wildcard.conf',
178
+ title: `The relative path to the nginx configuration file
179
+ in the /etc/nginx folder that specifies the SSL
180
+ certificate's public and private keys`,
181
+ },
174
182
 
175
183
  {
176
184
  name: 'webServer.auth.enabled',
@@ -201,8 +209,9 @@ class MCPGenerator {
201
209
  name: 'NODE_ENV',
202
210
  },
203
211
  {
204
- skip: true,
205
212
  name: 'SERVICE_INSTANCE',
213
+ defaultValue: '',
214
+ title: 'Suffix of the service name in Consul and process manager',
206
215
  },
207
216
  {
208
217
  skip: true,
@@ -213,6 +222,17 @@ class MCPGenerator {
213
222
  defaultValue: '',
214
223
  title: 'Maintainer url',
215
224
  },
225
+ {
226
+ name: 'logger.useFileLogger',
227
+ defaultValue: '',
228
+ title: 'Whether to check MCP name in the token',
229
+ },
230
+ {
231
+ skip: true,
232
+ name: 'logger.dir',
233
+ defaultValue: '',
234
+ title: 'Папка, куда будут записываться логи',
235
+ },
216
236
  ];
217
237
  }
218
238
 
@@ -430,14 +450,28 @@ class MCPGenerator {
430
450
  value = await ask.optional(title, name, defaultValue);
431
451
  if (value) {
432
452
  // Auto-generate upstream from mcp.domain by replacing dots with dashes
433
- config['upstream'] = value.replace(/\./g, '-');
453
+ config.upstream = value.replace(/\./g, '-');
434
454
  }
435
455
  continue;
436
456
 
437
457
  case 'maintainerUrl':
438
458
  value = await ask.optional(title, name, defaultValue);
439
459
  if (value) {
440
- config['maintainerHtml'] = `<a href="${value}" target="_blank" rel="noopener" class="clickable">Support</a>`;
460
+ config.maintainerHtml = `<a href="${value}" target="_blank" rel="noopener" class="clickable">Support</a>`;
461
+ }
462
+ continue;
463
+ case 'logger.useFileLogger':
464
+ const enabled = await ask.yn(title, name, defaultValue);
465
+ config[name] = String(enabled);
466
+ if (enabled) {
467
+ const nm = 'logger.dir';
468
+ const p = this.optionalParams.find(({ name: n }) => n === nm);
469
+ value = await ask.optional(p.title, nm, config[nm] || p.defaultValue);
470
+ if (value) {
471
+ config[nm] = value;
472
+ }
473
+ } else {
474
+ config[nm] = '';
441
475
  }
442
476
  continue;
443
477
  default:
@@ -518,6 +552,11 @@ class MCPGenerator {
518
552
  // Loop until configuration is confirmed
519
553
  while (!confirmed) {
520
554
  await this.collectConfigData(configProxy, isRetry);
555
+
556
+ // Set NODE_ENV and PM2_NAMESPACE based on isProduction
557
+ config.NODE_ENV = config.isProduction === 'true' ? 'production' : 'development';
558
+ config.PM2_NAMESPACE = config.isProduction === 'true' ? 'prod' : 'dev';
559
+
521
560
  confirmed = await this.confirmConfiguration(config);
522
561
 
523
562
  if (!confirmed) {
@@ -1,6 +1,4 @@
1
1
  SERVICE_NAME={{project.name}}
2
- # Used as an alternate service name when using systemd
3
- SERVICE_NAME_ALT={{project.name}}
4
2
  NODE_ENV={{NODE_ENV}}
5
3
  # Used for PM2
6
4
  SERVICE_INSTANCE={{SERVICE_INSTANCE}}
@@ -15,7 +15,7 @@ consul:
15
15
  token: '{{consul.agent.reg.token}}'
16
16
  service:
17
17
  enable: {{consul.service.enable}} # true - Allows registration of the service with the consul
18
- instance: 'instance-0' # This value will be specified as a suffix in the id of the service
18
+ instance: '{{SERVICE_INSTANCE}}' # This value will be specified as a suffix in the id of the service
19
19
  envCode: # Used to generate the service ID
20
20
  prod: '{{consul.envCode.prod}}' # Production environment code
21
21
  dev: '{{consul.envCode.dev}}' # Development environment code
@@ -35,7 +35,8 @@ db:
35
35
 
36
36
  logger:
37
37
  level: info
38
- useFileLogger: true # To use or not to use logging to a file
38
+ useFileLogger: {{logger.useFileLogger}} # To use or not to use logging to a file
39
+ dir: '{{logger.dir}}'
39
40
 
40
41
  mcp:
41
42
  transportType: http # 'stdio' or 'http'
@@ -15,6 +15,7 @@ consul:
15
15
  timeout: '5s'
16
16
  deregistercriticalserviceafter: '3m'
17
17
  agent:
18
+ # Credentials for getting information about services in the DEV DC
18
19
  dev:
19
20
  dc: '{{consul.agent.dev.dc}}'
20
21
  host: '{{consul.agent.dev.host}}'
@@ -22,6 +23,7 @@ consul:
22
23
  secure: true
23
24
  # Token for getting information about DEV services
24
25
  token: '***'
26
+ # Credentials for getting information about services in the PROD DC
25
27
  prd:
26
28
  dc: '{{consul.agent.prd.dc}}'
27
29
  host: '{{consul.agent.prd.host}}'
@@ -29,8 +31,10 @@ consul:
29
31
  secure: true
30
32
  # Token for obtaining information about PROD services
31
33
  token: '***'
34
+ # Credentials for registering the service with Consul
32
35
  reg:
33
- host: null # The host of the consul agent where the service will be registered. If not specified, the server on which the service is running is used
36
+ # The host of the consul agent where the service will be registered. If not specified, the server on which the service is running is used
37
+ host: null
34
38
  port: 8500
35
39
  secure: false
36
40
  # Token for registering the service in the consul agent
@@ -38,7 +42,7 @@ consul:
38
42
  service:
39
43
  enable: {{consul.service.enable}} # true - Allows registration of the service with the consul
40
44
  name: <name> # <name> will be replaced by <package.json>.name at initialization
41
- instance: 'instance-0' # This value will be specified as a suffix in the id of the service
45
+ instance: '{{SERVICE_INSTANCE}}' # This value will be specified as a suffix in the id of the service
42
46
  version: <version> # <version> will be replaced by <package.json>.version at initialization
43
47
  description: <description> # <description> will be replaced by <package.json>.description at initialization
44
48
  tags: [] # If null or empty array - Will be pulled up from package.keywords at initialization
@@ -63,7 +67,8 @@ db:
63
67
 
64
68
  logger:
65
69
  level: info
66
- useFileLogger: true # To use or not to use logging to a file
70
+ useFileLogger: {{logger.useFileLogger}} # To use or not to use logging to a file
71
+ dir: '{{logger.dir}}'
67
72
 
68
73
  mcp:
69
74
  transportType: http # stdio | http
@@ -77,12 +82,17 @@ mcp:
77
82
 
78
83
  swagger:
79
84
  servers: # An array of servers that will be added to swagger docs
80
- - url: http://localhost:{{port}}
81
- description: "Development server (localhost)"
82
- - url: http://0.0.0.0:{{port}}
83
- description: "Development server (all interfaces)"
85
+ # - url: http://localhost:9020
86
+ # description: "Development server (localhost)"
87
+ # - url: http://0.0.0.0:9020
88
+ # description: "Development server (all interfaces)"
89
+ # - url: http://<prod_server_host_or_ip>:{{port}}
90
+ # description: "PROD server"
91
+ - url: https://{{mcp.domain}}
92
+ description: "PROD server"
84
93
 
85
- uiColor: # Font color of the header and a number of interface elements on the ABOUT page
94
+ uiColor:
95
+ # Font color of the header and a number of interface elements on the ABOUT page
86
96
  primary: '#0f65dc'
87
97
 
88
98
  webServer:
@@ -40,7 +40,7 @@ server {
40
40
  }
41
41
 
42
42
  listen 443 ssl http2;
43
- include snippets/ssl-wildcard-finam-ru.conf;
43
+ include {{ssl-wildcard.conf.rel.path}};
44
44
  include snippets/ssl-params.conf;
45
45
  ssl_protocols TLSv1.3;
46
46
  }
@@ -0,0 +1,18 @@
1
+ ssl_protocols TLSv1.2;
2
+ ssl_prefer_server_ciphers on;
3
+ ssl_dhparam /etc/nginx/dhparam.pem;
4
+ ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
5
+ ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0
6
+ ssl_session_timeout 10m;
7
+ ssl_session_cache shared:SSL:10m;
8
+ ssl_session_tickets off; # Requires nginx >= 1.5.9
9
+ ssl_stapling off; # Requires nginx >= 1.3.7
10
+ ssl_stapling_verify on; # Requires nginx => 1.3.7
11
+ resolver 8.8.8.8 8.8.4.4 valid=300s;
12
+ resolver_timeout 5s;
13
+ # Disable strict transport security for now. You can uncomment the following
14
+ # line if you understand the implications.
15
+ # add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
16
+ add_header X-Frame-Options DENY;
17
+ add_header X-Content-Type-Options nosniff;
18
+ add_header X-XSS-Protection "1; mode=block";
@@ -0,0 +1,3 @@
1
+ # Provide the correct paths to the certificate and private key
2
+ ssl_certificate /etc/nginx/cert/wildcard.crt;
3
+ ssl_certificate_key /etc/nginx/cert/wildcard.key;
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * srv.cjs - Unified systemd service management script (Node.js version)
5
+ * Analog of deploy/srv.sh
6
+ * Can be run from project root or from deploy/ directory
7
+ */
8
+
9
+ /* Colors for output */
10
+ const c = '\x1b[1;36m', lc = '\x1b[0;36m';
11
+ const g = '\x1b[1;32m', lg = '\x1b[0;32m';
12
+ const m = '\x1b[1;35m', lm = '\x1b[0;35m';
13
+ const r = '\x1b[1;31m', lr = '\x1b[0;31m';
14
+ const y = '\x1b[1;33m', ly = '\x1b[0;33m';
15
+ const c0 = '\x1b[0m';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const { execSync, spawn, spawnSync } = require('child_process');
21
+
22
+ /* Script configuration */
23
+ const SCRIPT_DIR = __dirname; // deploy/
24
+ const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..'); // Project root
25
+ process.chdir(PROJECT_ROOT);
26
+
27
+ let SERVICE_NAME = '';
28
+ let NODE_VERSION = '';
29
+ let PORT = '';
30
+ let COMMAND = '';
31
+
32
+ const trim = (v) => String(v || '').trim();
33
+
34
+ /* Helpers */
35
+ function log (...args) { console.log(...args); }
36
+
37
+ function err (...args) { console.error(...args); }
38
+
39
+ function sh (cmd, opts = {}) {
40
+ try {
41
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
42
+ } catch (e) {
43
+ // Attach stderr to error message for easier diagnosis
44
+ const stderr = e.stderr ? e.stderr.toString() : '';
45
+ const stdout = e.stdout ? e.stdout.toString() : '';
46
+ const msg = stderr || stdout || e.message;
47
+ const errObj = new Error(msg);
48
+ errObj.code = e.status;
49
+ throw errObj;
50
+ }
51
+ }
52
+
53
+ function showUsage () {
54
+ const usage = `
55
+ Usage:
56
+ ${process.argv[1]} i|install [-n <service_name>] [-v <node_version>]
57
+ ${process.argv[1]} d|delete [-n <service_name>] [-p <port>]
58
+ ${process.argv[1]} r|reinstall [-n <service_name>] [-p <port>] [-v <node_version>]
59
+
60
+ Commands:
61
+ i, install - Install and start systemd service
62
+ d, delete - Stop and remove systemd service
63
+ r, reinstall - Reinstall service (delete + install)
64
+
65
+ Options:
66
+ -n <name> - Alternative service name (default: from package.json)
67
+ -v <version> - Node.js version (default: auto-detected)
68
+ -p <port> - Port number for service cleanup (default: auto-detected)
69
+
70
+ Working directories:
71
+ Script location: ${SCRIPT_DIR}
72
+ Project root: ${PROJECT_ROOT}
73
+ `.trim();
74
+ log(usage);
75
+ }
76
+
77
+ function serviceExists (name) {
78
+ try {
79
+ const cmd = `systemctl list-units --all -t service --full --no-legend ${name}.service`;
80
+ const out = sh(cmd, { shell: '/bin/bash' });
81
+ const re = new RegExp(`\\b${name}\\.service\\b`);
82
+ return re.test(out);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function readFileSafe (p) {
89
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
90
+ }
91
+
92
+ function detectNodeVersion () {
93
+ let version = '';
94
+ if (NODE_VERSION) {
95
+ version = NODE_VERSION;
96
+ err(`${c}**** Using provided Node.js version: ${g}${version}${c} ****${c0}`);
97
+ } else {
98
+ const envrc = path.join(PROJECT_ROOT, '.envrc');
99
+ if (fs.existsSync(envrc)) {
100
+ const content = readFileSafe(envrc);
101
+ const m = content.match(/^\s*nvm\s+use\s+([0-9]+\.[0-9]+\.[0-9]+)/m);
102
+ if (m) {
103
+ version = m[1];
104
+ err(`${c}**** Found Node.js version in .envrc: ${g}${version}${c} ****${c0}`);
105
+ }
106
+ }
107
+ }
108
+ if (!version) {
109
+ try {
110
+ const v = sh('node -v 2>/dev/null', { shell: '/bin/bash' });
111
+ version = v.replace(/^v/, '');
112
+ if (version) {
113
+ err(`${c}**** Using current Node.js version: ${g}${version}${c} ****${c0}`);
114
+ }
115
+ } catch {
116
+ // ignore
117
+ }
118
+ }
119
+ if (!version) {
120
+ err(`${r}**** Error: Could not detect Node.js version ****${c0}`);
121
+ process.exit(1);
122
+ }
123
+ return version;
124
+ }
125
+
126
+ function findNodePath (version) {
127
+ // Try NVM path first
128
+ const nvmPath = path.join(os.homedir(), '.nvm', 'versions', 'node', `v${version}`, 'bin', 'node');
129
+ if (fs.existsSync(nvmPath)) {
130
+ err(`${c}**** Found Node.js at NVM path: ${g}${nvmPath}${c} ****${c0}`);
131
+ return nvmPath;
132
+ }
133
+ // Try system node
134
+ try {
135
+ const whichNode = sh('which node 2>/dev/null', { shell: '/bin/bash' });
136
+ if (whichNode) {
137
+ let currentVersion = '';
138
+ try { currentVersion = sh('node -v 2>/dev/null', { shell: '/bin/bash' }).replace(/^v/, ''); } catch {}
139
+ if (currentVersion === version) {
140
+ err(`${c}**** Found Node.js at system path: ${g}${whichNode}${c} ****${c0}`);
141
+ } else {
142
+ const warn = `
143
+ ${y}**** Warning: System Node.js version (${currentVersion}) differs from target (${version}) ****${c0}
144
+ ${c}**** Using system path anyway: ${g}${whichNode}${c} ****${c0}
145
+ `.trim();
146
+ err(warn);
147
+ }
148
+ return whichNode;
149
+ }
150
+ } catch {
151
+ // ignore
152
+ }
153
+ err(`${r}**** Error: Could not find Node.js binary ****${c0}`);
154
+ process.exit(1);
155
+ }
156
+
157
+ function parsePackageJson (field) {
158
+ const packageFile = path.join(PROJECT_ROOT, 'package.json');
159
+ if (!fs.existsSync(packageFile)) {
160
+ err(`${r}**** Error: package.json not found at ${packageFile} ****${c0}`);
161
+ process.exit(1);
162
+ }
163
+ try {
164
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
165
+ const value = pkg[field] || '';
166
+ if (!value) throw new Error('empty');
167
+ return value;
168
+ } catch {
169
+ err(`${r}**** Error: Could not parse ${field} from package.json ****${c0}`);
170
+ process.exit(1);
171
+ }
172
+ }
173
+
174
+ // Parse .env in project root to extract variables
175
+ function parseDotEnvVars () {
176
+ const envPath = path.join(PROJECT_ROOT, '.env');
177
+ const result = {};
178
+ if (!fs.existsSync(envPath)) return result;
179
+ try {
180
+ const content = fs.readFileSync(envPath, 'utf8');
181
+ // Simple line parser, ignores comments and empty lines
182
+ for (const line of content.split(/\r?\n/)) {
183
+ if (!line || /^\s*#/.test(line)) continue;
184
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
185
+ if (!m) continue;
186
+ const key = m[1];
187
+ // remove surrounding quotes if present
188
+ let val = m[2];
189
+ const q = val[0];
190
+ if ((q === '"' || q === '\'') && val[val.length - 1] === q) {
191
+ val = val.slice(1, -1);
192
+ }
193
+ result[key] = val;
194
+ }
195
+ } catch {}
196
+ return result;
197
+ }
198
+
199
+ // Return full service name aligned with update.cjs logic: base name plus optional "--<instance>"
200
+ function getServiceName () {
201
+ // Highest priority: CLI parameter (-n)
202
+ let baseName = trim(SERVICE_NAME);
203
+ if (baseName) {
204
+ return baseName;
205
+ }
206
+ let instance = '';
207
+
208
+ const envVars = parseDotEnvVars();
209
+
210
+ // If base name not provided via CLI, try from .env, then from package.json
211
+ if (!baseName) {
212
+ baseName = trim(envVars.SERVICE_NAME);
213
+ }
214
+ if (!baseName) {
215
+ baseName = parsePackageJson('name');
216
+ }
217
+
218
+ // Instance suffix from .env if present
219
+ const rawInstance = trim(envVars.SERVICE_INSTANCE);
220
+ if (rawInstance) {
221
+ instance = `--${rawInstance}`;
222
+ }
223
+
224
+ return `${baseName}${instance}`;
225
+ }
226
+
227
+ async function detectPort () {
228
+ if (PORT) return PORT;
229
+ // Try to load config from project root
230
+ try {
231
+ // Ensure module resolution relative to project root
232
+ const createRequire = require('module').createRequire;
233
+ const projectRequire = createRequire(path.join(PROJECT_ROOT, 'package.json'));
234
+ let cfg;
235
+ try {
236
+ cfg = projectRequire('config');
237
+ } catch (e) {
238
+ // Try dynamic import
239
+ cfg = await new Function('p', 'return import(p)')('config');
240
+ cfg = cfg.default || cfg;
241
+ }
242
+ const port = cfg?.webServer?.port;
243
+ if (port && String(port).match(/^[0-9]{2,5}$/)) return String(port);
244
+ } catch (e) {
245
+ // ignore, will fail below
246
+ }
247
+ err(`${r}**** Error: Could not detect port from config ****${c0}`);
248
+ process.exit(1);
249
+ }
250
+
251
+ function generateUnitFile (serviceName, nodePath, mainFile) {
252
+ const workingDir = PROJECT_ROOT;
253
+ const serviceFile = `/etc/systemd/system/${serviceName}.service`;
254
+ const content = `
255
+ [Unit]
256
+ Description=${serviceName}
257
+ After=network.target
258
+ # https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#StartLimitIntervalSec=interval
259
+ StartLimitIntervalSec=0
260
+
261
+ [Service]
262
+ User=root
263
+ WorkingDirectory=${workingDir}
264
+ EnvironmentFile=${workingDir}/.env
265
+ ExecStart=${nodePath} ${mainFile}
266
+ Restart=always
267
+ RestartSec=3
268
+
269
+ [Install]
270
+ WantedBy=multi-user.target
271
+ `.trimStart();
272
+ fs.writeFileSync(serviceFile, content, 'utf8');
273
+ const info = `
274
+ ${c}**** Generated unit file for ${g}${serviceName}${c} ****${c0}
275
+ ${lc} WorkingDirectory: ${g}${workingDir}${c0}
276
+ ${lc} ExecStart: ${g}${nodePath} ${mainFile}${c0}
277
+ ${lc} View Service file: ${g}cat ${serviceFile}${c0}
278
+ `.trim();
279
+ err(info);
280
+ }
281
+
282
+ function systemctl (args, inherit = false) {
283
+ if (inherit) {
284
+ const p = spawn('systemctl', args.split(/\s+/).filter(Boolean), { stdio: 'inherit' });
285
+ return new Promise((resolve, reject) => {
286
+ p.on('exit', code => code === 0 ? resolve() : reject(new Error(`systemctl ${args} exited with ${code}`)));
287
+ });
288
+ } else {
289
+ sh(`systemctl ${args}`);
290
+ }
291
+ }
292
+
293
+ async function installService () {
294
+ log(`${c}**** Installing service ****${c0}`);
295
+ const nodeVersion = detectNodeVersion();
296
+ const nodePath = findNodePath(nodeVersion);
297
+ const mainFile = parsePackageJson('main');
298
+ const serviceName = getServiceName();
299
+
300
+ const cfgInfo = `
301
+ ${c}**** Service configuration ****${c0}
302
+ ${lc} Service name: ${g}${serviceName}${c0}
303
+ ${lc} Node.js version: ${g}${nodeVersion}${c0}
304
+ ${lc} Node.js path: ${g}${nodePath}${c0}
305
+ ${lc} Main file: ${g}${mainFile}${c0}
306
+ ${lc} Project root: ${g}${PROJECT_ROOT}${c0}
307
+ `.trim();
308
+ err(cfgInfo);
309
+
310
+ if (serviceExists(serviceName)) {
311
+ err(`${c}**** Service ${g}${serviceName}${c} already installed ****${c0}`);
312
+ return;
313
+ }
314
+
315
+ generateUnitFile(serviceName, nodePath, mainFile);
316
+
317
+ // Reload systemd and enable service
318
+ sh('systemctl daemon-reload');
319
+ try {
320
+ sh(`systemctl enable --now ${serviceName}`);
321
+ } catch (e) {
322
+ const msg = `
323
+ ${r}**** Error: Failed to install service ${serviceName} ****${c0}
324
+ ${e.message || String(e)}
325
+ `.trim();
326
+ err(msg);
327
+ process.exit(1);
328
+ }
329
+
330
+ const post = `
331
+ ${c}**** Service ${g}${serviceName}${c} installed and started ****${c0}
332
+
333
+ ${m}View status: ${y}systemctl -l status ${serviceName}${c0}
334
+ ${m}View logs: ${y}journalctl -o cat -xefu ${serviceName}${c0}
335
+ `.trim();
336
+ log(post);
337
+ }
338
+
339
+ async function deleteService () {
340
+ const serviceName = getServiceName();
341
+ const port = await detectPort();
342
+ log(`${c}**** Removing service ${g}${serviceName}${c} listening on port ${g}${port}${c} ****${c0}`);
343
+
344
+ if (!serviceExists(serviceName)) {
345
+ log(`${c}**** Service ${g}${serviceName}${c} not found ****${c0}`);
346
+ return;
347
+ }
348
+
349
+ try { sh(`systemctl stop ${serviceName}`); } catch {}
350
+ try { sh(`systemctl disable ${serviceName}`); } catch {}
351
+
352
+ try { fs.unlinkSync(`/etc/systemd/system/${serviceName}.service`); } catch {}
353
+
354
+ // Kill any remaining process on the port
355
+ try {
356
+ const out = sh(`lsof -i tcp:${port} 2>/dev/null | grep ${port} | awk '{print $2}' | head -1`, { shell: '/bin/bash' });
357
+ if (out) {
358
+ log(`${c}**** Killing process ${g}${out}${c} on port ${g}${port}${c} ****${c0}`);
359
+ try { sh(`kill -9 ${out}`); } catch {}
360
+ }
361
+ } catch {}
362
+
363
+ try { sh('systemctl daemon-reload'); } catch {}
364
+ log(`${c}**** Service ${g}${serviceName}${c} removed ****${c0}`);
365
+ }
366
+
367
+ async function reinstallService () {
368
+ log(`${c}**** Reinstalling service ****${c0}`);
369
+ await deleteService();
370
+ await installService();
371
+ const serviceName = getServiceName();
372
+ log(`${c}**** Service ${g}${serviceName}${c} reinstalled ****${c0}`);
373
+
374
+ try {
375
+ await systemctl(`status ${serviceName}`, true);
376
+ } catch {}
377
+ log(`\n${m}Following logs (Ctrl+C to exit): ${y}journalctl -o cat -xefu ${serviceName}${c0}`);
378
+ // Follow logs until user interrupts
379
+ const p = spawn('journalctl', ['-o', 'cat', '-xefu', serviceName], { stdio: 'inherit' });
380
+ await new Promise(resolve => p.on('exit', resolve));
381
+ }
382
+
383
+ /* Argument parsing */
384
+ async function main () {
385
+ if (process.argv.length <= 2) {
386
+ showUsage();
387
+ process.exit(1);
388
+ }
389
+
390
+ const args = process.argv.slice(2);
391
+ const cmd = args.shift();
392
+ switch (cmd) {
393
+ case 'i':
394
+ case 'install':
395
+ COMMAND = 'install';
396
+ break;
397
+ case 'd':
398
+ case 'delete':
399
+ COMMAND = 'delete';
400
+ break;
401
+ case 'r':
402
+ case 'reinstall':
403
+ COMMAND = 'reinstall';
404
+ break;
405
+ default:
406
+ err(`${r}**** Error: Unknown command '${cmd}' ****${c0}`);
407
+ showUsage();
408
+ process.exit(1);
409
+ }
410
+
411
+ // Parse options
412
+ while (args.length > 0) {
413
+ const a = args.shift();
414
+ switch (a) {
415
+ case '-n':
416
+ SERVICE_NAME = args.shift() || '';
417
+ break;
418
+ case '-v':
419
+ NODE_VERSION = args.shift() || '';
420
+ break;
421
+ case '-p':
422
+ PORT = args.shift() || '';
423
+ break;
424
+ default:
425
+ err(`${r}**** Error: Unknown option '${a}' ****${c0}`);
426
+ showUsage();
427
+ process.exit(1);
428
+ }
429
+ }
430
+
431
+ switch (COMMAND) {
432
+ case 'install':
433
+ await installService();
434
+ break;
435
+ case 'delete':
436
+ await deleteService();
437
+ break;
438
+ case 'reinstall':
439
+ await reinstallService();
440
+ break;
441
+ }
442
+ }
443
+
444
+ main().then(() => {
445
+ process.exit(0);
446
+ }).catch(e => {
447
+ err(String(e && e.message ? e.message : e));
448
+ process.exit(1);
449
+ });