@krovacloud/cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -6
- package/dist/index.js +1070 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -23
- package/bin/krova.js +0 -29
package/dist/index.js
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command10 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/auth.ts
|
|
7
|
+
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"
|
|
20
|
+
};
|
|
21
|
+
function configDir() {
|
|
22
|
+
const xdg = (process.env.XDG_CONFIG_HOME ?? "").trim();
|
|
23
|
+
if (xdg) return join(xdg, "krova");
|
|
24
|
+
return join(homedir(), ".config", "krova");
|
|
25
|
+
}
|
|
26
|
+
function configPath() {
|
|
27
|
+
return join(configDir(), "config.json");
|
|
28
|
+
}
|
|
29
|
+
function load() {
|
|
30
|
+
let cfg = {};
|
|
31
|
+
try {
|
|
32
|
+
cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
cfg = {};
|
|
35
|
+
}
|
|
36
|
+
return migrate(cfg);
|
|
37
|
+
}
|
|
38
|
+
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;
|
|
54
|
+
}
|
|
55
|
+
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);
|
|
73
|
+
}
|
|
74
|
+
function find(cfg, name) {
|
|
75
|
+
return (cfg.contexts ?? []).find((c) => c.name === name);
|
|
76
|
+
}
|
|
77
|
+
function current(cfg, override) {
|
|
78
|
+
const name = (override ?? "").trim() || (cfg.currentContext ?? "").trim();
|
|
79
|
+
if (!name) return void 0;
|
|
80
|
+
return find(cfg, name);
|
|
81
|
+
}
|
|
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
|
+
ctx.baseUrl = incoming.baseUrl;
|
|
94
|
+
return ctx;
|
|
95
|
+
}
|
|
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;
|
|
104
|
+
}
|
|
105
|
+
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
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function sanitizeContextName(name) {
|
|
126
|
+
return name.trim().toLowerCase().replace(/\s+/g, "-");
|
|
127
|
+
}
|
|
128
|
+
function maskKey(key) {
|
|
129
|
+
if (!key) return "";
|
|
130
|
+
if (key.length <= 8) return "****";
|
|
131
|
+
return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/lib/output.ts
|
|
135
|
+
function printJSON(value) {
|
|
136
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
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
|
+
`);
|
|
148
|
+
}
|
|
149
|
+
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
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/lib/client.ts
|
|
158
|
+
import { KrovaClient } from "@krovacloud/sdk";
|
|
159
|
+
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 });
|
|
166
|
+
}
|
|
167
|
+
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
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/lib/runtime.ts
|
|
197
|
+
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;
|
|
216
|
+
}
|
|
217
|
+
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
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async function fetchSpace(baseUrl, apiKey, timeoutMs) {
|
|
236
|
+
try {
|
|
237
|
+
const { status, data } = await rawRequest({
|
|
238
|
+
method: "GET",
|
|
239
|
+
baseUrl,
|
|
240
|
+
path: "/space",
|
|
241
|
+
apiKey,
|
|
242
|
+
timeoutMs
|
|
243
|
+
});
|
|
244
|
+
if (status === 200 && data?.id) return data;
|
|
245
|
+
return null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function resolveSpace(rt) {
|
|
251
|
+
if (rt.res.spaceId) return rt.res.spaceId;
|
|
252
|
+
const { status, data } = await rawRequest({
|
|
253
|
+
method: "GET",
|
|
254
|
+
baseUrl: rt.res.baseUrl,
|
|
255
|
+
path: "/space",
|
|
256
|
+
apiKey: rt.res.apiKey,
|
|
257
|
+
timeoutMs: rt.timeoutMs
|
|
258
|
+
});
|
|
259
|
+
if (status === 200 && data?.id) {
|
|
260
|
+
if (rt.res.contextName) {
|
|
261
|
+
upsert(rt.cfg, {
|
|
262
|
+
name: rt.res.contextName,
|
|
263
|
+
spaceId: data.id,
|
|
264
|
+
spaceName: data.name,
|
|
265
|
+
baseUrl: rt.res.baseUrl
|
|
266
|
+
});
|
|
267
|
+
try {
|
|
268
|
+
save(rt.cfg);
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return data.id;
|
|
273
|
+
}
|
|
274
|
+
if (status === 404) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"couldn't auto-detect your space (the server doesn't support it yet) \u2014 pass --space, set KROVA_SPACE_ID, or run `krova login`"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (status === 401 || status === 403) {
|
|
280
|
+
throw new Error(`auto-detect space failed: the API key was rejected (HTTP ${status})`);
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`auto-detect space failed (HTTP ${status})`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/lib/persist.ts
|
|
286
|
+
async function persistLogin(input) {
|
|
287
|
+
const cfg = load();
|
|
288
|
+
let spaceId = input.spaceId ?? "";
|
|
289
|
+
let spaceName = "";
|
|
290
|
+
let derivedName = "";
|
|
291
|
+
const sp = await fetchSpace(input.baseUrl, input.apiKey, input.timeoutMs);
|
|
292
|
+
if (sp) {
|
|
293
|
+
spaceId = sp.id;
|
|
294
|
+
spaceName = sp.name ?? "";
|
|
295
|
+
derivedName = (sp.slug || sp.name || "").trim();
|
|
296
|
+
}
|
|
297
|
+
let name = (input.ctxName ?? "").trim();
|
|
298
|
+
if (!name) name = derivedName || DEFAULT_CONTEXT_NAME;
|
|
299
|
+
name = sanitizeContextName(name);
|
|
300
|
+
const cur = upsert(cfg, {
|
|
301
|
+
name,
|
|
302
|
+
apiKey: input.apiKey,
|
|
303
|
+
spaceId,
|
|
304
|
+
spaceName,
|
|
305
|
+
baseUrl: input.baseUrl
|
|
306
|
+
});
|
|
307
|
+
cfg.currentContext = cur.name;
|
|
308
|
+
save(cfg);
|
|
309
|
+
return { ctxName: cur.name, spaceName: cur.spaceName ?? "" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/commands/auth.ts
|
|
313
|
+
function promptAPIKey() {
|
|
314
|
+
return new Promise((resolve2, reject) => {
|
|
315
|
+
process.stdout.write("Krova API key: ");
|
|
316
|
+
const stdin = process.stdin;
|
|
317
|
+
const tty = Boolean(stdin.isTTY);
|
|
318
|
+
let buf = "";
|
|
319
|
+
if (tty) stdin.setRawMode(true);
|
|
320
|
+
stdin.resume();
|
|
321
|
+
stdin.setEncoding("utf8");
|
|
322
|
+
const finish = (fn) => {
|
|
323
|
+
if (tty) stdin.setRawMode(false);
|
|
324
|
+
stdin.pause();
|
|
325
|
+
stdin.removeListener("data", onData);
|
|
326
|
+
fn();
|
|
327
|
+
};
|
|
328
|
+
const onData = (ch) => {
|
|
329
|
+
for (const c of ch) {
|
|
330
|
+
const code = c.charCodeAt(0);
|
|
331
|
+
if (code === 10 || code === 13) {
|
|
332
|
+
finish(() => {
|
|
333
|
+
process.stdout.write("\n");
|
|
334
|
+
resolve2(buf.trim());
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (code === 3) {
|
|
339
|
+
finish(() => reject(new Error("cancelled")));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (code === 127 || code === 8) {
|
|
343
|
+
buf = buf.slice(0, -1);
|
|
344
|
+
} else if (code >= 32) {
|
|
345
|
+
buf += c;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
stdin.on("data", onData);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
function authCommand() {
|
|
353
|
+
const auth = new Command("auth").description("manage Krova Cloud credentials");
|
|
354
|
+
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) => {
|
|
355
|
+
const rt = getRuntime(cmd);
|
|
356
|
+
let key = opts.apiKey || rt.flags.apiKey || "";
|
|
357
|
+
if (!key.trim()) key = await promptAPIKey();
|
|
358
|
+
if (!key.trim()) throw new Error("no API key provided");
|
|
359
|
+
const { ctxName, spaceName } = await persistLogin({
|
|
360
|
+
apiKey: key.trim(),
|
|
361
|
+
baseUrl: rt.res.baseUrl,
|
|
362
|
+
spaceId: opts.space,
|
|
363
|
+
ctxName: opts.context,
|
|
364
|
+
timeoutMs: rt.timeoutMs
|
|
365
|
+
});
|
|
366
|
+
process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}
|
|
367
|
+
`);
|
|
368
|
+
if (spaceName) process.stdout.write(`Space: ${spaceName}
|
|
369
|
+
`);
|
|
370
|
+
});
|
|
371
|
+
auth.command("status").description("show the resolved credentials").action((_opts, cmd) => {
|
|
372
|
+
const rt = getRuntime(cmd);
|
|
373
|
+
const authenticated = Boolean(rt.res.apiKey);
|
|
374
|
+
if (rt.json) {
|
|
375
|
+
return printJSON({
|
|
376
|
+
authenticated,
|
|
377
|
+
context: rt.res.contextName,
|
|
378
|
+
apiKeySource: rt.res.apiKeySource,
|
|
379
|
+
apiKeyMasked: maskKey(rt.res.apiKey),
|
|
380
|
+
spaceId: rt.res.spaceId,
|
|
381
|
+
spaceSource: rt.res.spaceIdSource,
|
|
382
|
+
baseUrl: rt.res.baseUrl,
|
|
383
|
+
configPath: configPath()
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
printKeyValue([
|
|
387
|
+
["Authenticated", authenticated ? "yes" : "no"],
|
|
388
|
+
["Context", rt.res.contextName || "\u2014"],
|
|
389
|
+
["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : "\u2014"],
|
|
390
|
+
["Space ID", rt.res.spaceId ? `${rt.res.spaceId} (${rt.res.spaceIdSource})` : "\u2014"],
|
|
391
|
+
["Base URL", rt.res.baseUrl],
|
|
392
|
+
["Config", configPath()]
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
return auth;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/commands/catalog.ts
|
|
399
|
+
import { Command as Command2 } from "commander";
|
|
400
|
+
function fmtVal(v) {
|
|
401
|
+
if (v === null || v === void 0) return "";
|
|
402
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
403
|
+
return String(v);
|
|
404
|
+
}
|
|
405
|
+
function renderCatalog(obj) {
|
|
406
|
+
const arrKey = Object.keys(obj).find((k) => Array.isArray(obj[k]));
|
|
407
|
+
if (arrKey) {
|
|
408
|
+
const arr = obj[arrKey] ?? [];
|
|
409
|
+
const cols = [...new Set(arr.flatMap((o) => Object.keys(o)))];
|
|
410
|
+
printTable(
|
|
411
|
+
cols.map((c) => c.toUpperCase()),
|
|
412
|
+
arr.map((o) => cols.map((c) => fmtVal(o[c])))
|
|
413
|
+
);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
printKeyValue(Object.entries(obj).map(([k, v]) => [k, fmtVal(v)]));
|
|
417
|
+
}
|
|
418
|
+
function catalogCmd(name, desc, fetch2) {
|
|
419
|
+
return new Command2(name).description(desc).action(async (_opts, cmd) => {
|
|
420
|
+
const rt = getRuntime(cmd);
|
|
421
|
+
const client = makeClient(rt.res);
|
|
422
|
+
const data = await fetch2(client);
|
|
423
|
+
if (rt.json) return printJSON(data);
|
|
424
|
+
renderCatalog(data);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function regionsCommand() {
|
|
428
|
+
return catalogCmd(
|
|
429
|
+
"regions",
|
|
430
|
+
"list regions with available capacity",
|
|
431
|
+
(c) => c.catalog.regions()
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
function imagesCommand() {
|
|
435
|
+
return catalogCmd(
|
|
436
|
+
"images",
|
|
437
|
+
"list available OS images",
|
|
438
|
+
(c) => c.catalog.images()
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
function pricingCommand() {
|
|
442
|
+
return catalogCmd(
|
|
443
|
+
"pricing",
|
|
444
|
+
"show per-resource hourly pricing",
|
|
445
|
+
(c) => c.catalog.pricing()
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/commands/context.ts
|
|
450
|
+
import { Command as Command3 } from "commander";
|
|
451
|
+
function contextCommand() {
|
|
452
|
+
const ctx = new Command3("context").aliases(["ctx", "contexts"]).description("manage named credential contexts (like kubectl/aws profiles)");
|
|
453
|
+
ctx.command("list").aliases(["ls"]).description("list all contexts").action((_opts, cmd) => {
|
|
454
|
+
const { json } = getRuntime(cmd);
|
|
455
|
+
const cfg = load();
|
|
456
|
+
if (json) {
|
|
457
|
+
return printJSON({
|
|
458
|
+
currentContext: cfg.currentContext ?? "",
|
|
459
|
+
contexts: (cfg.contexts ?? []).map((c) => ({
|
|
460
|
+
name: c.name,
|
|
461
|
+
apiKey: maskKey(c.apiKey ?? ""),
|
|
462
|
+
spaceId: c.spaceId ?? "",
|
|
463
|
+
spaceName: c.spaceName ?? "",
|
|
464
|
+
baseUrl: c.baseUrl ?? ""
|
|
465
|
+
}))
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
printTable(
|
|
469
|
+
["CURRENT", "NAME", "SPACE", "SPACE ID", "BASE URL"],
|
|
470
|
+
(cfg.contexts ?? []).map((c) => [
|
|
471
|
+
c.name === cfg.currentContext ? "*" : "",
|
|
472
|
+
c.name,
|
|
473
|
+
c.spaceName ?? "",
|
|
474
|
+
c.spaceId ?? "",
|
|
475
|
+
c.baseUrl ?? ""
|
|
476
|
+
])
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
ctx.command("current").description("print the current context name").action(() => {
|
|
480
|
+
const cfg = load();
|
|
481
|
+
if (!cfg.currentContext) throw new Error("no current context set");
|
|
482
|
+
process.stdout.write(`${cfg.currentContext}
|
|
483
|
+
`);
|
|
484
|
+
});
|
|
485
|
+
ctx.command("use").argument("<name>", "context name").description("switch the current context").action((name) => {
|
|
486
|
+
const cfg = load();
|
|
487
|
+
if (!(cfg.contexts ?? []).some((c) => c.name === name)) {
|
|
488
|
+
throw new Error(`no context named "${name}"`);
|
|
489
|
+
}
|
|
490
|
+
cfg.currentContext = name;
|
|
491
|
+
save(cfg);
|
|
492
|
+
process.stdout.write(`Switched to context ${name}
|
|
493
|
+
`);
|
|
494
|
+
});
|
|
495
|
+
ctx.command("rename").argument("<old>", "current name").argument("<new>", "new name").description("rename a context").action((oldName, newName) => {
|
|
496
|
+
const cfg = load();
|
|
497
|
+
if (!newName.trim()) throw new Error("new name must not be empty");
|
|
498
|
+
const c = (cfg.contexts ?? []).find((x) => x.name === oldName);
|
|
499
|
+
if (!c) throw new Error(`no context named "${oldName}"`);
|
|
500
|
+
if ((cfg.contexts ?? []).some((x) => x.name === newName)) {
|
|
501
|
+
throw new Error(`a context named "${newName}" already exists`);
|
|
502
|
+
}
|
|
503
|
+
c.name = newName;
|
|
504
|
+
if (cfg.currentContext === oldName) cfg.currentContext = newName;
|
|
505
|
+
save(cfg);
|
|
506
|
+
process.stdout.write(`Renamed ${oldName} \u2192 ${newName}
|
|
507
|
+
`);
|
|
508
|
+
});
|
|
509
|
+
ctx.command("delete").aliases(["rm"]).argument("<name>", "context name").description("delete a context").action((name) => {
|
|
510
|
+
const cfg = load();
|
|
511
|
+
if (!remove(cfg, name)) throw new Error(`no context named "${name}"`);
|
|
512
|
+
save(cfg);
|
|
513
|
+
process.stdout.write(`Deleted context ${name}
|
|
514
|
+
`);
|
|
515
|
+
});
|
|
516
|
+
return ctx;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/commands/cubes.ts
|
|
520
|
+
import { Command as Command4 } from "commander";
|
|
521
|
+
|
|
522
|
+
// src/lib/resolve.ts
|
|
523
|
+
async function resolveCube(client, spaceId, ref) {
|
|
524
|
+
const { cubes } = await client.cubes.list(spaceId);
|
|
525
|
+
if (cubes.some((c) => c.id === ref)) return ref;
|
|
526
|
+
const byName = cubes.filter((c) => c.name === ref);
|
|
527
|
+
if (byName.length === 1) return byName[0].id;
|
|
528
|
+
if (byName.length === 0) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`no cube named or with ID "${ref}" in this space (see \`krova cubes list\`)`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const ids = byName.map((c) => c.id).join(", ");
|
|
534
|
+
throw new Error(
|
|
535
|
+
`cube name "${ref}" is ambiguous: it matches ${byName.length} cubes (${ids}) \u2014 use the cube ID instead`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/commands/cubes.ts
|
|
540
|
+
function listCmd() {
|
|
541
|
+
return new Command4("list").aliases(["ls"]).description("list Cubes in the space").action(async (_opts, cmd) => {
|
|
542
|
+
const rt = getRuntime(cmd);
|
|
543
|
+
const client = makeClient(rt.res);
|
|
544
|
+
const space = await resolveSpace(rt);
|
|
545
|
+
const { cubes } = await client.cubes.list(space);
|
|
546
|
+
if (rt.json) return printJSON(cubes);
|
|
547
|
+
printTable(
|
|
548
|
+
["ID", "NAME", "STATE", "VCPU", "RAM(GB)", "DISK(GB)", "IMAGE", "IPV4"],
|
|
549
|
+
cubes.map((c) => [
|
|
550
|
+
c.id,
|
|
551
|
+
c.name,
|
|
552
|
+
c.state,
|
|
553
|
+
String(c.resources.vcpu),
|
|
554
|
+
String(c.resources.ramGb),
|
|
555
|
+
String(c.resources.diskGb),
|
|
556
|
+
c.image,
|
|
557
|
+
c.publicIpv4 ?? "\u2014"
|
|
558
|
+
])
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
function getCmd() {
|
|
563
|
+
return new Command4("get").argument("<cube>", "cube name or ID").description("show a single Cube").action(async (cubeRef, _opts, cmd) => {
|
|
564
|
+
const rt = getRuntime(cmd);
|
|
565
|
+
const client = makeClient(rt.res);
|
|
566
|
+
const space = await resolveSpace(rt);
|
|
567
|
+
const id = await resolveCube(client, space, cubeRef);
|
|
568
|
+
const cube = await client.cubes.get(space, id);
|
|
569
|
+
if (rt.json) return printJSON(cube);
|
|
570
|
+
printKeyValue([
|
|
571
|
+
["ID", cube.id],
|
|
572
|
+
["Name", cube.name],
|
|
573
|
+
["State", cube.state],
|
|
574
|
+
["Image", cube.image],
|
|
575
|
+
["vCPU", String(cube.resources.vcpu)],
|
|
576
|
+
["RAM (GB)", String(cube.resources.ramGb)],
|
|
577
|
+
["Disk (GB)", String(cube.resources.diskGb)],
|
|
578
|
+
["Public IPv4", cube.publicIpv4 ?? "\u2014"],
|
|
579
|
+
["Cost/hour", `$${cube.costPerHour}`]
|
|
580
|
+
]);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
function createCmd() {
|
|
584
|
+
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) => {
|
|
585
|
+
const rt = getRuntime(cmd);
|
|
586
|
+
const client = makeClient(rt.res);
|
|
587
|
+
const space = await resolveSpace(rt);
|
|
588
|
+
const body = {
|
|
589
|
+
name: opts.name,
|
|
590
|
+
image: opts.image,
|
|
591
|
+
sshPublicKey: opts.sshKey,
|
|
592
|
+
resources: {
|
|
593
|
+
vcpu: Number(opts.vcpu),
|
|
594
|
+
ramGb: Number(opts.ram),
|
|
595
|
+
diskGb: Number(opts.disk)
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
if (opts.region) body.region = opts.region;
|
|
599
|
+
if (opts.userData) body.userData = opts.userData;
|
|
600
|
+
const cube = await client.cubes.create(
|
|
601
|
+
space,
|
|
602
|
+
body,
|
|
603
|
+
opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0
|
|
604
|
+
);
|
|
605
|
+
if (rt.json) return printJSON(cube);
|
|
606
|
+
process.stdout.write(`Created cube ${cube.id} (${cube.state})
|
|
607
|
+
`);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function actionCmd(name, past, fn) {
|
|
611
|
+
return new Command4(name).argument("<cube>", "cube name or ID").description(`${name} a Cube`).action(async (cubeRef, _opts, cmd) => {
|
|
612
|
+
const rt = getRuntime(cmd);
|
|
613
|
+
const client = makeClient(rt.res);
|
|
614
|
+
const space = await resolveSpace(rt);
|
|
615
|
+
const id = await resolveCube(client, space, cubeRef);
|
|
616
|
+
await fn(client.cubes, space, id);
|
|
617
|
+
if (rt.json) return printJSON({ id, result: past });
|
|
618
|
+
process.stdout.write(`${past} cube ${id}
|
|
619
|
+
`);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function sshPortCmd() {
|
|
623
|
+
return new Command4("ssh-port").argument("<cube>", "cube name or ID").requiredOption("--port <n>", "the host port to expose SSH on").description("change the host port a Cube's SSH is reachable on").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
|
+
const cubePort = Number(opts.port);
|
|
629
|
+
if (!Number.isInteger(cubePort) || cubePort < 1 || cubePort > 65535) {
|
|
630
|
+
throw new Error("--port must be an integer between 1 and 65535");
|
|
631
|
+
}
|
|
632
|
+
await client.cubes.update(space, id, { cubePort });
|
|
633
|
+
if (rt.json) return printJSON({ id, cubePort });
|
|
634
|
+
process.stdout.write(`SSH port for cube ${id} set to ${cubePort}
|
|
635
|
+
`);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
function cubesCommand() {
|
|
639
|
+
const cubes = new Command4("cubes").description("manage Cubes (Firecracker microVMs)");
|
|
640
|
+
cubes.addCommand(listCmd());
|
|
641
|
+
cubes.addCommand(getCmd());
|
|
642
|
+
cubes.addCommand(createCmd());
|
|
643
|
+
cubes.addCommand(actionCmd("sleep", "Sleeping", (c, s, id) => c.sleep(s, id)));
|
|
644
|
+
cubes.addCommand(actionCmd("wake", "Waking", (c, s, id) => c.wake(s, id)));
|
|
645
|
+
cubes.addCommand(actionCmd("delete", "Deleting", (c, s, id) => c.delete(s, id)));
|
|
646
|
+
cubes.addCommand(sshPortCmd());
|
|
647
|
+
return cubes;
|
|
648
|
+
}
|
|
649
|
+
var rootListCommand = listCmd;
|
|
650
|
+
var rootGetCommand = getCmd;
|
|
651
|
+
|
|
652
|
+
// src/commands/login.ts
|
|
653
|
+
import { spawn } from "child_process";
|
|
654
|
+
import { Command as Command5 } from "commander";
|
|
655
|
+
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";
|
|
656
|
+
function safeBrowserURL(raw) {
|
|
657
|
+
let u;
|
|
658
|
+
try {
|
|
659
|
+
u = new URL(raw);
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
if (u.protocol === "https:" && u.hostname) return raw;
|
|
664
|
+
if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
|
|
665
|
+
return raw;
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
function openBrowser(url) {
|
|
670
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
671
|
+
const args = process.platform === "win32" ? ["", url] : [url];
|
|
672
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: process.platform === "win32" });
|
|
673
|
+
child.on("error", () => {
|
|
674
|
+
});
|
|
675
|
+
child.unref();
|
|
676
|
+
}
|
|
677
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
678
|
+
function loginCommand() {
|
|
679
|
+
return new Command5("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) => {
|
|
680
|
+
const rt = getRuntime(cmd);
|
|
681
|
+
const baseUrl = rt.res.baseUrl;
|
|
682
|
+
const start = await rawRequest({
|
|
683
|
+
method: "POST",
|
|
684
|
+
baseUrl,
|
|
685
|
+
path: "/auth/cli/start",
|
|
686
|
+
timeoutMs: rt.timeoutMs
|
|
687
|
+
});
|
|
688
|
+
if (start.status === 404) throw new Error(BROWSER_UNAVAILABLE);
|
|
689
|
+
if (start.status !== 200 || !start.data?.deviceCode) {
|
|
690
|
+
throw new Error(`device login failed to start (HTTP ${start.status})`);
|
|
691
|
+
}
|
|
692
|
+
const s = start.data;
|
|
693
|
+
process.stdout.write(`Your verification code is: ${s.userCode}
|
|
694
|
+
`);
|
|
695
|
+
const target = s.verificationUriComplete || s.verificationUri;
|
|
696
|
+
const safe = safeBrowserURL(target);
|
|
697
|
+
if (safe && opts.browser !== false) {
|
|
698
|
+
process.stdout.write(`Opening ${safe} \u2026
|
|
699
|
+
`);
|
|
700
|
+
openBrowser(safe);
|
|
701
|
+
} else {
|
|
702
|
+
process.stdout.write(`Open this URL to approve the login:
|
|
703
|
+
${target}
|
|
704
|
+
`);
|
|
705
|
+
}
|
|
706
|
+
const intervalMs = Math.max((s.interval || 5) * 1e3, 5e3);
|
|
707
|
+
const deadline = Date.now() + Math.max(s.expiresIn || 600, 1) * 1e3;
|
|
708
|
+
process.stdout.write("Waiting for approval\u2026\n");
|
|
709
|
+
for (; ; ) {
|
|
710
|
+
if (Date.now() >= deadline) {
|
|
711
|
+
throw new Error(
|
|
712
|
+
"login timed out: the verification code expired before it was approved"
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
await sleep(intervalMs);
|
|
716
|
+
const poll = await rawRequest({
|
|
717
|
+
method: "POST",
|
|
718
|
+
baseUrl,
|
|
719
|
+
path: "/auth/cli/poll",
|
|
720
|
+
body: { deviceCode: s.deviceCode },
|
|
721
|
+
timeoutMs: rt.timeoutMs
|
|
722
|
+
});
|
|
723
|
+
if (poll.status === 200) {
|
|
724
|
+
if (!poll.data?.apiKey) throw new Error("login succeeded but no API key was returned");
|
|
725
|
+
const { ctxName, spaceName } = await persistLogin({
|
|
726
|
+
apiKey: poll.data.apiKey,
|
|
727
|
+
baseUrl,
|
|
728
|
+
spaceId: poll.data.spaceId,
|
|
729
|
+
ctxName: opts.context,
|
|
730
|
+
timeoutMs: rt.timeoutMs
|
|
731
|
+
});
|
|
732
|
+
process.stdout.write(`Logged in. Saved context "${ctxName}" to ${configPath()}
|
|
733
|
+
`);
|
|
734
|
+
if (spaceName) process.stdout.write(`Space: ${spaceName}
|
|
735
|
+
`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (poll.status === 202 || poll.status === 425 || poll.status === 428) continue;
|
|
739
|
+
if (poll.status === 410) {
|
|
740
|
+
throw new Error("login expired: request a new code with `krova login`");
|
|
741
|
+
}
|
|
742
|
+
if (poll.status === 404) throw new Error(BROWSER_UNAVAILABLE);
|
|
743
|
+
throw new Error(`login failed while polling (HTTP ${poll.status})`);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/commands/ssh.ts
|
|
749
|
+
import { Command as Command6 } from "commander";
|
|
750
|
+
|
|
751
|
+
// src/lib/ssh.ts
|
|
752
|
+
import { spawn as spawn2 } from "child_process";
|
|
753
|
+
import { appendFileSync, chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
754
|
+
import { dirname as dirname2 } from "path";
|
|
755
|
+
import { join as join2 } from "path";
|
|
756
|
+
var HOST_BANNED = new Set(
|
|
757
|
+
"@/\\'\"`$;&|<>(){}*?!#=,".split("")
|
|
758
|
+
);
|
|
759
|
+
var USER_BANNED = new Set(
|
|
760
|
+
"@/\\'\"`$;&|<>(){}*?!#,".split("")
|
|
761
|
+
);
|
|
762
|
+
function validateSSHHost(host) {
|
|
763
|
+
const h = host.trim();
|
|
764
|
+
if (!h) throw new Error("empty host");
|
|
765
|
+
if (h.startsWith("-")) throw new Error("host must not start with '-'");
|
|
766
|
+
for (const r of h) {
|
|
767
|
+
const code = r.codePointAt(0) ?? 0;
|
|
768
|
+
if (code <= 32 || code === 127) {
|
|
769
|
+
throw new Error("host contains whitespace or control characters");
|
|
770
|
+
}
|
|
771
|
+
if (HOST_BANNED.has(r)) {
|
|
772
|
+
throw new Error(`host contains a disallowed character ${JSON.stringify(r)}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function validateSSHUser(user) {
|
|
777
|
+
const u = user.trim();
|
|
778
|
+
if (!u) return;
|
|
779
|
+
if (u.startsWith("-")) throw new Error("user must not start with '-'");
|
|
780
|
+
for (const r of u) {
|
|
781
|
+
const code = r.codePointAt(0) ?? 0;
|
|
782
|
+
if (code <= 32 || code === 127) {
|
|
783
|
+
throw new Error("user contains whitespace or control characters");
|
|
784
|
+
}
|
|
785
|
+
if (USER_BANNED.has(r)) {
|
|
786
|
+
throw new Error(`user contains a disallowed character ${JSON.stringify(r)}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function knownHostsHost(host, port) {
|
|
791
|
+
if (port > 0 && port !== 22) return `[${host}]:${port}`;
|
|
792
|
+
return host;
|
|
793
|
+
}
|
|
794
|
+
function knownHostsPath() {
|
|
795
|
+
return join2(configDir(), "known_hosts");
|
|
796
|
+
}
|
|
797
|
+
function writeKnownHosts(info) {
|
|
798
|
+
const path = knownHostsPath();
|
|
799
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
|
|
800
|
+
const field = knownHostsHost(info.host, info.port);
|
|
801
|
+
let existing = "";
|
|
802
|
+
try {
|
|
803
|
+
existing = readFileSync2(path, "utf8");
|
|
804
|
+
} catch {
|
|
805
|
+
existing = "";
|
|
806
|
+
}
|
|
807
|
+
const kept = existing.split("\n").filter((line) => line.trim() && line.split(/\s+/)[0] !== field);
|
|
808
|
+
writeFileSync2(path, kept.length ? `${kept.join("\n")}
|
|
809
|
+
` : "", { mode: 384 });
|
|
810
|
+
chmodSync2(path, 384);
|
|
811
|
+
for (const k of info.hostKeys) {
|
|
812
|
+
appendFileSync(path, `${field} ${k.type} ${k.key}
|
|
813
|
+
`);
|
|
814
|
+
}
|
|
815
|
+
return path;
|
|
816
|
+
}
|
|
817
|
+
function buildSSHArgs(info, o) {
|
|
818
|
+
const args = [];
|
|
819
|
+
if (o.knownHosts) {
|
|
820
|
+
args.push("-o", `UserKnownHostsFile=${o.knownHosts}`, "-o", "StrictHostKeyChecking=yes");
|
|
821
|
+
}
|
|
822
|
+
if ((o.identity ?? "").trim()) args.push("-i", o.identity);
|
|
823
|
+
if (info.port > 0) args.push("-p", String(info.port));
|
|
824
|
+
for (const l of o.localFwd ?? []) if (l.trim()) args.push("-L", l);
|
|
825
|
+
for (const r of o.remoteFwd ?? []) if (r.trim()) args.push("-R", r);
|
|
826
|
+
const user = info.user.trim();
|
|
827
|
+
const target = user ? `${user}@${info.host}` : info.host;
|
|
828
|
+
args.push("--", target);
|
|
829
|
+
args.push(...o.remoteCmd ?? []);
|
|
830
|
+
return args;
|
|
831
|
+
}
|
|
832
|
+
function execSSH(args) {
|
|
833
|
+
return new Promise((resolve2, reject) => {
|
|
834
|
+
const child = spawn2("ssh", args, { stdio: "inherit" });
|
|
835
|
+
child.on("error", reject);
|
|
836
|
+
child.on("exit", (code) => resolve2(code ?? 0));
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/commands/ssh.ts
|
|
841
|
+
var collect = (v, acc) => {
|
|
842
|
+
acc.push(v);
|
|
843
|
+
return acc;
|
|
844
|
+
};
|
|
845
|
+
function sshCommand() {
|
|
846
|
+
return new Command6("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) => {
|
|
847
|
+
const rt = getRuntime(cmd);
|
|
848
|
+
const client = makeClient(rt.res);
|
|
849
|
+
const space = await resolveSpace(rt);
|
|
850
|
+
const id = await resolveCube(client, space, cubeRef);
|
|
851
|
+
const { status, data } = await rawRequest({
|
|
852
|
+
method: "GET",
|
|
853
|
+
baseUrl: rt.res.baseUrl,
|
|
854
|
+
path: `/spaces/${space}/cubes/${id}/ssh`,
|
|
855
|
+
apiKey: rt.res.apiKey,
|
|
856
|
+
timeoutMs: rt.timeoutMs
|
|
857
|
+
});
|
|
858
|
+
if (status === 404) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`SSH info isn't available on this server yet \u2014 try \`krova cubes get ${cubeRef}\` for the Cube's IP and ssh manually`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (status === 401 || status === 403) {
|
|
864
|
+
throw new Error(`SSH info request was rejected (HTTP ${status})`);
|
|
865
|
+
}
|
|
866
|
+
if (status !== 200 || !data?.host) {
|
|
867
|
+
throw new Error(`couldn't fetch SSH info (HTTP ${status})`);
|
|
868
|
+
}
|
|
869
|
+
const info = {
|
|
870
|
+
host: data.host,
|
|
871
|
+
port: data.port ?? 0,
|
|
872
|
+
user: data.user ?? "",
|
|
873
|
+
hostKeys: data.hostKeys ?? []
|
|
874
|
+
};
|
|
875
|
+
validateSSHHost(info.host);
|
|
876
|
+
validateSSHUser(info.user);
|
|
877
|
+
if (info.port < 0 || info.port > 65535) throw new Error("invalid ssh port");
|
|
878
|
+
let knownHosts = "";
|
|
879
|
+
if (info.hostKeys.length) {
|
|
880
|
+
knownHosts = writeKnownHosts(info);
|
|
881
|
+
} else {
|
|
882
|
+
process.stderr.write(
|
|
883
|
+
"note: this server didn't provide host keys \u2014 using ssh trust-on-first-use (host-key checking stays on).\n"
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
const args = buildSSHArgs(info, {
|
|
887
|
+
identity: opts.identity,
|
|
888
|
+
localFwd: opts.localForward,
|
|
889
|
+
remoteFwd: opts.remoteForward,
|
|
890
|
+
knownHosts,
|
|
891
|
+
remoteCmd: command
|
|
892
|
+
});
|
|
893
|
+
process.exitCode = await execSSH(args);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/commands/version.ts
|
|
898
|
+
import { createRequire } from "module";
|
|
899
|
+
import { Command as Command7 } from "commander";
|
|
900
|
+
var require2 = createRequire(import.meta.url);
|
|
901
|
+
var pkg = require2("../package.json");
|
|
902
|
+
var CLI_VERSION = pkg.version;
|
|
903
|
+
function versionCommand() {
|
|
904
|
+
return new Command7("version").description("print the krova CLI version").action((_opts, cmd) => {
|
|
905
|
+
const rt = getRuntime(cmd);
|
|
906
|
+
const info = {
|
|
907
|
+
version: CLI_VERSION,
|
|
908
|
+
node: process.version,
|
|
909
|
+
platform: `${process.platform}/${process.arch}`
|
|
910
|
+
};
|
|
911
|
+
if (rt.json) return printJSON(info);
|
|
912
|
+
process.stdout.write(`krova ${info.version} (node ${info.node}, ${info.platform})
|
|
913
|
+
`);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/commands/webhooks.ts
|
|
918
|
+
import { createServer } from "http";
|
|
919
|
+
import { verifyKrovaWebhookOrThrow } from "@krovacloud/webhook";
|
|
920
|
+
import { Command as Command8 } from "commander";
|
|
921
|
+
function webhooksCommand() {
|
|
922
|
+
const wh = new Command8("webhooks").description("developer tools for Krova Cloud webhooks");
|
|
923
|
+
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) => {
|
|
924
|
+
const rt = getRuntime(cmd);
|
|
925
|
+
const secret = opts.secret || process.env.KROVA_WEBHOOK_SECRET || "";
|
|
926
|
+
if (!secret) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
"a signing secret is required: pass --secret or set KROVA_WEBHOOK_SECRET"
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
const addr = String(opts.addr);
|
|
932
|
+
const lastColon = addr.lastIndexOf(":");
|
|
933
|
+
const host = lastColon > 0 ? addr.slice(0, lastColon) : "127.0.0.1";
|
|
934
|
+
const port = Number(lastColon > 0 ? addr.slice(lastColon + 1) : addr) || 4666;
|
|
935
|
+
const wantPath = String(opts.path);
|
|
936
|
+
const server = createServer((req, res) => {
|
|
937
|
+
const reqPath = (req.url ?? "/").split("?")[0];
|
|
938
|
+
if (req.method !== "POST" || reqPath !== wantPath) {
|
|
939
|
+
res.writeHead(405);
|
|
940
|
+
res.end("method not allowed");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const chunks = [];
|
|
944
|
+
let size = 0;
|
|
945
|
+
req.on("data", (c) => {
|
|
946
|
+
size += c.length;
|
|
947
|
+
if (size <= 1048576) chunks.push(c);
|
|
948
|
+
});
|
|
949
|
+
req.on("end", () => {
|
|
950
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
951
|
+
const sig = req.headers["x-krova-signature"] || "";
|
|
952
|
+
try {
|
|
953
|
+
verifyKrovaWebhookOrThrow({ payload: body, signature: sig, secret });
|
|
954
|
+
} catch (e) {
|
|
955
|
+
process.stderr.write(`\u2717 rejected delivery: ${e.message}
|
|
956
|
+
`);
|
|
957
|
+
res.writeHead(400);
|
|
958
|
+
res.end("invalid signature");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const event = JSON.parse(body);
|
|
963
|
+
if (rt.json) process.stdout.write(`${JSON.stringify(event)}
|
|
964
|
+
`);
|
|
965
|
+
else printJSON(event);
|
|
966
|
+
} catch {
|
|
967
|
+
process.stdout.write(`${body}
|
|
968
|
+
`);
|
|
969
|
+
}
|
|
970
|
+
res.writeHead(200);
|
|
971
|
+
res.end("ok");
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
server.listen(port, host, () => {
|
|
975
|
+
process.stderr.write(`Listening for webhooks on http://${host}:${port}${wantPath}
|
|
976
|
+
`);
|
|
977
|
+
});
|
|
978
|
+
const stop = () => server.close(() => process.exit(0));
|
|
979
|
+
process.on("SIGINT", stop);
|
|
980
|
+
process.on("SIGTERM", stop);
|
|
981
|
+
});
|
|
982
|
+
return wh;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/commands/whoami.ts
|
|
986
|
+
import { Command as Command9 } from "commander";
|
|
987
|
+
function whoamiCommand() {
|
|
988
|
+
return new Command9("whoami").description("show the current context, space, and base URL").action(async (_opts, cmd) => {
|
|
989
|
+
const rt = getRuntime(cmd);
|
|
990
|
+
const ctx = find(rt.cfg, rt.res.contextName);
|
|
991
|
+
let spaceId = rt.res.spaceId;
|
|
992
|
+
let spaceName = ctx?.spaceName ?? "";
|
|
993
|
+
let spaceLive = false;
|
|
994
|
+
if (rt.res.apiKey) {
|
|
995
|
+
const sp = await fetchSpace(rt.res.baseUrl, rt.res.apiKey, rt.timeoutMs);
|
|
996
|
+
if (sp) {
|
|
997
|
+
spaceId = sp.id;
|
|
998
|
+
spaceName = sp.name ?? spaceName;
|
|
999
|
+
spaceLive = true;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (rt.json) {
|
|
1003
|
+
return printJSON({
|
|
1004
|
+
context: rt.res.contextName,
|
|
1005
|
+
spaceId,
|
|
1006
|
+
spaceName,
|
|
1007
|
+
apiKeyMasked: maskKey(rt.res.apiKey),
|
|
1008
|
+
apiKeySource: rt.res.apiKeySource,
|
|
1009
|
+
baseUrl: rt.res.baseUrl,
|
|
1010
|
+
spaceLive
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
printKeyValue([
|
|
1014
|
+
["Context", rt.res.contextName || "\u2014"],
|
|
1015
|
+
["Space", spaceName || "\u2014"],
|
|
1016
|
+
["Space ID", spaceId || "\u2014"],
|
|
1017
|
+
["API key", rt.res.apiKey ? `${maskKey(rt.res.apiKey)} (${rt.res.apiKeySource})` : "\u2014"],
|
|
1018
|
+
["Base URL", rt.res.baseUrl]
|
|
1019
|
+
]);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// src/index.ts
|
|
1024
|
+
function addGlobalOptions(cmd) {
|
|
1025
|
+
const have = new Set(cmd.options.map((o) => o.long));
|
|
1026
|
+
const add = (flags, desc, def) => {
|
|
1027
|
+
const long = flags.split(/[ ,<[]/).find((f) => f.startsWith("--"));
|
|
1028
|
+
if (long && !have.has(long)) cmd.option(flags, desc, def);
|
|
1029
|
+
};
|
|
1030
|
+
add("--api-key <key>", "Krova Cloud API key (overrides env and context)");
|
|
1031
|
+
add("--space <id>", "Space ID (overrides KROVA_SPACE_ID and context)");
|
|
1032
|
+
add("--base-url <url>", "override the API base URL");
|
|
1033
|
+
add("--context <name>", "use a named context (overrides KROVA_CONTEXT)");
|
|
1034
|
+
add("--json", "output machine-readable JSON instead of a table");
|
|
1035
|
+
add("--timeout <duration>", "per-request timeout", "30s");
|
|
1036
|
+
}
|
|
1037
|
+
var program = new Command10();
|
|
1038
|
+
program.name("krova").description(
|
|
1039
|
+
"krova is the command-line interface for Krova Cloud \u2014 manage Cubes (Firecracker microVMs), browse the catalog, and receive webhooks."
|
|
1040
|
+
).version(CLI_VERSION, "-v, --version", "print the krova CLI version").showHelpAfterError();
|
|
1041
|
+
program.addCommand(loginCommand());
|
|
1042
|
+
program.addCommand(authCommand());
|
|
1043
|
+
program.addCommand(contextCommand());
|
|
1044
|
+
program.addCommand(whoamiCommand());
|
|
1045
|
+
program.addCommand(cubesCommand());
|
|
1046
|
+
program.addCommand(sshCommand());
|
|
1047
|
+
program.addCommand(rootListCommand());
|
|
1048
|
+
program.addCommand(rootGetCommand());
|
|
1049
|
+
program.addCommand(regionsCommand());
|
|
1050
|
+
program.addCommand(imagesCommand());
|
|
1051
|
+
program.addCommand(pricingCommand());
|
|
1052
|
+
program.addCommand(webhooksCommand());
|
|
1053
|
+
program.addCommand(versionCommand());
|
|
1054
|
+
var applyAll = (cmd) => {
|
|
1055
|
+
addGlobalOptions(cmd);
|
|
1056
|
+
for (const c of cmd.commands) applyAll(c);
|
|
1057
|
+
};
|
|
1058
|
+
applyAll(program);
|
|
1059
|
+
async function main() {
|
|
1060
|
+
try {
|
|
1061
|
+
await program.parseAsync(process.argv);
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1064
|
+
process.stderr.write(`Error: ${msg}
|
|
1065
|
+
`);
|
|
1066
|
+
process.exitCode = 1;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
void main();
|
|
1070
|
+
//# sourceMappingURL=index.js.map
|