Skip to content

Commit 67fcf39

Browse files
dorlugasigalCopilot
andcommitted
fix(service): boot persistence and SW auth reliability
Fix pm2 startup hook never being installed — pm2 startup exits 1 by design, so execFileSync threw and the sudo command was lost. Now parses stdout from the error and passes PATH as a proper argument to avoid breakage with spaces in PATH. Fix service worker no-response errors on /api/ routes by removing the explicit NetworkOnly interception — unmatched requests fall through to the browser natively. Fix getConfig localStorage cache poisoning where a stale no-password cache could bypass auth on a password-protected server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bc395b0 commit 67fcf39

5 files changed

Lines changed: 78 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "termbeam",
3-
"version": "1.13.2",
3+
"version": "1.13.1",
44
"description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
55
"main": "src/server/index.js",
66
"bin": {

src/cli/service.js

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -424,19 +424,62 @@ async function actionInstall() {
424424
// Run pm2 startup if chosen during wizard
425425
if (config.startup) {
426426
console.log('');
427-
// pm2 startup outputs a sudo command to copy/paste — capture it and run it
428-
const startupOutput = pm2Exec(['startup'], { silent: true }) || '';
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+
}
429440
const sudoMatch = startupOutput.match(/^(sudo .+)$/m);
430441
if (sudoMatch) {
431442
console.log(dim('Setting up boot persistence (may ask for your password)...\n'));
432-
const { spawn } = require('child_process');
433-
const child = spawn('sh', ['-c', sudoMatch[1]], { stdio: 'inherit' });
434-
await new Promise((resolve) => child.on('close', resolve));
435-
pm2Exec(['save'], { inherit: true });
436-
console.log(green('✓ TermBeam will start automatically on boot.'));
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+(.+)$/,
449+
);
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) {
466+
pm2Exec(['save'], { inherit: true });
467+
console.log(green('✓ TermBeam will start automatically on boot.'));
468+
} else {
469+
console.error(red('\n✗ Failed to set up boot persistence.'));
470+
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])}`);
473+
console.log(yellow('\n Then run:'));
474+
console.log(` ${cyan('pm2 save')}\n`);
475+
}
437476
} else {
438-
// Fallback: just show what pm2 said
439-
console.log(startupOutput);
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`);
440483
}
441484
}
442485

src/frontend/src/services/api.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,17 +186,19 @@ export async function getConfig(): Promise<{ passwordRequired: boolean }> {
186186
const res = await fetchWithTimeout(`${BASE}/api/config`, { credentials: 'same-origin' });
187187
const ct = res.headers.get('content-type') || '';
188188
if (!ct.includes('application/json')) {
189-
// Server unreachable or tunnel returning HTML — use cached value
189+
// Non-JSON response (e.g. DevTunnel auth HTML) — use cached value if available.
190+
// Default to passwordRequired=true (safe: shows login rather than bypassing auth).
190191
const cached = localStorage.getItem('tb:passwordRequired');
191-
return { passwordRequired: cached !== 'false' };
192+
return { passwordRequired: cached === null ? true : cached !== 'false' };
192193
}
193194
const data = (await res.json()) as { passwordRequired: boolean };
194195
localStorage.setItem('tb:passwordRequired', String(data.passwordRequired));
195196
return data;
196197
} catch {
197-
// Network error — fall back to cached value
198-
const cached = localStorage.getItem('tb:passwordRequired');
199-
return { passwordRequired: cached !== 'false' };
198+
// Network error — server may be starting up, SW race, or genuinely down.
199+
// Always default to passwordRequired=true (safe default) to prevent a stale
200+
// no-password cache from bypassing auth on a password-protected server.
201+
return { passwordRequired: true };
200202
}
201203
}
202204

src/frontend/src/sw.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// <reference lib="webworker" />
22
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
33
import { registerRoute, NavigationRoute } from 'workbox-routing';
4-
import { CacheFirst, NetworkFirst, NetworkOnly } from 'workbox-strategies';
4+
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
55
import { ExpirationPlugin } from 'workbox-expiration';
66
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
77

@@ -40,8 +40,13 @@ registerRoute(
4040
}),
4141
);
4242

43-
// Network-only for API calls — never cache auth or session data
44-
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
43+
// Network-only for API calls — never cache auth or session data.
44+
// Use a fetch event listener instead of registerRoute to avoid workbox
45+
// wrapping the fetch in a Response handler that can throw "no-response"
46+
// when the network request fails (e.g. during SW activation race).
47+
// By not registering a route, unmatched /api/ requests fall through to
48+
// the browser's native fetch — more resilient than SW interception.
49+
// (Previously used: registerRoute(({url}) => url.pathname.startsWith('/api/'), new NetworkOnly()));
4550

4651
// Skip waiting and claim clients immediately
4752
self.addEventListener('install', () => {

test/cli/service.test.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -939,16 +939,24 @@ describe('actionInstall wizard (via run)', () => {
939939
execOverride ||
940940
((cmd, args) => {
941941
if (cmd === 'which' || cmd === 'where') return '/usr/bin/pm2\n';
942-
if (cmd === 'pm2' && args[0] === 'startup') return startupOutput;
942+
// pm2 startup always exits 1 — throw with stdout containing the sudo command
943+
if (cmd === 'pm2' && args[0] === 'startup') {
944+
const err = new Error('pm2 startup exits 1');
945+
err.stdout = startupOutput;
946+
err.stderr = '';
947+
err.status = 1;
948+
throw err;
949+
}
943950
return '';
944951
}),
945952
spawn:
946953
spawnMock ||
947954
(() => ({
948955
on: (event, cb) => {
949-
if (event === 'close') setImmediate(cb);
956+
if (event === 'close') setImmediate(() => cb(0));
950957
},
951958
})),
959+
spawnSync: spawnMock || (() => ({ status: 0 })),
952960
},
953961
readline: {
954962
createInterface: () => ({

0 commit comments

Comments
 (0)