@krovacloud/cli 0.5.8 → 0.6.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/dist/index.js CHANGED
@@ -1,1323 +1,1287 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { Command as Command13 } from "commander";
5
-
6
- // src/commands/auth.ts
2
+ import { createRequire } from "node:module";
7
3
  import { Command } from "commander";
8
-
9
- // src/lib/config.ts
10
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
11
- import { homedir } from "os";
12
- import { dirname, join } from "path";
13
- var DEFAULT_BASE_URL = "https://krova.cloud/api/v1";
14
- var DEFAULT_CONTEXT_NAME = "default";
15
- var ENV = {
16
- apiKey: "KROVA_API_KEY",
17
- spaceId: "KROVA_SPACE_ID",
18
- baseUrl: "KROVA_BASE_URL",
19
- context: "KROVA_CONTEXT"
4
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { KrovaClient } from "@krovacloud/sdk";
8
+ import { spawn } from "node:child_process";
9
+ import { createServer } from "node:http";
10
+ import { verifyKrovaWebhookOrThrow } from "@krovacloud/webhook";
11
+ const DEFAULT_CONTEXT_NAME = "default";
12
+ const ENV = {
13
+ apiKey: "KROVA_API_KEY",
14
+ spaceId: "KROVA_SPACE_ID",
15
+ baseUrl: "KROVA_BASE_URL",
16
+ context: "KROVA_CONTEXT"
20
17
  };
21
18
  function configDir() {
22
- const xdg = (process.env.XDG_CONFIG_HOME ?? "").trim();
23
- if (xdg) return join(xdg, "krova");
24
- return join(homedir(), ".config", "krova");
19
+ const xdg = (process.env.XDG_CONFIG_HOME ?? "").trim();
20
+ if (xdg) return join(xdg, "krova");
21
+ return join(homedir(), ".config", "krova");
25
22
  }
26
23
  function configPath() {
27
- return join(configDir(), "config.json");
24
+ return join(configDir(), "config.json");
28
25
  }
29
26
  function load() {
30
- let cfg = {};
31
- try {
32
- cfg = JSON.parse(readFileSync(configPath(), "utf8"));
33
- } catch {
34
- cfg = {};
35
- }
36
- return migrate(cfg);
27
+ let cfg = {};
28
+ try {
29
+ cfg = JSON.parse(readFileSync(configPath(), "utf8"));
30
+ } catch {
31
+ cfg = {};
32
+ }
33
+ return migrate(cfg);
37
34
  }
38
35
  function migrate(cfg) {
39
- if ((cfg.apiKey ?? "").trim() && (!cfg.contexts || cfg.contexts.length === 0)) {
40
- cfg.contexts = [
41
- {
42
- name: DEFAULT_CONTEXT_NAME,
43
- apiKey: cfg.apiKey,
44
- spaceId: cfg.spaceId,
45
- baseUrl: cfg.baseUrl
46
- }
47
- ];
48
- cfg.currentContext = DEFAULT_CONTEXT_NAME;
49
- }
50
- cfg.apiKey = void 0;
51
- cfg.spaceId = void 0;
52
- cfg.baseUrl = void 0;
53
- return cfg;
36
+ if ((cfg.apiKey ?? "").trim() && (!cfg.contexts || cfg.contexts.length === 0)) {
37
+ cfg.contexts = [{
38
+ name: DEFAULT_CONTEXT_NAME,
39
+ apiKey: cfg.apiKey,
40
+ spaceId: cfg.spaceId,
41
+ baseUrl: cfg.baseUrl
42
+ }];
43
+ cfg.currentContext = DEFAULT_CONTEXT_NAME;
44
+ }
45
+ cfg.apiKey = void 0;
46
+ cfg.spaceId = void 0;
47
+ cfg.baseUrl = void 0;
48
+ return cfg;
54
49
  }
55
50
  function save(cfg) {
56
- const path = configPath();
57
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
58
- const clean = {};
59
- if (cfg.currentContext) clean.currentContext = cfg.currentContext;
60
- if (cfg.contexts && cfg.contexts.length) {
61
- clean.contexts = cfg.contexts.map((c) => {
62
- const o = { name: c.name };
63
- if (c.apiKey) o.apiKey = c.apiKey;
64
- if (c.spaceId) o.spaceId = c.spaceId;
65
- if (c.spaceName) o.spaceName = c.spaceName;
66
- if (c.baseUrl) o.baseUrl = c.baseUrl;
67
- return o;
68
- });
69
- }
70
- writeFileSync(path, `${JSON.stringify(clean, null, 2)}
71
- `, { mode: 384 });
72
- chmodSync(path, 384);
51
+ const path = configPath();
52
+ mkdirSync(dirname(path), {
53
+ recursive: true,
54
+ mode: 448
55
+ });
56
+ const clean = {};
57
+ if (cfg.currentContext) clean.currentContext = cfg.currentContext;
58
+ if (cfg.contexts && cfg.contexts.length) clean.contexts = cfg.contexts.map((c) => {
59
+ const o = { name: c.name };
60
+ if (c.apiKey) o.apiKey = c.apiKey;
61
+ if (c.spaceId) o.spaceId = c.spaceId;
62
+ if (c.spaceName) o.spaceName = c.spaceName;
63
+ if (c.baseUrl) o.baseUrl = c.baseUrl;
64
+ return o;
65
+ });
66
+ writeFileSync(path, `${JSON.stringify(clean, null, 2)}\n`, { mode: 384 });
67
+ chmodSync(path, 384);
73
68
  }
74
69
  function find(cfg, name) {
75
- return (cfg.contexts ?? []).find((c) => c.name === name);
70
+ return (cfg.contexts ?? []).find((c) => c.name === name);
76
71
  }
72
+ /** The active context: `override` (e.g. --context) wins, else currentContext. */
77
73
  function current(cfg, override) {
78
- const name = (override ?? "").trim() || (cfg.currentContext ?? "").trim();
79
- if (!name) return void 0;
80
- return find(cfg, name);
74
+ const name = (override ?? "").trim() || (cfg.currentContext ?? "").trim();
75
+ if (!name) return void 0;
76
+ return find(cfg, name);
81
77
  }
78
+ /** Merge-in a context: only NON-EMPTY apiKey/spaceId/spaceName/baseUrl
79
+ * overwrite an existing value. This keeps a transient per-command override
80
+ * (e.g. a one-off `--base-url`) from being silently persisted over the
81
+ * context's real base URL. First-ever context becomes current. Returns it. */
82
82
  function upsert(cfg, incoming) {
83
- cfg.contexts ??= [];
84
- let ctx = cfg.contexts.find((c) => c.name === incoming.name);
85
- if (!ctx) {
86
- ctx = { name: incoming.name };
87
- cfg.contexts.push(ctx);
88
- if (!cfg.currentContext) cfg.currentContext = ctx.name;
89
- }
90
- if ((incoming.apiKey ?? "").trim()) ctx.apiKey = incoming.apiKey;
91
- if ((incoming.spaceId ?? "").trim()) ctx.spaceId = incoming.spaceId;
92
- if ((incoming.spaceName ?? "").trim()) ctx.spaceName = incoming.spaceName;
93
- if ((incoming.baseUrl ?? "").trim()) ctx.baseUrl = incoming.baseUrl;
94
- return ctx;
83
+ cfg.contexts ??= [];
84
+ let ctx = cfg.contexts.find((c) => c.name === incoming.name);
85
+ if (!ctx) {
86
+ ctx = { name: incoming.name };
87
+ cfg.contexts.push(ctx);
88
+ if (!cfg.currentContext) cfg.currentContext = ctx.name;
89
+ }
90
+ if ((incoming.apiKey ?? "").trim()) ctx.apiKey = incoming.apiKey;
91
+ if ((incoming.spaceId ?? "").trim()) ctx.spaceId = incoming.spaceId;
92
+ if ((incoming.spaceName ?? "").trim()) ctx.spaceName = incoming.spaceName;
93
+ if ((incoming.baseUrl ?? "").trim()) ctx.baseUrl = incoming.baseUrl;
94
+ return ctx;
95
95
  }
96
96
  function remove(cfg, name) {
97
- const before = (cfg.contexts ?? []).length;
98
- cfg.contexts = (cfg.contexts ?? []).filter((c) => c.name !== name);
99
- if (cfg.contexts.length === before) return false;
100
- if (cfg.currentContext === name) {
101
- cfg.currentContext = cfg.contexts[0]?.name ?? "";
102
- }
103
- return true;
97
+ const before = (cfg.contexts ?? []).length;
98
+ cfg.contexts = (cfg.contexts ?? []).filter((c) => c.name !== name);
99
+ if (cfg.contexts.length === before) return false;
100
+ if (cfg.currentContext === name) cfg.currentContext = cfg.contexts[0]?.name ?? "";
101
+ return true;
104
102
  }
103
+ /** Resolve credentials with precedence flag > env > active context. */
105
104
  function resolve(cfg, flags) {
106
- const ctx = current(cfg, (flags.context ?? "").trim() || process.env[ENV.context]);
107
- const pick = (flag, env, ctxVal) => {
108
- if ((flag ?? "").trim()) return [flag, "flag"];
109
- if ((env ?? "").trim()) return [env, "env"];
110
- if ((ctxVal ?? "").trim()) return [ctxVal, "context"];
111
- return ["", ""];
112
- };
113
- const [apiKey, apiKeySource] = pick(flags.apiKey, process.env[ENV.apiKey], ctx?.apiKey);
114
- const [spaceId, spaceIdSource] = pick(flags.space, process.env[ENV.spaceId], ctx?.spaceId);
115
- const [baseUrlRaw] = pick(flags.baseUrl, process.env[ENV.baseUrl], ctx?.baseUrl);
116
- return {
117
- apiKey,
118
- spaceId,
119
- baseUrl: baseUrlRaw || DEFAULT_BASE_URL,
120
- apiKeySource,
121
- spaceIdSource,
122
- contextName: ctx?.name ?? ""
123
- };
105
+ const ctx = current(cfg, (flags.context ?? "").trim() || process.env[ENV.context]);
106
+ const pick = (flag, env, ctxVal) => {
107
+ if ((flag ?? "").trim()) return [flag, "flag"];
108
+ if ((env ?? "").trim()) return [env, "env"];
109
+ if ((ctxVal ?? "").trim()) return [ctxVal, "context"];
110
+ return ["", ""];
111
+ };
112
+ const [apiKey, apiKeySource] = pick(flags.apiKey, process.env[ENV.apiKey], ctx?.apiKey);
113
+ const [spaceId, spaceIdSource] = pick(flags.space, process.env[ENV.spaceId], ctx?.spaceId);
114
+ const [baseUrlRaw] = pick(flags.baseUrl, process.env[ENV.baseUrl], ctx?.baseUrl);
115
+ return {
116
+ apiKey,
117
+ spaceId,
118
+ baseUrl: baseUrlRaw || "https://krova.cloud/api/v1",
119
+ apiKeySource,
120
+ spaceIdSource,
121
+ contextName: ctx?.name ?? ""
122
+ };
124
123
  }
125
124
  function sanitizeContextName(name) {
126
- return name.trim().toLowerCase().replace(/\s+/g, "-");
125
+ return name.trim().toLowerCase().replace(/\s+/g, "-");
127
126
  }
128
127
  function maskKey(key) {
129
- if (!key) return "";
130
- if (key.length <= 8) return "****";
131
- return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
128
+ if (!key) return "";
129
+ if (key.length <= 8) return "****";
130
+ return `${key.slice(0, 6)}…${key.slice(-4)}`;
132
131
  }
133
-
134
- // src/lib/output.ts
132
+ //#endregion
133
+ //#region src/lib/output.ts
135
134
  function printJSON(value) {
136
- process.stdout.write(`${JSON.stringify(value, null, 2)}
137
- `);
135
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
138
136
  }
139
137
  function printTable(header, rows) {
140
- const widths = header.map(
141
- (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
142
- );
143
- const fmt = (cols) => cols.map((c, i) => (c ?? "").padEnd(widths[i] ?? 0)).join(" ").replace(/\s+$/, "");
144
- process.stdout.write(`${fmt(header)}
145
- `);
146
- for (const r of rows) process.stdout.write(`${fmt(r)}
147
- `);
138
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
139
+ const fmt = (cols) => cols.map((c, i) => (c ?? "").padEnd(widths[i] ?? 0)).join(" ").replace(/\s+$/, "");
140
+ process.stdout.write(`${fmt(header)}\n`);
141
+ for (const r of rows) process.stdout.write(`${fmt(r)}\n`);
148
142
  }
149
143
  function printKeyValue(pairs) {
150
- const w = Math.max(0, ...pairs.map(([k]) => k.length));
151
- for (const [k, v] of pairs) {
152
- process.stdout.write(`${k.padEnd(w)} ${v}
153
- `);
154
- }
144
+ const w = Math.max(0, ...pairs.map(([k]) => k.length));
145
+ for (const [k, v] of pairs) process.stdout.write(`${k.padEnd(w)} ${v}\n`);
155
146
  }
156
-
157
- // src/lib/client.ts
158
- import { KrovaClient } from "@krovacloud/sdk";
147
+ //#endregion
148
+ //#region src/lib/client.ts
149
+ /** Build the SDK client, or throw the same message the Go CLI used. */
159
150
  function makeClient(res) {
160
- if (!res.apiKey) {
161
- throw new Error(
162
- "no API key found: run `krova auth login`, set KROVA_API_KEY, or pass --api-key"
163
- );
164
- }
165
- return new KrovaClient({ apiKey: res.apiKey, baseUrl: res.baseUrl });
151
+ if (!res.apiKey) throw new Error("no API key found: run `krova auth login`, set KROVA_API_KEY, or pass --api-key");
152
+ return new KrovaClient({
153
+ apiKey: res.apiKey,
154
+ baseUrl: res.baseUrl
155
+ });
166
156
  }
157
+ /** Raw JSON request for endpoints the SDK doesn't cover:
158
+ * GET /space, GET .../ssh, POST /auth/cli/start, POST /auth/cli/poll. */
167
159
  async function rawRequest(opts) {
168
- const controller = new AbortController();
169
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
170
- try {
171
- const headers = { Accept: "application/json" };
172
- if (opts.apiKey) headers["X-API-KEY"] = opts.apiKey;
173
- if (opts.body !== void 0) headers["Content-Type"] = "application/json";
174
- const res = await fetch(`${opts.baseUrl.replace(/\/+$/, "")}${opts.path}`, {
175
- method: opts.method,
176
- headers,
177
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
178
- redirect: "manual",
179
- signal: controller.signal
180
- });
181
- let data;
182
- const text = await res.text();
183
- if (text) {
184
- try {
185
- data = JSON.parse(text);
186
- } catch {
187
- data = void 0;
188
- }
189
- }
190
- return { status: res.status, data };
191
- } finally {
192
- clearTimeout(timer);
193
- }
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
162
+ try {
163
+ const headers = { Accept: "application/json" };
164
+ if (opts.apiKey) headers["X-API-KEY"] = opts.apiKey;
165
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
166
+ const res = await fetch(`${opts.baseUrl.replace(/\/+$/, "")}${opts.path}`, {
167
+ method: opts.method,
168
+ headers,
169
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
170
+ redirect: "manual",
171
+ signal: controller.signal
172
+ });
173
+ let data;
174
+ const text = await res.text();
175
+ if (text) try {
176
+ data = JSON.parse(text);
177
+ } catch {
178
+ data = void 0;
179
+ }
180
+ return {
181
+ status: res.status,
182
+ data
183
+ };
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
194
187
  }
195
-
196
- // src/lib/runtime.ts
188
+ //#endregion
189
+ //#region src/lib/runtime.ts
190
+ /** Parse a Go-style duration ("30s", "500ms", "2m", "1h") to ms. Non-positive
191
+ * or unparseable floors to 30s (matches the Go requestTimeout behavior). */
197
192
  function parseDuration(input) {
198
- const s = (input ?? "").trim();
199
- if (!s) return 3e4;
200
- let total = 0;
201
- const re = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
202
- let m = re.exec(s);
203
- let matched = false;
204
- while (m) {
205
- matched = true;
206
- const n = Number(m[1]);
207
- const unit = m[2];
208
- total += unit === "ms" ? n : unit === "s" ? n * 1e3 : unit === "m" ? n * 6e4 : n * 36e5;
209
- m = re.exec(s);
210
- }
211
- if (!matched) {
212
- const n = Number(s);
213
- if (Number.isFinite(n)) total = n * 1e3;
214
- }
215
- return total > 0 ? total : 3e4;
193
+ const s = (input ?? "").trim();
194
+ if (!s) return 3e4;
195
+ let total = 0;
196
+ const re = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
197
+ let m = re.exec(s);
198
+ let matched = false;
199
+ while (m) {
200
+ matched = true;
201
+ const n = Number(m[1]);
202
+ const unit = m[2];
203
+ total += unit === "ms" ? n : unit === "s" ? n * 1e3 : unit === "m" ? n * 6e4 : n * 36e5;
204
+ m = re.exec(s);
205
+ }
206
+ if (!matched) {
207
+ const n = Number(s);
208
+ if (Number.isFinite(n)) total = n * 1e3;
209
+ }
210
+ return total > 0 ? total : 3e4;
216
211
  }
217
212
  function getRuntime(cmd) {
218
- const opts = cmd.optsWithGlobals();
219
- const flags = {
220
- apiKey: opts.apiKey,
221
- space: opts.space,
222
- baseUrl: opts.baseUrl,
223
- context: opts.context
224
- };
225
- const cfg = load();
226
- const res = resolve(cfg, flags);
227
- return {
228
- flags,
229
- cfg,
230
- res,
231
- json: Boolean(opts.json),
232
- timeoutMs: parseDuration(opts.timeout)
233
- };
213
+ const opts = cmd.optsWithGlobals();
214
+ const flags = {
215
+ apiKey: opts.apiKey,
216
+ space: opts.space,
217
+ baseUrl: opts.baseUrl,
218
+ context: opts.context
219
+ };
220
+ const cfg = load();
221
+ return {
222
+ flags,
223
+ cfg,
224
+ res: resolve(cfg, flags),
225
+ json: Boolean(opts.json),
226
+ timeoutMs: parseDuration(opts.timeout)
227
+ };
234
228
  }
229
+ /** Ask the server whether `apiKey` is actually accepted. Never throws. */
235
230
  async function probeAuth(baseUrl, apiKey, timeoutMs) {
236
- if (!apiKey) return { state: "missing" };
237
- try {
238
- const { status, data } = await rawRequest({
239
- method: "GET",
240
- baseUrl,
241
- path: "/space",
242
- apiKey,
243
- timeoutMs
244
- });
245
- if (status === 200 && data?.id) return { state: "valid", space: data };
246
- if (status === 401 || status === 403) return { state: "rejected", status };
247
- if (status === 404) return { state: "unsupported" };
248
- return { state: "unreachable", error: `HTTP ${status}` };
249
- } catch (err) {
250
- return {
251
- state: "unreachable",
252
- error: err instanceof Error ? err.message : String(err)
253
- };
254
- }
231
+ if (!apiKey) return { state: "missing" };
232
+ try {
233
+ const { status, data } = await rawRequest({
234
+ method: "GET",
235
+ baseUrl,
236
+ path: "/space",
237
+ apiKey,
238
+ timeoutMs
239
+ });
240
+ if (status === 200 && data?.id) return {
241
+ state: "valid",
242
+ space: data
243
+ };
244
+ if (status === 401 || status === 403) return {
245
+ state: "rejected",
246
+ status
247
+ };
248
+ if (status === 404) return { state: "unsupported" };
249
+ return {
250
+ state: "unreachable",
251
+ error: `HTTP ${status}`
252
+ };
253
+ } catch (err) {
254
+ return {
255
+ state: "unreachable",
256
+ error: err instanceof Error ? err.message : String(err)
257
+ };
258
+ }
255
259
  }
260
+ /** GET /space with the given key; null on 404 / error (non-fatal). Thin
261
+ * wrapper over `probeAuth` for callers that only need the space. */
256
262
  async function fetchSpace(baseUrl, apiKey, timeoutMs) {
257
- const probe = await probeAuth(baseUrl, apiKey, timeoutMs);
258
- return probe.state === "valid" ? probe.space : null;
263
+ const probe = await probeAuth(baseUrl, apiKey, timeoutMs);
264
+ return probe.state === "valid" ? probe.space : null;
259
265
  }
266
+ /** Resolve the active space id: explicit value wins, else auto-detect via
267
+ * GET /space (and cache it into the current context). */
260
268
  async function resolveSpace(rt) {
261
- if (rt.res.spaceId) return rt.res.spaceId;
262
- const { status, data } = await rawRequest({
263
- method: "GET",
264
- baseUrl: rt.res.baseUrl,
265
- path: "/space",
266
- apiKey: rt.res.apiKey,
267
- timeoutMs: rt.timeoutMs
268
- });
269
- if (status === 200 && data?.id) {
270
- if (rt.res.contextName) {
271
- upsert(rt.cfg, {
272
- name: rt.res.contextName,
273
- spaceId: data.id,
274
- spaceName: data.name
275
- });
276
- try {
277
- save(rt.cfg);
278
- } catch {
279
- }
280
- }
281
- return data.id;
282
- }
283
- if (status === 404) {
284
- throw new Error(
285
- "couldn't auto-detect your space (the server doesn't support it yet) \u2014 pass --space, set KROVA_SPACE_ID, or run `krova login`"
286
- );
287
- }
288
- if (status === 401 || status === 403) {
289
- throw new Error(`auto-detect space failed: the API key was rejected (HTTP ${status})`);
290
- }
291
- throw new Error(`auto-detect space failed (HTTP ${status})`);
269
+ if (rt.res.spaceId) return rt.res.spaceId;
270
+ const { status, data } = await rawRequest({
271
+ method: "GET",
272
+ baseUrl: rt.res.baseUrl,
273
+ path: "/space",
274
+ apiKey: rt.res.apiKey,
275
+ timeoutMs: rt.timeoutMs
276
+ });
277
+ if (status === 200 && data?.id) {
278
+ if (rt.res.contextName) {
279
+ upsert(rt.cfg, {
280
+ name: rt.res.contextName,
281
+ spaceId: data.id,
282
+ spaceName: data.name
283
+ });
284
+ try {
285
+ save(rt.cfg);
286
+ } catch {}
287
+ }
288
+ return data.id;
289
+ }
290
+ if (status === 404) throw new Error("couldn't auto-detect your space (the server doesn't support it yet) — pass --space, set KROVA_SPACE_ID, or run `krova login`");
291
+ if (status === 401 || status === 403) throw new Error(`auto-detect space failed: the API key was rejected (HTTP ${status})`);
292
+ throw new Error(`auto-detect space failed (HTTP ${status})`);
292
293
  }
293
-
294
- // src/lib/persist.ts
294
+ //#endregion
295
+ //#region src/lib/persist.ts
296
+ /** Save + switch to a context after a login. Best-effort space lookup fills in
297
+ * spaceId/spaceName and a nicer context name. Mirrors the Go persistLogin. */
295
298
  async function persistLogin(input) {
296
- const cfg = load();
297
- let spaceId = input.spaceId ?? "";
298
- let spaceName = "";
299
- let derivedName = "";
300
- const sp = await fetchSpace(input.baseUrl, input.apiKey, input.timeoutMs);
301
- if (sp) {
302
- if (!spaceId) spaceId = sp.id;
303
- spaceName = sp.name ?? "";
304
- derivedName = (sp.slug || sp.name || "").trim();
305
- }
306
- let name = (input.ctxName ?? "").trim();
307
- if (!name) name = derivedName || DEFAULT_CONTEXT_NAME;
308
- name = sanitizeContextName(name);
309
- const cur = upsert(cfg, {
310
- name,
311
- apiKey: input.apiKey,
312
- spaceId,
313
- spaceName,
314
- baseUrl: input.baseUrl
315
- });
316
- cfg.currentContext = cur.name;
317
- save(cfg);
318
- return { ctxName: cur.name, spaceName: cur.spaceName ?? "" };
299
+ const cfg = load();
300
+ let spaceId = input.spaceId ?? "";
301
+ let spaceName = "";
302
+ let derivedName = "";
303
+ const sp = await fetchSpace(input.baseUrl, input.apiKey, input.timeoutMs);
304
+ if (sp) {
305
+ if (!spaceId) spaceId = sp.id;
306
+ spaceName = sp.name ?? "";
307
+ derivedName = (sp.slug || sp.name || "").trim();
308
+ }
309
+ let name = (input.ctxName ?? "").trim();
310
+ if (!name) name = derivedName || "default";
311
+ name = sanitizeContextName(name);
312
+ const cur = upsert(cfg, {
313
+ name,
314
+ apiKey: input.apiKey,
315
+ spaceId,
316
+ spaceName,
317
+ baseUrl: input.baseUrl
318
+ });
319
+ cfg.currentContext = cur.name;
320
+ save(cfg);
321
+ return {
322
+ ctxName: cur.name,
323
+ spaceName: cur.spaceName ?? ""
324
+ };
319
325
  }
320
-
321
- // src/commands/auth.ts
326
+ //#endregion
327
+ //#region src/commands/auth.ts
328
+ /** Prompt for an API key. Hidden echo when stdin is a TTY. */
322
329
  function promptAPIKey() {
323
- return new Promise((resolve2, reject) => {
324
- process.stdout.write("Krova API key: ");
325
- const stdin = process.stdin;
326
- const tty = Boolean(stdin.isTTY);
327
- let buf = "";
328
- if (tty) stdin.setRawMode(true);
329
- stdin.resume();
330
- stdin.setEncoding("utf8");
331
- const finish = (fn) => {
332
- if (tty) stdin.setRawMode(false);
333
- stdin.pause();
334
- stdin.removeListener("data", onData);
335
- fn();
336
- };
337
- const onData = (ch) => {
338
- for (const c of ch) {
339
- const code = c.charCodeAt(0);
340
- if (code === 10 || code === 13) {
341
- finish(() => {
342
- process.stdout.write("\n");
343
- resolve2(buf.trim());
344
- });
345
- return;
346
- }
347
- if (code === 3) {
348
- finish(() => reject(new Error("cancelled")));
349
- return;
350
- }
351
- if (code === 127 || code === 8) {
352
- buf = buf.slice(0, -1);
353
- } else if (code >= 32) {
354
- buf += c;
355
- }
356
- }
357
- };
358
- stdin.on("data", onData);
359
- });
330
+ return new Promise((resolve, reject) => {
331
+ process.stdout.write("Krova API key: ");
332
+ const stdin = process.stdin;
333
+ const tty = Boolean(stdin.isTTY);
334
+ let buf = "";
335
+ if (tty) stdin.setRawMode(true);
336
+ stdin.resume();
337
+ stdin.setEncoding("utf8");
338
+ const finish = (fn) => {
339
+ if (tty) stdin.setRawMode(false);
340
+ stdin.pause();
341
+ stdin.removeListener("data", onData);
342
+ fn();
343
+ };
344
+ const onData = (ch) => {
345
+ for (const c of ch) {
346
+ const code = c.charCodeAt(0);
347
+ if (code === 10 || code === 13) {
348
+ finish(() => {
349
+ process.stdout.write("\n");
350
+ resolve(buf.trim());
351
+ });
352
+ return;
353
+ }
354
+ if (code === 3) {
355
+ finish(() => reject(/* @__PURE__ */ new Error("cancelled")));
356
+ return;
357
+ }
358
+ if (code === 127 || code === 8) buf = buf.slice(0, -1);
359
+ else if (code >= 32) buf += c;
360
+ }
361
+ };
362
+ stdin.on("data", onData);
363
+ });
360
364
  }
361
365
  function authCommand() {
362
- const auth = new Command("auth").description("manage Krova Cloud credentials");
363
- auth.command("login").description("log in by pasting an API key").option("--api-key <key>", "API key (else you'll be prompted)").option("--space <id>", "Space ID to store with the key").option("--context <name>", "name for the saved context").action(async (opts, cmd) => {
364
- const rt = getRuntime(cmd);
365
- let key = opts.apiKey || rt.flags.apiKey || "";
366
- if (!key.trim()) key = await promptAPIKey();
367
- if (!key.trim()) throw new Error("no API key provided");
368
- const { ctxName, spaceName } = await persistLogin({
369
- apiKey: key.trim(),
370
- baseUrl: rt.res.baseUrl,
371
- spaceId: opts.space,
372
- ctxName: opts.context,
373
- timeoutMs: rt.timeoutMs
374
- });
375
- process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}
376
- `);
377
- if (spaceName) process.stdout.write(`Space: ${spaceName}
378
- `);
379
- });
380
- auth.command("status").description("show the resolved credentials and verify them against the API").option("--offline", "skip the live check; only report what's stored locally").action(async (opts, cmd) => {
381
- const rt = getRuntime(cmd);
382
- const offline = Boolean(opts.offline);
383
- const probe = offline ? { state: "missing" } : await probeAuth(rt.res.baseUrl, rt.res.apiKey, rt.timeoutMs);
384
- const hasKey = Boolean(rt.res.apiKey);
385
- const authenticated = offline ? hasKey : probe.state === "valid";
386
- const detail = offline || !hasKey ? "" : probe.state === "rejected" ? `the API rejected this key (HTTP ${probe.status}) \u2014 it was revoked or belongs to another environment; run \`krova login\`` : probe.state === "unreachable" ? `could not reach ${rt.res.baseUrl} (${probe.error}) \u2014 the key was NOT verified` : probe.state === "unsupported" ? "this server has no /space endpoint, so the key could not be verified" : "";
387
- const spaceId = probe.state === "valid" ? probe.space.id : rt.res.spaceId;
388
- if (rt.json) {
389
- return printJSON({
390
- authenticated,
391
- verified: probe.state === "valid",
392
- checkState: offline ? "skipped" : probe.state,
393
- detail: detail || void 0,
394
- context: rt.res.contextName,
395
- apiKeySource: rt.res.apiKeySource,
396
- apiKeyMasked: maskKey(rt.res.apiKey),
397
- spaceId,
398
- spaceSource: rt.res.spaceIdSource,
399
- baseUrl: rt.res.baseUrl,
400
- configPath: configPath()
401
- });
402
- }
403
- const label = !hasKey ? "no (no API key found)" : offline ? "not checked (--offline; a key is stored)" : probe.state === "valid" ? "yes (verified against the API)" : probe.state === "rejected" ? "NO \u2014 key rejected by the API" : "unknown (could not verify)";
404
- printKeyValue([
405
- ["Authenticated", label],
406
- ["Context", rt.res.contextName || "\u2014"],
407
- ["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : "\u2014"],
408
- ["Space ID", spaceId ? `${spaceId} (${rt.res.spaceIdSource})` : "\u2014"],
409
- ["Base URL", rt.res.baseUrl],
410
- ["Config", configPath()]
411
- ]);
412
- if (detail) process.stdout.write(`
413
- ${detail}
414
- `);
415
- if (!hasKey || probe.state === "rejected") process.exitCode = 1;
416
- });
417
- return auth;
366
+ const auth = new Command("auth").description("manage Krova Cloud credentials");
367
+ auth.command("login").description("log in by pasting an API key").option("--api-key <key>", "API key (else you'll be prompted)").option("--space <id>", "Space ID to store with the key").option("--context <name>", "name for the saved context").action(async (opts, cmd) => {
368
+ const rt = getRuntime(cmd);
369
+ let key = opts.apiKey || rt.flags.apiKey || "";
370
+ if (!key.trim()) key = await promptAPIKey();
371
+ if (!key.trim()) throw new Error("no API key provided");
372
+ const { ctxName, spaceName } = await persistLogin({
373
+ apiKey: key.trim(),
374
+ baseUrl: rt.res.baseUrl,
375
+ spaceId: opts.space,
376
+ ctxName: opts.context,
377
+ timeoutMs: rt.timeoutMs
378
+ });
379
+ process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}\n`);
380
+ if (spaceName) process.stdout.write(`Space: ${spaceName}\n`);
381
+ });
382
+ auth.command("status").description("show the resolved credentials and verify them against the API").option("--offline", "skip the live check; only report what's stored locally").action(async (opts, cmd) => {
383
+ const rt = getRuntime(cmd);
384
+ const offline = Boolean(opts.offline);
385
+ const probe = offline ? { state: "missing" } : await probeAuth(rt.res.baseUrl, rt.res.apiKey, rt.timeoutMs);
386
+ const hasKey = Boolean(rt.res.apiKey);
387
+ const authenticated = offline ? hasKey : probe.state === "valid";
388
+ const detail = offline || !hasKey ? "" : probe.state === "rejected" ? `the API rejected this key (HTTP ${probe.status}) — it was revoked or belongs to another environment; run \`krova login\`` : probe.state === "unreachable" ? `could not reach ${rt.res.baseUrl} (${probe.error}) — the key was NOT verified` : probe.state === "unsupported" ? "this server has no /space endpoint, so the key could not be verified" : "";
389
+ const spaceId = probe.state === "valid" ? probe.space.id : rt.res.spaceId;
390
+ if (rt.json) return printJSON({
391
+ authenticated,
392
+ verified: probe.state === "valid",
393
+ checkState: offline ? "skipped" : probe.state,
394
+ detail: detail || void 0,
395
+ context: rt.res.contextName,
396
+ apiKeySource: rt.res.apiKeySource,
397
+ apiKeyMasked: maskKey(rt.res.apiKey),
398
+ spaceId,
399
+ spaceSource: rt.res.spaceIdSource,
400
+ baseUrl: rt.res.baseUrl,
401
+ configPath: configPath()
402
+ });
403
+ printKeyValue([
404
+ ["Authenticated", !hasKey ? "no (no API key found)" : offline ? "not checked (--offline; a key is stored)" : probe.state === "valid" ? "yes (verified against the API)" : probe.state === "rejected" ? "NO — key rejected by the API" : "unknown (could not verify)"],
405
+ ["Context", rt.res.contextName || "—"],
406
+ ["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : "—"],
407
+ ["Space ID", spaceId ? `${spaceId} (${rt.res.spaceIdSource})` : ""],
408
+ ["Base URL", rt.res.baseUrl],
409
+ ["Config", configPath()]
410
+ ]);
411
+ if (detail) process.stdout.write(`\n${detail}\n`);
412
+ if (!hasKey || probe.state === "rejected") process.exitCode = 1;
413
+ });
414
+ return auth;
418
415
  }
419
-
420
- // src/commands/catalog.ts
421
- import { Command as Command2 } from "commander";
416
+ //#endregion
417
+ //#region src/commands/catalog.ts
422
418
  function fmtVal(v) {
423
- if (v === null || v === void 0) return "";
424
- if (typeof v === "object") return JSON.stringify(v);
425
- return String(v);
419
+ if (v === null || v === void 0) return "";
420
+ if (typeof v === "object") return JSON.stringify(v);
421
+ return String(v);
426
422
  }
423
+ /** Flatten a top-level object field one level deep into `key.subkey` rows, so
424
+ * nested objects (e.g. pricing's `rates`) render as readable key/value pairs
425
+ * instead of a JSON blob. Exported for testing. */
427
426
  function flattenRows(entries) {
428
- const rows = [];
429
- for (const [k, v] of entries) {
430
- if (v && typeof v === "object" && !Array.isArray(v)) {
431
- for (const [sk, sv] of Object.entries(v)) {
432
- rows.push([`${k}.${sk}`, fmtVal(sv)]);
433
- }
434
- } else {
435
- rows.push([k, fmtVal(v)]);
436
- }
437
- }
438
- return rows;
427
+ const rows = [];
428
+ for (const [k, v] of entries) if (v && typeof v === "object" && !Array.isArray(v)) for (const [sk, sv] of Object.entries(v)) rows.push([`${k}.${sk}`, fmtVal(sv)]);
429
+ else rows.push([k, fmtVal(v)]);
430
+ return rows;
439
431
  }
432
+ /**
433
+ * Render a catalog payload as text. If the payload has an array field, print any
434
+ * scalar/object fields first (as key/value) and then the array as a table —
435
+ * otherwise the non-array fields are silently dropped. This matters for
436
+ * `pricing`, whose per-resource `rates` (the actual hourly prices), `currency`,
437
+ * and `note` sit alongside the `tiers` array. `regions`/`images` have only the
438
+ * array, so their output is unchanged.
439
+ */
440
440
  function renderCatalog(obj) {
441
- const arrKey = Object.keys(obj).find((k) => Array.isArray(obj[k]));
442
- if (!arrKey) {
443
- printKeyValue(flattenRows(Object.entries(obj)));
444
- return;
445
- }
446
- const rest = flattenRows(Object.entries(obj).filter(([k]) => k !== arrKey));
447
- if (rest.length) printKeyValue(rest);
448
- const arr = obj[arrKey] ?? [];
449
- const cols = [...new Set(arr.flatMap((o) => Object.keys(o)))];
450
- printTable(
451
- cols.map((c) => c.toUpperCase()),
452
- arr.map((o) => cols.map((c) => fmtVal(o[c])))
453
- );
441
+ const arrKey = Object.keys(obj).find((k) => Array.isArray(obj[k]));
442
+ if (!arrKey) {
443
+ printKeyValue(flattenRows(Object.entries(obj)));
444
+ return;
445
+ }
446
+ const rest = flattenRows(Object.entries(obj).filter(([k]) => k !== arrKey));
447
+ if (rest.length) printKeyValue(rest);
448
+ const arr = obj[arrKey] ?? [];
449
+ const cols = [...new Set(arr.flatMap((o) => Object.keys(o)))];
450
+ printTable(cols.map((c) => c.toUpperCase()), arr.map((o) => cols.map((c) => fmtVal(o[c]))));
454
451
  }
455
- function catalogCmd(name, desc, fetch2) {
456
- return new Command2(name).description(desc).action(async (_opts, cmd) => {
457
- const rt = getRuntime(cmd);
458
- const client = makeClient(rt.res);
459
- const data = await fetch2(client);
460
- if (rt.json) return printJSON(data);
461
- renderCatalog(data);
462
- });
452
+ function catalogCmd(name, desc, fetch) {
453
+ return new Command(name).description(desc).action(async (_opts, cmd) => {
454
+ const rt = getRuntime(cmd);
455
+ const data = await fetch(makeClient(rt.res));
456
+ if (rt.json) return printJSON(data);
457
+ renderCatalog(data);
458
+ });
463
459
  }
464
460
  function regionsCommand() {
465
- return catalogCmd(
466
- "regions",
467
- "list regions with available capacity",
468
- (c) => c.catalog.regions()
469
- );
461
+ return catalogCmd("regions", "list regions with available capacity", (c) => c.catalog.regions());
470
462
  }
471
463
  function imagesCommand() {
472
- return catalogCmd(
473
- "images",
474
- "list available OS images",
475
- (c) => c.catalog.images()
476
- );
464
+ return catalogCmd("images", "list available OS images", (c) => c.catalog.images());
477
465
  }
478
466
  function pricingCommand() {
479
- return catalogCmd(
480
- "pricing",
481
- "show per-resource hourly pricing",
482
- (c) => c.catalog.pricing()
483
- );
467
+ return catalogCmd("pricing", "show per-resource hourly pricing", (c) => c.catalog.pricing());
484
468
  }
485
-
486
- // src/commands/context.ts
487
- import { Command as Command3 } from "commander";
469
+ //#endregion
470
+ //#region src/commands/context.ts
488
471
  function contextCommand() {
489
- const ctx = new Command3("context").aliases(["ctx", "contexts"]).description("manage named credential contexts (like kubectl/aws profiles)");
490
- ctx.command("list").aliases(["ls"]).description("list all contexts").action((_opts, cmd) => {
491
- const { json } = getRuntime(cmd);
492
- const cfg = load();
493
- if (json) {
494
- return printJSON({
495
- currentContext: cfg.currentContext ?? "",
496
- contexts: (cfg.contexts ?? []).map((c) => ({
497
- name: c.name,
498
- apiKey: maskKey(c.apiKey ?? ""),
499
- spaceId: c.spaceId ?? "",
500
- spaceName: c.spaceName ?? "",
501
- baseUrl: c.baseUrl ?? ""
502
- }))
503
- });
504
- }
505
- printTable(
506
- ["CURRENT", "NAME", "SPACE", "SPACE ID", "BASE URL"],
507
- (cfg.contexts ?? []).map((c) => [
508
- c.name === cfg.currentContext ? "*" : "",
509
- c.name,
510
- c.spaceName ?? "",
511
- c.spaceId ?? "",
512
- c.baseUrl ?? ""
513
- ])
514
- );
515
- });
516
- ctx.command("current").description("print the current context name").action(() => {
517
- const cfg = load();
518
- if (!cfg.currentContext) throw new Error("no current context set");
519
- process.stdout.write(`${cfg.currentContext}
520
- `);
521
- });
522
- ctx.command("use").argument("<name>", "context name").description("switch the current context").action((name) => {
523
- const cfg = load();
524
- if (!(cfg.contexts ?? []).some((c) => c.name === name)) {
525
- throw new Error(`no context named "${name}"`);
526
- }
527
- cfg.currentContext = name;
528
- save(cfg);
529
- process.stdout.write(`Switched to context ${name}
530
- `);
531
- });
532
- ctx.command("rename").argument("<old>", "current name").argument("<new>", "new name").description("rename a context").action((oldName, newName) => {
533
- const cfg = load();
534
- if (!newName.trim()) throw new Error("new name must not be empty");
535
- const c = (cfg.contexts ?? []).find((x) => x.name === oldName);
536
- if (!c) throw new Error(`no context named "${oldName}"`);
537
- if ((cfg.contexts ?? []).some((x) => x.name === newName)) {
538
- throw new Error(`a context named "${newName}" already exists`);
539
- }
540
- c.name = newName;
541
- if (cfg.currentContext === oldName) cfg.currentContext = newName;
542
- save(cfg);
543
- process.stdout.write(`Renamed ${oldName} \u2192 ${newName}
544
- `);
545
- });
546
- ctx.command("delete").aliases(["rm"]).argument("<name>", "context name").description("delete a context").action((name) => {
547
- const cfg = load();
548
- if (!remove(cfg, name)) throw new Error(`no context named "${name}"`);
549
- save(cfg);
550
- process.stdout.write(`Deleted context ${name}
551
- `);
552
- });
553
- return ctx;
472
+ const ctx = new Command("context").aliases(["ctx", "contexts"]).description("manage named credential contexts (like kubectl/aws profiles)");
473
+ ctx.command("list").aliases(["ls"]).description("list all contexts").action((_opts, cmd) => {
474
+ const { json } = getRuntime(cmd);
475
+ const cfg = load();
476
+ if (json) return printJSON({
477
+ currentContext: cfg.currentContext ?? "",
478
+ contexts: (cfg.contexts ?? []).map((c) => ({
479
+ name: c.name,
480
+ apiKey: maskKey(c.apiKey ?? ""),
481
+ spaceId: c.spaceId ?? "",
482
+ spaceName: c.spaceName ?? "",
483
+ baseUrl: c.baseUrl ?? ""
484
+ }))
485
+ });
486
+ printTable([
487
+ "CURRENT",
488
+ "NAME",
489
+ "SPACE",
490
+ "SPACE ID",
491
+ "BASE URL"
492
+ ], (cfg.contexts ?? []).map((c) => [
493
+ c.name === cfg.currentContext ? "*" : "",
494
+ c.name,
495
+ c.spaceName ?? "",
496
+ c.spaceId ?? "",
497
+ c.baseUrl ?? ""
498
+ ]));
499
+ });
500
+ ctx.command("current").description("print the current context name").action(() => {
501
+ const cfg = load();
502
+ if (!cfg.currentContext) throw new Error("no current context set");
503
+ process.stdout.write(`${cfg.currentContext}\n`);
504
+ });
505
+ ctx.command("use").argument("<name>", "context name").description("switch the current context").action((name) => {
506
+ const cfg = load();
507
+ if (!(cfg.contexts ?? []).some((c) => c.name === name)) throw new Error(`no context named "${name}"`);
508
+ cfg.currentContext = name;
509
+ save(cfg);
510
+ process.stdout.write(`Switched to context ${name}\n`);
511
+ });
512
+ ctx.command("rename").argument("<old>", "current name").argument("<new>", "new name").description("rename a context").action((oldName, newName) => {
513
+ const cfg = load();
514
+ if (!newName.trim()) throw new Error("new name must not be empty");
515
+ const c = (cfg.contexts ?? []).find((x) => x.name === oldName);
516
+ if (!c) throw new Error(`no context named "${oldName}"`);
517
+ if ((cfg.contexts ?? []).some((x) => x.name === newName)) throw new Error(`a context named "${newName}" already exists`);
518
+ c.name = newName;
519
+ if (cfg.currentContext === oldName) cfg.currentContext = newName;
520
+ save(cfg);
521
+ process.stdout.write(`Renamed ${oldName} ${newName}\n`);
522
+ });
523
+ ctx.command("delete").aliases(["rm"]).argument("<name>", "context name").description("delete a context").action((name) => {
524
+ const cfg = load();
525
+ if (!remove(cfg, name)) throw new Error(`no context named "${name}"`);
526
+ save(cfg);
527
+ process.stdout.write(`Deleted context ${name}\n`);
528
+ });
529
+ return ctx;
554
530
  }
555
-
556
- // src/commands/cubes.ts
557
- import { Command as Command4 } from "commander";
558
-
559
- // src/lib/resolve.ts
531
+ //#endregion
532
+ //#region src/lib/resolve.ts
533
+ /** Resolve a cube name-or-id to an id (parity with the Go resolveCube):
534
+ * exact id wins; else exactly one exact name match; else a clear error. */
560
535
  async function resolveCube(client, spaceId, ref) {
561
- const { cubes } = await client.cubes.list(spaceId);
562
- if (cubes.some((c) => c.id === ref)) return ref;
563
- const byName = cubes.filter((c) => c.name === ref);
564
- if (byName.length === 1) return byName[0].id;
565
- if (byName.length === 0) {
566
- throw new Error(
567
- `no cube named or with ID "${ref}" in this space (see \`krova cubes list\`)`
568
- );
569
- }
570
- const ids = byName.map((c) => c.id).join(", ");
571
- throw new Error(
572
- `cube name "${ref}" is ambiguous: it matches ${byName.length} cubes (${ids}) \u2014 use the cube ID instead`
573
- );
536
+ const { cubes } = await client.cubes.list(spaceId);
537
+ if (cubes.some((c) => c.id === ref)) return ref;
538
+ const byName = cubes.filter((c) => c.name === ref);
539
+ if (byName.length === 1) return byName[0].id;
540
+ if (byName.length === 0) throw new Error(`no cube named or with ID "${ref}" in this space (see \`krova cubes list\`)`);
541
+ const ids = byName.map((c) => c.id).join(", ");
542
+ throw new Error(`cube name "${ref}" is ambiguous: it matches ${byName.length} cubes (${ids}) — use the cube ID instead`);
574
543
  }
575
-
576
- // src/commands/cubes.ts
544
+ //#endregion
545
+ //#region src/commands/cubes.ts
577
546
  function listCmd() {
578
- return new Command4("list").aliases(["ls"]).description("list Cubes in the space").action(async (_opts, cmd) => {
579
- const rt = getRuntime(cmd);
580
- const client = makeClient(rt.res);
581
- const space = await resolveSpace(rt);
582
- const { cubes } = await client.cubes.list(space);
583
- if (rt.json) return printJSON(cubes);
584
- printTable(
585
- ["ID", "NAME", "STATE", "VCPU", "RAM(GB)", "DISK(GB)", "IMAGE", "IPV4"],
586
- cubes.map((c) => [
587
- c.id,
588
- c.name,
589
- c.state,
590
- String(c.resources.vcpu),
591
- String(c.resources.ramGb),
592
- String(c.resources.diskGb),
593
- c.image,
594
- c.publicIpv4 ?? "\u2014"
595
- ])
596
- );
597
- });
547
+ return new Command("list").aliases(["ls"]).description("list Cubes in the space").action(async (_opts, cmd) => {
548
+ const rt = getRuntime(cmd);
549
+ const client = makeClient(rt.res);
550
+ const space = await resolveSpace(rt);
551
+ const { cubes } = await client.cubes.list(space);
552
+ if (rt.json) return printJSON(cubes);
553
+ printTable([
554
+ "ID",
555
+ "NAME",
556
+ "STATE",
557
+ "VCPU",
558
+ "RAM(GB)",
559
+ "DISK(GB)",
560
+ "IMAGE",
561
+ "IPV4"
562
+ ], cubes.map((c) => [
563
+ c.id,
564
+ c.name,
565
+ c.state,
566
+ String(c.resources.vcpu),
567
+ String(c.resources.ramGb),
568
+ String(c.resources.diskGb),
569
+ c.image,
570
+ c.publicIpv4 ?? "—"
571
+ ]));
572
+ });
598
573
  }
599
574
  function getCmd() {
600
- return new Command4("get").argument("<cube>", "cube name or ID").description("show a single Cube").action(async (cubeRef, _opts, cmd) => {
601
- const rt = getRuntime(cmd);
602
- const client = makeClient(rt.res);
603
- const space = await resolveSpace(rt);
604
- const id = await resolveCube(client, space, cubeRef);
605
- const cube = await client.cubes.get(space, id);
606
- if (rt.json) return printJSON(cube);
607
- printKeyValue([
608
- ["ID", cube.id],
609
- ["Name", cube.name],
610
- ["State", cube.state],
611
- ["Image", cube.image],
612
- ["vCPU", String(cube.resources.vcpu)],
613
- ["RAM (GB)", String(cube.resources.ramGb)],
614
- ["Disk (GB)", String(cube.resources.diskGb)],
615
- ["Public IPv4", cube.publicIpv4 ?? "\u2014"],
616
- ["Cost/hour", `$${cube.costPerHour}`]
617
- ]);
618
- });
575
+ return new Command("get").argument("<cube>", "cube name or ID").description("show a single Cube").action(async (cubeRef, _opts, cmd) => {
576
+ const rt = getRuntime(cmd);
577
+ const client = makeClient(rt.res);
578
+ const space = await resolveSpace(rt);
579
+ const id = await resolveCube(client, space, cubeRef);
580
+ const cube = await client.cubes.get(space, id);
581
+ if (rt.json) return printJSON(cube);
582
+ printKeyValue([
583
+ ["ID", cube.id],
584
+ ["Name", cube.name],
585
+ ["State", cube.state],
586
+ ["Image", cube.image],
587
+ ["vCPU", String(cube.resources.vcpu)],
588
+ ["RAM (GB)", String(cube.resources.ramGb)],
589
+ ["Disk (GB)", String(cube.resources.diskGb)],
590
+ ["Public IPv4", cube.publicIpv4 ?? ""],
591
+ ["Cost/hour", `$${cube.costPerHour}`]
592
+ ]);
593
+ });
619
594
  }
620
595
  function createCmd() {
621
- return new Command4("create").description("provision a new Cube").requiredOption("--name <name>", "cube name").requiredOption("--image <slug>", "OS image slug (see `krova images`)").requiredOption("--ssh-key <key>", "SSH public key written to authorized_keys").option("--vcpu <n>", "number of vCPUs", "1").option("--ram <gb>", "RAM in GB", "1").option("--disk <gb>", "disk size in GB", "10").option("--region <slug>", "region slug (see `krova regions`)").option("--user-data <script>", "cloud-init script").option("--idempotency-key <key>", "idempotency key (24h dedupe)").action(async (opts, cmd) => {
622
- const rt = getRuntime(cmd);
623
- const client = makeClient(rt.res);
624
- const space = await resolveSpace(rt);
625
- const posInt = (flag, raw) => {
626
- const n = Number(raw);
627
- if (!Number.isInteger(n) || n <= 0) {
628
- throw new Error(`--${flag} must be a positive integer (got "${raw}").`);
629
- }
630
- return n;
631
- };
632
- const body = {
633
- name: opts.name,
634
- image: opts.image,
635
- sshPublicKey: opts.sshKey,
636
- resources: {
637
- vcpu: posInt("vcpu", opts.vcpu),
638
- ramGb: posInt("ram", opts.ram),
639
- diskGb: posInt("disk", opts.disk)
640
- }
641
- };
642
- if (opts.region) body.region = opts.region;
643
- if (opts.userData) body.userData = opts.userData;
644
- const cube = await client.cubes.create(
645
- space,
646
- body,
647
- opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0
648
- );
649
- if (rt.json) return printJSON(cube);
650
- process.stdout.write(`Created cube ${cube.id} (${cube.state})
651
- `);
652
- });
596
+ return new Command("create").description("provision a new Cube").requiredOption("--name <name>", "cube name").requiredOption("--image <slug>", "OS image slug (see `krova images`)").requiredOption("--ssh-key <key>", "SSH public key written to authorized_keys").option("--vcpu <n>", "number of vCPUs", "1").option("--ram <gb>", "RAM in GB", "1").option("--disk <gb>", "disk size in GB", "10").option("--region <slug>", "region slug (see `krova regions`)").option("--user-data <script>", "cloud-init script").option("--idempotency-key <key>", "idempotency key (24h dedupe)").action(async (opts, cmd) => {
597
+ const rt = getRuntime(cmd);
598
+ const client = makeClient(rt.res);
599
+ const space = await resolveSpace(rt);
600
+ const posInt = (flag, raw) => {
601
+ const n = Number(raw);
602
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`--${flag} must be a positive integer (got "${raw}").`);
603
+ return n;
604
+ };
605
+ const body = {
606
+ name: opts.name,
607
+ image: opts.image,
608
+ sshPublicKey: opts.sshKey,
609
+ resources: {
610
+ vcpu: posInt("vcpu", opts.vcpu),
611
+ ramGb: posInt("ram", opts.ram),
612
+ diskGb: posInt("disk", opts.disk)
613
+ }
614
+ };
615
+ if (opts.region) body.region = opts.region;
616
+ if (opts.userData) body.userData = opts.userData;
617
+ const cube = await client.cubes.create(space, body, opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0);
618
+ if (rt.json) return printJSON(cube);
619
+ process.stdout.write(`Created cube ${cube.id} (${cube.state})\n`);
620
+ });
653
621
  }
654
622
  function actionCmd(name, past, fn) {
655
- return new Command4(name).argument("<cube>", "cube name or ID").description(`${name} a Cube`).action(async (cubeRef, _opts, cmd) => {
656
- const rt = getRuntime(cmd);
657
- const client = makeClient(rt.res);
658
- const space = await resolveSpace(rt);
659
- const id = await resolveCube(client, space, cubeRef);
660
- await fn(client.cubes, space, id);
661
- if (rt.json) return printJSON({ id, result: past });
662
- process.stdout.write(`${past} cube ${id}
663
- `);
664
- });
623
+ return new Command(name).argument("<cube>", "cube name or ID").description(`${name} a Cube`).action(async (cubeRef, _opts, cmd) => {
624
+ const rt = getRuntime(cmd);
625
+ const client = makeClient(rt.res);
626
+ const space = await resolveSpace(rt);
627
+ const id = await resolveCube(client, space, cubeRef);
628
+ await fn(client.cubes, space, id);
629
+ if (rt.json) return printJSON({
630
+ id,
631
+ result: past
632
+ });
633
+ process.stdout.write(`${past} cube ${id}\n`);
634
+ });
665
635
  }
666
636
  function restartCmd() {
667
- return new Command4("restart").argument("<cube>", "cube name or ID").description("restart a Cube (cold restart \u2014 picks up a refreshed kernel)").action(async (cubeRef, _opts, cmd) => {
668
- const rt = getRuntime(cmd);
669
- const client = makeClient(rt.res);
670
- const space = await resolveSpace(rt);
671
- const id = await resolveCube(client, space, cubeRef);
672
- await client.cubes.restart(space, id);
673
- if (rt.json) return printJSON({ id, result: "Restarting" });
674
- process.stdout.write(
675
- `Restarting cube ${id} (cold restart \u2014 disk state is preserved)
676
- `
677
- );
678
- });
637
+ return new Command("restart").argument("<cube>", "cube name or ID").description("restart a Cube (cold restart picks up a refreshed kernel)").action(async (cubeRef, _opts, cmd) => {
638
+ const rt = getRuntime(cmd);
639
+ const client = makeClient(rt.res);
640
+ const space = await resolveSpace(rt);
641
+ const id = await resolveCube(client, space, cubeRef);
642
+ await client.cubes.restart(space, id);
643
+ if (rt.json) return printJSON({
644
+ id,
645
+ result: "Restarting"
646
+ });
647
+ process.stdout.write(`Restarting cube ${id} (cold restart — disk state is preserved)\n`);
648
+ });
679
649
  }
680
650
  function sshPortCmd() {
681
- return new Command4("ssh-port").argument("<cube>", "cube name or ID").requiredOption(
682
- "--port <n>",
683
- "the port INSIDE the Cube that sshd listens on (default 22)"
684
- ).description("change the in-Cube port that SSH is forwarded to (not the host port)").action(async (cubeRef, opts, cmd) => {
685
- const rt = getRuntime(cmd);
686
- const client = makeClient(rt.res);
687
- const space = await resolveSpace(rt);
688
- const id = await resolveCube(client, space, cubeRef);
689
- const cubePort = Number(opts.port);
690
- if (!Number.isInteger(cubePort) || cubePort < 1 || cubePort > 65535) {
691
- throw new Error("--port must be an integer between 1 and 65535");
692
- }
693
- await client.cubes.update(space, id, { cubePort });
694
- if (rt.json) return printJSON({ id, cubePort });
695
- process.stdout.write(
696
- `Cube ${id}: SSH now forwarded to in-Cube port ${cubePort}.
697
- ` + (cubePort === 22 ? "" : `Warning: sshd inside the Cube must be listening on ${cubePort}, or SSH will stop working. The default is 22.
698
- `)
699
- );
700
- });
651
+ return new Command("ssh-port").argument("<cube>", "cube name or ID").requiredOption("--port <n>", "the port INSIDE the Cube that sshd listens on (default 22)").description("change the in-Cube port that SSH is forwarded to (not the host port)").action(async (cubeRef, opts, cmd) => {
652
+ const rt = getRuntime(cmd);
653
+ const client = makeClient(rt.res);
654
+ const space = await resolveSpace(rt);
655
+ const id = await resolveCube(client, space, cubeRef);
656
+ const cubePort = Number(opts.port);
657
+ if (!Number.isInteger(cubePort) || cubePort < 1 || cubePort > 65535) throw new Error("--port must be an integer between 1 and 65535");
658
+ await client.cubes.update(space, id, { cubePort });
659
+ if (rt.json) return printJSON({
660
+ id,
661
+ cubePort
662
+ });
663
+ process.stdout.write(`Cube ${id}: SSH now forwarded to in-Cube port ${cubePort}.\n` + (cubePort === 22 ? "" : `Warning: sshd inside the Cube must be listening on ${cubePort}, or SSH will stop working. The default is 22.
664
+ `));
665
+ });
701
666
  }
702
667
  function cubesCommand() {
703
- const cubes = new Command4("cubes").description("manage Cubes (Firecracker microVMs)");
704
- cubes.addCommand(listCmd());
705
- cubes.addCommand(getCmd());
706
- cubes.addCommand(createCmd());
707
- cubes.addCommand(
708
- actionCmd("power-off", "Powering off", (c, s, id) => c.powerOff(s, id))
709
- );
710
- cubes.addCommand(actionCmd("wake", "Starting", (c, s, id) => c.wake(s, id)));
711
- cubes.addCommand(actionCmd("delete", "Deleting", (c, s, id) => c.delete(s, id)));
712
- cubes.addCommand(restartCmd());
713
- cubes.addCommand(sshPortCmd());
714
- return cubes;
668
+ const cubes = new Command("cubes").description("manage Cubes (Firecracker microVMs)");
669
+ cubes.addCommand(listCmd());
670
+ cubes.addCommand(getCmd());
671
+ cubes.addCommand(createCmd());
672
+ cubes.addCommand(actionCmd("power-off", "Powering off", (c, s, id) => c.powerOff(s, id)));
673
+ cubes.addCommand(actionCmd("wake", "Starting", (c, s, id) => c.wake(s, id)));
674
+ cubes.addCommand(actionCmd("delete", "Deleting", (c, s, id) => c.delete(s, id)));
675
+ cubes.addCommand(restartCmd());
676
+ cubes.addCommand(sshPortCmd());
677
+ return cubes;
715
678
  }
716
- var rootListCommand = listCmd;
717
- var rootGetCommand = getCmd;
718
-
719
- // src/commands/domains.ts
720
- import { Command as Command5 } from "commander";
679
+ const rootListCommand = listCmd;
680
+ const rootGetCommand = getCmd;
681
+ //#endregion
682
+ //#region src/commands/domains.ts
683
+ /**
684
+ * Narrow a `--origin-scheme` / positional scheme to the two the API accepts.
685
+ * Returns undefined when the flag was omitted, so callers can leave the field
686
+ * off the request entirely rather than sending an explicit default.
687
+ */
721
688
  function parseOriginScheme(value) {
722
- if (value === void 0 || value === null || value === "") return void 0;
723
- if (value === "http" || value === "https") return value;
724
- throw new Error(`--origin-scheme must be "http" or "https" (got "${String(value)}").`);
689
+ if (value === void 0 || value === null || value === "") return void 0;
690
+ if (value === "http" || value === "https") return value;
691
+ throw new Error(`--origin-scheme must be "http" or "https" (got "${String(value)}").`);
692
+ }
693
+ /** How each live state reads on a terminal. */
694
+ const STATE_LABEL = {
695
+ found: "found",
696
+ missing: "not added yet",
697
+ mismatch: "needs a change",
698
+ unknown: "couldn't check"
699
+ };
700
+ /**
701
+ * Print the DNS records a domain needs.
702
+ *
703
+ * Both the host AND the value, because a record cannot be created from the
704
+ * value alone — which is what made the old guidance impossible to act on.
705
+ */
706
+ function printRecords(records, withState = false) {
707
+ if (records.length === 0) return;
708
+ process.stdout.write("\nDNS records to publish:\n");
709
+ for (const r of records) {
710
+ const state = withState && r.state ? ` [${STATE_LABEL[r.state] ?? r.state}]` : "";
711
+ process.stdout.write(`\n ${r.type.padEnd(5)} ${r.host}${state}\n`);
712
+ process.stdout.write(` -> ${r.value}\n`);
713
+ if (r.mustBeGrey) process.stdout.write(" On Cloudflare: DNS only (grey cloud)\n");
714
+ if (withState && r.detail && r.state !== "found") process.stdout.write(` ${r.detail}\n`);
715
+ }
725
716
  }
717
+ /** `krova domains` — manage a Cube's custom domains. */
726
718
  function domainsCommand() {
727
- const cmd = new Command5("domains").description("manage a Cube's custom domains");
728
- cmd.command("list").argument("<cube>", "cube name or ID").description("list the custom domains attached to a Cube").action(async (cubeRef, _opts, c) => {
729
- const rt = getRuntime(c);
730
- const client = makeClient(rt.res);
731
- const space = await resolveSpace(rt);
732
- const id = await resolveCube(client, space, cubeRef);
733
- const domains = await client.domains.list(space, id);
734
- if (rt.json) return printJSON(domains);
735
- printTable(
736
- ["ID", "DOMAIN", "PORT", "STATUS"],
737
- domains.map((d) => [d.id, d.domain, String(d.port ?? ""), d.status])
738
- );
739
- });
740
- cmd.command("add").argument("<cube>", "cube name or ID").requiredOption("--domain <domain>", "the domain name to attach").requiredOption("--port <n>", "the in-Cube port to route to").option("--origin-scheme <scheme>", "transport the edge uses to reach the Cube: http (default) or https when the Cube terminates TLS itself").description("attach a custom domain to a Cube").action(async (cubeRef, opts, c) => {
741
- const rt = getRuntime(c);
742
- const client = makeClient(rt.res);
743
- const space = await resolveSpace(rt);
744
- const id = await resolveCube(client, space, cubeRef);
745
- const port = Number(opts.port);
746
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
747
- throw new Error(`--port must be a valid port (got "${opts.port}").`);
748
- }
749
- const originScheme = parseOriginScheme(opts.originScheme);
750
- const domain = await client.domains.create(space, id, {
751
- domain: opts.domain,
752
- port,
753
- ...originScheme ? { originScheme } : {}
754
- });
755
- if (rt.json) return printJSON(domain);
756
- process.stdout.write(`Attached ${domain.domain} (${domain.id}) \u2014 status ${domain.status}
757
- `);
758
- });
759
- cmd.command("set-origin").argument("<cube>", "cube name or ID").argument("<domain-id>", "the domain mapping ID (see `krova domains list`)").argument("<scheme>", "http or https").description("set the transport the edge uses to reach the Cube").action(async (cubeRef, mappingId, scheme, _opts, c) => {
760
- const rt = getRuntime(c);
761
- const client = makeClient(rt.res);
762
- const space = await resolveSpace(rt);
763
- const id = await resolveCube(client, space, cubeRef);
764
- const originScheme = parseOriginScheme(scheme);
765
- if (!originScheme) {
766
- throw new Error(`scheme must be "http" or "https" (got "${scheme}").`);
767
- }
768
- const domain = await client.domains.update(space, id, mappingId, { originScheme });
769
- if (rt.json) return printJSON(domain);
770
- process.stdout.write(`${domain.domain} now reached over ${originScheme}
771
- `);
772
- });
773
- cmd.command("rm").argument("<cube>", "cube name or ID").argument("<domain-id>", "the domain mapping ID (see `krova domains list`)").description("detach a custom domain from a Cube").action(async (cubeRef, mappingId, _opts, c) => {
774
- const rt = getRuntime(c);
775
- const client = makeClient(rt.res);
776
- const space = await resolveSpace(rt);
777
- const id = await resolveCube(client, space, cubeRef);
778
- await client.domains.delete(space, id, mappingId);
779
- if (rt.json) return printJSON({ id: mappingId, result: "detached" });
780
- process.stdout.write(`Detached domain ${mappingId}
781
- `);
782
- });
783
- return cmd;
719
+ const cmd = new Command("domains").description("manage a Cube's custom domains");
720
+ cmd.command("list").argument("<cube>", "cube name or ID").description("list the custom domains attached to a Cube").action(async (cubeRef, _opts, c) => {
721
+ const rt = getRuntime(c);
722
+ const client = makeClient(rt.res);
723
+ const space = await resolveSpace(rt);
724
+ const id = await resolveCube(client, space, cubeRef);
725
+ const domains = await client.domains.list(space, id);
726
+ if (rt.json) return printJSON(domains);
727
+ printTable([
728
+ "ID",
729
+ "DOMAIN",
730
+ "PORT",
731
+ "STATUS"
732
+ ], domains.map((d) => [
733
+ d.id,
734
+ d.domain,
735
+ String(d.port ?? ""),
736
+ d.status
737
+ ]));
738
+ });
739
+ cmd.command("add").argument("<cube>", "cube name or ID").requiredOption("--domain <domain>", "the domain name to attach").requiredOption("--port <n>", "the in-Cube port to route to").option("--origin-scheme <scheme>", "transport the edge uses to reach the Cube: http (default) or https when the Cube terminates TLS itself").description("attach a custom domain to a Cube").action(async (cubeRef, opts, c) => {
740
+ const rt = getRuntime(c);
741
+ const client = makeClient(rt.res);
742
+ const space = await resolveSpace(rt);
743
+ const id = await resolveCube(client, space, cubeRef);
744
+ const port = Number(opts.port);
745
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`--port must be a valid port (got "${opts.port}").`);
746
+ const originScheme = parseOriginScheme(opts.originScheme);
747
+ const { domain, records } = await client.domains.create(space, id, {
748
+ domain: opts.domain,
749
+ port,
750
+ ...originScheme ? { originScheme } : {}
751
+ });
752
+ if (rt.json) return printJSON({
753
+ domain,
754
+ records
755
+ });
756
+ process.stdout.write(`Attached ${domain.domain} (${domain.id}) — status ${domain.status}\n`);
757
+ printRecords(records);
758
+ process.stdout.write(`\nAfter publishing them: krova domains records ${cubeRef} ${domain.id}\n`);
759
+ });
760
+ cmd.command("records").argument("<cube>", "cube name or ID").argument("<domain-id>", "the domain mapping ID (see `krova domains list`)").description("show the DNS records a domain needs, and whether they resolve yet").action(async (cubeRef, mappingId, _opts, c) => {
761
+ const rt = getRuntime(c);
762
+ const client = makeClient(rt.res);
763
+ const space = await resolveSpace(rt);
764
+ const id = await resolveCube(client, space, cubeRef);
765
+ const out = await client.domains.records(space, id, mappingId);
766
+ if (rt.json) return printJSON(out);
767
+ process.stdout.write(`${out.domain} ${out.summary.found} of ${out.summary.total} records found\n`);
768
+ printRecords(out.records, true);
769
+ if (!out.summary.complete) process.stdout.write("\nDNS changes can take a few minutes to spread.\n");
770
+ });
771
+ cmd.command("set-origin").argument("<cube>", "cube name or ID").argument("<domain-id>", "the domain mapping ID (see `krova domains list`)").argument("<scheme>", "http or https").description("set the transport the edge uses to reach the Cube").action(async (cubeRef, mappingId, scheme, _opts, c) => {
772
+ const rt = getRuntime(c);
773
+ const client = makeClient(rt.res);
774
+ const space = await resolveSpace(rt);
775
+ const id = await resolveCube(client, space, cubeRef);
776
+ const originScheme = parseOriginScheme(scheme);
777
+ if (!originScheme) throw new Error(`scheme must be "http" or "https" (got "${scheme}").`);
778
+ const domain = await client.domains.update(space, id, mappingId, { originScheme });
779
+ if (rt.json) return printJSON(domain);
780
+ process.stdout.write(`${domain.domain} now reached over ${originScheme}\n`);
781
+ });
782
+ cmd.command("rm").argument("<cube>", "cube name or ID").argument("<domain-id>", "the domain mapping ID (see `krova domains list`)").description("detach a custom domain from a Cube").action(async (cubeRef, mappingId, _opts, c) => {
783
+ const rt = getRuntime(c);
784
+ const client = makeClient(rt.res);
785
+ const space = await resolveSpace(rt);
786
+ const id = await resolveCube(client, space, cubeRef);
787
+ await client.domains.delete(space, id, mappingId);
788
+ if (rt.json) return printJSON({
789
+ id: mappingId,
790
+ result: "detached"
791
+ });
792
+ process.stdout.write(`Detached domain ${mappingId}\n`);
793
+ });
794
+ return cmd;
784
795
  }
785
-
786
- // src/commands/login.ts
787
- import { spawn } from "child_process";
788
- import { Command as Command6 } from "commander";
789
- var BROWSER_UNAVAILABLE = "Browser login isn't enabled on this server yet \u2014 run `krova auth login` to paste an API key from https://krova.cloud";
796
+ //#endregion
797
+ //#region src/commands/login.ts
798
+ const BROWSER_UNAVAILABLE = "Browser login isn't enabled on this server yet — run `krova auth login` to paste an API key from https://krova.cloud";
799
+ /** Only open https URLs, or http to a loopback host. Everything else is unsafe. */
790
800
  function safeBrowserURL(raw) {
791
- let u;
792
- try {
793
- u = new URL(raw);
794
- } catch {
795
- return null;
796
- }
797
- if (u.protocol === "https:" && u.hostname) return raw;
798
- if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
799
- return raw;
800
- }
801
- return null;
801
+ let u;
802
+ try {
803
+ u = new URL(raw);
804
+ } catch {
805
+ return null;
806
+ }
807
+ if (u.protocol === "https:" && u.hostname) return raw;
808
+ if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) return raw;
809
+ return null;
802
810
  }
