@rollupdate/hctl 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -322,6 +322,7 @@ hctl firewall template get|delete <id>
322
322
  ```bash
323
323
  hctl vswitch list
324
324
  hctl vswitch get <id>
325
+ hctl vswitch servers [id]
325
326
  hctl vswitch create <name> <vlan>
326
327
  hctl vswitch update <id> [-n <name>] [-v <vlan>]
327
328
  hctl vswitch delete <id> [-y] [--date <YYYY-MM-DD>]
@@ -382,8 +383,10 @@ hctl cloud context active
382
383
 
383
384
  `hctl cloud context bootstrap` is an explicit opt-in assisted browser flow. It
384
385
  opens a visible local real-browser session so you can log in to the Hetzner Cloud
385
- Console yourself, including 2FA. `hctl` does not ask for or store your console
386
- password and does not scrape the one-time API token from the page. After Hetzner
386
+ Console yourself, including 2FA. `hctl` attaches to the browser only after you
387
+ press Enter, so no automation runs during login. It does not ask for or store
388
+ your console password and does not scrape the one-time API token from the page.
389
+ After Hetzner
387
390
  prepares the token form, `hctl` asks for confirmation again before generating
388
391
  the credential. Copy the resulting token yourself and paste it into the hidden
389
392
  CLI prompt. The token is then validated and saved as a normal `hctl` cloud
@@ -813,6 +816,8 @@ import type {
813
816
  |--------|-------------|
814
817
  | `listVSwitches()` | List vSwitches |
815
818
  | `getVSwitch(id)` | Get details |
819
+ | `listVSwitchesWithServers()` | Get details for every vSwitch, including servers |
820
+ | `addVSwitchServerNames(vswitches)` | Add server names to vSwitch details |
816
821
  | `createVSwitch(name, vlan)` | Create |
817
822
  | `updateVSwitch(id, name?, vlan?)` | Update |
818
823
  | `deleteVSwitch(id, date?)` | Delete |
@@ -1,4 +1,5 @@
1
- import { connect } from "puppeteer-real-browser";
1
+ import { type Options } from "chrome-launcher";
2
+ import type { Page } from "playwright-core";
2
3
  export type CloudTokenPermission = "read" | "read-write";
3
4
  export interface CloudTokenBootstrapOptions {
4
5
  contextName: string;
@@ -12,5 +13,16 @@ export interface CloudTokenBootstrapOptions {
12
13
  export declare function normalizePermission(value: string): CloudTokenPermission;
13
14
  export declare function upsertEnvValue(content: string, key: string, value: string): string;
14
15
  export declare function writeEnvToken(envFile: string, token: string): void;
15
- export declare function browserConnectionOptions(): Parameters<typeof connect>[0];
16
+ export declare function chromeLaunchOptions(startingUrl: string, extraFlags?: string[]): Options;
17
+ export interface ConsoleBrowserSession {
18
+ attach: () => Promise<Page>;
19
+ close: () => Promise<void>;
20
+ }
21
+ /**
22
+ * Open Chrome without any automation attached. The user logs in to an ordinary
23
+ * browser session; `attach` connects only once they ask hctl to continue.
24
+ */
25
+ export declare function launchConsoleBrowser(consoleUrl: string, extraFlags?: string[]): Promise<ConsoleBrowserSession>;
26
+ export declare function submitApiTokenForm(page: Page): Promise<void>;
27
+ export declare function automateTokenSetup(page: Page, options: CloudTokenBootstrapOptions, tokenName: string): Promise<void>;
16
28
  export declare function runCloudTokenBootstrap(options: CloudTokenBootstrapOptions): Promise<boolean>;
@@ -1,8 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { confirm, input, password } from "@inquirer/prompts";
4
- import { Launcher } from "chrome-launcher";
5
- import { connect } from "puppeteer-real-browser";
4
+ import { Launcher, launch } from "chrome-launcher";
6
5
  import { createContext, validateCloudToken } from "./context.js";
7
6
  import { HETZNER_CLOUD_CONSOLE_URL } from "./token-instructions.js";
8
7
  const ENV_TOKEN_KEY = "HETZNER_CLOUD_TOKEN";
@@ -15,6 +14,7 @@ const READ_PERMISSION_SELECTOR = '[data-test="radio-item--read"]';
15
14
  const READ_WRITE_PERMISSION_SELECTOR = '[data-test="radio-item--read_write"]';
16
15
  const SELECT_ALL_MODIFIER = process.platform === "darwin" ? "Meta" : "Control";
17
16
  const DISABLE_FEATURES_FLAG = "--disable-features=";
17
+ const ELEMENT_TIMEOUT_MS = 15_000;
18
18
  function sandboxedChromeFlags() {
19
19
  const flags = Launcher.defaultFlags()
20
20
  .filter((flag) => flag !== "--disable-component-update")
@@ -61,20 +61,57 @@ export function writeEnvToken(envFile, token) {
61
61
  writeFileSync(envFile, nextContent, { mode: 0o600 });
62
62
  chmodSync(envFile, 0o600);
63
63
  }
64
- export function browserConnectionOptions() {
64
+ export function chromeLaunchOptions(startingUrl, extraFlags = []) {
65
65
  return {
66
- args: [
66
+ chromeFlags: [
67
67
  ...sandboxedChromeFlags(),
68
68
  "--start-maximized",
69
69
  "--disable-dev-shm-usage",
70
+ ...extraFlags,
70
71
  ],
71
- connectOption: {
72
- defaultViewport: null,
72
+ // The defaults are already filtered into the flags above.
73
+ ignoreDefaultFlags: true,
74
+ startingUrl,
75
+ };
76
+ }
77
+ function findConsolePage(browser, consoleUrl) {
78
+ const { origin } = new URL(consoleUrl);
79
+ const pages = browser.contexts().flatMap((context) => context.pages());
80
+ const page = pages.filter((candidate) => candidate.url().startsWith(origin)).at(-1) ??
81
+ pages.at(-1);
82
+ if (!page) {
83
+ throw new Error("No browser tab is open. Rerun the bootstrap to continue.");
84
+ }
85
+ return page;
86
+ }
87
+ /**
88
+ * Open Chrome without any automation attached. The user logs in to an ordinary
89
+ * browser session; `attach` connects only once they ask hctl to continue.
90
+ */
91
+ export async function launchConsoleBrowser(consoleUrl, extraFlags = []) {
92
+ const chrome = await launch(chromeLaunchOptions(consoleUrl, extraFlags));
93
+ let browser = null;
94
+ return {
95
+ attach: async () => {
96
+ try {
97
+ // Loaded here so other commands do not pay for it at startup.
98
+ const { chromium } = await import("playwright-core");
99
+ browser = await chromium.connectOverCDP(`http://127.0.0.1:${chrome.port}`);
100
+ }
101
+ catch (error) {
102
+ throw new Error("Could not connect to the browser. Keep the window open until setup finishes.", { cause: error });
103
+ }
104
+ return findConsolePage(browser, consoleUrl);
105
+ },
106
+ close: async () => {
107
+ try {
108
+ // Only disconnects: a browser attached over CDP keeps running.
109
+ await browser?.close();
110
+ }
111
+ finally {
112
+ chrome.kill();
113
+ }
73
114
  },
