termbeam 1.15.2 → 1.17.0

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.
Files changed (68) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -1
  3. package/public/assets/{_basePickBy-BSIbg2Hw.js → _basePickBy-Crmlna7W.js} +1 -1
  4. package/public/assets/{_baseUniq-CYmx81nY.js → _baseUniq-h6HY8nD4.js} +1 -1
  5. package/public/assets/{arc-CDJcNcKc.js → arc-BI4RNUD8.js} +1 -1
  6. package/public/assets/{architectureDiagram-2XIMDMQ5-C1qauSxh.js → architectureDiagram-2XIMDMQ5-C2PAl3D6.js} +1 -1
  7. package/public/assets/{blockDiagram-WCTKOSBZ-nTHCaU6g.js → blockDiagram-WCTKOSBZ-CADYyoNx.js} +1 -1
  8. package/public/assets/{c4Diagram-IC4MRINW-CdGuCZNN.js → c4Diagram-IC4MRINW-CQtNNlqT.js} +1 -1
  9. package/public/assets/channel-DlFJ0YtH.js +1 -0
  10. package/public/assets/{chunk-4BX2VUAB-IxfdQ8zN.js → chunk-4BX2VUAB-BZhBHL2q.js} +1 -1
  11. package/public/assets/{chunk-55IACEB6-mjdLMPLu.js → chunk-55IACEB6-DaOODotQ.js} +1 -1
  12. package/public/assets/{chunk-FMBD7UC4-B-QgE8A5.js → chunk-FMBD7UC4-D7ZUE2Qt.js} +1 -1
  13. package/public/assets/{chunk-JSJVCQXG-BlBtV3cx.js → chunk-JSJVCQXG-Cr7LmD49.js} +1 -1
  14. package/public/assets/{chunk-KX2RTZJC-ByLbXYtr.js → chunk-KX2RTZJC-mSzu7V0i.js} +1 -1
  15. package/public/assets/{chunk-NQ4KR5QH-DdgKg6ac.js → chunk-NQ4KR5QH-UNIo7K3P.js} +1 -1
  16. package/public/assets/{chunk-QZHKN3VN-DK0sNhO7.js → chunk-QZHKN3VN-D8pHtVTR.js} +1 -1
  17. package/public/assets/{chunk-WL4C6EOR-CMhwM8MW.js → chunk-WL4C6EOR-CKtSBmtm.js} +1 -1
  18. package/public/assets/classDiagram-VBA2DB6C-Uh272C_T.js +1 -0
  19. package/public/assets/classDiagram-v2-RAHNMMFH-Uh272C_T.js +1 -0
  20. package/public/assets/clone-BiOpyrvc.js +1 -0
  21. package/public/assets/{cose-bilkent-S5V4N54A-C4J5lbLg.js → cose-bilkent-S5V4N54A-C73dVsDU.js} +1 -1
  22. package/public/assets/{dagre-KLK3FWXG-CmPYo_iW.js → dagre-KLK3FWXG-CGtdO-e6.js} +1 -1
  23. package/public/assets/{diagram-E7M64L7V-BSDHjD_1.js → diagram-E7M64L7V-B3RnL1-2.js} +1 -1
  24. package/public/assets/{diagram-IFDJBPK2-DZFEThmE.js → diagram-IFDJBPK2-BhT13Y--.js} +1 -1
  25. package/public/assets/{diagram-P4PSJMXO-D2vA458R.js → diagram-P4PSJMXO-w4ta5qzj.js} +1 -1
  26. package/public/assets/{erDiagram-INFDFZHY-CqngKW80.js → erDiagram-INFDFZHY-p_XdulXc.js} +1 -1
  27. package/public/assets/{flowDiagram-PKNHOUZH-2ndb8I08.js → flowDiagram-PKNHOUZH-cKD9roCC.js} +1 -1
  28. package/public/assets/{ganttDiagram-A5KZAMGK-DGH9iwxm.js → ganttDiagram-A5KZAMGK-kRLcbnHy.js} +1 -1
  29. package/public/assets/{gitGraphDiagram-K3NZZRJ6-DBszyq19.js → gitGraphDiagram-K3NZZRJ6-CfqReYYJ.js} +1 -1
  30. package/public/assets/{graph-B-VDztTg.js → graph-2Z05uqaC.js} +1 -1
  31. package/public/assets/index-Bpz9aDGB.css +32 -0
  32. package/public/assets/index-Cvxh0Fjh.js +394 -0
  33. package/public/assets/{infoDiagram-LFFYTUFH-BQYostn9.js → infoDiagram-LFFYTUFH-D2bxFvYS.js} +1 -1
  34. package/public/assets/{ishikawaDiagram-PHBUUO56-BF9SDQjL.js → ishikawaDiagram-PHBUUO56-olWTIvNJ.js} +1 -1
  35. package/public/assets/{journeyDiagram-4ABVD52K-BVygcg_3.js → journeyDiagram-4ABVD52K-T_3LhARU.js} +1 -1
  36. package/public/assets/{kanban-definition-K7BYSVSG-C360CZ_M.js → kanban-definition-K7BYSVSG-BCmUNdAK.js} +1 -1
  37. package/public/assets/{layout-D1dS_Xae.js → layout-BuQ9md8V.js} +1 -1
  38. package/public/assets/{linear-DSiHoSbJ.js → linear-BGGATdCH.js} +1 -1
  39. package/public/assets/{mindmap-definition-YRQLILUH-DW7C3qtv.js → mindmap-definition-YRQLILUH-Bz_sgl78.js} +1 -1
  40. package/public/assets/{pieDiagram-SKSYHLDU-C8vfomtz.js → pieDiagram-SKSYHLDU-wxt-R3l5.js} +1 -1
  41. package/public/assets/{quadrantDiagram-337W2JSQ-DXT_qKk-.js → quadrantDiagram-337W2JSQ-0yTHkNo0.js} +1 -1
  42. package/public/assets/{requirementDiagram-Z7DCOOCP-Dj2MzFq3.js → requirementDiagram-Z7DCOOCP-CLqLwKcJ.js} +1 -1
  43. package/public/assets/{sankeyDiagram-WA2Y5GQK-YfmbQXg2.js → sankeyDiagram-WA2Y5GQK-CV2OX87k.js} +1 -1
  44. package/public/assets/{sequenceDiagram-2WXFIKYE-Bp9hgUSv.js → sequenceDiagram-2WXFIKYE-DaQifS2p.js} +1 -1
  45. package/public/assets/{stateDiagram-RAJIS63D-D8VgKzZe.js → stateDiagram-RAJIS63D-Bi5e4H5H.js} +1 -1
  46. package/public/assets/stateDiagram-v2-FVOUBMTO-D2d2wuS-.js +1 -0
  47. package/public/assets/{timeline-definition-YZTLITO2-3ErXxqpK.js → timeline-definition-YZTLITO2-Bu0j_UbL.js} +1 -1
  48. package/public/assets/{treemap-KZPCXAKY-D3-uSz_K.js → treemap-KZPCXAKY-BreHb2Q6.js} +1 -1
  49. package/public/assets/{vennDiagram-LZ73GAT5-D7Isk6A4.js → vennDiagram-LZ73GAT5-C5vHpUCv.js} +1 -1
  50. package/public/assets/{xychartDiagram-JWTSCODW-CpuvGwXM.js → xychartDiagram-JWTSCODW-DEN428FH.js} +1 -1
  51. package/public/index.html +2 -2
  52. package/public/sw.js +2 -2
  53. package/src/server/index.js +35 -4
  54. package/src/server/push.js +118 -0
  55. package/src/server/routes.js +350 -5
  56. package/src/server/sessions.js +144 -0
  57. package/src/server/websocket.js +21 -2
  58. package/src/utils/git.js +338 -1
  59. package/src/utils/update-check.js +139 -9
  60. package/src/utils/update-executor.js +340 -0
  61. package/src/utils/vapid.js +45 -0
  62. package/public/assets/channel-Bh_CZXn-.js +0 -1
  63. package/public/assets/classDiagram-VBA2DB6C-CSkcpaag.js +0 -1
  64. package/public/assets/classDiagram-v2-RAHNMMFH-CSkcpaag.js +0 -1
  65. package/public/assets/clone-BR1se3-G.js +0 -1
  66. package/public/assets/index-BWUfRdC9.js +0 -391
  67. package/public/assets/index-D_1GL6a5.css +0 -32
  68. package/public/assets/stateDiagram-v2-FVOUBMTO-By5luAVT.js +0 -1