803
811
  function openBrowser(url) {
804
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
805
- const args = process.platform === "win32" ? ["", url] : [url];
806
- const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: process.platform === "win32" });
807
- child.on("error", () => {
808
- });
809
- child.unref();
812
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
813
+ const args = process.platform === "win32" ? ["", url] : [url];
814
+ const child = spawn(cmd, args, {
815
+ stdio: "ignore",
816
+ detached: true,
817
+ shell: process.platform === "win32"
818
+ });
819
+ child.on("error", () => {});
820
+ child.unref();
810
821
  }
811
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
822
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
812
823
  function loginCommand() {
813
- return new Command6("login").description("log in through your browser (device authorization)").option("--no-browser", "don't open a browser; just print the URL").option("--context <name>", "name for the saved context").action(async (opts, cmd) => {
814
- const rt = getRuntime(cmd);
815
- const baseUrl = rt.res.baseUrl;
816
- const start = await rawRequest({
817
- method: "POST",
818
- baseUrl,
819
- path: "/auth/cli/start",
820
- timeoutMs: rt.timeoutMs
821
- });
822
- if (start.status === 404) throw new Error(BROWSER_UNAVAILABLE);
823
- if (start.status !== 200 || !start.data?.deviceCode) {
824
- throw new Error(`device login failed to start (HTTP ${start.status})`);
825
- }
826
- const s = start.data;
827
- process.stdout.write(`Your verification code is: ${s.userCode}
828
- `);
829
- const target = s.verificationUriComplete || s.verificationUri;
830
- const safe = safeBrowserURL(target);
831
- if (safe && opts.browser !== false) {
832
- process.stdout.write(`Opening ${safe} \u2026
833
- `);
834
- openBrowser(safe);
835
- } else {
836
- process.stdout.write(`Open this URL to approve the login:
837
- ${target}
838
- `);
839
- }
840
- const intervalMs = Math.max((s.interval || 5) * 1e3, 5e3);
841
- const deadline = Date.now() + Math.max(s.expiresIn || 600, 1) * 1e3;
842
- process.stdout.write("Waiting for approval\u2026\n");
843
- for (; ; ) {
844
- if (Date.now() >= deadline) {
845
- throw new Error(
846
- "login timed out: the verification code expired before it was approved"
847
- );
848
- }
849
- await sleep(intervalMs);
850
- const poll = await rawRequest({
851
- method: "POST",
852
- baseUrl,
853
- path: "/auth/cli/poll",
854
- body: { deviceCode: s.deviceCode },
855
- timeoutMs: rt.timeoutMs
856
- });
857
- if (poll.status === 200) {
858
- if (!poll.data?.apiKey) throw new Error("login succeeded but no API key was returned");
859
- const { ctxName, spaceName } = await persistLogin({
860
- apiKey: poll.data.apiKey,
861
- baseUrl,
862
- spaceId: poll.data.spaceId,
863
- ctxName: opts.context,
864
- timeoutMs: rt.timeoutMs
865
- });
866
- process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}
867
- `);
868
- if (spaceName) process.stdout.write(`Space: ${spaceName}
869
- `);
870
- return;
871
- }
872
- if (poll.status === 202 || poll.status === 425 || poll.status === 428) continue;
873
- if (poll.status === 410) {
874
- throw new Error("login expired: request a new code with `krova login`");
875
- }
876
- if (poll.status === 404) throw new Error(BROWSER_UNAVAILABLE);
877
- throw new Error(`login failed while polling (HTTP ${poll.status})`);
878
- }
879
- });
824
+ return new Command("login").description("log in through your browser (device authorization)").option("--no-browser", "don't open a browser; just print the URL").option("--context <name>", "name for the saved context").action(async (opts, cmd) => {
825
+ const rt = getRuntime(cmd);
826
+ const baseUrl = rt.res.baseUrl;
827
+ const start = await rawRequest({
828
+ method: "POST",
829
+ baseUrl,
830
+ path: "/auth/cli/start",
831
+ timeoutMs: rt.timeoutMs
832
+ });
833
+ if (start.status === 404) throw new Error(BROWSER_UNAVAILABLE);
834
+ if (start.status !== 200 || !start.data?.deviceCode) throw new Error(`device login failed to start (HTTP ${start.status})`);
835
+ const s = start.data;
836
+ process.stdout.write(`Your verification code is: ${s.userCode}\n`);
837
+ const target = s.verificationUriComplete || s.verificationUri;
838
+ const safe = safeBrowserURL(target);
839
+ if (safe && opts.browser !== false) {
840
+ process.stdout.write(`Opening ${safe} …\n`);
841
+ openBrowser(safe);
842
+ } else process.stdout.write(`Open this URL to approve the login:\n ${target}\n`);
843
+ const intervalMs = Math.max((s.interval || 5) * 1e3, 5e3);
844
+ const deadline = Date.now() + Math.max(s.expiresIn || 600, 1) * 1e3;
845
+ process.stdout.write("Waiting for approval…\n");
846
+ for (;;) {
847
+ if (Date.now() >= deadline) throw new Error("login timed out: the verification code expired before it was approved");
848
+ await sleep(intervalMs);
849
+ const poll = await rawRequest({
850
+ method: "POST",
851
+ baseUrl,
852
+ path: "/auth/cli/poll",
853
+ body: { deviceCode: s.deviceCode },
854
+ timeoutMs: rt.timeoutMs
855
+ });
856
+ if (poll.status === 200) {
857
+ if (!poll.data?.apiKey) throw new Error("login succeeded but no API key was returned");
858
+ const { ctxName, spaceName } = await persistLogin({
859
+ apiKey: poll.data.apiKey,
860
+ baseUrl,
861
+ spaceId: poll.data.spaceId,
862
+ ctxName: opts.context,
863
+ timeoutMs: rt.timeoutMs
864
+ });
865
+ process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}\n`);
866
+ if (spaceName) process.stdout.write(`Space: ${spaceName}\n`);
867
+ return;
868
+ }
869
+ if (poll.status === 202 || poll.status === 425 || poll.status === 428) continue;
870
+ if (poll.status === 410) throw new Error("login expired: request a new code with `krova login`");
871
+ if (poll.status === 404) throw new Error(BROWSER_UNAVAILABLE);
872
+ throw new Error(`login failed while polling (HTTP ${poll.status})`);
873
+ }
874
+ });
880
875
  }
