rds_ssm_connect 1.7.13 → 1.7.14

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
@@ -7,10 +7,14 @@ 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.14' }
14
18
 
15
19
  const execAsync = promisify(exec)
16
20
 
@@ -389,21 +393,34 @@ async function getRdsPort(ENV, projectConfig) {
389
393
  }
390
394
 
391
395
  function getProfilesForProject(allProfiles, projectConfig, allProjectConfigs) {
392
- const { profileFilter } = projectConfig
396
+ const { profileFilter, envPortMapping } = projectConfig
393
397
 
398
+ let filtered
394
399
  if (profileFilter) {
395
400
  // Project has explicit filter - return profiles starting with filter
396
- return allProfiles.filter((env) => env.startsWith(profileFilter))
401
+ filtered = allProfiles.filter((env) => env.startsWith(profileFilter))
397
402
  } else {
398
403
  // No filter (legacy project like TLN) - return profiles that don't match any other project's filter
399
404
  const otherFilters = Object.values(allProjectConfigs)
400
405
  .filter((config) => config.profileFilter)
401
406
  .map((config) => config.profileFilter)
402
407
 
403
- return allProfiles.filter(
408
+ filtered = allProfiles.filter(
404
409
  (env) => !otherFilters.some((filter) => env.startsWith(filter)),
405
410
  )
406
411
  }
412
+
413
+ // Further restrict to profiles matching an envPortMapping suffix
414
+ if (envPortMapping && Object.keys(envPortMapping).length > 0) {
415
+ const suffixes = Object.keys(envPortMapping).sort(
416
+ (a, b) => b.length - a.length,
417
+ )
418
+ filtered = filtered.filter((env) =>
419
+ suffixes.some((suffix) => env.endsWith(suffix) || env === suffix),
420
+ )
421
+ }
422
+
423
+ return filtered
407
424
  }
408
425
 
409
426
  // Get local port number based on environment suffix
@@ -480,6 +497,8 @@ async function getAvailableProjects() {
480
497
  return []
481
498
  }
482
499
 
500
+ const PROJECT_CONFIGS = await loadProjectConfigs()
501
+
483
502
  return Object.entries(PROJECT_CONFIGS)
484
503
  .filter(([_key, config]) => {
485
504
  const matchingProfiles = getProfilesForProject(
@@ -498,6 +517,7 @@ async function getAvailableProjects() {
498
517
  // Get profiles for a specific project
499
518
  async function getProfilesForProjectKey(projectKey) {
500
519
  const allProfiles = await readAwsConfig()
520
+ const PROJECT_CONFIGS = await loadProjectConfigs()
501
521
  const projectConfig = PROJECT_CONFIGS[projectKey]
502
522
 
503
523
  if (!projectConfig) {
@@ -511,6 +531,7 @@ async function getProfilesForProjectKey(projectKey) {
511
531
  // Includes keepalive (prevents SSM idle timeout) and auto-reconnect
512
532
  // (transparently reconnects on the same port if session drops unexpectedly).
513
533
  async function connect(projectKey, profile, options = {}) {
534
+ const PROJECT_CONFIGS = await loadProjectConfigs()
514
535
  const projectConfig = PROJECT_CONFIGS[projectKey]
515
536
  if (!projectConfig) {
516
537
  throw new Error(`Unknown project: ${projectKey}`)
@@ -674,6 +695,116 @@ async function main() {
674
695
  await checkForUpdates()
675
696
 
676
697
  try {
698
+ // Load project configs from user config file
699
+ let PROJECT_CONFIGS = await loadProjectConfigs()
700
+
701
+ // First-run wizard: if no projects configured, prompt to create one
702
+ if (Object.keys(PROJECT_CONFIGS).length === 0) {
703
+ const { setupNow } = await inquirer.default.prompt([
704
+ {
705
+ type: 'confirm',
706
+ name: 'setupNow',
707
+ message:
708
+ 'No projects configured. Would you like to set up a project now?',
709
+ default: true,
710
+ },
711
+ ])
712
+
713
+ if (!setupNow) {
714
+ console.log(
715
+ `\nTo configure manually, create ~/.rds-ssm-connect/projects.json\nSee the README for the config schema.\n`,
716
+ )
717
+ return
718
+ }
719
+
720
+ const answers = await inquirer.default.prompt([
721
+ {
722
+ type: 'input',
723
+ name: 'key',
724
+ message: 'Project key (lowercase, hyphens):',
725
+ validate: (v) =>
726
+ /^[a-z][a-z0-9-]*$/.test(v) || 'Lowercase letters, digits, hyphens',
727
+ },
728
+ { type: 'input', name: 'name', message: 'Display name:' },
729
+ {
730
+ type: 'input',
731
+ name: 'region',
732
+ message: 'AWS region:',
733
+ default: 'us-east-1',
734
+ },
735
+ { type: 'input', name: 'database', message: 'Database name:' },
736
+ {
737
+ type: 'input',
738
+ name: 'secretPrefix',
739
+ message: 'Secret prefix (e.g. rds!cluster):',
740
+ },
741
+ {
742
+ type: 'select',
743
+ name: 'rdsType',
744
+ message: 'RDS type:',
745
+ choices: ['cluster', 'instance'],
746
+ },
747
+ {
748
+ type: 'input',
749
+ name: 'rdsPattern',
750
+ message: 'RDS identifier pattern:',
751
+ },
752
+ {
753
+ type: 'input',
754
+ name: 'profileFilter',
755
+ message: 'Profile filter prefix (leave empty for none):',
756
+ default: '',
757
+ },
758
+ {
759
+ type: 'input',
760
+ name: 'defaultPort',
761
+ message: 'Default local port:',
762
+ default: '5432',
763
+ },
764
+ ])
765
+
766
+ // Collect port mappings
767
+ const envPortMappingInput = {}
768
+ let addMore = true
769
+ while (addMore) {
770
+ const mapping = await inquirer.default.prompt([
771
+ { type: 'input', name: 'suffix', message: 'Environment suffix:' },
772
+ { type: 'input', name: 'port', message: 'Local port:' },
773
+ {
774
+ type: 'confirm',
775
+ name: 'more',
776
+ message: 'Add another port mapping?',
777
+ default: false,
778
+ },
779
+ ])
780
+ envPortMappingInput[mapping.suffix] = mapping.port
781
+ addMore = mapping.more
782
+ }
783
+
784
+ const newConfig = {
785
+ name: answers.name,
786
+ region: answers.region,
787
+ database: answers.database,
788
+ secretPrefix: answers.secretPrefix,
789
+ rdsType: answers.rdsType,
790
+ rdsPattern: answers.rdsPattern,
791
+ profileFilter: answers.profileFilter || null,
792
+ envPortMapping: envPortMappingInput,
793
+ defaultPort: answers.defaultPort,
794
+ }
795
+
796
+ const validation = validateProjectConfig(newConfig)
797
+ if (!validation.valid) {
798
+ console.log('\nValidation errors:')
799
+ validation.errors.forEach((e) => console.log(` - ${e}`))
800
+ return
801
+ }
802
+
803
+ await saveProjectConfig(answers.key, newConfig)
804
+ console.log(`\nProject "${answers.name}" saved!\n`)
805
+ PROJECT_CONFIGS = await loadProjectConfigs()
806
+ }
807
+
677
808
  // Read all AWS profiles first
678
809
  const allProfiles = await readAwsConfig()
679
810
 
@@ -834,6 +965,6 @@ export {
834
965
  getLocalPort,
835
966
  connect,
836
967
  ipcEmitter,
837
- PROJECT_CONFIGS,
968
+ loadProjectConfigs,
838
969
  RETRY_CONFIG,
839
970
  }
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
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.14",
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
+
package/src/App.svelte CHANGED
@@ -32,6 +32,7 @@ let showSavePrompt = $state(false)
32
32
  let lastConnectedConfig = $state(null)
33
33
  let saveConnectionName = $state('')
34
34
  let showDeleteConfirm = $state(null)
35
+ let showCloseConfirm = $state(false)
35
36
  let isCheckingUpdates = $state(false)
36
37
  let updateCheckMessage = $state('')
37
38
 
@@ -42,10 +43,12 @@ let showSettings = $state(false)
42
43
 
43
44
  let invoke = null
44
45
  let listen = null
46
+ let appWindow = null
45
47
 
46
48
  // Cleanup references
47
49
  let cancelUpdateMsgTimeout = null
48
50
  let unlistenSidecar = null
51
+ let unlistenCloseRequested = null
49
52
 
50
53
  // Global keyboard shortcuts
51
54
  function handleGlobalKeydown(e) {
@@ -83,21 +86,31 @@ async function initApp() {
83
86
  loadingProjects = true
84
87
 
85
88
  try {
86
- const [core, event] = await withTimeout(
89
+ const [core, event, windowModule] = await withTimeout(
87
90
  Promise.all([
88
91
  import('@tauri-apps/api/core'),
89
92
  import('@tauri-apps/api/event'),
93
+ import('@tauri-apps/api/window'),
90
94
  ]),
91
95
  5000,
92
96
  )
93
97
  invoke = core.invoke
94
98
  listen = event.listen
99
+ appWindow = windowModule.getCurrentWindow()
95
100
  } catch (err) {
96
101
  errorMessage = `Failed to load Tauri API: ${err}`
97
102
  loadingProjects = false
98
103
  return
99
104
  }
100
105
 
106
+ // Intercept window close — prompt if there are active connections
107
+ unlistenCloseRequested = await appWindow.onCloseRequested(async (event) => {
108
+ if (activeConnections.length > 0) {
109
+ event.preventDefault()
110
+ showCloseConfirm = true
111
+ }
112
+ })
113
+
101
114
  // Set up sidecar listener (non-blocking)
102
115
  listen('sidecar-event', (ev) => {
103
116
  const data = ev.payload
@@ -159,8 +172,23 @@ function retryInit() {
159
172
  onDestroy(() => {
160
173
  cancelUpdateMsgTimeout?.()
161
174
  unlistenSidecar?.()
175
+ unlistenCloseRequested?.()
162
176
  })
163
177
 
178
+ async function confirmClose() {
179
+ showCloseConfirm = false
180
+ try {
181
+ await invoke('disconnect_all')
182
+ } catch (_err) {
183
+ // Best-effort disconnect before closing
184
+ }
185
+ await invoke('quit_app')
186
+ }
187
+
188
+ function cancelClose() {
189
+ showCloseConfirm = false
190
+ }
191
+
164
192
  async function checkForUpdates() {
165
193
  if (isCheckingUpdates) return
166
194
  isCheckingUpdates = true
@@ -213,6 +241,14 @@ async function loadProfiles() {
213
241
  }
214
242
  }
215
243
 
244
+ async function refreshProjects() {
245
+ try {
246
+ projects = await invoke('list_projects')
247
+ } catch (err) {
248
+ errorMessage = `Failed to refresh projects: ${err}`
249
+ }
250
+ }
251
+
216
252
  async function handleConnect() {
217
253
  if (!selectedProject || !selectedProfile) return
218
254
 
@@ -490,6 +526,7 @@ const isAlreadySaved = $derived(
490
526
  {savedConnections}
491
527
  {activeConnections}
492
528
  {projects}
529
+ {connectingId}
493
530
  onConnect={handleSavedConnectionConnect}
494
531
  onDisconnect={handleDisconnectOne}
495
532
  onDelete={handleDeleteSavedConnection}
@@ -586,6 +623,18 @@ const isAlreadySaved = $derived(
586
623
  />
587
624
  {/if}
588
625
 
626
+ {#if showCloseConfirm}
627
+ <ConfirmDialog
628
+ title="Close Application"
629
+ message="All active connections will be closed. Are you sure you want to quit?"
630
+ confirmLabel="Quit"
631
+ cancelLabel="Cancel"
632
+ destructive={true}
633
+ onConfirm={confirmClose}
634
+ onCancel={cancelClose}
635
+ />
636
+ {/if}
637
+
589
638
  {#if showPrerequisites}
590
639
  <PrerequisitesCheck
591
640
  prerequisites={prerequisitesData}
@@ -598,6 +647,7 @@ const isAlreadySaved = $derived(
598
647
  <Settings
599
648
  onClose={() => showSettings = false}
600
649
  {invoke}
650
+ onProjectsChanged={refreshProjects}
601
651
  />
602
652
  {/if}
603
653
  {/if}