package/src/utils/git.js CHANGED
@@ -125,4 +125,341 @@ function parseStatus(output, ahead, behind) {
125
125
  return { clean, modified, staged, untracked, ahead: ahead || 0, behind: behind || 0, summary };
126
126
  }
127
127
 
128
- module.exports = { getGitInfo, parseRemoteUrl, parseStatus };
128
+ // --- Extended git utilities (async, using execFile for security) ---
129
+
130
+ const GIT_TIMEOUT = 5000;
131
+ const MAX_DIFF_BUFFER = 1024 * 1024; // 1 MB
132
+ const MAX_BLAME_BUFFER = 2 * 1024 * 1024; // 2 MB
133
+ const MAX_LOG_BUFFER = 1024 * 1024; // 1 MB
134
+
135
+ async function gitAsync(args, cwd, options = {}) {
136
+ return new Promise((resolve, reject) => {
137
+ require('child_process').execFile(
138
+ 'git',
139
+ args,
140
+ {
141
+ cwd,
142
+ timeout: options.timeout || GIT_TIMEOUT,
143
+ maxBuffer: options.maxBuffer || MAX_DIFF_BUFFER,
144
+ },
145
+ (err, stdout) => {
146
+ if (err) return reject(err);
147
+ resolve(stdout);
148
+ },
149
+ );
150
+ });
151
+ }
152
+
153
+ async function getDetailedStatus(cwd) {
154
+ try {
155
+ await gitAsync(['rev-parse', '--is-inside-work-tree'], cwd);
156
+ } catch {
157
+ return { isGitRepo: false };
158
+ }
159
+
160
+ const result = {
161
+ branch: null,
162
+ ahead: 0,
163
+ behind: 0,
164
+ staged: [],
165
+ modified: [],
166
+ untracked: [],
167
+ isGitRepo: true,
168
+ };
169
+
170
+ try {
171
+ const raw = await gitAsync(['status', '--porcelain=v1', '-b'], cwd);
172
+ const lines = raw.split('\n').filter(Boolean);
173
+
174
+ for (const line of lines) {
175
+ // Branch header line
176
+ if (line.startsWith('## ')) {
177
+ const branchInfo = line.slice(3);
178
+ const trackMatch = branchInfo.match(/^(.+?)(?:\.\.\.(.+?))?(?:\s+\[(.+)\])?$/);
179
+ if (trackMatch) {
180
+ result.branch = trackMatch[1];
181
+ const tracking = trackMatch[3];
182
+ if (tracking) {
183
+ const aheadMatch = tracking.match(/ahead (\d+)/);
184
+ const behindMatch = tracking.match(/behind (\d+)/);
185
+ if (aheadMatch) result.ahead = parseInt(aheadMatch[1], 10);
186
+ if (behindMatch) result.behind = parseInt(behindMatch[1], 10);
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+
192
+ const index = line[0];
193
+ const working = line[1];
194
+ const filePart = line.slice(3);
195
+
196
+ // Untracked
197
+ if (index === '?' && working === '?') {
198
+ result.untracked.push(filePart);
199
+ continue;
200
+ }
201
+
202
+ // Staged changes (index column)
203
+ if (index !== ' ' && index !== '?') {
204
+ const entry = { path: filePart, status: index, oldPath: null };
205
+ if (index === 'R' || index === 'C') {
206
+ const parts = filePart.split(' -> ');
207
+ if (parts.length === 2) {
208
+ entry.oldPath = parts[0];
209
+ entry.path = parts[1];
210
+ }
211
+ }
212
+ result.staged.push(entry);
213
+ }
214
+
215
+ // Working tree changes (working column)
216
+ if (working !== ' ' && working !== '?') {
217
+ result.modified.push({ path: filePart, status: working, oldPath: null });
218
+ }
219
+ }
220
+ } catch (err) {
221
+ log.warn(`getDetailedStatus failed: ${err.message}`);
222
+ }
223
+
224
+ return result;
225
+ }
226
+
227
+ async function parseDiffOutput(raw, filePath) {
228
+ const result = {
229
+ file: filePath,
230
+ hunks: [],
231
+ additions: 0,
232
+ deletions: 0,
233
+ isBinary: false,
234
+ };
235
+
236
+ if (!raw.trim()) return result;
237
+
238
+ // Binary file detection — only check the diff header (lines before the first hunk).
239
+ // Content lines (prefixed with +/-/ ) may contain "Binary files ... differ" as text.
240
+ const firstHunkIdx = raw.indexOf('\n@@');
241
+ const header = firstHunkIdx >= 0 ? raw.slice(0, firstHunkIdx) : raw;
242
+ if (header.includes('Binary files') && header.includes('differ')) {
243
+ result.isBinary = true;
244
+ return result;
245
+ }
246
+
247
+ // Parse unified diff into hunks
248
+ const lines = raw.split('\n');
249
+ let currentHunk = null;
250
+ let oldLine = 0;
251
+ let newLine = 0;
252
+
253
+ for (const line of lines) {
254
+ // Hunk header
255
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
256
+ if (hunkMatch) {
257
+ currentHunk = {
258
+ header: line.match(/^@@.*@@/)[0],
259
+ oldStart: parseInt(hunkMatch[1], 10),
260
+ oldLines: parseInt(hunkMatch[2] ?? '1', 10),
261
+ newStart: parseInt(hunkMatch[3], 10),
262
+ newLines: parseInt(hunkMatch[4] ?? '1', 10),
263
+ lines: [],
264
+ };
265
+ result.hunks.push(currentHunk);
266
+ oldLine = currentHunk.oldStart;
267
+ newLine = currentHunk.newStart;
268
+ continue;
269
+ }
270
+
271
+ if (!currentHunk) continue;
272
+
273
+ if (line.startsWith('+')) {
274
+ currentHunk.lines.push({
275
+ type: 'add',
276
+ content: line.slice(1),
277
+ oldLine: null,
278
+ newLine: newLine++,
279
+ });
280
+ result.additions++;
281
+ } else if (line.startsWith('-')) {
282
+ currentHunk.lines.push({
283
+ type: 'remove',
284
+ content: line.slice(1),
285
+ oldLine: oldLine++,
286
+ newLine: null,
287
+ });
288
+ result.deletions++;
289
+ } else if (line.startsWith(' ')) {
290
+ currentHunk.lines.push({
291
+ type: 'context',
292
+ content: line.slice(1),
293
+ oldLine: oldLine++,
294
+ newLine: newLine++,
295
+ });
296
+ }
297
+ // Skip diff header lines (diff --git, index, ---, +++)
298
+ }
299
+
300
+ return result;
301
+ }
302
+
303
+ async function getFileDiff(cwd, filePath, options = {}) {
304
+ const { staged = false, untracked = false, context = 3 } = options;
305
+
306
+ try {
307
+ // Untracked files: use --no-index to diff against the null device
308
+ const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
309
+ if (untracked) {
310
+ const raw = await new Promise((resolve, reject) => {
311
+ require('child_process').execFile(
312
+ 'git',
313
+ ['diff', '--no-index', '--no-color', `--unified=${context}`, '--', nullDevice, filePath],
314
+ {
315
+ cwd,
316
+ timeout: GIT_TIMEOUT,
317
+ maxBuffer: MAX_DIFF_BUFFER,
318
+ },
319
+ (err, stdout) => {
320
+ // git diff --no-index exits with 1 when files differ — that's expected
321
+ if (err && err.code !== 1) return reject(err);
322
+ resolve(stdout || '');
323
+ },
324
+ );
325
+ });
326
+ return parseDiffOutput(raw, filePath);
327
+ }
328
+
329
+ const args = ['diff', `--unified=${context}`, '--no-color'];
330
+ if (staged) args.push('--cached');
331
+ args.push('--', filePath);
332
+
333
+ const raw = await gitAsync(args, cwd, { maxBuffer: MAX_DIFF_BUFFER });
334
+ return parseDiffOutput(raw, filePath);
335
+ } catch (err) {
336
+ // Empty diff or git error
337
+ if (err.code !== 1) {
338
+ log.warn(`getFileDiff failed: ${err.message}`);
339
+ }
340
+ }
341
+
342
+ return {
343
+ file: filePath,
344
+ hunks: [],
345
+ additions: 0,
346
+ deletions: 0,
347
+ isBinary: false,
348
+ };
349
+ }
350
+
351
+ async function getFileBlame(cwd, filePath) {
352
+ const result = { file: filePath, lines: [] };
353
+
354
+ try {
355
+ const raw = await gitAsync(['blame', '--porcelain', '--', filePath], cwd, {
356
+ maxBuffer: MAX_BLAME_BUFFER,
357
+ });
358
+
359
+ const rawLines = raw.split('\n');
360
+ let currentCommit = null;
361
+ let currentLine = null;
362
+ const commitInfo = {}; // cache commit metadata
363
+
364
+ for (let i = 0; i < rawLines.length; i++) {
365
+ const line = rawLines[i];
366
+
367
+ // Commit line: <40-char-hash> <orig-line> <final-line> [<num-lines>]
368
+ const commitMatch = line.match(/^([0-9a-f]{40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/);
369
+ if (commitMatch) {
370
+ currentCommit = commitMatch[1];
371
+ currentLine = parseInt(commitMatch[3], 10);
372
+
373
+ if (!commitInfo[currentCommit]) {
374
+ commitInfo[currentCommit] = {
375
+ author: null,
376
+ date: null,
377
+ summary: null,
378
+ };
379
+ }
380
+ continue;
381
+ }
382
+
383
+ // Metadata lines
384
+ if (currentCommit && line.startsWith('author ')) {
385
+ commitInfo[currentCommit].author = line.slice(7);
386
+ } else if (currentCommit && line.startsWith('author-time ')) {
387
+ const timestamp = parseInt(line.slice(12), 10);
388
+ commitInfo[currentCommit].date = new Date(timestamp * 1000).toISOString();
389
+ } else if (currentCommit && line.startsWith('summary ')) {
390
+ commitInfo[currentCommit].summary = line.slice(8);
391
+ } else if (currentCommit && line.startsWith('\t')) {
392
+ // Content line
393
+ const info = commitInfo[currentCommit];
394
+ const isUncommitted = currentCommit === '0000000000000000000000000000000000000000';
395
+ result.lines.push({
396
+ line: currentLine,
397
+ content: line.slice(1),
398
+ commit: isUncommitted ? null : currentCommit.slice(0, 7),
399
+ author: isUncommitted ? 'Not Committed Yet' : info.author || 'Unknown',
400
+ date: isUncommitted ? null : info.date || null,
401
+ summary: isUncommitted ? 'Uncommitted changes' : info.summary || '',
402
+ });
403
+ }
404
+ }
405
+ } catch (err) {
406
+ log.warn(`getFileBlame failed: ${err.message}`);
407
+ }
408
+
409
+ return result;
410
+ }
411
+
412
+ const LOG_SEPARATOR = '---GIT_LOG_SEP---';
413
+ const LOG_FIELD_SEP = '---GIT_FIELD_SEP---';
414
+ const LOG_FORMAT = [
415
+ '%H', // hash
416
+ '%h', // short hash
417
+ '%an', // author name
418
+ '%ae', // author email
419
+ '%aI', // author date ISO
420
+ '%s', // subject
421
+ '%b', // body
422
+ ].join(LOG_FIELD_SEP);
423
+
424
+ async function getGitLog(cwd, options = {}) {
425
+ const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100);
426
+ const result = { commits: [] };
427
+
428
+ try {
429
+ const args = ['log', `--format=${LOG_SEPARATOR}${LOG_FORMAT}`, `-n`, String(limit)];
430
+ if (options.file) {
431
+ args.push('--follow', '--', options.file);
432
+ }
433
+
434
+ const raw = await gitAsync(args, cwd, { maxBuffer: MAX_LOG_BUFFER });
435
+
436
+ const entries = raw.split(LOG_SEPARATOR).filter((e) => e.trim());
437
+ for (const entry of entries) {
438
+ const fields = entry.trim().split(LOG_FIELD_SEP);
439
+ if (fields.length < 6) continue;
440
+ result.commits.push({
441
+ hash: fields[0],
442
+ shortHash: fields[1],
443
+ author: fields[2],
444
+ email: fields[3],
445
+ date: fields[4],
446
+ subject: fields[5],
447
+ body: (fields[6] || '').trim(),
448
+ });
449
+ }
450
+ } catch (err) {
451
+ log.warn(`getGitLog failed: ${err.message}`);
452
+ }
453
+
454
+ return result;
455
+ }
456
+
457
+ module.exports = {
458
+ getGitInfo,
459
+ parseRemoteUrl,
460
+ parseStatus,
461
+ getDetailedStatus,
462
+ getFileDiff,
463
+ getFileBlame,
464
+ getGitLog,
465
+ };
@@ -73,6 +73,8 @@ function normalizeVersion(version) {
73
73
  /**
74
74
  * Compare two semver version strings (e.g. "1.10.2" vs "1.11.0").
75
75
  * Returns true if `latest` is newer than `current`.
76
+ * Pre-release versions (e.g. "1.15.3-rc.1") are considered older than
77
+ * the same stable version ("1.15.3"), so an update will be offered.
76
78
  * Returns false if either version cannot be parsed.
77
79
  */
78
80
  function isNewerVersion(current, latest) {
@@ -83,9 +85,25 @@ function isNewerVersion(current, latest) {
83
85
  if (lat[i] > cur[i]) return true;
84
86
  if (lat[i] < cur[i]) return false;
85
87
  }
88
+ // Same base version — if current is a pre-release but latest is stable,
89
+ // the stable release is newer (e.g. 1.15.3-rc.1 → 1.15.3)
90
+ if (isPreRelease(current) && !isPreRelease(latest)) return true;
86
91
  return false;
87
92
  }
88
93
 
94
+ /**
95
+ * Check if a version string contains pre-release metadata (e.g. "-rc.1", "-dev.5").
96
+ */
97
+ function isPreRelease(version) {
98
+ if (typeof version !== 'string') return false;
99
+ let v = version.trim();
100
+ if (v[0] === 'v' || v[0] === 'V') v = v.slice(1);
101
+ // Strip build metadata first (+foo)
102
+ const plusIdx = v.indexOf('+');
103
+ if (plusIdx !== -1) v = v.slice(0, plusIdx);
104
+ return v.includes('-');
105
+ }
106
+
89
107
  /**
90
108
  * Strip ANSI escape sequences and control characters from a string.
91
109
  * Prevents terminal injection if the registry returns malicious data.
@@ -222,39 +240,151 @@ async function checkForUpdate({ currentVersion, force = false } = {}) {
222
240
  }
223
241
 
224
242
  /**
225
- * Detect how TermBeam was installed and return the appropriate update command.
226
- * @returns {{ method: string, command: string }}
243
+ * Detect how TermBeam was installed and return the appropriate update command,
244
+ * whether it can auto-update, and the restart strategy.
245
+ * @returns {{ method: string, command: string, canAutoUpdate: boolean, restartStrategy: 'pm2'|'exit'|'none', installCmd: string|null, installArgs: string[]|null }}
246
+ * Note: installCmd/installArgs are internal — stripped before sending to API clients.
227
247
  */
228
248
  function detectInstallMethod() {
229
249
  // npx / npm exec — npm sets npm_command=exec
230
250
  if (process.env.npm_command === 'exec') {
231
251
  log.debug('Install method: npx');
232
- return { method: 'npx', command: 'npx termbeam@latest' };
252
+ return {
253
+ method: 'npx',
254
+ command: 'npx termbeam@latest',
255
+ installCmd: 'npx',
256
+ installArgs: ['termbeam@latest'],
257
+ canAutoUpdate: false,
258
+ restartStrategy: 'none',
259
+ };
233
260
  }
234
261
 
262
+ // PM2 managed — detect via PM2 environment variables
263
+ const isPm2 = isRunningUnderPm2();
264
+
235
265
  // Detect package manager from npm_execpath (set during npm/yarn/pnpm lifecycle)
266
+ // Check this before file-system checks since env vars are more reliable
236
267
  const execPath = process.env.npm_execpath || '';
237
268
  if (execPath.includes('yarn')) {
238
- log.debug('Install method: yarn');
239
- return { method: 'yarn', command: 'yarn global add termbeam@latest' };
269
+ log.debug(`Install method: yarn${isPm2 ? ' (PM2)' : ''}`);
270
+ return {
271
+ method: 'yarn',
272
+ command: 'yarn global add termbeam@latest',
273
+ installCmd: 'yarn',
274
+ installArgs: ['global', 'add', 'termbeam@latest'],
275
+ canAutoUpdate: true,
276
+ restartStrategy: isPm2 ? 'pm2' : 'exit',
277
+ };
240
278
  }
241
279
  if (execPath.includes('pnpm')) {
242
- log.debug('Install method: pnpm');
243
- return { method: 'pnpm', command: 'pnpm add -g termbeam@latest' };
280
+ log.debug(`Install method: pnpm${isPm2 ? ' (PM2)' : ''}`);
281
+ return {
282
+ method: 'pnpm',
283
+ command: 'pnpm add -g termbeam@latest',
284
+ installCmd: 'pnpm',
285
+ installArgs: ['add', '-g', 'termbeam@latest'],
286
+ canAutoUpdate: true,
287
+ restartStrategy: isPm2 ? 'pm2' : 'exit',
288
+ };
289
+ }
290
+
291
+ // Development / git clone — not in node_modules and .git exists
292
+ // Check before Docker: a git checkout running inside a container (CI/devcontainers)
293
+ // should be treated as source, not Docker
294
+ if (isRunningFromSource()) {
295
+ log.debug('Install method: source');
296
+ return {
297
+ method: 'source',
298
+ command: 'git pull && npm install && npm run build:frontend',
299
+ installCmd: null,
300
+ installArgs: null,
301
+ canAutoUpdate: false,
302
+ restartStrategy: 'none',
303
+ };
304
+ }
305
+
306
+ // Docker — check for /.dockerenv or /proc/1/cgroup containing docker
307
+ if (isRunningInDocker()) {
308
+ log.debug('Install method: docker');
309
+ return {
310
+ method: 'docker',
311
+ command: 'docker pull termbeam:latest && docker-compose up -d',
312
+ installCmd: null,
313
+ installArgs: null,
314
+ canAutoUpdate: false,
315
+ restartStrategy: 'none',
316
+ };
244
317
  }
245
318
 
246
319
  // Default: npm global install
247
- log.debug('Install method: npm');
248
- return { method: 'npm', command: 'npm install -g termbeam@latest' };
320
+ log.debug(`Install method: npm${isPm2 ? ' (PM2)' : ''}`);
321
+ return {
322
+ method: 'npm',
323
+ command: 'npm install -g termbeam@latest',
324
+ installCmd: 'npm',
325
+ installArgs: ['install', '-g', 'termbeam@latest'],
326
+ canAutoUpdate: true,
327
+ restartStrategy: isPm2 ? 'pm2' : 'exit',
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Detect if running inside a Docker container.
333
+ */
334
+ function isRunningInDocker() {
335
+ try {
336
+ if (fs.existsSync('/.dockerenv')) return true;
337
+ } catch {
338
+ // ignore
339
+ }
340
+ try {
341
+ const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
342
+ if (cgroup.includes('docker') || cgroup.includes('containerd')) return true;
343
+ } catch {
344
+ // Not Linux or no access — not Docker
345
+ }
346
+ return false;
347
+ }
348
+
349
+ /**
350
+ * Detect if running from a git source checkout (not installed as a package).
351
+ * Walks upward from __dirname looking for .git to avoid fragile fixed-depth assumptions.
352
+ */
353
+ function isRunningFromSource() {
354
+ // If __dirname is inside node_modules, it's a package install
355
+ if (__dirname.includes('node_modules')) return false;
356
+ try {
357
+ let currentDir = __dirname;
358
+ for (let i = 0; i < 10; i++) {
359
+ if (fs.existsSync(path.join(currentDir, '.git'))) return true;
360
+ const parentDir = path.dirname(currentDir);
361
+ if (!parentDir || parentDir === currentDir) break;
362
+ currentDir = parentDir;
363
+ }
364
+ return false;
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Detect if running under PM2 process manager.
372
+ */
373
+ function isRunningUnderPm2() {
374
+ return !!(process.env.PM2_HOME || process.env.pm_id || process.env.PM2_USAGE);
249
375
  }
250
376
 
251
377
  module.exports = {
252
378
  checkForUpdate,
253
379
  isNewerVersion,
380
+ isPreRelease,
254
381
  normalizeVersion,
255
382
  fetchLatestVersion,
256
383
  readCache,
257
384
  writeCache,
258
385
  sanitizeVersion,
259
386
  detectInstallMethod,
387
+ isRunningInDocker,
388
+ isRunningFromSource,
389
+ isRunningUnderPm2,
260
390
  };