@eldrforge/git-tools 0.1.7 → 0.1.10

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/dist/index.js CHANGED
@@ -1,5 +1,1401 @@
1
- export { ConsoleLogger, getLogger, setLogger } from './logger.js';
2
- export { escapeShellArg, run, runSecure, runSecureWithDryRunSupport, runSecureWithInheritedStdio, runWithDryRunSupport, runWithInheritedStdio, validateFilePath, validateGitRef } from './child.js';
3
- export { safeJsonParse, validateHasProperty, validatePackageJson, validateString } from './validation.js';
4
- export { findPreviousReleaseTag, getBranchCommitSha, getCurrentBranch, getCurrentVersion, getDefaultFromRef, getGitStatusSummary, getGloballyLinkedPackages, getLinkCompatibilityProblems, getLinkProblems, getLinkedDependencies, getRemoteDefaultBranch, isBranchInSyncWithRemote, isNpmLinked, isValidGitRef, localBranchExists, remoteBranchExists, safeSyncBranchWithRemote } from './git.js';
1
+ import { spawn, exec } from 'child_process';
2
+ import util from 'util';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import * as semver from 'semver';
6
+
7
+ /**
8
+ * Minimal logging interface that git-tools requires.
9
+ * Allows consumers to provide their own logger implementation (e.g., Winston, console, etc.)
10
+ */ function _define_property(obj, key, value) {
11
+ if (key in obj) {
12
+ Object.defineProperty(obj, key, {
13
+ value: value,
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true
17
+ });
18
+ } else {
19
+ obj[key] = value;
20
+ }
21
+ return obj;
22
+ }
23
+ /**
24
+ * Default console-based logger implementation
25
+ */ class ConsoleLogger {
26
+ shouldLog(level) {
27
+ const levels = [
28
+ 'error',
29
+ 'warn',
30
+ 'info',
31
+ 'verbose',
32
+ 'debug'
33
+ ];
34
+ const currentLevelIndex = levels.indexOf(this.level);
35
+ const messageLevelIndex = levels.indexOf(level);
36
+ return messageLevelIndex <= currentLevelIndex;
37
+ }
38
+ error(message, ...meta) {
39
+ if (this.shouldLog('error')) {
40
+ // eslint-disable-next-line no-console
41
+ console.error(message, ...meta);
42
+ }
43
+ }
44
+ warn(message, ...meta) {
45
+ if (this.shouldLog('warn')) {
46
+ // eslint-disable-next-line no-console
47
+ console.warn(message, ...meta);
48
+ }
49
+ }
50
+ info(message, ...meta) {
51
+ if (this.shouldLog('info')) {
52
+ // eslint-disable-next-line no-console
53
+ console.info(message, ...meta);
54
+ }
55
+ }
56
+ verbose(message, ...meta) {
57
+ if (this.shouldLog('verbose')) {
58
+ // eslint-disable-next-line no-console
59
+ console.log('[VERBOSE]', message, ...meta);
60
+ }
61
+ }
62
+ debug(message, ...meta) {
63
+ if (this.shouldLog('debug')) {
64
+ // eslint-disable-next-line no-console
65
+ console.log('[DEBUG]', message, ...meta);
66
+ }
67
+ }
68
+ constructor(level = 'info'){
69
+ _define_property(this, "level", void 0);
70
+ this.level = level;
71
+ }
72
+ }
73
+ /**
74
+ * Global logger instance - defaults to console logger
75
+ * Use setLogger() to replace with your own implementation
76
+ */ let globalLogger = new ConsoleLogger();
77
+ /**
78
+ * Set the global logger instance
79
+ */ function setLogger(logger) {
80
+ globalLogger = logger;
81
+ }
82
+ /**
83
+ * Get the global logger instance
84
+ */ function getLogger() {
85
+ return globalLogger;
86
+ }
87
+
88
+ /**
89
+ * Escapes shell arguments to prevent command injection
90
+ */ function escapeShellArg(arg) {
91
+ // For Windows, we need different escaping
92
+ if (process.platform === 'win32') {
93
+ // Escape double quotes and backslashes
94
+ return `"${arg.replace(/[\\"]/g, '\\$&')}"`;
95
+ } else {
96
+ // For Unix-like systems, escape single quotes
97
+ return `'${arg.replace(/'/g, "'\\''")}'`;
98
+ }
99
+ }
100
+ /**
101
+ * Validates git references to prevent injection
102
+ */ function validateGitRef(ref) {
103
+ // Git refs can contain letters, numbers, hyphens, underscores, slashes, and dots
104
+ // But cannot contain certain dangerous characters
105
+ const validRefPattern = /^[a-zA-Z0-9._/-]+$/;
106
+ const invalidPatterns = [
107
+ /\.\./,
108
+ /^-/,
109
+ /[\s;<>|&`$(){}[\]]/ // No shell metacharacters
110
+ ];
111
+ if (!validRefPattern.test(ref)) {
112
+ return false;
113
+ }
114
+ return !invalidPatterns.some((pattern)=>pattern.test(ref));
115
+ }
116
+ /**
117
+ * Validates file paths to prevent injection
118
+ */ function validateFilePath(filePath) {
119
+ // Basic validation - no shell metacharacters
120
+ const invalidChars = /[;<>|&`$(){}[\]]/;
121
+ return !invalidChars.test(filePath);
122
+ }
123
+ /**
124
+ * Securely executes a command with arguments array (no shell injection risk)
125
+ */ async function runSecure(command, args = [], options = {}) {
126
+ const logger = getLogger();
127
+ // Extract our custom option and pass the rest to spawn
128
+ const { suppressErrorLogging = false, ...spawnOptions } = options || {};
129
+ logger.debug(`runSecure: command="${command}" args=[${args.join(', ')}] suppressErrorLogging=${suppressErrorLogging}`);
130
+ return new Promise((resolve, reject)=>{
131
+ logger.verbose(`Executing command securely: ${command} ${args.join(' ')}`);
132
+ logger.verbose(`Working directory: ${(spawnOptions === null || spawnOptions === void 0 ? void 0 : spawnOptions.cwd) || process.cwd()}`);
133
+ const child = spawn(command, args, {
134
+ ...spawnOptions,
135
+ shell: false,
136
+ stdio: 'pipe'
137
+ });
138
+ let stdout = '';
139
+ let stderr = '';
140
+ if (child.stdout) {
141
+ child.stdout.on('data', (data)=>{
142
+ stdout += data.toString();
143
+ });
144
+ }
145
+ if (child.stderr) {
146
+ child.stderr.on('data', (data)=>{
147
+ stderr += data.toString();
148
+ });
149
+ }
150
+ child.on('close', (code)=>{
151
+ if (code === 0) {
152
+ logger.verbose(`Command completed successfully`);
153
+ logger.verbose(`stdout: ${stdout}`);
154
+ if (stderr) {
155
+ logger.verbose(`stderr: ${stderr}`);
156
+ }
157
+ resolve({
158
+ stdout,
159
+ stderr
160
+ });
161
+ } else {
162
+ if (!suppressErrorLogging) {
163
+ logger.error(`Command failed with exit code ${code}`);
164
+ logger.error(`stdout: ${stdout}`);
165
+ logger.error(`stderr: ${stderr}`);
166
+ }
167
+ reject(new Error(`Command "${[
168
+ command,
169
+ ...args
170
+ ].join(' ')}" failed with exit code ${code}`));
171
+ }
172
+ });
173
+ child.on('error', (error)=>{
174
+ if (!suppressErrorLogging) {
175
+ logger.error(`Command failed to start: ${error.message}`);
176
+ }
177
+ reject(error);
178
+ });
179
+ });
180
+ }
181
+ /**
182
+ * Securely executes a command with inherited stdio (no shell injection risk)
183
+ */ async function runSecureWithInheritedStdio(command, args = [], options = {}) {
184
+ const logger = getLogger();
185
+ return new Promise((resolve, reject)=>{
186
+ logger.verbose(`Executing command securely with inherited stdio: ${command} ${args.join(' ')}`);
187
+ logger.verbose(`Working directory: ${(options === null || options === void 0 ? void 0 : options.cwd) || process.cwd()}`);
188
+ const child = spawn(command, args, {
189
+ ...options,
190
+ shell: false,
191
+ stdio: 'inherit'
192
+ });
193
+ child.on('close', (code)=>{
194
+ if (code === 0) {
195
+ logger.verbose(`Command completed successfully with code ${code}`);
196
+ resolve();
197
+ } else {
198
+ logger.error(`Command failed with exit code ${code}`);
199
+ reject(new Error(`Command "${[
200
+ command,
201
+ ...args
202
+ ].join(' ')}" failed with exit code ${code}`));
203
+ }
204
+ });
205
+ child.on('error', (error)=>{
206
+ logger.error(`Command failed to start: ${error.message}`);
207
+ reject(error);
208
+ });
209
+ });
210
+ }
211
+ async function run(command, options = {}) {
212
+ const logger = getLogger();
213
+ const execPromise = util.promisify(exec);
214
+ // Extract our custom option and pass the rest to exec
215
+ const { suppressErrorLogging = false, ...execOptions } = options || {};
216
+ // Ensure encoding is set to 'utf8' to get string output instead of Buffer
217
+ const finalOptions = {
218
+ encoding: 'utf8',
219
+ ...execOptions
220
+ };
221
+ logger.debug(`run: command="${command}" suppressErrorLogging=${suppressErrorLogging}`);
222
+ logger.verbose(`Executing command: ${command}`);
223
+ logger.verbose(`Working directory: ${(finalOptions === null || finalOptions === void 0 ? void 0 : finalOptions.cwd) || process.cwd()}`);
224
+ logger.verbose(`Environment variables: ${Object.keys((finalOptions === null || finalOptions === void 0 ? void 0 : finalOptions.env) || process.env).length} variables`);
225
+ try {
226
+ const result = await execPromise(command, finalOptions);
227
+ logger.verbose(`Command completed successfully`);
228
+ logger.verbose(`stdout: ${result.stdout}`);
229
+ if (result.stderr) {
230
+ logger.verbose(`stderr: ${result.stderr}`);
231
+ }
232
+ // Ensure result is properly typed as strings
233
+ return {
234
+ stdout: String(result.stdout),
235
+ stderr: String(result.stderr)
236
+ };
237
+ } catch (error) {
238
+ if (!suppressErrorLogging) {
239
+ logger.error(`Command failed: ${command}`);
240
+ logger.error(`Error: ${error.message}`);
241
+ logger.error(`Exit code: ${error.code}`);
242
+ logger.error(`Signal: ${error.signal}`);
243
+ if (error.stdout) {
244
+ logger.error(`stdout: ${error.stdout}`);
245
+ }
246
+ if (error.stderr) {
247
+ logger.error(`stderr: ${error.stderr}`);
248
+ }
249
+ } else {
250
+ // Still log at debug level for troubleshooting
251
+ logger.debug(`Command failed (suppressed): ${command} | Error: ${error.message}`);
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+ /**
257
+ * @deprecated Use runSecureWithInheritedStdio instead for better security
258
+ * Legacy function for backward compatibility - parses shell command string
259
+ */ async function runWithInheritedStdio(command, options = {}) {
260
+ // Parse command to extract command and arguments safely
261
+ const parts = command.trim().split(/\s+/);
262
+ if (parts.length === 0) {
263
+ throw new Error('Empty command provided');
264
+ }
265
+ const cmd = parts[0];
266
+ const args = parts.slice(1);
267
+ // Use the secure version
268
+ return runSecureWithInheritedStdio(cmd, args, options);
269
+ }
270
+ async function runWithDryRunSupport(command, isDryRun, options = {}, useInheritedStdio = false) {
271
+ const logger = getLogger();
272
+ if (isDryRun) {
273
+ logger.info(`DRY RUN: Would execute command: ${command}`);
274
+ return {
275
+ stdout: '',
276
+ stderr: ''
277
+ };
278
+ }
279
+ if (useInheritedStdio) {
280
+ await runWithInheritedStdio(command, options);
281
+ return {
282
+ stdout: '',
283
+ stderr: ''
284
+ }; // No output captured when using inherited stdio
285
+ }
286
+ return run(command, options);
287
+ }
288
+ /**
289
+ * Secure version of runWithDryRunSupport using argument arrays
290
+ */ async function runSecureWithDryRunSupport(command, args = [], isDryRun, options = {}, useInheritedStdio = false) {
291
+ const logger = getLogger();
292
+ if (isDryRun) {
293
+ logger.info(`DRY RUN: Would execute command: ${command} ${args.join(' ')}`);
294
+ return {
295
+ stdout: '',
296
+ stderr: ''
297
+ };
298
+ }
299
+ if (useInheritedStdio) {
300
+ await runSecureWithInheritedStdio(command, args, options);
301
+ return {
302
+ stdout: '',
303
+ stderr: ''
304
+ }; // No output captured when using inherited stdio
305
+ }
306
+ return runSecure(command, args, options);
307
+ }
308
+
309
+ /**
310
+ * Runtime validation utilities for safe type handling
311
+ */ /**
312
+ * Safely parses JSON with error handling
313
+ */ const safeJsonParse = (jsonString, context)=>{
314
+ try {
315
+ const parsed = JSON.parse(jsonString);
316
+ if (parsed === null || parsed === undefined) {
317
+ throw new Error('Parsed JSON is null or undefined');
318
+ }
319
+ return parsed;
320
+ } catch (error) {
321
+ const contextStr = context ? ` (${context})` : '';
322
+ throw new Error(`Failed to parse JSON${contextStr}: ${error instanceof Error ? error.message : 'Unknown error'}`);
323
+ }
324
+ };
325
+ /**
326
+ * Validates that a value is a non-empty string
327
+ */ const validateString = (value, fieldName)=>{
328
+ if (typeof value !== 'string') {
329
+ throw new Error(`${fieldName} must be a string, got ${typeof value}`);
330
+ }
331
+ if (value.trim() === '') {
332
+ throw new Error(`${fieldName} cannot be empty`);
333
+ }
334
+ return value;
335
+ };
336
+ /**
337
+ * Validates that a value exists and has a specific property
338
+ */ const validateHasProperty = (obj, property, context)=>{
339
+ if (!obj || typeof obj !== 'object') {
340
+ const contextStr = context ? ` in ${context}` : '';
341
+ throw new Error(`Object is null or not an object${contextStr}`);
342
+ }
343
+ if (!(property in obj)) {
344
+ const contextStr = context ? ` in ${context}` : '';
345
+ throw new Error(`Missing required property '${property}'${contextStr}`);
346
+ }
347
+ };
348
+ /**
349
+ * Validates package.json structure has basic required fields
350
+ */ const validatePackageJson = (data, context, requireName = true)=>{
351
+ if (!data || typeof data !== 'object') {
352
+ const contextStr = context ? ` (${context})` : '';
353
+ throw new Error(`Invalid package.json${contextStr}: not an object`);
354
+ }
355
+ if (requireName && typeof data.name !== 'string') {
356
+ const contextStr = context ? ` (${context})` : '';
357
+ throw new Error(`Invalid package.json${contextStr}: name must be a string`);
358
+ }
359
+ return data;
360
+ };
361
+
362
+ /**
363
+ * Validates that a git remote name is safe (prevents option injection).
364
+ * Git remote names must not start with '-' and should match /^[A-Za-z0-9._\/-]+$/
365
+ */ function isValidGitRemoteName(remote) {
366
+ // Disallow starting dash, spaces, and only allow common git remote name characters.
367
+ // See: https://git-scm.com/docs/git-remote#_remotes
368
+ return typeof remote === 'string' && remote.length > 0 && !remote.startsWith('-') && /^[A-Za-z0-9._/-]+$/.test(remote);
369
+ }
370
+ /**
371
+ * Tests if a git reference exists and is valid (silent version that doesn't log errors)
372
+ */ const isValidGitRefSilent = async (ref)=>{
373
+ try {
374
+ // Validate the ref first to prevent injection
375
+ if (!validateGitRef(ref)) {
376
+ return false;
377
+ }
378
+ await runSecure('git', [
379
+ 'rev-parse',
380
+ '--verify',
381
+ ref
382
+ ], {
383
+ stdio: 'ignore',
384
+ suppressErrorLogging: true
385
+ });
386
+ return true;
387
+ } catch {
388
+ return false;
389
+ }
390
+ };
391
+ /**
392
+ * Tests if a git reference exists and is valid
393
+ */ const isValidGitRef = async (ref)=>{
394
+ const logger = getLogger();
395
+ try {
396
+ // Validate the ref first to prevent injection
397
+ if (!validateGitRef(ref)) {
398
+ logger.debug(`Git reference '${ref}' contains invalid characters`);
399
+ return false;
400
+ }
401
+ await runSecure('git', [
402
+ 'rev-parse',
403
+ '--verify',
404
+ ref
405
+ ], {
406
+ stdio: 'ignore'
407
+ });
408
+ logger.debug(`Git reference '${ref}' is valid`);
409
+ return true;
410
+ } catch (error) {
411
+ logger.debug(`Git reference '${ref}' is not valid: ${error}`);
412
+ return false;
413
+ }
414
+ };
415
+ /**
416
+ * Finds the previous release tag based on the current version using semantic versioning.
417
+ * Returns the highest version tag that is less than the current version.
418
+ *
419
+ * @param currentVersion The current version (e.g., "1.2.3", "2.0.0")
420
+ * @param tagPattern The pattern to match tags (e.g., "v*", "working/v*")
421
+ * @returns The previous release tag or null if none found
422
+ */ const findPreviousReleaseTag = async (currentVersion, tagPattern = 'v*')=>{
423
+ const logger = getLogger();
424
+ try {
425
+ // Parse current version first to validate it
426
+ const currentSemver = semver.parse(currentVersion);
427
+ if (!currentSemver) {
428
+ logger.warn(`❌ Invalid version format: ${currentVersion}`);
429
+ return null;
430
+ }
431
+ logger.info(`🔍 findPreviousReleaseTag: Looking for tags matching "${tagPattern}" < ${currentVersion}`);
432
+ // Get all tags - try sorted first, fallback to unsorted
433
+ let tags;
434
+ try {
435
+ logger.info(` Running: git tag -l "${tagPattern}" --sort=-version:refname`);
436
+ const { stdout } = await runSecure('git', [
437
+ 'tag',
438
+ '-l',
439
+ tagPattern,
440
+ '--sort=-version:refname'
441
+ ]);
442
+ tags = stdout.trim().split('\n').filter((tag)=>tag.length > 0);
443
+ logger.info(` ✅ Found ${tags.length} tags matching pattern "${tagPattern}"`);
444
+ if (tags.length > 0) {
445
+ logger.info(` 📋 Tags (newest first): ${tags.slice(0, 15).join(', ')}${tags.length > 15 ? ` ... (${tags.length - 15} more)` : ''}`);
446
+ }
447
+ } catch (sortError) {
448
+ // Fallback for older git versions that don't support --sort
449
+ logger.info(` ⚠️ Git tag --sort failed: ${sortError.message}`);
450
+ logger.info(` Falling back to manual sorting...`);
451
+ const { stdout } = await runSecure('git', [
452
+ 'tag',
453
+ '-l',
454
+ tagPattern
455
+ ]);
456
+ tags = stdout.trim().split('\n').filter((tag)=>tag.length > 0);
457
+ logger.info(` Found ${tags.length} tags (unsorted) matching pattern "${tagPattern}"`);
458
+ // Manual semantic version sorting
459
+ tags.sort((a, b)=>{
460
+ const aMatch = a.match(/v?(\d+\.\d+\.\d+.*?)$/);
461
+ const bMatch = b.match(/v?(\d+\.\d+\.\d+.*?)$/);
462
+ if (!aMatch || !bMatch) return 0;
463
+ const aSemver = semver.parse(aMatch[1]);
464
+ const bSemver = semver.parse(bMatch[1]);
465
+ if (!aSemver || !bSemver) return 0;
466
+ return semver.rcompare(aSemver, bSemver);
467
+ });
468
+ logger.info(` ✅ Sorted ${tags.length} tags manually`);
469
+ }
470
+ if (tags.length === 0) {
471
+ logger.warn('');
472
+ logger.warn(`❌ NO TAGS FOUND matching pattern "${tagPattern}"`);
473
+ logger.warn(` To verify, run: git tag -l '${tagPattern}'`);
474
+ logger.warn('');
475
+ return null;
476
+ }
477
+ logger.info(` 🔬 Processing ${tags.length} tags to find the highest version < ${currentVersion}...`);
478
+ // Find the highest version that is less than the current version
479
+ let previousTag = null;
480
+ let previousVersion = null;
481
+ let validTags = 0;
482
+ let skippedTags = 0;
483
+ for (const tag of tags){
484
+ // Extract version from tag - handle "v1.2.13", "1.2.13", and "working/v1.2.13"
485
+ const versionMatch = tag.match(/v?(\d+\.\d+\.\d+.*?)$/);
486
+ if (!versionMatch) {
487
+ logger.debug(` ⏭️ Skipping tag "${tag}" (doesn't match version pattern)`);
488
+ continue;
489
+ }
490
+ const versionString = versionMatch[1];
491
+ const tagSemver = semver.parse(versionString);
492
+ if (tagSemver) {
493
+ validTags++;
494
+ // Check if this tag version is less than current version
495
+ if (semver.lt(tagSemver, currentSemver)) {
496
+ // If we don't have a previous version yet, or this one is higher than our current previous
497
+ if (!previousVersion || semver.gt(tagSemver, previousVersion)) {
498
+ previousVersion = tagSemver;
499
+ previousTag = tag; // Keep the original tag format
500
+ logger.info(` ✅ New best candidate: ${tag} (${versionString} < ${currentVersion})`);
501
+ } else {
502
+ logger.debug(` ⏭️ ${tag} (${versionString}) is < current but not better than ${previousTag}`);
503
+ }
504
+ } else {
505
+ skippedTags++;
506
+ logger.debug(` ⏭️ ${tag} (${versionString}) >= current (${currentVersion}), skipping`);
507
+ }
508
+ }
509
+ }
510
+ logger.info('');
511
+ logger.info(` 📊 Tag analysis results:`);
512
+ logger.info(` - Total tags examined: ${tags.length}`);
513
+ logger.info(` - Valid semver tags: ${validTags}`);
514
+ logger.info(` - Tags >= current version (skipped): ${skippedTags}`);
515
+ logger.info(` - Best match: ${previousTag || 'none'}`);
516
+ logger.info('');
517
+ if (previousTag) {
518
+ logger.info(`✅ SUCCESS: Found previous tag: ${previousTag}`);
519
+ logger.info(` Version comparison: ${previousVersion === null || previousVersion === void 0 ? void 0 : previousVersion.version} < ${currentVersion}`);
520
+ logger.info('');
521
+ return previousTag;
522
+ }
523
+ logger.warn(`❌ FAILED: No previous tag found for version ${currentVersion}`);
524
+ logger.warn(` Pattern searched: "${tagPattern}"`);
525
+ logger.warn(` Reason: All ${validTags} valid tags were >= ${currentVersion}`);
526
+ logger.warn('');
527
+ return null;
528
+ } catch (error) {
529
+ logger.debug(`Error finding previous release tag: ${error.message}`);
530
+ return null;
531
+ }
532
+ };
533
+ /**
534
+ * Gets the current version from package.json
535
+ *
536
+ * @returns The current version string or null if not found
537
+ */ const getCurrentVersion = async ()=>{
538
+ const logger = getLogger();
539
+ try {
540
+ // First try to get from committed version in HEAD
541
+ const { stdout } = await runSecure('git', [
542
+ 'show',
543
+ 'HEAD:package.json'
544
+ ]);
545
+ const packageJson = safeJsonParse(stdout, 'package.json');
546
+ const validated = validatePackageJson(packageJson, 'package.json');
547
+ if (validated.version) {
548
+ logger.debug(`Current version from HEAD:package.json: ${validated.version}`);
549
+ return validated.version;
550
+ }
551
+ return null;
552
+ } catch (error) {
553
+ logger.debug(`Could not read version from HEAD:package.json: ${error.message}`);
554
+ // Fallback to reading from working directory
555
+ try {
556
+ const packageJsonPath = path.join(process.cwd(), 'package.json');
557
+ const content = await fs.readFile(packageJsonPath, 'utf-8');
558
+ const packageJson = safeJsonParse(content, 'package.json');
559
+ const validated = validatePackageJson(packageJson, 'package.json');
560
+ if (validated.version) {
561
+ logger.debug(`Current version from working directory package.json: ${validated.version}`);
562
+ return validated.version;
563
+ }
564
+ return null;
565
+ } catch (fallbackError) {
566
+ logger.debug(`Error reading current version from filesystem: ${fallbackError.message}`);
567
+ return null;
568
+ }
569
+ }
570
+ };
571
+ /**
572
+ * Gets a reliable default for the --from parameter by trying multiple fallbacks
573
+ *
574
+ * Tries in order:
575
+ * 1. Previous working branch tag (if on working branch)
576
+ * 2. Previous release tag (if current version can be determined)
577
+ * 3. main (local main branch - typical release comparison base)
578
+ * 4. master (local master branch - legacy default)
579
+ * 5. origin/main (remote main branch fallback)
580
+ * 6. origin/master (remote master branch fallback)
581
+ *
582
+ * @param forceMainBranch If true, skip tag detection and use main branch
583
+ * @param currentBranch Current branch name for branch-aware tag detection
584
+ * @returns A valid git reference to use as the default from parameter
585
+ * @throws Error if no valid reference can be found
586
+ */ const getDefaultFromRef = async (forceMainBranch = false, currentBranch)=>{
587
+ const logger = getLogger();
588
+ logger.info('');
589
+ logger.info('═══════════════════════════════════════════════════════════');
590
+ logger.info('🔍 DETECTING DEFAULT --from REFERENCE FOR RELEASE NOTES');
591
+ logger.info('═══════════════════════════════════════════════════════════');
592
+ logger.info(`📋 Input parameters:`);
593
+ logger.info(` - forceMainBranch: ${forceMainBranch}`);
594
+ logger.info(` - currentBranch: "${currentBranch}"`);
595
+ logger.info('');
596
+ // If forced to use main branch, skip tag detection
597
+ if (forceMainBranch) {
598
+ logger.info('⚡ Forced to use main branch, skipping tag detection');
599
+ } else {
600
+ // If on working branch, look for working branch tags first
601
+ logger.info(`🎯 Branch check: currentBranch="${currentBranch}", isWorking=${currentBranch === 'working'}`);
602
+ if (currentBranch && currentBranch === 'working') {
603
+ logger.info('');
604
+ logger.info('📍 DETECTED WORKING BRANCH - Searching for working branch tags...');
605
+ logger.info('───────────────────────────────────────────────────────────');
606
+ try {
607
+ const currentVersion = await getCurrentVersion();
608
+ logger.info(`📦 Current version from package.json: ${currentVersion}`);
609
+ if (currentVersion) {
610
+ logger.info(`🔍 Searching for tags matching pattern: "working/v*"`);
611
+ logger.info(` (Looking for tags < ${currentVersion})`);
612
+ logger.info('');
613
+ const previousTag = await findPreviousReleaseTag(currentVersion, 'working/v*');
614
+ logger.info(`🎯 findPreviousReleaseTag result: ${previousTag || 'null (no tag found)'}`);
615
+ if (previousTag) {
616
+ logger.info(`🔬 Validating tag reference: "${previousTag}"`);
617
+ const isValid = await isValidGitRef(previousTag);
618
+ logger.info(` Tag is valid git ref: ${isValid}`);
619
+ if (isValid) {
620
+ logger.info('');
621
+ logger.info(`✅ SUCCESS: Using previous working branch tag '${previousTag}'`);
622
+ logger.info(` This shows commits added since the last release`);
623
+ logger.info('═══════════════════════════════════════════════════════════');
624
+ logger.info('');
625
+ return previousTag;
626
+ } else {
627
+ logger.warn('');
628
+ logger.warn(`⚠️ VALIDATION FAILED: Tag "${previousTag}" exists but is not a valid git reference`);
629
+ logger.warn(` This should not happen - the tag might be corrupted`);
630
+ }
631
+ } else {
632
+ logger.warn('');
633
+ logger.warn('❌ NO WORKING BRANCH TAG FOUND matching pattern "working/v*"');
634
+ logger.warn(` Current version: ${currentVersion}`);
635
+ logger.warn(' 💡 To create working branch tags for past releases, run:');
636
+ logger.warn(' kodrdriv development --create-retroactive-tags');
637
+ logger.warn('');
638
+ logger.warn(' Falling back to regular tag search...');
639
+ }
640
+ } else {
641
+ logger.warn('');
642
+ logger.warn('❌ CANNOT READ VERSION from package.json');
643
+ logger.warn(' Cannot search for working branch tags without current version');
644
+ }
645
+ } catch (error) {
646
+ logger.warn('');
647
+ logger.warn(`❌ ERROR while searching for working branch tag: ${error.message}`);
648
+ logger.debug(`Full error stack: ${error.stack}`);
649
+ logger.warn(' Falling back to regular tag search...');
650
+ }
651
+ logger.info('───────────────────────────────────────────────────────────');
652
+ logger.info('');
653
+ } else {
654
+ logger.info(`ℹ️ Not on "working" branch - skipping working tag search`);
655
+ logger.info(` (Only search for working/v* tags when on working branch)`);
656
+ logger.info('');
657
+ }
658
+ // First, try to find the previous release tag
659
+ try {
660
+ const currentVersion = await getCurrentVersion();
661
+ if (currentVersion) {
662
+ const previousTag = await findPreviousReleaseTag(currentVersion);
663
+ if (previousTag && await isValidGitRef(previousTag)) {
664
+ logger.info(`Using previous release tag '${previousTag}' as default --from reference`);
665
+ return previousTag;
666
+ }
667
+ }
668
+ } catch (error) {
669
+ logger.debug(`Could not determine previous release tag: ${error.message}`);
670
+ }
671
+ }
672
+ // Fallback to branch-based references
673
+ const candidates = [
674
+ 'main',
675
+ 'master',
676
+ 'origin/main',
677
+ 'origin/master'
678
+ ];
679
+ for (const candidate of candidates){
680
+ logger.debug(`Testing git reference candidate: ${candidate}`);
681
+ if (await isValidGitRef(candidate)) {
682
+ if (forceMainBranch) {
683
+ logger.info(`Using '${candidate}' as forced main branch reference`);
684
+ } else {
685
+ logger.info(`Using '${candidate}' as fallback --from reference (no previous release tag found)`);
686
+ }
687
+ return candidate;
688
+ }
689
+ }
690
+ // If we get here, something is seriously wrong with the git repository
691
+ throw new Error('Could not find a valid default git reference for --from parameter. ' + 'Please specify --from explicitly or check your git repository configuration. ' + `Tried: ${forceMainBranch ? 'main branch only' : 'previous release tag'}, ${candidates.join(', ')}`);
692
+ };
693
+ /**
694
+ * Gets the default branch name from the remote repository
695
+ */ const getRemoteDefaultBranch = async (cwd)=>{
696
+ const logger = getLogger();
697
+ try {
698
+ // Try to get the symbolic reference for origin/HEAD
699
+ const { stdout } = await run('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo ""', {
700
+ cwd,
701
+ suppressErrorLogging: true
702
+ });
703
+ if (stdout.trim()) {
704
+ // Extract branch name from refs/remotes/origin/branch-name
705
+ const match = stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
706
+ if (match) {
707
+ const branchName = match[1];
708
+ logger.debug(`Remote default branch is: ${branchName}`);
709
+ return branchName;
710
+ }
711
+ }
712
+ // Fallback: try to get it from ls-remote
713
+ const { stdout: lsRemoteOutput } = await run('git ls-remote --symref origin HEAD', {
714
+ cwd,
715
+ suppressErrorLogging: true
716
+ });
717
+ const symrefMatch = lsRemoteOutput.match(/ref: refs\/heads\/(.+)\s+HEAD/);
718
+ if (symrefMatch) {
719
+ const branchName = symrefMatch[1];
720
+ logger.debug(`Remote default branch from ls-remote: ${branchName}`);
721
+ return branchName;
722
+ }
723
+ logger.debug('Could not determine remote default branch');
724
+ return null;
725
+ } catch (error) {
726
+ logger.debug(`Failed to get remote default branch: ${error}`);
727
+ return null;
728
+ }
729
+ };
730
+ /**
731
+ * Checks if a directory is a git repository
732
+ * @param cwd Directory to check
733
+ * @returns true if directory is a git repository
734
+ */ const isGitRepository = async (cwd)=>{
735
+ try {
736
+ await run('git rev-parse --is-inside-work-tree', {
737
+ cwd,
738
+ suppressErrorLogging: true
739
+ });
740
+ return true;
741
+ } catch {
742
+ return false;
743
+ }
744
+ };
745
+ /**
746
+ * Checks if a local branch exists
747
+ */ const localBranchExists = async (branchName)=>{
748
+ const logger = getLogger();
749
+ const result = await isValidGitRefSilent(`refs/heads/${branchName}`);
750
+ if (result) {
751
+ logger.debug(`Local branch '${branchName}' exists`);
752
+ } else {
753
+ logger.debug(`Local branch '${branchName}' does not exist`);
754
+ }
755
+ return result;
756
+ };
757
+ /**
758
+ * Checks if a remote branch exists
759
+ */ const remoteBranchExists = async (branchName, remote = 'origin')=>{
760
+ const logger = getLogger();
761
+ const result = await isValidGitRefSilent(`refs/remotes/${remote}/${branchName}`);
762
+ if (result) {
763
+ logger.debug(`Remote branch '${remote}/${branchName}' exists`);
764
+ } else {
765
+ logger.debug(`Remote branch '${remote}/${branchName}' does not exist`);
766
+ }
767
+ return result;
768
+ };
769
+ /**
770
+ * Gets the commit SHA for a given branch (local or remote)
771
+ */ const getBranchCommitSha = async (branchRef)=>{
772
+ // Validate the ref first to prevent injection
773
+ if (!validateGitRef(branchRef)) {
774
+ throw new Error(`Invalid git reference: ${branchRef}`);
775
+ }
776
+ const { stdout } = await runSecure('git', [
777
+ 'rev-parse',
778
+ branchRef
779
+ ]);
780
+ return stdout.trim();
781
+ };
782
+ /**
783
+ * Checks if a local branch is in sync with its remote counterpart
784
+ */ const isBranchInSyncWithRemote = async (branchName, remote = 'origin')=>{
785
+ const logger = getLogger();
786
+ try {
787
+ // Validate inputs first to prevent injection
788
+ if (!validateGitRef(branchName)) {
789
+ throw new Error(`Invalid branch name: ${branchName}`);
790
+ }
791
+ if (!validateGitRef(remote)) {
792
+ throw new Error(`Invalid remote name: ${remote}`);
793
+ }
794
+ // First, fetch latest remote refs without affecting working directory
795
+ await runSecure('git', [
796
+ 'fetch',
797
+ remote,
798
+ '--quiet'
799
+ ]);
800
+ const localExists = await localBranchExists(branchName);
801
+ const remoteExists = await remoteBranchExists(branchName, remote);
802
+ if (!localExists) {
803
+ return {
804
+ inSync: false,
805
+ localExists: false,
806
+ remoteExists,
807
+ error: `Local branch '${branchName}' does not exist`
808
+ };
809
+ }
810
+ if (!remoteExists) {
811
+ return {
812
+ inSync: false,
813
+ localExists: true,
814
+ remoteExists: false,
815
+ error: `Remote branch '${remote}/${branchName}' does not exist`
816
+ };
817
+ }
818
+ // Both branches exist, compare their SHAs
819
+ const localSha = await getBranchCommitSha(`refs/heads/${branchName}`);
820
+ const remoteSha = await getBranchCommitSha(`refs/remotes/${remote}/${branchName}`);
821
+ const inSync = localSha === remoteSha;
822
+ logger.debug(`Branch sync check for '${branchName}': local=${localSha.substring(0, 8)}, remote=${remoteSha.substring(0, 8)}, inSync=${inSync}`);
823
+ return {
824
+ inSync,
825
+ localSha,
826
+ remoteSha,
827
+ localExists: true,
828
+ remoteExists: true
829
+ };
830
+ } catch (error) {
831
+ logger.debug(`Failed to check branch sync for '${branchName}': ${error.message}`);
832
+ return {
833
+ inSync: false,
834
+ localExists: false,
835
+ remoteExists: false,
836
+ error: `Failed to check branch sync: ${error.message}`
837
+ };
838
+ }
839
+ };
840
+ /**
841
+ * Attempts to safely sync a local branch with its remote counterpart
842
+ * Returns true if successful, false if conflicts exist that require manual resolution
843
+ */ const safeSyncBranchWithRemote = async (branchName, remote = 'origin')=>{
844
+ const logger = getLogger();
845
+ // Validate the remote parameter to prevent command injection
846
+ if (!isValidGitRemoteName(remote)) {
847
+ return {
848
+ success: false,
849
+ error: `Invalid remote name: '${remote}'`
850
+ };
851
+ }
852
+ try {
853
+ // Validate inputs first to prevent injection
854
+ if (!validateGitRef(branchName)) {
855
+ throw new Error(`Invalid branch name: ${branchName}`);
856
+ }
857
+ if (!validateGitRef(remote)) {
858
+ throw new Error(`Invalid remote name: ${remote}`);
859
+ }
860
+ // Explicitly disallow remotes that look like command-line options (e.g. "--upload-pack")
861
+ if (remote.startsWith('-')) {
862
+ throw new Error(`Disallowed remote name (cannot start with '-'): ${remote}`);
863
+ }
864
+ // Check current branch to restore later if needed
865
+ const { stdout: currentBranch } = await runSecure('git', [
866
+ 'branch',
867
+ '--show-current'
868
+ ]);
869
+ const originalBranch = currentBranch.trim();
870
+ // Fetch latest remote refs
871
+ await runSecure('git', [
872
+ 'fetch',
873
+ remote,
874
+ '--quiet'
875
+ ]);
876
+ // Check if local branch exists
877
+ const localExists = await localBranchExists(branchName);
878
+ const remoteExists = await remoteBranchExists(branchName, remote);
879
+ if (!remoteExists) {
880
+ return {
881
+ success: false,
882
+ error: `Remote branch '${remote}/${branchName}' does not exist`
883
+ };
884
+ }
885
+ if (!localExists) {
886
+ // Create local branch tracking the remote
887
+ await runSecure('git', [
888
+ 'branch',
889
+ branchName,
890
+ `${remote}/${branchName}`
891
+ ]);
892
+ logger.debug(`Created local branch '${branchName}' tracking '${remote}/${branchName}'`);
893
+ return {
894
+ success: true
895
+ };
896
+ }
897
+ // Check if we need to switch to the target branch
898
+ const needToSwitch = originalBranch !== branchName;
899
+ if (needToSwitch) {
900
+ // Check for uncommitted changes before switching
901
+ const { stdout: statusOutput } = await runSecure('git', [
902
+ 'status',
903
+ '--porcelain'
904
+ ]);
905
+ if (statusOutput.trim()) {
906
+ return {
907
+ success: false,
908
+ error: `Cannot switch to branch '${branchName}' because you have uncommitted changes. Please commit or stash your changes first.`
909
+ };
910
+ }
911
+ // Switch to target branch
912
+ await runSecure('git', [
913
+ 'checkout',
914
+ branchName
915
+ ]);
916
+ }
917
+ try {
918
+ // Try to pull with fast-forward only
919
+ await runSecure('git', [
920
+ 'pull',
921
+ remote,
922
+ branchName,
923
+ '--ff-only'
924
+ ]);
925
+ logger.debug(`Successfully synced '${branchName}' with '${remote}/${branchName}'`);
926
+ // Switch back to original branch if we switched
927
+ if (needToSwitch && originalBranch) {
928
+ await runSecure('git', [
929
+ 'checkout',
930
+ originalBranch
931
+ ]);
932
+ }
933
+ return {
934
+ success: true
935
+ };
936
+ } catch (pullError) {
937
+ // Switch back to original branch if we switched
938
+ if (needToSwitch && originalBranch) {
939
+ try {
940
+ await runSecure('git', [
941
+ 'checkout',
942
+ originalBranch
943
+ ]);
944
+ } catch (checkoutError) {
945
+ logger.warn(`Failed to switch back to original branch '${originalBranch}': ${checkoutError}`);
946
+ }
947
+ }
948
+ // Check if this is a merge conflict or diverged branches
949
+ if (pullError.message.includes('diverged') || pullError.message.includes('non-fast-forward') || pullError.message.includes('conflict') || pullError.message.includes('CONFLICT')) {
950
+ return {
951
+ success: false,
952
+ conflictResolutionRequired: true,
953
+ error: `Branch '${branchName}' has diverged from '${remote}/${branchName}' and requires manual conflict resolution`
954
+ };
955
+ }
956
+ return {
957
+ success: false,
958
+ error: `Failed to sync branch '${branchName}': ${pullError.message}`
959
+ };
960
+ }
961
+ } catch (error) {
962
+ return {
963
+ success: false,
964
+ error: `Failed to sync branch '${branchName}': ${error.message}`
965
+ };
966
+ }
967
+ };
968
+ /**
969
+ * Gets the current branch name
970
+ */ const getCurrentBranch = async ()=>{
971
+ const { stdout } = await runSecure('git', [
972
+ 'branch',
973
+ '--show-current'
974
+ ]);
975
+ return stdout.trim();
976
+ };
977
+ /**
978
+ * Gets git status summary including unstaged files, uncommitted changes, and unpushed commits
979
+ */ const getGitStatusSummary = async (workingDir)=>{
980
+ const logger = getLogger();
981
+ try {
982
+ const originalCwd = process.cwd();
983
+ if (workingDir) {
984
+ process.chdir(workingDir);
985
+ }
986
+ try {
987
+ // Get current branch
988
+ const branch = await getCurrentBranch();
989
+ // Get git status for unstaged and uncommitted changes
990
+ const { stdout: statusOutput } = await runSecure('git', [
991
+ 'status',
992
+ '--porcelain'
993
+ ]);
994
+ const statusLines = statusOutput.trim().split('\n').filter((line)=>line.trim());
995
+ // Count different types of changes
996
+ let unstagedCount = 0;
997
+ let uncommittedCount = 0;
998
+ for (const line of statusLines){
999
+ const statusCode = line.substring(0, 2);
1000
+ // For untracked files (??) count as unstaged only once
1001
+ if (statusCode === '??') {
1002
+ unstagedCount++;
1003
+ continue;
1004
+ }
1005
+ // Check for unstaged changes (working directory changes)
1006
+ // Second character represents working tree status
1007
+ if (statusCode[1] !== ' ' && statusCode[1] !== '') {
1008
+ unstagedCount++;
1009
+ }
1010
+ // Check for uncommitted changes (staged changes)
1011
+ // First character represents index status
1012
+ if (statusCode[0] !== ' ' && statusCode[0] !== '') {
1013
+ uncommittedCount++;
1014
+ }
1015
+ }
1016
+ // Check for unpushed commits by comparing with remote
1017
+ let unpushedCount = 0;
1018
+ let hasUnpushedCommits = false;
1019
+ try {
1020
+ // First fetch to get latest remote refs
1021
+ await runSecure('git', [
1022
+ 'fetch',
1023
+ 'origin',
1024
+ '--quiet'
1025
+ ]);
1026
+ // Check if remote branch exists
1027
+ const remoteExists = await remoteBranchExists(branch);
1028
+ if (remoteExists) {
1029
+ // Get count of commits ahead of remote (branch already validated in calling function)
1030
+ const { stdout: aheadOutput } = await runSecure('git', [
1031
+ 'rev-list',
1032
+ '--count',
1033
+ `origin/${branch}..HEAD`
1034
+ ]);
1035
+ unpushedCount = parseInt(aheadOutput.trim()) || 0;
1036
+ hasUnpushedCommits = unpushedCount > 0;
1037
+ }
1038
+ } catch (error) {
1039
+ logger.debug(`Could not check for unpushed commits: ${error}`);
1040
+ // Remote might not exist or other issues - not critical for status
1041
+ }
1042
+ const hasUnstagedFiles = unstagedCount > 0;
1043
+ const hasUncommittedChanges = uncommittedCount > 0;
1044
+ // Build status summary
1045
+ const statusParts = [];
1046
+ if (hasUnstagedFiles) {
1047
+ statusParts.push(`${unstagedCount} unstaged`);
1048
+ }
1049
+ if (hasUncommittedChanges) {
1050
+ statusParts.push(`${uncommittedCount} uncommitted`);
1051
+ }
1052
+ if (hasUnpushedCommits) {
1053
+ statusParts.push(`${unpushedCount} unpushed`);
1054
+ }
1055
+ const status = statusParts.length > 0 ? statusParts.join(', ') : 'clean';
1056
+ return {
1057
+ branch,
1058
+ hasUnstagedFiles,
1059
+ hasUncommittedChanges,
1060
+ hasUnpushedCommits,
1061
+ unstagedCount,
1062
+ uncommittedCount,
1063
+ unpushedCount,
1064
+ status
1065
+ };
1066
+ } finally{
1067
+ if (workingDir) {
1068
+ process.chdir(originalCwd);
1069
+ }
1070
+ }
1071
+ } catch (error) {
1072
+ logger.debug(`Failed to get git status summary: ${error.message}`);
1073
+ return {
1074
+ branch: 'unknown',
1075
+ hasUnstagedFiles: false,
1076
+ hasUncommittedChanges: false,
1077
+ hasUnpushedCommits: false,
1078
+ unstagedCount: 0,
1079
+ uncommittedCount: 0,
1080
+ unpushedCount: 0,
1081
+ status: 'error'
1082
+ };
1083
+ }
1084
+ };
1085
+ /**
1086
+ * Gets the list of globally linked packages (packages available to be linked to)
1087
+ */ const getGloballyLinkedPackages = async ()=>{
1088
+ const execPromise = util.promisify(exec);
1089
+ try {
1090
+ const { stdout } = await execPromise('npm ls --link -g --json');
1091
+ const result = safeJsonParse(stdout, 'npm ls global output');
1092
+ if (result.dependencies && typeof result.dependencies === 'object') {
1093
+ return new Set(Object.keys(result.dependencies));
1094
+ }
1095
+ return new Set();
1096
+ } catch (error) {
1097
+ // Try to parse from error stdout if available
1098
+ if (error.stdout) {
1099
+ try {
1100
+ const result = safeJsonParse(error.stdout, 'npm ls global error output');
1101
+ if (result.dependencies && typeof result.dependencies === 'object') {
1102
+ return new Set(Object.keys(result.dependencies));
1103
+ }
1104
+ } catch {
1105
+ // If JSON parsing fails, return empty set
1106
+ }
1107
+ }
1108
+ return new Set();
1109
+ }
1110
+ };
1111
+ /**
1112
+ * Gets the list of packages that this package is actively linking to (consuming linked packages)
1113
+ */ const getLinkedDependencies = async (packageDir)=>{
1114
+ const execPromise = util.promisify(exec);
1115
+ try {
1116
+ const { stdout } = await execPromise('npm ls --link --json', {
1117
+ cwd: packageDir
1118
+ });
1119
+ const result = safeJsonParse(stdout, 'npm ls local output');
1120
+ if (result.dependencies && typeof result.dependencies === 'object') {
1121
+ return new Set(Object.keys(result.dependencies));
1122
+ }
1123
+ return new Set();
1124
+ } catch (error) {
1125
+ // npm ls --link often exits with non-zero code but still provides valid JSON in stdout
1126
+ if (error.stdout) {
1127
+ try {
1128
+ const result = safeJsonParse(error.stdout, 'npm ls local error output');
1129
+ if (result.dependencies && typeof result.dependencies === 'object') {
1130
+ return new Set(Object.keys(result.dependencies));
1131
+ }
1132
+ } catch {
1133
+ // If JSON parsing fails, return empty set
1134
+ }
1135
+ }
1136
+ return new Set();
1137
+ }
1138
+ };
1139
+ /**
1140
+ * Checks for actual semantic version compatibility issues between linked packages and their consumers
1141
+ * Returns a set of dependency names that have real compatibility problems
1142
+ *
1143
+ * This function ignores npm's strict prerelease handling and focuses on actual compatibility:
1144
+ * - "^4.4" is compatible with "4.4.53-dev.0" (prerelease of compatible minor version)
1145
+ * - "^4.4" is incompatible with "4.5.3" (different minor version)
1146
+ */ const getLinkCompatibilityProblems = async (packageDir, allPackagesInfo)=>{
1147
+ try {
1148
+ // Read the consumer package.json
1149
+ const packageJsonPath = path.join(packageDir, 'package.json');
1150
+ const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
1151
+ const parsed = safeJsonParse(packageJsonContent, packageJsonPath);
1152
+ const packageJson = validatePackageJson(parsed, packageJsonPath);
1153
+ const problemDependencies = new Set();
1154
+ // Get linked dependencies
1155
+ const linkedDeps = await getLinkedDependencies(packageDir);
1156
+ // Check each dependency type
1157
+ const dependencyTypes = [
1158
+ 'dependencies',
1159
+ 'devDependencies',
1160
+ 'peerDependencies',
1161
+ 'optionalDependencies'
1162
+ ];
1163
+ for (const depType of dependencyTypes){
1164
+ const deps = packageJson[depType];
1165
+ if (!deps || typeof deps !== 'object') continue;
1166
+ for (const [depName, versionRange] of Object.entries(deps)){
1167
+ // Only check dependencies that are currently linked
1168
+ if (!linkedDeps.has(depName)) continue;
1169
+ // Skip if version range is not a string or is invalid
1170
+ if (typeof versionRange !== 'string') continue;
1171
+ try {
1172
+ let linkedVersion;
1173
+ // If we have package info provided, use it
1174
+ if (allPackagesInfo) {
1175
+ const packageInfo = allPackagesInfo.get(depName);
1176
+ if (packageInfo) {
1177
+ linkedVersion = packageInfo.version;
1178
+ }
1179
+ }
1180
+ // If we don't have version from package info, try to read it from the linked package
1181
+ if (!linkedVersion) {
1182
+ try {
1183
+ // Get the linked package path and read its version
1184
+ const nodeModulesPath = path.join(packageDir, 'node_modules', depName, 'package.json');
1185
+ const linkedPackageJson = await fs.readFile(nodeModulesPath, 'utf-8');
1186
+ const linkedParsed = safeJsonParse(linkedPackageJson, nodeModulesPath);
1187
+ const linkedValidated = validatePackageJson(linkedParsed, nodeModulesPath);
1188
+ linkedVersion = linkedValidated.version;
1189
+ } catch {
1190
+ continue;
1191
+ }
1192
+ }
1193
+ if (!linkedVersion) continue;
1194
+ // Check compatibility with custom logic for prerelease versions
1195
+ if (!isVersionCompatibleWithRange(linkedVersion, versionRange)) {
1196
+ problemDependencies.add(depName);
1197
+ }
1198
+ } catch {
1199
+ continue;
1200
+ }
1201
+ }
1202
+ }
1203
+ return problemDependencies;
1204
+ } catch {
1205
+ // If we can't read the package.json or process it, return empty set
1206
+ return new Set();
1207
+ }
1208
+ };
1209
+ /**
1210
+ * Custom semver compatibility check that handles prerelease versions more intelligently
1211
+ * than npm's strict checking, with stricter caret range handling
1212
+ *
1213
+ * Examples:
1214
+ * - isVersionCompatibleWithRange("4.4.53-dev.0", "^4.4") => true
1215
+ * - isVersionCompatibleWithRange("4.5.3", "^4.4") => false
1216
+ * - isVersionCompatibleWithRange("4.4.1", "^4.4") => true
1217
+ */ const isVersionCompatibleWithRange = (version, range)=>{
1218
+ try {
1219
+ const parsedVersion = semver.parse(version);
1220
+ if (!parsedVersion) return false;
1221
+ // Parse the range to understand what we're comparing against
1222
+ const rangeObj = semver.validRange(range);
1223
+ if (!rangeObj) return false;
1224
+ // For caret ranges like "^4.4", we want more strict checking than semver's default
1225
+ if (range.startsWith('^')) {
1226
+ const rangeVersion = range.substring(1); // Remove the ^
1227
+ // Try to parse as a complete version first
1228
+ let parsedRange = semver.parse(rangeVersion);
1229
+ // If that fails, try to coerce it (handles cases like "4.4" -> "4.4.0")
1230
+ if (!parsedRange) {
1231
+ const coercedRange = semver.coerce(rangeVersion);
1232
+ if (coercedRange) {
1233
+ parsedRange = coercedRange;
1234
+ } else {
1235
+ return false;
1236
+ }
1237
+ }
1238
+ // For prerelease versions, check if the base version (without prerelease)
1239
+ // matches the major.minor from the range
1240
+ if (parsedVersion.prerelease.length > 0) {
1241
+ return parsedVersion.major === parsedRange.major && parsedVersion.minor === parsedRange.minor;
1242
+ }
1243
+ // For regular versions with caret ranges, be strict about minor version
1244
+ // ^4.4 should only accept 4.4.x, not 4.5.x
1245
+ return parsedVersion.major === parsedRange.major && parsedVersion.minor === parsedRange.minor;
1246
+ }
1247
+ // For other range types (exact, tilde, etc.), use standard semver checking
1248
+ if (parsedVersion.prerelease.length > 0) {
1249
+ const baseVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}`;
1250
+ return semver.satisfies(baseVersion, range);
1251
+ }
1252
+ return semver.satisfies(version, range);
1253
+ } catch {
1254
+ // If semver parsing fails, assume incompatible
1255
+ return false;
1256
+ }
1257
+ };
1258
+ /**
1259
+ * Checks for npm link problems (version mismatches) in a package directory
1260
+ * Returns a set of dependency names that have link problems
1261
+ *
1262
+ * @deprecated Use getLinkCompatibilityProblems instead for better prerelease version handling
1263
+ */ const getLinkProblems = async (packageDir)=>{
1264
+ const execPromise = util.promisify(exec);
1265
+ try {
1266
+ const { stdout } = await execPromise('npm ls --link --json', {
1267
+ cwd: packageDir
1268
+ });
1269
+ const result = safeJsonParse(stdout, 'npm ls troubleshoot output');
1270
+ const problemDependencies = new Set();
1271
+ // Check if there are any problems reported
1272
+ if (result.problems && Array.isArray(result.problems)) {
1273
+ // Parse problems array to extract dependency names
1274
+ for (const problem of result.problems){
1275
+ if (typeof problem === 'string' && problem.includes('invalid:')) {
1276
+ // Extract package name from problem string like "invalid: @fjell/eslint-config@1.1.20-dev.0 ..."
1277
+ // Handle both scoped (@scope/name) and unscoped (name) packages
1278
+ const match = problem.match(/invalid:\s+(@[^/]+\/[^@\s]+|[^@\s]+)@/);
1279
+ if (match) {
1280
+ problemDependencies.add(match[1]);
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ // Also check individual dependencies for problems
1286
+ if (result.dependencies && typeof result.dependencies === 'object') {
1287
+ for (const [depName, depInfo] of Object.entries(result.dependencies)){
1288
+ if (depInfo && typeof depInfo === 'object') {
1289
+ const dep = depInfo;
1290
+ // Check if this dependency has problems or is marked as invalid
1291
+ if (dep.problems && Array.isArray(dep.problems) && dep.problems.length > 0 || dep.invalid) {
1292
+ problemDependencies.add(depName);
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+ return problemDependencies;
1298
+ } catch (error) {
1299
+ // npm ls --link often exits with non-zero code when there are problems
1300
+ // but still provides valid JSON in stdout
1301
+ if (error.stdout) {
1302
+ try {
1303
+ const result = safeJsonParse(error.stdout, 'npm ls troubleshoot error output');
1304
+ const problemDependencies = new Set();
1305
+ // Check if there are any problems reported
1306
+ if (result.problems && Array.isArray(result.problems)) {
1307
+ for (const problem of result.problems){
1308
+ if (typeof problem === 'string' && problem.includes('invalid:')) {
1309
+ const match = problem.match(/invalid:\s+(@[^/]+\/[^@\s]+|[^@\s]+)@/);
1310
+ if (match) {
1311
+ problemDependencies.add(match[1]);
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ // Also check individual dependencies for problems
1317
+ if (result.dependencies && typeof result.dependencies === 'object') {
1318
+ for (const [depName, depInfo] of Object.entries(result.dependencies)){
1319
+ if (depInfo && typeof depInfo === 'object') {
1320
+ const dep = depInfo;
1321
+ if (dep.problems && Array.isArray(dep.problems) && dep.problems.length > 0 || dep.invalid) {
1322
+ problemDependencies.add(depName);
1323
+ }
1324
+ }
1325
+ }
1326
+ }
1327
+ return problemDependencies;
1328
+ } catch {
1329
+ // If JSON parsing fails, return empty set
1330
+ return new Set();
1331
+ }
1332
+ }
1333
+ return new Set();
1334
+ }
1335
+ };
1336
+ /**
1337
+ * Checks if a package directory is npm linked (has a global symlink)
1338
+ */ const isNpmLinked = async (packageDir)=>{
1339
+ const logger = getLogger();
1340
+ try {
1341
+ // Read package.json to get the package name
1342
+ const packageJsonPath = path.join(packageDir, 'package.json');
1343
+ try {
1344
+ await fs.access(packageJsonPath);
1345
+ } catch {
1346
+ // No package.json found
1347
+ return false;
1348
+ }
1349
+ const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
1350
+ const packageJson = safeJsonParse(packageJsonContent, packageJsonPath);
1351
+ const packageName = packageJson.name;
1352
+ if (!packageName) {
1353
+ return false;
1354
+ }
1355
+ // Check if the package is globally linked by running npm ls -g --depth=0
1356
+ try {
1357
+ const { stdout } = await runSecure('npm', [
1358
+ 'ls',
1359
+ '-g',
1360
+ '--depth=0',
1361
+ '--json'
1362
+ ]);
1363
+ const globalPackages = safeJsonParse(stdout, 'npm ls global depth check output');
1364
+ // Check if our package is in the global dependencies
1365
+ if (globalPackages.dependencies && globalPackages.dependencies[packageName]) {
1366
+ // Verify the symlink actually points to our directory
1367
+ const globalPath = globalPackages.dependencies[packageName].resolved;
1368
+ if (globalPath && globalPath.startsWith('file:')) {
1369
+ const linkedPath = globalPath.replace('file:', '');
1370
+ const realPackageDir = await fs.realpath(packageDir);
1371
+ const realLinkedPath = await fs.realpath(linkedPath);
1372
+ return realPackageDir === realLinkedPath;
1373
+ }
1374
+ }
1375
+ } catch (error) {
1376
+ // If npm ls fails, try alternative approach
1377
+ logger.debug(`npm ls failed for ${packageName}, trying alternative check: ${error}`);
1378
+ // Alternative: check if there's a symlink in npm's global node_modules
1379
+ try {
1380
+ const { stdout: npmPrefix } = await run('npm prefix -g');
1381
+ const globalNodeModules = path.join(npmPrefix.trim(), 'node_modules', packageName);
1382
+ const stat = await fs.lstat(globalNodeModules);
1383
+ if (stat.isSymbolicLink()) {
1384
+ const realGlobalPath = await fs.realpath(globalNodeModules);
1385
+ const realPackageDir = await fs.realpath(packageDir);
1386
+ return realGlobalPath === realPackageDir;
1387
+ }
1388
+ } catch {
1389
+ // If all else fails, assume not linked
1390
+ return false;
1391
+ }
1392
+ }
1393
+ return false;
1394
+ } catch (error) {
1395
+ logger.debug(`Error checking npm link status for ${packageDir}: ${error}`);
1396
+ return false;
1397
+ }
1398
+ };
1399
+
1400
+ export { ConsoleLogger, escapeShellArg, findPreviousReleaseTag, getBranchCommitSha, getCurrentBranch, getCurrentVersion, getDefaultFromRef, getGitStatusSummary, getGloballyLinkedPackages, getLinkCompatibilityProblems, getLinkProblems, getLinkedDependencies, getLogger, getRemoteDefaultBranch, isBranchInSyncWithRemote, isGitRepository, isNpmLinked, isValidGitRef, localBranchExists, remoteBranchExists, run, runSecure, runSecureWithDryRunSupport, runSecureWithInheritedStdio, runWithDryRunSupport, runWithInheritedStdio, safeJsonParse, safeSyncBranchWithRemote, setLogger, validateFilePath, validateGitRef, validateHasProperty, validatePackageJson, validateString };
5
1401
  //# sourceMappingURL=index.js.map