pg-local-cli 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.
- package/README.md +138 -0
- package/bin/index.js +260 -0
- package/lib/config.js +107 -0
- package/lib/daemon.js +119 -0
- package/lib/manager.js +242 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# pg-local-cli
|
|
2
|
+
|
|
3
|
+
Một công cụ CLI bằng **Node.js** giúp bạn dễ dàng khởi tạo, khởi chạy và quản lý các database instance **PostgreSQL** trên localhost mà **không cần sử dụng Docker, Podman hay cài đặt PostgreSQL toàn hệ thống**.
|
|
4
|
+
|
|
5
|
+
Dự án này sử dụng các binary PostgreSQL portable chính thức được tải tự động tùy theo hệ điều hành (Windows, macOS, Linux) thông qua thư viện `embedded-postgres`.
|
|
6
|
+
|
|
7
|
+
## ✨ Tính năng chính
|
|
8
|
+
|
|
9
|
+
- **Không cần Docker/Podman**: Phù hợp cho máy yếu hoặc môi trường không cài được Docker.
|
|
10
|
+
- **Không cần cài Postgres hệ thống**: Tự động tải binary độc lập về thư mục home của user.
|
|
11
|
+
- **Hỗ trợ đổi Host/Port**: Dễ dàng đổi cổng hoặc địa chỉ IP lắng nghe để tránh xung đột cổng (ví dụ: cổng mặc định 5432 bị chiếm).
|
|
12
|
+
- **Chạy ngầm (Daemon)**: Khi gõ `start`, Postgres sẽ chạy ngầm, bạn có thể tắt terminal đi mà database vẫn chạy bình thường.
|
|
13
|
+
- **Hỗ trợ nhiều Instance**: Tạo và cấu hình độc lập nhiều database khác nhau (ví dụ: `my_app`, `dev_db`).
|
|
14
|
+
- **Đa nền tảng**: Hỗ trợ đầy đủ Windows (x64), macOS (Intel, Apple Silicon) và Linux (x64, arm64, v.v.).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🚀 Cài đặt
|
|
19
|
+
|
|
20
|
+
Cài đặt global thông qua npm để sử dụng CLI ở mọi nơi:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g pg-local-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
*(Lưu ý: npm sẽ tự động tải phiên bản PostgreSQL binary phù hợp nhất với OS và kiến trúc CPU của bạn trong quá trình cài đặt).*
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 🛠 Hướng dẫn sử dụng
|
|
31
|
+
|
|
32
|
+
Sau khi cài đặt, bạn sẽ có lệnh `pg-local`.
|
|
33
|
+
|
|
34
|
+
### 1. Khởi động PostgreSQL (Start)
|
|
35
|
+
Khởi chạy instance mặc định (tên là `default`):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pg-local start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Khởi chạy một instance với tên cụ thể:
|
|
42
|
+
```bash
|
|
43
|
+
pg-local start my_app
|
|
44
|
+
```
|
|
45
|
+
*(Nếu đây là lần đầu chạy, CLI sẽ tự động khởi tạo database cluster - chạy `initdb` trước khi start).*
|
|
46
|
+
|
|
47
|
+
### 2. Kiểm tra trạng thái (Status)
|
|
48
|
+
Xem thông tin chi tiết của instance đang chạy hay dừng:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pg-local status
|
|
52
|
+
```
|
|
53
|
+
Hoặc xem instance cụ thể:
|
|
54
|
+
```bash
|
|
55
|
+
pg-local status my_app
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Dừng PostgreSQL (Stop)
|
|
59
|
+
Dừng instance một cách an toàn và giải phóng tài nguyên:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pg-local stop
|
|
63
|
+
```
|
|
64
|
+
Hoặc:
|
|
65
|
+
```bash
|
|
66
|
+
pg-local stop my_app
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 4. Liệt kê các instance (List)
|
|
70
|
+
Xem danh sách toàn bộ các database instance mà bạn đã tạo cùng trạng thái và cổng chạy của chúng:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pg-local list
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 5. Cấu hình Host / Port / User (Config)
|
|
77
|
+
Xem cấu hình hiện tại:
|
|
78
|
+
```bash
|
|
79
|
+
pg-local config show
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Thay đổi các thông số cấu hình (Ví dụ thay đổi port thành `5433` để tránh xung đột cổng):
|
|
83
|
+
```bash
|
|
84
|
+
pg-local config set port 5433
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Thay đổi port cho một instance cụ thể:
|
|
88
|
+
```bash
|
|
89
|
+
pg-local config set port 15432 my_app
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Các khóa cấu hình hỗ trợ:
|
|
93
|
+
- `port`: Cổng TCP chạy database (mặc định: `5432`).
|
|
94
|
+
- `host`: Địa chỉ IP lắng nghe (mặc định: `127.0.0.1`).
|
|
95
|
+
- `user`: Tên tài khoản superuser (mặc định: `postgres`).
|
|
96
|
+
- `password`: Mật khẩu superuser (mặc định: `password`).
|
|
97
|
+
- `databaseDir`: Đường dẫn lưu dữ liệu database (mặc định trong thư mục `~/.pg-local/instances/<name>/data`).
|
|
98
|
+
|
|
99
|
+
### 6. Xem Log của PostgreSQL (Logs)
|
|
100
|
+
Xem toàn bộ log đã ghi:
|
|
101
|
+
```bash
|
|
102
|
+
pg-local logs
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Theo dõi log thời gian thực (real-time stream) giống như lệnh `tail -f`:
|
|
106
|
+
```bash
|
|
107
|
+
pg-local logs -f
|
|
108
|
+
# Hoặc cho instance cụ thể
|
|
109
|
+
pg-local logs my_app -f
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 7. Xóa dữ liệu (Destroy)
|
|
113
|
+
Dừng database và xóa hoàn toàn thư mục chứa dữ liệu của instance đó:
|
|
114
|
+
```bash
|
|
115
|
+
pg-local destroy
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 🔌 Chuỗi kết nối (Connection String)
|
|
121
|
+
|
|
122
|
+
Mặc định, bạn có thể kết nối tới PostgreSQL bằng chuỗi kết nối:
|
|
123
|
+
```
|
|
124
|
+
postgresql://postgres:password@127.0.0.1:5432/postgres
|
|
125
|
+
```
|
|
126
|
+
*(Hoặc cổng mới nếu bạn đã thay đổi trong cấu hình).*
|
|
127
|
+
|
|
128
|
+
Bạn có thể kết nối từ bất kỳ công cụ quản lý nào như **DBeaver**, **TablePlus**, **pgAdmin**, hoặc các ORM như **Prisma**, **Drizzle**, **TypeORM** trong dự án của bạn.
|
|
129
|
+
|
|
130
|
+
## 📂 Nơi lưu trữ dữ liệu
|
|
131
|
+
|
|
132
|
+
Toàn bộ cấu hình và dữ liệu của công cụ được lưu trữ độc lập tại:
|
|
133
|
+
- **Unix/macOS/Linux**: `~/.pg-local/`
|
|
134
|
+
- **Windows**: `C:\Users\<Tên_User>\.pg-local\`
|
|
135
|
+
|
|
136
|
+
## 📄 Giấy phép
|
|
137
|
+
|
|
138
|
+
Dự án này được phân phối dưới giấy phép **MIT**.
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const fs = require('fs/promises');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const manager = require('../lib/manager');
|
|
7
|
+
const { getLogPath, saveConfig, loadConfig } = require('../lib/config');
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
const colors = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
bright: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
red: '\x1b[31m',
|
|
16
|
+
green: '\x1b[32m',
|
|
17
|
+
yellow: '\x1b[33m',
|
|
18
|
+
blue: '\x1b[34m',
|
|
19
|
+
magenta: '\x1b[35m',
|
|
20
|
+
cyan: '\x1b[36m',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function formatStatus(status) {
|
|
24
|
+
if (status.running) {
|
|
25
|
+
return `${colors.green}● Running (PID: ${status.pid})${colors.reset}`;
|
|
26
|
+
}
|
|
27
|
+
return `${colors.red}○ Stopped${colors.reset}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.name('pg-local')
|
|
32
|
+
.description('Manage local PostgreSQL instances without Docker/Podman')
|
|
33
|
+
.version('1.0.0');
|
|
34
|
+
|
|
35
|
+
// Start Command
|
|
36
|
+
program
|
|
37
|
+
.command('start [name]')
|
|
38
|
+
.description('Start a local PostgreSQL instance')
|
|
39
|
+
.action(async (name = 'default') => {
|
|
40
|
+
console.log(`${colors.cyan}Starting PostgreSQL instance "${name}"...${colors.reset}`);
|
|
41
|
+
try {
|
|
42
|
+
const result = await manager.startInstance(name);
|
|
43
|
+
if (result.alreadyRunning) {
|
|
44
|
+
console.log(`${colors.yellow}Instance "${name}" is already running (PID: ${result.pid}).${colors.reset}`);
|
|
45
|
+
} else {
|
|
46
|
+
console.log(`${colors.green}✓ PostgreSQL instance "${name}" successfully started!${colors.reset}`);
|
|
47
|
+
}
|
|
48
|
+
console.log(`\nConnection details:`);
|
|
49
|
+
console.log(` ${colors.bright}Host:${colors.reset} ${result.config.host}`);
|
|
50
|
+
console.log(` ${colors.bright}Port:${colors.reset} ${result.config.port}`);
|
|
51
|
+
console.log(` ${colors.bright}User:${colors.reset} ${result.config.user}`);
|
|
52
|
+
console.log(` ${colors.bright}Password:${colors.reset} ${result.config.password}`);
|
|
53
|
+
console.log(` ${colors.bright}URI:${colors.reset} postgresql://${result.config.user}:${result.config.password}@${result.config.host}:${result.config.port}/postgres\n`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Stop Command
|
|
61
|
+
program
|
|
62
|
+
.command('stop [name]')
|
|
63
|
+
.description('Stop a local PostgreSQL instance')
|
|
64
|
+
.action(async (name = 'default') => {
|
|
65
|
+
console.log(`${colors.cyan}Stopping PostgreSQL instance "${name}"...${colors.reset}`);
|
|
66
|
+
try {
|
|
67
|
+
const result = await manager.stopInstance(name);
|
|
68
|
+
if (result.alreadyStopped) {
|
|
69
|
+
console.log(`${colors.yellow}Instance "${name}" is already stopped.${colors.reset}`);
|
|
70
|
+
} else if (result.forced) {
|
|
71
|
+
console.log(`${colors.yellow}! Instance "${name}" did not respond to graceful stop. Terminated forcefully.${colors.reset}`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(`${colors.green}✓ PostgreSQL instance "${name}" stopped gracefully.${colors.reset}`);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Status Command
|
|
82
|
+
program
|
|
83
|
+
.command('status [name]')
|
|
84
|
+
.description('Check the status of a PostgreSQL instance')
|
|
85
|
+
.action(async (name = 'default') => {
|
|
86
|
+
try {
|
|
87
|
+
const status = await manager.getStatus(name);
|
|
88
|
+
console.log(`${colors.bright}Instance:${colors.reset} ${status.name}`);
|
|
89
|
+
console.log(`${colors.bright}Status:${colors.reset} ${formatStatus(status)}`);
|
|
90
|
+
console.log(`${colors.bright}Host:${colors.reset} ${status.host}`);
|
|
91
|
+
console.log(`${colors.bright}Port:${colors.reset} ${status.port}`);
|
|
92
|
+
console.log(`${colors.bright}User:${colors.reset} ${status.user}`);
|
|
93
|
+
console.log(`${colors.bright}Dir:${colors.reset} ${status.databaseDir}`);
|
|
94
|
+
if (status.running) {
|
|
95
|
+
console.log(`${colors.bright}URI:${colors.reset} ${status.connectionString}`);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// List Command
|
|
104
|
+
program
|
|
105
|
+
.command('list')
|
|
106
|
+
.description('List all local PostgreSQL instances')
|
|
107
|
+
.action(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const instances = await manager.listInstances();
|
|
110
|
+
if (instances.length === 0) {
|
|
111
|
+
console.log(`${colors.yellow}No instances found. Start one with "pg-local start [name]".${colors.reset}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
console.log(`${colors.bright}Local PostgreSQL Instances:${colors.reset}`);
|
|
115
|
+
console.log(''.padEnd(50, '-'));
|
|
116
|
+
for (const inst of instances) {
|
|
117
|
+
const status = await manager.getStatus(inst.name);
|
|
118
|
+
console.log(` ${colors.bright}${inst.name.padEnd(12)}${colors.reset} | ${formatStatus(status).padEnd(20)} | Port: ${inst.port}`);
|
|
119
|
+
}
|
|
120
|
+
console.log(''.padEnd(50, '-'));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Config Command
|
|
128
|
+
const configCmd = program
|
|
129
|
+
.command('config')
|
|
130
|
+
.description('Manage configuration for PostgreSQL instances');
|
|
131
|
+
|
|
132
|
+
configCmd
|
|
133
|
+
.command('show [name]')
|
|
134
|
+
.description('Show configuration for an instance')
|
|
135
|
+
.action(async (name = 'default') => {
|
|
136
|
+
try {
|
|
137
|
+
const config = await loadConfig(name);
|
|
138
|
+
console.log(JSON.stringify(config, null, 2));
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
configCmd
|
|
146
|
+
.command('set <key> <value> [name]')
|
|
147
|
+
.description('Set a configuration parameter (key: port, host, user, password, databaseDir)')
|
|
148
|
+
.action(async (key, value, name = 'default') => {
|
|
149
|
+
try {
|
|
150
|
+
const config = await loadConfig(name);
|
|
151
|
+
|
|
152
|
+
// Validation & parsing
|
|
153
|
+
if (key === 'port') {
|
|
154
|
+
const port = parseInt(value, 10);
|
|
155
|
+
if (isNaN(port) || port <= 0 || port > 65535) {
|
|
156
|
+
throw new Error('Port must be a valid number between 1 and 65535.');
|
|
157
|
+
}
|
|
158
|
+
config.port = port;
|
|
159
|
+
} else if (['host', 'user', 'password', 'databaseDir'].includes(key)) {
|
|
160
|
+
config[key] = value;
|
|
161
|
+
} else {
|
|
162
|
+
throw new Error(`Invalid config key "${key}". Valid keys: port, host, user, password, databaseDir`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await saveConfig(name, config);
|
|
166
|
+
console.log(`${colors.green}✓ Saved configuration for "${name}": ${key} = ${value}${colors.reset}`);
|
|
167
|
+
console.log(`${colors.yellow}Note: If the instance is currently running, you must restart it for changes to take effect.${colors.reset}`);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Logs Command
|
|
175
|
+
program
|
|
176
|
+
.command('logs [name]')
|
|
177
|
+
.description('Show logs of a local PostgreSQL instance')
|
|
178
|
+
.option('-f, --follow', 'Follow log output in real-time')
|
|
179
|
+
.action(async (name = 'default', options) => {
|
|
180
|
+
const logPath = getLogPath(name);
|
|
181
|
+
try {
|
|
182
|
+
const stats = await fs.stat(logPath);
|
|
183
|
+
if (!stats.isFile()) {
|
|
184
|
+
throw new Error('Not a file');
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(`${colors.red}No logs found at ${logPath}${colors.reset}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (options.follow) {
|
|
192
|
+
console.log(`${colors.cyan}Following logs for "${name}" (Press Ctrl+C to stop)...${colors.reset}`);
|
|
193
|
+
try {
|
|
194
|
+
let stats = await fs.stat(logPath);
|
|
195
|
+
const initialData = await fs.readFile(logPath, 'utf8');
|
|
196
|
+
process.stdout.write(initialData);
|
|
197
|
+
|
|
198
|
+
let size = stats.size;
|
|
199
|
+
const interval = setInterval(async () => {
|
|
200
|
+
try {
|
|
201
|
+
const newStats = await fs.stat(logPath);
|
|
202
|
+
if (newStats.size > size) {
|
|
203
|
+
const fd = await fs.open(logPath, 'r');
|
|
204
|
+
const buffer = Buffer.alloc(newStats.size - size);
|
|
205
|
+
await fd.read(buffer, 0, buffer.length, size);
|
|
206
|
+
await fd.close();
|
|
207
|
+
process.stdout.write(buffer.toString('utf8'));
|
|
208
|
+
size = newStats.size;
|
|
209
|
+
} else if (newStats.size < size) {
|
|
210
|
+
size = newStats.size;
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {}
|
|
213
|
+
}, 500);
|
|
214
|
+
|
|
215
|
+
process.on('SIGINT', () => {
|
|
216
|
+
clearInterval(interval);
|
|
217
|
+
process.exit(0);
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(`${colors.red}Error reading logs: ${err.message}${colors.reset}`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
const data = await fs.readFile(logPath, 'utf8');
|
|
225
|
+
process.stdout.write(data);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Destroy Command
|
|
230
|
+
program
|
|
231
|
+
.command('destroy [name]')
|
|
232
|
+
.description('Stop instance and delete all data directory')
|
|
233
|
+
.action(async (name = 'default') => {
|
|
234
|
+
const readline = require('readline').createInterface({
|
|
235
|
+
input: process.stdin,
|
|
236
|
+
output: process.stdout,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const config = await loadConfig(name);
|
|
240
|
+
readline.question(
|
|
241
|
+
`${colors.red}${colors.bright}WARNING: This will permanently delete all data in "${config.databaseDir}".\nAre you sure you want to proceed? (y/N): ${colors.reset}`,
|
|
242
|
+
async (answer) => {
|
|
243
|
+
readline.close();
|
|
244
|
+
if (answer.toLowerCase() === 'y') {
|
|
245
|
+
console.log(`${colors.cyan}Destroying instance "${name}"...${colors.reset}`);
|
|
246
|
+
try {
|
|
247
|
+
await manager.destroyInstance(name);
|
|
248
|
+
console.log(`${colors.green}✓ Instance "${name}" has been destroyed.${colors.reset}`);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
console.log('Action cancelled.');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
program.parse(process.argv);
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const BASE_DIR = path.join(os.homedir(), '.pg-local');
|
|
6
|
+
|
|
7
|
+
function getInstanceDir(name) {
|
|
8
|
+
return path.join(BASE_DIR, 'instances', name);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getConfigPath(name) {
|
|
12
|
+
return path.join(getInstanceDir(name), 'config.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getPidPath(name) {
|
|
16
|
+
return path.join(getInstanceDir(name), 'daemon.pid');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getLogPath(name) {
|
|
20
|
+
return path.join(getInstanceDir(name), 'postgres.log');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CONFIG = {
|
|
24
|
+
port: 5432,
|
|
25
|
+
host: '127.0.0.1',
|
|
26
|
+
user: 'postgres',
|
|
27
|
+
password: 'password',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function ensureBaseDir() {
|
|
31
|
+
await fs.mkdir(BASE_DIR, { recursive: true });
|
|
32
|
+
await fs.mkdir(path.join(BASE_DIR, 'instances'), { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadConfig(name = 'default') {
|
|
36
|
+
await ensureBaseDir();
|
|
37
|
+
const configPath = getConfigPath(name);
|
|
38
|
+
const instanceDir = getInstanceDir(name);
|
|
39
|
+
|
|
40
|
+
let loaded = {};
|
|
41
|
+
try {
|
|
42
|
+
const data = await fs.readFile(configPath, 'utf8');
|
|
43
|
+
loaded = JSON.parse(data);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Config doesn't exist yet, we will write defaults
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Merge with defaults and set derived properties (like databaseDir)
|
|
49
|
+
const config = {
|
|
50
|
+
name,
|
|
51
|
+
port: loaded.port || DEFAULT_CONFIG.port,
|
|
52
|
+
host: loaded.host || DEFAULT_CONFIG.host,
|
|
53
|
+
user: loaded.user || DEFAULT_CONFIG.user,
|
|
54
|
+
password: loaded.password || DEFAULT_CONFIG.password,
|
|
55
|
+
databaseDir: loaded.databaseDir || path.join(instanceDir, 'data'),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function saveConfig(name, config) {
|
|
62
|
+
await ensureBaseDir();
|
|
63
|
+
const instanceDir = getInstanceDir(name);
|
|
64
|
+
await fs.mkdir(instanceDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const configPath = getConfigPath(name);
|
|
67
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function listInstances() {
|
|
71
|
+
await ensureBaseDir();
|
|
72
|
+
const instancesDir = path.join(BASE_DIR, 'instances');
|
|
73
|
+
try {
|
|
74
|
+
const files = await fs.readdir(instancesDir, { withFileTypes: true });
|
|
75
|
+
const list = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (file.isDirectory()) {
|
|
78
|
+
try {
|
|
79
|
+
const config = await loadConfig(file.name);
|
|
80
|
+
list.push(config);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Ignore invalid directories
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return list;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function deleteInstanceDir(name) {
|
|
93
|
+
const instanceDir = getInstanceDir(name);
|
|
94
|
+
await fs.rm(instanceDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
BASE_DIR,
|
|
99
|
+
getInstanceDir,
|
|
100
|
+
getConfigPath,
|
|
101
|
+
getPidPath,
|
|
102
|
+
getLogPath,
|
|
103
|
+
loadConfig,
|
|
104
|
+
saveConfig,
|
|
105
|
+
listInstances,
|
|
106
|
+
deleteInstanceDir,
|
|
107
|
+
};
|
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const fsPromises = require('fs/promises');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const EmbeddedPostgres = require('embedded-postgres').default;
|
|
5
|
+
const { loadConfig, getPidPath, getLogPath, getInstanceDir } = require('./config');
|
|
6
|
+
|
|
7
|
+
const instanceName = process.argv[2] || 'default';
|
|
8
|
+
|
|
9
|
+
async function run() {
|
|
10
|
+
const config = await loadConfig(instanceName);
|
|
11
|
+
const pidPath = getPidPath(instanceName);
|
|
12
|
+
const logPath = getLogPath(instanceName);
|
|
13
|
+
const instanceDir = getInstanceDir(instanceName);
|
|
14
|
+
const stopPath = path.join(instanceDir, 'daemon.stop');
|
|
15
|
+
|
|
16
|
+
// Synchronous log writer to prevent buffering issues on exit
|
|
17
|
+
const writeLog = (msg) => {
|
|
18
|
+
const line = `[${new Date().toISOString()}] ${msg.trim()}\n`;
|
|
19
|
+
try {
|
|
20
|
+
fs.appendFileSync(logPath, line, 'utf8');
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error('Failed to write to log file:', e);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Write our PID to file so manager can stop us
|
|
27
|
+
await fsPromises.writeFile(pidPath, process.pid.toString(), 'utf8');
|
|
28
|
+
|
|
29
|
+
// Clean up any stale stop file
|
|
30
|
+
await fsPromises.unlink(stopPath).catch(() => {});
|
|
31
|
+
|
|
32
|
+
writeLog(`Daemon started with PID ${process.pid}`);
|
|
33
|
+
|
|
34
|
+
const pg = new EmbeddedPostgres({
|
|
35
|
+
databaseDir: config.databaseDir,
|
|
36
|
+
user: config.user,
|
|
37
|
+
password: config.password,
|
|
38
|
+
port: config.port,
|
|
39
|
+
persistent: true,
|
|
40
|
+
args: ['-h', config.host],
|
|
41
|
+
onLog: (msg) => writeLog(msg),
|
|
42
|
+
onError: (err) => writeLog(`[ERROR] ${err.message || err}`),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let isStopping = false;
|
|
46
|
+
async function gracefulShutdown() {
|
|
47
|
+
if (isStopping) return;
|
|
48
|
+
isStopping = true;
|
|
49
|
+
|
|
50
|
+
if (checkStopInterval) {
|
|
51
|
+
clearInterval(checkStopInterval);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
writeLog('Received stop signal, shutting down PostgreSQL...');
|
|
55
|
+
try {
|
|
56
|
+
await pg.stop();
|
|
57
|
+
writeLog('PostgreSQL stopped successfully.');
|
|
58
|
+
} catch (err) {
|
|
59
|
+
writeLog(`Error stopping PostgreSQL: ${err.message || err}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Clean up pid and stop files
|
|
63
|
+
try {
|
|
64
|
+
await fsPromises.unlink(pidPath);
|
|
65
|
+
} catch (e) {}
|
|
66
|
+
try {
|
|
67
|
+
await fsPromises.unlink(stopPath);
|
|
68
|
+
} catch (e) {}
|
|
69
|
+
|
|
70
|
+
writeLog('Daemon exiting.');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Monitor for the stop file
|
|
75
|
+
const checkStopInterval = setInterval(async () => {
|
|
76
|
+
try {
|
|
77
|
+
await fsPromises.access(stopPath);
|
|
78
|
+
writeLog('Stop file detected.');
|
|
79
|
+
await gracefulShutdown();
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// File not found, keep running
|
|
82
|
+
}
|
|
83
|
+
}, 500);
|
|
84
|
+
|
|
85
|
+
// Handle standard signals
|
|
86
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
87
|
+
process.on('SIGINT', gracefulShutdown);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// If not initialized, initialize it first
|
|
91
|
+
const isInitialized = await fsPromises.access(path.join(config.databaseDir, 'PG_VERSION'))
|
|
92
|
+
.then(() => true)
|
|
93
|
+
.catch(() => false);
|
|
94
|
+
|
|
95
|
+
if (!isInitialized) {
|
|
96
|
+
writeLog('Initializing database cluster...');
|
|
97
|
+
await pg.initialise();
|
|
98
|
+
writeLog('Database cluster initialized.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
writeLog(`Starting PostgreSQL on ${config.host}:${config.port}...`);
|
|
102
|
+
await pg.start();
|
|
103
|
+
writeLog('PostgreSQL is ready to accept connections.');
|
|
104
|
+
} catch (err) {
|
|
105
|
+
writeLog(`Fatal startup error: ${err.message || err}`);
|
|
106
|
+
try {
|
|
107
|
+
await fsPromises.unlink(pidPath);
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
try {
|
|
110
|
+
await fsPromises.unlink(stopPath);
|
|
111
|
+
} catch (e) {}
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
run().catch((err) => {
|
|
117
|
+
console.error('Fatal daemon error:', err);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
package/lib/manager.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { spawn, execSync } = require('child_process');
|
|
5
|
+
const {
|
|
6
|
+
loadConfig,
|
|
7
|
+
saveConfig,
|
|
8
|
+
getPidPath,
|
|
9
|
+
getLogPath,
|
|
10
|
+
getInstanceDir,
|
|
11
|
+
deleteInstanceDir,
|
|
12
|
+
listInstances,
|
|
13
|
+
} = require('./config');
|
|
14
|
+
|
|
15
|
+
// Check if a TCP port is open and listening
|
|
16
|
+
function checkPort(port, host, timeout = 1000) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const socket = new net.Socket();
|
|
19
|
+
const onError = () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(false);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
socket.setTimeout(timeout);
|
|
25
|
+
socket.once('error', onError);
|
|
26
|
+
socket.once('timeout', onError);
|
|
27
|
+
|
|
28
|
+
socket.connect(port, host, () => {
|
|
29
|
+
socket.end();
|
|
30
|
+
resolve(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if a process ID is running
|
|
36
|
+
function isPidAlive(pid) {
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
try {
|
|
39
|
+
const output = execSync(`tasklist /fi "PID eq ${pid}"`, { encoding: 'utf8' });
|
|
40
|
+
return output.includes(pid.toString());
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return err.code === 'EPERM';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Forcefully terminate a process
|
|
55
|
+
function forceKill(pid) {
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
try {
|
|
58
|
+
execSync(`taskkill /pid ${pid} /f /t`);
|
|
59
|
+
} catch (e) {}
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 'SIGKILL');
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Helper to wait
|
|
68
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
|
|
70
|
+
async function getRunningPid(name) {
|
|
71
|
+
const pidPath = getPidPath(name);
|
|
72
|
+
try {
|
|
73
|
+
const pidStr = await fs.readFile(pidPath, 'utf8');
|
|
74
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
75
|
+
if (!isNaN(pid) && isPidAlive(pid)) {
|
|
76
|
+
return pid;
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function startInstance(name) {
|
|
83
|
+
const config = await loadConfig(name);
|
|
84
|
+
|
|
85
|
+
// Check if already running
|
|
86
|
+
const runningPid = await getRunningPid(name);
|
|
87
|
+
if (runningPid) {
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
alreadyRunning: true,
|
|
91
|
+
pid: runningPid,
|
|
92
|
+
config,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if port is already in use by another process
|
|
97
|
+
const portInUse = await checkPort(config.port, config.host);
|
|
98
|
+
if (portInUse) {
|
|
99
|
+
throw new Error(`Port ${config.port} is already in use by another process on ${config.host}. Choose a different port using: pg-local config set port <new_port> ${name}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Ensure directories are clean
|
|
103
|
+
const instanceDir = getInstanceDir(name);
|
|
104
|
+
await fs.mkdir(instanceDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
const stopPath = path.join(instanceDir, 'daemon.stop');
|
|
107
|
+
await fs.unlink(stopPath).catch(() => {});
|
|
108
|
+
|
|
109
|
+
// Spawn daemon in background
|
|
110
|
+
const daemonPath = path.join(__dirname, 'daemon.js');
|
|
111
|
+
const child = spawn(process.execPath, [daemonPath, name], {
|
|
112
|
+
detached: true,
|
|
113
|
+
stdio: 'ignore',
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
|
|
117
|
+
// Wait for the server to become ready (up to 15 seconds)
|
|
118
|
+
const startTime = Date.now();
|
|
119
|
+
const timeoutMs = 15000;
|
|
120
|
+
let isReady = false;
|
|
121
|
+
|
|
122
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
123
|
+
// Check if the PID file exists and is alive
|
|
124
|
+
const currentPid = await getRunningPid(name);
|
|
125
|
+
if (currentPid) {
|
|
126
|
+
// Check if the port is open now
|
|
127
|
+
const portOpen = await checkPort(config.port, config.host);
|
|
128
|
+
if (portOpen) {
|
|
129
|
+
isReady = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
// Check if the daemon process died
|
|
134
|
+
try {
|
|
135
|
+
const pidStr = await fs.readFile(getPidPath(name), 'utf8');
|
|
136
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
137
|
+
if (!isNaN(pid) && !isPidAlive(pid)) {
|
|
138
|
+
// Daemon was started but is now dead!
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
// PID file doesn't exist yet, or is empty. Keep waiting.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await sleep(200);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isReady) {
|
|
149
|
+
// Check if the process exited or logged errors
|
|
150
|
+
const pid = await getRunningPid(name);
|
|
151
|
+
if (!pid) {
|
|
152
|
+
throw new Error(`PostgreSQL daemon failed to start. Check logs at: ${getLogPath(name)}`);
|
|
153
|
+
} else {
|
|
154
|
+
throw new Error(`PostgreSQL started but did not respond on ${config.host}:${config.port} within 15 seconds.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pid = await getRunningPid(name);
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
alreadyRunning: false,
|
|
162
|
+
pid,
|
|
163
|
+
config,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function stopInstance(name) {
|
|
168
|
+
const pid = await getRunningPid(name);
|
|
169
|
+
if (!pid) {
|
|
170
|
+
return { success: true, alreadyStopped: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const instanceDir = getInstanceDir(name);
|
|
174
|
+
const stopPath = path.join(instanceDir, 'daemon.stop');
|
|
175
|
+
|
|
176
|
+
// Request graceful shutdown by creating the stop file
|
|
177
|
+
await fs.writeFile(stopPath, 'stop', 'utf8');
|
|
178
|
+
|
|
179
|
+
// Also send SIGTERM for Unix systems as secondary trigger
|
|
180
|
+
if (process.platform !== 'win32') {
|
|
181
|
+
try {
|
|
182
|
+
process.kill(pid, 'SIGTERM');
|
|
183
|
+
} catch (e) {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Wait for it to stop (up to 8 seconds)
|
|
187
|
+
const startTime = Date.now();
|
|
188
|
+
const timeoutMs = 8000;
|
|
189
|
+
let stopped = false;
|
|
190
|
+
|
|
191
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
192
|
+
if (!isPidAlive(pid)) {
|
|
193
|
+
stopped = true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
await sleep(200);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If not stopped, force kill
|
|
200
|
+
if (!stopped) {
|
|
201
|
+
forceKill(pid);
|
|
202
|
+
// Cleanup files
|
|
203
|
+
const pidPath = getPidPath(name);
|
|
204
|
+
await fs.unlink(pidPath).catch(() => {});
|
|
205
|
+
await fs.unlink(stopPath).catch(() => {});
|
|
206
|
+
return { success: true, forced: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { success: true, forced: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function getStatus(name) {
|
|
213
|
+
const config = await loadConfig(name);
|
|
214
|
+
const pid = await getRunningPid(name);
|
|
215
|
+
const isPortOpen = await checkPort(config.port, config.host);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
name,
|
|
219
|
+
running: !!pid && isPortOpen,
|
|
220
|
+
pid,
|
|
221
|
+
port: config.port,
|
|
222
|
+
host: config.host,
|
|
223
|
+
user: config.user,
|
|
224
|
+
databaseDir: config.databaseDir,
|
|
225
|
+
connectionString: `postgresql://${config.user}:${config.password}@${config.host}:${config.port}/postgres`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function destroyInstance(name) {
|
|
230
|
+
await stopInstance(name);
|
|
231
|
+
await deleteInstanceDir(name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
startInstance,
|
|
236
|
+
stopInstance,
|
|
237
|
+
getStatus,
|
|
238
|
+
destroyInstance,
|
|
239
|
+
loadConfig,
|
|
240
|
+
saveConfig,
|
|
241
|
+
listInstances,
|
|
242
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pg-local-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Node.js CLI tool to easily create, start, and manage local PostgreSQL instances without Docker, Podman, or system dependencies.",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pg-local": "bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=16"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"postgres",
|
|
17
|
+
"postgresql",
|
|
18
|
+
"local",
|
|
19
|
+
"cli",
|
|
20
|
+
"database",
|
|
21
|
+
"embedded",
|
|
22
|
+
"without-docker",
|
|
23
|
+
"development"
|
|
24
|
+
],
|
|
25
|
+
"author": "Antigravity Dev",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "^12.0.0",
|
|
29
|
+
"embedded-postgres": "^18.4.0-beta.17"
|
|
30
|
+
}
|
|
31
|
+
}
|