rds_ssm_connect 1.7.13 → 1.7.15

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.
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.rds-ssm-connect')
6
+ const CONFIG_FILE = 'projects.json'
7
+
8
+ export function getConfigPath() {
9
+ return path.join(CONFIG_DIR, CONFIG_FILE)
10
+ }
11
+
12
+ export async function loadProjectConfigs(configPath) {
13
+ const filePath = configPath || getConfigPath()
14
+ try {
15
+ const data = await fs.readFile(filePath, 'utf-8')
16
+ return JSON.parse(data)
17
+ } catch (err) {
18
+ if (err.code === 'ENOENT') return {}
19
+ throw err
20
+ }
21
+ }
22
+
23
+ export async function saveProjectConfigs(configs, configPath) {
24
+ const filePath = configPath || getConfigPath()
25
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
26
+ await fs.writeFile(filePath, JSON.stringify(configs, null, 2) + '\n')
27
+ }
28
+
29
+ export async function saveProjectConfig(key, config, configPath) {
30
+ const configs = await loadProjectConfigs(configPath)
31
+ configs[key] = config
32
+ await saveProjectConfigs(configs, configPath)
33
+ }
34
+
35
+ export async function deleteProjectConfig(key, configPath) {
36
+ const configs = await loadProjectConfigs(configPath)
37
+ delete configs[key]
38
+ await saveProjectConfigs(configs, configPath)
39
+ }
40
+
41
+ const REQUIRED_FIELDS = [
42
+ 'name',
43
+ 'region',
44
+ 'database',
45
+ 'secretPrefix',
46
+ 'rdsType',
47
+ 'rdsPattern',
48
+ 'envPortMapping',
49
+ 'defaultPort',
50
+ ]
51
+
52
+ const VALID_RDS_TYPES = ['cluster', 'instance']
53
+ const REGION_PATTERN = /^[a-z]{2}(-[a-z]+-\d+)$/
54
+ const PORT_PATTERN = /^\d+$/
55
+
56
+ export function validateProjectConfig(config) {
57
+ const errors = []
58
+
59
+ for (const field of REQUIRED_FIELDS) {
60
+ if (config[field] === undefined || config[field] === null || config[field] === '') {
61
+ errors.push(`Missing required field: ${field}`)
62
+ }
63
+ }
64
+
65
+ if (config.rdsType && !VALID_RDS_TYPES.includes(config.rdsType)) {
66
+ errors.push(`rdsType must be one of: ${VALID_RDS_TYPES.join(', ')}`)
67
+ }
68
+
69
+ if (config.region && !REGION_PATTERN.test(config.region)) {
70
+ errors.push(`Invalid region format: ${config.region}`)
71
+ }
72
+
73
+ if (config.defaultPort && !PORT_PATTERN.test(config.defaultPort)) {
74
+ errors.push(`defaultPort must be a numeric string: ${config.defaultPort}`)
75
+ }
76
+
77
+ if (config.envPortMapping && typeof config.envPortMapping === 'object') {
78
+ for (const [key, value] of Object.entries(config.envPortMapping)) {
79
+ if (!PORT_PATTERN.test(value)) {
80
+ errors.push(`Port for "${key}" must be a numeric string: ${value}`)
81
+ }
82
+ }
83
+ }
84
+
85
+ return { valid: errors.length === 0, errors }
86
+ }
package/connect.js CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { exec } from 'node:child_process'
3
+ import { exec, spawn } from 'node:child_process'
4
4
  import { EventEmitter } from 'node:events'
5
5
  import fs from 'node:fs/promises'
6
6
  import net from 'node:net'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
9
  import { promisify } from 'node:util'
10
- import { PROJECT_CONFIGS } from './envPortMapping.js'
10
+ import {
11
+ loadProjectConfigs,
12
+ saveProjectConfig,
13
+ validateProjectConfig,
14
+ } from './configLoader.js'
11
15
 
12
16
  // Package info for version checking
13
- const packageJson = { name: 'rds_ssm_connect', version: '1.7.13' }
17
+ const packageJson = { name: 'rds_ssm_connect', version: '1.7.15' }
14
18
 
15
19
  const execAsync = promisify(exec)
16
20
 