881
-
882
- // src/commands/snapshots.ts
883
- import { Command as Command7 } from "commander";
876
+ //#endregion
877
+ //#region src/commands/snapshots.ts
878
+ /** `krova snapshots` snapshot and restore a Cube's disk. */
884
879
  function snapshotsCommand() {
885
- const cmd = new Command7("snapshots").description("snapshot and restore a Cube's disk");
886
- cmd.command("list").argument("<cube>", "cube name or ID").description("list a Cube's snapshots").action(async (cubeRef, _opts, c) => {
887
- const rt = getRuntime(c);
888
- const client = makeClient(rt.res);
889
- const space = await resolveSpace(rt);
890
- const id = await resolveCube(client, space, cubeRef);
891
- const snaps = await client.snapshots.list(space, id);
892
- if (rt.json) return printJSON(snaps);
893
- printTable(
894
- ["ID", "NAME", "STATUS", "KIND", "SIZE (BYTES)", "CREATED"],
895
- snaps.map((s) => [
896
- s.id,
897
- s.name,
898
- s.status,
899
- s.kind,
900
- s.sizeBytes == null ? "" : String(s.sizeBytes),
901
- s.createdAt
902
- ])
903
- );
904
- });
905
- cmd.command("create").argument("<cube>", "cube name or ID").option("--name <name>", "a name for the snapshot").description("create a snapshot of a Cube's disk").action(async (cubeRef, opts, c) => {
906
- const rt = getRuntime(c);
907
- const client = makeClient(rt.res);
908
- const space = await resolveSpace(rt);
909
- const id = await resolveCube(client, space, cubeRef);
910
- const snap = await client.snapshots.create(space, id, opts.name ? { name: opts.name } : {});
911
- if (rt.json) return printJSON(snap);
912
- process.stdout.write(`Created snapshot ${snap.id} (${snap.status})
913
- `);
914
- });
915
- cmd.command("rm").argument("<cube>", "cube name or ID").argument("<snapshot-id>", "the snapshot ID (see `krova snapshots list`)").description("delete a snapshot").action(async (cubeRef, snapshotId, _opts, c) => {
916
- const rt = getRuntime(c);
917
- const client = makeClient(rt.res);
918
- const space = await resolveSpace(rt);
919
- const id = await resolveCube(client, space, cubeRef);
920
- await client.snapshots.delete(space, id, snapshotId);
921
- if (rt.json) return printJSON({ id: snapshotId, result: "deleted" });
922
- process.stdout.write(`Deleted snapshot ${snapshotId}
923
- `);
924
- });
925
- cmd.command("restore").argument("<cube>", "cube name or ID").argument("<snapshot-id>", "the snapshot to restore the Cube's disk from").description("restore a Cube's disk from one of its snapshots (replaces the disk)").action(async (cubeRef, snapshotId, _opts, c) => {
926
- const rt = getRuntime(c);
927
- const client = makeClient(rt.res);
928
- const space = await resolveSpace(rt);
929
- const id = await resolveCube(client, space, cubeRef);
930
- await client.cubes.restore(space, id, snapshotId);
931
- if (rt.json) return printJSON({ id, snapshotId, result: "restore enqueued" });
932
- process.stdout.write(`Restore of cube ${id} from ${snapshotId} enqueued
933
- `);
934
- });
935
- return cmd;
880
+ const cmd = new Command("snapshots").description("snapshot and restore a Cube's disk");
881
+ cmd.command("list").argument("<cube>", "cube name or ID").description("list a Cube's snapshots").action(async (cubeRef, _opts, c) => {
882
+ const rt = getRuntime(c);
883
+ const client = makeClient(rt.res);
884
+ const space = await resolveSpace(rt);
885
+ const id = await resolveCube(client, space, cubeRef);
886
+ const snaps = await client.snapshots.list(space, id);
887
+ if (rt.json) return printJSON(snaps);
888
+ printTable([
889
+ "ID",
890
+ "NAME",
891
+ "STATUS",
892
+ "KIND",
893
+ "SIZE (BYTES)",
894
+ "CREATED"
895
+ ], snaps.map((s) => [
896
+ s.id,
897
+ s.name,
898
+ s.status,
899
+ s.kind,
900
+ s.sizeBytes == null ? "" : String(s.sizeBytes),
901
+ s.createdAt
902
+ ]));
903
+ });
904
+ cmd.command("create").argument("<cube>", "cube name or ID").option("--name <name>", "a name for the snapshot").description("create a snapshot of a Cube's disk").action(async (cubeRef, opts, c) => {
905
+ const rt = getRuntime(c);
906
+ const client = makeClient(rt.res);
907
+ const space = await resolveSpace(rt);
908
+ const id = await resolveCube(client, space, cubeRef);
909
+ const snap = await client.snapshots.create(space, id, opts.name ? { name: opts.name } : {});
910
+ if (rt.json) return printJSON(snap);
911
+ process.stdout.write(`Created snapshot ${snap.id} (${snap.status})\n`);
912
+ });
913
+ cmd.command("rm").argument("<cube>", "cube name or ID").argument("<snapshot-id>", "the snapshot ID (see `krova snapshots list`)").description("delete a snapshot").action(async (cubeRef, snapshotId, _opts, c) => {
914
+ const rt = getRuntime(c);
915
+ const client = makeClient(rt.res);
916
+ const space = await resolveSpace(rt);
917
+ const id = await resolveCube(client, space, cubeRef);
918
+ await client.snapshots.delete(space, id, snapshotId);
919
+ if (rt.json) return printJSON({
920
+ id: snapshotId,
921
+ result: "deleted"
922
+ });
923
+ process.stdout.write(`Deleted snapshot ${snapshotId}\n`);
924
+ });
925
+ cmd.command("restore").argument("<cube>", "cube name or ID").argument("<snapshot-id>", "the snapshot to restore the Cube's disk from").description("restore a Cube's disk from one of its snapshots (replaces the disk)").action(async (cubeRef, snapshotId, _opts, c) => {
926
+ const rt = getRuntime(c);
927
+ const client = makeClient(rt.res);
928
+ const space = await resolveSpace(rt);
929
+ const id = await resolveCube(client, space, cubeRef);
930
+ await client.cubes.restore(space, id, snapshotId);
931
+ if (rt.json) return printJSON({
932
+ id,
933
+ snapshotId,
934
+ result: "restore enqueued"
935
+ });
936
+ process.stdout.write(`Restore of cube ${id} from ${snapshotId} enqueued\n`);
937
+ });
938
+ return cmd;
936
939
  }
