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.
- package/configLoader.js +86 -0
- package/connect.js +189 -23
- package/gui-adapter.js +72 -55
- package/package.json +1 -1
- package/scripts/generate-update-json.js +10 -0
- package/src/App.svelte +60 -3
- package/src/lib/SavedConnections.svelte +76 -11
- package/src/lib/Settings.svelte +433 -28
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/lib.rs +130 -0
- package/src-tauri/tauri.conf.json +1 -1
- package/test/configLoader.test.js +150 -0
- package/envPortMapping.js +0 -54
- package/latest.json +0 -27
package/configLoader.js
ADDED
|
@@ -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 {
|
|
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.
|
|
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
|
|
100
|
+
const killAll = () => {
|
|
85
101
|
activeChildProcesses.forEach((child) => {
|
|
86
|
-
|
|
87
|
-
child.kill('SIGTERM')
|
|
88
|
-
}
|
|
102
|
+
killProcessTree(child)
|
|
89
103
|
})
|
|
90
|
-
|
|
104
|
+
activeChildProcesses = []
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
process.on('SIGINT',
|
|
94
|
-
process.on('SIGTERM',
|
|
95
|
-
process.on('exit',
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
237
|
-
function
|
|
238
|
-
const
|
|
239
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
process.on('
|
|
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
|
-
|
|
278
|
-
connection.disconnect()
|
|
279
|
-
}
|
|
280
|
-
activeConnections.clear()
|
|
297
|
+
disconnectAll()
|
|
281
298
|
process.exit(0)
|
|
282
299
|
})
|
|
283
300
|
}
|
package/package.json
CHANGED
|
@@ -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
|
+
|