@rollupdate/hctl 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +1071 -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 +28 -0
- package/dist/cloud/browser-bootstrap.js +480 -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 +256 -0
- package/dist/robot/client.js +745 -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 +81 -0
- package/dist/robot/commands/wol.d.ts +2 -0
- package/dist/robot/commands/wol.js +20 -0
- package/dist/robot/formatter.d.ts +57 -0
- package/dist/robot/formatter.js +582 -0
- package/dist/robot/types.d.ts +359 -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 +689 -0
- package/dist/shared/schemas.d.ts +1 -0
- package/dist/shared/schemas.js +1031 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/package.json +127 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { colorize, createTable, heading, info } from "../shared/formatter.js";
|
|
2
|
+
function summarizeDisks(server) {
|
|
3
|
+
const parts = [];
|
|
4
|
+
const { nvme, sata, hdd } = server.serverDiskData;
|
|
5
|
+
if (nvme.length > 0) {
|
|
6
|
+
parts.push(`${nvme.length}x ${formatDiskSize(nvme[0])} NVMe`);
|
|
7
|
+
}
|
|
8
|
+
if (sata.length > 0) {
|
|
9
|
+
parts.push(`${sata.length}x ${formatDiskSize(sata[0])} SATA`);
|
|
10
|
+
}
|
|
11
|
+
if (hdd.length > 0) {
|
|
12
|
+
parts.push(`${hdd.length}x ${formatDiskSize(hdd[0])} HDD`);
|
|
13
|
+
}
|
|
14
|
+
return parts.length > 0 ? parts.join(", ") : "-";
|
|
15
|
+
}
|
|
16
|
+
function formatDiskSize(gb) {
|
|
17
|
+
if (gb >= 1000) {
|
|
18
|
+
return `${(gb / 1000).toFixed(gb % 1000 === 0 ? 0 : 1)} TB`;
|
|
19
|
+
}
|
|
20
|
+
return `${gb} GB`;
|
|
21
|
+
}
|
|
22
|
+
function formatNextReduce(server) {
|
|
23
|
+
if (server.fixed_price) {
|
|
24
|
+
return "Fixed";
|
|
25
|
+
}
|
|
26
|
+
if (server.next_reduce <= 0) {
|
|
27
|
+
return "-";
|
|
28
|
+
}
|
|
29
|
+
const hours = Math.floor(server.next_reduce / 60);
|
|
30
|
+
const mins = server.next_reduce % 60;
|
|
31
|
+
if (hours > 0) {
|
|
32
|
+
return `${hours}h ${mins}m`;
|
|
33
|
+
}
|
|
34
|
+
return `${mins}m`;
|
|
35
|
+
}
|
|
36
|
+
function formatUpdatedAt(timestamp) {
|
|
37
|
+
const parsed = new Date(timestamp);
|
|
38
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
39
|
+
return timestamp;
|
|
40
|
+
}
|
|
41
|
+
return parsed.toLocaleString();
|
|
42
|
+
}
|
|
43
|
+
function formatAge(ageSeconds) {
|
|
44
|
+
if (ageSeconds < 60) {
|
|
45
|
+
return `${ageSeconds}s ago`;
|
|
46
|
+
}
|
|
47
|
+
const minutes = Math.floor(ageSeconds / 60);
|
|
48
|
+
if (minutes < 60) {
|
|
49
|
+
return `${minutes}m ago`;
|
|
50
|
+
}
|
|
51
|
+
const hours = Math.floor(minutes / 60);
|
|
52
|
+
const remainingMinutes = minutes % 60;
|
|
53
|
+
return remainingMinutes === 0
|
|
54
|
+
? `${hours}h ago`
|
|
55
|
+
: `${hours}h ${remainingMinutes}m ago`;
|
|
56
|
+
}
|
|
57
|
+
function formatSource(source) {
|
|
58
|
+
if (source === "cloudflare-r2") {
|
|
59
|
+
return "hosted cache";
|
|
60
|
+
}
|
|
61
|
+
if (source === "cloudflare-kv") {
|
|
62
|
+
return "hosted cache fallback";
|
|
63
|
+
}
|
|
64
|
+
if (source === "direct") {
|
|
65
|
+
return "Hetzner direct";
|
|
66
|
+
}
|
|
67
|
+
if (source === "local-cache") {
|
|
68
|
+
return "local cache";
|
|
69
|
+
}
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
export function formatAuctionFetchMetadata(metadata) {
|
|
73
|
+
const source = formatSource(metadata.source);
|
|
74
|
+
const updatedAt = metadata.updatedAt === undefined
|
|
75
|
+
? `fetched ${formatUpdatedAt(metadata.fetchedAt)}`
|
|
76
|
+
: `updated ${formatUpdatedAt(metadata.updatedAt)}`;
|
|
77
|
+
const age = metadata.ageSeconds === undefined
|
|
78
|
+
? ""
|
|
79
|
+
: ` (${formatAge(metadata.ageSeconds)})`;
|
|
80
|
+
const stale = metadata.stale ? " stale" : "";
|
|
81
|
+
return colorize(`Auction data: ${updatedAt}${age} via ${source}${stale}`, "gray");
|
|
82
|
+
}
|
|
83
|
+
export function formatAuctionStatus(status) {
|
|
84
|
+
const table = createTable(["Property", "Value"]);
|
|
85
|
+
table.push(["Currency", status.currency], ["Endpoint", status.endpointUrl], ["Source", formatSource(status.source)], ["Server Count", status.serverCount.toString()], ["Updated", status.updatedAt ? formatUpdatedAt(status.updatedAt) : "-"], [
|
|
86
|
+
"Age",
|
|
87
|
+
status.ageSeconds === undefined ? "-" : formatAge(status.ageSeconds),
|
|
88
|
+
], ["Stale", status.stale ? "Yes" : "No"], ["Offline Fallback", status.usingLocalFallback ? "Yes" : "No"]);
|
|
89
|
+
if (status.localCache !== null) {
|
|
90
|
+
table.push([
|
|
91
|
+
"Local Cache Updated",
|
|
92
|
+
status.localCache.updatedAt
|
|
93
|
+
? formatUpdatedAt(status.localCache.updatedAt)
|
|
94
|
+
: "-",
|
|
95
|
+
], [
|
|
96
|
+
"Local Cache Age",
|
|
97
|
+
status.localCache.ageSeconds === undefined
|
|
98
|
+
? "-"
|
|
99
|
+
: formatAge(status.localCache.ageSeconds),
|
|
100
|
+
], ["Local Cache Servers", status.localCache.serverCount.toString()]);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
table.push(["Local Cache", "Not seeded"]);
|
|
104
|
+
}
|
|
105
|
+
return `${heading("Auction Status")}\n${table.toString()}`;
|
|
106
|
+
}
|
|
107
|
+
export function formatAuctionList(servers) {
|
|
108
|
+
if (servers.length === 0) {
|
|
109
|
+
return info("No auction servers found matching your filters.");
|
|
110
|
+
}
|
|
111
|
+
const table = createTable([
|
|
112
|
+
"ID",
|
|
113
|
+
"CPU",
|
|
114
|
+
"RAM",
|
|
115
|
+
"Disks",
|
|
116
|
+
"DC",
|
|
117
|
+
"Price",
|
|
118
|
+
"Setup",
|
|
119
|
+
"Type",
|
|
120
|
+
"Reduce",
|
|
121
|
+
"Specials",
|
|
122
|
+
]);
|
|
123
|
+
for (const s of servers) {
|
|
124
|
+
const specials = s.specials.length > 0 ? s.specials.join(", ") : "-";
|
|
125
|
+
table.push([
|
|
126
|
+
s.id.toString(),
|
|
127
|
+
s.cpu,
|
|
128
|
+
`${s.ram_size} GB${s.is_ecc ? " ECC" : ""}`,
|
|
129
|
+
summarizeDisks(s),
|
|
130
|
+
s.datacenter,
|
|
131
|
+
`€${s.price.toFixed(2)}`,
|
|
132
|
+
s.setup_price > 0 ? `€${s.setup_price.toFixed(2)}` : "Free",
|
|
133
|
+
s.fixed_price ? "Fixed" : "Auction",
|
|
134
|
+
formatNextReduce(s),
|
|
135
|
+
specials,
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
return `${table.toString()}\n${colorize(`${servers.length} server(s) found`, "gray")}`;
|
|
139
|
+
}
|
|
140
|
+
export function formatAuctionDetails(server) {
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(heading(`Auction Server ${server.id}`));
|
|
143
|
+
lines.push("");
|
|
144
|
+
const table = createTable(["Property", "Value"]);
|
|
145
|
+
table.push(["ID", server.id.toString()], ["Name", server.name], ["CPU", server.cpu], ["CPU Count", server.cpu_count.toString()], ["RAM", `${server.ram_size} GB${server.is_ecc ? " (ECC)" : ""}`], ["RAM Details", server.ram.join(", ")], ["Disks", summarizeDisks(server)], ["Disk Details", server.hdd_hr.join(", ") || "-"], ["Total Disk Size", `${server.hdd_size} GB`], ["Disk Count", server.hdd_count.toString()], ["Datacenter", `${server.datacenter_hr} (${server.datacenter})`], ["Traffic", server.traffic], ["Bandwidth", `${server.bandwidth} Mbit/s`], ["Price", `€${server.price.toFixed(2)}/mo`], ["Hourly Price", `€${server.hourly_price.toFixed(4)}/h`], [
|
|
146
|
+
"Setup Price",
|
|
147
|
+
server.setup_price > 0 ? `€${server.setup_price.toFixed(2)}` : "Free",
|
|
148
|
+
], ["Price Type", server.fixed_price ? "Fixed" : "Auction"], ["Next Reduce", formatNextReduce(server)], [
|
|
149
|
+
"Specials",
|
|
150
|
+
server.specials.length > 0 ? server.specials.join(", ") : "None",
|
|
151
|
+
], ["High I/O", server.is_highio ? "Yes" : "No"], ["ECC", server.is_ecc ? "Yes" : "No"]);
|
|
152
|
+
lines.push(table.toString());
|
|
153
|
+
// Disk breakdown
|
|
154
|
+
const { nvme, sata, hdd } = server.serverDiskData;
|
|
155
|
+
if (nvme.length > 0 || sata.length > 0 || hdd.length > 0) {
|
|
156
|
+
lines.push("");
|
|
157
|
+
lines.push(colorize("Disk Breakdown:", "bold"));
|
|
158
|
+
if (nvme.length > 0) {
|
|
159
|
+
lines.push(` NVMe: ${nvme.map((d) => formatDiskSize(d)).join(", ")}`);
|
|
160
|
+
}
|
|
161
|
+
if (sata.length > 0) {
|
|
162
|
+
lines.push(` SATA: ${sata.map((d) => formatDiskSize(d)).join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
if (hdd.length > 0) {
|
|
165
|
+
lines.push(` HDD: ${hdd.map((d) => formatDiskSize(d)).join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// IP Pricing
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(colorize("Additional IP Pricing:", "bold"));
|
|
171
|
+
lines.push(` Monthly: €${server.ip_price.Monthly.toFixed(2)}`);
|
|
172
|
+
lines.push(` Hourly: €${server.ip_price.Hourly.toFixed(4)}`);
|
|
173
|
+
// Description
|
|
174
|
+
if (server.description.length > 0) {
|
|
175
|
+
lines.push("");
|
|
176
|
+
lines.push(colorize("Description:", "bold"));
|
|
177
|
+
for (const line of server.description) {
|
|
178
|
+
lines.push(` ${line}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { AuctionFetchMetadata } from "./client.js";
|
|
3
|
+
import type { AuctionServer } from "./types.js";
|
|
4
|
+
export interface StoredSnapshot {
|
|
5
|
+
currency: string;
|
|
6
|
+
id: number;
|
|
7
|
+
serverCount: number;
|
|
8
|
+
takenAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CachedAuctionData {
|
|
11
|
+
data: {
|
|
12
|
+
server: AuctionServer[];
|
|
13
|
+
serverCount: number;
|
|
14
|
+
};
|
|
15
|
+
metadata: AuctionFetchMetadata;
|
|
16
|
+
}
|
|
17
|
+
export declare function initSchema(db: Database.Database): void;
|
|
18
|
+
export declare function openDatabase(dbPath?: string): Database.Database;
|
|
19
|
+
export declare function saveSnapshot(db: Database.Database, servers: AuctionServer[], currency: string): StoredSnapshot;
|
|
20
|
+
export declare function getLatestSnapshot(db: Database.Database, currency: string): StoredSnapshot | null;
|
|
21
|
+
export declare function getSnapshotServers(db: Database.Database, snapshotId: number): AuctionServer[];
|
|
22
|
+
export declare function saveAuctionCache(db: Database.Database, currency: string, cached: CachedAuctionData): void;
|
|
23
|
+
export declare function getAuctionCache(db: Database.Database, currency: string): CachedAuctionData | null;
|
|
24
|
+
export declare function pruneSnapshots(db: Database.Database, currency: string, keepCount?: number): number;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".hctl");
|
|
6
|
+
const DB_PATH = join(CONFIG_DIR, "auction.db");
|
|
7
|
+
// Pre-1.0 builds wrote the auction DB under ~/.hetzner-cli. We migrate it once
|
|
8
|
+
// to the new ~/.hctl location so the old directory stops accumulating state.
|
|
9
|
+
const LEGACY_DB_PATH = join(homedir(), ".hetzner-cli", "auction.db");
|
|
10
|
+
function ensureDir(dir) {
|
|
11
|
+
if (!existsSync(dir)) {
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function initSchema(db) {
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
taken_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
server_count INTEGER NOT NULL,
|
|
21
|
+
currency TEXT NOT NULL DEFAULT 'EUR'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS servers (
|
|
25
|
+
snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
|
|
26
|
+
server_id INTEGER NOT NULL,
|
|
27
|
+
price REAL NOT NULL,
|
|
28
|
+
data TEXT NOT NULL,
|
|
29
|
+
PRIMARY KEY (snapshot_id, server_id)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_servers_server_id ON servers(server_id);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS auction_cache (
|
|
35
|
+
currency TEXT PRIMARY KEY,
|
|
36
|
+
data TEXT NOT NULL,
|
|
37
|
+
fetched_at TEXT NOT NULL,
|
|
38
|
+
source TEXT NOT NULL,
|
|
39
|
+
stale INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
updated_at TEXT,
|
|
41
|
+
url TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
`);
|
|
44
|
+
db.pragma("foreign_keys = ON");
|
|
45
|
+
}
|
|
46
|
+
export function openDatabase(dbPath) {
|
|
47
|
+
const path = dbPath ?? resolveDefaultDbPath();
|
|
48
|
+
ensureDir(dirname(path));
|
|
49
|
+
const db = new Database(path);
|
|
50
|
+
db.pragma("journal_mode = WAL");
|
|
51
|
+
db.pragma("busy_timeout = 5000");
|
|
52
|
+
initSchema(db);
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
function resolveDefaultDbPath() {
|
|
56
|
+
if (!existsSync(LEGACY_DB_PATH)) {
|
|
57
|
+
return DB_PATH;
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(DB_PATH)) {
|
|
60
|
+
return DB_PATH;
|
|
61
|
+
}
|
|
62
|
+
ensureDir(dirname(DB_PATH));
|
|
63
|
+
try {
|
|
64
|
+
renameSync(LEGACY_DB_PATH, DB_PATH);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return LEGACY_DB_PATH;
|
|
68
|
+
}
|
|
69
|
+
return DB_PATH;
|
|
70
|
+
}
|
|
71
|
+
export function saveSnapshot(db, servers, currency) {
|
|
72
|
+
const insertSnapshot = db.prepare("INSERT INTO snapshots (server_count, currency) VALUES (?, ?)");
|
|
73
|
+
const insertServer = db.prepare("INSERT INTO servers (snapshot_id, server_id, price, data) VALUES (?, ?, ?, ?)");
|
|
74
|
+
const result = db.transaction(() => {
|
|
75
|
+
const info = insertSnapshot.run(servers.length, currency);
|
|
76
|
+
const snapshotId = info.lastInsertRowid;
|
|
77
|
+
for (const server of servers) {
|
|
78
|
+
insertServer.run(snapshotId, server.id, server.price, JSON.stringify(server));
|
|
79
|
+
}
|
|
80
|
+
return snapshotId;
|
|
81
|
+
})();
|
|
82
|
+
const row = db
|
|
83
|
+
.prepare("SELECT id, taken_at, server_count, currency FROM snapshots WHERE id = ?")
|
|
84
|
+
.get(result);
|
|
85
|
+
return {
|
|
86
|
+
id: row.id,
|
|
87
|
+
takenAt: row.taken_at,
|
|
88
|
+
serverCount: row.server_count,
|
|
89
|
+
currency: row.currency,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function getLatestSnapshot(db, currency) {
|
|
93
|
+
const row = db
|
|
94
|
+
.prepare("SELECT id, taken_at, server_count, currency FROM snapshots WHERE currency = ? ORDER BY id DESC LIMIT 1")
|
|
95
|
+
.get(currency);
|
|
96
|
+
if (!row) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: row.id,
|
|
101
|
+
takenAt: row.taken_at,
|
|
102
|
+
serverCount: row.server_count,
|
|
103
|
+
currency: row.currency,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function getSnapshotServers(db, snapshotId) {
|
|
107
|
+
const rows = db
|
|
108
|
+
.prepare("SELECT data FROM servers WHERE snapshot_id = ?")
|
|
109
|
+
.all(snapshotId);
|
|
110
|
+
return rows.map((row) => parseStoredServer(row.data));
|
|
111
|
+
}
|
|
112
|
+
function parseStoredServer(raw) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
118
|
+
throw new Error(`Stored auction server payload is not valid JSON (schema drift or DB corruption): ${detail}`, { cause: err });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function saveAuctionCache(db, currency, cached) {
|
|
122
|
+
db.prepare(`INSERT INTO auction_cache
|
|
123
|
+
(currency, data, fetched_at, source, stale, updated_at, url)
|
|
124
|
+
VALUES
|
|
125
|
+
(?, ?, ?, ?, ?, ?, ?)
|
|
126
|
+
ON CONFLICT(currency) DO UPDATE SET
|
|
127
|
+
data = excluded.data,
|
|
128
|
+
fetched_at = excluded.fetched_at,
|
|
129
|
+
source = excluded.source,
|
|
130
|
+
stale = excluded.stale,
|
|
131
|
+
updated_at = excluded.updated_at,
|
|
132
|
+
url = excluded.url`).run(currency, JSON.stringify(cached.data), cached.metadata.fetchedAt, cached.metadata.source, cached.metadata.stale ? 1 : 0, cached.metadata.updatedAt ?? null, cached.metadata.url);
|
|
133
|
+
}
|
|
134
|
+
export function getAuctionCache(db, currency) {
|
|
135
|
+
const row = db
|
|
136
|
+
.prepare("SELECT data, fetched_at, source, stale, updated_at, url FROM auction_cache WHERE currency = ?")
|
|
137
|
+
.get(currency);
|
|
138
|
+
if (row === undefined) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const updatedAt = row.updated_at ?? row.fetched_at;
|
|
142
|
+
const updatedTime = Date.parse(updatedAt);
|
|
143
|
+
const ageSeconds = Number.isNaN(updatedTime)
|
|
144
|
+
? undefined
|
|
145
|
+
: Math.max(0, Math.floor((Date.now() - updatedTime) / 1000));
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(row.data);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
data: parsed,
|
|
155
|
+
metadata: {
|
|
156
|
+
ageSeconds,
|
|
157
|
+
fetchedAt: row.fetched_at,
|
|
158
|
+
source: "local-cache",
|
|
159
|
+
stale: true,
|
|
160
|
+
updatedAt,
|
|
161
|
+
url: row.url,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export function pruneSnapshots(db, currency, keepCount = 50) {
|
|
166
|
+
const result = db
|
|
167
|
+
.prepare(`DELETE FROM snapshots
|
|
168
|
+
WHERE currency = ?
|
|
169
|
+
AND id NOT IN (
|
|
170
|
+
SELECT id FROM snapshots
|
|
171
|
+
WHERE currency = ?
|
|
172
|
+
ORDER BY id DESC
|
|
173
|
+
LIMIT ?
|
|
174
|
+
)`)
|
|
175
|
+
.run(currency, currency, keepCount);
|
|
176
|
+
return result.changes;
|
|
177
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface AuctionDiskData {
|
|
2
|
+
general: number[];
|
|
3
|
+
hdd: number[];
|
|
4
|
+
nvme: number[];
|
|
5
|
+
sata: number[];
|
|
6
|
+
}
|
|
7
|
+
export interface AuctionIpPrice {
|
|
8
|
+
Amount: number;
|
|
9
|
+
Hourly: number;
|
|
10
|
+
Monthly: number;
|
|
11
|
+
}
|
|
12
|
+
export interface AuctionServer {
|
|
13
|
+
bandwidth: number;
|
|
14
|
+
cat_id?: number;
|
|
15
|
+
category?: string;
|
|
16
|
+
cpu: string;
|
|
17
|
+
cpu_count: number;
|
|
18
|
+
datacenter: string;
|
|
19
|
+
datacenter_hr: string;
|
|
20
|
+
description: string[];
|
|
21
|
+
dist: string[];
|
|
22
|
+
fixed_price: boolean;
|
|
23
|
+
hdd_arr: string[];
|
|
24
|
+
hdd_count: number;
|
|
25
|
+
hdd_hr: string[];
|
|
26
|
+
hdd_size: number;
|
|
27
|
+
hourly_price: number;
|
|
28
|
+
id: number;
|
|
29
|
+
information: string[] | null;
|
|
30
|
+
ip_price: AuctionIpPrice;
|
|
31
|
+
is_ecc: boolean;
|
|
32
|
+
is_highio: boolean;
|
|
33
|
+
key: number;
|
|
34
|
+
name: string;
|
|
35
|
+
next_reduce: number;
|
|
36
|
+
next_reduce_hr: boolean;
|
|
37
|
+
next_reduce_timestamp: number;
|
|
38
|
+
price: number;
|
|
39
|
+
ram: string[];
|
|
40
|
+
ram_size: number;
|
|
41
|
+
serverDiskData: AuctionDiskData;
|
|
42
|
+
setup_price: number;
|
|
43
|
+
specials: string[];
|
|
44
|
+
traffic: string;
|
|
45
|
+
}
|
|
46
|
+
export interface AuctionFilterOptions {
|
|
47
|
+
cpu?: string;
|
|
48
|
+
datacenter?: string;
|
|
49
|
+
diskType?: "nvme" | "sata" | "hdd";
|
|
50
|
+
ecc?: boolean;
|
|
51
|
+
fixedPrice?: boolean;
|
|
52
|
+
gpu?: boolean;
|
|
53
|
+
highio?: boolean;
|
|
54
|
+
inic?: boolean;
|
|
55
|
+
maxCpuCount?: number;
|
|
56
|
+
maxDiskCount?: number;
|
|
57
|
+
maxDiskSize?: number;
|
|
58
|
+
maxHourlyPrice?: number;
|
|
59
|
+
maxPrice?: number;
|
|
60
|
+
maxRam?: number;
|
|
61
|
+
maxSetupPrice?: number;
|
|
62
|
+
minBandwidth?: number;
|
|
63
|
+
minCpuCount?: number;
|
|
64
|
+
minDiskCount?: number;
|
|
65
|
+
minDiskSize?: number;
|
|
66
|
+
minPrice?: number;
|
|
67
|
+
minRam?: number;
|
|
68
|
+
specials?: string;
|
|
69
|
+
text?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface AuctionResponse {
|
|
72
|
+
server: AuctionServer[];
|
|
73
|
+
serverCount: number;
|
|
74
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Hetzner Auction Types
|
|
3
|
+
// Public API: https://www.hetzner.com/_resources/app/data/app/live_data_sb_EUR.json
|
|
4
|
+
// ============================================================================
|
|
5
|
+
export {};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command, Option } from "commander";
|
|
4
|
+
import { config } from "dotenv";
|
|
5
|
+
import { registerAuctionCommands } from "./auction/commands.js";
|
|
6
|
+
import { registerCloudCommands } from "./cloud/commands/index.js";
|
|
7
|
+
import { registerRobotCommands } from "./robot/commands/index.js";
|
|
8
|
+
import { registerInitCommand } from "./shared/init.js";
|
|
9
|
+
import { generateReference, } from "./shared/reference.js";
|
|
10
|
+
import { outputSchemas } from "./shared/schemas.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { version } = require("../package.json");
|
|
13
|
+
// `quiet: true` suppresses dotenv 17's "[dotenv@…] injecting env (0) from .env"
|
|
14
|
+
// banner that otherwise lands on stdout for every command. The banner breaks
|
|
15
|
+
// any consumer that pipes `hctl --json …` into jq, since the first line of
|
|
16
|
+
// stdout is no longer parseable JSON.
|
|
17
|
+
config({ quiet: true });
|
|
18
|
+
const program = new Command();
|
|
19
|
+
program
|
|
20
|
+
.name("hctl")
|
|
21
|
+
.description("Unified CLI for Hetzner Robot API (dedicated servers), Cloud API, and public server auction.\n\n" +
|
|
22
|
+
" Three APIs, one tool:\n" +
|
|
23
|
+
" Robot commands — manage dedicated servers (requires Robot web service credentials)\n" +
|
|
24
|
+
' Cloud commands — manage cloud infrastructure under "hctl cloud ..." (requires Cloud API token)\n' +
|
|
25
|
+
' Auction commands — browse server auction under "hctl auction ..." (no auth required)\n\n' +
|
|
26
|
+
' Run "hctl reference" for a complete structured reference of all commands and options.\n' +
|
|
27
|
+
' Run "hctl <command> --help" for help on a specific command.')
|
|
28
|
+
.version(version)
|
|
29
|
+
.option("-u, --user <username>", "Hetzner Robot web service username (or set HETZNER_ROBOT_USER)")
|
|
30
|
+
.option("-p, --password <password>", 'Hetzner Robot web service password (or set HETZNER_ROBOT_PASSWORD; use "-" to read from stdin)')
|
|
31
|
+
.option("--json", "Output raw JSON instead of formatted tables")
|
|
32
|
+
.option("--output-schema", "Print TypeScript type for --json output and exit (no API call)");
|
|
33
|
+
// Top-level setup wizard for first-time users
|
|
34
|
+
registerInitCommand(program);
|
|
35
|
+
// Register all robot commands directly on program (backward compat)
|
|
36
|
+
registerRobotCommands(program);
|
|
37
|
+
// Register cloud commands under 'hctl cloud ...'
|
|
38
|
+
registerCloudCommands(program);
|
|
39
|
+
// Register auction commands (public API, no auth required)
|
|
40
|
+
registerAuctionCommands(program);
|
|
41
|
+
// Built-in reference command (structured docs for LLMs and humans)
|
|
42
|
+
program
|
|
43
|
+
.command("reference")
|
|
44
|
+
.alias("ref")
|
|
45
|
+
.description("Print complete CLI reference (optimized for LLM context)")
|
|
46
|
+
.addOption(new Option("--section <section>", "Show only a specific section").choices([
|
|
47
|
+
"robot",
|
|
48
|
+
"cloud",
|
|
49
|
+
"auction",
|
|
50
|
+
]))
|
|
51
|
+
.action((options) => {
|
|
52
|
+
console.log(generateReference(options.section, { version }));
|
|
53
|
+
});
|
|
54
|
+
program.hook("preAction", (thisCommand, actionCommand) => {
|
|
55
|
+
if (thisCommand.opts().outputSchema) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
let cmd = actionCommand;
|
|
58
|
+
while (cmd && cmd !== program) {
|
|
59
|
+
parts.unshift(cmd.name());
|
|
60
|
+
cmd = cmd.parent;
|
|
61
|
+
}
|
|
62
|
+
const path = parts.join(" ");
|
|
63
|
+
const schema = outputSchemas[path];
|
|
64
|
+
if (schema) {
|
|
65
|
+
console.log(schema);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error(`No output schema for: hctl ${path}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
program.parse();
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { type AuctionFetchMetadata, type AuctionFetchOptions, type AuctionFetchResult, type AuctionSource, DEFAULT_AUCTION_CACHE_BASE_URL, fetchAuctionData, fetchAuctionServers, filterAuctionServers, getAuctionDataUrl, sortAuctionServers, } from "./auction/client.js";
|
|
2
|
+
export { HetznerRobotClient } from "./robot/client.js";
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Re-export from new location for backward compatibility
|
|
2
|
+
// Auction client (public API, no auth)
|
|
3
|
+
export { DEFAULT_AUCTION_CACHE_BASE_URL, fetchAuctionData, fetchAuctionServers, filterAuctionServers, getAuctionDataUrl, sortAuctionServers, } from "./auction/client.js";
|
|
4
|
+
export { HetznerRobotClient } from "./robot/client.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Options } from "chrome-launcher";
|
|
2
|
+
import type { Page } from "playwright-core";
|
|
3
|
+
export type CloudTokenPermission = "read" | "read-write";
|
|
4
|
+
export interface CloudTokenBootstrapOptions {
|
|
5
|
+
contextName: string;
|
|
6
|
+
createProject?: boolean;
|
|
7
|
+
envFile?: string | boolean;
|
|
8
|
+
permission: CloudTokenPermission;
|
|
9
|
+
projectName: string;
|
|
10
|
+
tokenName?: string;
|
|
11
|
+
verify?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function normalizePermission(value: string): CloudTokenPermission;
|
|
14
|
+
export declare function upsertEnvValue(content: string, key: string, value: string): string;
|
|
15
|
+
export declare function writeEnvToken(envFile: string, token: string): void;
|
|
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>;
|
|
28
|
+
export declare function runCloudTokenBootstrap(options: CloudTokenBootstrapOptions): Promise<boolean>;
|