937
-
938
- // src/commands/ssh.ts
939
- import { Command as Command8 } from "commander";
940
-
941
- // src/lib/ssh.ts
942
- import { spawn as spawn2 } from "child_process";
943
- import { appendFileSync, chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
944
- import { dirname as dirname2 } from "path";
945
- import { join as join2 } from "path";
946
- var HOST_BANNED = new Set(
947
- "@/\\'\"`$;&|<>(){}*?!#=,".split("")
948
- );
949
- var USER_BANNED = new Set(
950
- "@/\\'\"`$;&|<>(){}*?!#,".split("")
951
- );
940
+ //#endregion
941
+ //#region src/lib/ssh.ts
942
+ const HOST_BANNED = new Set("@/\\'\"`$;&|<>(){}*?!#=,".split(""));
943
+ const USER_BANNED = new Set("@/\\'\"`$;&|<>(){}*?!#,".split(""));
952
944
  function validateSSHHost(host) {
953
- const h = host.trim();
954
- if (!h) throw new Error("empty host");
955
- if (h.startsWith("-")) throw new Error("host must not start with '-'");
956
- for (const r of h) {
957
- const code = r.codePointAt(0) ?? 0;
958
- if (code <= 32 || code === 127) {
959
- throw new Error("host contains whitespace or control characters");
960
- }
961
- if (HOST_BANNED.has(r)) {
962
- throw new Error(`host contains a disallowed character ${JSON.stringify(r)}`);
963
- }
964
- }
945
+ const h = host.trim();
946
+ if (!h) throw new Error("empty host");
947
+ if (h.startsWith("-")) throw new Error("host must not start with '-'");
948
+ for (const r of h) {
949
+ const code = r.codePointAt(0) ?? 0;
950
+ if (code <= 32 || code === 127) throw new Error("host contains whitespace or control characters");
951
+ if (HOST_BANNED.has(r)) throw new Error(`host contains a disallowed character ${JSON.stringify(r)}`);
952
+ }
965
953
  }
966
954
  function validateSSHUser(user) {
967
- const u = user.trim();
968
- if (!u) return;
969
- if (u.startsWith("-")) throw new Error("user must not start with '-'");
970
- for (const r of u) {
971
- const code = r.codePointAt(0) ?? 0;
972
- if (code <= 32 || code === 127) {
973
- throw new Error("user contains whitespace or control characters");
974
- }
975
- if (USER_BANNED.has(r)) {
976
- throw new Error(`user contains a disallowed character ${JSON.stringify(r)}`);
977
- }
978
- }
955
+ const u = user.trim();
956
+ if (!u) return;
957
+ if (u.startsWith("-")) throw new Error("user must not start with '-'");
958
+ for (const r of u) {
959
+ const code = r.codePointAt(0) ?? 0;
960
+ if (code <= 32 || code === 127) throw new Error("user contains whitespace or control characters");
961
+ if (USER_BANNED.has(r)) throw new Error(`user contains a disallowed character ${JSON.stringify(r)}`);
962
+ }
979
963
  }
964
+ /** known_hosts host field: "[host]:port" when the port isn't the default 22. */
980
965
  function knownHostsHost(host, port) {
981
- if (port > 0 && port !== 22) return `[${host}]:${port}`;
982
- return host;
966
+ if (port > 0 && port !== 22) return `[${host}]:${port}`;
967
+ return host;
983
968
  }
984
969
  function knownHostsPath() {
985
- return join2(configDir(), "known_hosts");
970
+ return join(configDir(), "known_hosts");
986
971
  }
972
+ /** Pin the cube's host keys to ~/.config/krova/known_hosts (0600), pruning any
973
+ * stale line for the same host:port (rebuilt cubes can reuse an IP+DNAT port). */
987
974
  function writeKnownHosts(info) {
988
- const path = knownHostsPath();
989
- mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
990
- const field = knownHostsHost(info.host, info.port);
991
- let existing = "";
992
- try {
993
- existing = readFileSync2(path, "utf8");
994
- } catch {
995
- existing = "";
996
- }
997
- const kept = existing.split("\n").filter((line) => line.trim() && line.split(/\s+/)[0] !== field);
998
- writeFileSync2(path, kept.length ? `${kept.join("\n")}
999
- ` : "", { mode: 384 });
1000
- chmodSync2(path, 384);
1001
- for (const k of info.hostKeys) {
1002
- appendFileSync(path, `${field} ${k.type} ${k.key}
1003
- `);
1004
- }
1005
- return path;
975
+ const path = knownHostsPath();
976
+ mkdirSync(dirname(path), {
977
+ recursive: true,
978
+ mode: 448
979
+ });
980
+ const field = knownHostsHost(info.host, info.port);
981
+ let existing = "";
982
+ try {
983
+ existing = readFileSync(path, "utf8");
984
+ } catch {
985
+ existing = "";
986
+ }
987
+ const kept = existing.split("\n").filter((line) => line.trim() && line.split(/\s+/)[0] !== field);
988
+ writeFileSync(path, kept.length ? `${kept.join("\n")}\n` : "", { mode: 384 });
989
+ chmodSync(path, 384);
990
+ for (const k of info.hostKeys) appendFileSync(path, `${field} ${k.type} ${k.key}\n`);
991
+ return path;
1006
992
  }
993
+ /** Build the ssh argv. `--` is placed immediately before the destination —
994
+ * the primary option-injection guard (with the validators above). */
1007
995
  function buildSSHArgs(info, o) {
1008
- const args = [];
1009
- if (o.knownHosts) {
1010
- args.push("-o", `UserKnownHostsFile=${o.knownHosts}`, "-o", "StrictHostKeyChecking=yes");
1011
- }
1012
- if ((o.identity ?? "").trim()) args.push("-i", o.identity);
1013
- if (info.port > 0) args.push("-p", String(info.port));
1014
- for (const l of o.localFwd ?? []) if (l.trim()) args.push("-L", l);
1015
- for (const r of o.remoteFwd ?? []) if (r.trim()) args.push("-R", r);
1016
- const user = info.user.trim();
1017
- const target = user ? `${user}@${info.host}` : info.host;
1018
- args.push("--", target);
1019
- args.push(...o.remoteCmd ?? []);
1020
- return args;
996
+ const args = [];
997
+ if (o.knownHosts) args.push("-o", `UserKnownHostsFile=${o.knownHosts}`, "-o", "StrictHostKeyChecking=yes");
998
+ if ((o.identity ?? "").trim()) args.push("-i", o.identity);
999
+ if (info.port > 0) args.push("-p", String(info.port));
1000
+ for (const l of o.localFwd ?? []) if (l.trim()) args.push("-L", l);
1001
+ for (const r of o.remoteFwd ?? []) if (r.trim()) args.push("-R", r);
1002
+ const user = info.user.trim();
1003
+ const target = user ? `${user}@${info.host}` : info.host;
1004
+ args.push("--", target);
1005
+ args.push(...o.remoteCmd ?? []);
1006
+ return args;
1021
1007
  }
1008
+ /** Exec system ssh, inheriting stdio (interactive unless a remote cmd is given). */
1022
1009
  function execSSH(args) {
1023
- return new Promise((resolve2, reject) => {
1024
- const child = spawn2("ssh", args, { stdio: "inherit" });
1025
- child.on("error", reject);
1026
- child.on("exit", (code) => resolve2(code ?? 0));
1027
- });
1010
+ return new Promise((resolve, reject) => {
1011
+ const child = spawn("ssh", args, { stdio: "inherit" });
1012
+ child.on("error", reject);
1013
+ child.on("exit", (code) => resolve(code ?? 0));
1014
+ });
1028
1015
  }
1029
-
1030
- // src/commands/ssh.ts
1031
- var collect = (v, acc) => {
1032
- acc.push(v);
1033
- return acc;
1016
+ //#endregion
1017
+ //#region src/commands/ssh.ts
1018
+ const collect = (v, acc) => {
1019
+ acc.push(v);
1020
+ return acc;
1034
1021
  };
1035
1022
  function sshCommand() {
1036
- return new Command8("ssh").argument("<cube>", "cube name or ID").argument("[command...]", "command to run non-interactively (after --)").description("open an SSH session to a Cube (by name or ID)").option("-i, --identity <file>", "SSH identity (private key) file, passed to ssh -i").option("-L, --local-forward <spec>", "local port forward, passed to ssh -L (repeatable)", collect, []).option("-R, --remote-forward <spec>", "remote port forward, passed to ssh -R (repeatable)", collect, []).action(async (cubeRef, command, opts, cmd) => {
1037
- const rt = getRuntime(cmd);
1038
- const client = makeClient(rt.res);
1039
- const space = await resolveSpace(rt);
1040
- const id = await resolveCube(client, space, cubeRef);
1041
- const { status, data } = await rawRequest({
1042
- method: "GET",
1043
- baseUrl: rt.res.baseUrl,
1044
- path: `/spaces/${space}/cubes/${id}/ssh`,
1045
- apiKey: rt.res.apiKey,
1046
- timeoutMs: rt.timeoutMs
1047
- });
1048
- if (status === 404) {
1049
- throw new Error(
1050
- `SSH info isn't available on this server yet \u2014 try \`krova cubes get ${cubeRef}\` for the Cube's IP and ssh manually`
1051
- );
1052
- }
1053
- if (status === 401 || status === 403) {
1054
- throw new Error(`SSH info request was rejected (HTTP ${status})`);
1055
- }
1056
- if (status !== 200 || !data?.host) {
1057
- throw new Error(`couldn't fetch SSH info (HTTP ${status})`);
1058
- }
1059
- const info = {
1060
- host: data.host,
1061
- port: data.port ?? 0,
1062
- user: data.user ?? "",
1063
- hostKeys: data.hostKeys ?? []
1064
- };
1065
- validateSSHHost(info.host);
1066
- validateSSHUser(info.user);
1067
- if (info.port < 0 || info.port > 65535) throw new Error("invalid ssh port");
1068
- let knownHosts = "";
1069
- if (info.hostKeys.length) {
1070
- knownHosts = writeKnownHosts(info);
1071
- } else {
1072
- process.stderr.write(
1073
- "note: this server didn't provide host keys \u2014 using ssh trust-on-first-use (host-key checking stays on).\n"
1074
- );
1075
- }
1076
- const args = buildSSHArgs(info, {
1077
- identity: opts.identity,
1078
- localFwd: opts.localForward,
1079
- remoteFwd: opts.remoteForward,
1080
- knownHosts,
1081
- remoteCmd: command
1082
- });
1083
- process.exitCode = await execSSH(args);
1084
- });
1023
+ return new Command("ssh").argument("<cube>", "cube name or ID").argument("[command...]", "command to run non-interactively (after --)").description("open an SSH session to a Cube (by name or ID)").option("-i, --identity <file>", "SSH identity (private key) file, passed to ssh -i").option("-L, --local-forward <spec>", "local port forward, passed to ssh -L (repeatable)", collect, []).option("-R, --remote-forward <spec>", "remote port forward, passed to ssh -R (repeatable)", collect, []).action(async (cubeRef, command, opts, cmd) => {
1024
+ const rt = getRuntime(cmd);
1025
+ const client = makeClient(rt.res);
1026
+ const space = await resolveSpace(rt);
1027
+ const id = await resolveCube(client, space, cubeRef);
1028
+ const { status, data } = await rawRequest({
1029
+ method: "GET",
1030
+ baseUrl: rt.res.baseUrl,
1031
+ path: `/spaces/${space}/cubes/${id}/ssh`,
1032
+ apiKey: rt.res.apiKey,
1033
+ timeoutMs: rt.timeoutMs
1034
+ });
1035
+ if (status === 404) throw new Error(`SSH info isn't available on this server yet — try \`krova cubes get ${cubeRef}\` for the Cube's IP and ssh manually`);
1036
+ if (status === 401 || status === 403) throw new Error(`SSH info request was rejected (HTTP ${status})`);
1037
+ if (status !== 200 || !data?.host) throw new Error(`couldn't fetch SSH info (HTTP ${status})`);
1038
+ const info = {
1039
+ host: data.host,
1040
+ port: data.port ?? 0,
1041
+ user: data.user ?? "",
1042
+ hostKeys: data.hostKeys ?? []
1043
+ };
1044
+ validateSSHHost(info.host);
1045
+ validateSSHUser(info.user);
1046
+ if (info.port < 0 || info.port > 65535) throw new Error("invalid ssh port");
1047
+ let knownHosts = "";
1048
+ if (info.hostKeys.length) knownHosts = writeKnownHosts(info);
1049
+ else process.stderr.write("note: this server didn't provide host keys — using ssh trust-on-first-use (host-key checking stays on).\n");
1050
+ const args = buildSSHArgs(info, {
1051
+ identity: opts.identity,
1052
+ localFwd: opts.localForward,
1053
+ remoteFwd: opts.remoteForward,
1054
+ knownHosts,
1055
+ remoteCmd: command
1056
+ });
1057
+ process.exitCode = await execSSH(args);
1058
+ });
1085
1059
  }
1086
-
1087
- // src/commands/tcp.ts
1088
- import { Command as Command9 } from "commander";
1060
+ //#endregion
1061
+ //#region src/commands/tcp.ts
1062
+ /** `krova tcp` manage a Cube's TCP port mappings. */
1089
1063
  function tcpCommand() {
1090
- const cmd = new Command9("tcp").description("manage a Cube's TCP port mappings");
1091
- cmd.command("list").argument("<cube>", "cube name or ID").description("list a Cube's TCP port mappings").action(async (cubeRef, _opts, c) => {
1092
- const rt = getRuntime(c);
1093
- const client = makeClient(rt.res);
1094
- const space = await resolveSpace(rt);
1095
- const id = await resolveCube(client, space, cubeRef);
1096
- const maps = await client.tcpMappings.list(space, id);
1097
- if (rt.json) return printJSON(maps);
1098
- printTable(
1099
- ["ID", "CUBE PORT", "HOST PORT", "LABEL", "STATUS", "SSH"],
1100
- maps.map((m) => [
1101
- m.id,
1102
- String(m.cubePort),
1103
- String(m.hostPort),
1104
- m.label ?? "",
1105
- m.status,
1106
- m.isSsh ? "yes" : "no"
1107
- ])
1108
- );
1109
- });
1110
- cmd.command("add").argument("<cube>", "cube name or ID").requiredOption("--port <n>", "the in-Cube port to expose").option(
1111
- "--whitelist <cidr>",
1112
- "restrict access to this IP/CIDR (repeatable)",
1113
- (val, acc) => {
1114
- acc.push(val);
1115
- return acc;
1116
- },
1117
- []
1118
- ).description("expose a Cube TCP port on the host, optionally IP-restricted").action(async (cubeRef, opts, c) => {
1119
- const rt = getRuntime(c);
1120
- const client = makeClient(rt.res);
1121
- const space = await resolveSpace(rt);
1122
- const id = await resolveCube(client, space, cubeRef);
1123
- const cubePort = Number(opts.port);
1124
- if (!Number.isInteger(cubePort) || cubePort <= 0 || cubePort > 65535) {
1125
- throw new Error(`--port must be a valid port (got "${opts.port}").`);
1126
- }
1127
- const whitelist = opts.whitelist;
1128
- const mapping = await client.tcpMappings.create(space, id, {
1129
- cubePort,
1130
- ...whitelist.length ? { whitelistIps: whitelist } : {}
1131
- });
1132
- if (rt.json) return printJSON(mapping);
1133
- process.stdout.write(
1134
- `Mapped cube port ${mapping.cubePort} \u2192 host port ${mapping.hostPort} (${mapping.id})
1135
- `
1136
- );
1137
- });
1138
- cmd.command("rm").argument("<cube>", "cube name or ID").argument("<mapping-id>", "the mapping ID (see `krova tcp list`)").description("remove a TCP port mapping").action(async (cubeRef, mappingId, _opts, c) => {
1139
- const rt = getRuntime(c);
1140
- const client = makeClient(rt.res);
1141
- const space = await resolveSpace(rt);
1142
- const id = await resolveCube(client, space, cubeRef);
1143
- await client.tcpMappings.delete(space, id, mappingId);
1144
- if (rt.json) return printJSON({ id: mappingId, result: "removed" });
1145
- process.stdout.write(`Removed TCP mapping ${mappingId}
1146
- `);
1147
- });
1148
- return cmd;
1064
+ const cmd = new Command("tcp").description("manage a Cube's TCP port mappings");
1065
+ cmd.command("list").argument("<cube>", "cube name or ID").description("list a Cube's TCP port mappings").action(async (cubeRef, _opts, c) => {
1066
+ const rt = getRuntime(c);
1067
+ const client = makeClient(rt.res);
1068
+ const space = await resolveSpace(rt);
1069
+ const id = await resolveCube(client, space, cubeRef);
1070
+ const maps = await client.tcpMappings.list(space, id);
1071
+ if (rt.json) return printJSON(maps);
1072
+ printTable([
1073
+ "ID",
1074
+ "CUBE PORT",
1075
+ "HOST PORT",
1076
+ "LABEL",
1077
+ "STATUS",
1078
+ "SSH"
1079
+ ], maps.map((m) => [
1080
+ m.id,
1081
+ String(m.cubePort),
1082
+ String(m.hostPort),
1083
+ m.label ?? "",
1084
+ m.status,
1085
+ m.isSsh ? "yes" : "no"
1086
+ ]));
1087
+ });
1088
+ cmd.command("add").argument("<cube>", "cube name or ID").requiredOption("--port <n>", "the in-Cube port to expose").option("--whitelist <cidr>", "restrict access to this IP/CIDR (repeatable)", (val, acc) => {
1089
+ acc.push(val);
1090
+ return acc;
1091
+ }, []).description("expose a Cube TCP port on the host, optionally IP-restricted").action(async (cubeRef, opts, c) => {
1092
+ const rt = getRuntime(c);
1093
+ const client = makeClient(rt.res);
1094
+ const space = await resolveSpace(rt);
1095
+ const id = await resolveCube(client, space, cubeRef);
1096
+ const cubePort = Number(opts.port);
1097
+ if (!Number.isInteger(cubePort) || cubePort <= 0 || cubePort > 65535) throw new Error(`--port must be a valid port (got "${opts.port}").`);
1098
+ const whitelist = opts.whitelist;
1099
+ const mapping = await client.tcpMappings.create(space, id, {
1100
+ cubePort,
1101
+ ...whitelist.length ? { whitelistIps: whitelist } : {}
1102
+ });
1103
+ if (rt.json) return printJSON(mapping);
1104
+ process.stdout.write(`Mapped cube port ${mapping.cubePort} host port ${mapping.hostPort} (${mapping.id})\n`);
1105
+ });
1106
+ cmd.command("rm").argument("<cube>", "cube name or ID").argument("<mapping-id>", "the mapping ID (see `krova tcp list`)").description("remove a TCP port mapping").action(async (cubeRef, mappingId, _opts, c) => {
1107
+ const rt = getRuntime(c);
1108
+ const client = makeClient(rt.res);
1109
+ const space = await resolveSpace(rt);
1110
+ const id = await resolveCube(client, space, cubeRef);
1111
+ await client.tcpMappings.delete(space, id, mappingId);
1112
+ if (rt.json) return printJSON({
1113
+ id: mappingId,
1114
+ result: "removed"
1115
+ });
1116
+ process.stdout.write(`Removed TCP mapping ${mappingId}\n`);
1117
+ });
1118
+ return cmd;
1149
1119
  }
1150
-
1151
- // src/commands/version.ts
1152
- import { createRequire } from "module";
1153
- import { Command as Command10 } from "commander";
1154
- var require2 = createRequire(import.meta.url);
1155
- var pkg = require2("../package.json");
1156
- var CLI_VERSION = pkg.version;
1120
+ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
1157
1121
  function versionCommand() {
1158
- return new Command10("version").description("print the krova CLI version").action((_opts, cmd) => {
1159
- const rt = getRuntime(cmd);
1160
- const info = {
1161
- version: CLI_VERSION,
1162
- node: process.version,
1163
- platform: `${process.platform}/${process.arch}`
1164
- };
1165
- if (rt.json) return printJSON(info);
1166
- process.stdout.write(`krova ${info.version} (node ${info.node}, ${info.platform})
1167
- `);
1168
- });
1122
+ return new Command("version").description("print the krova CLI version").action((_opts, cmd) => {
1123
+ const rt = getRuntime(cmd);
1124
+ const info = {
1125
+ version: CLI_VERSION,
1126
+ node: process.version,
1127
+ platform: `${process.platform}/${process.arch}`
1128
+ };
1129
+ if (rt.json) return printJSON(info);
1130
+ process.stdout.write(`krova ${info.version} (node ${info.node}, ${info.platform})\n`);
1131
+ });
1169
1132
  }
1170
-
1171
- // src/commands/webhooks.ts
1172
- import { createServer } from "http";
1173
- import { verifyKrovaWebhookOrThrow } from "@krovacloud/webhook";
1174
- import { Command as Command11 } from "commander";
1175
- var DEFAULT_LISTEN_HOST = "127.0.0.1";
1176
- var DEFAULT_LISTEN_PORT = 4666;
1133
+ //#endregion
1134
+ //#region src/commands/webhooks.ts
1135
+ const DEFAULT_LISTEN_HOST = "127.0.0.1";
1136
+ const DEFAULT_LISTEN_PORT = 4666;
1137
+ /**
1138
+ * Parse a `--addr` value into `{ host, port }`. Handles `host:port`, a bare host
1139
+ * (e.g. `localhost`), a bare port, bracketed IPv6 (`[::1]:4666`), and bare IPv6
1140
+ * (`::1`). Exported for testing.
1141
+ */
1177
1142
  function parseListenAddr(addr) {
1178
- const s = (addr ?? "").trim();
1179
- const bracket = s.match(/^\[([^\]]+)\](?::(\d+))?$/);
1180
- if (bracket) {
1181
- return { host: bracket[1], port: bracket[2] ? Number(bracket[2]) : DEFAULT_LISTEN_PORT };
1182
- }
1183
- if ((s.match(/:/g)?.length ?? 0) >= 2) {
1184
- return { host: s, port: DEFAULT_LISTEN_PORT };
1185
- }
1186
- const i = s.lastIndexOf(":");
1187
- if (i > 0) {
1188
- const port = Number(s.slice(i + 1));
1189
- return {
1190
- host: s.slice(0, i),
1191
- port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_LISTEN_PORT
1192
- };
1193
- }
1194
- if (/^\d+$/.test(s)) {
1195
- const port = Number(s);
1196
- return { host: DEFAULT_LISTEN_HOST, port: port > 0 && port <= 65535 ? port : DEFAULT_LISTEN_PORT };
1197
- }
1198
- return { host: s || DEFAULT_LISTEN_HOST, port: DEFAULT_LISTEN_PORT };
1143
+ const s = (addr ?? "").trim();
1144
+ const bracket = s.match(/^\[([^\]]+)\](?::(\d+))?$/);
1145
+ if (bracket) return {
1146
+ host: bracket[1],
1147
+ port: bracket[2] ? Number(bracket[2]) : DEFAULT_LISTEN_PORT
1148
+ };
1149
+ if ((s.match(/:/g)?.length ?? 0) >= 2) return {
1150
+ host: s,
1151
+ port: DEFAULT_LISTEN_PORT
1152
+ };
1153
+ const i = s.lastIndexOf(":");
1154
+ if (i > 0) {
1155
+ const port = Number(s.slice(i + 1));
1156
+ return {
1157
+ host: s.slice(0, i),
1158
+ port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_LISTEN_PORT
1159
+ };
1160
+ }
1161
+ if (/^\d+$/.test(s)) {
1162
+ const port = Number(s);
1163
+ return {
1164
+ host: DEFAULT_LISTEN_HOST,
1165
+ port: port > 0 && port <= 65535 ? port : DEFAULT_LISTEN_PORT
1166
+ };
1167
+ }
1168
+ return {
1169
+ host: s || DEFAULT_LISTEN_HOST,
1170
+ port: DEFAULT_LISTEN_PORT
1171
+ };
1199
1172
  }
