@rexaray008/nodeproxy 1.0.1 → 2.0.1

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/cli.js CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  const { loadConfig, saveConfig, ensureConfig, DEFAULT_CONFIG_NAME } = require('../lib/config');
5
5
  const { startServer } = require('../lib/server');
6
+ const cf = require('../lib/cloudflare');
7
+ const pm2 = require('../lib/pm2');
6
8
 
7
9
  function usage() {
8
10
  console.log(`
@@ -12,9 +14,43 @@ Usage:
12
14
  nodeproxy start [--config <file>] Start the proxy (auto-creates a
13
15
  default config on first run)
14
16
  nodeproxy init [--config <file>] Just create the config file
15
- nodeproxy add <host> <target> [--config <file>]
17
+ nodeproxy add <host> <target> [--port <n>] [--config <file>]
16
18
  Add/update a route, e.g.
17
19
  nodeproxy add example.com http://localhost:4000
20
+ Targets the first listener unless
21
+ --port picks a specific one.
22
+ nodeproxy listen <port> [--config <file>]
23
+ Add a brand new listener on a new
24
+ port, e.g. nodeproxy listen 9090
25
+ (then "nodeproxy add ... --port 9090")
26
+
27
+ nodeproxy tunnel setup --domain <domain> [--name <name>] [--port <n>]
28
+ One-time: install cloudflared if
29
+ needed, log in (opens a browser,
30
+ you click Authorize once), create
31
+ a tunnel, and point <domain> at
32
+ your local port (default 8080).
33
+ nodeproxy tunnel start [--name <name>] Run just the tunnel.
34
+ nodeproxy tunnel doctor --domain <domain>
35
+ Diagnose "works locally but not
36
+ on other devices" issues.
37
+
38
+ nodeproxy app add <name> --cmd "<command>" [--cwd <dir>]
39
+ Register your own project to be
40
+ started/kept alive automatically
41
+ (via pm2), e.g.
42
+ nodeproxy app add myapp --cmd "npm start" --cwd ./my-project
43
+ nodeproxy app list Show registered/running apps.
44
+ nodeproxy app logs <name> Tail logs for a registered app.
45
+ nodeproxy app stop <name> Stop a registered app.
46
+
47
+ nodeproxy start [--config <file>] [--tunnel] [--name <name>]
48
+ Starts any registered apps (via
49
+ pm2), then the proxy, then the
50
+ tunnel if --tunnel is given.
51
+ This is the one command that
52
+ brings everything up together.
53
+
18
54
  nodeproxy help Show this message
19
55
 
20
56
  One-command quick start:
@@ -46,29 +82,192 @@ function main() {
46
82
  if (cmd === 'add') {
47
83
  const host = args[1];
48
84
  const target = args[2];
85
+ const portFlag = getFlag(args, '--port');
49
86
  if (!host || !target) {
50
- console.error('Usage: nodeproxy add <host> <target>');
87
+ console.error('Usage: nodeproxy add <host> <target> [--port <n>]');
51
88
  process.exit(1);
52
89
  }
53
90
  const { config, file } = loadConfig(configPath);
54
- const existing = config.routes.find(r => r.host === host);
91
+ const listener = portFlag
92
+ ? config.servers.find(s => String(s.port) === String(portFlag))
93
+ : config.servers[0];
94
+ if (!listener) {
95
+ console.error(`No listener on port ${portFlag}. Create one first with: nodeproxy listen ${portFlag}`);
96
+ process.exit(1);
97
+ }
98
+ const existing = listener.routes.find(r => r.host === host);
55
99
  if (existing) {
56
100
  existing.targets = [target];
57
101
  } else {
58
- config.routes.push({ host, targets: [target], staticDir: null });
102
+ listener.routes.push({ host, targets: [target], staticDir: null });
103
+ }
104
+ saveConfig(config, configPath);
105
+ console.log(`[nodeproxy] Route added: ${host} -> ${target} on port ${listener.port} (${file})`);
106
+ return;
107
+ }
108
+
109
+ if (cmd === 'listen') {
110
+ const port = Number(args[1]);
111
+ if (!port) {
112
+ console.error('Usage: nodeproxy listen <port>');
113
+ process.exit(1);
114
+ }
115
+ const { config, file } = loadConfig(configPath);
116
+ if (config.servers.some(s => s.port === port)) {
117
+ console.error(`Port ${port} already has a listener.`);
118
+ process.exit(1);
59
119
  }
120
+ config.servers.push({
121
+ port,
122
+ https: { enabled: false, key: '', cert: '' },
123
+ routes: []
124
+ });
60
125
  saveConfig(config, configPath);
61
- console.log(`[nodeproxy] Route added: ${host} -> ${target} (${file})`);
126
+ console.log(`[nodeproxy] New listener added on port ${port} (${file}). Now add routes to it with: nodeproxy add <host> <target> --port ${port}`);
62
127
  return;
63
128
  }
64
129
 
130
+ if (cmd === 'tunnel') {
131
+ const sub = args[1];
132
+ const domain = getFlag(args, '--domain');
133
+ const name = getFlag(args, '--name') || (domain ? domain.replace(/\./g, '-') : 'nodeproxy-tunnel');
134
+ const port = Number(getFlag(args, '--port')) || 8080;
135
+
136
+ if (sub === 'setup') {
137
+ if (!domain) {
138
+ console.error('Usage: nodeproxy tunnel setup --domain <yourdomain.com> [--name <name>] [--port <n>]');
139
+ console.error('(Your domain must already be added to your Cloudflare account.)');
140
+ process.exit(1);
141
+ }
142
+ if (!cf.ensureInstalled()) process.exit(1);
143
+ if (!cf.isLoggedIn()) {
144
+ cf.login();
145
+ } else {
146
+ console.log('[tunnel] Already logged in to Cloudflare on this machine.');
147
+ }
148
+ if (!cf.tunnelExists(name)) {
149
+ cf.createTunnel(name);
150
+ } else {
151
+ console.log(`[tunnel] Tunnel "${name}" already exists — reusing it.`);
152
+ }
153
+ const tunnelId = cf.getTunnelId(name);
154
+ cf.writeIngressConfig(name, tunnelId, domain, port);
155
+ cf.routeDns(name, domain);
156
+ console.log(`\n[tunnel] Done! "${domain}" now points at your local port ${port}.`);
157
+ console.log(`[tunnel] From now on, just run: nodeproxy start --tunnel --name ${name}\n`);
158
+ return;
159
+ }
160
+
161
+ if (sub === 'start') {
162
+ cf.runTunnel(name);
163
+ return;
164
+ }
165
+
166
+ if (sub === 'doctor') {
167
+ if (!domain) {
168
+ console.error('Usage: nodeproxy tunnel doctor --domain <domain>');
169
+ process.exit(1);
170
+ }
171
+ cf.diagnose(domain).then(cf.printDiagnosis);
172
+ return;
173
+ }
174
+
175
+ console.error('Usage: nodeproxy tunnel setup --domain <domain> | nodeproxy tunnel start [--name <name>] | nodeproxy tunnel doctor --domain <domain>');
176
+ process.exit(1);
177
+ }
178
+
179
+ if (cmd === 'app') {
180
+ const sub = args[1];
181
+
182
+ if (sub === 'add') {
183
+ const name = args[2];
184
+ const appCmd = getFlag(args, '--cmd');
185
+ const cwd = getFlag(args, '--cwd');
186
+ if (!name || !appCmd) {
187
+ console.error('Usage: nodeproxy app add <name> --cmd "<command>" [--cwd <dir>]');
188
+ process.exit(1);
189
+ }
190
+ const { config, file } = loadConfig(configPath);
191
+ const existing = config.apps.find(a => a.name === name);
192
+ const entry = { name, cmd: appCmd, cwd: cwd || process.cwd() };
193
+ if (existing) {
194
+ Object.assign(existing, entry);
195
+ } else {
196
+ config.apps.push(entry);
197
+ }
198
+ saveConfig(config, configPath);
199
+ console.log(`[nodeproxy] Registered app "${name}" (${appCmd}) in ${file}`);
200
+ console.log('[nodeproxy] It will now start automatically with "nodeproxy start".');
201
+ return;
202
+ }
203
+
204
+ if (sub === 'list') {
205
+ const { config } = loadConfig(configPath);
206
+ if (config.apps.length === 0) {
207
+ console.log('No apps registered. Add one with: nodeproxy app add <name> --cmd "<command>"');
208
+ return;
209
+ }
210
+ const running = pm2.isInstalled() ? pm2.listApps() : [];
211
+ for (const a of config.apps) {
212
+ const proc = running.find(p => p.name === a.name);
213
+ const status = proc ? proc.pm2_env.status : 'not running';
214
+ console.log(`- ${a.name}: ${a.cmd} (${a.cwd}) [${status}]`);
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (sub === 'stop') {
220
+ const name = args[2];
221
+ if (!name) {
222
+ console.error('Usage: nodeproxy app stop <name>');
223
+ process.exit(1);
224
+ }
225
+ pm2.stopApp(name);
226
+ return;
227
+ }
228
+
229
+ if (sub === 'logs') {
230
+ const name = args[2];
231
+ if (!name) {
232
+ console.error('Usage: nodeproxy app logs <name>');
233
+ process.exit(1);
234
+ }
235
+ pm2.logs(name);
236
+ return;
237
+ }
238
+
239
+ console.error('Usage: nodeproxy app add <name> --cmd "<command>" [--cwd <dir>] | list | stop <name> | logs <name>');
240
+ process.exit(1);
241
+ }
242
+
65
243
  if (cmd === 'start') {
66
244
  const { config, file } = loadConfig(configPath);
67
245
  console.log(`[nodeproxy] Using config: ${file}`);
68
246
  if (file.endsWith(DEFAULT_CONFIG_NAME)) {
69
247
  console.log('[nodeproxy] (edit this file, or use "nodeproxy add <host> <target>", to change routing)');
70
248
  }
249
+
250
+ if (config.apps && config.apps.length > 0) {
251
+ if (!pm2.ensureInstalled()) {
252
+ console.error('[nodeproxy] Could not set up pm2 — registered apps will not be started automatically.');
253
+ } else {
254
+ for (const app of config.apps) {
255
+ pm2.startApp(app);
256
+ }
257
+ pm2.save();
258
+ }
259
+ }
260
+
71
261
  startServer(config);
262
+
263
+ if (args.includes('--tunnel')) {
264
+ const name = getFlag(args, '--name') || 'nodeproxy-tunnel';
265
+ if (!cf.isInstalled()) {
266
+ console.error('[tunnel] cloudflared is not installed. Run "nodeproxy tunnel setup --domain <domain>" first.');
267
+ return;
268
+ }
269
+ cf.runTunnel(name);
270
+ }
72
271
  return;
73
272
  }
74
273
 
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const { execSync, spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const CF_DIR = path.join(os.homedir(), '.cloudflared');
9
+
10
+ function isInstalled() {
11
+ try {
12
+ execSync('cloudflared --version', { stdio: 'ignore' });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function installInstructions() {
20
+ const platform = os.platform();
21
+ if (platform === 'darwin') {
22
+ return 'brew install cloudflared';
23
+ }
24
+ if (platform === 'linux') {
25
+ return [
26
+ '# Debian/Ubuntu (x86_64):',
27
+ 'curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb && sudo dpkg -i cloudflared.deb',
28
+ '',
29
+ '# Or as a plain binary (any distro, x86_64):',
30
+ 'sudo curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && sudo chmod +x /usr/local/bin/cloudflared'
31
+ ].join('\n');
32
+ }
33
+ if (platform === 'win32') {
34
+ return 'winget install --id Cloudflare.cloudflared\n# or: choco install cloudflared';
35
+ }
36
+ return 'See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
37
+ }
38
+
39
+ // Best-effort automatic install. Returns true on success.
40
+ function tryAutoInstall() {
41
+ const platform = os.platform();
42
+ try {
43
+ if (platform === 'darwin') {
44
+ console.log('[tunnel] Installing cloudflared via Homebrew...');
45
+ execSync('brew install cloudflared', { stdio: 'inherit' });
46
+ return isInstalled();
47
+ }
48
+ if (platform === 'linux') {
49
+ console.log('[tunnel] Installing cloudflared binary to /usr/local/bin ...');
50
+ execSync(
51
+ 'curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared && ' +
52
+ 'chmod +x /tmp/cloudflared && ' +
53
+ '(sudo mv /tmp/cloudflared /usr/local/bin/cloudflared || mv /tmp/cloudflared /usr/local/bin/cloudflared)',
54
+ { stdio: 'inherit' }
55
+ );
56
+ return isInstalled();
57
+ }
58
+ } catch (err) {
59
+ console.warn('[tunnel] Automatic install failed:', err.message);
60
+ }
61
+ return false;
62
+ }
63
+
64
+ function ensureInstalled() {
65
+ if (isInstalled()) return true;
66
+ console.log('[tunnel] cloudflared not found — attempting automatic install...');
67
+ if (tryAutoInstall()) {
68
+ console.log('[tunnel] cloudflared installed successfully.');
69
+ return true;
70
+ }
71
+ console.error('\n[tunnel] Could not auto-install cloudflared. Install it manually with:\n');
72
+ console.error(installInstructions());
73
+ console.error('\nThen re-run this command.\n');
74
+ return false;
75
+ }
76
+
77
+ function isLoggedIn() {
78
+ return fs.existsSync(path.join(CF_DIR, 'cert.pem'));
79
+ }
80
+
81
+ // Opens a browser for the one-time Cloudflare account authorization.
82
+ // This step is inherently interactive (OAuth) and cannot be scripted away.
83
+ function login() {
84
+ console.log('\n[tunnel] Opening your browser to authorize this machine with your Cloudflare account.');
85
+ console.log('[tunnel] (One-time step. After this, everything is fully automatic.)\n');
86
+ execSync('cloudflared tunnel login', { stdio: 'inherit' });
87
+ }
88
+
89
+ function tunnelExists(name) {
90
+ try {
91
+ const out = execSync('cloudflared tunnel list -o json', { encoding: 'utf8' });
92
+ const list = JSON.parse(out);
93
+ return list.some(t => t.name === name);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function createTunnel(name) {
100
+ console.log(`[tunnel] Creating tunnel "${name}"...`);
101
+ execSync(`cloudflared tunnel create ${name}`, { stdio: 'inherit' });
102
+ }
103
+
104
+ function getTunnelId(name) {
105
+ const out = execSync('cloudflared tunnel list -o json', { encoding: 'utf8' });
106
+ const list = JSON.parse(out);
107
+ const match = list.find(t => t.name === name);
108
+ return match ? match.id : null;
109
+ }
110
+
111
+ function routeDns(name, domain) {
112
+ console.log(`[tunnel] Pointing ${domain} at tunnel "${name}"...`);
113
+ execSync(`cloudflared tunnel route dns ${name} ${domain}`, { stdio: 'inherit' });
114
+ }
115
+
116
+ // Writes the cloudflared ingress config that maps the public domain
117
+ // to the local nodeproxy port.
118
+ function writeIngressConfig(name, tunnelId, domain, localPort) {
119
+ const credFile = path.join(CF_DIR, `${tunnelId}.json`);
120
+ const configYml = [
121
+ `tunnel: ${tunnelId}`,
122
+ `credentials-file: ${credFile}`,
123
+ 'ingress:',
124
+ ` - hostname: ${domain}`,
125
+ ` service: http://localhost:${localPort}`,
126
+ ' - service: http_status:404',
127
+ ''
128
+ ].join('\n');
129
+ const configPath = path.join(CF_DIR, `${name}.yml`);
130
+ fs.writeFileSync(configPath, configYml);
131
+ return configPath;
132
+ }
133
+
134
+ // Runs the tunnel as a background child process. Returns the child so
135
+ // the caller can manage its lifecycle alongside the local proxy.
136
+ function runTunnel(name) {
137
+ const configPath = path.join(CF_DIR, `${name}.yml`);
138
+ console.log(`[tunnel] Starting cloudflared tunnel "${name}"...`);
139
+ const child = spawn('cloudflared', ['tunnel', '--config', configPath, 'run', name], {
140
+ stdio: 'inherit'
141
+ });
142
+ child.on('exit', (code) => {
143
+ console.log(`[tunnel] cloudflared exited with code ${code}`);
144
+ });
145
+ return child;
146
+ }
147
+
148
+ const dns = require('dns').promises;
149
+
150
+ // Diagnoses the single most common cause of "works on my machine but not
151
+ // on other devices/networks": the DNS record Cloudflare created for the
152
+ // tunnel is set to "DNS only" (grey cloud) instead of "Proxied" (orange
153
+ // cloud). Tunnel hostnames only resolve publicly when proxied — an
154
+ // unproxied record either fails to resolve for everyone else, or resolves
155
+ // to an address only reachable from networks that already have a route to
156
+ // it (which can look like "it works for me" if you're testing from
157
+ // somewhere with cached/local resolution).
158
+ async function diagnose(domain) {
159
+ const results = { domain, checks: [] };
160
+
161
+ let cname = null;
162
+ try {
163
+ const records = await dns.resolveCname(domain);
164
+ cname = records[0];
165
+ results.checks.push({ ok: true, label: 'CNAME record found', detail: cname });
166
+ } catch (err) {
167
+ results.checks.push({ ok: false, label: 'No CNAME record found', detail: err.code });
168
+ }
169
+
170
+ if (cname && !cname.includes('cfargotunnel.com')) {
171
+ results.checks.push({
172
+ ok: false,
173
+ label: 'CNAME does not point at a Cloudflare Tunnel',
174
+ detail: `Points at "${cname}" instead of a *.cfargotunnel.com address. Re-run "nodeproxy tunnel setup" to fix routing.`
175
+ });
176
+ }
177
+
178
+ try {
179
+ const addrs = await dns.resolve4(domain);
180
+ results.checks.push({ ok: true, label: 'Resolves to an IP address publicly', detail: addrs.join(', ') });
181
+ results.publiclyResolvable = true;
182
+ } catch (err) {
183
+ results.publiclyResolvable = false;
184
+ results.checks.push({
185
+ ok: false,
186
+ label: 'Does NOT resolve to an IP address publicly',
187
+ detail: `${err.code}. This is almost always because the DNS record is set to "DNS only" (grey cloud) in the Cloudflare dashboard instead of "Proxied" (orange cloud). Fix: Cloudflare dashboard → DNS → click the cloud icon next to this record so it turns orange, then wait ~1 minute and retry.`
188
+ });
189
+ }
190
+
191
+ return results;
192
+ }
193
+
194
+ function printDiagnosis(results) {
195
+ console.log(`\n[tunnel doctor] Checking ${results.domain} ...\n`);
196
+ for (const c of results.checks) {
197
+ console.log(`${c.ok ? '✓' : '✗'} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
198
+ }
199
+ if (results.publiclyResolvable) {
200
+ console.log('\n[tunnel doctor] DNS looks correctly configured for public access.');
201
+ console.log('If it still only works on your own machine, check: is "nodeproxy start --tunnel" actually running right now? The tunnel only carries traffic while that process is alive.\n');
202
+ } else {
203
+ console.log('\n[tunnel doctor] This is why other devices can\'t reach it — see the fix above.\n');
204
+ }
205
+ }
206
+
207
+ module.exports = {
208
+ isInstalled,
209
+ ensureInstalled,
210
+ installInstructions,
211
+ isLoggedIn,
212
+ login,
213
+ tunnelExists,
214
+ createTunnel,
215
+ getTunnelId,
216
+ routeDns,
217
+ writeIngressConfig,
218
+ runTunnel,
219
+ diagnose,
220
+ printDiagnosis,
221
+ CF_DIR
222
+ };
package/lib/config.js CHANGED
@@ -7,34 +7,64 @@ const DEFAULT_CONFIG_NAME = 'nodeproxy.config.json';
7
7
 
8
8
  function defaultConfig() {
9
9
  return {
10
- // Port the proxy itself listens on
11
- port: 8080,
10
+ // Request logging to stdout (applies to all listeners)
11
+ logging: true,
12
12
 
13
- // Optional HTTPS. Leave "enabled": false until you have real certs.
14
- https: {
15
- enabled: false,
16
- port: 8443,
17
- key: '', // path to privkey.pem
18
- cert: '' // path to fullchain.pem
19
- },
13
+ // Backend project(s) that nodeproxy will start and keep alive for you
14
+ // via pm2, so you don't have to manually run "npm start" yourself
15
+ // before the proxy has anything to forward to. Optional — leave this
16
+ // empty if you're already running your project some other way.
17
+ apps: [
18
+ // { "name": "myapp", "cmd": "npm start", "cwd": "/path/to/project" }
19
+ ],
20
20
 
21
- // Request logging to stdout
22
- logging: true,
21
+ // One entry per port you want the proxy to listen on.
22
+ // Each listener has its own independent set of host-based routes,
23
+ // so project A can live on 8080 and project B on 9090, fully
24
+ // separate from each other.
25
+ servers: [
26
+ {
27
+ port: 8080,
28
+ https: {
29
+ enabled: false,
30
+ key: '', // path to privkey.pem
31
+ cert: '' // path to fullchain.pem
32
+ },
33
+ // Virtual hosts / routes for THIS listener.
34
+ // "host" supports "*" as a catch-all for local testing.
35
+ // "targets" is a list -> automatic round-robin load balancing.
36
+ routes: [
37
+ {
38
+ host: '*',
39
+ targets: ['http://localhost:3000'],
40
+ // If set, static files are served from this directory when
41
+ // no upstream target responds (or as the sole behavior if
42
+ // targets is empty).
43
+ staticDir: null
44
+ }
45
+ ]
46
+ }
47
+ ]
48
+ };
49
+ }
23
50
 
24
- // Gzip/deflate pass-through is automatic (we just forward headers),
25
- // nothing to configure there.
51
+ // Accepts either the current multi-listener format ({ servers: [...] })
52
+ // or the old single-listener format ({ port, https, routes }) and always
53
+ // returns the multi-listener shape, so old config files keep working.
54
+ function normalizeConfig(config) {
55
+ if (Array.isArray(config.servers)) {
56
+ if (!Array.isArray(config.apps)) config.apps = [];
57
+ return config;
58
+ }
26
59
 
27
- // Virtual hosts / routes.
28
- // "host" supports "*" as a catch-all for local testing.
29
- // "targets" is a list -> automatic round-robin load balancing.
30
- routes: [
60
+ return {
61
+ logging: config.logging !== false,
62
+ apps: Array.isArray(config.apps) ? config.apps : [],
63
+ servers: [
31
64
  {
32
- host: '*',
33
- targets: ['http://localhost:3000'],
34
- // If set, static files are served from this directory when
35
- // no upstream target responds (or as the sole behavior if
36
- // targets is empty).
37
- staticDir: null
65
+ port: config.port || 8080,
66
+ https: config.https || { enabled: false, key: '', cert: '' },
67
+ routes: config.routes || []
38
68
  }
39
69
  ]
40
70
  };
@@ -58,7 +88,7 @@ function loadConfig(customPath) {
58
88
  const file = ensureConfig(customPath);
59
89
  const raw = fs.readFileSync(file, 'utf8');
60
90
  try {
61
- return { config: JSON.parse(raw), file };
91
+ return { config: normalizeConfig(JSON.parse(raw)), file };
62
92
  } catch (err) {
63
93
  throw new Error(`Failed to parse ${file}: ${err.message}`);
64
94
  }
@@ -70,4 +100,4 @@ function saveConfig(config, customPath) {
70
100
  return file;
71
101
  }
72
102
 
73
- module.exports = { defaultConfig, configPath, ensureConfig, loadConfig, saveConfig, DEFAULT_CONFIG_NAME };
103
+ module.exports = { defaultConfig, normalizeConfig, configPath, ensureConfig, loadConfig, saveConfig, DEFAULT_CONFIG_NAME };
package/lib/pm2.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { execSync, spawnSync } = require('child_process');
4
+
5
+ function isInstalled() {
6
+ try {
7
+ execSync('pm2 -v', { stdio: 'ignore' });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ function ensureInstalled() {
15
+ if (isInstalled()) return true;
16
+ console.log('[pm2] pm2 not found — installing globally via npm...');
17
+ try {
18
+ execSync('npm install -g pm2', { stdio: 'inherit' });
19
+ return isInstalled();
20
+ } catch (err) {
21
+ console.error('[pm2] Automatic install failed:', err.message);
22
+ console.error('[pm2] Install it manually with: npm install -g pm2');
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function listApps() {
28
+ try {
29
+ const out = execSync('pm2 jlist', { encoding: 'utf8' });
30
+ return JSON.parse(out);
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ function isRunning(name) {
37
+ return listApps().some(p => p.name === name && p.pm2_env && p.pm2_env.status === 'online');
38
+ }
39
+
40
+ // Starts (or restarts, if already registered) an app under pm2.
41
+ // { name, cmd, cwd } — cmd can be "npm start", "node server.js", etc.
42
+ function startApp({ name, cmd, cwd }) {
43
+ const exists = listApps().some(p => p.name === name);
44
+ if (exists) {
45
+ console.log(`[pm2] "${name}" already registered — restarting it.`);
46
+ const res = spawnSync('pm2', ['restart', name], { stdio: 'inherit' });
47
+ return res.status === 0;
48
+ }
49
+
50
+ console.log(`[pm2] Starting "${name}" (${cmd}) in ${cwd || process.cwd()} ...`);
51
+ // Split "npm start" style commands into interpreter + args so pm2 can
52
+ // manage them directly rather than as a raw shell string.
53
+ const parts = cmd.trim().split(/\s+/);
54
+ const bin = parts[0];
55
+ const args = parts.slice(1);
56
+
57
+ const pm2Args = ['start', bin, '--name', name];
58
+ if (cwd) pm2Args.push('--cwd', cwd);
59
+ if (args.length) pm2Args.push('--', ...args);
60
+
61
+ const res = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
62
+ return res.status === 0;
63
+ }
64
+
65
+ function stopApp(name) {
66
+ spawnSync('pm2', ['stop', name], { stdio: 'inherit' });
67
+ }
68
+
69
+ function deleteApp(name) {
70
+ spawnSync('pm2', ['delete', name], { stdio: 'inherit' });
71
+ }
72
+
73
+ function logs(name) {
74
+ spawnSync('pm2', ['logs', name], { stdio: 'inherit' });
75
+ }
76
+
77
+ // Persists the current pm2 process list so it comes back after a reboot,
78
+ // PROVIDED "pm2 startup" has also been run once (that step needs sudo and
79
+ // is printed to the user rather than run automatically).
80
+ function save() {
81
+ spawnSync('pm2', ['save'], { stdio: 'inherit' });
82
+ }
83
+
84
+ function printStartupInstructions() {
85
+ console.log('\n[pm2] To make your app(s) survive a machine reboot, run this once:');
86
+ console.log(' pm2 startup');
87
+ console.log(' (then copy/run the sudo command it prints)');
88
+ console.log(' pm2 save\n');
89
+ }
90
+
91
+ module.exports = {
92
+ isInstalled,
93
+ ensureInstalled,
94
+ listApps,
95
+ isRunning,
96
+ startApp,
97
+ stopApp,
98
+ deleteApp,
99
+ logs,
100
+ save,
101
+ printStartupInstructions
102
+ };