termbeam 1.12.5 → 1.13.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.
@@ -294,6 +294,7 @@ function createAuth(password) {
294
294
  // Periodically clean up expired tokens and stale rate-limit entries
295
295
  const cleanupInterval = setInterval(
296
296
  () => {
297
+ log.debug('Token cleanup: removing expired auth and share tokens');
297
298
  const now = Date.now();
298
299
  for (const [token, expiry] of tokens) {
299
300
  if (now > expiry) tokens.delete(token);
@@ -339,6 +340,7 @@ function createAuth(password) {
339
340
  function generateToken() {
340
341
  const token = crypto.randomBytes(32).toString('hex');
341
342
  tokens.set(token, Date.now() + 24 * 60 * 60 * 1000);
343
+ log.debug('Auth token generated (24h expiry)');
342
344
  return token;
343
345
  }
344
346
 
@@ -84,6 +84,7 @@ function createTermBeamServer(overrides = {}) {
84
84
  function shutdown() {
85
85
  if (shuttingDown) return;
86
86
  shuttingDown = true;
87
+ log.info('Shutdown initiated');
87
88
  auth.cleanup();
88
89
  sessions.shutdown();
89
90
  cleanupUploadedFiles();
@@ -231,6 +232,7 @@ function createTermBeamServer(overrides = {}) {
231
232
  publicUrl = tunnel.url;
232
233
  state.shareBaseUrl = publicUrl;
233
234
  } else {
235
+ log.warn('Tunnel failed to start, falling back to LAN-only');
234
236
  console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
235
237
  }
236
238
  }
@@ -322,18 +324,22 @@ module.exports = { createTermBeamServer, getLocalIP };
322
324
  // Auto-start when run directly (e.g. `node src/server.js`)
323
325
  if (require.main === module) {
324
326
  const instance = createTermBeamServer();
327
+ const log = require('../utils/logger');
325
328
 
326
329
  process.on('SIGINT', () => {
330
+ log.info('Received SIGINT signal');
327
331
  console.log('\n[termbeam] Shutting down...');
328
332
  instance.shutdown();
329
333
  setTimeout(() => process.exit(0), 500).unref();
330
334
  });
331
335
  process.on('SIGTERM', () => {
336
+ log.info('Received SIGTERM signal');
332
337
  console.log('\n[termbeam] Shutting down...');
333
338
  instance.shutdown();
334
339
  setTimeout(() => process.exit(0), 500).unref();
335
340
  });
336
341
  process.on('uncaughtException', (err) => {
342
+ log.error(`Uncaught exception: ${err.message}`);
337
343
  console.error('[termbeam] Uncaught exception:', err.message);
338
344
  cleanupTunnel();
339
345
  process.exit(1);
@@ -26,6 +26,7 @@ function createPreviewProxy() {
26
26
  function proxyRequest(req, res) {
27
27
  const port = Number(req.params.port);
28
28
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
29
+ log.warn(`Preview proxy: invalid port ${req.params.port} rejected`);
29
30
  return res
30
31
  .status(400)
31
32
  .json({ error: 'Invalid port: must be an integer between 1 and 65535' });
@@ -87,6 +88,7 @@ function createPreviewProxy() {
87
88
  });
88
89
 
89
90
  proxyReq.setTimeout(PROXY_TIMEOUT, () => {
91
+ log.warn(`Preview proxy: request to port ${port} timed out after ${PROXY_TIMEOUT}ms`);
90
92
  proxyReq.destroy();
91
93
  if (!res.headersSent) {
92
94
  res.status(504).json({ error: 'Gateway timeout: upstream server did not respond in time' });
@@ -83,12 +83,14 @@ function setupRoutes(app, { auth, sessions, config, state }) {
83
83
 
84
84
  // Version API
85
85
  app.get('/api/version', (_req, res) => {
86
+ log.debug('Version requested');
86
87
  const { getVersion } = require('../utils/version');
87
88
  res.json({ version: getVersion() });
88
89
  });
89
90
 
90
91
  // Update check API
91
92
  app.get('/api/update-check', apiRateLimit, auth.middleware, async (req, res) => {
93
+ log.debug('Update check requested');
92
94
  const { checkForUpdate, detectInstallMethod } = require('../utils/update-check');
93
95
  const force = req.query.force === 'true';
94
96
 
@@ -97,7 +99,8 @@ function setupRoutes(app, { auth, sessions, config, state }) {
97
99
  const installInfo = detectInstallMethod();
98
100
  state.updateInfo = { ...info, ...installInfo };
99
101
  res.json(state.updateInfo);
100
- } catch {
102
+ } catch (err) {
103
+ log.warn(`Update check failed: ${err.message}`);
101
104
  const installInfo = detectInstallMethod();
102
105
  const fallback = {
103
106
  current: config.version,
@@ -144,6 +147,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
144
147
 
145
148
  // Share token — generates a temporary share token for the share button
146
149
  app.get('/api/share-token', auth.middleware, (req, res) => {
150
+ log.debug('Share token requested');
147
151
  if (!auth.password) return res.status(404).json({ error: 'auth disabled' });
148
152
  const shareToken = auth.generateShareToken();
149
153
  const base = (state && state.shareBaseUrl) || `${req.protocol}://${req.get('host')}`;
@@ -152,6 +156,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
152
156
 
153
157
  // Session API
154
158
  app.get('/api/sessions', apiRateLimit, auth.middleware, (_req, res) => {
159
+ log.debug('Sessions list requested');
155
160
  res.json(sessions.list());
156
161
  });
157
162
 
@@ -163,6 +168,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
163
168
  const availableShells = detectShells();
164
169
  const isValid = availableShells.some((s) => s.path === shell || s.cmd === shell);
165
170
  if (!isValid) {
171
+ log.warn(`Session creation failed: invalid shell "${shell}"`);
166
172
  return res.status(400).json({ error: 'Invalid shell' });
167
173
  }
168
174
  }
@@ -170,6 +176,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
170
176
  // Validate args field — must be an array of strings
171
177
  if (shellArgs !== undefined) {
172
178
  if (!Array.isArray(shellArgs) || !shellArgs.every((a) => typeof a === 'string')) {
179
+ log.warn('Session creation failed: args must be an array of strings');
173
180
  return res.status(400).json({ error: 'args must be an array of strings' });
174
181
  }
175
182
  }
@@ -177,6 +184,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
177
184
  // Validate initialCommand field — must be a string
178
185
  if (initialCommand !== undefined && initialCommand !== null) {
179
186
  if (typeof initialCommand !== 'string') {
187
+ log.warn('Session creation failed: initialCommand must be a string');
180
188
  return res.status(400).json({ error: 'initialCommand must be a string' });
181
189
  }
182
190
  }
@@ -184,13 +192,16 @@ function setupRoutes(app, { auth, sessions, config, state }) {
184
192
  // Validate cwd field
185
193
  if (cwd) {
186
194
  if (!path.isAbsolute(cwd)) {
195
+ log.warn(`Session creation failed: cwd must be an absolute path (got "${cwd}")`);
187
196
  return res.status(400).json({ error: 'cwd must be an absolute path' });
188
197
  }
189
198
  try {
190
199
  if (!fs.statSync(cwd).isDirectory()) {
200
+ log.warn(`Session creation failed: cwd is not a directory (${cwd})`);
191
201
  return res.status(400).json({ error: 'cwd is not a directory' });
192
202
  }
193
203
  } catch {
204
+ log.warn(`Session creation failed: cwd does not exist (${cwd})`);
194
205
  return res.status(400).json({ error: 'cwd does not exist' });
195
206
  }
196
207
  }
@@ -216,6 +227,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
216
227
 
217
228
  // Available shells
218
229
  app.get('/api/shells', auth.middleware, (_req, res) => {
230
+ log.debug('Available shells requested');
219
231
  const shells = detectShells();
220
232
  const ds = config.defaultShell;
221
233
  const match = shells.find((s) => s.cmd === ds || s.path === ds || s.name === ds);
@@ -223,6 +235,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
223
235
  });
224
236
 
225
237
  app.get('/api/sessions/:id/detect-port', auth.middleware, (req, res) => {
238
+ log.debug(`Port detection requested for session ${req.params.id}`);
226
239
  const session = sessions.get(req.params.id);
227
240
  if (!session) return res.status(404).json({ error: 'not found' });
228
241
 
@@ -236,6 +249,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
236
249
  }
237
250
 
238
251
  if (lastPort !== null) {
252
+ log.debug(`Port detected for session ${req.params.id}: ${lastPort}`);
239
253
  res.json({ detected: true, port: lastPort });
240
254
  } else {
241
255
  res.json({ detected: false });
@@ -244,8 +258,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
244
258
 
245
259
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
246
260
  if (sessions.delete(req.params.id)) {
261
+ log.info(`Session deleted: ${req.params.id}`);
247
262
  res.status(204).end();
248
263
  } else {
264
+ log.warn(`Session delete failed: not found (${req.params.id})`);
249
265
  res.status(404).json({ error: 'not found' });
250
266
  }
251
267
  });
@@ -256,14 +272,17 @@ function setupRoutes(app, { auth, sessions, config, state }) {
256
272
  if (color !== undefined) updates.color = color;
257
273
  if (name !== undefined) updates.name = name;
258
274
  if (sessions.update(req.params.id, updates)) {
275
+ log.info(`Session updated: ${req.params.id}`);
259
276
  res.json({ ok: true });
260
277
  } else {
278
+ log.warn(`Session update failed: not found (${req.params.id})`);
261
279
  res.status(404).json({ error: 'not found' });
262
280
  }
263
281
  });
264
282
 
265
283
  // Image upload
266
284
  app.post('/api/upload', auth.middleware, (req, res) => {
285
+ log.debug('Image upload started');
267
286
  const contentType = req.headers['content-type'] || '';
268
287
  if (!contentType.startsWith('image/')) {
269
288
  log.warn(`Upload rejected: invalid content-type "${contentType}"`);
@@ -334,6 +353,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
334
353
 
335
354
  // General file upload to a session's working directory
336
355
  app.post('/api/sessions/:id/upload', apiRateLimit, auth.middleware, (req, res) => {
356
+ log.debug(`File upload started for session ${req.params.id}`);
337
357
  const session = sessions.get(req.params.id);
338
358
  if (!session) {
339
359
  return res.status(404).json({ error: 'Session not found' });
@@ -440,6 +460,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
440
460
 
441
461
  // Directory listing for folder browser
442
462
  app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
463
+ log.debug(`Directory listing requested: ${req.query.q || config.cwd}`);
443
464
  const query = req.query.q || config.cwd + path.sep;
444
465
  const endsWithSep = query.endsWith('/') || query.endsWith('\\');
445
466
  const dir = path.resolve(endsWithSep ? query : path.dirname(query));
@@ -453,13 +474,15 @@ function setupRoutes(app, { auth, sessions, config, state }) {
453
474
  .slice(0, 50)
454
475
  .map((e) => path.join(dir, e.name));
455
476
  res.json({ base: dir, dirs });
456
- } catch {
477
+ } catch (err) {
478
+ log.warn(`Directory listing failed: ${err.message}`);
457
479
  res.json({ base: dir, dirs: [] });
458
480
  }
459
481
  });
460
482
  }
461
483
 
462
484
  function cleanupUploadedFiles() {
485
+ log.debug(`Cleaning up ${uploadedFiles.size} uploaded files`);
463
486
  for (const [_id, filepath] of uploadedFiles) {
464
487
  try {
465
488
  if (fs.existsSync(filepath)) {
@@ -15,17 +15,20 @@ function getCachedGitInfo(sessionId, pid, originalCwd) {
15
15
  const now = Date.now();
16
16
  const cached = _gitCache.get(sessionId);
17
17
  if (cached && now - cached.ts < GIT_CACHE_TTL) {
18
+ log.debug(`Git cache hit for session ${sessionId}`);
18
19
  return { cwd: cached.cwd, git: cached.git };
19
20
  }
20
21
 
21
22
  // Always refresh asynchronously to avoid blocking the event loop.
22
23
  // Return stale data if available, or null on first call.
24
+ log.debug(`Git cache miss for session ${sessionId}, scheduling refresh`);
23
25
  scheduleGitRefresh(sessionId, pid, originalCwd);
24
26
  if (cached) return { cwd: cached.cwd, git: cached.git };
25
27
  return { cwd: originalCwd, git: null };
26
28
  }
27
29
 
28
30
  function scheduleGitRefresh(sessionId, pid, originalCwd) {
31
+ log.debug(`Scheduling git refresh for session ${sessionId}`);
29
32
  // Mark as refreshing to prevent duplicate refreshes
30
33
  const cached = _gitCache.get(sessionId);
31
34
  if (cached && cached._refreshing) return;
@@ -61,6 +64,7 @@ function scheduleGitRefresh(sessionId, pid, originalCwd) {
61
64
  }
62
65
  const git = getGitInfo(liveCwd);
63
66
  _gitCache.set(sessionId, { cwd: liveCwd, git, ts: Date.now() });
67
+ log.debug(`Git refresh complete for session ${sessionId} (cwd=${liveCwd})`);
64
68
  });
65
69
  }
66
70
 
@@ -97,14 +101,17 @@ class SessionManager {
97
101
  /[;&|`$(){}\[\]!#~]/.test(shell) ||
98
102
  (!path.isAbsolute(shell) && !shell.match(/^[a-zA-Z0-9._-]+(\.exe)?$/))
99
103
  ) {
104
+ log.warn(`Invalid shell rejected: ${shell}`);
100
105
  throw new Error('Invalid shell');
101
106
  }
102
107
 
103
108
  // Defense-in-depth: validate args and initialCommand types
104
109
  if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
110
+ log.warn(`Invalid args rejected: ${JSON.stringify(args)}`);
105
111
  throw new Error('args must be an array of strings');
106
112
  }
107
113
  if (initialCommand !== null && typeof initialCommand !== 'string') {
114
+ log.warn(`Invalid initialCommand rejected: ${typeof initialCommand}`);
108
115
  throw new Error('initialCommand must be a string');
109
116
  }
110
117
 
@@ -112,6 +119,7 @@ class SessionManager {
112
119
  if (!color) {
113
120
  color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
114
121
  }
122
+ log.debug(`Spawning PTY: shell=${shell}, args=[${args.length} items], cwd=${cwd}`);
115
123
  const ptyProcess = pty.spawn(shell, args, {
116
124
  name: 'xterm-256color',
117
125
  cols,
@@ -122,6 +130,7 @@ class SessionManager {
122
130
 
123
131
  // Send initial command once the shell is ready
124
132
  if (initialCommand) {
133
+ log.debug(`Scheduling initialCommand for session ${id} (${initialCommand.length} chars)`);
125
134
  setTimeout(() => ptyProcess.write(initialCommand + '\r'), 300);
126
135
  }
127
136
 
@@ -177,6 +186,7 @@ class SessionManager {
177
186
  }
178
187
  // High/low water scrollback cap: trim to 500k chars when buffer exceeds 1,000,000 chars
179
188
  if (session.scrollbackBuf.length > 1000000) {
189
+ log.debug(`Trimming scrollback buffer from ${session.scrollbackBuf.length} to 500k chars`);
180
190
  let buf = session.scrollbackBuf.slice(-500000);
181
191
  // Advance to first newline to avoid starting mid-line
182
192
  const nlIdx = buf.indexOf('\n');
@@ -211,8 +221,18 @@ class SessionManager {
211
221
  update(id, fields) {
212
222
  const s = this.sessions.get(id);
213
223
  if (!s) return false;
214
- if (fields.color !== undefined) s.color = fields.color;
215
- if (fields.name !== undefined) s.name = fields.name;
224
+ const changes = [];
225
+ if (fields.color !== undefined) {
226
+ s.color = fields.color;
227
+ changes.push(`color=${fields.color}`);
228
+ }
229
+ if (fields.name !== undefined) {
230
+ s.name = fields.name;
231
+ changes.push(`name=${fields.name}`);
232
+ }
233
+ if (changes.length > 0) {
234
+ log.debug(`Session ${id} updated: ${changes.join(', ')}`);
235
+ }
216
236
  return true;
217
237
  }
218
238
 
@@ -242,15 +262,17 @@ class SessionManager {
242
262
  git,
243
263
  });
244
264
  }
265
+ log.debug(`Listing ${list.length} session(s)`);
245
266
  return list;
246
267
  }
247
268
 
248
269
  shutdown() {
270
+ log.info(`Shutting down ${this.sessions.size} session(s)`);
249
271
  for (const [_id, s] of this.sessions) {
250
272
  try {
251
273
  s.pty.kill();
252
- } catch {
253
- /* ignore */
274
+ } catch (err) {
275
+ log.warn(`Failed to kill session ${_id}: ${err.message}`);
254
276
  }
255
277
  }
256
278
  this.sessions.clear();
@@ -54,6 +54,7 @@ function recalcPtySize(session) {
54
54
  if (minCols === session._lastCols && minRows === session._lastRows) return;
55
55
  session._lastCols = minCols;
56
56
  session._lastRows = minRows;
57
+ log.debug(`PTY resized to ${minCols}×${minRows}`);
57
58
  session.pty.resize(minCols, minRows);
58
59
  }
59
60
 
@@ -177,6 +178,7 @@ function setupWebSocket(wss, { auth, sessions }) {
177
178
  } else if (msg.type === 'resize') {
178
179
  const cols = Math.floor(msg.cols);
179
180
  const rows = Math.floor(msg.rows);
181
+ log.debug(`Client resize request: ${cols}×${rows}`);
180
182
  if (cols > 0 && cols <= 500 && rows > 0 && rows <= 200) {
181
183
  ws._dims = { cols, rows };
182
184
  ws._lastActivity = Date.now();
package/src/utils/git.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { execSync } = require('child_process');
2
2
  const path = require('path');
3
+ const log = require('./logger');
3
4
 
4
5
  function git(cmd, cwd) {
5
6
  return execSync(`git ${cmd}`, { cwd, stdio: 'pipe', timeout: 3000 }).toString().trim();
@@ -9,6 +10,7 @@ function getGitInfo(cwd) {
9
10
  try {
10
11
  git('rev-parse --is-inside-work-tree', cwd);
11
12
  } catch {
13
+ log.debug(`Not a git repository: ${cwd}`);
12
14
  return null;
13
15
  }
14
16
 
@@ -59,6 +61,7 @@ function getGitInfo(cwd) {
59
61
  /* ignore */
60
62
  }
61
63
 
64
+ log.debug(`Git info resolved for ${cwd}`);
62
65
  return result;
63
66
  }
64
67
 
@@ -3,6 +3,7 @@ const http = require('http');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const log = require('./logger');
6
7
 
7
8
  const PACKAGE_NAME = 'termbeam';
8
9
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -114,9 +115,11 @@ function sanitizeVersion(v) {
114
115
  function fetchLatestVersion(registryUrl) {
115
116
  const url = registryUrl || REGISTRY_URL;
116
117
  const client = url.startsWith('https') ? https : http;
118
+ log.debug('Fetching latest version from npm registry');
117
119
  return new Promise((resolve) => {
118
120
  const req = client.get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
119
121
  if (res.statusCode !== 200) {
122
+ log.warn(`Registry returned HTTP ${res.statusCode}`);
120
123
  res.resume();
121
124
  resolve(null);
122
125
  return;
@@ -154,8 +157,12 @@ function fetchLatestVersion(registryUrl) {
154
157
  });
155
158
  // Unref so a pending update check can't delay process exit
156
159
  req.on('socket', (socket) => socket.unref());
157
- req.on('error', () => resolve(null));
160
+ req.on('error', (err) => {
161
+ log.debug(`Network error checking updates: ${err.message}`);
162
+ resolve(null);
163
+ });
158
164
  req.on('timeout', () => {
165
+ log.warn('Update check timed out');
159
166
  req.destroy();
160
167
  resolve(null);
161
168
  });
@@ -170,6 +177,7 @@ function fetchLatestVersion(registryUrl) {
170
177
  * @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean}>}
171
178
  */
172
179
  async function checkForUpdate({ currentVersion, force = false } = {}) {
180
+ log.debug(`Update check: current=${currentVersion}`);
173
181
  if (!currentVersion) {
174
182
  return { current: 'unknown', latest: null, updateAvailable: false };
175
183
  }
@@ -180,6 +188,7 @@ async function checkForUpdate({ currentVersion, force = false } = {}) {
180
188
  if (cache && Date.now() - cache.checkedAt < CACHE_TTL_MS) {
181
189
  const cachedLatest = typeof cache.latest === 'string' ? sanitizeVersion(cache.latest) : null;
182
190
  if (cachedLatest && /^\d+\.\d+\.\d+$/.test(cachedLatest)) {
191
+ log.debug('Using cached update check result');
183
192
  return {
184
193
  current: currentVersion,
185
194
  latest: cachedLatest,
@@ -198,10 +207,17 @@ async function checkForUpdate({ currentVersion, force = false } = {}) {
198
207
  // Cache the result
199
208
  writeCache(latest);
200
209
 
210
+ const updateAvailable = isNewerVersion(currentVersion, latest);
211
+ log.debug(
212
+ updateAvailable
213
+ ? `Update available: ${currentVersion} → ${latest}`
214
+ : `Already on latest version: ${currentVersion}`,
215
+ );
216
+
201
217
  return {
202
218
  current: currentVersion,
203
219
  latest,
204
- updateAvailable: isNewerVersion(currentVersion, latest),
220
+ updateAvailable,
205
221
  };
206
222
  }
207
223
 
@@ -212,19 +228,23 @@ async function checkForUpdate({ currentVersion, force = false } = {}) {
212
228
  function detectInstallMethod() {
213
229
  // npx / npm exec — npm sets npm_command=exec
214
230
  if (process.env.npm_command === 'exec') {
231
+ log.debug('Install method: npx');
215
232
  return { method: 'npx', command: 'npx termbeam@latest' };
216
233
  }
217
234
 
218
235
  // Detect package manager from npm_execpath (set during npm/yarn/pnpm lifecycle)
219
236
  const execPath = process.env.npm_execpath || '';
220
237
  if (execPath.includes('yarn')) {
238
+ log.debug('Install method: yarn');
221
239
  return { method: 'yarn', command: 'yarn global add termbeam@latest' };
222
240
  }
223
241
  if (execPath.includes('pnpm')) {
242
+ log.debug('Install method: pnpm');
224
243
  return { method: 'pnpm', command: 'pnpm add -g termbeam@latest' };
225
244
  }
226
245
 
227
246
  // Default: npm global install
247
+ log.debug('Install method: npm');
228
248
  return { method: 'npm', command: 'npm install -g termbeam@latest' };
229
249
  }
230
250
 
@@ -1,5 +1,6 @@
1
1
  const path = require('path');
2
2
  const { execSync } = require('child_process');
3
+ const log = require('./logger');
3
4
 
4
5
  function getVersion() {
5
6
  const pkg = require(path.join(__dirname, '..', '..', 'package.json'));
@@ -7,7 +8,12 @@ function getVersion() {
7
8
 
8
9
  // If installed via npm (global or npx), use the package version as-is
9
10
  if (process.env.npm_package_version || isInstalledGlobally()) {
10
- return base;
11
+ log.debug(
12
+ `Version source: ${process.env.npm_package_version ? 'npm_package_version' : 'global install'}`,
13
+ );
14
+ const version = base;
15
+ log.debug(`Resolved version: ${version}`);
16
+ return version;
11
17
  }
12
18
 
13
19
  // Running from source — git tags are the version source of truth.
@@ -27,20 +33,33 @@ function getVersion() {
27
33
  const dirty = tagMatch[4];
28
34
 
29
35
  // Exactly on a clean tag — return the tag version
30
- if (!commits && !dirty) return gitVersion;
36
+ if (!commits && !dirty) {
37
+ log.debug('Version source: git describe');
38
+ const version = gitVersion;
39
+ log.debug(`Resolved version: ${version}`);
40
+ return version;
41
+ }
31
42
 
32
43
  // Build a combined semver-style dev string
33
44
  let ver = `${gitVersion}-dev`;
34
45
  if (commits) ver += `.${commits}`;
35
46
  const meta = [hash ? `g${hash}` : null, dirty ? 'dirty' : null].filter(Boolean).join('.');
36
47
  if (meta) ver += `+${meta}`;
48
+ log.debug('Version source: git describe');
49
+ log.debug(`Resolved version: ${ver}`);
37
50
  return ver;
38
51
  }
39
52
 
40
53
  // No semver tag found (e.g. bare commit hash) — fall back to package.json
41
- return `${base}-dev+${gitDesc}`;
54
+ log.debug('Version source: package.json fallback');
55
+ const version = `${base}-dev+${gitDesc}`;
56
+ log.debug(`Resolved version: ${version}`);
57
+ return version;
42
58
  } catch {
43
- return `${base}-dev`;
59
+ log.debug('Version source: package.json fallback');
60
+ const version = `${base}-dev`;
61
+ log.debug(`Resolved version: ${version}`);
62
+ return version;
44
63
  }
45
64
  }
46
65