1200
1173
  function webhooksCommand() {
1201
- const wh = new Command11("webhooks").description("developer tools for Krova Cloud webhooks");
1202
- wh.command("listen").description("run a local server that verifies + prints incoming webhook deliveries").option("--addr <host:port>", "address to listen on", "127.0.0.1:4666").option("--path <path>", "path to accept POSTs on", "/").option("--secret <secret>", "signing secret (or the KROVA_WEBHOOK_SECRET env var)").action((opts, cmd) => {
1203
- const rt = getRuntime(cmd);
1204
- const secret = opts.secret || process.env.KROVA_WEBHOOK_SECRET || "";
1205
- if (!secret) {
1206
- throw new Error(
1207
- "a signing secret is required: pass --secret or set KROVA_WEBHOOK_SECRET"
1208
- );
1209
- }
1210
- const { host, port } = parseListenAddr(String(opts.addr));
1211
- const wantPath = String(opts.path);
1212
- const server = createServer((req, res) => {
1213
- const reqPath = (req.url ?? "/").split("?")[0];
1214
- if (req.method !== "POST" || reqPath !== wantPath) {
1215
- res.writeHead(405);
1216
- res.end("method not allowed");
1217
- return;
1218
- }
1219
- const chunks = [];
1220
- let size = 0;
1221
- req.on("data", (c) => {
1222
- size += c.length;
1223
- if (size <= 1048576) chunks.push(c);
1224
- });
1225
- req.on("end", () => {
1226
- const body = Buffer.concat(chunks).toString("utf8");
1227
- const sig = req.headers["x-krova-signature"] || "";
1228
- try {
1229
- verifyKrovaWebhookOrThrow({ payload: body, signature: sig, secret });
1230
- } catch (e) {
1231
- process.stderr.write(`\u2717 rejected delivery: ${e.message}
1232
- `);
1233
- res.writeHead(400);
1234
- res.end("invalid signature");
1235
- return;
1236
- }
1237
- try {
1238
- const event = JSON.parse(body);
1239
- if (rt.json) process.stdout.write(`${JSON.stringify(event)}
1240
- `);
1241
- else printJSON(event);
1242
- } catch {
1243
- process.stdout.write(`${body}
1244
- `);
1245
- }
1246
- res.writeHead(200);
1247
- res.end("ok");
1248
- });
1249
- });
1250
- server.listen(port, host, () => {
1251
- process.stderr.write(`Listening for webhooks on http://${host}:${port}${wantPath}
1252
- `);
1253
- });
1254
- const stop = () => server.close(() => process.exit(0));
1255
- process.on("SIGINT", stop);
1256
- process.on("SIGTERM", stop);
1257
- });
1258
- return wh;
1174
+ const wh = new Command("webhooks").description("developer tools for Krova Cloud webhooks");
1175
+ wh.command("listen").description("run a local server that verifies + prints incoming webhook deliveries").option("--addr <host:port>", "address to listen on", "127.0.0.1:4666").option("--path <path>", "path to accept POSTs on", "/").option("--secret <secret>", "signing secret (or the KROVA_WEBHOOK_SECRET env var)").action((opts, cmd) => {
1176
+ const rt = getRuntime(cmd);
1177
+ const secret = opts.secret || process.env.KROVA_WEBHOOK_SECRET || "";
1178
+ if (!secret) throw new Error("a signing secret is required: pass --secret or set KROVA_WEBHOOK_SECRET");
1179
+ const { host, port } = parseListenAddr(String(opts.addr));
1180
+ const wantPath = String(opts.path);
1181
+ const server = createServer((req, res) => {
1182
+ const reqPath = (req.url ?? "/").split("?")[0];
1183
+ if (req.method !== "POST" || reqPath !== wantPath) {
1184
+ res.writeHead(405);
1185
+ res.end("method not allowed");
1186
+ return;
1187
+ }
1188
+ const chunks = [];
1189
+ let size = 0;
1190
+ req.on("data", (c) => {
1191
+ size += c.length;
1192
+ if (size <= 1048576) chunks.push(c);
1193
+ });
1194
+ req.on("end", () => {
1195
+ const body = Buffer.concat(chunks).toString("utf8");
1196
+ const sig = req.headers["x-krova-signature"] || "";
1197
+ try {
1198
+ verifyKrovaWebhookOrThrow({
1199
+ payload: body,
1200
+ signature: sig,
1201
+ secret
1202
+ });
1203
+ } catch (e) {
1204
+ process.stderr.write(`✗ rejected delivery: ${e.message}\n`);
1205
+ res.writeHead(400);
1206
+ res.end("invalid signature");
1207
+ return;
1208
+ }
1209
+ try {
1210
+ const event = JSON.parse(body);
1211
+ if (rt.json) process.stdout.write(`${JSON.stringify(event)}\n`);
1212
+ else printJSON(event);
1213
+ } catch {
1214
+ process.stdout.write(`${body}\n`);
1215
+ }
1216
+ res.writeHead(200);
1217
+ res.end("ok");
1218
+ });
1219
+ });
1220
+ server.listen(port, host, () => {
1221
+ process.stderr.write(`Listening for webhooks on http://${host}:${port}${wantPath}\n`);
1222
+ });
1223
+ const stop = () => server.close(() => process.exit(0));
1224
+ process.on("SIGINT", stop);
1225
+ process.on("SIGTERM", stop);
1226
+ });
1227
+ return wh;
1259
1228
  }
