sql-kite 1.0.0

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,71 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync } from 'fs';
4
+
5
+ export const SQL_KITE_HOME = join(homedir(), '.sql-kite');
6
+ export const RUNTIME_DIR = join(SQL_KITE_HOME, 'runtime');
7
+ export const STUDIO_DIR = join(SQL_KITE_HOME, 'studio');
8
+ export const LOGS_DIR = join(SQL_KITE_HOME, 'logs');
9
+
10
+ export function getProjectPath(name) {
11
+ return join(RUNTIME_DIR, name);
12
+ }
13
+
14
+ export function getProjectDbPath(name) {
15
+ return join(getProjectPath(name), 'db.sqlite');
16
+ }
17
+
18
+ export function getProjectMetaPath(name) {
19
+ return join(getProjectPath(name), '.studio', 'meta.db');
20
+ }
21
+
22
+ export function getProjectServerInfoPath(name) {
23
+ return join(getProjectPath(name), '.studio', 'server.json');
24
+ }
25
+
26
+ export function ensureSqlKiteDirs() {
27
+ [SQL_KITE_HOME, RUNTIME_DIR, STUDIO_DIR, LOGS_DIR].forEach(dir => {
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+ });
32
+ }
33
+
34
+ export function projectExists(name) {
35
+ return existsSync(getProjectPath(name));
36
+ }
37
+
38
+ /**
39
+ * Validate project name to prevent path traversal attacks
40
+ * Only allows alphanumeric, hyphens, underscores
41
+ * @param {string} name - Project name to validate
42
+ * @returns {{ valid: boolean, error?: string, sanitized?: string }}
43
+ */
44
+ export function validateProjectName(name) {
45
+ if (!name || typeof name !== 'string') {
46
+ return { valid: false, error: 'Project name is required' };
47
+ }
48
+
49
+ const trimmed = name.trim();
50
+
51
+ if (trimmed.length === 0) {
52
+ return { valid: false, error: 'Project name cannot be empty' };
53
+ }
54
+
55
+ if (trimmed.length > 50) {
56
+ return { valid: false, error: 'Project name too long (max 50 characters)' };
57
+ }
58
+
59
+ // Block path traversal attempts
60
+ if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
61
+ return { valid: false, error: 'Project name contains invalid characters (no paths allowed)' };
62
+ }
63
+
64
+ // Only allow alphanumeric, hyphens, underscores
65
+ const validPattern = /^[a-zA-Z0-9_-]+$/;
66
+ if (!validPattern.test(trimmed)) {
67
+ return { valid: false, error: 'Project name can only contain letters, numbers, hyphens, and underscores' };
68
+ }
69
+
70
+ return { valid: true, sanitized: trimmed };
71
+ }
@@ -0,0 +1,239 @@
1
+ import { createServer } from 'net';
2
+ import portRegistry from './port-registry.js';
3
+
4
+ /**
5
+ * Port configuration
6
+ */
7
+ const PORT_CONFIG = {
8
+ DEFAULT_START: 3000,
9
+ DEFAULT_END: 3999,
10
+ FALLBACK_START: 4000,
11
+ FALLBACK_END: 4999,
12
+ MAX_ATTEMPTS: 100,
13
+ SCAN_BATCH_SIZE: 10
14
+ };
15
+
16
+ /**
17
+ * Check if a single port is available at OS level
18
+ */
19
+ async function isPortAvailable(port) {
20
+ return new Promise((resolve) => {
21
+ const server = createServer();
22
+
23
+ const cleanup = () => {
24
+ server.removeAllListeners();
25
+ };
26
+
27
+ server.once('error', (err) => {
28
+ cleanup();
29
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
30
+ resolve(false);
31
+ } else {
32
+ resolve(false);
33
+ }
34
+ });
35
+
36
+ server.once('listening', () => {
37
+ server.close(() => {
38
+ cleanup();
39
+ resolve(true);
40
+ });
41
+ });
42
+
43
+ // Bind to all interfaces to ensure we check properly
44
+ server.listen(port, '0.0.0.0');
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Find a free port in the given range
50
+ * Uses intelligent scanning with registry integration
51
+ */
52
+ async function findFreePortInRange(startPort, endPort, projectName = null) {
53
+ // Auto-cleanup stale allocations
54
+ portRegistry.autoCleanup();
55
+
56
+ // Get currently allocated ports from registry
57
+ const allocatedPorts = portRegistry.getAllocatedPorts();
58
+
59
+ let attempts = 0;
60
+ let currentPort = startPort;
61
+
62
+ while (currentPort <= endPort && attempts < PORT_CONFIG.MAX_ATTEMPTS) {
63
+ attempts++;
64
+
65
+ // Skip ports already allocated in registry
66
+ if (allocatedPorts.includes(currentPort)) {
67
+ currentPort++;
68
+ continue;
69
+ }
70
+
71
+ // Check if port is available at OS level
72
+ const available = await isPortAvailable(currentPort);
73
+
74
+ if (available) {
75
+ // Try to reserve in registry if project name provided
76
+ if (projectName) {
77
+ const reserved = await portRegistry.reservePort(projectName, currentPort);
78
+ if (reserved) {
79
+ return currentPort;
80
+ }
81
+ // Port was taken by another process between check and reserve
82
+ // Continue to next port
83
+ currentPort++;
84
+ continue;
85
+ }
86
+
87
+ return currentPort;
88
+ }
89
+
90
+ currentPort++;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Scan multiple ports in parallel (batch scanning)
98
+ * Much faster for finding free ports in busy systems
99
+ */
100
+ async function scanPortsBatch(ports) {
101
+ const results = await Promise.all(
102
+ ports.map(async (port) => ({
103
+ port,
104
+ available: await isPortAvailable(port)
105
+ }))
106
+ );
107
+
108
+ const availablePort = results.find(r => r.available);
109
+ return availablePort ? availablePort.port : null;
110
+ }
111
+
112
+ /**
113
+ * Find a free port with intelligent strategies
114
+ *
115
+ * Strategy:
116
+ * 1. Check registry for existing project allocation
117
+ * 2. Try default range (3000-3999) with batch scanning
118
+ * 3. Fallback to higher range (4000-4999)
119
+ * 4. Sequential scan as last resort
120
+ *
121
+ * @param {number} startPort - Preferred starting port (default: 3000)
122
+ * @param {string} projectName - Project name for registry tracking (optional)
123
+ * @returns {Promise<number>} - Free port number
124
+ */
125
+ export async function findFreePort(startPort = PORT_CONFIG.DEFAULT_START, projectName = null) {
126
+ try {
127
+ // Auto-cleanup stale allocations first
128
+ portRegistry.autoCleanup();
129
+
130
+ // If project name provided, check if already has allocated port
131
+ if (projectName) {
132
+ const existingPort = portRegistry.getProjectPort(projectName);
133
+ if (existingPort) {
134
+ // Verify it's still valid
135
+ const isValid = await isPortAvailable(existingPort);
136
+ if (isValid) {
137
+ return existingPort;
138
+ }
139
+ // Port no longer valid, release it
140
+ portRegistry.releasePort(projectName);
141
+ }
142
+ }
143
+
144
+ // Strategy 1: Try the requested start port
145
+ if (startPort >= PORT_CONFIG.DEFAULT_START && startPort <= PORT_CONFIG.DEFAULT_END) {
146
+ const allocatedPorts = portRegistry.getAllocatedPorts();
147
+ if (!allocatedPorts.includes(startPort)) {
148
+ const available = await isPortAvailable(startPort);
149
+ if (available) {
150
+ if (projectName) {
151
+ const reserved = await portRegistry.reservePort(projectName, startPort);
152
+ if (reserved) return startPort;
153
+ } else {
154
+ return startPort;
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ // Strategy 2: Quick batch scan in default range
161
+ const defaultRangeStart = Math.max(startPort, PORT_CONFIG.DEFAULT_START);
162
+ const batchPorts = [];
163
+ for (let i = 0; i < PORT_CONFIG.SCAN_BATCH_SIZE; i++) {
164
+ const port = defaultRangeStart + i;
165
+ if (port <= PORT_CONFIG.DEFAULT_END) {
166
+ batchPorts.push(port);
167
+ }
168
+ }
169
+
170
+ if (batchPorts.length > 0) {
171
+ const allocatedPorts = portRegistry.getAllocatedPorts();
172
+ const freePorts = batchPorts.filter(p => !allocatedPorts.includes(p));
173
+
174
+ if (freePorts.length > 0) {
175
+ const batchResult = await scanPortsBatch(freePorts);
176
+ if (batchResult) {
177
+ if (projectName) {
178
+ const reserved = await portRegistry.reservePort(projectName, batchResult);
179
+ if (reserved) return batchResult;
180
+ } else {
181
+ return batchResult;
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // Strategy 3: Sequential scan in default range
188
+ let port = await findFreePortInRange(
189
+ defaultRangeStart,
190
+ PORT_CONFIG.DEFAULT_END,
191
+ projectName
192
+ );
193
+ if (port) return port;
194
+
195
+ // Strategy 4: Fallback range
196
+ port = await findFreePortInRange(
197
+ PORT_CONFIG.FALLBACK_START,
198
+ PORT_CONFIG.FALLBACK_END,
199
+ projectName
200
+ );
201
+ if (port) return port;
202
+
203
+ // Strategy 5: Extended range (5000-9999)
204
+ port = await findFreePortInRange(5000, 9999, projectName);
205
+ if (port) return port;
206
+
207
+ // No port found
208
+ throw new Error(
209
+ 'Unable to find a free port. Please ensure you have available ports in range 3000-9999'
210
+ );
211
+ } catch (error) {
212
+ if (error.message.includes('Unable to find')) {
213
+ throw error;
214
+ }
215
+ // Fallback to simple sequential search on unexpected errors
216
+ return findFreePortInRange(startPort, startPort + 1000, projectName);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Release a port for a project
222
+ */
223
+ export function releasePort(projectName) {
224
+ return portRegistry.releasePort(projectName);
225
+ }
226
+
227
+ /**
228
+ * Get port registry status
229
+ */
230
+ export function getPortStatus() {
231
+ return portRegistry.getStatus();
232
+ }
233
+
234
+ /**
235
+ * Manual cleanup of stale allocations
236
+ */
237
+ export function cleanupStalePorts() {
238
+ return portRegistry.cleanup();
239
+ }
@@ -0,0 +1,233 @@
1
+ import { join } from 'path';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
3
+ import { RUNTIME_DIR } from './paths.js';
4
+ import { createServer } from 'net';
5
+
6
+ const REGISTRY_FILE = join(RUNTIME_DIR, '.port-registry.json');
7
+ const LOCK_TIMEOUT = 5000; // 5 seconds
8
+
9
+ /**
10
+ * Port Registry - Manages port allocation across all SQL Kite projects
11
+ *
12
+ * Features:
13
+ * - Centralized port tracking
14
+ * - Atomic port allocation
15
+ * - Conflict detection
16
+ * - Automatic cleanup of stale allocations
17
+ * - Fast port validation
18
+ */
19
+
20
+ class PortRegistry {
21
+ constructor() {
22
+ this.ensureRegistryFile();
23
+ }
24
+
25
+ ensureRegistryFile() {
26
+ if (!existsSync(RUNTIME_DIR)) {
27
+ mkdirSync(RUNTIME_DIR, { recursive: true });
28
+ }
29
+ if (!existsSync(REGISTRY_FILE)) {
30
+ this.writeRegistry({ ports: {}, last_cleanup: Date.now() });
31
+ }
32
+ }
33
+
34
+ readRegistry() {
35
+ try {
36
+ const data = readFileSync(REGISTRY_FILE, 'utf-8');
37
+ return JSON.parse(data);
38
+ } catch (e) {
39
+ // Corrupted registry, recreate
40
+ this.writeRegistry({ ports: {}, last_cleanup: Date.now() });
41
+ return { ports: {}, last_cleanup: Date.now() };
42
+ }
43
+ }
44
+
45
+ writeRegistry(data) {
46
+ writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2));
47
+ }
48
+
49
+ /**
50
+ * Get all currently allocated ports
51
+ */
52
+ getAllocatedPorts() {
53
+ const registry = this.readRegistry();
54
+ return Object.values(registry.ports).map(entry => entry.port);
55
+ }
56
+
57
+ /**
58
+ * Check if a port is allocated to any project
59
+ */
60
+ isPortAllocated(port) {
61
+ const allocatedPorts = this.getAllocatedPorts();
62
+ return allocatedPorts.includes(port);
63
+ }
64
+
65
+ /**
66
+ * Get port for a specific project
67
+ */
68
+ getProjectPort(projectName) {
69
+ const registry = this.readRegistry();
70
+ return registry.ports[projectName]?.port || null;
71
+ }
72
+
73
+ /**
74
+ * Reserve a port for a project atomically
75
+ */
76
+ async reservePort(projectName, port) {
77
+ const registry = this.readRegistry();
78
+
79
+ // Check if project already has a port
80
+ if (registry.ports[projectName]) {
81
+ const existingPort = registry.ports[projectName].port;
82
+ // Verify the existing port is still valid
83
+ const isValid = await this.isPortActuallyFree(existingPort);
84
+ if (isValid) {
85
+ return existingPort;
86
+ }
87
+ // Stale allocation, remove it
88
+ delete registry.ports[projectName];
89
+ }
90
+
91
+ // Check if port is already allocated
92
+ const allocatedPorts = Object.values(registry.ports).map(e => e.port);
93
+ if (allocatedPorts.includes(port)) {
94
+ return null; // Port already taken
95
+ }
96
+
97
+ // Verify port is actually free at OS level
98
+ const isFree = await this.isPortActuallyFree(port);
99
+ if (!isFree) {
100
+ return null;
101
+ }
102
+
103
+ // Reserve the port
104
+ registry.ports[projectName] = {
105
+ port,
106
+ allocated_at: Date.now(),
107
+ pid: process.pid
108
+ };
109
+
110
+ this.writeRegistry(registry);
111
+ return port;
112
+ }
113
+
114
+ /**
115
+ * Release a port for a project
116
+ */
117
+ releasePort(projectName) {
118
+ const registry = this.readRegistry();
119
+ if (registry.ports[projectName]) {
120
+ delete registry.ports[projectName];
121
+ this.writeRegistry(registry);
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Check if a port is actually free at OS level
129
+ */
130
+ async isPortActuallyFree(port) {
131
+ return new Promise((resolve) => {
132
+ const server = createServer();
133
+
134
+ server.once('error', (err) => {
135
+ if (err.code === 'EADDRINUSE') {
136
+ resolve(false);
137
+ } else {
138
+ resolve(false);
139
+ }
140
+ });
141
+
142
+ server.once('listening', () => {
143
+ server.close(() => {
144
+ resolve(true);
145
+ });
146
+ });
147
+
148
+ server.listen(port, '0.0.0.0');
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Cleanup stale port allocations
154
+ * Checks if server.json files exist for allocated projects
155
+ */
156
+ cleanup() {
157
+ const registry = this.readRegistry();
158
+ const projects = Object.keys(registry.ports);
159
+ let cleanedCount = 0;
160
+
161
+ for (const projectName of projects) {
162
+ const serverInfoPath = join(RUNTIME_DIR, projectName, '.studio', 'server.json');
163
+
164
+ // If server.json doesn't exist, the port allocation is stale
165
+ if (!existsSync(serverInfoPath)) {
166
+ delete registry.ports[projectName];
167
+ cleanedCount++;
168
+ } else {
169
+ // Verify the server is actually running
170
+ try {
171
+ const serverInfo = JSON.parse(readFileSync(serverInfoPath, 'utf-8'));
172
+ try {
173
+ // Check if process exists (signal 0 doesn't kill, just checks)
174
+ process.kill(serverInfo.pid, 0);
175
+ } catch (e) {
176
+ // Process doesn't exist, cleanup
177
+ delete registry.ports[projectName];
178
+ unlinkSync(serverInfoPath);
179
+ cleanedCount++;
180
+ }
181
+ } catch (e) {
182
+ // Corrupted server.json
183
+ delete registry.ports[projectName];
184
+ cleanedCount++;
185
+ }
186
+ }
187
+ }
188
+
189
+ registry.last_cleanup = Date.now();
190
+ this.writeRegistry(registry);
191
+
192
+ return cleanedCount;
193
+ }
194
+
195
+ /**
196
+ * Auto cleanup if last cleanup was more than 1 hour ago
197
+ */
198
+ autoCleanup() {
199
+ const registry = this.readRegistry();
200
+ const ONE_HOUR = 60 * 60 * 1000;
201
+
202
+ if (!registry.last_cleanup || (Date.now() - registry.last_cleanup) > ONE_HOUR) {
203
+ return this.cleanup();
204
+ }
205
+
206
+ return 0;
207
+ }
208
+
209
+ /**
210
+ * Get registry status
211
+ */
212
+ getStatus() {
213
+ const registry = this.readRegistry();
214
+ return {
215
+ total_allocations: Object.keys(registry.ports).length,
216
+ allocations: registry.ports,
217
+ last_cleanup: registry.last_cleanup
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Force clear all allocations (dangerous - for debugging only)
223
+ */
224
+ clearAll() {
225
+ this.writeRegistry({ ports: {}, last_cleanup: Date.now() });
226
+ }
227
+ }
228
+
229
+ // Singleton instance
230
+ const portRegistry = new PortRegistry();
231
+
232
+ export default portRegistry;
233
+ export { PortRegistry };