termbeam 1.17.1 → 1.17.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server/index.js",
6
6
  "bin": {
@@ -46,6 +46,7 @@ function installPm2Global() {
46
46
  execFileSync('npm', ['install', '-g', 'pm2'], {
47
47
  stdio: 'inherit',
48
48
  timeout: 120000,
49
+ shell: os.platform() === 'win32',
49
50
  });
50
51
  console.log(green('✓ PM2 installed successfully.\n'));
51
52
  return true;
@@ -122,6 +123,20 @@ function writeEcosystem(content) {
122
123
  fs.writeFileSync(ECOSYSTEM_FILE, content, 'utf8');
123
124
  }
124
125
 
126
+ function readEcosystemName() {
127
+ try {
128
+ const content = fs.readFileSync(ECOSYSTEM_FILE, 'utf8');
129
+ const json = content.replace(/^module\.exports\s*=\s*/, '').replace(/;\s*$/, '');
130
+ const eco = JSON.parse(json);
131
+ if (eco.apps && eco.apps[0] && eco.apps[0].name) {
132
+ return eco.apps[0].name;
133
+ }
134
+ } catch {
135
+ // ecosystem file missing or malformed
136
+ }
137
+ return DEFAULT_SERVICE_NAME;
138
+ }
139
+
125
140
  // ── PM2 Commands ─────────────────────────────────────────────────────────────
126
141
 
127
142
  function pm2Exec(args, opts = {}) {
@@ -131,6 +146,8 @@ function pm2Exec(args, opts = {}) {
131
146
  encoding: 'utf8',
132
147
  stdio: opts.inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'],
133
148
  timeout: 30000,
149
+ // Windows npm globals are .cmd wrappers; execFileSync needs shell to resolve them
150
+ shell: os.platform() === 'win32',
134
151
  ...opts,
135
152
  });
136
153
  } catch (err) {
@@ -225,6 +242,7 @@ async function actionInstall() {
225
242
 
226
243
  // Service name
227
244
  showProgress(0);
245
+ console.log(dim(' The PM2 process name for this service.\n'));
228
246
  config.name = await ask(rl, 'Service name:', DEFAULT_SERVICE_NAME);
229
247
  decisions.push({ label: 'Service name', value: config.name });
230
248
 
@@ -424,62 +442,108 @@ async function actionInstall() {
424
442
  // Run pm2 startup if chosen during wizard
425
443
  if (config.startup) {
426
444
  console.log('');
427
- // pm2 startup outputs a sudo command to copy/paste — but it always exits 1
428
- // (since the startup hook isn't installed yet). Extract stdout from the error.
429
- let startupOutput = '';
430
- try {
431
- startupOutput = execFileSync('pm2', ['startup'], {
432
- encoding: 'utf8',
433
- stdio: ['pipe', 'pipe', 'pipe'],
434
- timeout: 15000,
435
- });
436
- } catch (err) {
437
- // pm2 startup exits 1 by design — the sudo command is in stdout
438
- startupOutput = (err.stdout || '') + (err.stderr || '');
439
- }
440
- const sudoMatch = startupOutput.match(/^(sudo .+)$/m);
441
- if (sudoMatch) {
442
- console.log(dim('Setting up boot persistence (may ask for your password)...\n'));
443
- const { spawnSync } = require('child_process');
444
- // pm2 outputs: sudo env PATH=$PATH:/extra /path/to/pm2 startup <init> -u <user> --hp <home>
445
- // We can't use sh -c because $PATH may contain spaces (e.g. "Visual Studio Code.app").
446
- // Instead, parse the structured command and pass PATH via env to avoid shell expansion.
447
- const envMatch = sudoMatch[1].match(
448
- /^sudo\s+env\s+PATH=\$PATH:([\S]+)\s+(\S+)\s+startup\s+(.+)$/,
445
+ if (os.platform() === 'win32') {
446
+ // Windows: pm2 startup doesn't support Windows init systems.
447
+ // Instead, create a script in the Windows Startup folder that runs pm2 resurrect.
448
+ const startupDir = path.join(
449
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
450
+ 'Microsoft',
451
+ 'Windows',
452
+ 'Start Menu',
453
+ 'Programs',
454
+ 'Startup',
449
455
  );
450
- let result;
451
- if (envMatch) {
452
- const extraPath = envMatch[1]; // e.g. /opt/homebrew/.../bin
453
- const pm2Bin = envMatch[2]; // e.g. /opt/homebrew/.../pm2
454
- const restArgs = envMatch[3].split(/\s+/); // e.g. ['launchd', '-u', 'user', '--hp', '/home']
455
- const fullPath = (process.env.PATH || '') + ':' + extraPath;
456
- result = spawnSync('sudo', ['env', `PATH=${fullPath}`, pm2Bin, 'startup', ...restArgs], {
457
- stdio: 'inherit',
458
- });
459
- } else {
460
- // Fallback: try running via sh with quoted PATH
461
- const resolved = sudoMatch[1].replace(/\$PATH/g, `'${process.env.PATH || ''}'`);
462
- result = spawnSync('sh', ['-c', resolved], { stdio: 'inherit' });
463
- }
464
- const code = result.status;
465
- if (code === 0) {
456
+ const startupScript = path.join(startupDir, 'termbeam-pm2.cmd');
457
+ try {
458
+ fs.writeFileSync(startupScript, '@echo off\r\npm2 resurrect\r\n', 'utf8');
466
459
  pm2Exec(['save'], { inherit: true });
467
460
  console.log(green('✓ TermBeam will start automatically on boot.'));
468
- } else {
469
- console.error(red('\n✗ Failed to set up boot persistence.'));
461
+ console.log(dim(` Startup script: ${startupScript}`));
462
+ } catch (err) {
463
+ console.error(red('✗ Failed to create startup script.'));
470
464
  console.log(yellow(" TermBeam is running, but won't auto-start after a reboot."));
471
- console.log(yellow(' To fix this, run the following command manually:\n'));
472
- console.log(` ${cyan(sudoMatch[1])}`);
465
+ console.log(yellow(' To fix this manually, create a file at:\n'));
466
+ console.log(` ${cyan(startupScript)}`);
467
+ console.log(yellow('\n With contents:'));
468
+ console.log(` ${cyan('@echo off & pm2 resurrect')}`);
473
469
  console.log(yellow('\n Then run:'));
474
470
  console.log(` ${cyan('pm2 save')}\n`);
475
471
  }
476
472
  } else {
477
- console.error(red('✗ Could not determine boot persistence command.'));
478
- console.log(yellow(" TermBeam is running, but won't auto-start after a reboot."));
479
- console.log(yellow(' To fix this, run:\n'));
480
- console.log(` ${cyan('pm2 startup')}`);
481
- console.log(dim(' …then run the sudo command it outputs, followed by:'));
482
- console.log(` ${cyan('pm2 save')}\n`);
473
+ // Unix/macOS/WSL: try pm2 startup to set up boot persistence
474
+ let startupOutput = '';
475
+ let succeeded = false;
476
+ let initSystemError = false;
477
+ try {
478
+ startupOutput = execFileSync('pm2', ['startup'], {
479
+ encoding: 'utf8',
480
+ stdio: ['pipe', 'pipe', 'pipe'],
481
+ timeout: 15000,
482
+ });
483
+ // Exit 0 means pm2 configured startup directly (e.g. running as root)
484
+ succeeded = true;
485
+ } catch (err) {
486
+ // pm2 startup exits 1 by design — the sudo command is in stdout
487
+ startupOutput = (err.stdout || '') + (err.stderr || '');
488
+ if (startupOutput.includes('Init system not found')) {
489
+ initSystemError = true;
490
+ }
491
+ }
492
+
493
+ if (succeeded) {
494
+ // pm2 startup succeeded (typically running as root) — just save
495
+ pm2Exec(['save'], { inherit: true });
496
+ console.log(green('✓ TermBeam will start automatically on boot.'));
497
+ } else if (initSystemError) {
498
+ // WSL without systemd, or other environments without an init system
499
+ console.log(yellow('⚠ No init system detected (common in WSL without systemd).'));
500
+ console.log(yellow(" TermBeam is running, but won't auto-start after a reboot."));
501
+ console.log(dim(' To enable boot persistence, either:'));
502
+ console.log(dim(' • Enable systemd in WSL: add [boot] systemd=true to /etc/wsl.conf'));
503
+ console.log(dim(' • Or add "pm2 resurrect" to your shell profile (~/.bashrc)\n'));
504
+ } else {
505
+ const sudoMatch = startupOutput.match(/^(sudo .+)$/m);
506
+ if (sudoMatch) {
507
+ console.log(dim('Setting up boot persistence (may ask for your password)...\n'));
508
+ const { spawnSync } = require('child_process');
509
+ const envMatch = sudoMatch[1].match(
510
+ /^sudo\s+env\s+PATH=\$PATH:([\S]+)\s+(\S+)\s+startup\s+(.+)$/,
511
+ );
512
+ let result;
513
+ if (envMatch) {
514
+ const extraPath = envMatch[1];
515
+ const pm2Bin = envMatch[2];
516
+ const restArgs = envMatch[3].split(/\s+/);
517
+ const fullPath = (process.env.PATH || '') + ':' + extraPath;
518
+ result = spawnSync(
519
+ 'sudo',
520
+ ['env', `PATH=${fullPath}`, pm2Bin, 'startup', ...restArgs],
521
+ { stdio: 'inherit' },
522
+ );
523
+ } else {
524
+ const resolved = sudoMatch[1].replace(/\$PATH/g, `'${process.env.PATH || ''}'`);
525
+ result = spawnSync('sh', ['-c', resolved], { stdio: 'inherit' });
526
+ }
527
+ if (result.status === 0) {
528
+ pm2Exec(['save'], { inherit: true });
529
+ console.log(green('✓ TermBeam will start automatically on boot.'));
530
+ } else {
531
+ console.error(red('\n✗ Failed to set up boot persistence.'));
532
+ console.log(yellow(" TermBeam is running, but won't auto-start after a reboot."));
533
+ console.log(yellow(' To fix this, run the following command manually:\n'));
534
+ console.log(` ${cyan(sudoMatch[1])}`);
535
+ console.log(yellow('\n Then run:'));
536
+ console.log(` ${cyan('pm2 save')}\n`);
537
+ }
538
+ } else {
539
+ console.error(red('✗ Could not determine boot persistence command.'));
540
+ console.log(yellow(" TermBeam is running, but won't auto-start after a reboot."));
541
+ console.log(yellow(' To fix this, run:\n'));
542
+ console.log(` ${cyan('pm2 startup')}`);
543
+ console.log(dim(' …then run the sudo command it outputs, followed by:'));
544
+ console.log(` ${cyan('pm2 save')}\n`);
545
+ }
546
+ }
483
547
  }
484
548
  }
485
549
 
@@ -528,20 +592,22 @@ async function actionUninstall() {
528
592
  process.exit(1);
529
593
  }
530
594
 
531
- // Find running termbeam services
595
+ // Determine service name: prefer ecosystem config, then PM2 process list, then default
596
+ const ecoName = readEcosystemName();
532
597
  const list = pm2Exec(['jlist'], { silent: true });
533
598
  let services = [];
534
599
  if (list) {
535
600
  try {
536
601
  services = JSON.parse(list).filter(
537
- (p) => p.name === DEFAULT_SERVICE_NAME || p.name.startsWith('termbeam'),
602
+ (p) =>
603
+ p.name === ecoName || p.name === DEFAULT_SERVICE_NAME || p.name.startsWith('termbeam'),
538
604
  );
539
605
  } catch {
540
606
  // ignore parse errors
541
607
  }
542
608
  }
543
609
 
544
- const name = services.length > 0 ? services[0].name : DEFAULT_SERVICE_NAME;
610
+ const name = services.length > 0 ? services[0].name : ecoName;
545
611
 
546
612
  const rl = createRL();
547
613
  const sure = await confirm(rl, `Remove TermBeam service "${name}" from PM2?`, true);
@@ -562,6 +628,23 @@ async function actionUninstall() {
562
628
  console.log(dim(`Removed ${ECOSYSTEM_FILE}`));
563
629
  }
564
630
 
631
+ // Clean up Windows startup script if present
632
+ if (os.platform() === 'win32') {
633
+ const startupScript = path.join(
634
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
635
+ 'Microsoft',
636
+ 'Windows',
637
+ 'Start Menu',
638
+ 'Programs',
639
+ 'Startup',
640
+ 'termbeam-pm2.cmd',
641
+ );
642
+ if (fs.existsSync(startupScript)) {
643
+ fs.unlinkSync(startupScript);
644
+ console.log(dim(`Removed ${startupScript}`));
645
+ }
646
+ }
647
+
565
648
  log.info('Service stopped');
566
649
  console.log(green(`\n✓ TermBeam service "${name}" removed.\n`));
567
650
  }
@@ -572,7 +655,7 @@ function actionStatus() {
572
655
  console.error(red('✗ PM2 is not installed. Run: npm install -g pm2'));
573
656
  process.exit(1);
574
657
  }
575
- pm2Exec(['describe', DEFAULT_SERVICE_NAME], { inherit: true });
658
+ pm2Exec(['describe', readEcosystemName()], { inherit: true });
576
659
  }
577
660
 
578
661
  function actionLogs() {
@@ -582,8 +665,9 @@ function actionLogs() {
582
665
  process.exit(1);
583
666
  }
584
667
  const { spawn } = require('child_process');
585
- const child = spawn('pm2', ['logs', DEFAULT_SERVICE_NAME, '--lines', '200'], {
668
+ const child = spawn('pm2', ['logs', readEcosystemName(), '--lines', '200'], {
586
669
  stdio: 'inherit',
670
+ shell: os.platform() === 'win32',
587
671
  });
588
672
  child.on('error', (err) => {
589
673
  console.error(red(`✗ Failed to stream logs: ${err.message}`));
@@ -596,7 +680,7 @@ function actionRestart() {
596
680
  console.error(red('✗ PM2 is not installed. Run: npm install -g pm2'));
597
681
  process.exit(1);
598
682
  }
599
- pm2Exec(['restart', DEFAULT_SERVICE_NAME], { inherit: true });
683
+ pm2Exec(['restart', readEcosystemName()], { inherit: true });
600
684
  log.info('Service restarted');
601
685
  console.log(green('\n✓ TermBeam service restarted.\n'));
602
686
  }
@@ -651,6 +735,7 @@ module.exports = {
651
735
  buildArgs,
652
736
  generateEcosystem,
653
737
  writeEcosystem,
738
+ readEcosystemName,
654
739
  pm2Exec,
655
740
  actionStatus,
656
741
  actionRestart,
@@ -101,12 +101,12 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
101
101
 
102
102
  try {
103
103
  const info = await checkForUpdate({ currentVersion: config.version, force });
104
- const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
104
+ const { installCmd, installArgs, cwd, ...publicInstallInfo } = detectInstallMethod();
105
105
  state.updateInfo = { ...info, ...publicInstallInfo };
106
106
  res.json(state.updateInfo);
107
107
  } catch (err) {
108
108
  log.warn(`Update check failed: ${err.message}`);
109
- const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
109
+ const { installCmd, installArgs, cwd, ...publicInstallInfo } = detectInstallMethod();
110
110
  const fallback = {
111
111
  current: config.version,
112
112
  latest: null,
@@ -226,6 +226,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
226
226
  restartStrategy: installInfo.restartStrategy,
227
227
  onProgress: broadcastProgress,
228
228
  performRestart,
229
+ cwd: installInfo.cwd,
229
230
  }).catch((err) => {
230
231
  log.error(`Update execution error: ${err.message}`);
231
232
  });
@@ -292,14 +292,31 @@ function detectInstallMethod() {
292
292
  // Check before Docker: a git checkout running inside a container (CI/devcontainers)
293
293
  // should be treated as source, not Docker
294
294
  if (isRunningFromSource()) {
295
+ const sourceRoot = getSourceRoot();
296
+ const baseCmd = 'git pull && npm install && npm run build:frontend';
297
+
298
+ if (isPm2) {
299
+ log.debug('Install method: source (PM2)');
300
+ return {
301
+ method: 'source',
302
+ command: `${baseCmd} && pm2 restart termbeam`,
303
+ installCmd: process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : 'sh',
304
+ installArgs: process.platform === 'win32' ? ['/c', baseCmd] : ['-c', baseCmd],
305
+ canAutoUpdate: true,
306
+ restartStrategy: 'pm2',
307
+ cwd: sourceRoot,
308
+ };
309
+ }
310
+
295
311
  log.debug('Install method: source');
296
312
  return {
297
313
  method: 'source',
298
- command: 'git pull && npm install && npm run build:frontend',
314
+ command: baseCmd,
299
315
  installCmd: null,
300
316
  installArgs: null,
301
317
  canAutoUpdate: false,
302
318
  restartStrategy: 'none',
319
+ cwd: sourceRoot,
303
320
  };
304
321
  }
305
322
 
@@ -346,6 +363,26 @@ function isRunningInDocker() {
346
363
  return false;
347
364
  }
348
365
 
366
+ /**
367
+ * Find the root of the source checkout by walking up from __dirname.
368
+ * Returns the absolute path to the repo root, or null if not found.
369
+ */
370
+ function getSourceRoot() {
371
+ if (__dirname.includes('node_modules')) return null;
372
+ try {
373
+ let currentDir = __dirname;
374
+ for (let i = 0; i < 10; i++) {
375
+ if (fs.existsSync(path.join(currentDir, '.git'))) return currentDir;
376
+ const parentDir = path.dirname(currentDir);
377
+ if (!parentDir || parentDir === currentDir) break;
378
+ currentDir = parentDir;
379
+ }
380
+ } catch {
381
+ // ignore
382
+ }
383
+ return null;
384
+ }
385
+
349
386
  /**
350
387
  * Detect if running from a git source checkout (not installed as a package).
351
388
  * Walks upward from __dirname looking for .git to avoid fragile fixed-depth assumptions.
@@ -387,4 +424,5 @@ module.exports = {
387
424
  isRunningInDocker,
388
425
  isRunningFromSource,
389
426
  isRunningUnderPm2,
427
+ getSourceRoot,
390
428
  };
@@ -71,6 +71,16 @@ function resetState() {
71
71
  * Returns { canUpdate, reason } — if canUpdate is false, reason explains why.
72
72
  */
73
73
  async function checkPermissions(method) {
74
+ // Source installs use git, not a package manager
75
+ if (method === 'source') {
76
+ try {
77
+ await execFilePromise('git', ['--version'], { timeout: VERIFY_TIMEOUT_MS });
78
+ } catch {
79
+ return { canUpdate: false, reason: 'git not found on PATH' };
80
+ }
81
+ return { canUpdate: true, reason: null };
82
+ }
83
+
74
84
  const cmd = method === 'yarn' ? 'yarn' : method === 'pnpm' ? 'pnpm' : 'npm';
75
85
 
76
86
  // Check if the package manager is available by running it directly
@@ -124,6 +134,7 @@ async function executeUpdate({
124
134
  restartStrategy,
125
135
  onProgress,
126
136
  performRestart,
137
+ cwd,
127
138
  }) {
128
139
  if (updateState.status !== 'idle' && updateState.status !== 'failed') {
129
140
  return { ...updateState, error: 'Update already in progress' };
@@ -169,6 +180,7 @@ async function executeUpdate({
169
180
  timeout: INSTALL_TIMEOUT_MS,
170
181
  maxBuffer: 10 * 1024 * 1024, // 10 MB — package manager installs can be verbose
171
182
  env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
183
+ cwd: cwd || undefined,
172
184
  });
173
185
 
174
186
  const output = (stdout + '\n' + stderr).trim();
@@ -178,7 +190,7 @@ async function executeUpdate({
178
190
  // Step 3: Verify
179
191
  notify({ status: 'verifying', phase: 'Verifying update...' });
180
192
 
181
- const newVersion = await verifyInstalledVersion(method);
193
+ const newVersion = await verifyInstalledVersion(method, cwd);
182
194
  if (!newVersion) {
183
195
  notify({
184
196
  status: 'failed',
@@ -230,7 +242,22 @@ async function executeUpdate({
230
242
 
231
243
  // ── Version Verification ─────────────────────────────────────────────────────
232
244
 
233
- async function verifyInstalledVersion(method) {
245
+ async function verifyInstalledVersion(method, cwd) {
246
+ // Source installs: read version from the repo's package.json after git pull
247
+ if (method === 'source') {
248
+ try {
249
+ const pkgPath = cwd
250
+ ? path.join(cwd, 'package.json')
251
+ : path.resolve(__dirname, '../../package.json');
252
+ const content = await fs.promises.readFile(pkgPath, 'utf8');
253
+ const pkg = JSON.parse(content);
254
+ return pkg.version || null;
255
+ } catch (err) {
256
+ log.debug(`Version verification via package.json failed: ${err.message}`);
257
+ }
258
+ return null;
259
+ }
260
+
234
261
  const cmd = method === 'yarn' ? 'yarn' : method === 'pnpm' ? 'pnpm' : 'npm';
235
262
  try {
236
263
  // Use npm/yarn/pnpm to read the installed version