@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/CHANGELOG.md +10 -0
- package/dist/index.js +1173 -1207
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
24
|
+
return join(configDir(), "config.json");
|
|
28
25
|
}
|
|
29
26
|
function load() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`, { mode: 384 });
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
+
return name.trim().toLowerCase().replace(/\s+/g, "-");
|
|
127
126
|
}
|
|
128
127
|
function maskKey(key) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
if (!key) return "";
|
|
129
|
+
if (key.length <= 8) return "****";
|
|
130
|
+
return `${key.slice(0, 6)}…${key.slice(-4)}`;
|
|
132
131
|
}
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/lib/output.ts
|
|
135
134
|
function printJSON(value) {
|
|
136
|
-
|
|
137
|
-
`);
|
|
135
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
138
136
|
}
|
|
139
137
|
function printTable(header, rows) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
`);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
421
|
-
import { Command as Command2 } from "commander";
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/commands/catalog.ts
|
|
422
418
|
function fmtVal(v) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
import { Command as Command3 } from "commander";
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/commands/context.ts
|
|
488
471
|
function contextCommand() {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
`);
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
`);
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/commands/cubes.ts
|
|
577
546
|
function listCmd() {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
822
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
812
823
|
function loginCommand() {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
883
|
-
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/commands/snapshots.ts
|
|
878
|
+
/** `krova snapshots` — snapshot and restore a Cube's disk. */
|
|
884
879
|
function snapshotsCommand() {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
982
|
-
|
|
966
|
+
if (port > 0 && port !== 22) return `[${host}]:${port}`;
|
|
967
|
+
return host;
|
|
983
968
|
}
|
|
984
969
|
function knownHostsPath() {
|
|
985
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/commands/tcp.ts
|
|
1062
|
+
/** `krova tcp` — manage a Cube's TCP port mappings. */
|
|
1089
1063
|
function tcpCommand() {
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1262
|
-
import { Command as Command12 } from "commander";
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/commands/whoami.ts
|
|
1263
1231
|
function whoamiCommand() {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1315
|
+
main();
|
|
1316
|
+
//#endregion
|
|
1317
|
+
export {};
|
|
1318
|
+
|
|
1353
1319
|
//# sourceMappingURL=index.js.map
|