@sysnee/pgs 0.1.7-rc.11 → 0.1.7-rc.13

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,282 @@
1
+ # Local Dev Environment — PGS
2
+
3
+ Simulates the production setup (Node.js + `pgs` CLI + Traefik + PostgreSQL containers) locally, without installing the package globally or needing real DNS/SSL certificates.
4
+
5
+ ## Overview
6
+
7
+ | Concern | Production | Local Dev |
8
+ |---|---|---|
9
+ | Domain | `pgs.br-sp-1.sysnee.com` | `pgs.local` |
10
+ | DNS | Real DNS wildcard A record | dnsmasq (`*.pgs.local → 127.0.0.1`) |
11
+ | TLS cert | Let's Encrypt wildcard | Self-signed wildcard CA |
12
+ | CLI | `pgs` (global npm install) | `node ./manager.js` (source directly) |
13
+ | Setup | `pgs setup` (certbot, installs Docker) | `dev-setup.sh` (manual bootstrap) |
14
+
15
+ ---
16
+
17
+ ## Prerequisites
18
+
19
+ - Docker & Docker Compose installed
20
+ - Node.js 24
21
+ - `dnsmasq` (install below)
22
+ - `openssl` (usually pre-installed)
23
+
24
+ ---
25
+
26
+ ## Step 1 — Make `DOMAIN` configurable in `manager.js`
27
+
28
+ Change line 20 from:
29
+
30
+ ```js
31
+ const DOMAIN = 'pgs.br-sp-1.sysnee.com';
32
+ ```
33
+
34
+ To:
35
+
36
+ ```js
37
+ const DOMAIN = process.env.PGS_DOMAIN || 'pgs.br-sp-1.sysnee.com';
38
+ ```
39
+
40
+ This is the only code change needed. Production behavior is unaffected (env var not set → falls back to real domain).
41
+
42
+ ---
43
+
44
+ ## Step 2 — Install and configure dnsmasq
45
+
46
+ dnsmasq resolves all `*.pgs.local` subdomains to `127.0.0.1` automatically, without needing to edit `/etc/hosts` per tenant.
47
+
48
+ ```bash
49
+ sudo apt-get install -y dnsmasq
50
+ ```
51
+
52
+ Add the wildcard rule:
53
+
54
+ ```bash
55
+ echo "address=/.pgs.local/127.0.0.1" | sudo tee /etc/dnsmasq.d/pgs-local.conf
56
+ sudo systemctl restart dnsmasq
57
+ ```
58
+
59
+ On systems using `systemd-resolved` (Ubuntu 20.04+), you need to tell it to use dnsmasq for `.local` queries. Check if it's active:
60
+
61
+ ```bash
62
+ systemctl is-active systemd-resolved
63
+ ```
64
+
65
+ If active, add to `/etc/systemd/resolved.conf`:
66
+
67
+ ```ini
68
+ [Resolve]
69
+ DNS=127.0.0.1
70
+ Domains=~local
71
+ ```
72
+
73
+ Then restart:
74
+
75
+ ```bash
76
+ sudo systemctl restart systemd-resolved
77
+ ```
78
+
79
+ Verify resolution works:
80
+
81
+ ```bash
82
+ dig tenant1.pgs.local @127.0.0.1
83
+ # or
84
+ nslookup tenant1.pgs.local 127.0.0.1
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Step 3 — Bootstrap `~/.sysnee-config/` (replaces `pgs setup`)
90
+
91
+ Create the file `pgs/dev-setup.sh`:
92
+
93
+ ```bash
94
+ #!/usr/bin/env bash
95
+ set -e
96
+
97
+ CONFIG_DIR="$HOME/.sysnee-config"
98
+ CERTS_DIR="$CONFIG_DIR/certs"
99
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
100
+
101
+ echo "── Bootstrapping ~/.sysnee-config/ ──"
102
+
103
+ mkdir -p "$CERTS_DIR"
104
+
105
+ # Copy template files from source
106
+ cp "$SCRIPT_DIR/docker-compose.yml" "$CONFIG_DIR/docker-compose.yml"
107
+ cp "$SCRIPT_DIR/traefik.yml" "$CONFIG_DIR/traefik.yml"
108
+
109
+ [ -f "$CONFIG_DIR/tenant-access.json" ] || echo '{}' > "$CONFIG_DIR/tenant-access.json"
110
+
111
+ # Write setup status so checkSetupStatus() passes
112
+ echo -n "container:ok" > "$CONFIG_DIR/status.txt"
113
+
114
+ echo "── Generating self-signed wildcard cert for *.pgs.local ──"
115
+
116
+ # CA
117
+ openssl genrsa -out "$CERTS_DIR/ca.key" 4096 2>/dev/null
118
+ openssl req -x509 -new -nodes \
119
+ -key "$CERTS_DIR/ca.key" \
120
+ -sha256 -days 825 \
121
+ -out "$CERTS_DIR/ca.pem" \
122
+ -subj "/CN=PGS Dev CA/O=Local Dev"
123
+
124
+ # Wildcard cert
125
+ openssl genrsa -out "$CERTS_DIR/privkey.pem" 4096 2>/dev/null
126
+ openssl req -new \
127
+ -key "$CERTS_DIR/privkey.pem" \
128
+ -out /tmp/pgs-dev.csr \
129
+ -subj "/CN=*.pgs.local"
130
+
131
+ # SAN required by modern TLS clients (Chrome, DBeaver, psql)
132
+ cat > /tmp/pgs-dev-ext.cnf <<EOF
133
+ subjectAltName=DNS:*.pgs.local,DNS:pgs.local
134
+ basicConstraints=CA:FALSE
135
+ keyUsage=digitalSignature,keyEncipherment
136
+ extendedKeyUsage=serverAuth
137
+ EOF
138
+
139
+ openssl x509 -req \
140
+ -in /tmp/pgs-dev.csr \
141
+ -CA "$CERTS_DIR/ca.pem" \
142
+ -CAkey "$CERTS_DIR/ca.key" \
143
+ -CAcreateserial \
144
+ -out "$CERTS_DIR/fullchain.pem" \
145
+ -days 825 \
146
+ -sha256 \
147
+ -extfile /tmp/pgs-dev-ext.cnf
148
+
149
+ echo "── Generating initial dynamic.yml ──"
150
+ cat > "$CONFIG_DIR/dynamic.yml" <<'EOF'
151
+ tcp:
152
+ routers: {}
153
+ services: {}
154
+ tls:
155
+ certificates:
156
+ - certFile: /etc/traefik/certs/fullchain.pem
157
+ keyFile: /etc/traefik/certs/privkey.pem
158
+ EOF
159
+
160
+ echo ""
161
+ echo "✓ Dev environment ready at $CONFIG_DIR"
162
+ echo ""
163
+ echo "Cert files:"
164
+ echo " CA: $CERTS_DIR/ca.pem ← import this into DBeaver / OS trust store"
165
+ echo " Cert: $CERTS_DIR/fullchain.pem"
166
+ echo " Key: $CERTS_DIR/privkey.pem"
167
+ echo ""
168
+ echo "Next: PGS_DOMAIN=pgs.local node ./manager.js create <tenant-id>"
169
+ ```
170
+
171
+ Make it executable:
172
+
173
+ ```bash
174
+ chmod +x pgs/dev-setup.sh
175
+ ```
176
+
177
+ Run once:
178
+
179
+ ```bash
180
+ cd pgs && ./dev-setup.sh
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Step 4 — Workflow: creating and starting a tenant
186
+
187
+ All commands use `node ./manager.js` directly from source. Changes are reflected instantly.
188
+
189
+ ```bash
190
+ # From the pgs/ directory
191
+ export PGS_DOMAIN=pgs.local
192
+
193
+ node ./manager.js create tenant1
194
+ node ./manager.js start tenant1
195
+
196
+ # Verify
197
+ node ./manager.js list
198
+ ```
199
+
200
+ No reinstall required between code changes.
201
+
202
+ ---
203
+
204
+ ## Step 5 — Connect from DBeaver
205
+
206
+ ### Trust the dev CA (once)
207
+
208
+ DBeaver needs to trust the self-signed CA to accept the TLS connection.
209
+
210
+ In DBeaver: **Window → Preferences → Connections → SSL** → add `~/.sysnee-config/certs/ca.pem` as a trusted CA.
211
+
212
+ Alternatively, add it to the system trust store (so any tool trusts it):
213
+
214
+ ```bash
215
+ sudo cp ~/.sysnee-config/certs/ca.pem /usr/local/share/ca-certificates/pgs-dev-ca.crt
216
+ sudo update-ca-certificates
217
+ ```
218
+
219
+ ### Connection settings
220
+
221
+ | Field | Value |
222
+ |---|---|
223
+ | Host | `tenant1.pgs.local` |
224
+ | Port | `5432` |
225
+ | Database | `tenant1` |
226
+ | Username | `postgres` |
227
+ | Password | (set at create time) |
228
+ | SSL mode | `require` |
229
+ | SSL CA cert | `~/.sysnee-config/certs/ca.pem` |
230
+
231
+ ### psql equivalent
232
+
233
+ ```bash
234
+ psql "postgresql://postgres:PASSWORD@tenant1.pgs.local:5432/tenant1?sslmode=require&sslrootcert=$HOME/.sysnee-config/certs/ca.pem"
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Teardown
240
+
241
+ ```bash
242
+ export PGS_DOMAIN=pgs.local
243
+
244
+ node ./manager.js stop
245
+ node ./manager.js remove tenant1
246
+
247
+ # Full reset (removes all config and containers)
248
+ docker compose -f ~/.sysnee-config/docker-compose.yml down -v
249
+ rm -rf ~/.sysnee-config
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Troubleshooting
255
+
256
+ **DNS not resolving**
257
+
258
+ ```bash
259
+ # Check dnsmasq is running
260
+ sudo systemctl status dnsmasq
261
+
262
+ # Test directly
263
+ dig +short tenant1.pgs.local @127.0.0.1
264
+ ```
265
+
266
+ **TLS handshake error in DBeaver / psql**
267
+
268
+ Make sure the CA cert is trusted and `sslrootcert` points to `ca.pem`, not `fullchain.pem`.
269
+
270
+ **`checkSetupStatus()` fails (exits with "Please run pgs setup")**
271
+
272
+ ```bash
273
+ echo -n "container:ok" > ~/.sysnee-config/status.txt
274
+ ```
275
+
276
+ **Port 5432 already in use**
277
+
278
+ A local PostgreSQL instance may be listening. Stop it temporarily:
279
+
280
+ ```bash
281
+ sudo systemctl stop postgresql
282
+ ```
package/manager.js CHANGED
@@ -8,6 +8,7 @@ import { Command } from 'commander';
8
8
  import { execSync } from 'child_process';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname } from 'path';