1260
-
1261
- // src/commands/whoami.ts
1262
- import { Command as Command12 } from "commander";
1229
+ //#endregion
1230
+ //#region src/commands/whoami.ts
1263
1231
  function whoamiCommand() {
1264
- return new Command12("whoami").description("show the current context, space, and base URL").action(async (_opts, cmd) => {
1265
- const rt = getRuntime(cmd);
1266
- const ctx = find(rt.cfg, rt.res.contextName);
1267
- let spaceId = rt.res.spaceId;
1268
- let spaceName = ctx?.spaceName ?? "";
1269
- const probe = await probeAuth(rt.res.baseUrl, rt.res.apiKey, rt.timeoutMs);
1270
- if (probe.state === "valid") {
1271
- spaceId = probe.space.id;
1272
- spaceName = probe.space.name ?? spaceName;
1273
- }
1274
- const spaceLive = probe.state === "valid";
1275
- const warning = probe.state === "rejected" ? `the API rejected this key (HTTP ${probe.status}) \u2014 run \`krova login\`. The values below are cached locally and may be stale.` : probe.state === "unreachable" ? `could not reach ${rt.res.baseUrl} (${probe.error}) \u2014 showing locally cached values.` : "";
1276
- if (rt.json) {
1277
- return printJSON({
1278
- context: rt.res.contextName,
1279
- spaceId,
1280
- spaceName,
1281
- apiKeyMasked: maskKey(rt.res.apiKey),
1282
- apiKeySource: rt.res.apiKeySource,
1283
- baseUrl: rt.res.baseUrl,
1284
- spaceLive,
1285
- checkState: probe.state,
1286
- warning: warning || void 0
1287
- });
1288
- }
1289
- printKeyValue([
1290
- ["Context", rt.res.contextName || "\u2014"],
1291
- ["Space", spaceName || "\u2014"],
1292
- ["Space ID", spaceId || "\u2014"],
1293
- ["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : "\u2014"],
1294
- ["Base URL", rt.res.baseUrl]
1295
- ]);
1296
- if (warning) process.stdout.write(`
1297
- ${warning}
1298
- `);
1299
- if (probe.state === "rejected") process.exitCode = 1;
1300
- });
1232
+ return new Command("whoami").description("show the current context, space, and base URL").action(async (_opts, cmd) => {
1233
+ const rt = getRuntime(cmd);
1234
+ const ctx = find(rt.cfg, rt.res.contextName);
1235
+ let spaceId = rt.res.spaceId;
1236
+ let spaceName = ctx?.spaceName ?? "";
1237
+ const probe = await probeAuth(rt.res.baseUrl, rt.res.apiKey, rt.timeoutMs);
1238
+ if (probe.state === "valid") {
1239
+ spaceId = probe.space.id;
1240
+ spaceName = probe.space.name ?? spaceName;
1241
+ }
1242
+ const spaceLive = probe.state === "valid";
1243
+ const warning = probe.state === "rejected" ? `the API rejected this key (HTTP ${probe.status}) run \`krova login\`. The values below are cached locally and may be stale.` : probe.state === "unreachable" ? `could not reach ${rt.res.baseUrl} (${probe.error}) showing locally cached values.` : "";
1244
+ if (rt.json) return printJSON({
1245
+ context: rt.res.contextName,
1246
+ spaceId,
1247
+ spaceName,
1248
+ apiKeyMasked: maskKey(rt.res.apiKey),
1249
+ apiKeySource: rt.res.apiKeySource,
1250
+ baseUrl: rt.res.baseUrl,
1251
+ spaceLive,
1252
+ checkState: probe.state,
1253
+ warning: warning || void 0
1254
+ });
1255
+ printKeyValue([
1256
+ ["Context", rt.res.contextName || "—"],
1257
+ ["Space", spaceName || "—"],
1258
+ ["Space ID", spaceId || ""],
1259
+ ["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : ""],
1260
+ ["Base URL", rt.res.baseUrl]
1261
+ ]);
1262
+ if (warning) process.stdout.write(`\n${warning}\n`);
1263
+ if (probe.state === "rejected") process.exitCode = 1;
1264
+ });
1301
1265
  }
1302
-
1303
- // src/index.ts
1266
+ //#endregion
1267
+ //#region src/index.ts
1268
+ /** cobra-style persistent flags: usable on any command, before or after it.
1269
+ * Skips a flag a command already defines locally (e.g. login's own --context). */
1304
1270
  function addGlobalOptions(cmd) {
1305
- const have = new Set(cmd.options.map((o) => o.long));
1306
- const add = (flags, desc, def) => {
1307
- const long = flags.split(/[ ,<[]/).find((f) => f.startsWith("--"));
1308
- if (long && !have.has(long)) cmd.option(flags, desc, def);
1309
- };
1310
- add("--api-key <key>", "Krova Cloud API key (overrides env and context)");
1311
- add("--space <id>", "Space ID (overrides KROVA_SPACE_ID and context)");
1312
- add("--base-url <url>", "override the API base URL");
1313
- add("--context <name>", "use a named context (overrides KROVA_CONTEXT)");
1314
- add("--json", "output machine-readable JSON instead of a table");
1315
- add("--timeout <duration>", "per-request timeout", "30s");
1271
+ const have = new Set(cmd.options.map((o) => o.long));
1272
+ const add = (flags, desc, def) => {
1273
+ const long = flags.split(/[ ,<[]/).find((f) => f.startsWith("--"));
1274
+ if (long && !have.has(long)) cmd.option(flags, desc, def);
1275
+ };
1276
+ add("--api-key <key>", "Krova Cloud API key (overrides env and context)");
1277
+ add("--space <id>", "Space ID (overrides KROVA_SPACE_ID and context)");
1278
+ add("--base-url <url>", "override the API base URL");
1279
+ add("--context <name>", "use a named context (overrides KROVA_CONTEXT)");
1280
+ add("--json", "output machine-readable JSON instead of a table");
1281
+ add("--timeout <duration>", "per-request timeout", "30s");
1316
1282
  }
1317
- var program = new Command13();
1318
- program.name("krova").description(
1319
- "krova is the command-line interface for Krova Cloud \u2014 manage Cubes (Firecracker microVMs), browse the catalog, and receive webhooks."
1320
- ).version(CLI_VERSION, "-v, --version", "print the krova CLI version").showHelpAfterError();
1283
+ const program = new Command();
1284
+ program.name("krova").description("krova is the command-line interface for Krova Cloud — manage Cubes (Firecracker microVMs), browse the catalog, and receive webhooks.").version(CLI_VERSION, "-v, --version", "print the krova CLI version").showHelpAfterError();
1321
1285
  program.addCommand(loginCommand());
1322
1286
  program.addCommand(authCommand());
1323
1287
  program.addCommand(contextCommand());
@@ -1334,20 +1298,22 @@ program.addCommand(imagesCommand());
1334
1298
  program.addCommand(pricingCommand());
1335
1299
  program.addCommand(webhooksCommand());
1336
1300
  program.addCommand(versionCommand());
1337
- var applyAll = (cmd) => {
1338
- addGlobalOptions(cmd);
1339
- for (const c of cmd.commands) applyAll(c);
1301
+ const applyAll = (cmd) => {
1302
+ addGlobalOptions(cmd);
1303
+ for (const c of cmd.commands) applyAll(c);
1340
1304
  };
1341
1305
  applyAll(program);
1342
1306
  async function main() {
1343
- try {
1344
- await program.parseAsync(process.argv);
1345
- } catch (err) {
1346
- const msg = err instanceof Error ? err.message : String(err);
1347
- process.stderr.write(`Error: ${msg}
1348
- `);
1349
- process.exitCode = 1;
1350
- }
1307
+ try {
1308
+ await program.parseAsync(process.argv);
1309
+ } catch (err) {
1310
+ const msg = err instanceof Error ? err.message : String(err);
1311
+ process.stderr.write(`Error: ${msg}\n`);
1312
+ process.exitCode = 1;
1313
+ }
1351
1314
  }
1352
- void main();
1315
+ main();
1316
+ //#endregion
1317
+ export {};
1318
+
1353
1319
  //# sourceMappingURL=index.js.map