Skip to content

Commit f201084

Browse files
dorlugasigalCopilot
andcommitted
fix(update): enable auto-update for source installs running under PM2
When running from a git source checkout via PM2 service, the update detection now correctly identifies PM2 and enables auto-update. The install command runs git pull && npm install && npm run build:frontend in the repo root via sh -c, and PM2 handles the restart automatically. Also adds source-aware permission checks (verifies git is on PATH) and version verification (reads package.json after pull instead of npm ls -g). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2245102 commit f201084

4 files changed

Lines changed: 140 additions & 13 deletions

File tree

src/server/routes.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,12 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
101101

102102
try {
103103
const info = await checkForUpdate({ currentVersion: config.version, force });
104-
const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
104+
const { installCmd, installArgs, cwd, ...publicInstallInfo } = detectInstallMethod();
105105
state.updateInfo = { ...info, ...publicInstallInfo };
106106
res.json(state.updateInfo);
107107
} catch (err) {
108108
log.warn(`Update check failed: ${err.message}`);
109-
const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
109+
const { installCmd, installArgs, cwd, ...publicInstallInfo } = detectInstallMethod();
110110
const fallback = {
111111
current: config.version,
112112
latest: null,
@@ -226,6 +226,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
226226
restartStrategy: installInfo.restartStrategy,
227227
onProgress: broadcastProgress,
228228
performRestart,
229+
cwd: installInfo.cwd,
229230
}).catch((err) => {
230231
log.error(`Update execution error: ${err.message}`);
231232
});

src/utils/update-check.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,14 +292,31 @@ function detectInstallMethod() {
292292
// Check before Docker: a git checkout running inside a container (CI/devcontainers)
293293
// should be treated as source, not Docker
294294
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+
295311
log.debug('Install method: source');
296312
return {
297313
method: 'source',
298-
command: 'git pull && npm install && npm run build:frontend',
314+
command: baseCmd,
299315
installCmd: null,
300316
installArgs: null,
301317
canAutoUpdate: false,
302318
restartStrategy: 'none',
319+
cwd: sourceRoot,
303320
};
304321
}
305322

@@ -346,6 +363,26 @@ function isRunningInDocker() {
346363
return false;
347364
}
348365

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+
349386
/**
350387
* Detect if running from a git source checkout (not installed as a package).
351388
* Walks upward from __dirname looking for .git to avoid fragile fixed-depth assumptions.
@@ -387,4 +424,5 @@ module.exports = {
387424
isRunningInDocker,
388425
isRunningFromSource,
389426
isRunningUnderPm2,
427+
getSourceRoot,
390428
};

src/utils/update-executor.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ function resetState() {
7171
* Returns { canUpdate, reason } — if canUpdate is false, reason explains why.
7272
*/
7373
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+
7484
const cmd = method === 'yarn' ? 'yarn' : method === 'pnpm' ? 'pnpm' : 'npm';
7585

