pacificdb-cli 0.2.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/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/pacificdb.js +10 -0
- package/package.json +36 -0
- package/src/api.js +48 -0
- package/src/cli.js +330 -0
- package/src/config.js +50 -0
- package/src/output.js +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hitesh Reddy K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pacificdb-cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for the PacificDB control plane.
|
|
4
|
+
Runs anywhere Node.js ≥ 18 runs — **Linux, macOS, and Windows**.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
**Linux / macOS**
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g pacificdb-cli
|
|
11
|
+
pacificdb login
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Windows (PowerShell or CMD)**
|
|
15
|
+
```powershell
|
|
16
|
+
npm install -g pacificdb-cli
|
|
17
|
+
pacificdb login
|
|
18
|
+
```
|
|
19
|
+
npm creates the `pacificdb` / `pacificdb.cmd` shims automatically; no PATH edits needed.
|
|
20
|
+
Config and the login token are stored per-OS at:
|
|
21
|
+
|
|
22
|
+
| OS | Location |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Linux | `~/.pacificdb/config.json` |
|
|
25
|
+
| macOS | `/Users/<you>/.pacificdb/config.json` |
|
|
26
|
+
| Windows | `C:\Users\<you>\.pacificdb\config.json` |
|
|
27
|
+
|
|
28
|
+
Override with `PACIFICDB_CONFIG=<path>` or pass `--api-url` / `PACIFICDB_API_URL` per call.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pacificdb login # prompts for email/password; token stored in ~/.pacificdb/config.json (0600)
|
|
34
|
+
pacificdb logout
|
|
35
|
+
|
|
36
|
+
pacificdb org list
|
|
37
|
+
pacificdb project create --name demo [--org org_id]
|
|
38
|
+
pacificdb project list
|
|
39
|
+
pacificdb cluster create --name c1 --project prj_id [--tier free]
|
|
40
|
+
pacificdb cluster list [--project prj_id]
|
|
41
|
+
pacificdb cluster status --cluster cl_id
|
|
42
|
+
pacificdb database create --name appdb --cluster cl_id
|
|
43
|
+
pacificdb database list --cluster cl_id
|
|
44
|
+
pacificdb dbuser create --database db_id --username app
|
|
45
|
+
pacificdb dbuser rotate --database db_id --dbuser dbu_id
|
|
46
|
+
pacificdb connection-string generate --database db_id --dbuser dbu_id
|
|
47
|
+
pacificdb backup create --cluster cl_id
|
|
48
|
+
pacificdb backup list
|
|
49
|
+
pacificdb restore create --backup bk_id [--cluster cl_id]
|
|
50
|
+
pacificdb metrics cluster --cluster cl_id
|
|
51
|
+
|
|
52
|
+
pacificdb benchmark run # runs the local artifact-backed quick suite (repo checkout required)
|
|
53
|
+
pacificdb benchmark report # prints the public-safe benchmark report
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Global flags
|
|
57
|
+
|
|
58
|
+
| Flag | Meaning |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `--api-url URL` | Control-plane URL (or `PACIFICDB_API_URL`) |
|
|
61
|
+
| `--format json\|table` | Output format (default `table`) |
|
|
62
|
+
| `--no-color` | Disable ANSI colors |
|
|
63
|
+
| `--org / --project / --cluster / --database` | Scope ids (defaults come from config) |
|
|
64
|
+
|
|
65
|
+
## Security
|
|
66
|
+
|
|
67
|
+
- The session token is stored in `~/.pacificdb/config.json` with `0600`
|
|
68
|
+
permissions; override the location with `PACIFICDB_CONFIG` or inject the
|
|
69
|
+
token via `PACIFICDB_TOKEN` (CI).
|
|
70
|
+
- Secrets (tokens, passwords, connection strings) are redacted from all
|
|
71
|
+
output except the one-time display when a database user is created or
|
|
72
|
+
rotated — that is the only time the server can show the credential.
|
|
73
|
+
- `--password` on the command line is supported for automation but warns,
|
|
74
|
+
since it exposes the secret to shell history; prefer the interactive prompt.
|
|
75
|
+
|
|
76
|
+
## Exit codes
|
|
77
|
+
|
|
78
|
+
| Code | Meaning |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| 0 | Success |
|
|
81
|
+
| 1 | Generic failure |
|
|
82
|
+
| 2 | Usage error |
|
|
83
|
+
| 3 | Not authenticated |
|
|
84
|
+
| 4 | Not found / permission denied |
|
|
85
|
+
| 5 | Server or network error |
|
package/bin/pacificdb.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pacificdb-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "PacificDB command-line interface for the control plane (orgs, projects, clusters, databases, users, backups, metrics, benchmarks)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pacificdb",
|
|
7
|
+
"database",
|
|
8
|
+
"cli"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "PacificDB",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/hitesh-reddy-k/database-engine-linux.git",
|
|
15
|
+
"directory": "packages/pacificdb-cli"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"bin": {
|
|
19
|
+
"pacificdb": "bin/pacificdb.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test test/*.test.mjs"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-plane API client for the CLI. Maps failures to CliError with
|
|
3
|
+
* meaningful exit codes: 2 usage, 3 auth, 4 not found / permission, 5 server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveToken } from './config.js';
|
|
7
|
+
|
|
8
|
+
export class CliError extends Error {
|
|
9
|
+
constructor(message, exitCode = 1) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function api(apiUrl, method, path, body, { token, timeoutMs = 30000 } = {}) {
|
|
16
|
+
const auth = token ?? resolveToken();
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(`${apiUrl}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
headers: {
|
|
25
|
+
'content-type': 'application/json',
|
|
26
|
+
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
|
27
|
+
},
|
|
28
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
if (error.name === 'AbortError') throw new CliError(`Request timed out after ${timeoutMs}ms`, 5);
|
|
33
|
+
throw new CliError(`Cannot reach PacificDB API at ${apiUrl}: ${error.message}`, 5);
|
|
34
|
+
}
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
|
|
37
|
+
let json = {};
|
|
38
|
+
try { json = await res.json(); } catch { /* empty body */ }
|
|
39
|
+
|
|
40
|
+
if (res.ok && json.success !== false) return json.data ?? json;
|
|
41
|
+
|
|
42
|
+
const message = typeof json.error === 'object' ? json.error?.message : json.error;
|
|
43
|
+
if (res.status === 401) throw new CliError(`Not authenticated${message ? `: ${message}` : ''}. Run \`pacificdb login\`.`, 3);
|
|
44
|
+
if (res.status === 403) throw new CliError(`Permission denied${message ? `: ${message}` : ''}`, 4);
|
|
45
|
+
if (res.status === 404) throw new CliError(`Not found${message ? `: ${message}` : ''}`, 4);
|
|
46
|
+
if (res.status === 429) throw new CliError('Rate limited by the API. Try again shortly.', 5);
|
|
47
|
+
throw new CliError(message || `API error (HTTP ${res.status})`, res.status >= 500 ? 5 : 1);
|
|
48
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pacificdb CLI - command router.
|
|
3
|
+
*
|
|
4
|
+
* Commands (see README for details):
|
|
5
|
+
* login | logout
|
|
6
|
+
* org list
|
|
7
|
+
* project create|list
|
|
8
|
+
* cluster create|list|status
|
|
9
|
+
* database create|list
|
|
10
|
+
* dbuser create|rotate
|
|
11
|
+
* connection-string generate
|
|
12
|
+
* backup create|list
|
|
13
|
+
* restore create
|
|
14
|
+
* metrics cluster
|
|
15
|
+
* benchmark run|report
|
|
16
|
+
*
|
|
17
|
+
* Global flags: --api-url --org --project --cluster --database
|
|
18
|
+
* --format json|table --no-color --quiet
|
|
19
|
+
* Exit codes: 0 ok, 1 generic, 2 usage, 3 auth, 4 not-found/denied, 5 server.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
import { api, CliError } from './api.js';
|
|
26
|
+
import { loadConfig, saveConfig, clearToken, resolveApiUrl, configPath } from './config.js';
|
|
27
|
+
import { printResult, color } from './output.js';
|
|
28
|
+
|
|
29
|
+
const USAGE = `pacificdb <command> [subcommand] [flags]
|
|
30
|
+
|
|
31
|
+
Auth
|
|
32
|
+
login [--email x --password y] Log in and store the session token
|
|
33
|
+
logout Revoke the session and clear the token
|
|
34
|
+
|
|
35
|
+
Resources
|
|
36
|
+
org list
|
|
37
|
+
project create --name N [--org ORG_ID]
|
|
38
|
+
project list [--org ORG_ID]
|
|
39
|
+
cluster create --name N --project PRJ [--tier free]
|
|
40
|
+
cluster list [--project PRJ]
|
|
41
|
+
cluster status --cluster CL
|
|
42
|
+
database create --name N --cluster CL
|
|
43
|
+
database list --cluster CL
|
|
44
|
+
dbuser create --database DB --username U
|
|
45
|
+
dbuser rotate --database DB --dbuser DBU
|
|
46
|
+
connection-string generate --database DB --dbuser DBU
|
|
47
|
+
backup create --cluster CL [--type full]
|
|
48
|
+
backup list
|
|
49
|
+
restore create --backup BK [--cluster CL]
|
|
50
|
+
metrics cluster --cluster CL
|
|
51
|
+
|
|
52
|
+
Benchmarks
|
|
53
|
+
benchmark run Run the local artifact-backed quick suite
|
|
54
|
+
benchmark report Print the public-safe benchmark report
|
|
55
|
+
|
|
56
|
+
Global flags
|
|
57
|
+
--api-url URL PacificDB control plane (or PACIFICDB_API_URL)
|
|
58
|
+
--format json|table (default table)
|
|
59
|
+
--no-color Disable ANSI colors
|
|
60
|
+
--help Show this help
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export function parseArgs(argv) {
|
|
64
|
+
const positional = [];
|
|
65
|
+
const flags = {};
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === '--help' || arg === '-h') flags.help = true;
|
|
69
|
+
else if (arg === '--no-color') flags.noColor = true;
|
|
70
|
+
else if (arg === '--quiet' || arg === '-q') flags.quiet = true;
|
|
71
|
+
else if (arg.startsWith('--')) {
|
|
72
|
+
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
73
|
+
const next = argv[i + 1];
|
|
74
|
+
if (next === undefined || next.startsWith('--')) flags[key] = true;
|
|
75
|
+
else { flags[key] = next; i++; }
|
|
76
|
+
} else {
|
|
77
|
+
positional.push(arg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (flags.format && !['json', 'table'].includes(flags.format)) {
|
|
81
|
+
throw new CliError(`Invalid --format "${flags.format}" (expected json or table)`, 2);
|
|
82
|
+
}
|
|
83
|
+
return { positional, flags };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function promptHidden(question) {
|
|
87
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
88
|
+
const muted = { muted: false };
|
|
89
|
+
rl._writeToOutput = function (s) {
|
|
90
|
+
if (muted.muted && s.trim() && !s.includes(question)) return;
|
|
91
|
+
process.stderr.write(s);
|
|
92
|
+
};
|
|
93
|
+
const answer = await new Promise((resolve) => {
|
|
94
|
+
rl.question(question, (a) => { process.stderr.write('\n'); resolve(a); });
|
|
95
|
+
muted.muted = true;
|
|
96
|
+
});
|
|
97
|
+
rl.close();
|
|
98
|
+
return answer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function prompt(question) {
|
|
102
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
103
|
+
const answer = await new Promise((resolve) => rl.question(question, resolve));
|
|
104
|
+
rl.close();
|
|
105
|
+
return answer;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireFlag(flags, name, exitCode = 2) {
|
|
109
|
+
const value = flags[name];
|
|
110
|
+
if (!value || value === true) throw new CliError(`--${name} is required`, exitCode);
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findRepoRoot(startDir) {
|
|
115
|
+
let dir = startDir;
|
|
116
|
+
for (let i = 0; i < 8; i++) {
|
|
117
|
+
if (fs.existsSync(path.join(dir, 'benchmarks', 'reports'))) return dir;
|
|
118
|
+
const parent = path.dirname(dir);
|
|
119
|
+
if (parent === dir) break;
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function run(argv, { io = process } = {}) {
|
|
126
|
+
const { positional, flags } = parseArgs(argv);
|
|
127
|
+
const [command, subcommand] = positional;
|
|
128
|
+
const format = flags.format || 'table';
|
|
129
|
+
const c = color(!flags.noColor && io.stdout.isTTY);
|
|
130
|
+
const apiUrl = resolveApiUrl(flags.apiUrl);
|
|
131
|
+
const out = (data, opts = {}) => printResult(data, { format, ...opts });
|
|
132
|
+
|
|
133
|
+
if (!command || flags.help) {
|
|
134
|
+
io.stdout.write(USAGE);
|
|
135
|
+
return command ? 0 : 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
switch (`${command} ${subcommand || ''}`.trim()) {
|
|
139
|
+
case 'login': {
|
|
140
|
+
const email = flags.email || (await prompt('email: '));
|
|
141
|
+
const password = flags.password || (await promptHidden('password: '));
|
|
142
|
+
if (flags.password) {
|
|
143
|
+
io.stderr.write(c.yellow('warning: --password exposes the secret to shell history; prefer the prompt\n'));
|
|
144
|
+
}
|
|
145
|
+
const data = await api(apiUrl, 'POST', '/api/v1/auth/login', { email, password }, { token: '' });
|
|
146
|
+
const token = data?.session?.token;
|
|
147
|
+
if (!token) throw new CliError('Login did not return a session token', 3);
|
|
148
|
+
saveConfig({ apiUrl, token, userId: data.user?.id, email: data.user?.email });
|
|
149
|
+
const orgs = (data.organizations || []).map((o) => ({ id: o.id, name: o.name, role: o.role || '' }));
|
|
150
|
+
if (orgs.length === 1) saveConfig({ orgId: orgs[0].id });
|
|
151
|
+
io.stderr.write(c.green(`logged in as ${data.user?.email} (config: ${configPath()})\n`));
|
|
152
|
+
out({ user: { id: data.user?.id, email: data.user?.email }, organizations: orgs }, { rows: orgs, columns: ['id', 'name', 'role'] });
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'logout': {
|
|
157
|
+
try { await api(apiUrl, 'POST', '/api/v1/auth/logout'); } catch { /* already invalid is fine */ }
|
|
158
|
+
clearToken();
|
|
159
|
+
io.stderr.write('logged out\n');
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'org list': {
|
|
164
|
+
const data = await api(apiUrl, 'GET', '/api/v1/orgs');
|
|
165
|
+
const rows = (data.organizations || []).map((o) => ({ id: o.id, name: o.name, plan: o.plan, members: (o.members || []).length }));
|
|
166
|
+
out(data, { rows, columns: ['id', 'name', 'plan', 'members'] });
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'project create': {
|
|
171
|
+
const name = requireFlag(flags, 'name');
|
|
172
|
+
const organizationId = flags.org || loadConfig().orgId;
|
|
173
|
+
if (!organizationId) throw new CliError('--org is required (no default org in config)', 2);
|
|
174
|
+
const data = await api(apiUrl, 'POST', '/api/v1/projects', { name, organizationId });
|
|
175
|
+
out(data, { rows: [{ id: data.project?.id, name: data.project?.name, org: organizationId }] });
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'project list': {
|
|
180
|
+
const organizationId = flags.org || loadConfig().orgId;
|
|
181
|
+
const data = await api(apiUrl, 'GET', `/api/v1/projects${organizationId ? `?organizationId=${organizationId}` : ''}`);
|
|
182
|
+
const rows = (data.projects || []).map((p) => ({ id: p.id, name: p.name, org: p.organizationId, clusters: p.clusterCount ?? '' }));
|
|
183
|
+
out(data, { rows, columns: ['id', 'name', 'org', 'clusters'] });
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'cluster create': {
|
|
188
|
+
const name = requireFlag(flags, 'name');
|
|
189
|
+
const projectId = flags.project || loadConfig().projectId;
|
|
190
|
+
if (!projectId) throw new CliError('--project is required', 2);
|
|
191
|
+
const data = await api(apiUrl, 'POST', '/api/v1/clusters', { name, projectId, tier: flags.tier || 'free', region: flags.region });
|
|
192
|
+
out(data, { rows: [{ id: data.cluster?.id, name: data.cluster?.name, tier: data.cluster?.tier, status: data.cluster?.status }] });
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'cluster list': {
|
|
197
|
+
const projectId = flags.project || '';
|
|
198
|
+
const data = await api(apiUrl, 'GET', `/api/v1/clusters${projectId ? `?projectId=${projectId}` : ''}`);
|
|
199
|
+
const rows = (data.clusters || []).map((cl) => ({ id: cl.id, name: cl.name, tier: cl.tier, region: cl.region, status: cl.status }));
|
|
200
|
+
out(data, { rows, columns: ['id', 'name', 'tier', 'region', 'status'] });
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'cluster status': {
|
|
205
|
+
const clusterId = requireFlag(flags, 'cluster');
|
|
206
|
+
const data = await api(apiUrl, 'GET', `/api/v1/clusters/${clusterId}`);
|
|
207
|
+
const cl = data.cluster || {};
|
|
208
|
+
out(data, {
|
|
209
|
+
rows: [{ id: cl.id, name: cl.name, status: cl.status, tier: cl.tier, nodes: (data.nodes || []).length, alerts: (data.alerts || []).length }],
|
|
210
|
+
});
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case 'database create': {
|
|
215
|
+
const name = requireFlag(flags, 'name');
|
|
216
|
+
const clusterId = requireFlag(flags, 'cluster');
|
|
217
|
+
const data = await api(apiUrl, 'POST', `/api/v1/clusters/${clusterId}/databases`, { name });
|
|
218
|
+
out(data, { rows: [{ id: data.database?.id, name: data.database?.name, engineProvisioned: data.engineProvisioned }] });
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'database list': {
|
|
223
|
+
const clusterId = requireFlag(flags, 'cluster');
|
|
224
|
+
const data = await api(apiUrl, 'GET', `/api/v1/clusters/${clusterId}/databases`);
|
|
225
|
+
const rows = (data.databases || []).map((d) => ({ id: d.id, name: d.name, status: d.status }));
|
|
226
|
+
out(data, { rows, columns: ['id', 'name', 'status'] });
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case 'dbuser create': {
|
|
231
|
+
const databaseId = requireFlag(flags, 'database');
|
|
232
|
+
const username = requireFlag(flags, 'username');
|
|
233
|
+
const data = await api(apiUrl, 'POST', `/api/v1/databases/${databaseId}/users`, {
|
|
234
|
+
username,
|
|
235
|
+
roles: flags.roles ? String(flags.roles).split(',') : undefined,
|
|
236
|
+
ipAllowlist: flags.ipAllowlist ? String(flags.ipAllowlist).split(',') : undefined,
|
|
237
|
+
});
|
|
238
|
+
io.stderr.write(c.yellow(`\n${data.warning || 'Store the connection string now; it is shown only once.'}\n\n`));
|
|
239
|
+
out(data, {
|
|
240
|
+
rows: [{ id: data.user?.id, username: data.user?.username, connectionString: data.connectionString }],
|
|
241
|
+
columns: ['id', 'username', 'connectionString'],
|
|
242
|
+
showSecrets: new Set(['connectionString']),
|
|
243
|
+
});
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'dbuser rotate': {
|
|
248
|
+
const databaseId = requireFlag(flags, 'database');
|
|
249
|
+
const dbUserId = requireFlag(flags, 'dbuser');
|
|
250
|
+
const data = await api(apiUrl, 'POST', `/api/v1/databases/${databaseId}/users/${dbUserId}/rotate-password`, {});
|
|
251
|
+
io.stderr.write(c.yellow(`\n${data.warning || 'New connection string shown once; old credentials are revoked.'}\n\n`));
|
|
252
|
+
out(data, {
|
|
253
|
+
rows: [{ id: data.user?.id, revokedSessions: data.revokedSessions, connectionString: data.connectionString }],
|
|
254
|
+
columns: ['id', 'revokedSessions', 'connectionString'],
|
|
255
|
+
showSecrets: new Set(['connectionString']),
|
|
256
|
+
});
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case 'connection-string generate': {
|
|
261
|
+
const databaseId = requireFlag(flags, 'database');
|
|
262
|
+
const dbUserId = requireFlag(flags, 'dbuser');
|
|
263
|
+
const data = await api(apiUrl, 'POST', `/api/v1/databases/${databaseId}/connection-string`, { dbUserId });
|
|
264
|
+
io.stderr.write(c.yellow(`\n${data.warning || 'Connection string shown once (credentials were rotated).'}\n\n`));
|
|
265
|
+
out(data, {
|
|
266
|
+
rows: [{ dbUserId, connectionString: data.connectionString }],
|
|
267
|
+
columns: ['dbUserId', 'connectionString'],
|
|
268
|
+
showSecrets: new Set(['connectionString']),
|
|
269
|
+
});
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case 'backup create': {
|
|
274
|
+
const clusterId = flags.cluster || loadConfig().clusterId;
|
|
275
|
+
if (!clusterId) throw new CliError('--cluster is required', 2);
|
|
276
|
+
const data = await api(apiUrl, 'POST', '/api/v1/backups', { clusterId, type: flags.type || 'manual' });
|
|
277
|
+
out(data, { rows: [{ id: data.backup?.id, status: data.backup?.status, type: data.backup?.type }] });
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'backup list': {
|
|
282
|
+
const data = await api(apiUrl, 'GET', '/api/v1/backups');
|
|
283
|
+
const rows = (data.backups || []).map((b) => ({ id: b.id, cluster: b.clusterId, type: b.type, status: b.status, createdAt: b.createdAt }));
|
|
284
|
+
out(data, { rows, columns: ['id', 'cluster', 'type', 'status', 'createdAt'] });
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case 'restore create': {
|
|
289
|
+
const backupId = requireFlag(flags, 'backup');
|
|
290
|
+
const data = await api(apiUrl, 'POST', `/api/v1/backups/${backupId}/restore`, {
|
|
291
|
+
targetClusterId: flags.cluster || undefined,
|
|
292
|
+
});
|
|
293
|
+
out(data, { rows: [{ backup: backupId, target: data.targetCluster, message: data.message || 'restore initiated' }] });
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'metrics cluster': {
|
|
298
|
+
const clusterId = requireFlag(flags, 'cluster');
|
|
299
|
+
const data = await api(apiUrl, 'GET', `/api/v1/metrics/cluster/${clusterId}`);
|
|
300
|
+
out(data, { rows: Array.isArray(data) ? data : [data] });
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
case 'benchmark run': {
|
|
305
|
+
const root = findRepoRoot(io.cwd ? io.cwd() : process.cwd());
|
|
306
|
+
if (!root) {
|
|
307
|
+
throw new CliError('benchmark run must be executed inside a PacificDB repository (benchmarks/ not found). Benchmarks execute locally against your own hardware; results are artifact-backed and claim-safe.', 2);
|
|
308
|
+
}
|
|
309
|
+
const { spawnSync } = await import('node:child_process');
|
|
310
|
+
io.stderr.write('running: npm run benchmark:quick (system + storage + security tools + report)\n');
|
|
311
|
+
// shell:true resolves npm to npm.cmd on Windows; args are fixed strings (no injection surface).
|
|
312
|
+
const res = spawnSync('npm', ['run', 'benchmark:quick'], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
313
|
+
return res.status ?? 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'benchmark report': {
|
|
317
|
+
const root = findRepoRoot(io.cwd ? io.cwd() : process.cwd());
|
|
318
|
+
const reportPath = root && path.join(root, 'benchmarks', 'reports', 'final', 'PACIFICDB_PUBLIC_RESULTS.md');
|
|
319
|
+
if (!reportPath || !fs.existsSync(reportPath)) {
|
|
320
|
+
throw new CliError('No public benchmark report found (benchmarks/reports/final/PACIFICDB_PUBLIC_RESULTS.md). Run `pacificdb benchmark run` first.', 4);
|
|
321
|
+
}
|
|
322
|
+
io.stdout.write(fs.readFileSync(reportPath, 'utf8'));
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
default:
|
|
327
|
+
io.stderr.write(USAGE);
|
|
328
|
+
throw new CliError(`Unknown command: ${positional.join(' ') || '(none)'}`, 2);
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI configuration: ~/.pacificdb/config.json (created 0600).
|
|
3
|
+
* Stores the API URL, session token, and default org/project/cluster ids.
|
|
4
|
+
* The token can be overridden with PACIFICDB_TOKEN, the config path with
|
|
5
|
+
* PACIFICDB_CONFIG, and the API URL with PACIFICDB_API_URL.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
export function configPath() {
|
|
13
|
+
return process.env.PACIFICDB_CONFIG || path.join(os.homedir(), '.pacificdb', 'config.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveConfig(update) {
|
|
25
|
+
const file = configPath();
|
|
26
|
+
const dir = path.dirname(file);
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
const merged = { ...loadConfig(), ...update };
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
|
30
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort on non-POSIX */ }
|
|
31
|
+
return merged;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearToken() {
|
|
35
|
+
const cfg = loadConfig();
|
|
36
|
+
delete cfg.token;
|
|
37
|
+
delete cfg.userId;
|
|
38
|
+
const file = configPath();
|
|
39
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
40
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
41
|
+
return cfg;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveToken() {
|
|
45
|
+
return process.env.PACIFICDB_TOKEN || loadConfig().token || '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveApiUrl(flagValue) {
|
|
49
|
+
return (flagValue || process.env.PACIFICDB_API_URL || loadConfig().apiUrl || 'http://localhost:3000').replace(/\/+$/, '');
|
|
50
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output helpers: JSON or aligned tables, optional color, secret redaction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SECRET_KEYS = new Set(['password', 'token', 'secret', 'key', 'sessiontoken', 'connectionstring', 'passwordhash']);
|
|
6
|
+
|
|
7
|
+
export function redact(value, { except = new Set() } = {}) {
|
|
8
|
+
if (!value || typeof value !== 'object') return value;
|
|
9
|
+
if (Array.isArray(value)) return value.map((v) => redact(v, { except }));
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const [k, v] of Object.entries(value)) {
|
|
12
|
+
if (SECRET_KEYS.has(k.toLowerCase()) && !except.has(k)) {
|
|
13
|
+
out[k] = 'REDACTED';
|
|
14
|
+
} else {
|
|
15
|
+
out[k] = redact(v, { except });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function color(enabled) {
|
|
22
|
+
const wrap = (code) => (s) => (enabled ? `[${code}m${s}[0m` : String(s));
|
|
23
|
+
return { bold: wrap('1'), green: wrap('32'), red: wrap('31'), yellow: wrap('33'), dim: wrap('2') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function table(rows, columns) {
|
|
27
|
+
if (!rows.length) return '(no results)';
|
|
28
|
+
const cols = columns || Object.keys(rows[0]);
|
|
29
|
+
const cells = rows.map((r) => cols.map((c) => formatCell(r[c])));
|
|
30
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...cells.map((row) => row[i].length)));
|
|
31
|
+
const line = (parts) => parts.map((p, i) => p.padEnd(widths[i])).join(' ');
|
|
32
|
+
return [line(cols.map((c) => c.toUpperCase())), line(widths.map((w) => '-'.repeat(w))), ...cells.map(line)].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatCell(v) {
|
|
36
|
+
if (v === null || v === undefined) return '';
|
|
37
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
38
|
+
return String(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function printResult(data, { format, rows, columns, showSecrets = new Set() }) {
|
|
42
|
+
if (format === 'json') {
|
|
43
|
+
process.stdout.write(JSON.stringify(redact(data, { except: showSecrets }), null, 2) + '\n');
|
|
44
|
+
} else {
|
|
45
|
+
const safeRows = redact(rows ?? data, { except: showSecrets });
|
|
46
|
+
process.stdout.write(table(Array.isArray(safeRows) ? safeRows : [safeRows], columns) + '\n');
|
|
47
|
+
}
|
|
48
|
+
}
|