11
+ import { createInterface } from 'readline';
11
12
 
12
13
  import packageJson from './package.json' with { type: 'json' };
13
14
 
@@ -16,6 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
17
18
 
18
19
  const CONFIG_DIR = path.join(os.homedir(), '.sysnee-config');
20
+ const DOMAIN = 'pgs.br-sp-1.sysnee.com';
19
21
 
20
22
  const COMPOSE_FILE_PATH = path.join(CONFIG_DIR, 'docker-compose.yml');
21
23
  const INIT_DIR_PATH = path.join(CONFIG_DIR, 'init');
@@ -27,7 +29,6 @@ const SETUP_STATUS_PATH = path.join(CONFIG_DIR, 'status.txt');
27
29
 
28
30
  function loadCompose() {
29
31
  const content = fs.readFileSync(COMPOSE_FILE_PATH, 'utf8');
30
- console.debug(`docker-compose file readed from ${COMPOSE_FILE_PATH}`)
31
32
  return yaml.load(content);
32
33
  }
33
34
 
@@ -114,7 +115,10 @@ function generateTraefikDynamicConfig(tenantsManifests = {}) {
114
115
 
115
116
  if (sourceRanges.length > 0) {
116
117
  router.middlewares = [middlewareName];
117
-
118
+
119
+ if (!dynamicConfig.tcp.middlewares) {
120
+ dynamicConfig.tcp.middlewares = {};
121
+ }
118
122
  if (!dynamicConfig.tcp.middlewares[middlewareName]) {
119
123
  dynamicConfig.tcp.middlewares[middlewareName] = {
120
124
  ipAllowList: {
@@ -230,15 +234,15 @@ function ensureNetwork(doc) {
230
234
  }
231
235
  }
232
236
 
233
- function initialSetup() {
237
+ async function initialSetup() {
234
238
  installDocker()
235
239
  createInitialFiles()
236
240
  ensureDockerPrivilegies()
241
+ await setupCertificates()
237
242
  console.log('All ready!')
238
243
  }
239
244
 
240
245
  function installDocker() {
241
-
242
246
  try {
243
247
  execSync('docker info', { stdio: 'pipe' })
244
248
  console.log('Docker already installed')
@@ -249,7 +253,7 @@ function installDocker() {
249
253
  execSync('curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh', { stdio: 'inherit' })
250
254
  execSync('rm get-docker.sh')
251
255
  console.log('Docker installed successfully')
252
-
256
+
253
257
  console.log('Installing Docker Compose plugin...')
254
258
  execSync('sudo apt-get install -y docker-compose-plugin', { stdio: 'inherit' })
255
259
  console.log('Docker Compose plugin installed successfully')
@@ -260,6 +264,107 @@ function ensureDockerPrivilegies() {
260
264
  writeFileSync(SETUP_STATUS_PATH, 'container:ok')
261
265
  }
262
266
 
267
+ function waitForEnter(message) {
268
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
269
+ return new Promise(resolve => {
270
+ rl.question(message, () => {
271
+ rl.close();
272
+ resolve();
273
+ });
274
+ });
275
+ }
276
+
277
+ function installCertbot() {
278
+ try {
279
+ execSync('certbot --version', { stdio: 'pipe' });
280
+ console.log('Certbot already installed');
281
+ return;
282
+ } catch (_) {}
283
+
284
+ console.log('Installing certbot...');
285
+ execSync('sudo apt-get install -y certbot', { stdio: 'inherit' });
286
+ console.log('Certbot installed successfully');
287
+ }
288
+
289
+ function findCertbotLivePath() {
290
+ const basePath = '/etc/letsencrypt/live';
291
+ const candidates = [
292
+ path.join(basePath, DOMAIN),
293
+ path.join(basePath, `*.${DOMAIN}`),
294
+ ];
295
+
296
+ try {
297
+ const dirs = fs.readdirSync(basePath);
298
+ for (const dir of dirs) {
299
+ const p = path.join(basePath, dir);
300
+ if (!candidates.includes(p)) candidates.push(p);
301
+ }
302
+ } catch (_) {}
303
+
304
+ for (const candidate of candidates) {
305
+ const fullchain = path.join(candidate, 'fullchain.pem');
306
+ const privkey = path.join(candidate, 'privkey.pem');
307
+ if (existsSync(fullchain) && existsSync(privkey)) {
308
+ return candidate;
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ function copyCertificates(retries = 5, retryDelaySecs = 3) {
315
+ mkdirSync(CERTS_DIR_PATH, { recursive: true });
316
+
317
+ for (let attempt = 1; attempt <= retries; attempt++) {
318
+ const certPath = findCertbotLivePath();
319
+ if (certPath) {
320
+ const username = os.userInfo().username;
321
+ execSync(`sudo cp "${path.join(certPath, 'fullchain.pem')}" "${path.join(CERTS_DIR_PATH, 'fullchain.pem')}"`, { stdio: 'inherit' });
322
+ execSync(`sudo cp "${path.join(certPath, 'privkey.pem')}" "${path.join(CERTS_DIR_PATH, 'privkey.pem')}"`, { stdio: 'inherit' });
323
+ execSync(`sudo chown ${username}:${username} "${CERTS_DIR_PATH}/fullchain.pem" "${CERTS_DIR_PATH}/privkey.pem"`, { stdio: 'inherit' });
324
+ console.log(`✓ Certificates copied from ${certPath}`);
325
+ return;
326
+ }
327
+ if (attempt < retries) {
328
+ console.log(`Certificate path not found, retrying in ${retryDelaySecs}s... (${attempt}/${retries})`);
329
+ execSync(`sleep ${retryDelaySecs}`);
330
+ }
331
+ }
332
+ throw new Error('Could not find certbot certificates. Check /etc/letsencrypt/live/');
333
+ }
334
+
335
+ async function setupCertificates() {
336
+ installCertbot();
337
+
338
+ let serverIp = 'YOUR_SERVER_IP';
339
+ try {
340
+ serverIp = execSync('curl -s --max-time 5 ifconfig.me || hostname -I | awk \'{print $1}\'', { stdio: 'pipe' }).toString().trim();
341
+ } catch (_) {}
342
+
343
+ console.log('\n── DNS Setup Required ────────────────────────────');
344
+ console.log(` Add the following A records in your DNS provider:`);
345
+ console.log(` ${DOMAIN} → ${serverIp}`);
346
+ console.log(` *.${DOMAIN} → ${serverIp}`);
347
+ console.log('──────────────────────────────────────────────────\n');
348
+ await waitForEnter('Press Enter once the A records are set (propagation may take a few minutes)...\n');
349
+
350
+ console.log('Running certbot DNS-01 challenge...');
351
+ console.log(`Certbot will ask you to create a TXT record at _acme-challenge.${DOMAIN}`);
352
+ execSync(
353
+ `sudo certbot certonly --manual --preferred-challenges dns -d "*.${DOMAIN}" --agree-tos`,
354
+ { stdio: 'inherit' }
355
+ );
356
+
357
+ console.log('Copying certificates...');
358
+ copyCertificates();
359
+
360
+ console.log('Restarting Traefik...');
361
+ try {
362
+ execSync('docker restart postgres_proxy', { stdio: 'inherit' });
363
+ } catch (_) {
364
+ console.log('Traefik not running yet, skipping restart');
365
+ }
366
+ }
367
+
263
368
  function createInitialFiles() {
264
369
  // config dir
265
370
  if (!existsSync(CONFIG_DIR)) {
@@ -445,10 +550,21 @@ function removeTenant(tenantId, manifests = {}) {
445
550
  throw new Error(`Tenant ${tenantId} not found`);
446
551
  }
447
552
 
553
+ try {
554
+ execSync(`docker compose stop ${serviceName}`, { stdio: 'inherit', cwd: CONFIG_DIR });
555
+ } catch (err) {
556
+ console.warn(`Warning: failed to stop ${serviceName}: ${err.message}`);
557
+ }
558
+ try {
559
+ execSync(`docker compose rm -f ${serviceName}`, { stdio: 'inherit', cwd: CONFIG_DIR });
560
+ } catch (err) {
561
+ console.warn(`Warning: failed to remove container ${serviceName}: ${err.message}`);
562
+ }
563
+
448
564
  delete doc.services[serviceName];
449
565
 
450
566
  const volumeName = `pgdata_${tenantId}`;
451
- if (doc.volumes && doc.volumes[volumeName]) {
567
+ if (doc.volumes && Object.prototype.hasOwnProperty.call(doc.volumes, volumeName)) {
452
568
  delete doc.volumes[volumeName];
453
569
  }
454
570
 
@@ -463,9 +579,101 @@ function removeTenant(tenantId, manifests = {}) {
463
579
  generateTraefikDynamicConfig(manifests);
464
580
  updateTraefikService();
465
581
 
582
+ try {
583
+ const matching = execSync(
584
+ `docker volume ls --format '{{.Name}}' | grep -E '_${volumeName}$' || true`,
585
+ { stdio: 'pipe', cwd: CONFIG_DIR }
586
+ ).toString().trim();
587
+ if (matching) {
588
+ for (const vol of matching.split('\n')) {
589
+ if (vol.trim()) {
590
+ try {
591
+ execSync(`docker volume rm ${vol.trim()}`, { stdio: 'inherit', cwd: CONFIG_DIR });
592
+ } catch (err) {
593
+ console.warn(`Warning: failed to remove volume ${vol}: ${err.message}`);
594
+ }
595
+ }
596
+ }
597
+ }
598
+ } catch (err) {
599
+ console.warn(`Warning: could not enumerate volumes: ${err.message}`);
600
+ }
601
+
466
602
  console.log(`✓ Removed tenant: ${tenantId}`);
467
- console.log(` Run: docker compose down ${serviceName}`);
468
- console.log(` Run: docker volume rm pgs_${volumeName}`);
603
+ }
604
+
605
+ function getTenantStatus(tenantId) {
606
+ const doc = loadCompose();
607
+ const serviceName = `pgs_${tenantId}`;
608
+ const exists = !!(doc.services && doc.services[serviceName]);
609
+
610
+ if (!exists) {
611
+ return { tenantId, exists: false, running: false };
612
+ }
613
+
614
+ try {
615
+ const inspect = JSON.parse(
616
+ execSync(`docker inspect ${serviceName}`, { stdio: 'pipe' }).toString()
617
+ );
618
+ const state = inspect[0]?.State || {};
619
+ return {
620
+ tenantId,
621
+ exists: true,
622
+ running: state.Running === true,
623
+ status: state.Status,
624
+ startedAt: state.StartedAt,
625
+ };
626
+ } catch (_) {
627
+ return { tenantId, exists: true, running: false, status: 'not-created' };
628
+ }
629
+ }
630
+
631
+ function showStatus() {
632
+ console.log('\n── Traefik Proxy ─────────────────────────────────');
633
+ try {
634
+ const inspect = JSON.parse(execSync('docker inspect postgres_proxy', { stdio: 'pipe' }).toString());
635
+ const state = inspect[0]?.State;
636
+ const running = state?.Running;
637
+ const status = state?.Status;
638
+ const startedAt = state?.StartedAt;
639
+ const exitCode = state?.ExitCode;
640
+
641
+ console.log(` Status: ${running ? '✓ running' : '✗ stopped'} (${status})`);
642
+ if (startedAt) console.log(` Started at: ${new Date(startedAt).toLocaleString()}`);
643
+ if (!running && exitCode !== undefined) console.log(` Exit code: ${exitCode}`);
644
+ } catch (_) {
645
+ console.log(' Status: container not found');
646
+ }
647
+
648
+ console.log('\n── Traefik Errors (last 100 lines) ───────────────');
649
+ try {
650
+ const logs = execSync('docker logs postgres_proxy --tail 100 2>&1', { stdio: 'pipe' }).toString();
651
+ const errorLines = logs
652
+ .split('\n')
653
+ .filter(line => /\b(ERR|error|WARN|warn)\b/i.test(line) && line.trim().length > 0);
654
+
655
+ if (errorLines.length === 0) {
656
+ console.log(' No errors or warnings found');
657
+ } else {
658
+ errorLines.forEach(line => console.log(` ${line}`));
659
+ }
660
+ } catch (_) {
661
+ console.log(' Could not read container logs');
662
+ }
663
+
664
+ console.log('\n── Certificates ──────────────────────────────────');
665
+ const fullchainPath = path.join(CERTS_DIR_PATH, 'fullchain.pem');
666
+ if (existsSync(fullchainPath)) {
667
+ try {
668
+ const certInfo = execSync(`openssl x509 -in "${fullchainPath}" -noout -enddate -subject 2>/dev/null`, { stdio: 'pipe' }).toString().trim();
669
+ certInfo.split('\n').forEach(line => console.log(` ${line}`));
670
+ } catch (_) {
671
+ console.log(' Could not read certificate info');
672
+ }
673
+ } else {
674
+ console.log(' ✗ fullchain.pem not found');
675
+ }
676
+ console.log('──────────────────────────────────────────────────\n');
469
677
  }
470
678
 
471
679
  function executeCommand(command) {
@@ -490,9 +698,33 @@ program
490
698
 
491
699
  program
492
700
  .command('setup')
493
- .description('Initial required setup')
494
- .action(() => {
495
- initialSetup()
701
+ .description('Initial required setup (installs Docker, certbot, configures SSL)')
702
+ .action(async () => {
703
+ await initialSetup()
704
+ })
705
+
706
+ program
707
+ .command('status')
708
+ .description('Show status of postgres_proxy container (or a tenant container if tenant-id is given)')
709
+ .argument('[tenant-id]', 'Tenant identifier (optional)')
710
+ .option('--json', 'Output tenant status as JSON (only with tenant-id)')
711
+ .action((tenantId, opts) => {
712
+ if (tenantId) {
713
+ checkSetupStatus()
714
+ try {
715
+ const info = getTenantStatus(tenantId);
716
+ if (opts.json) {
717
+ process.stdout.write(JSON.stringify(info));
718
+ return;
719
+ }
720
+ console.log(info);
721
+ } catch (error) {
722
+ console.error(`Error: ${error.message}`);
723
+ process.exit(1);
724
+ }
725
+ return;
726
+ }
727
+ showStatus()
496
728
  })
497
729
 
498
730
  program
@@ -509,9 +741,6 @@ program
509
741
  try {
510
742
 
511
743
  const providedTenantId = args[0];
512
- const randomSuffix = Math.random().toString(36).substring(2, 8);
513
- const tenantId = `${providedTenantId}-${randomSuffix}`;
514
-
515
744
  const opts = args[1];
516
745
 
517
746
  const tenantOptions = {
@@ -521,6 +750,8 @@ program
521
750
  manifest: null
522
751
  }
523
752
 
753
+ let tenantId;
754
+
524
755
  if (opts.file) {
525
756
  const manifestFilePath = path.resolve(opts.file)
526
757
  const optsFromManifest = JSON.parse(readFileSync(manifestFilePath))
@@ -533,15 +764,23 @@ program
533
764
  tenantOptions.limits = optsFromManifest.shared_limits
534
765
  tenantOptions.version = optsFromManifest.version
535
766
  tenantOptions.manifest = optsFromManifest
767
+ tenantOptions.password = opts.password || optsFromManifest.postgres?.password || generateRandomPassword();
768
+
769
+ tenantId = optsFromManifest.slug || providedTenantId;
770
+ if (!tenantId) {
771
+ throw new Error('Manifest must contain "slug" or tenant-id must be provided');
772
+ }
536
773
  } else {
537
774
  tenantOptions.version = opts.version;
538
775
  tenantOptions.limits = {
539
776
  cpu: parseFloat(opts.cpu),
540
777
  memory: parseInt(opts.memory, 10),
541
778
  };
542
- }
779
+ tenantOptions.password = opts.password || generateRandomPassword();
543
780
 
544
- tenantOptions.password = opts.password || generateRandomPassword();
781
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
782
+ tenantId = `${providedTenantId}-${randomSuffix}`;
783
+ }
545
784
 
546
785
  createTenant(tenantId, tenantOptions);
547
786
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sysnee/pgs",
3
- "version": "0.1.7-rc.11",
3
+ "version": "0.1.7-rc.13",
4
4
  "description": "Dynamic PostgreSQL service instance manager",
5
5
  "type": "module",
6
6
  "bin": {