7686
// Check if the package manager is available by running it directly
@@ -124,6 +134,7 @@ async function executeUpdate({
124134
restartStrategy,
125135
onProgress,
126136
performRestart,
137+
cwd,
127138
}) {
128139
if (updateState.status !== 'idle' && updateState.status !== 'failed') {
129140
return { ...updateState, error: 'Update already in progress' };
@@ -169,6 +180,7 @@ async function executeUpdate({
169180
timeout: INSTALL_TIMEOUT_MS,
170181
maxBuffer: 10 * 1024 * 1024, // 10 MB — package manager installs can be verbose
171182
env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
183+
cwd: cwd || undefined,
172184
});
173185

174186
const output = (stdout + '\n' + stderr).trim();
@@ -178,7 +190,7 @@ async function executeUpdate({
178190
// Step 3: Verify
179191
notify({ status: 'verifying', phase: 'Verifying update...' });
180192

181-
const newVersion = await verifyInstalledVersion(method);
193+
const newVersion = await verifyInstalledVersion(method, cwd);
182194
if (!newVersion) {
183195
notify({
184196
status: 'failed',
@@ -230,7 +242,22 @@ async function executeUpdate({
230242

231243
// ── Version Verification ─────────────────────────────────────────────────────
232244

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+
234261
const cmd = method === 'yarn' ? 'yarn' : method === 'pnpm' ? 'pnpm' : 'npm';
235262
try {
236263
// Use npm/yarn/pnpm to read the installed version

test/utils/update-check.test.js

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -404,16 +404,77 @@ describe('update-check', () => {
404404
it('should detect source when running from git repo', () => {
405405
delete process.env.npm_command;
406406
delete process.env.npm_execpath;
407-
const { detectInstallMethod } = require('../../src/utils/update-check');
408-
const result = detectInstallMethod();
409-
// Running tests from the repo — .git exists and not in node_modules.
410-
// In Docker/CI containers, may report 'docker' instead — both are non-auto-updatable.
407+
// Clear PM2 env vars to test the non-PM2 source path
408+
const origPm2Home = process.env.PM2_HOME;
409+
const origPmId = process.env.pm_id;
410+
const origPm2Usage = process.env.PM2_USAGE;
411+
delete process.env.PM2_HOME;
412+
delete process.env.pm_id;
413+
delete process.env.PM2_USAGE;
414+
delete require.cache[require.resolve('../../src/utils/update-check')];
415+
try {
416+
const { detectInstallMethod } = require('../../src/utils/update-check');
417+
const result = detectInstallMethod();
418+
// Running tests from the repo — .git exists and not in node_modules.
419+
// In Docker/CI containers, may report 'docker' instead — both are non-auto-updatable.
420+
assert.ok(
421+
['source', 'docker'].includes(result.method),
422+
`expected 'source' or 'docker', got '${result.method}'`,
423+
);
424+
assert.equal(result.canAutoUpdate, false);
425+
assert.equal(result.restartStrategy, 'none');
426+
if (result.method === 'source') {
427+
assert.ok(result.cwd, 'source method should include cwd');
428+
}
429+
} finally {
430+
if (origPm2Home !== undefined) process.env.PM2_HOME = origPm2Home;
431+
else delete process.env.PM2_HOME;
432+
if (origPmId !== undefined) process.env.pm_id = origPmId;
433+
else delete process.env.pm_id;
434+
if (origPm2Usage !== undefined) process.env.PM2_USAGE = origPm2Usage;
435+
else delete process.env.PM2_USAGE;
436+
}
437+
});
438+
439+
it('should enable auto-update for source under PM2', () => {
440+
delete process.env.npm_command;
441+
delete process.env.npm_execpath;
442+
const origPm2Home = process.env.PM2_HOME;
443+
const origPmId = process.env.pm_id;
444+
process.env.PM2_HOME = '/home/user/.pm2';
445+
process.env.pm_id = '0';
446+
delete require.cache[require.resolve('../../src/utils/update-check')];
447+
try {
448+
const {
449+
detectInstallMethod,
450+
isRunningFromSource,
451+
} = require('../../src/utils/update-check');
452+
if (!isRunningFromSource()) return; // Skip in Docker/CI
453+
const result = detectInstallMethod();
454+
assert.equal(result.method, 'source');
455+
assert.equal(result.canAutoUpdate, true);
456+
assert.equal(result.restartStrategy, 'pm2');
457+
assert.ok(result.command.includes('pm2 restart'), 'command should include pm2 restart');
458+
assert.ok(result.installCmd, 'should have installCmd for auto-update');
459+
assert.ok(result.installArgs, 'should have installArgs for auto-update');
460+
assert.ok(result.cwd, 'should include cwd for source install');
461+
} finally {
462+
if (origPm2Home !== undefined) process.env.PM2_HOME = origPm2Home;
463+
else delete process.env.PM2_HOME;
464+
if (origPmId !== undefined) process.env.pm_id = origPmId;
465+
else delete process.env.pm_id;
466+
}
467+
});
468+
469+
it('getSourceRoot should return repo root directory', () => {
470+
const { getSourceRoot } = require('../../src/utils/update-check');
471+
const root = getSourceRoot();
472+
assert.ok(root, 'should find source root');
473+
assert.ok(fs.existsSync(path.join(root, '.git')), 'source root should contain .git');
411474
assert.ok(
412-
['source', 'docker'].includes(result.method),
413-
`expected 'source' or 'docker', got '${result.method}'`,
475+
fs.existsSync(path.join(root, 'package.json')),
476+
'source root should contain package.json',
414477
);
415-
assert.equal(result.canAutoUpdate, false);
416-
assert.equal(result.restartStrategy, 'none');
417478
});
418479

419480
it('should return canAutoUpdate and restartStrategy fields', () => {

0 commit comments

Comments
 (0)