74
- headless: false,
75
- // puppeteer-real-browser otherwise injects --no-sandbox.
76
- ignoreAllFlags: true,
77
- turnstile: true,
78
115
  };
79
116
  }
80
117
  async function delay(ms) {
@@ -123,13 +160,14 @@ async function clickVisibleText(page, labels) {
123
160
  async function clickVisibleSelector(page, selector) {
124
161
  try {
125
162
  const element = await page.waitForSelector(selector, {
126
- timeout: 15_000,
127
- visible: true,
163
+ state: "visible",
164
+ timeout: ELEMENT_TIMEOUT_MS,
128
165
  });
129
166
  if (!element) {
130
167
  return false;
131
168
  }
132
- await element.click();
169
+ // Styled controls can cover their own input, which fails actionability.
170
+ await element.click({ force: true, timeout: ELEMENT_TIMEOUT_MS });
133
171
  return true;
134
172
  }
135
173
  catch {
@@ -321,7 +359,10 @@ async function openApiTokenForm(page, tokenName, permission) {
321
359
  }
322
360
  await delay(1000);
323
361
  const inputReady = await page
324
- .waitForSelector(DIALOG_INPUT_SELECTOR, { timeout: 15_000, visible: true })
362
+ .waitForSelector(DIALOG_INPUT_SELECTOR, {
363
+ state: "visible",
364
+ timeout: ELEMENT_TIMEOUT_MS,
365
+ })
325
366
  .then(Boolean)
326
367
  .catch(() => false);
327
368
  const filledTokenName = inputReady &&
@@ -336,7 +377,7 @@ async function openApiTokenForm(page, tokenName, permission) {
336
377
  throw new Error(`Could not select ${permission} token permissions.`);
337
378
  }
338
379
  }
339
- async function submitApiTokenForm(page) {
380
+ export async function submitApiTokenForm(page) {
340
381
  const clickedFinalGenerate = await clickVisibleText(page, [
341
382
  "generate api token",
342
383
  ]);
@@ -345,20 +386,20 @@ async function submitApiTokenForm(page) {
345
386
  }
346
387
  await delay(1500);
347
388
  }
348
- async function automateTokenSetup(page, options, tokenName) {
389
+ export async function automateTokenSetup(page, options, tokenName) {
349
390
  await ensureProjectOpen(page, options.projectName, options.createProject === true);
350
391
  await openApiTokenForm(page, tokenName, options.permission);
351
392
  }
352
393
  async function openConsole(projectName) {
353
- const { browser, page } = await connect(browserConnectionOptions());
354
- await page.goto(HETZNER_CLOUD_CONSOLE_URL, { waitUntil: "domcontentloaded" });
394
+ const session = await launchConsoleBrowser(HETZNER_CLOUD_CONSOLE_URL);
355
395
  console.log("");
356
396
  console.log("A local real-browser session is open.");
357
397
  console.log("Use the browser to log in to Hetzner Cloud, including 2FA.");
398
+ console.log("hctl does not control the browser until you press Enter.");
358
399
  console.log(`After login, hctl will prepare project '${projectName}'.`);
359
400
  console.log("");
360
401
  console.log("Leave the browser open until you have copied the generated token.");
361
- return { browser, page };
402
+ return session;
362
403
  }
363
404
  export async function runCloudTokenBootstrap(options) {
364
405
  const tokenName = options.tokenName ?? `hctl-${options.projectName.toLowerCase()}`;
@@ -388,11 +429,12 @@ export async function runCloudTokenBootstrap(options) {
388
429
  console.log("Aborted.");
389
430
  return false;
390
431
  }
391
- const { browser, page } = await openConsole(options.projectName);
432
+ const session = await openConsole(options.projectName);
392
433
  try {
393
434
  await input({
394
435
  message: "After logging in to Hetzner Cloud in the browser, press Enter to automate project/token setup.",
395
436
  });
437
+ const page = await session.attach();
396
438
  console.log("Automating Hetzner Console setup...");
397
439
  await automateTokenSetup(page, options, tokenName);
398
440
  console.log("");
@@ -433,6 +475,6 @@ export async function runCloudTokenBootstrap(options) {
433
475
  return true;
434
476
  }
435
477
  finally {
436
- await browser.close();
478
+ await session.close();
437
479
  }
438
480
  }
@@ -1,3 +1,3 @@
1
1
  export { formatAuctionDetails, formatAuctionList, } from "./auction/formatter.js";
2
- export { formatBootConfig, formatCancellation, formatFailoverList, formatFailoverSwitch, formatFirewall, formatFirewallTemplateList, formatIpDetails, formatIpList, formatLinuxActivation, formatRdnsList, formatRescueActivation, formatResetOptions, formatResetResult, formatServerDetails, formatServerList, formatServerMarketProductList, formatServerProductList, formatSshKeyDetails, formatSshKeyList, formatStorageBoxDetails, formatStorageBoxList, formatStorageBoxSnapshots, formatStorageBoxSubaccounts, formatSubnetList, formatTraffic, formatTransactionList, formatVSwitchDetails, formatVSwitchList, formatWolResult, } from "./robot/formatter.js";
2
+ export { formatBootConfig, formatCancellation, formatFailoverList, formatFailoverSwitch, formatFirewall, formatFirewallTemplateList, formatIpDetails, formatIpList, formatLinuxActivation, formatRdnsList, formatRescueActivation, formatResetOptions, formatResetResult, formatServerDetails, formatServerList, formatServerMarketProductList, formatServerProductList, formatSshKeyDetails, formatSshKeyList, formatStorageBoxDetails, formatStorageBoxList, formatStorageBoxSnapshots, formatStorageBoxSubaccounts, formatSubnetList, formatTraffic, formatTransactionList, formatVSwitchDetails, formatVSwitchList, formatVSwitchServers, formatWolResult, } from "./robot/formatter.js";
3
3
  export { colorize, colors, createTable, error, formatBytes, formatDate, formatDateTime, formatJson, formatStatus, heading, info, success, warning, } from "./shared/formatter.js";
package/dist/formatter.js CHANGED
@@ -2,5 +2,5 @@
2
2
  // Auction formatters
3
3
  export { formatAuctionDetails, formatAuctionList, } from "./auction/formatter.js";
4
4
  // Re-export robot-specific formatters for backward compatibility
5
- export { formatBootConfig, formatCancellation, formatFailoverList, formatFailoverSwitch, formatFirewall, formatFirewallTemplateList, formatIpDetails, formatIpList, formatLinuxActivation, formatRdnsList, formatRescueActivation, formatResetOptions, formatResetResult, formatServerDetails, formatServerList, formatServerMarketProductList, formatServerProductList, formatSshKeyDetails, formatSshKeyList, formatStorageBoxDetails, formatStorageBoxList, formatStorageBoxSnapshots, formatStorageBoxSubaccounts, formatSubnetList, formatTraffic, formatTransactionList, formatVSwitchDetails, formatVSwitchList, formatWolResult, } from "./robot/formatter.js";
5
+ export { formatBootConfig, formatCancellation, formatFailoverList, formatFailoverSwitch, formatFirewall, formatFirewallTemplateList, formatIpDetails, formatIpList, formatLinuxActivation, formatRdnsList, formatRescueActivation, formatResetOptions, formatResetResult, formatServerDetails, formatServerList, formatServerMarketProductList, formatServerProductList, formatSshKeyDetails, formatSshKeyList, formatStorageBoxDetails, formatStorageBoxList, formatStorageBoxSnapshots, formatStorageBoxSubaccounts, formatSubnetList, formatTraffic, formatTransactionList, formatVSwitchDetails, formatVSwitchList, formatVSwitchServers, formatWolResult, } from "./robot/formatter.js";
6
6
  export { colorize, colors, createTable, error, formatBytes, formatDate, formatDateTime, formatJson, formatStatus, heading, info, success, warning, } from "./shared/formatter.js";
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { HetznerCloudClient } from "./cloud/client.js";
6
6
  export { createContext, deleteContext, getActiveContext, listContexts, resolveToken, useContext, validateCloudToken, } from "./cloud/context.js";
7
7
  export type { Certificate, CloudAction, CloudApiError, CloudFirewall, CloudFirewallAppliedTo, CloudFirewallRule, CloudServer, CloudSshKey, Datacenter, FloatingIp, Image, ISO, Labels, LoadBalancer, LoadBalancerService, LoadBalancerTarget, LoadBalancerType, Location, Network, NetworkRoute, NetworkSubnet, PaginationMeta, PlacementGroup, PrimaryIp, Protection, ServerType, ServerTypePrice, Volume, } from "./cloud/types.js";
8
8
  export { HetznerRobotClient } from "./robot/client.js";
9
- export type { ApiError, ApiResponse, BootConfig, Cancellation, CpanelConfig, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, PleskConfig, ProductPrice, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerSubnet, ServerTransaction, ServerTransactionProduct, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, TrafficData, VncConfig, VSwitch, VSwitchCloudNetwork, VSwitchServer, VSwitchSubnet, VSwitchSummary, WindowsConfig, Wol, } from "./robot/types.js";
9
+ export type { ApiError, ApiResponse, BootConfig, Cancellation, CpanelConfig, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, NamedVSwitchServer, PleskConfig, ProductPrice, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerSubnet, ServerTransaction, ServerTransactionProduct, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, TrafficData, VncConfig, VSwitch, VSwitchCloudNetwork, VSwitchServer, VSwitchSubnet, VSwitchSummary, VSwitchWithServerNames, WindowsConfig, Wol, } from "./robot/types.js";
10
10
  export { type Config, clearConfig, getCredentials, hasCredentials, loadConfig, promptLogin, requireCredentials, saveConfig, } from "./shared/config.js";
11
11
  export { colorize, colors, error, formatBytes, formatDate, formatDateTime, formatJson, formatStatus, heading, info, success, warning, } from "./shared/formatter.js";
12
12
  export { generateReference } from "./shared/reference.js";
@@ -1,4 +1,4 @@
1
- import type { BootConfig, Cancellation, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerTransaction, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, VncConfig, VSwitch, VSwitchSummary, WindowsConfig, Wol } from "./types.js";
1
+ import type { BootConfig, Cancellation, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerTransaction, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, VncConfig, VSwitch, VSwitchSummary, VSwitchWithServerNames, WindowsConfig, Wol } from "./types.js";
2
2
  export declare class HetznerRobotClient {
3
3
  private readonly auth;
4
4
  constructor(username: string, password: string);
@@ -171,6 +171,16 @@ export declare class HetznerRobotClient {
171
171
  deleteFirewallTemplate(templateId: number): Promise<void>;
172
172
  listVSwitches(): Promise<VSwitchSummary[]>;
173
173
  getVSwitch(id: number): Promise<VSwitch>;
174
+ /**
175
+ * Fetch full details for every vSwitch. The list endpoint omits servers, so
176
+ * this makes one extra request per vSwitch.
177
+ */
178
+ listVSwitchesWithServers(): Promise<VSwitch[]>;
179
+ /**
180
+ * Add server names, which vSwitch responses do not include. Costs one server
181
+ * list request, skipped when no server is attached.
182
+ */
183
+ addVSwitchServerNames(vswitches: VSwitch[]): Promise<VSwitchWithServerNames[]>;
174
184
  createVSwitch(name: string, vlan: number): Promise<VSwitch>;
175
185
  updateVSwitch(id: number, name?: string, vlan?: number): Promise<void>;
176
186
  deleteVSwitch(id: number, cancellationDate?: string): Promise<void>;
@@ -435,6 +435,32 @@ export class HetznerRobotClient {
435
435
  async getVSwitch(id) {
436
436
  return await this.request(`/vswitch/${id}`);
437
437
  }
438
+ /**
439
+ * Fetch full details for every vSwitch. The list endpoint omits servers, so
440
+ * this makes one extra request per vSwitch.
441
+ */
442
+ async listVSwitchesWithServers() {
443
+ const vswitches = await this.listVSwitches();
444
+ return await Promise.all(vswitches.map(({ id }) => this.getVSwitch(id)));
445
+ }
446
+ /**
447
+ * Add server names, which vSwitch responses do not include. Costs one server
448
+ * list request, skipped when no server is attached.
449
+ */
450
+ async addVSwitchServerNames(vswitches) {
451
+ const hasServers = vswitches.some(({ server }) => server.length > 0);
452
+ const servers = hasServers ? await this.listServers() : [];
453
+ const names = new Map(servers
454
+ .filter(({ server }) => server.server_name.length > 0)
455
+ .map(({ server }) => [server.server_number, server.server_name]));
456
+ return vswitches.map((vswitch) => ({
457
+ ...vswitch,
458
+ server: vswitch.server.map((srv) => ({
459
+ ...srv,
460
+ server_name: names.get(srv.server_number) ?? null,
461
+ })),
462
+ }));
463
+ }
438
464
  async createVSwitch(name, vlan) {
439
465
  return await this.request("/vswitch", {
440
466
  method: "POST",
@@ -1,6 +1,13 @@
1
1
  import { success } from "../../shared/formatter.js";
2
2
  import { asyncAction, confirmAction, output, } from "../../shared/helpers.js";
3
- import { formatVSwitchDetails, formatVSwitchList } from "../formatter.js";
3
+ import { formatVSwitchDetails, formatVSwitchList, formatVSwitchServers, } from "../formatter.js";
4
+ const DIGITS_ONLY_REGEX = /^\d+$/;
5
+ function parseVSwitchId(value) {
6
+ if (!DIGITS_ONLY_REGEX.test(value)) {
7
+ throw new Error(`Invalid vSwitch ID: ${value}`);
8
+ }
9
+ return Number.parseInt(value, 10);
10
+ }
4
11
  export function registerVSwitchCommands(parent) {
5
12
  const vswitch = parent.command("vswitch").description("vSwitch management");
6
13
  vswitch
@@ -19,6 +26,15 @@ export function registerVSwitchCommands(parent) {
19
26
  const vs = await client.getVSwitch(Number.parseInt(vswitchId, 10));
20
27
  output(vs, formatVSwitchDetails, options);
21
28
  }));
29
+ vswitch
30
+ .command("servers [id]")
31
+ .description("List servers attached to all vSwitches, or to one vSwitch")
32
+ .action(asyncAction(async (client, vswitchId, options) => {
33
+ const vswitches = vswitchId === undefined
34
+ ? await client.listVSwitchesWithServers()
35
+ : [await client.getVSwitch(parseVSwitchId(vswitchId))];
36
+ output(await client.addVSwitchServerNames(vswitches), formatVSwitchServers, options);
37
+ }));
22
38
  vswitch
23
39
  .command("create <name> <vlan>")
24
40
  .description("Create a new vSwitch")
@@ -1,4 +1,4 @@
1
- import type { BootConfig, Cancellation, Failover, Firewall, FirewallTemplate, IP, LinuxConfig, Rdns, RescueConfig, Reset, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerTransaction, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSubaccount, Subnet, Traffic, VSwitch, VSwitchSummary, Wol } from "./types.js";
1
+ import type { BootConfig, Cancellation, Failover, Firewall, FirewallTemplate, IP, LinuxConfig, Rdns, RescueConfig, Reset, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerTransaction, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSubaccount, Subnet, Traffic, VSwitch, VSwitchSummary, VSwitchWithServerNames, Wol } from "./types.js";
2
2
  export declare function formatServerList(servers: {
3
3
  server: Server;
4
4
  }[]): string;
@@ -31,6 +31,7 @@ export declare function formatFirewallTemplateList(templates: {
31
31
  firewall_template: FirewallTemplate;
32
32
  }[]): string;
33
33
  export declare function formatVSwitchList(vswitches: VSwitchSummary[]): string;
34
+ export declare function formatVSwitchServers(vswitches: VSwitchWithServerNames[]): string;
34
35
  export declare function formatVSwitchDetails(vswitch: VSwitch): string;
35
36
  export declare function formatStorageBoxList(boxes: {
36
37
  storagebox: StorageBox;
@@ -324,6 +324,42 @@ export function formatVSwitchList(vswitches) {
324
324
  }
325
325
  return table.toString();
326
326
  }
327
+ export function formatVSwitchServers(vswitches) {
328
+ if (vswitches.length === 0) {
329
+ return info("No vSwitches found.");
330
+ }
331
+ const table = createTable([
332
+ "vSwitch",
333
+ "Name",
334
+ "VLAN",
335
+ "Cancelled",
336
+ "Server IP",
337
+ "Server #",
338
+ "Server Name",
339
+ "Status",
340
+ ]);
341
+ for (const vswitch of vswitches) {
342
+ const identity = [
343
+ vswitch.id.toString(),
344
+ vswitch.name,
345
+ vswitch.vlan.toString(),
346
+ vswitch.cancelled ? colorize("Yes", "red") : "No",
347
+ ];
348
+ if (vswitch.server.length === 0) {
349
+ table.push([...identity, "-", "-", "-", "-"]);
350
+ }
351
+ for (const srv of vswitch.server) {
352
+ table.push([
353
+ ...identity,
354
+ srv.server_ip,
355
+ srv.server_number.toString(),
356
+ srv.server_name ?? "-",
357
+ formatStatus(srv.status),
358
+ ]);
359
+ }
360
+ }
361
+ return table.toString();
362
+ }
327
363
  export function formatVSwitchDetails(vswitch) {
328
364
  const lines = [];
329
365
  lines.push(heading(`vSwitch ${vswitch.id}: ${vswitch.name}`));
@@ -189,6 +189,12 @@ export interface VSwitchServer {
189
189
  server_number: number;
190
190
  status: "ready" | "in process" | "failed";
191
191
  }
192
+ export interface NamedVSwitchServer extends VSwitchServer {
193
+ server_name: string | null;
194
+ }
195
+ export type VSwitchWithServerNames = Omit<VSwitch, "server"> & {
196
+ server: NamedVSwitchServer[];
197
+ };
192
198
  export interface VSwitchSubnet {
193
199
  gateway: string;
194
200
  ip: string;
@@ -182,6 +182,7 @@ hctl firewall template delete <id> # Delete template (alias: rm)
182
182
  # --- vswitch: vSwitch Management ---
183
183
  hctl vswitch list # List all vSwitches (alias: ls)
184
184
  hctl vswitch get <id> # Get vSwitch details (alias: show)
185
+ hctl vswitch servers [id] # List servers on all vSwitches, or one (one API request per vSwitch, plus one for names)
185
186
  hctl vswitch create <name> <vlan> # Create a new vSwitch
186
187
  hctl vswitch update <id> # Update vSwitch
187
188
  -n, --name <name> # New name
@@ -262,7 +262,15 @@ interface VSwitch {
262
262
  cloud_network: VSwitchCloudNetwork[];
263
263
  }
264
264
 
265
- type VSwitchSummary = Pick<VSwitch, 'id' | 'name' | 'vlan' | 'cancelled'>;`;
265
+ type VSwitchSummary = Pick<VSwitch, 'id' | 'name' | 'vlan' | 'cancelled'>;
266
+
267
+ interface NamedVSwitchServer extends VSwitchServer {
268
+ server_name: string | null;
269
+ }
270
+
271
+ type VSwitchWithServerNames = Omit<VSwitch, 'server'> & {
272
+ server: NamedVSwitchServer[];
273
+ };`;
266
274
  const ROBOT_STORAGEBOX = `\
267
275
  interface StorageBox {
268
276
  id: number;
@@ -948,6 +956,7 @@ export const outputSchemas = {
948
956
  // --- vswitch ---
949
957
  "vswitch list": `${ROBOT_VSWITCH}\n\ntype Output = VSwitchSummary[];`,
950
958
  "vswitch get": `${ROBOT_VSWITCH}\n\ntype Output = VSwitch;`,
959
+ "vswitch servers": `${ROBOT_VSWITCH}\n\ntype Output = VSwitchWithServerNames[];`,
951
960
  // --- storagebox ---
952
961
  "storagebox list": `${ROBOT_STORAGEBOX}\n\ntype Output = { storagebox: StorageBox }[];`,
953
962
  "storagebox get": `${ROBOT_STORAGEBOX}\n\ntype Output = StorageBox;`,
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export type { AuctionDiskData, AuctionFilterOptions, AuctionIpPrice, AuctionResponse, AuctionServer, } from "./auction/types.js";
2
- export type { ApiError, ApiResponse, BootConfig, Cancellation, CpanelConfig, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, PleskConfig, ProductPrice, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerSubnet, ServerTransaction, ServerTransactionProduct, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, TrafficData, VncConfig, VSwitch, VSwitchCloudNetwork, VSwitchServer, VSwitchSubnet, VSwitchSummary, WindowsConfig, Wol, } from "./robot/types.js";
2
+ export type { ApiError, ApiResponse, BootConfig, Cancellation, CpanelConfig, Failover, Firewall, FirewallRule, FirewallTemplate, IP, LinuxConfig, Mac, NamedVSwitchServer, PleskConfig, ProductPrice, Rdns, RescueConfig, Reset, ResetType, Server, ServerDetails, ServerMarketProduct, ServerProduct, ServerSubnet, ServerTransaction, ServerTransactionProduct, SshKey, StorageBox, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxSubaccount, Subnet, Traffic, TrafficData, VncConfig, VSwitch, VSwitchCloudNetwork, VSwitchServer, VSwitchSubnet, VSwitchSummary, VSwitchWithServerNames, WindowsConfig, Wol, } from "./robot/types.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rollupdate/hctl",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Unified CLI and Node.js library for Hetzner Robot API, Cloud API, and server auction",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -100,7 +100,7 @@
100
100
  "commander": "^14.0.3",
101
101
  "dotenv": "^18.0.9",
102
102
  "keytar": "^7.9.0",
103
- "puppeteer-real-browser": "^1.4.4"
103
+ "playwright-core": "^1.65.0"
104
104
  },
105
105
  "devDependencies": {
106
106
  "@biomejs/biome": "2.4.4",
@@ -124,10 +124,6 @@
124
124
  "allowScripts": {
125
125
  "better-sqlite3@12.11.1": true,
126
126
  "fsevents@2.3.3": true,
127
- "keytar@7.9.0": true,
128
- "sleep@6.1.0": true
129
- },
130
- "overrides": {
131
- "rebrowser-puppeteer-core": "^25.16.0"
127
+ "keytar@7.9.0": true
132
128
  }
133
129
  }