@rollupdate/hctl 0.0.0-stage → 3.0.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/LICENSE +21 -0
- package/README.md +1066 -2
- package/dist/auction/cache.d.ts +6 -0
- package/dist/auction/cache.js +55 -0
- package/dist/auction/client.d.ts +24 -0
- package/dist/auction/client.js +236 -0
- package/dist/auction/commands.d.ts +2 -0
- package/dist/auction/commands.js +440 -0
- package/dist/auction/diff-formatter.d.ts +3 -0
- package/dist/auction/diff-formatter.js +61 -0
- package/dist/auction/diff.d.ts +11 -0
- package/dist/auction/diff.js +28 -0
- package/dist/auction/formatter.d.ts +21 -0
- package/dist/auction/formatter.js +182 -0
- package/dist/auction/store.d.ts +24 -0
- package/dist/auction/store.js +177 -0
- package/dist/auction/types.d.ts +74 -0
- package/dist/auction/types.js +5 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +4 -0
- package/dist/cloud/browser-bootstrap.d.ts +16 -0
- package/dist/cloud/browser-bootstrap.js +438 -0
- package/dist/cloud/client.d.ts +520 -0
- package/dist/cloud/client.js +755 -0
- package/dist/cloud/commands/certificate.d.ts +2 -0
- package/dist/cloud/commands/certificate.js +108 -0
- package/dist/cloud/commands/context.d.ts +2 -0
- package/dist/cloud/commands/context.js +133 -0
- package/dist/cloud/commands/datacenter.d.ts +2 -0
- package/dist/cloud/commands/datacenter.js +24 -0
- package/dist/cloud/commands/firewall.d.ts +2 -0
- package/dist/cloud/commands/firewall.js +142 -0
- package/dist/cloud/commands/floating-ip.d.ts +2 -0
- package/dist/cloud/commands/floating-ip.js +138 -0
- package/dist/cloud/commands/image.d.ts +2 -0
- package/dist/cloud/commands/image.js +98 -0
- package/dist/cloud/commands/index.d.ts +2 -0
- package/dist/cloud/commands/index.js +47 -0
- package/dist/cloud/commands/inventory.d.ts +2 -0
- package/dist/cloud/commands/inventory.js +13 -0
- package/dist/cloud/commands/iso.d.ts +2 -0
- package/dist/cloud/commands/iso.js +27 -0
- package/dist/cloud/commands/load-balancer-type.d.ts +2 -0
- package/dist/cloud/commands/load-balancer-type.js +24 -0
- package/dist/cloud/commands/load-balancer.d.ts +2 -0
- package/dist/cloud/commands/load-balancer.js +269 -0
- package/dist/cloud/commands/location.d.ts +2 -0
- package/dist/cloud/commands/location.js +24 -0
- package/dist/cloud/commands/network.d.ts +2 -0
- package/dist/cloud/commands/network.js +157 -0
- package/dist/cloud/commands/placement-group.d.ts +2 -0
- package/dist/cloud/commands/placement-group.js +79 -0
- package/dist/cloud/commands/pricing.d.ts +2 -0
- package/dist/cloud/commands/pricing.js +15 -0
- package/dist/cloud/commands/primary-ip.d.ts +2 -0
- package/dist/cloud/commands/primary-ip.js +139 -0
- package/dist/cloud/commands/server-type.d.ts +2 -0
- package/dist/cloud/commands/server-type.js +24 -0
- package/dist/cloud/commands/server.d.ts +2 -0
- package/dist/cloud/commands/server.js +346 -0
- package/dist/cloud/commands/ssh-key.d.ts +2 -0
- package/dist/cloud/commands/ssh-key.js +94 -0
- package/dist/cloud/commands/volume.d.ts +2 -0
- package/dist/cloud/commands/volume.js +138 -0
- package/dist/cloud/context.d.ts +34 -0
- package/dist/cloud/context.js +215 -0
- package/dist/cloud/formatter.d.ts +40 -0
- package/dist/cloud/formatter.js +824 -0
- package/dist/cloud/helpers.d.ts +27 -0
- package/dist/cloud/helpers.js +67 -0
- package/dist/cloud/inventory.d.ts +100 -0
- package/dist/cloud/inventory.js +229 -0
- package/dist/cloud/pricing.d.ts +27 -0
- package/dist/cloud/pricing.js +103 -0
- package/dist/cloud/token-instructions.d.ts +2 -0
- package/dist/cloud/token-instructions.js +21 -0
- package/dist/cloud/types.d.ts +437 -0
- package/dist/cloud/types.js +5 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/formatter.d.ts +3 -0
- package/dist/formatter.js +6 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +21 -0
- package/dist/robot/client.d.ts +246 -0
- package/dist/robot/client.js +719 -0
- package/dist/robot/commands/auth.d.ts +2 -0
- package/dist/robot/commands/auth.js +54 -0
- package/dist/robot/commands/boot.d.ts +2 -0
- package/dist/robot/commands/boot.js +76 -0
- package/dist/robot/commands/cancel.d.ts +2 -0
- package/dist/robot/commands/cancel.js +37 -0
- package/dist/robot/commands/failover.d.ts +2 -0
- package/dist/robot/commands/failover.js +46 -0
- package/dist/robot/commands/firewall.d.ts +2 -0
- package/dist/robot/commands/firewall.js +72 -0
- package/dist/robot/commands/index.d.ts +2 -0
- package/dist/robot/commands/index.js +36 -0
- package/dist/robot/commands/interactive.d.ts +2 -0
- package/dist/robot/commands/interactive.js +152 -0
- package/dist/robot/commands/ip.d.ts +2 -0
- package/dist/robot/commands/ip.js +55 -0
- package/dist/robot/commands/key.d.ts +2 -0
- package/dist/robot/commands/key.js +68 -0
- package/dist/robot/commands/order.d.ts +2 -0
- package/dist/robot/commands/order.js +33 -0
- package/dist/robot/commands/rdns.d.ts +2 -0
- package/dist/robot/commands/rdns.js +41 -0
- package/dist/robot/commands/reset.d.ts +2 -0
- package/dist/robot/commands/reset.js +77 -0
- package/dist/robot/commands/server.d.ts +2 -0
- package/dist/robot/commands/server.js +32 -0
- package/dist/robot/commands/storagebox.d.ts +2 -0
- package/dist/robot/commands/storagebox.js +126 -0
- package/dist/robot/commands/subnet.d.ts +2 -0
- package/dist/robot/commands/subnet.js +24 -0
- package/dist/robot/commands/traffic.d.ts +2 -0
- package/dist/robot/commands/traffic.js +23 -0
- package/dist/robot/commands/vswitch.d.ts +2 -0
- package/dist/robot/commands/vswitch.js +65 -0
- package/dist/robot/commands/wol.d.ts +2 -0
- package/dist/robot/commands/wol.js +20 -0
- package/dist/robot/formatter.d.ts +56 -0
- package/dist/robot/formatter.js +546 -0
- package/dist/robot/types.d.ts +353 -0
- package/dist/robot/types.js +5 -0
- package/dist/shared/config.d.ts +86 -0
- package/dist/shared/config.js +289 -0
- package/dist/shared/formatter.d.ts +29 -0
- package/dist/shared/formatter.js +121 -0
- package/dist/shared/helpers.d.ts +39 -0
- package/dist/shared/helpers.js +86 -0
- package/dist/shared/init.d.ts +2 -0
- package/dist/shared/init.js +107 -0
- package/dist/shared/reference.d.ts +5 -0
- package/dist/shared/reference.js +688 -0
- package/dist/shared/schemas.d.ts +1 -0
- package/dist/shared/schemas.js +1022 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/package.json +131 -4
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { confirm, input, password } from "@inquirer/prompts";
|
|
4
|
+
import { Launcher } from "chrome-launcher";
|
|
5
|
+
import { connect } from "puppeteer-real-browser";
|
|
6
|
+
import { createContext, validateCloudToken } from "./context.js";
|
|
7
|
+
import { HETZNER_CLOUD_CONSOLE_URL } from "./token-instructions.js";
|
|
8
|
+
const ENV_TOKEN_KEY = "HETZNER_CLOUD_TOKEN";
|
|
9
|
+
const TRAILING_NEWLINES_REGEX = /\n+$/;
|
|
10
|
+
const PROJECT_CARD_SELECTOR = '[data-test="project"]';
|
|
11
|
+
const SECURITY_LINK_SELECTOR = '[data-test="main-nav__link--security"]';
|
|
12
|
+
const API_TOKENS_LINK_SELECTOR = 'a[href$="/security/tokens"]';
|
|
13
|
+
const DIALOG_INPUT_SELECTOR = 'input[data-test="input"]';
|
|
14
|
+
const READ_PERMISSION_SELECTOR = '[data-test="radio-item--read"]';
|
|
15
|
+
const READ_WRITE_PERMISSION_SELECTOR = '[data-test="radio-item--read_write"]';
|
|
16
|
+
const SELECT_ALL_MODIFIER = process.platform === "darwin" ? "Meta" : "Control";
|
|
17
|
+
const DISABLE_FEATURES_FLAG = "--disable-features=";
|
|
18
|
+
function sandboxedChromeFlags() {
|
|
19
|
+
const flags = Launcher.defaultFlags()
|
|
20
|
+
.filter((flag) => flag !== "--disable-component-update")
|
|
21
|
+
.filter((flag) => !flag.includes("no-sandbox"));
|
|
22
|
+
return flags.map((flag) => flag.startsWith(DISABLE_FEATURES_FLAG)
|
|
23
|
+
? `${flag},AutomationControlled`
|
|
24
|
+
: flag);
|
|
25
|
+
}
|
|
26
|
+
function resolveEnvFilePath(envFile) {
|
|
27
|
+
if (typeof envFile === "string") {
|
|
28
|
+
return resolve(envFile);
|
|
29
|
+
}
|
|
30
|
+
return resolve(process.cwd(), ".env");
|
|
31
|
+
}
|
|
32
|
+
export function normalizePermission(value) {
|
|
33
|
+
if (value === "read" || value === "read-write") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
throw new Error("Permission must be 'read' or 'read-write'.");
|
|
37
|
+
}
|
|
38
|
+
export function upsertEnvValue(content, key, value) {
|
|
39
|
+
const normalizedContent = content.replace(TRAILING_NEWLINES_REGEX, "");
|
|
40
|
+
const lines = normalizedContent.length > 0 ? normalizedContent.split("\n") : [];
|
|
41
|
+
const nextLine = `${key}=${value}`;
|
|
42
|
+
let found = false;
|
|
43
|
+
const updatedLines = lines.map((line) => {
|
|
44
|
+
if (line.startsWith(`${key}=`)) {
|
|
45
|
+
found = true;
|
|
46
|
+
return nextLine;
|
|
47
|
+
}
|
|
48
|
+
return line;
|
|
49
|
+
});
|
|
50
|
+
if (!found) {
|
|
51
|
+
updatedLines.push(nextLine);
|
|
52
|
+
}
|
|
53
|
+
return `${updatedLines.join("\n").replace(TRAILING_NEWLINES_REGEX, "")}\n`;
|
|
54
|
+
}
|
|
55
|
+
export function writeEnvToken(envFile, token) {
|
|
56
|
+
const currentContent = existsSync(envFile)
|
|
57
|
+
? readFileSync(envFile, "utf-8")
|
|
58
|
+
: "";
|
|
59
|
+
const nextContent = upsertEnvValue(currentContent, ENV_TOKEN_KEY, token);
|
|
60
|
+
mkdirSync(dirname(envFile), { recursive: true });
|
|
61
|
+
writeFileSync(envFile, nextContent, { mode: 0o600 });
|
|
62
|
+
chmodSync(envFile, 0o600);
|
|
63
|
+
}
|
|
64
|
+
export function browserConnectionOptions() {
|
|
65
|
+
return {
|
|
66
|
+
args: [
|
|
67
|
+
...sandboxedChromeFlags(),
|
|
68
|
+
"--start-maximized",
|
|
69
|
+
"--disable-dev-shm-usage",
|
|
70
|
+
],
|
|
71
|
+
connectOption: {
|
|
72
|
+
defaultViewport: null,
|
|
73
|
+
},
|
|
74
|
+
headless: false,
|
|
75
|
+
// puppeteer-real-browser otherwise injects --no-sandbox.
|
|
76
|
+
ignoreAllFlags: true,
|
|
77
|
+
turnstile: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function delay(ms) {
|
|
81
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
82
|
+
}
|
|
83
|
+
async function clickVisibleText(page, labels) {
|
|
84
|
+
const point = await page.evaluate((candidateLabels) => {
|
|
85
|
+
const normalizedLabels = candidateLabels.map((label) => label.toLowerCase().trim());
|
|
86
|
+
const isVisible = (element) => {
|
|
87
|
+
const htmlElement = element;
|
|
88
|
+
const rect = htmlElement.getBoundingClientRect();
|
|
89
|
+
const style = window.getComputedStyle(htmlElement);
|
|
90
|
+
return (rect.width > 0 &&
|
|
91
|
+
rect.height > 0 &&
|
|
92
|
+
style.display !== "none" &&
|
|
93
|
+
style.visibility !== "hidden" &&
|
|
94
|
+
style.opacity !== "0");
|
|
95
|
+
};
|
|
96
|
+
const getTexts = (element) => [
|
|
97
|
+
element.textContent ?? "",
|
|
98
|
+
element.getAttribute("aria-label") ?? "",
|
|
99
|
+
element.getAttribute("title") ?? "",
|
|
100
|
+
element.getAttribute("value") ?? "",
|
|
101
|
+
]
|
|
102
|
+
.map((value) => value.toLowerCase().replace(/\s+/g, " ").trim())
|
|
103
|
+
.filter((value) => value.length > 0);
|
|
104
|
+
const elements = Array.from(document.querySelectorAll("button,a,[role='button'],[role='radio'],input[type='button'],input[type='submit']"));
|
|
105
|
+
const matches = elements.filter((element) => {
|
|
106
|
+
const elementTexts = getTexts(element);
|
|
107
|
+
return (isVisible(element) &&
|
|
108
|
+
normalizedLabels.some((label) => elementTexts.includes(label)));
|
|
109
|
+
});
|
|
110
|
+
const match = matches.at(-1);
|
|
111
|
+
if (!match) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const rect = match.getBoundingClientRect();
|
|
115
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
116
|
+
}, labels);
|
|
117
|
+
if (!point) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
await page.mouse.click(point.x, point.y);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
async function clickVisibleSelector(page, selector) {
|
|
124
|
+
try {
|
|
125
|
+
const element = await page.waitForSelector(selector, {
|
|
126
|
+
timeout: 15_000,
|
|
127
|
+
visible: true,
|
|
128
|
+
});
|
|
129
|
+
if (!element) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
await element.click();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function fillVisibleInput(page, value, labels = []) {
|
|
140
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
141
|
+
const filled = await page.evaluate(({ candidateLabels }) => {
|
|
142
|
+
const normalizedLabels = candidateLabels.map((label) => label.toLowerCase().trim());
|
|
143
|
+
const isVisible = (element) => {
|
|
144
|
+
const htmlElement = element;
|
|
145
|
+
const rect = htmlElement.getBoundingClientRect();
|
|
146
|
+
const style = window.getComputedStyle(htmlElement);
|
|
147
|
+
return (rect.width > 0 &&
|
|
148
|
+
rect.height > 0 &&
|
|
149
|
+
style.display !== "none" &&
|
|
150
|
+
style.visibility !== "hidden" &&
|
|
151
|
+
style.opacity !== "0");
|
|
152
|
+
};
|
|
153
|
+
const getInputText = (inputElement) => [
|
|
154
|
+
inputElement.name,
|
|
155
|
+
inputElement.id,
|
|
156
|
+
inputElement.placeholder,
|
|
157
|
+
inputElement.getAttribute("aria-label") ?? "",
|
|
158
|
+
Array.from(inputElement.labels ?? [])
|
|
159
|
+
.map((label) => label.textContent ?? "")
|
|
160
|
+
.join(" "),
|
|
161
|
+
]
|
|
162
|
+
.join(" ")
|
|
163
|
+
.toLowerCase();
|
|
164
|
+
const inputs = Array.from(document.querySelectorAll("input:not([type='hidden']):not([disabled]),textarea:not([disabled])")).filter(isVisible);
|
|
165
|
+
const matchedInput = [...inputs]
|
|
166
|
+
.reverse()
|
|
167
|
+
.find((inputElement) => normalizedLabels.some((label) => getInputText(inputElement).includes(label))) ?? inputs.at(-1);
|
|
168
|
+
if (!matchedInput) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const rect = matchedInput.getBoundingClientRect();
|
|
172
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
173
|
+
}, { candidateLabels: labels });
|
|
174
|
+
if (filled) {
|
|
175
|
+
await page.mouse.click(filled.x, filled.y);
|
|
176
|
+
await page.keyboard.down(SELECT_ALL_MODIFIER);
|
|
177
|
+
await page.keyboard.press("A");
|
|
178
|
+
await page.keyboard.up(SELECT_ALL_MODIFIER);
|
|
179
|
+
await page.keyboard.type(value);
|
|
180
|
+
await page.keyboard.press("Tab");
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
await delay(500);
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
async function clickTopRightOverlayControl(page) {
|
|
188
|
+
const point = await page.evaluate(() => {
|
|
189
|
+
const isVisible = (element) => {
|
|
190
|
+
const htmlElement = element;
|
|
191
|
+
const rect = htmlElement.getBoundingClientRect();
|
|
192
|
+
const style = window.getComputedStyle(htmlElement);
|
|
193
|
+
return (rect.width > 0 &&
|
|
194
|
+
rect.height > 0 &&
|
|
195
|
+
style.display !== "none" &&
|
|
196
|
+
style.visibility !== "hidden" &&
|
|
197
|
+
style.opacity !== "0");
|
|
198
|
+
};
|
|
199
|
+
const overlays = Array.from(document.querySelectorAll("[role='dialog'],.modal,.dialog")).filter(isVisible);
|
|
200
|
+
const overlay = overlays.find((element) => (element.textContent ?? "").toLowerCase().includes("what's new")) ?? overlays.at(-1);
|
|
201
|
+
if (!overlay) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const overlayRect = overlay.getBoundingClientRect();
|
|
205
|
+
const controls = Array.from(overlay.querySelectorAll("button,[role='button'],a,svg,[aria-label]")).filter(isVisible);
|
|
206
|
+
const candidates = controls
|
|
207
|
+
.map((control) => {
|
|
208
|
+
const rect = control.getBoundingClientRect();
|
|
209
|
+
return {
|
|
210
|
+
score: rect.left + (overlayRect.bottom - rect.top),
|
|
211
|
+
x: rect.left + rect.width / 2,
|
|
212
|
+
y: rect.top + rect.height / 2,
|
|
213
|
+
};
|
|
214
|
+
})
|
|
215
|
+
.sort((left, right) => right.score - left.score);
|
|
216
|
+
return candidates[0] ? { x: candidates[0].x, y: candidates[0].y } : null;
|
|
217
|
+
});
|
|
218
|
+
if (!point) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
await page.mouse.click(point.x, point.y);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
async function closeConsoleOverlays(page) {
|
|
225
|
+
await page.keyboard.press("Escape");
|
|
226
|
+
await delay(500);
|
|
227
|
+
if (!(await clickVisibleText(page, ["close", "dismiss", "×"]))) {
|
|
228
|
+
await clickTopRightOverlayControl(page);
|
|
229
|
+
}
|
|
230
|
+
await delay(500);
|
|
231
|
+
}
|
|
232
|
+
async function clickProjectCard(page, projectName) {
|
|
233
|
+
const point = await page.evaluate(({ name, selector }) => {
|
|
234
|
+
const normalizedName = name.toLowerCase().trim();
|
|
235
|
+
const cards = Array.from(document.querySelectorAll(selector));
|
|
236
|
+
const match = cards.find((card) => {
|
|
237
|
+
const rect = card.getBoundingClientRect();
|
|
238
|
+
const style = window.getComputedStyle(card);
|
|
239
|
+
const text = (card.textContent ?? "")
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
.replace(/\s+/g, " ")
|
|
242
|
+
.trim();
|
|
243
|
+
return (rect.width > 0 &&
|
|
244
|
+
rect.height > 0 &&
|
|
245
|
+
style.visibility !== "hidden" &&
|
|
246
|
+
text.includes(normalizedName));
|
|
247
|
+
});
|
|
248
|
+
if (!match) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const rect = match.getBoundingClientRect();
|
|
252
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
253
|
+
}, { name: projectName, selector: PROJECT_CARD_SELECTOR });
|
|
254
|
+
if (!point) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
await page.mouse.click(point.x, point.y);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
async function createProject(page, projectName) {
|
|
261
|
+
const clickedNewProject = await clickVisibleText(page, [
|
|
262
|
+
"new project",
|
|
263
|
+
"+ new project",
|
|
264
|
+
]);
|
|
265
|
+
if (!clickedNewProject) {
|
|
266
|
+
throw new Error("Could not find the New project control.");
|
|
267
|
+
}
|
|
268
|
+
await delay(1000);
|
|
269
|
+
const filledName = await fillVisibleInput(page, projectName, [
|
|
270
|
+
"project",
|
|
271
|
+
"name",
|
|
272
|
+
]);
|
|
273
|
+
if (!filledName) {
|
|
274
|
+
throw new Error("Could not find the project name field.");
|
|
275
|
+
}
|
|
276
|
+
const clickedCreate = await clickVisibleText(page, ["add project"]);
|
|
277
|
+
if (!clickedCreate) {
|
|
278
|
+
throw new Error("Could not find the Create project button.");
|
|
279
|
+
}
|
|
280
|
+
await delay(3000);
|
|
281
|
+
}
|
|
282
|
+
async function ensureProjectOpen(page, projectName, createIfMissing) {
|
|
283
|
+
await page.goto(`${HETZNER_CLOUD_CONSOLE_URL}/projects`, {
|
|
284
|
+
waitUntil: "domcontentloaded",
|
|
285
|
+
});
|
|
286
|
+
await delay(2000);
|
|
287
|
+
await closeConsoleOverlays(page);
|
|
288
|
+
if (await clickProjectCard(page, projectName)) {
|
|
289
|
+
await delay(2500);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!createIfMissing) {
|
|
293
|
+
throw new Error(`Could not find project '${projectName}'. Rerun with --create-project to create it.`);
|
|
294
|
+
}
|
|
295
|
+
await createProject(page, projectName);
|
|
296
|
+
await closeConsoleOverlays(page);
|
|
297
|
+
if (await clickProjectCard(page, projectName)) {
|
|
298
|
+
await delay(2500);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`Created project '${projectName}' but could not open it.`);
|
|
302
|
+
}
|
|
303
|
+
async function openApiTokenForm(page, tokenName, permission) {
|
|
304
|
+
await closeConsoleOverlays(page);
|
|
305
|
+
const clickedSecurity = await clickVisibleSelector(page, SECURITY_LINK_SELECTOR);
|
|
306
|
+
if (!clickedSecurity) {
|
|
307
|
+
throw new Error("Could not find the Security section.");
|
|
308
|
+
}
|
|
309
|
+
await delay(1500);
|
|
310
|
+
const clickedApiTokens = await clickVisibleSelector(page, API_TOKENS_LINK_SELECTOR);
|
|
311
|
+
if (!clickedApiTokens) {
|
|
312
|
+
throw new Error("Could not find the API Tokens section.");
|
|
313
|
+
}
|
|
314
|
+
await delay(1500);
|
|
315
|
+
const clickedGenerate = await clickVisibleText(page, [
|
|
316
|
+
"generate api token",
|
|
317
|
+
"generate",
|
|
318
|
+
]);
|
|
319
|
+
if (!clickedGenerate) {
|
|
320
|
+
throw new Error("Could not find the Generate API Token button.");
|
|
321
|
+
}
|
|
322
|
+
await delay(1000);
|
|
323
|
+
const inputReady = await page
|
|
324
|
+
.waitForSelector(DIALOG_INPUT_SELECTOR, { timeout: 15_000, visible: true })
|
|
325
|
+
.then(Boolean)
|
|
326
|
+
.catch(() => false);
|
|
327
|
+
const filledTokenName = inputReady &&
|
|
328
|
+
(await fillVisibleInput(page, tokenName, ["description", "name"]));
|
|
329
|
+
if (!filledTokenName) {
|
|
330
|
+
throw new Error("Could not find the API token name field.");
|
|
331
|
+
}
|
|
332
|
+
const permissionClicked = await clickVisibleSelector(page, permission === "read-write"
|
|
333
|
+
? READ_WRITE_PERMISSION_SELECTOR
|
|
334
|
+
: READ_PERMISSION_SELECTOR);
|
|
335
|
+
if (!permissionClicked) {
|
|
336
|
+
throw new Error(`Could not select ${permission} token permissions.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function submitApiTokenForm(page) {
|
|
340
|
+
const clickedFinalGenerate = await clickVisibleText(page, [
|
|
341
|
+
"generate api token",
|
|
342
|
+
]);
|
|
343
|
+
if (!clickedFinalGenerate) {
|
|
344
|
+
throw new Error("Could not find the final Generate token button.");
|
|
345
|
+
}
|
|
346
|
+
await delay(1500);
|
|
347
|
+
}
|
|
348
|
+
async function automateTokenSetup(page, options, tokenName) {
|
|
349
|
+
await ensureProjectOpen(page, options.projectName, options.createProject === true);
|
|
350
|
+
await openApiTokenForm(page, tokenName, options.permission);
|
|
351
|
+
}
|
|
352
|
+
async function openConsole(projectName) {
|
|
353
|
+
const { browser, page } = await connect(browserConnectionOptions());
|
|
354
|
+
await page.goto(HETZNER_CLOUD_CONSOLE_URL, { waitUntil: "domcontentloaded" });
|
|
355
|
+
console.log("");
|
|
356
|
+
console.log("A local real-browser session is open.");
|
|
357
|
+
console.log("Use the browser to log in to Hetzner Cloud, including 2FA.");
|
|
358
|
+
console.log(`After login, hctl will prepare project '${projectName}'.`);
|
|
359
|
+
console.log("");
|
|
360
|
+
console.log("Leave the browser open until you have copied the generated token.");
|
|
361
|
+
return { browser, page };
|
|
362
|
+
}
|
|
363
|
+
export async function runCloudTokenBootstrap(options) {
|
|
364
|
+
const tokenName = options.tokenName ?? `hctl-${options.projectName.toLowerCase()}`;
|
|
365
|
+
const permissionLabel = options.permission === "read-write" ? "Read & Write" : "Read";
|
|
366
|
+
console.log("");
|
|
367
|
+
console.log("Assisted Hetzner Cloud API Token Bootstrap");
|
|
368
|
+
console.log("");
|
|
369
|
+
console.log("This opens a visible local real-browser session.");
|
|
370
|
+
console.log("hctl does not ask for or store your Hetzner console password.");
|
|
371
|
+
console.log("hctl does not scrape the one-time token from the page.");
|
|
372
|
+
console.log("After Hetzner shows the token, copy it yourself and paste it into the hidden prompt.");
|
|
373
|
+
console.log("");
|
|
374
|
+
console.log(`Project: ${options.projectName}`);
|
|
375
|
+
console.log(`Create project if missing: ${options.createProject ? "yes" : "no"}`);
|
|
376
|
+
console.log(`Token name: ${tokenName}`);
|
|
377
|
+
console.log(`Token permission: ${permissionLabel}`);
|
|
378
|
+
console.log(`Context to save: ${options.contextName}`);
|
|
379
|
+
if (options.envFile) {
|
|
380
|
+
console.log(`Also write ${ENV_TOKEN_KEY} to: ${resolveEnvFilePath(options.envFile)}`);
|
|
381
|
+
}
|
|
382
|
+
console.log("");
|
|
383
|
+
const proceed = await confirm({
|
|
384
|
+
default: false,
|
|
385
|
+
message: "Open the Hetzner Cloud Console in a local real-browser session and continue?",
|
|
386
|
+
});
|
|
387
|
+
if (!proceed) {
|
|
388
|
+
console.log("Aborted.");
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
const { browser, page } = await openConsole(options.projectName);
|
|
392
|
+
try {
|
|
393
|
+
await input({
|
|
394
|
+
message: "After logging in to Hetzner Cloud in the browser, press Enter to automate project/token setup.",
|
|
395
|
+
});
|
|
396
|
+
console.log("Automating Hetzner Console setup...");
|
|
397
|
+
await automateTokenSetup(page, options, tokenName);
|
|
398
|
+
console.log("");
|
|
399
|
+
console.log("The API token form should now be ready in the browser.");
|
|
400
|
+
console.log(` - Token name: ${tokenName}`);
|
|
401
|
+
console.log(` - Permission: ${permissionLabel}`);
|
|
402
|
+
console.log("");
|
|
403
|
+
const generateToken = await confirm({
|
|
404
|
+
default: false,
|
|
405
|
+
message: `Generate the '${tokenName}' ${permissionLabel} API token now?`,
|
|
406
|
+
});
|
|
407
|
+
if (!generateToken) {
|
|
408
|
+
console.log("Aborted before token generation.");
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
await submitApiTokenForm(page);
|
|
412
|
+
console.log("The token was generated. Copy it now; Hetzner shows it only once.");
|
|
413
|
+
console.log("");
|
|
414
|
+
const tokenCopied = await confirm({
|
|
415
|
+
default: false,
|
|
416
|
+
message: "Have you copied the generated Hetzner Cloud API token?",
|
|
417
|
+
});
|
|
418
|
+
if (!tokenCopied) {
|
|
419
|
+
console.log("Aborted.");
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
const token = await password({
|
|
423
|
+
message: "Paste the generated Hetzner Cloud API token:",
|
|
424
|
+
validate: (value) => value.trim().length > 0 || "Token is required",
|
|
425
|
+
});
|
|
426
|
+
if (options.verify !== false) {
|
|
427
|
+
await validateCloudToken(token);
|
|
428
|
+
}
|
|
429
|
+
await createContext(options.contextName, token, { verify: false });
|
|
430
|
+
if (options.envFile) {
|
|
431
|
+
writeEnvToken(resolveEnvFilePath(options.envFile), token);
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await browser.close();
|
|
437
|
+
}
|
|
438
|
+
}
|