@fredlackey/cli-proxmox 0.0.1

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,132 @@
1
+ /**
2
+ * Single-file config loader.
3
+ *
4
+ * Config lives at exactly `~/.config/cli-proxmox/config.json` and nowhere
5
+ * else. No environment variables, no project-local dotfiles, no ~/.{tool}rc.
6
+ *
7
+ * Value resolution precedence for any required input:
8
+ * 1. command-line flag (passed in opts)
9
+ * 2. this config file
10
+ * 3. error
11
+ *
12
+ * See `~/Source/Personal/FredLackeySandbox/CLAUDE.md` Rule 2 for the policy.
13
+ *
14
+ * Config schema (camelCase):
15
+ * - baseUrl (required, e.g. https://pve.example.com:8006)
16
+ * - tokenId (required, e.g. root@pam!terraform-token)
17
+ * - tokenSecret (required, UUID-format secret)
18
+ * - defaultNode (optional, PVE node name)
19
+ * - verifySsl (optional, boolean, default false for self-signed certs)
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+
26
+ const CLI_NAME = 'cli-proxmox';
27
+ const CONFIG_DIR = path.join(os.homedir(), '.config', CLI_NAME);
28
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
29
+
30
+ export function getConfigPath() {
31
+ return CONFIG_FILE;
32
+ }
33
+
34
+ export function getConfigDir() {
35
+ return CONFIG_DIR;
36
+ }
37
+
38
+ /**
39
+ * Load the config file. Returns null if it does not exist or cannot be
40
+ * parsed. Callers should treat null as "nothing saved yet" and fall through
41
+ * to command-line flags.
42
+ */
43
+ export function loadConfig() {
44
+ try {
45
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Write the config file atomically. Creates the parent directory if needed.
54
+ * File mode is 0600 (owner read/write only) since it contains a token secret.
55
+ */
56
+ export function saveConfig(config) {
57
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
58
+ const tmp = CONFIG_FILE + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
60
+ fs.renameSync(tmp, CONFIG_FILE);
61
+ }
62
+
63
+ /**
64
+ * Resolve a required value from command-line flags first, then from the
65
+ * saved config file. Throws a structured error if missing from both.
66
+ */
67
+ export function resolveValue(key, opts, config, flagName) {
68
+ if (opts && opts[key] !== undefined && opts[key] !== null && opts[key] !== '') {
69
+ return opts[key];
70
+ }
71
+ if (config && config[key] !== undefined && config[key] !== null && config[key] !== '') {
72
+ return config[key];
73
+ }
74
+ const err = new Error(
75
+ `Missing required value: ${flagName || key}. Pass it as a flag or run "proxmox configure".`
76
+ );
77
+ err.code = 'missing_required_value';
78
+ err.detail = { key, flag: flagName };
79
+ throw err;
80
+ }
81
+
82
+ /**
83
+ * Optional value: flag first, config file second, default third. Never
84
+ * throws.
85
+ */
86
+ export function resolveOptional(key, opts, config, defaultValue) {
87
+ if (opts && opts[key] !== undefined && opts[key] !== null && opts[key] !== '') {
88
+ return opts[key];
89
+ }
90
+ if (config && config[key] !== undefined && config[key] !== null && config[key] !== '') {
91
+ return config[key];
92
+ }
93
+ return defaultValue;
94
+ }
95
+
96
+ /**
97
+ * Convenience: resolve all five Proxmox credentials/settings in one call.
98
+ * Used by every command that hits the Proxmox API.
99
+ *
100
+ * Returns:
101
+ * baseUrl — required
102
+ * tokenId — required
103
+ * tokenSecret — required
104
+ * defaultNode — optional (may be undefined)
105
+ * verifySsl — boolean, defaults to false
106
+ */
107
+ export function resolveCredentials(opts) {
108
+ const config = loadConfig();
109
+ return {
110
+ baseUrl: resolveValue('baseUrl', opts, config, '--base-url'),
111
+ tokenId: resolveValue('tokenId', opts, config, '--token-id'),
112
+ tokenSecret: resolveValue('tokenSecret', opts, config, '--token-secret'),
113
+ defaultNode: resolveOptional('defaultNode', opts, config, undefined),
114
+ verifySsl: Boolean(resolveOptional('verifySsl', opts, config, false)),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Resolve a node name from an explicit --node flag, falling back to
120
+ * the configured defaultNode. Throws a structured error if neither
121
+ * source supplies a value.
122
+ */
123
+ export function resolveNode(opts, creds) {
124
+ if (opts && opts.node) return opts.node;
125
+ if (creds && creds.defaultNode) return creds.defaultNode;
126
+ const err = new Error(
127
+ 'No node specified. Pass --node <name> or set a defaultNode in ~/.config/cli-proxmox/config.json.'
128
+ );
129
+ err.code = 'missing_required_value';
130
+ err.detail = { key: 'node', flag: '--node' };
131
+ throw err;
132
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Fatal error emission.
3
+ *
4
+ * Called from the top-level parseAsync catch in index.js. A thrown error
5
+ * from any command action ends up here. In JSON mode we emit a structured
6
+ * JSON object on stderr so an agent can parse the failure. In interactive
7
+ * mode we emit a colored message.
8
+ *
9
+ * Either way, the process exits 1.
10
+ *
11
+ * Errors may carry extra metadata via `err.code` and `err.detail`.
12
+ * Errors from axios additionally carry `err.response.status` and
13
+ * `err.response.data` which get unwrapped into `httpStatus` / `httpBody`.
14
+ */
15
+
16
+ import chalk from 'chalk';
17
+ import { getRuntime } from './runtime.js';
18
+
19
+ export function fatalError(err) {
20
+ const runtime = getRuntime();
21
+
22
+ if (runtime.json) {
23
+ const payload = {
24
+ error: err?.message || String(err),
25
+ };
26
+ if (err?.code) payload.code = err.code;
27
+ if (err?.detail !== undefined) payload.detail = err.detail;
28
+ if (err?.response?.status) payload.httpStatus = err.response.status;
29
+ if (err?.response?.data !== undefined) payload.httpBody = err.response.data;
30
+ process.stderr.write(JSON.stringify(payload) + '\n');
31
+ } else {
32
+ const msg = err?.message || String(err);
33
+ process.stderr.write(chalk.red('Error: ') + msg + '\n');
34
+ if (err?.response?.data !== undefined) {
35
+ const body = typeof err.response.data === 'string'
36
+ ? err.response.data
37
+ : JSON.stringify(err.response.data, null, 2);
38
+ process.stderr.write(chalk.dim(body) + '\n');
39
+ }
40
+ }
41
+
42
+ process.exit(1);
43
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Unified output helper.
3
+ *
4
+ * `createOutput(runtime)` returns an object with the same API regardless of
5
+ * mode. In interactive mode, each call prints immediately with chalk
6
+ * formatting. In JSON mode, calls accumulate into a `_data` object and
7
+ * `flush()` emits the full object as JSON to stdout.
8
+ *
9
+ * Every command should:
10
+ * const runtime = getRuntime();
11
+ * const out = createOutput(runtime);
12
+ * // ... out.heading() / out.info() / out.set() ...
13
+ * out.flush(); // must be the last thing the command does
14
+ *
15
+ * Output routing:
16
+ * - stdout: the result stream. In JSON mode, only the single flush() call
17
+ * writes to stdout — everything else either accumulates (info, success,
18
+ * set, etc.) or is a no-op (banner, dim, blank).
19
+ * - stderr: warnings, failures, and any other diagnostic-flavored output
20
+ * when running in interactive mode. In JSON mode, warnings and soft
21
+ * failures accumulate into the result payload; truly fatal errors go
22
+ * through fatalError() in utils/errors.js, which writes structured JSON
23
+ * to stderr.
24
+ */
25
+
26
+ import chalk from 'chalk';
27
+
28
+ const DIVIDER = '\u2500';
29
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
30
+
31
+ function stripAnsi(value) {
32
+ return typeof value === 'string' ? value.replace(ANSI_RE, '') : value;
33
+ }
34
+
35
+ export function createOutput(runtime) {
36
+ const _data = {};
37
+ let _section = null;
38
+
39
+ function ensureSection() {
40
+ if (!_section) _section = '_log';
41
+ if (!_data[_section] || !Array.isArray(_data[_section])) {
42
+ _data[_section] = [];
43
+ }
44
+ }
45
+
46
+ function pushEntry(level, message, detail) {
47
+ if (!runtime.json) return;
48
+ ensureSection();
49
+ const entry = { level, message: stripAnsi(message) };
50
+ if (detail !== undefined) entry.detail = detail;
51
+ _data[_section].push(entry);
52
+ }
53
+
54
+ return {
55
+ /** Cosmetic banner. No-op in JSON mode. */
56
+ banner(title) {
57
+ if (!runtime.interactive) return;
58
+ const line = DIVIDER.repeat(Math.max(4, title.length + 4));
59
+ console.log('');
60
+ console.log(chalk.cyan(line));
61
+ console.log(chalk.cyan(` ${chalk.bold(title)}`));
62
+ console.log(chalk.cyan(line));
63
+ console.log('');
64
+ },
65
+
66
+ /**
67
+ * Start a new logical section. In JSON mode the section name becomes a
68
+ * key under which subsequent info/success/warn/fail entries accumulate.
69
+ */
70
+ heading(text) {
71
+ const key = text.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
72
+ _section = key || '_log';
73
+ if (runtime.json) {
74
+ if (!_data[_section]) _data[_section] = [];
75
+ return;
76
+ }
77
+ console.log('');
78
+ console.log(chalk.bold.white(` ${text}`));
79
+ console.log(chalk.dim(` ${DIVIDER.repeat(text.length)}`));
80
+ },
81
+
82
+ info(message, detail) {
83
+ pushEntry('info', message, detail);
84
+ if (!runtime.interactive) return;
85
+ console.log(chalk.white(` ${message}`));
86
+ },
87
+
88
+ success(message, detail) {
89
+ pushEntry('ok', message, detail);
90
+ if (!runtime.interactive) return;
91
+ console.log(chalk.green(` \u2713 ${message}`));
92
+ },
93
+
94
+ /** Diagnostic-flavored. Goes to stderr in interactive mode. */
95
+ warn(message, detail) {
96
+ pushEntry('warn', message, detail);
97
+ if (!runtime.interactive) return;
98
+ process.stderr.write(chalk.yellow(` ! ${message}`) + '\n');
99
+ },
100
+
101
+ /** Non-fatal failure. For fatal errors, throw and let errors.js handle it. */
102
+ fail(message, detail) {
103
+ pushEntry('error', message, detail);
104
+ if (!runtime.interactive) return;
105
+ process.stderr.write(chalk.red(` \u2716 ${message}`) + '\n');
106
+ },
107
+
108
+ /** Secondary/faded message. No-op in JSON mode. */
109
+ dim(message) {
110
+ if (!runtime.interactive) return;
111
+ console.log(chalk.dim(` ${message}`));
112
+ },
113
+
114
+ /** Spacer. No-op in JSON mode. */
115
+ blank() {
116
+ if (!runtime.interactive) return;
117
+ console.log('');
118
+ },
119
+
120
+ /**
121
+ * Set an arbitrary top-level key in the JSON payload. No-op in
122
+ * interactive mode — pair with info()/success() calls that the human
123
+ * will see.
124
+ */
125
+ set(key, value) {
126
+ if (runtime.json) {
127
+ _data[key] = value;
128
+ }
129
+ },
130
+
131
+ /**
132
+ * Emit the accumulated JSON payload to stdout. Must be called at the end
133
+ * of every command. No-op in interactive mode.
134
+ */
135
+ flush() {
136
+ if (!runtime.json) return;
137
+ process.stdout.write(JSON.stringify(_data, null, 2) + '\n');
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Proxmox VE API HTTP client.
3
+ *
4
+ * Constructed fresh per command from resolved credentials. Zero module-level
5
+ * state and zero environment variable access. Each command flow is:
6
+ *
7
+ * const creds = resolveCredentials(opts);
8
+ * const client = createProxmoxClient(creds);
9
+ * const data = await client.get(`nodes/${node}/qemu`);
10
+ *
11
+ * Proxmox quirks handled here:
12
+ *
13
+ * - Authentication uses the `PVEAPIToken={tokenId}={tokenSecret}` header
14
+ * format, not Bearer. See `pvesh`/`pveum` docs and the Proxmox REST API
15
+ * reference.
16
+ *
17
+ * - TLS is frequently self-signed on PVE installations, so by default we
18
+ * pass an https.Agent with `rejectUnauthorized: false`. Callers who have
19
+ * a properly signed cert can opt back into verification by setting
20
+ * `verifySsl: true` in config (or passing --verify-ssl).
21
+ *
22
+ * - The JSON API root is `/api2/json`. The base URL stored in config is
23
+ * the Proxmox host root (e.g. `https://pve.example.com:8006`) so we append
24
+ * the API path here.
25
+ *
26
+ * - Proxmox wraps responses in a `{ data: ... }` envelope. The client
27
+ * unwraps it automatically and returns the inner value so callers don't
28
+ * have to remember.
29
+ *
30
+ * - POST/PUT bodies must be application/x-www-form-urlencoded, not JSON.
31
+ * axios handles this when we pass a URLSearchParams instance.
32
+ */
33
+
34
+ import axios from 'axios';
35
+ import https from 'node:https';
36
+
37
+ /**
38
+ * Create a Proxmox VE client bound to a specific host and token.
39
+ *
40
+ * @param {{
41
+ * baseUrl: string,
42
+ * tokenId: string,
43
+ * tokenSecret: string,
44
+ * verifySsl?: boolean,
45
+ * defaultNode?: string,
46
+ * }} creds
47
+ */
48
+ export function createProxmoxClient(creds) {
49
+ const root = creds.baseUrl.replace(/\/+$/, '');
50
+ const httpsAgent = new https.Agent({
51
+ rejectUnauthorized: Boolean(creds.verifySsl),
52
+ });
53
+
54
+ const http = axios.create({
55
+ baseURL: `${root}/api2/json/`,
56
+ headers: {
57
+ Authorization: `PVEAPIToken=${creds.tokenId}=${creds.tokenSecret}`,
58
+ },
59
+ httpsAgent,
60
+ // never fall through to a raw http.Agent; even http:// requests go via
61
+ // axios defaults but Proxmox is effectively https-only in practice.
62
+ timeout: 60000,
63
+ });
64
+
65
+ function toFormBody(body) {
66
+ if (!body || typeof body !== 'object') return undefined;
67
+ const params = new URLSearchParams();
68
+ for (const [k, v] of Object.entries(body)) {
69
+ if (v === undefined || v === null) continue;
70
+ params.append(k, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
71
+ }
72
+ return params;
73
+ }
74
+
75
+ /** Strip the `{ data: ... }` envelope. */
76
+ function unwrap(res) {
77
+ if (res && res.data && Object.prototype.hasOwnProperty.call(res.data, 'data')) {
78
+ return res.data.data;
79
+ }
80
+ return res?.data;
81
+ }
82
+
83
+ return {
84
+ defaultNode: creds.defaultNode,
85
+
86
+ async get(pathname, params) {
87
+ const res = await http.get(pathname, params ? { params } : undefined);
88
+ return unwrap(res);
89
+ },
90
+
91
+ async post(pathname, body) {
92
+ const res = await http.post(pathname, toFormBody(body));
93
+ return unwrap(res);
94
+ },
95
+
96
+ async put(pathname, body) {
97
+ const res = await http.put(pathname, toFormBody(body));
98
+ return unwrap(res);
99
+ },
100
+
101
+ async delete(pathname) {
102
+ const res = await http.delete(pathname);
103
+ return unwrap(res);
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Install the credential option flags on a commander subcommand. Use this
110
+ * on any command that hits the Proxmox API so the flags stay consistent.
111
+ */
112
+ export function withCredentialOptions(cmd) {
113
+ return cmd
114
+ .option('--base-url <url>', 'Proxmox host root URL (e.g. https://pve.example.com:8006)')
115
+ .option('--token-id <id>', 'Proxmox API token ID (e.g. root@pam!terraform-token)')
116
+ .option('--token-secret <secret>', 'Proxmox API token secret (UUID)')
117
+ .option('--verify-ssl', 'Verify the Proxmox TLS certificate (default: false for self-signed)');
118
+ }
119
+
120
+ /**
121
+ * Install the `--node <name>` option on a commander subcommand. Commands
122
+ * that target a specific PVE node should apply this and then call
123
+ * `resolveNode()` from config.js to pick the effective value.
124
+ */
125
+ export function withNodeOption(cmd) {
126
+ return cmd.option('--node <name>', 'Proxmox node name (falls back to configured defaultNode)');
127
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal interactive prompt helpers.
3
+ *
4
+ * All of these gracefully return the provided default when stdin is not a
5
+ * TTY, so an agent invoking the CLI non-interactively will never hang on a
6
+ * prompt. Prompts themselves are written to stderr so they do not pollute
7
+ * the stdout result stream.
8
+ */
9
+
10
+ import readline from 'node:readline';
11
+
12
+ let _rl = null;
13
+
14
+ function getRl() {
15
+ if (_rl) return _rl;
16
+ _rl = readline.createInterface({
17
+ input: process.stdin,
18
+ output: process.stderr,
19
+ terminal: true,
20
+ });
21
+ return _rl;
22
+ }
23
+
24
+ export function closeRl() {
25
+ if (_rl) {
26
+ _rl.close();
27
+ _rl = null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Ask a free-form question. Returns the user's answer, or the default if
33
+ * stdin is not a TTY, or the default if the user just hits enter.
34
+ */
35
+ export function ask(question, defaultValue = '') {
36
+ return new Promise((resolve) => {
37
+ if (!process.stdin.isTTY) {
38
+ resolve(defaultValue);
39
+ return;
40
+ }
41
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
42
+ getRl().question(`${question}${suffix}: `, (answer) => {
43
+ resolve(answer.trim() || defaultValue);
44
+ });
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Ask a yes/no question. Returns boolean.
50
+ */
51
+ export function confirm(question, defaultValue = false) {
52
+ return new Promise((resolve) => {
53
+ if (!process.stdin.isTTY) {
54
+ resolve(defaultValue);
55
+ return;
56
+ }
57
+ const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
58
+ getRl().question(`${question}${suffix}: `, (answer) => {
59
+ const trimmed = answer.trim().toLowerCase();
60
+ if (!trimmed) {
61
+ resolve(defaultValue);
62
+ return;
63
+ }
64
+ resolve(trimmed === 'y' || trimmed === 'yes');
65
+ });
66
+ });
67
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Runtime mode detection.
3
+ *
4
+ * Two modes: `interactive` (human at a terminal, pretty output) and `json`
5
+ * (machine consumer, structured output). Selection rules, first match wins:
6
+ *
7
+ * 1. setForceJson(true) → json mode
8
+ * 2. setForceInteractive(true) → interactive mode
9
+ * 3. stdout.isTTY && stdin.isTTY → interactive mode
10
+ * 4. otherwise → json mode
11
+ *
12
+ * Checked fresh on every getRuntime() call. Never cached, because a single
13
+ * process may emit output in both modes (for example an interactive prompt
14
+ * during `configure` followed by a flush-to-stdout of the JSON result).
15
+ */
16
+
17
+ let _forceJson = false;
18
+ let _forceInteractive = false;
19
+
20
+ export function setForceJson(value) {
21
+ _forceJson = Boolean(value);
22
+ }
23
+
24
+ export function setForceInteractive(value) {
25
+ _forceInteractive = Boolean(value);
26
+ }
27
+
28
+ export function getRuntime() {
29
+ if (_forceJson) {
30
+ return { interactive: false, json: true };
31
+ }
32
+ if (_forceInteractive) {
33
+ return { interactive: true, json: false };
34
+ }
35
+ const stdoutTTY = process.stdout.isTTY === true;
36
+ const stdinTTY = process.stdin.isTTY === true;
37
+ const interactive = stdoutTTY && stdinTTY;
38
+ return { interactive, json: !interactive };
39
+ }