@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,440 @@
|
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
import { colorize, error, formatJson, info, success, warning, } from "../shared/formatter.js";
|
|
3
|
+
import { fetchAuctionDataWithCache } from "./cache.js";
|
|
4
|
+
import { filterAuctionServers, sortAuctionServers, } from "./client.js";
|
|
5
|
+
import { computeDiff } from "./diff.js";
|
|
6
|
+
import { formatDiff } from "./diff-formatter.js";
|
|
7
|
+
import { formatAuctionDetails, formatAuctionFetchMetadata, formatAuctionList, formatAuctionStatus, } from "./formatter.js";
|
|
8
|
+
import { getAuctionCache, getLatestSnapshot, getSnapshotServers, openDatabase, pruneSnapshots, saveSnapshot, } from "./store.js";
|
|
9
|
+
function resolveFixedPrice(fixedPrice, auctionOnly) {
|
|
10
|
+
if (fixedPrice) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (auctionOnly) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function parseNum(val) {
|
|
19
|
+
if (val === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const n = Number(val);
|
|
23
|
+
return Number.isNaN(n) ? undefined : n;
|
|
24
|
+
}
|
|
25
|
+
function buildFilters(options) {
|
|
26
|
+
return {
|
|
27
|
+
minPrice: parseNum(options.minPrice),
|
|
28
|
+
maxPrice: parseNum(options.maxPrice),
|
|
29
|
+
maxHourlyPrice: parseNum(options.maxHourlyPrice),
|
|
30
|
+
minRam: parseNum(options.minRam),
|
|
31
|
+
maxRam: parseNum(options.maxRam),
|
|
32
|
+
cpu: options.cpu,
|
|
33
|
+
datacenter: options.datacenter,
|
|
34
|
+
minDiskSize: parseNum(options.minDiskSize),
|
|
35
|
+
maxDiskSize: parseNum(options.maxDiskSize),
|
|
36
|
+
minDiskCount: parseNum(options.minDiskCount),
|
|
37
|
+
maxDiskCount: parseNum(options.maxDiskCount),
|
|
38
|
+
diskType: options.diskType,
|
|
39
|
+
minBandwidth: parseNum(options.minBandwidth),
|
|
40
|
+
ecc: options.ecc ? true : undefined,
|
|
41
|
+
gpu: options.gpu ? true : undefined,
|
|
42
|
+
inic: options.inic ? true : undefined,
|
|
43
|
+
highio: options.highio ? true : undefined,
|
|
44
|
+
specials: options.specials,
|
|
45
|
+
fixedPrice: resolveFixedPrice(options.fixedPrice, options.auctionOnly),
|
|
46
|
+
maxSetupPrice: options.noSetupFee ? 0 : parseNum(options.maxSetupPrice),
|
|
47
|
+
minCpuCount: parseNum(options.minCpuCount),
|
|
48
|
+
maxCpuCount: parseNum(options.maxCpuCount),
|
|
49
|
+
text: options.search,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function buildFetchOptions(options) {
|
|
53
|
+
return options.direct ? { source: "direct" } : {};
|
|
54
|
+
}
|
|
55
|
+
/** Status messages stay off stdout in JSON mode so it remains parseable. */
|
|
56
|
+
function statusLog(options) {
|
|
57
|
+
return options.json ? console.error : console.log;
|
|
58
|
+
}
|
|
59
|
+
function maybeWarnAboutLocalCache(metadata, options) {
|
|
60
|
+
if (options.json && metadata.source === "local-cache") {
|
|
61
|
+
console.error(warning(formatAuctionFetchMetadata(metadata)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function toCacheStatus(result) {
|
|
65
|
+
return {
|
|
66
|
+
ageSeconds: result.metadata.ageSeconds,
|
|
67
|
+
fetchedAt: result.metadata.fetchedAt,
|
|
68
|
+
serverCount: result.data.serverCount ?? result.data.server.length,
|
|
69
|
+
source: result.metadata.source,
|
|
70
|
+
stale: result.metadata.stale ?? false,
|
|
71
|
+
updatedAt: result.metadata.updatedAt,
|
|
72
|
+
url: result.metadata.url,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function readLocalAuctionCacheStatus(currency) {
|
|
76
|
+
let db = null;
|
|
77
|
+
try {
|
|
78
|
+
db = openDatabase();
|
|
79
|
+
const cached = getAuctionCache(db, currency);
|
|
80
|
+
return cached === null ? null : toCacheStatus(cached);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
db?.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function getAuctionStatusSummary(currency, options) {
|
|
90
|
+
const result = await fetchAuctionDataWithCache(currency, {
|
|
91
|
+
...buildFetchOptions(options),
|
|
92
|
+
allowLocalFallback: true,
|
|
93
|
+
});
|
|
94
|
+
const current = toCacheStatus(result);
|
|
95
|
+
return {
|
|
96
|
+
...current,
|
|
97
|
+
currency,
|
|
98
|
+
endpointUrl: result.metadata.url,
|
|
99
|
+
localCache: readLocalAuctionCacheStatus(currency),
|
|
100
|
+
usingLocalFallback: result.metadata.source === "local-cache",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const EMPTY_PAYLOAD_RATIO = 0.5;
|
|
104
|
+
function isSuspiciousEmptyPayload(servers, previous) {
|
|
105
|
+
if (servers.length === 0) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (previous && previous.serverCount > 0) {
|
|
109
|
+
return servers.length < previous.serverCount * EMPTY_PAYLOAD_RATIO;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
function fetchAndDiff(db, servers, currency, options) {
|
|
114
|
+
const previous = getLatestSnapshot(db, currency);
|
|
115
|
+
if (!previous) {
|
|
116
|
+
if (isSuspiciousEmptyPayload(servers, null)) {
|
|
117
|
+
console.error(warning("Skipping initial snapshot: upstream returned no servers"));
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const snapshot = saveSnapshot(db, servers, currency);
|
|
121
|
+
statusLog(options)(success(`Initial snapshot saved (${snapshot.serverCount} servers, snapshot #${snapshot.id})`));
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (isSuspiciousEmptyPayload(servers, previous)) {
|
|
125
|
+
console.error(warning(`Skipping snapshot: upstream returned ${servers.length} server(s) (was ${previous.serverCount}). Refusing to overwrite baseline with suspicious payload.`));
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const previousServers = getSnapshotServers(db, previous.id);
|
|
129
|
+
const diff = computeDiff(previousServers, servers);
|
|
130
|
+
const hasChanges = diff.added.length > 0 ||
|
|
131
|
+
diff.removed.length > 0 ||
|
|
132
|
+
diff.priceChanged.length > 0;
|
|
133
|
+
if (!hasChanges) {
|
|
134
|
+
return { previous, current: previous, diff };
|
|
135
|
+
}
|
|
136
|
+
const current = saveSnapshot(db, servers, currency);
|
|
137
|
+
pruneSnapshots(db, currency);
|
|
138
|
+
return { previous, current, diff };
|
|
139
|
+
}
|
|
140
|
+
async function pollOnce(db, currency, options) {
|
|
141
|
+
const { data: { server: servers }, metadata, } = await fetchAuctionDataWithCache(currency, {
|
|
142
|
+
allowLocalFallback: false,
|
|
143
|
+
source: options.source,
|
|
144
|
+
});
|
|
145
|
+
const result = fetchAndDiff(db, servers, currency, options);
|
|
146
|
+
if (!result) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const { diff, previous: prev, current: curr } = result;
|
|
150
|
+
const hasChanges = diff.added.length > 0 ||
|
|
151
|
+
diff.removed.length > 0 ||
|
|
152
|
+
diff.priceChanged.length > 0;
|
|
153
|
+
if (options.quiet && !hasChanges) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (options.json) {
|
|
157
|
+
console.log(formatJson({ timestamp: curr.takenAt, ...diff }));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const ts = colorize(`[${new Date().toLocaleTimeString()}]`, "dim");
|
|
161
|
+
console.log(`${ts} ${formatAuctionFetchMetadata(metadata)}\n${formatDiff(diff, prev, curr)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function auctionAction(fn) {
|
|
165
|
+
return async () => {
|
|
166
|
+
try {
|
|
167
|
+
await fn();
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
console.error(error(err instanceof Error ? err.message : "Failed to fetch auction data"));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function registerAuctionCommands(parent) {
|
|
176
|
+
const auction = parent
|
|
177
|
+
.command("auction")
|
|
178
|
+
.description("Server auction monitoring (public, no auth required).\n" +
|
|
179
|
+
"Fetches the current public auction inventory from the hosted auction cache by default and filters/sorts client-side.\n" +
|
|
180
|
+
"Typical ranges: 30-450 EUR/mo, 32-1024 GB RAM, NVMe/SATA/HDD, datacenters in FSN/HEL/NBG.");
|
|
181
|
+
// The root command also declares --json and consumes the flag wherever it
|
|
182
|
+
// appears, so copy it onto the subcommand that is about to run.
|
|
183
|
+
auction.hook("preAction", (_auction, actionCommand) => {
|
|
184
|
+
if (actionCommand.optsWithGlobals().json) {
|
|
185
|
+
actionCommand.setOptionValue("json", true);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
auction
|
|
189
|
+
.command("list")
|
|
190
|
+
.alias("ls")
|
|
191
|
+
.description("List and filter auction servers.\n" +
|
|
192
|
+
"All filters are optional and combinable. String filters (cpu, datacenter, specials, search) are case-insensitive substrings.\n" +
|
|
193
|
+
"Examples:\n" +
|
|
194
|
+
" hctl auction list --cpu epyc --ecc --disk-type nvme --datacenter HEL --sort price\n" +
|
|
195
|
+
" hctl auction list --gpu --max-price 150 --json\n" +
|
|
196
|
+
" hctl auction list --auction-only --sort next_reduce --limit 20")
|
|
197
|
+
.option("--min-price <n>", "Minimum monthly price")
|
|
198
|
+
.option("--max-price <n>", "Maximum monthly price")
|
|
199
|
+
.option("--max-hourly-price <n>", "Maximum hourly price")
|
|
200
|
+
.option("--min-ram <gb>", "Minimum RAM in GB")
|
|
201
|
+
.option("--max-ram <gb>", "Maximum RAM in GB")
|
|
202
|
+
.option("--cpu <text>", 'CPU model substring (e.g. "Ryzen", "i7-6700")')
|
|
203
|
+
.option("--min-cpu-count <n>", "Minimum CPU/socket count")
|
|
204
|
+
.option("--max-cpu-count <n>", "Maximum CPU/socket count")
|
|
205
|
+
.option("--datacenter <text>", 'Datacenter substring (e.g. "FSN", "HEL1-DC2")')
|
|
206
|
+
.option("--min-disk-size <gb>", "Minimum total disk capacity in GB")
|
|
207
|
+
.option("--max-disk-size <gb>", "Maximum total disk capacity in GB")
|
|
208
|
+
.option("--min-disk-count <n>", "Minimum number of drives")
|
|
209
|
+
.option("--max-disk-count <n>", "Maximum number of drives")
|
|
210
|
+
.addOption(new Option("--disk-type <type>", "Filter by disk type present").choices([
|
|
211
|
+
"nvme",
|
|
212
|
+
"sata",
|
|
213
|
+
"hdd",
|
|
214
|
+
]))
|
|
215
|
+
.option("--min-bandwidth <mbit>", "Minimum bandwidth in Mbit/s")
|
|
216
|
+
.option("--ecc", "Only ECC RAM servers")
|
|
217
|
+
.option("--gpu", "Only GPU servers")
|
|
218
|
+
.option("--inic", "Only servers with Intel NIC")
|
|
219
|
+
.option("--highio", "Only high I/O servers")
|
|
220
|
+
.option("--specials <text>", "Filter by special feature (substring match)")
|
|
221
|
+
.option("--fixed-price", "Only fixed-price servers")
|
|
222
|
+
.option("--auction-only", "Only auction (non-fixed) servers")
|
|
223
|
+
.option("--no-setup-fee", "Only servers with no setup fee")
|
|
224
|
+
.option("--max-setup-price <n>", "Maximum setup price")
|
|
225
|
+
.option("--search <text>", "Free-text search across description")
|
|
226
|
+
.addOption(new Option("--currency <currency>", "Price currency")
|
|
227
|
+
.choices(["EUR", "USD"])
|
|
228
|
+
.default("EUR"))
|
|
229
|
+
.option("--direct", "Fetch directly from Hetzner instead of the hosted auction cache")
|
|
230
|
+
.addOption(new Option("--sort <field>", "Sort by field")
|
|
231
|
+
.choices([
|
|
232
|
+
"price",
|
|
233
|
+
"hourly",
|
|
234
|
+
"setup",
|
|
235
|
+
"ram",
|
|
236
|
+
"disk",
|
|
237
|
+
"disk_count",
|
|
238
|
+
"cpu",
|
|
239
|
+
"cpu_count",
|
|
240
|
+
"datacenter",
|
|
241
|
+
"bandwidth",
|
|
242
|
+
"next_reduce",
|
|
243
|
+
])
|
|
244
|
+
.default("price"))
|
|
245
|
+
.option("--desc", "Sort in descending order")
|
|
246
|
+
.option("--limit <n>", "Limit output rows")
|
|
247
|
+
.option("--json", "Output raw JSON")
|
|
248
|
+
.action((options) => auctionAction(async () => {
|
|
249
|
+
const { data: { server: servers }, metadata, } = await fetchAuctionDataWithCache(options.currency, {
|
|
250
|
+
...buildFetchOptions(options),
|
|
251
|
+
allowLocalFallback: true,
|
|
252
|
+
});
|
|
253
|
+
const filters = buildFilters(options);
|
|
254
|
+
let filtered = filterAuctionServers(servers, filters);
|
|
255
|
+
filtered = sortAuctionServers(filtered, options.sort || "price", !!options.desc);
|
|
256
|
+
const limit = parseNum(options.limit);
|
|
257
|
+
if (limit !== undefined && limit > 0) {
|
|
258
|
+
filtered = filtered.slice(0, limit);
|
|
259
|
+
}
|
|
260
|
+
if (options.json) {
|
|
261
|
+
maybeWarnAboutLocalCache(metadata, options);
|
|
262
|
+
console.log(formatJson(filtered));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
console.log(`${formatAuctionFetchMetadata(metadata)}\n${formatAuctionList(filtered)}`);
|
|
266
|
+
}
|
|
267
|
+
})());
|
|
268
|
+
auction
|
|
269
|
+
.command("show <id>")
|
|
270
|
+
.description("Show detailed info for an auction server.\n" +
|
|
271
|
+
"Displays CPU, RAM, disk breakdown by type, datacenter, pricing (monthly + hourly),\n" +
|
|
272
|
+
"specials (GPU, iNIC, ECC), IP pricing, and full description.\n" +
|
|
273
|
+
"Example: hctl auction show 2919866")
|
|
274
|
+
.addOption(new Option("--currency <currency>", "Price currency")
|
|
275
|
+
.choices(["EUR", "USD"])
|
|
276
|
+
.default("EUR"))
|
|
277
|
+
.option("--direct", "Fetch directly from Hetzner instead of the hosted auction cache")
|
|
278
|
+
.option("--json", "Output raw JSON")
|
|
279
|
+
.action((id, options) => auctionAction(async () => {
|
|
280
|
+
const serverId = Number.parseInt(id, 10);
|
|
281
|
+
if (Number.isNaN(serverId)) {
|
|
282
|
+
console.error(error("Invalid server ID"));
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const { data: { server: servers }, metadata, } = await fetchAuctionDataWithCache(options.currency, {
|
|
286
|
+
...buildFetchOptions(options),
|
|
287
|
+
allowLocalFallback: true,
|
|
288
|
+
});
|
|
289
|
+
const server = servers.find((s) => s.id === serverId);
|
|
290
|
+
if (!server) {
|
|
291
|
+
console.error(error(`Auction server ${id} not found`));
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
if (options.json) {
|
|
295
|
+
maybeWarnAboutLocalCache(metadata, options);
|
|
296
|
+
console.log(formatJson(server));
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
console.log(`${formatAuctionFetchMetadata(metadata)}\n${formatAuctionDetails(server)}`);
|
|
300
|
+
}
|
|
301
|
+
})());
|
|
302
|
+
auction
|
|
303
|
+
.command("status")
|
|
304
|
+
.description("Show auction data freshness and cache status.\n" +
|
|
305
|
+
"Reports hosted cache source, update timestamp, age, stale flag, server count,\n" +
|
|
306
|
+
"and local offline cache state. Use --json for machine-readable output.")
|
|
307
|
+
.addOption(new Option("--currency <currency>", "Price currency")
|
|
308
|
+
.choices(["EUR", "USD"])
|
|
309
|
+
.default("EUR"))
|
|
310
|
+
.option("--direct", "Check Hetzner direct instead of the hosted auction cache")
|
|
311
|
+
.option("--json", "Output status as JSON")
|
|
312
|
+
.action((options) => auctionAction(async () => {
|
|
313
|
+
const status = await getAuctionStatusSummary(options.currency, options);
|
|
314
|
+
if (options.json) {
|
|
315
|
+
console.log(formatJson(status));
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log(formatAuctionStatus(status));
|
|
319
|
+
}
|
|
320
|
+
})());
|
|
321
|
+
auction
|
|
322
|
+
.command("diff")
|
|
323
|
+
.description("Show changes since last snapshot.\n" +
|
|
324
|
+
"Fetches current auction data, compares against the most recent stored snapshot,\n" +
|
|
325
|
+
"and shows added, removed, and price-changed servers.\n" +
|
|
326
|
+
"Example: hctl auction diff")
|
|
327
|
+
.addOption(new Option("--currency <currency>", "Price currency")
|
|
328
|
+
.choices(["EUR", "USD"])
|
|
329
|
+
.default("EUR"))
|
|
330
|
+
.option("--direct", "Fetch directly from Hetzner instead of the hosted auction cache")
|
|
331
|
+
.option("--json", "Output diff as JSON")
|
|
332
|
+
.action((options) => auctionAction(async () => {
|
|
333
|
+
const currency = options.currency;
|
|
334
|
+
const db = openDatabase();
|
|
335
|
+
try {
|
|
336
|
+
const { data: { server: servers }, metadata, } = await fetchAuctionDataWithCache(currency, {
|
|
337
|
+
...buildFetchOptions(options),
|
|
338
|
+
allowLocalFallback: false,
|
|
339
|
+
});
|
|
340
|
+
if (!options.json) {
|
|
341
|
+
console.log(formatAuctionFetchMetadata(metadata));
|
|
342
|
+
}
|
|
343
|
+
const result = fetchAndDiff(db, servers, currency, options);
|
|
344
|
+
if (!result) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (options.json) {
|
|
348
|
+
console.log(formatJson(result.diff));
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
console.log(formatDiff(result.diff, result.previous, result.current));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
db.close();
|
|
356
|
+
}
|
|
357
|
+
})());
|
|
358
|
+
auction
|
|
359
|
+
.command("watch")
|
|
360
|
+
.description("Continuously monitor auction changes.\n" +
|
|
361
|
+
"Polls the auction API at a configurable interval and prints changes as they occur.\n" +
|
|
362
|
+
"Press Ctrl+C to stop.\n" +
|
|
363
|
+
"Example: hctl auction watch --interval 10")
|
|
364
|
+
.option("--interval <minutes>", "Polling interval in minutes", "5")
|
|
365
|
+
.addOption(new Option("--currency <currency>", "Price currency")
|
|
366
|
+
.choices(["EUR", "USD"])
|
|
367
|
+
.default("EUR"))
|
|
368
|
+
.option("--direct", "Fetch directly from Hetzner instead of the hosted auction cache")
|
|
369
|
+
.option("--json", "Output diffs as JSON")
|
|
370
|
+
.option("--quiet", "Only show output when changes are detected")
|
|
371
|
+
.action((options) => auctionAction(async () => {
|
|
372
|
+
const currency = options.currency;
|
|
373
|
+
const intervalMin = parseWatchInterval(options.interval);
|
|
374
|
+
const intervalMs = intervalMin * 60 * 1000;
|
|
375
|
+
const db = openDatabase();
|
|
376
|
+
const fetchOptions = buildFetchOptions(options);
|
|
377
|
+
let shuttingDown = false;
|
|
378
|
+
let timer = null;
|
|
379
|
+
let drainTimer = null;
|
|
380
|
+
const requestShutdown = () => {
|
|
381
|
+
if (shuttingDown) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
shuttingDown = true;
|
|
385
|
+
if (timer) {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
timer = null;
|
|
388
|
+
}
|
|
389
|
+
drainTimer = setTimeout(() => {
|
|
390
|
+
console.error(warning(`Drain timeout (${SHUTDOWN_DRAIN_MS / 1000}s) reached; forcing exit.`));
|
|
391
|
+
db.close();
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}, SHUTDOWN_DRAIN_MS);
|
|
394
|
+
drainTimer.unref?.();
|
|
395
|
+
};
|
|
396
|
+
process.once("SIGINT", requestShutdown);
|
|
397
|
+
process.once("SIGTERM", requestShutdown);
|
|
398
|
+
statusLog(options)(info(`Watching auction (${currency}, every ${intervalMin}min). Press Ctrl+C to stop.`));
|
|
399
|
+
const poll = () => pollOnce(db, currency, {
|
|
400
|
+
...options,
|
|
401
|
+
source: fetchOptions.source,
|
|
402
|
+
}).catch((err) => {
|
|
403
|
+
console.error(error(err instanceof Error ? err.message : "Poll failed"));
|
|
404
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
while (!shuttingDown) {
|
|
407
|
+
await poll();
|
|
408
|
+
if (shuttingDown) {
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
await new Promise((resolve) => {
|
|
412
|
+
timer = setTimeout(() => {
|
|
413
|
+
timer = null;
|
|
414
|
+
resolve();
|
|
415
|
+
}, intervalMs);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
if (drainTimer) {
|
|
421
|
+
clearTimeout(drainTimer);
|
|
422
|
+
}
|
|
423
|
+
process.removeListener("SIGINT", requestShutdown);
|
|
424
|
+
process.removeListener("SIGTERM", requestShutdown);
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
})());
|
|
428
|
+
}
|
|
429
|
+
const SHUTDOWN_DRAIN_MS = 30_000;
|
|
430
|
+
const MAX_WATCH_INTERVAL_MIN = 24 * 60;
|
|
431
|
+
function parseWatchInterval(raw) {
|
|
432
|
+
if (raw === undefined) {
|
|
433
|
+
return 5;
|
|
434
|
+
}
|
|
435
|
+
const n = Number(raw);
|
|
436
|
+
if (!Number.isFinite(n) || n < 1 || n > MAX_WATCH_INTERVAL_MIN) {
|
|
437
|
+
throw new Error(`Invalid --interval "${raw}": expected a number between 1 and ${MAX_WATCH_INTERVAL_MIN} (minutes).`);
|
|
438
|
+
}
|
|
439
|
+
return n;
|
|
440
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { colorize, createTable, info } from "../shared/formatter.js";
|
|
2
|
+
function serverRow(s) {
|
|
3
|
+
return [
|
|
4
|
+
s.id.toString(),
|
|
5
|
+
s.cpu,
|
|
6
|
+
`${s.ram_size} GB${s.is_ecc ? " ECC" : ""}`,
|
|
7
|
+
s.datacenter,
|
|
8
|
+
`€${s.price.toFixed(2)}`,
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
function formatPriceChange(change) {
|
|
12
|
+
const s = change.server;
|
|
13
|
+
const delta = s.price - change.previousPrice;
|
|
14
|
+
const sign = delta > 0 ? "+" : "";
|
|
15
|
+
const deltaStr = `${sign}${delta.toFixed(2)}`;
|
|
16
|
+
const colored = delta < 0 ? colorize(deltaStr, "green") : colorize(deltaStr, "red");
|
|
17
|
+
return [
|
|
18
|
+
s.id.toString(),
|
|
19
|
+
s.cpu,
|
|
20
|
+
`${s.ram_size} GB${s.is_ecc ? " ECC" : ""}`,
|
|
21
|
+
s.datacenter,
|
|
22
|
+
`€${change.previousPrice.toFixed(2)} → €${s.price.toFixed(2)} (${colored})`,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
export function formatDiff(diff, previous, current) {
|
|
26
|
+
const totalChanges = diff.added.length + diff.removed.length + diff.priceChanged.length;
|
|
27
|
+
if (totalChanges === 0) {
|
|
28
|
+
return info(`No changes detected (${current.serverCount} servers, snapshot #${current.id})`);
|
|
29
|
+
}
|
|
30
|
+
const lines = [];
|
|
31
|
+
lines.push(colorize(`Auction diff: snapshot #${previous.id} → #${current.id}`, "bold"));
|
|
32
|
+
lines.push(colorize(` ${previous.serverCount} → ${current.serverCount} servers | ${diff.added.length} added, ${diff.removed.length} removed, ${diff.priceChanged.length} price changes`, "dim"));
|
|
33
|
+
lines.push("");
|
|
34
|
+
if (diff.added.length > 0) {
|
|
35
|
+
lines.push(colorize(`+ ${diff.added.length} new server(s)`, "green"));
|
|
36
|
+
const table = createTable(["ID", "CPU", "RAM", "DC", "Price"]);
|
|
37
|
+
for (const s of diff.added) {
|
|
38
|
+
table.push(serverRow(s));
|
|
39
|
+
}
|
|
40
|
+
lines.push(table.toString());
|
|
41
|
+
lines.push("");
|
|
42
|
+
}
|
|
43
|
+
if (diff.removed.length > 0) {
|
|
44
|
+
lines.push(colorize(`- ${diff.removed.length} removed server(s)`, "red"));
|
|
45
|
+
const table = createTable(["ID", "CPU", "RAM", "DC", "Price"]);
|
|
46
|
+
for (const s of diff.removed) {
|
|
47
|
+
table.push(serverRow(s));
|
|
48
|
+
}
|
|
49
|
+
lines.push(table.toString());
|
|
50
|
+
lines.push("");
|
|
51
|
+
}
|
|
52
|
+
if (diff.priceChanged.length > 0) {
|
|
53
|
+
lines.push(colorize(`~ ${diff.priceChanged.length} price change(s)`, "yellow"));
|
|
54
|
+
const table = createTable(["ID", "CPU", "RAM", "DC", "Price Change"]);
|
|
55
|
+
for (const change of diff.priceChanged) {
|
|
56
|
+
table.push(formatPriceChange(change));
|
|
57
|
+
}
|
|
58
|
+
lines.push(table.toString());
|
|
59
|
+
}
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AuctionServer } from "./types.js";
|
|
2
|
+
export interface DiffResult {
|
|
3
|
+
added: AuctionServer[];
|
|
4
|
+
priceChanged: PriceChange[];
|
|
5
|
+
removed: AuctionServer[];
|
|
6
|
+
}
|
|
7
|
+
export interface PriceChange {
|
|
8
|
+
previousPrice: number;
|
|
9
|
+
server: AuctionServer;
|
|
10
|
+
}
|
|
11
|
+
export declare function computeDiff(previous: AuctionServer[], current: AuctionServer[]): DiffResult;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function computeDiff(previous, current) {
|
|
2
|
+
const prevMap = new Map();
|
|
3
|
+
for (const s of previous) {
|
|
4
|
+
prevMap.set(s.id, s);
|
|
5
|
+
}
|
|
6
|
+
const currMap = new Map();
|
|
7
|
+
for (const s of current) {
|
|
8
|
+
currMap.set(s.id, s);
|
|
9
|
+
}
|
|
10
|
+
const added = [];
|
|
11
|
+
const priceChanged = [];
|
|
12
|
+
for (const server of current) {
|
|
13
|
+
const prev = prevMap.get(server.id);
|
|
14
|
+
if (!prev) {
|
|
15
|
+
added.push(server);
|
|
16
|
+
}
|
|
17
|
+
else if (prev.price !== server.price) {
|
|
18
|
+
priceChanged.push({ server, previousPrice: prev.price });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const removed = [];
|
|
22
|
+
for (const server of previous) {
|
|
23
|
+
if (!currMap.has(server.id)) {
|
|
24
|
+
removed.push(server);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { added, removed, priceChanged };
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AuctionFetchMetadata } from "./client.js";
|
|
2
|
+
import type { AuctionServer } from "./types.js";
|
|
3
|
+
export interface AuctionCacheStatus {
|
|
4
|
+
ageSeconds?: number;
|
|
5
|
+
fetchedAt: string;
|
|
6
|
+
serverCount: number;
|
|
7
|
+
source: string;
|
|
8
|
+
stale: boolean;
|
|
9
|
+
updatedAt?: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
export interface AuctionStatusSummary extends AuctionCacheStatus {
|
|
13
|
+
currency: "EUR" | "USD";
|
|
14
|
+
endpointUrl: string;
|
|
15
|
+
localCache: AuctionCacheStatus | null;
|
|
16
|
+
usingLocalFallback: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function formatAuctionFetchMetadata(metadata: AuctionFetchMetadata): string;
|
|
19
|
+
export declare function formatAuctionStatus(status: AuctionStatusSummary): string;
|
|
20
|
+
export declare function formatAuctionList(servers: AuctionServer[]): string;
|
|
21
|
+
export declare function formatAuctionDetails(server: AuctionServer): string;
|