@@ -79,20 +83,30 @@ async function checkForUpdates() {
79
83
  // Store active child processes for cleanup
80
84
  let activeChildProcesses = []
81
85
 
86
+ // Kill entire process group (shell + aws-vault + session-manager-plugin).
87
+ // Requires the child to have been spawned with `detached: true` so that
88
+ // setsid() makes it a process group leader.
89
+ function killProcessTree(child) {
90
+ if (!child || !child.pid) return
91
+ try {
92
+ process.kill(-child.pid, 'SIGTERM') // negative PID = kill entire process group
93
+ } catch (_err) {
94
+ // ESRCH: process group already exited — safe to ignore
95
+ }
96
+ }
97
+
82
98
  // Handle graceful shutdown
83
99
  function setupProcessCleanup() {
84
- const cleanup = () => {
100
+ const killAll = () => {
85
101
  activeChildProcesses.forEach((child) => {
86
- if (child && !child.killed) {
87
- child.kill('SIGTERM')
88
- }
102
+ killProcessTree(child)
89
103
  })
90
- process.exit(0)
104
+ activeChildProcesses = []
91
105
  }
92
106
 
93
- process.on('SIGINT', cleanup)
94
- process.on('SIGTERM', cleanup)
95
- process.on('exit', cleanup)
107
+ process.on('SIGINT', () => { killAll(); process.exit(0) })
108
+ process.on('SIGTERM', () => { killAll(); process.exit(0) })
109
+ process.on('exit', killAll)
96
110
  }
97
111
 
98
112
  async function readAwsConfig() {
@@ -247,6 +261,7 @@ async function handleTargetNotConnectedError(
247
261
  region,
248
262
  retryCount,
249
263
  maxRetries,
264
+ onChild,
250
265
  ) {
251
266
  // Terminate the disconnected instance
252
267
  await terminateBastionInstance(ENV, instanceId, region)
@@ -266,6 +281,7 @@ async function handleTargetNotConnectedError(
266
281
  region,
267
282
  retryCount + 1,
268
283
  maxRetries,
284
+ onChild,
269
285
  )
270
286
  }
271
287
 
@@ -278,7 +294,11 @@ function executePortForwardingCommand(
278
294
  region,
279
295
  ) {
280
296
  const portForwardingCommand = `aws-vault exec ${ENV} -- aws ssm start-session --region ${region} --target ${instanceId} --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters "host=${rdsEndpoint},portNumber='${remotePort}',localPortNumber='${portNumber}'" --cli-connect-timeout 0`
281
- const child = exec(portForwardingCommand)
297
+ const child = spawn(portForwardingCommand, {
298
+ shell: true,
299
+ detached: true,
300
+ stdio: ['ignore', 'pipe', 'pipe'],
301
+ })
282
302
 
283
303
  // Register child process for cleanup
284
304
  activeChildProcesses.push(child)
@@ -295,6 +315,7 @@ async function startPortForwardingWithConfig(
295
315
  region,
296
316
  retryCount = 0,
297
317
  maxRetries = RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES,
318
+ onChild,
298
319
  ) {
299
320
  return new Promise((resolve, reject) => {
300
321
  const child = executePortForwardingCommand(
@@ -305,6 +326,11 @@ async function startPortForwardingWithConfig(
305
326
  remotePort,
306
327
  region,
307
328
  )
329
+ // Notify caller of the new child (used for per-connection tracking).
330
+ // If the connection was already disconnected, the callback kills this
331
+ // child immediately so it doesn't linger as an orphan.
332
+ if (onChild) onChild(child)
333
+
308
334
  const sessionState = monitorPortForwardingSession(child)
309
335
 
310
336
  child.on('close', async (code) => {
@@ -327,6 +353,7 @@ async function startPortForwardingWithConfig(
327
353
  region,
328
354
  retryCount,
329
355
  maxRetries,
356
+ onChild,
330
357
  )
331
358
  resolve()
332
359
  } else if (code !== 0) {
@@ -389,21 +416,34 @@ async function getRdsPort(ENV, projectConfig) {
389
416
  }
390
417
 
391
418
  function getProfilesForProject(allProfiles, projectConfig, allProjectConfigs) {
392
- const { profileFilter } = projectConfig
419
+ const { profileFilter, envPortMapping } = projectConfig
393
420
 
421
+ let filtered
394
422
  if (profileFilter) {
395
423
  // Project has explicit filter - return profiles starting with filter
396
- return allProfiles.filter((env) => env.startsWith(profileFilter))
424
+ filtered = allProfiles.filter((env) => env.startsWith(profileFilter))
397
425
  } else {
398
426
  // No filter (legacy project like TLN) - return profiles that don't match any other project's filter
399
427
  const otherFilters = Object.values(allProjectConfigs)
400
428
  .filter((config) => config.profileFilter)
401
429
  .map((config) => config.profileFilter)
402
430
 
403
- return allProfiles.filter(
431
+ filtered = allProfiles.filter(
404
432
  (env) => !otherFilters.some((filter) => env.startsWith(filter)),
405
433
  )
406
434
  }
435
+
436
+ // Further restrict to profiles matching an envPortMapping suffix
437
+ if (envPortMapping && Object.keys(envPortMapping).length > 0) {
438
+ const suffixes = Object.keys(envPortMapping).sort(
439
+ (a, b) => b.length - a.length,
440
+ )
441
+ filtered = filtered.filter((env) =>
442
+ suffixes.some((suffix) => env.endsWith(suffix) || env === suffix),
443
+ )
444
+ }
445
+
446
+ return filtered
407
447
  }
408
448
 
409
449
  // Get local port number based on environment suffix
@@ -480,6 +520,8 @@ async function getAvailableProjects() {
480
520
  return []
481
521
  }
482
522
 
523
+ const PROJECT_CONFIGS = await loadProjectConfigs()
524
+
483
525
  return Object.entries(PROJECT_CONFIGS)
484
526
  .filter(([_key, config]) => {
485
527
  const matchingProfiles = getProfilesForProject(
@@ -498,6 +540,7 @@ async function getAvailableProjects() {
498
540
  // Get profiles for a specific project
499
541
  async function getProfilesForProjectKey(projectKey) {
500
542
  const allProfiles = await readAwsConfig()
543
+ const PROJECT_CONFIGS = await loadProjectConfigs()
501
544
  const projectConfig = PROJECT_CONFIGS[projectKey]
502
545
 
503
546
  if (!projectConfig) {
@@ -511,6 +554,7 @@ async function getProfilesForProjectKey(projectKey) {
511
554
  // Includes keepalive (prevents SSM idle timeout) and auto-reconnect
512
555
  // (transparently reconnects on the same port if session drops unexpectedly).
513
556
  async function connect(projectKey, profile, options = {}) {
557
+ const PROJECT_CONFIGS = await loadProjectConfigs()
514
558
  const projectConfig = PROJECT_CONFIGS[projectKey]
515
559
  if (!projectConfig) {
516
560
  throw new Error(`Unknown project: ${projectKey}`)
@@ -522,6 +566,18 @@ async function connect(projectKey, profile, options = {}) {
522
566
 
523
567
  let manualDisconnect = false
524
568
  let stopKeepalive = null
569
+ let currentChild = null // per-connection child tracking
570
+
571
+ // Called whenever a new child process is spawned for this connection.
572
+ // If disconnect() was already called, kills the child immediately so
573
+ // it doesn't linger as an orphan during retry chains.
574
+ const onChild = (child) => {
575
+ currentChild = child
576
+ if (manualDisconnect) {
577
+ killProcessTree(child)
578
+ activeChildProcesses = activeChildProcesses.filter((p) => p !== child)
579
+ }
580
+ }
525
581
 
526
582
  // Emit status updates
527
583
  const emit = (event, data) => {
@@ -579,6 +635,7 @@ async function connect(projectKey, profile, options = {}) {
579
635
  region,
580
636
  0,
581
637
  RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES,
638
+ onChild,
582
639
  )
583
640
 
584
641
  // Session ended — clean up keepalive
@@ -651,13 +708,12 @@ async function connect(projectKey, profile, options = {}) {
651
708
  disconnect: () => {
652
709
  manualDisconnect = true
653
710
  stopKeepalive?.()
654
- // Kill all active child processes
655
- activeChildProcesses.forEach((child) => {
656
- if (child && !child.killed) {
657
- child.kill('SIGTERM')
658
- }
659
- })
660
- activeChildProcesses = []
711
+ // Kill only THIS connection's child process group
712
+ if (currentChild) {
713
+ killProcessTree(currentChild)
714
+ activeChildProcesses = activeChildProcesses.filter((p) => p !== currentChild)
715
+ currentChild = null
716
+ }
661
717
  },
662
718
  waitForClose: () => portForwardingPromise,
663
719
  }
@@ -674,6 +730,116 @@ async function main() {
674
730
  await checkForUpdates()
675
731
 
676
732
  try {
733
+ // Load project configs from user config file
734
+ let PROJECT_CONFIGS = await loadProjectConfigs()
735
+
736
+ // First-run wizard: if no projects configured, prompt to create one
737
+ if (Object.keys(PROJECT_CONFIGS).length === 0) {
738
+ const { setupNow } = await inquirer.default.prompt([
739
+ {
740
+ type: 'confirm',
741
+ name: 'setupNow',
742
+ message:
743
+ 'No projects configured. Would you like to set up a project now?',
744
+ default: true,
745
+ },
746
+ ])
747
+
748
+ if (!setupNow) {
749
+ console.log(
750
+ `\nTo configure manually, create ~/.rds-ssm-connect/projects.json\nSee the README for the config schema.\n`,
751
+ )
752
+ return
753
+ }
754
+
755
+ const answers = await inquirer.default.prompt([
756
+ {
757
+ type: 'input',
758
+ name: 'key',
759
+ message: 'Project key (lowercase, hyphens):',
760
+ validate: (v) =>
761
+ /^[a-z][a-z0-9-]*$/.test(v) || 'Lowercase letters, digits, hyphens',
762
+ },
763
+ { type: 'input', name: 'name', message: 'Display name:' },
764
+ {
765
+ type: 'input',
766
+ name: 'region',
767
+ message: 'AWS region:',
768
+ default: 'us-east-1',
769
+ },
770
+ { type: 'input', name: 'database', message: 'Database name:' },
771
+ {
772
+ type: 'input',
773
+ name: 'secretPrefix',
774
+ message: 'Secret prefix (e.g. rds!cluster):',
775
+ },
776
+ {
777
+ type: 'select',
778
+ name: 'rdsType',
779
+ message: 'RDS type:',
780
+ choices: ['cluster', 'instance'],
781
+ },
782
+ {
783
+ type: 'input',
784
+ name: 'rdsPattern',
785
+ message: 'RDS identifier pattern:',
786
+ },
787
+ {
788
+ type: 'input',
789
+ name: 'profileFilter',
790
+ message: 'Profile filter prefix (leave empty for none):',
791
+ default: '',
792
+ },
793
+ {
794
+ type: 'input',
795
+ name: 'defaultPort',
796
+ message: 'Default local port:',
797
+ default: '5432',
798
+ },
799
+ ])
800
+
801
+ // Collect port mappings
802
+ const envPortMappingInput = {}
803
+ let addMore = true
804
+ while (addMore) {
805
+ const mapping = await inquirer.default.prompt([
806
+ { type: 'input', name: 'suffix', message: 'Environment suffix:' },
807
+ { type: 'input', name: 'port', message: 'Local port:' },
808
+ {
809
+ type: 'confirm',
810
+ name: 'more',
811
+ message: 'Add another port mapping?',
812
+ default: false,
813
+ },
814
+ ])
815
+ envPortMappingInput[mapping.suffix] = mapping.port
816
+ addMore = mapping.more
817
+ }
818
+
819
+ const newConfig = {
820
+ name: answers.name,
821
+ region: answers.region,
822
+ database: answers.database,
823
+ secretPrefix: answers.secretPrefix,
824
+ rdsType: answers.rdsType,
825
+ rdsPattern: answers.rdsPattern,
826
+ profileFilter: answers.profileFilter || null,
827
+ envPortMapping: envPortMappingInput,
828
+ defaultPort: answers.defaultPort,
829
+ }
830
+
831
+ const validation = validateProjectConfig(newConfig)
832
+ if (!validation.valid) {
833
+ console.log('\nValidation errors:')
834
+ validation.errors.forEach((e) => console.log(` - ${e}`))
835
+ return
836
+ }
837
+
838
+ await saveProjectConfig(answers.key, newConfig)
839
+ console.log(`\nProject "${answers.name}" saved!\n`)
840
+ PROJECT_CONFIGS = await loadProjectConfigs()
841
+ }
842
+
677
843
  // Read all AWS profiles first
678
844
  const allProfiles = await readAwsConfig()
679
845
 
@@ -834,6 +1000,6 @@ export {
834
1000
  getLocalPort,
835
1001
  connect,
836
1002
  ipcEmitter,
837
- PROJECT_CONFIGS,
1003
+ loadProjectConfigs,
838
1004
  RETRY_CONFIG,
839
1005
  }
package/gui-adapter.js CHANGED
@@ -22,8 +22,13 @@ import {
22
22
  getAvailableProjects,
23
23
  getLocalPort,
24
24
  getProfilesForProjectKey,
25
- PROJECT_CONFIGS,
25
+ loadProjectConfigs,
26
26
  } from './connect.js'
27
+ import {
28
+ deleteProjectConfig,
29
+ saveProjectConfig,
30
+ validateProjectConfig,
31
+ } from './configLoader.js'
27
32
 
28
33
  // Active connections Map - connectionId -> connection control object
29
34
  const activeConnections = new Map()
@@ -51,19 +56,6 @@ async function isPortAvailable(port) {
51
56
  })
52
57
  }
53
58
 
54
- // Find next available port starting from basePort
55
- async function findAvailablePort(basePort, usedPorts = []) {
56
- const usedSet = new Set(usedPorts.map((p) => parseInt(p, 10)))
57
-
58
- for (let port = basePort; port < basePort + 100; port++) {
59
- if (usedSet.has(port)) continue
60
- if (await isPortAvailable(port)) {
61
- return port.toString()
62
- }
63
- }
64
- throw new Error(`No available ports found starting from ${basePort}`)
65
- }
66
-
67
59
  // Handle incoming commands
68
60
  async function handleCommand(command) {
69
61
  const { id, action, ...params } = command
@@ -100,7 +92,8 @@ async function handleCommand(command) {
100
92
  const connectionId = `conn_${randomUUID().slice(0, 8)}`
101
93
 
102
94
  // Determine port to use
103
- const projectConfig = PROJECT_CONFIGS[projectKey]
95
+ const configs = await loadProjectConfigs()
96
+ const projectConfig = configs[projectKey]
104
97
  if (!projectConfig) {
105
98
  sendResponse(id, 'error', {
106
99
  message: `Unknown project: ${projectKey}`,
@@ -108,31 +101,21 @@ async function handleCommand(command) {
108
101
  break
109
102
  }
110
103
 
111
- let portToUse
112
- if (localPort) {
113
- // Use specified port if available
114
- const portNum = parseInt(localPort, 10)
115
- if (
116
- usedPorts.includes(localPort) ||
117
- !(await isPortAvailable(portNum))
118
- ) {
119
- sendResponse(id, 'error', {
120
- message: `Port ${localPort} is not available`,
121
- })
122
- break
123
- }
124
- portToUse = localPort
125
- } else {
126
- // Find next available port based on project's base port
127
- const basePort = parseInt(getLocalPort(profile, projectConfig), 10)
128
- // Combine usedPorts from params with currently active connections
129
- const allUsedPorts = [
130
- ...usedPorts,
131
- ...Array.from(activeConnections.values()).map(
132
- (c) => c.connectionInfo?.port,
133
- ),
134
- ].filter(Boolean)
135
- portToUse = await findAvailablePort(basePort, allUsedPorts)
104
+ const portToUse = localPort || getLocalPort(profile, projectConfig)
105
+ const portNum = parseInt(portToUse, 10)
106
+
107
+ // Strict port check — never silently increment
108
+ const allUsedPorts = new Set([
109
+ ...usedPorts.map((p) => parseInt(p, 10)),
110
+ ...Array.from(activeConnections.values())
111
+ .map((c) => parseInt(c.connectionInfo?.port, 10))
112
+ .filter(Boolean),
113
+ ])
114
+ if (allUsedPorts.has(portNum) || !(await isPortAvailable(portNum))) {
115
+ sendResponse(id, 'error', {
116
+ message: `Port ${portToUse} is not available. Close the application using it or change the port in project settings.`,
117
+ })
118
+ break
136
119
  }
137
120
 
138
121
  // Start new connection with the determined port
@@ -220,6 +203,43 @@ async function handleCommand(command) {
220
203
  break
221
204
  }
222
205
 
206
+ case 'list-project-configs': {
207
+ const configs = await loadProjectConfigs()
208
+ sendResponse(id, 'success', { configs })
209
+ break
210
+ }
211
+
212
+ case 'save-project-config': {
213
+ const { key, config } = params
214
+ if (!key || !config) {
215
+ sendResponse(id, 'error', {
216
+ message: 'key and config are required',
217
+ })
218
+ break
219
+ }
220
+ const validation = validateProjectConfig(config)
221
+ if (!validation.valid) {
222
+ sendResponse(id, 'error', {
223
+ message: `Validation failed: ${validation.errors.join(', ')}`,
224
+ })
225
+ break
226
+ }
227
+ await saveProjectConfig(key, config)
228
+ sendResponse(id, 'success', { message: 'Project config saved' })
229
+ break
230
+ }
231
+
232
+ case 'delete-project-config': {
233
+ const { key: deleteKey } = params
234
+ if (!deleteKey) {
235
+ sendResponse(id, 'error', { message: 'key is required' })
236
+ break
237
+ }
238
+ await deleteProjectConfig(deleteKey)
239
+ sendResponse(id, 'success', { message: 'Project config deleted' })
240
+ break
241
+ }
242
+
223
243
  case 'ping': {
224
244
  sendResponse(id, 'success', { message: 'pong' })
225
245
  break
@@ -233,19 +253,19 @@ async function handleCommand(command) {
233
253
  }
234
254
  }
235
255
 
236
- // Handle process signals for cleanup
237
- function setupCleanup() {
238
- const cleanup = () => {
239
- for (const [_connId, connection] of activeConnections) {
240
- connection.disconnect()
241
- }
242
- activeConnections.clear()
243
- process.exit(0)
256
+ // Disconnect all active connections (idempotent — safe to call multiple times)
257
+ function disconnectAll() {
258
+ for (const [_connId, connection] of activeConnections) {
259
+ connection.disconnect()
244
260
  }
261
+ activeConnections.clear()
262
+ }
245
263
 
246
- process.on('SIGINT', cleanup)
247
- process.on('SIGTERM', cleanup)
248
- process.on('exit', cleanup)
264
+ // Handle process signals for cleanup
265
+ function setupCleanup() {
266
+ process.on('SIGINT', () => { disconnectAll(); process.exit(0) })
267
+ process.on('SIGTERM', () => { disconnectAll(); process.exit(0) })
268
+ process.on('exit', disconnectAll)
249
269
  }
250
270
 
251
271
  // Main entry point
@@ -274,10 +294,7 @@ async function main() {
274
294
  })
275
295
 
276
296
  rl.on('close', () => {
277
- for (const [_connId, connection] of activeConnections) {
278
- connection.disconnect()
279
- }
280
- activeConnections.clear()
297
+ disconnectAll()
281
298
  process.exit(0)
282
299
  })
283
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rds_ssm_connect",
3
- "version": "1.7.13",
3
+ "version": "1.7.15",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -98,3 +98,13 @@ const updateManifest = {
98
98
 
99
99
  const outputPath = path.join(__dirname, '../latest.json')
100
100
  fs.writeFileSync(outputPath, JSON.stringify(updateManifest, null, 2))
101
+
102
+ // Warn if latest.json is not gitignored (it should never be committed)
103
+ const gitignorePath = path.join(__dirname, '../.gitignore')
104
+ try {
105
+ const gitignore = fs.readFileSync(gitignorePath, 'utf-8')
106
+ if (!gitignore.includes('latest.json')) {
107
+ console.warn('WARNING: latest.json is not in .gitignore — add it to avoid accidental commits')
108
+ }
109
+ } catch {}
110
+