@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.
- package/COMMANDS.md +453 -0
- package/LICENSE +13 -0
- package/README.md +64 -0
- package/package.json +43 -0
- package/src/bin/proxmox.js +4 -0
- package/src/commands/cluster.js +54 -0
- package/src/commands/configure.js +105 -0
- package/src/commands/node.js +81 -0
- package/src/commands/snapshot.js +157 -0
- package/src/commands/storage.js +95 -0
- package/src/commands/task.js +155 -0
- package/src/commands/vm.js +521 -0
- package/src/index.js +89 -0
- package/src/utils/config.js +132 -0
- package/src/utils/errors.js +43 -0
- package/src/utils/output.js +140 -0
- package/src/utils/proxmox-client.js +127 -0
- package/src/utils/readline.js +67 -0
- package/src/utils/runtime.js +39 -0
|
@@ -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
|
+
}
|