@absolutejs/deploy 0.2.0 → 0.3.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
@@ -154,6 +154,40 @@ interface so you can BYO `request(method, path, body?)` for retry /
154
154
  observability — the bundled `createDigitalOceanClient(token)` is just
155
155
  a sensible default.
156
156
 
157
+ ## `@absolutejs/deploy/hetzner` — provision-or-reuse from code (0.3.0)
158
+
159
+ Same shape as the DigitalOcean adapter, Hetzner Cloud v1 API
160
+ mappings underneath. Hetzner-specific differences: locations
161
+ (`nbg1` / `fsn1` / `hel1` / `ash` / `hil`), server types
162
+ (`cx22` / `cpx11` / `ccx13` / …), labels (key-value, not array),
163
+ and public-net IPv4/IPv6 are independently toggleable.
164
+
165
+ ```ts
166
+ import { createDeployer } from '@absolutejs/deploy';
167
+ import { hetznerTarget } from '@absolutejs/deploy/hetzner';
168
+
169
+ const target = await hetznerTarget({
170
+ token: process.env.HETZNER_TOKEN!,
171
+ name: 'absolutejs-prod-1',
172
+ location: 'nbg1',
173
+ serverType: 'cx22',
174
+ image: 'ubuntu-22.04',
175
+ sshKeys: [process.env.HETZNER_KEY_FINGERPRINT!],
176
+ labels: { env: 'prod', team: 'platform' },
177
+ userData: '#!/bin/bash\ncurl -fsSL https://bun.sh/install | bash',
178
+ });
179
+
180
+ const deployer = createDeployer({ appName: 'my-app', target });
181
+ await deployer.deploy({ source: { kind: 'directory', path: './build' } });
182
+ await target.destroy();
183
+ ```
184
+
185
+ Hetzner enforces unique server names per project, so the
186
+ idempotency contract is structural — `name` collisions never
187
+ happen. Admin helpers: `listHetznerServers({ token, labelSelector?
188
+ })` (Hetzner's `'env=prod'` / `'env in (prod,staging)'` syntax);
189
+ `destroyHetznerServer({ token, id })` (404 idempotent success).
190
+
157
191
  ## DigitalOcean Droplet — first deploy (manual)
158
192
 
159
193
  Assuming a fresh Ubuntu/Debian Droplet:
@@ -173,9 +207,9 @@ bun run my-deploy-script.ts
173
207
 
174
208
  The first run creates `/srv/<appName>/releases/<id>/`, drops a systemd unit at `/etc/systemd/system/<appName>.service` (if you're using `systemdManager`), starts the service, and probes. Subsequent runs just add a new release dir and swap the symlink.
175
209
 
176
- ## What v0.2.0 does NOT include
210
+ ## What v0.3.0 does NOT include
177
211
 
178
- - Provider-specific HTTP-API adapters beyond DigitalOcean (Cloudflare Workers, Fly Machines, AWS Fargate, GCP Cloud Run). Hetzner / Linode / Vultr follow the same shape as `digitalOceanTarget` and are next on the list.
212
+ - Provider-specific HTTP-API adapters beyond DigitalOcean + Hetzner (Cloudflare Workers, Fly Machines, AWS Fargate, GCP Cloud Run). Linode / Vultr follow the same shape and are next on the list.
179
213
  - Bun installation on the remote — caller does it once, out of band.
180
214
  - Multi-target / fan-out deploys (caller iterates).
181
215
  - Zero-downtime port-swap (start new release on a fresh port, then nginx-reload). The default pipeline does stop-then-start; for true zero-downtime, replace the `restart` step.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @absolutejs/deploy/hetzner — provision-or-reuse Target adapter for
3
+ * Hetzner Cloud servers. Sibling to {@link digitalOceanTarget}; same
4
+ * shape, different API.
5
+ *
6
+ * What it does:
7
+ *
8
+ * 1. Looks up a server by `name`. If present and running, reuses it.
9
+ * 2. If not present, creates it via the Hetzner Cloud v1 API and
10
+ * waits for `status === 'running'` with a public IPv4 assigned.
11
+ * 3. Waits for SSH readiness (TCP connect on port 22 with backoff,
12
+ * or a caller-supplied probe).
13
+ * 4. Returns a Target that wraps sshTarget against the server's
14
+ * public IPv4, plus `serverId`, `ipv4`, and a `destroy()` helper.
15
+ *
16
+ * Idempotent by name — Hetzner enforces unique server names per
17
+ * project, so calling twice with the same name returns the same
18
+ * server.
19
+ *
20
+ * Narrow HetznerClientLike interface keeps the official `hcloud-js`
21
+ * SDK out as a hard dep. Default client uses `fetch` against
22
+ * `api.hetzner.cloud/v1`; pass your own for retry / observability.
23
+ */
24
+ import type { Target } from './targets';
25
+ /**
26
+ * Minimal subset of Hetzner Cloud API calls we make. Lets callers
27
+ * BYO a client with retry / observability / etc.
28
+ */
29
+ export type HetznerClientLike = {
30
+ request: <T = unknown>(method: 'GET' | 'POST' | 'DELETE', path: string, body?: unknown) => Promise<T>;
31
+ };
32
+ /** A Hetzner Cloud server, narrowed to what we inspect. */
33
+ export type HetznerServer = {
34
+ id: number;
35
+ name: string;
36
+ status: 'initializing' | 'starting' | 'running' | 'stopping' | 'off' | 'deleting' | 'migrating' | 'rebuilding' | 'unknown';
37
+ public_net: {
38
+ ipv4: {
39
+ id: number;
40
+ ip: string;
41
+ blocked: boolean;
42
+ dns_ptr?: string;
43
+ } | null;
44
+ ipv6: {
45
+ id: number;
46
+ ip: string;
47
+ blocked: boolean;
48
+ } | null;
49
+ };
50
+ server_type?: {
51
+ name: string;
52
+ };
53
+ datacenter?: {
54
+ location: {
55
+ name: string;
56
+ };
57
+ };
58
+ labels?: Record<string, string>;
59
+ };
60
+ export type HetznerTargetOptions = {
61
+ /** API token (https://docs.hetzner.cloud/#authentication). Required unless `client` is set. */
62
+ token?: string;
63
+ /** Custom client. Overrides token-built default. */
64
+ client?: HetznerClientLike;
65
+ /** Server name. Hetzner-unique per project; also our idempotency key. */
66
+ name: string;
67
+ /** Location slug — `'nbg1'`, `'fsn1'`, `'hel1'`, `'ash'`, `'hil'`. */
68
+ location: string;
69
+ /** Server type slug — `'cx22'`, `'cpx11'`, `'ccx13'`, etc. */
70
+ serverType: string;
71
+ /** Image slug or numeric id, e.g. `'ubuntu-22.04'`. */
72
+ image: string | number;
73
+ /** SSH key fingerprints, numeric ids, or names. At least one required. */
74
+ sshKeys: ReadonlyArray<string | number>;
75
+ /** Labels (Hetzner's key-value tags). */
76
+ labels?: Record<string, string>;
77
+ /** cloud-init user data — a shell script or YAML config. */
78
+ userData?: string;
79
+ /** Attach to a Cloud Network (by id). */
80
+ networkId?: number;
81
+ /** Disable IPv4 public addressing. Default: enabled. */
82
+ disablePublicIpv4?: boolean;
83
+ /** Disable IPv6 public addressing. Default: enabled. */
84
+ disablePublicIpv6?: boolean;
85
+ /** SSH login user. Default `'root'`. */
86
+ user?: string;
87
+ /** Path to SSH identity file forwarded to sshTarget. */
88
+ identity?: string;
89
+ /** SSH port. Default 22. */
90
+ port?: number;
91
+ /** Max time to wait for server `running` + IPv4. Default 5 min. */
92
+ provisionTimeoutMs?: number;
93
+ /** Max time to wait for SSH probe to succeed. Default 2 min. */
94
+ sshReadinessTimeoutMs?: number;
95
+ /** Poll interval for provision + ssh probe. Default 5 s. */
96
+ pollIntervalMs?: number;
97
+ /** Called with status updates (one line each). Default: noop. */
98
+ onLog?: (line: string) => void;
99
+ /**
100
+ * Override the SSH readiness probe. Default opens a TCP socket to
101
+ * `host:port`. Tests pass a fake probe to skip real network IO.
102
+ */
103
+ probeSsh?: (host: string, port: number) => Promise<boolean>;
104
+ /** Sleep used between polls. Tests can pass a synchronous resolver. */
105
+ sleep?: (ms: number) => Promise<void>;
106
+ /** Wall clock. Defaults to `Date.now`. Tests can swap. */
107
+ now?: () => number;
108
+ };
109
+ export type HetznerTarget = Target & {
110
+ readonly serverId: number;
111
+ readonly ipv4: string;
112
+ /** Destroy the server via the Hetzner API. */
113
+ destroy: () => Promise<void>;
114
+ };
115
+ export declare class HetznerError extends Error {
116
+ readonly status: number;
117
+ readonly body: unknown;
118
+ constructor(message: string, status: number, body: unknown);
119
+ }
120
+ /**
121
+ * fetch-backed default client. Talks JSON to `api.hetzner.cloud/v1`.
122
+ * Throws HetznerError on non-2xx with the response body attached so
123
+ * the caller can switch on `err.status`.
124
+ */
125
+ export declare const createHetznerClient: (token: string, options?: {
126
+ baseUrl?: string;
127
+ fetch?: typeof fetch;
128
+ }) => HetznerClientLike;
129
+ /**
130
+ * Find a server by name. Returns undefined if absent. Hetzner
131
+ * enforces unique server names per project, so duplicates aren't
132
+ * possible — but if the API ever returns >1 we still surface that
133
+ * loudly.
134
+ */
135
+ export declare const findHetznerServer: (client: HetznerClientLike, name: string) => Promise<HetznerServer | undefined>;
136
+ /** List servers, optionally filtered by label selector. */
137
+ export declare const listHetznerServers: (options: {
138
+ token?: string;
139
+ client?: HetznerClientLike;
140
+ /** Label selector, e.g. `'env=prod'` or `'env in (prod,staging)'`. */
141
+ labelSelector?: string;
142
+ }) => Promise<HetznerServer[]>;
143
+ /** Destroy a server by id. 404 treated as idempotent success. */
144
+ export declare const destroyHetznerServer: (options: {
145
+ token?: string;
146
+ client?: HetznerClientLike;
147
+ id: number;
148
+ }) => Promise<void>;
149
+ /**
150
+ * Provision-or-reuse a Hetzner Cloud server by name, wait for SSH,
151
+ * return a Target. Idempotent: same name → same server.
152
+ */
153
+ export declare const hetznerTarget: (options: HetznerTargetOptions) => Promise<HetznerTarget>;
@@ -0,0 +1,374 @@
1
+ // @bun
2
+ // src/targets.ts
3
+ import { mkdir } from "fs/promises";
4
+ import { join } from "path";
5
+ var decodeChunks = async (reader, onLine) => {
6
+ if (!reader)
7
+ return "";
8
+ const decoder = new TextDecoder;
9
+ let buffer = "";
10
+ let collected = "";
11
+ const stream = reader.getReader();
12
+ try {
13
+ while (true) {
14
+ const { done, value } = await stream.read();
15
+ if (done)
16
+ break;
17
+ const chunk = decoder.decode(value, { stream: true });
18
+ collected += chunk;
19
+ if (!onLine)
20
+ continue;
21
+ buffer += chunk;
22
+ let newline = buffer.indexOf(`
23
+ `);
24
+ while (newline !== -1) {
25
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
26
+ if (line.length > 0)
27
+ onLine(line);
28
+ buffer = buffer.slice(newline + 1);
29
+ newline = buffer.indexOf(`
30
+ `);
31
+ }
32
+ }
33
+ const tail = decoder.decode();
34
+ collected += tail;
35
+ if (onLine && (buffer + tail).length > 0)
36
+ onLine((buffer + tail).replace(/\r$/, ""));
37
+ } finally {
38
+ stream.releaseLock();
39
+ }
40
+ return collected;
41
+ };
42
+ var runSpawn = async (argv, options) => {
43
+ const proc = Bun.spawn(argv, {
44
+ cwd: options.cwd,
45
+ env: options.env,
46
+ stderr: "pipe",
47
+ stdin: options.stdin === undefined ? "ignore" : "pipe",
48
+ stdout: "pipe"
49
+ });
50
+ if (options.stdin !== undefined && proc.stdin) {
51
+ const sink = proc.stdin;
52
+ const wrote = sink.write(options.stdin);
53
+ if (wrote && typeof wrote.then === "function") {
54
+ await wrote;
55
+ }
56
+ const ended = sink.end();
57
+ if (ended && typeof ended.then === "function") {
58
+ await ended;
59
+ }
60
+ }
61
+ const timeout = options.timeoutMs ?? 600000;
62
+ let timer;
63
+ if (timeout > 0) {
64
+ timer = setTimeout(() => {
65
+ try {
66
+ proc.kill();
67
+ } catch {}
68
+ }, timeout);
69
+ }
70
+ const stdoutPromise = decodeChunks(proc.stdout, options.onLog ? (line) => options.onLog(line, "stdout") : undefined);
71
+ const stderrPromise = decodeChunks(proc.stderr, options.onLog ? (line) => options.onLog(line, "stderr") : undefined);
72
+ const [stdout, stderr, exitCode] = await Promise.all([
73
+ stdoutPromise,
74
+ stderrPromise,
75
+ proc.exited
76
+ ]);
77
+ if (timer)
78
+ clearTimeout(timer);
79
+ return { exitCode: exitCode ?? -1, stderr, stdout };
80
+ };
81
+ var localTarget = (options) => {
82
+ const baseEnv = { ...options.env };
83
+ const ensureRoot = async () => {
84
+ await mkdir(options.root, { recursive: true });
85
+ };
86
+ return {
87
+ description: `local ${options.root}`,
88
+ exec: async (cmd, opts) => {
89
+ await ensureRoot();
90
+ return runSpawn(["sh", "-c", cmd], {
91
+ cwd: opts?.cwd ?? options.root,
92
+ env: { ...process.env, ...baseEnv, ...opts?.env ?? {} },
93
+ onLog: opts?.onLog,
94
+ stdin: opts?.stdin,
95
+ timeoutMs: opts?.timeoutMs
96
+ });
97
+ },
98
+ upload: async (localPath, remotePath, opts) => {
99
+ await ensureRoot();
100
+ const dest = remotePath.startsWith("/") ? remotePath : join(options.root, remotePath);
101
+ const argv = ["rsync", "-a"];
102
+ if (opts?.deleteOrphans)
103
+ argv.push("--delete");
104
+ for (const pattern of opts?.exclude ?? [])
105
+ argv.push("--exclude", pattern);
106
+ argv.push(localPath, dest);
107
+ const result = await runSpawn(argv, { timeoutMs: 600000 });
108
+ if (result.exitCode !== 0) {
109
+ throw new Error(`local upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
110
+ }
111
+ }
112
+ };
113
+ };
114
+ var sshTargetString = (options) => {
115
+ const user = options.user ?? "root";
116
+ return `${user}@${options.host}`;
117
+ };
118
+ var sshBaseFlags = (options) => {
119
+ const flags = [];
120
+ if (options.port !== undefined && options.port !== 22)
121
+ flags.push("-p", String(options.port));
122
+ if (options.identity !== undefined)
123
+ flags.push("-i", options.identity);
124
+ flags.push("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new");
125
+ for (const flag of options.sshFlags ?? [])
126
+ flags.push(flag);
127
+ return flags;
128
+ };
129
+ var shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
130
+ var buildRemoteCmd = (cmd, opts) => {
131
+ const env = opts?.env;
132
+ const envPrefix = env ? Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ") + " " : "";
133
+ if (opts?.cwd) {
134
+ return `cd ${shellQuote(opts.cwd)} && ${envPrefix}${cmd}`;
135
+ }
136
+ return `${envPrefix}${cmd}`;
137
+ };
138
+ var sshTarget = (options) => {
139
+ const remote = sshTargetString(options);
140
+ const useRsync = options.rsync ?? true;
141
+ return {
142
+ description: `ssh ${remote}${options.port && options.port !== 22 ? `:${options.port}` : ""}`,
143
+ exec: async (cmd, opts) => {
144
+ const argv = ["ssh", ...sshBaseFlags(options)];
145
+ for (const name of options.forwardEnv ?? [])
146
+ argv.push("-o", `SendEnv=${name}`);
147
+ argv.push(remote, buildRemoteCmd(cmd, opts));
148
+ return runSpawn(argv, {
149
+ onLog: opts?.onLog,
150
+ stdin: opts?.stdin,
151
+ timeoutMs: opts?.timeoutMs
152
+ });
153
+ },
154
+ upload: async (localPath, remotePath, opts) => {
155
+ if (useRsync) {
156
+ const sshCmd = ["ssh", ...sshBaseFlags(options)].map((part) => part.includes(" ") ? `'${part}'` : part).join(" ");
157
+ const argv2 = ["rsync", "-az", "-e", sshCmd];
158
+ if (opts?.deleteOrphans)
159
+ argv2.push("--delete");
160
+ for (const pattern of opts?.exclude ?? [])
161
+ argv2.push("--exclude", pattern);
162
+ argv2.push(localPath, `${remote}:${remotePath}`);
163
+ const result2 = await runSpawn(argv2, { timeoutMs: 600000 });
164
+ if (result2.exitCode !== 0) {
165
+ throw new Error(`rsync upload failed (exit ${result2.exitCode}): ${result2.stderr || result2.stdout}`);
166
+ }
167
+ return;
168
+ }
169
+ const argv = ["scp", "-r", ...sshBaseFlags(options), localPath, `${remote}:${remotePath}`];
170
+ const result = await runSpawn(argv, { timeoutMs: 600000 });
171
+ if (result.exitCode !== 0) {
172
+ throw new Error(`scp upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
173
+ }
174
+ }
175
+ };
176
+ };
177
+
178
+ // src/hetzner.ts
179
+ var HETZNER_API_BASE = "https://api.hetzner.cloud/v1";
180
+
181
+ class HetznerError extends Error {
182
+ status;
183
+ body;
184
+ constructor(message, status, body) {
185
+ super(message);
186
+ this.name = "HetznerError";
187
+ this.status = status;
188
+ this.body = body;
189
+ }
190
+ }
191
+ var createHetznerClient = (token, options = {}) => {
192
+ const base = options.baseUrl ?? HETZNER_API_BASE;
193
+ const f = options.fetch ?? fetch;
194
+ return {
195
+ request: async (method, path, body) => {
196
+ const init = {
197
+ headers: {
198
+ authorization: `Bearer ${token}`,
199
+ "content-type": "application/json"
200
+ },
201
+ method
202
+ };
203
+ if (body !== undefined)
204
+ init.body = JSON.stringify(body);
205
+ const response = await f(`${base}${path}`, init);
206
+ if (response.status === 204)
207
+ return;
208
+ const text = await response.text();
209
+ const parsed = text.length > 0 ? JSON.parse(text) : undefined;
210
+ if (!response.ok) {
211
+ throw new HetznerError(`Hetzner Cloud API ${method} ${path} failed: ${response.status} ${response.statusText}`, response.status, parsed);
212
+ }
213
+ return parsed;
214
+ }
215
+ };
216
+ };
217
+ var resolveClient = (options) => {
218
+ if (options.client !== undefined)
219
+ return options.client;
220
+ if (options.token !== undefined && options.token.length > 0) {
221
+ return createHetznerClient(options.token);
222
+ }
223
+ throw new Error("[deploy/hetzner] either `token` or `client` must be provided");
224
+ };
225
+ var publicIpv4 = (server) => server.public_net.ipv4?.ip;
226
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
227
+ var defaultProbeSsh = async (host, port) => {
228
+ const PROBE_TIMEOUT_MS = 2000;
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+ const settle = (value) => {
232
+ if (settled)
233
+ return;
234
+ settled = true;
235
+ resolve(value);
236
+ };
237
+ const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
238
+ Bun.connect({
239
+ hostname: host,
240
+ port,
241
+ socket: {
242
+ data: () => {},
243
+ error: () => {
244
+ clearTimeout(timer);
245
+ settle(false);
246
+ },
247
+ open: (socket) => {
248
+ clearTimeout(timer);
249
+ socket.end();
250
+ settle(true);
251
+ }
252
+ }
253
+ }).catch(() => {
254
+ clearTimeout(timer);
255
+ settle(false);
256
+ });
257
+ });
258
+ };
259
+ var findHetznerServer = async (client, name) => {
260
+ const body = await client.request("GET", `/servers?name=${encodeURIComponent(name)}`);
261
+ const matches = body.servers.filter((server) => server.name === name);
262
+ if (matches.length === 0)
263
+ return;
264
+ if (matches.length > 1) {
265
+ throw new Error(`[deploy/hetzner] multiple servers named "${name}" (${matches.map((server) => server.id).join(", ")}). Hetzner shouldn't allow this \u2014 resolve manually.`);
266
+ }
267
+ return matches[0];
268
+ };
269
+ var listHetznerServers = async (options) => {
270
+ const client = resolveClient(options);
271
+ const path = options.labelSelector !== undefined ? `/servers?label_selector=${encodeURIComponent(options.labelSelector)}` : "/servers";
272
+ const body = await client.request("GET", path);
273
+ return body.servers;
274
+ };
275
+ var destroyHetznerServer = async (options) => {
276
+ const client = resolveClient(options);
277
+ try {
278
+ await client.request("DELETE", `/servers/${options.id}`);
279
+ } catch (error) {
280
+ if (error instanceof HetznerError && error.status === 404) {
281
+ return;
282
+ }
283
+ throw error;
284
+ }
285
+ };
286
+ var hetznerTarget = async (options) => {
287
+ const client = resolveClient(options);
288
+ const log = options.onLog ?? (() => {});
289
+ const probeSsh = options.probeSsh ?? defaultProbeSsh;
290
+ const sleep = options.sleep ?? defaultSleep;
291
+ const now = options.now ?? Date.now;
292
+ const pollMs = options.pollIntervalMs ?? 5000;
293
+ const provisionTimeout = options.provisionTimeoutMs ?? 5 * 60000;
294
+ const sshTimeout = options.sshReadinessTimeoutMs ?? 2 * 60000;
295
+ const port = options.port ?? 22;
296
+ const existing = await findHetznerServer(client, options.name);
297
+ let current;
298
+ if (existing === undefined) {
299
+ log(`[hetzner] creating server "${options.name}" in ${options.location}`);
300
+ const publicIpv4Enabled = options.disablePublicIpv4 !== true;
301
+ const publicIpv6Enabled = options.disablePublicIpv6 !== true;
302
+ const created = await client.request("POST", "/servers", {
303
+ name: options.name,
304
+ location: options.location,
305
+ server_type: options.serverType,
306
+ image: options.image,
307
+ ssh_keys: [...options.sshKeys],
308
+ start_after_create: true,
309
+ public_net: {
310
+ enable_ipv4: publicIpv4Enabled,
311
+ enable_ipv6: publicIpv6Enabled
312
+ },
313
+ ...options.labels !== undefined ? { labels: options.labels } : {},
314
+ ...options.userData !== undefined ? { user_data: options.userData } : {},
315
+ ...options.networkId !== undefined ? { networks: [options.networkId] } : {}
316
+ });
317
+ current = created.server;
318
+ } else {
319
+ log(`[hetzner] reusing server "${options.name}" (id ${existing.id}, status ${existing.status})`);
320
+ current = existing;
321
+ }
322
+ const provisionStart = now();
323
+ let ipv4 = publicIpv4(current);
324
+ while (current.status !== "running" || ipv4 === undefined) {
325
+ if (now() - provisionStart > provisionTimeout) {
326
+ throw new Error(`[deploy/hetzner] provision timeout after ${provisionTimeout}ms \u2014 server ${current.id} status "${current.status}", ipv4 ${ipv4 ?? "(unassigned)"}`);
327
+ }
328
+ await sleep(pollMs);
329
+ const refreshed = await client.request("GET", `/servers/${current.id}`);
330
+ current = refreshed.server;
331
+ ipv4 = publicIpv4(current);
332
+ log(`[hetzner] poll: status=${current.status} ipv4=${ipv4 ?? "(none yet)"}`);
333
+ }
334
+ log(`[hetzner] server running at ${ipv4}`);
335
+ const sshStart = now();
336
+ while (!await probeSsh(ipv4, port)) {
337
+ if (now() - sshStart > sshTimeout) {
338
+ throw new Error(`[deploy/hetzner] SSH readiness timeout after ${sshTimeout}ms \u2014 ${ipv4}:${port} did not accept connections`);
339
+ }
340
+ await sleep(pollMs);
341
+ log(`[hetzner] waiting on ssh ${ipv4}:${port}`);
342
+ }
343
+ log(`[hetzner] ssh ready at ${ipv4}:${port}`);
344
+ const ssh = sshTarget({
345
+ host: ipv4,
346
+ ...options.user !== undefined ? { user: options.user } : {},
347
+ ...options.identity !== undefined ? { identity: options.identity } : {},
348
+ ...options.port !== undefined ? { port: options.port } : {}
349
+ });
350
+ const serverId = current.id;
351
+ const resolvedIpv4 = ipv4;
352
+ return {
353
+ description: `hetzner server "${options.name}" (${ssh.description})`,
354
+ destroy: () => destroyHetznerServer({ client, id: serverId }).then(() => {
355
+ log(`[hetzner] destroyed server ${serverId}`);
356
+ }),
357
+ exec: ssh.exec,
358
+ ipv4: resolvedIpv4,
359
+ serverId,
360
+ upload: ssh.upload,
361
+ ...ssh.close !== undefined ? { close: ssh.close } : {}
362
+ };
363
+ };
364
+ export {
365
+ listHetznerServers,
366
+ hetznerTarget,
367
+ findHetznerServer,
368
+ destroyHetznerServer,
369
+ createHetznerClient,
370
+ HetznerError
371
+ };
372
+
373
+ //# debugId=0EE85A74FB8C508064756E2164756E21
374
+ //# sourceMappingURL=hetzner.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/targets.ts", "../src/hetzner.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Target interface + bundled adapters (localTarget, sshTarget).\n *\n * A Target is the narrowest abstraction over \"a place I can deploy to\":\n *\n * - `exec(cmd, opts?)` — run a shell command, capture stdout/stderr/exitCode.\n * - `upload(localPath, remotePath, opts?)` — copy a local file or directory\n * to the target. Implementation is free to use whatever is fast (rsync,\n * scp, mv).\n * - `close?()` — optional teardown.\n *\n * Two adapters are bundled:\n *\n * - `localTarget` runs in a temp directory on the local filesystem. Useful\n * for tests and for \"deploy\" workflows that happen on the same host.\n * - `sshTarget` shells out to the system `ssh` and `rsync` binaries. No\n * `ssh2` npm dependency — the controller machine just needs `ssh` and\n * (optionally) `rsync` in PATH, which is universal on Mac/Linux/WSL.\n *\n * Provider-specific targets (Cloudflare Workers HTTP API, Fly Machines API,\n * AWS Fargate) don't fit \"exec + upload\" and ship as siblings later.\n */\n\nimport { mkdir } from 'node:fs/promises';\nimport { join } from 'node:path';\n\nexport type ExecOptions = {\n\t/** Working directory on the target. Default: target's root. */\n\tcwd?: string;\n\t/** Env vars to set for this command (merged onto target.env). */\n\tenv?: Record<string, string>;\n\t/** Hard kill after this many ms. Default 600_000 (10 min). 0 disables. */\n\ttimeoutMs?: number;\n\t/** Pipe stdout/stderr through here as it streams (lines, newline-stripped). */\n\tonLog?: (line: string, stream: 'stdout' | 'stderr') => void;\n\t/** Stdin payload — a string is written verbatim. */\n\tstdin?: string;\n};\n\nexport type ExecResult = {\n\tstdout: string;\n\tstderr: string;\n\texitCode: number;\n};\n\nexport type UploadOptions = {\n\t/** Exclude paths matching these globs from a directory upload. */\n\texclude?: string[];\n\t/** When uploading a directory, delete remote files not present locally. */\n\tdeleteOrphans?: boolean;\n};\n\nexport type Target = {\n\t/** Human-readable description (e.g. \"ssh root@droplet-1.example.com\"). */\n\treadonly description: string;\n\texec: (cmd: string, opts?: ExecOptions) => Promise<ExecResult>;\n\tupload: (localPath: string, remotePath: string, opts?: UploadOptions) => Promise<void>;\n\tclose?: () => Promise<void>;\n};\n\n// -----------------------------------------------------------------------------\n// localTarget\n// -----------------------------------------------------------------------------\n\nexport type LocalTargetOptions = {\n\t/** Root directory the target operates in. Created if missing. */\n\troot: string;\n\t/** Env merged into every exec. */\n\tenv?: Record<string, string>;\n};\n\nconst decodeChunks = async (\n\treader: ReadableStream<Uint8Array> | null,\n\tonLine: ((line: string) => void) | undefined,\n): Promise<string> => {\n\tif (!reader) return '';\n\tconst decoder = new TextDecoder();\n\tlet buffer = '';\n\tlet collected = '';\n\tconst stream = reader.getReader();\n\ttry {\n\t\twhile (true) {\n\t\t\tconst { done, value } = await stream.read();\n\t\t\tif (done) break;\n\t\t\tconst chunk = decoder.decode(value, { stream: true });\n\t\t\tcollected += chunk;\n\t\t\tif (!onLine) continue;\n\t\t\tbuffer += chunk;\n\t\t\tlet newline = buffer.indexOf('\\n');\n\t\t\twhile (newline !== -1) {\n\t\t\t\tconst line = buffer.slice(0, newline).replace(/\\r$/, '');\n\t\t\t\tif (line.length > 0) onLine(line);\n\t\t\t\tbuffer = buffer.slice(newline + 1);\n\t\t\t\tnewline = buffer.indexOf('\\n');\n\t\t\t}\n\t\t}\n\t\tconst tail = decoder.decode();\n\t\tcollected += tail;\n\t\tif (onLine && (buffer + tail).length > 0) onLine((buffer + tail).replace(/\\r$/, ''));\n\t} finally {\n\t\tstream.releaseLock();\n\t}\n\treturn collected;\n};\n\nconst runSpawn = async (\n\targv: string[],\n\toptions: {\n\t\tcwd?: string;\n\t\tenv?: Record<string, string>;\n\t\ttimeoutMs?: number;\n\t\tonLog?: ExecOptions['onLog'];\n\t\tstdin?: string;\n\t},\n): Promise<ExecResult> => {\n\tconst proc = Bun.spawn(argv, {\n\t\tcwd: options.cwd,\n\t\tenv: options.env,\n\t\tstderr: 'pipe',\n\t\tstdin: options.stdin === undefined ? 'ignore' : 'pipe',\n\t\tstdout: 'pipe',\n\t});\n\n\tif (options.stdin !== undefined && proc.stdin) {\n\t\t// Bun.spawn returns a FileSink for piped stdin — `write` + `end`, not a\n\t\t// WritableStream. (We use a permissive cast because @types/bun's\n\t\t// Subprocess.stdin discriminant flips based on the stdin generic.)\n\t\tconst sink = proc.stdin as unknown as {\n\t\t\twrite: (chunk: string | Uint8Array) => number | Promise<number>;\n\t\t\tend: () => void | Promise<void>;\n\t\t};\n\t\tconst wrote = sink.write(options.stdin);\n\t\tif (wrote && typeof (wrote as Promise<number>).then === 'function') {\n\t\t\tawait wrote;\n\t\t}\n\t\tconst ended = sink.end();\n\t\tif (ended && typeof (ended as Promise<void>).then === 'function') {\n\t\t\tawait ended;\n\t\t}\n\t}\n\n\tconst timeout = options.timeoutMs ?? 600_000;\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tif (timeout > 0) {\n\t\ttimer = setTimeout(() => {\n\t\t\ttry { proc.kill(); } catch { /* already gone */ }\n\t\t}, timeout);\n\t}\n\n\tconst stdoutPromise = decodeChunks(\n\t\tproc.stdout as unknown as ReadableStream<Uint8Array>,\n\t\toptions.onLog ? (line) => options.onLog!(line, 'stdout') : undefined,\n\t);\n\tconst stderrPromise = decodeChunks(\n\t\tproc.stderr as unknown as ReadableStream<Uint8Array>,\n\t\toptions.onLog ? (line) => options.onLog!(line, 'stderr') : undefined,\n\t);\n\n\tconst [stdout, stderr, exitCode] = await Promise.all([\n\t\tstdoutPromise,\n\t\tstderrPromise,\n\t\tproc.exited,\n\t]);\n\tif (timer) clearTimeout(timer);\n\n\treturn { exitCode: exitCode ?? -1, stderr, stdout };\n};\n\nexport const localTarget = (options: LocalTargetOptions): Target => {\n\tconst baseEnv = { ...options.env };\n\tconst ensureRoot = async () => { await mkdir(options.root, { recursive: true }); };\n\n\treturn {\n\t\tdescription: `local ${options.root}`,\n\t\texec: async (cmd, opts) => {\n\t\t\tawait ensureRoot();\n\t\t\treturn runSpawn(['sh', '-c', cmd], {\n\t\t\t\tcwd: opts?.cwd ?? options.root,\n\t\t\t\tenv: { ...process.env, ...baseEnv, ...(opts?.env ?? {}) } as Record<string, string>,\n\t\t\t\tonLog: opts?.onLog,\n\t\t\t\tstdin: opts?.stdin,\n\t\t\t\ttimeoutMs: opts?.timeoutMs,\n\t\t\t});\n\t\t},\n\t\tupload: async (localPath, remotePath, opts) => {\n\t\t\tawait ensureRoot();\n\t\t\tconst dest = remotePath.startsWith('/') ? remotePath : join(options.root, remotePath);\n\t\t\tconst argv = ['rsync', '-a'];\n\t\t\tif (opts?.deleteOrphans) argv.push('--delete');\n\t\t\tfor (const pattern of opts?.exclude ?? []) argv.push('--exclude', pattern);\n\t\t\t// rsync semantics: a trailing slash on the source copies *contents*; without it the dir itself is nested.\n\t\t\targv.push(localPath, dest);\n\t\t\tconst result = await runSpawn(argv, { timeoutMs: 600_000 });\n\t\t\tif (result.exitCode !== 0) {\n\t\t\t\tthrow new Error(`local upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);\n\t\t\t}\n\t\t},\n\t};\n};\n\n// -----------------------------------------------------------------------------\n// sshTarget\n// -----------------------------------------------------------------------------\n\nexport type SshTargetOptions = {\n\t/** Hostname or IP of the remote. */\n\thost: string;\n\t/** Login user. Default `root`. */\n\tuser?: string;\n\t/** SSH port. Default 22. */\n\tport?: number;\n\t/** Path to SSH identity file. Default: ssh's own search. */\n\tidentity?: string;\n\t/** Extra flags appended to every `ssh` invocation. */\n\tsshFlags?: string[];\n\t/**\n\t * Use rsync for `upload`. Default true. When false, falls back to `scp`\n\t * which is universal but doesn't support delete / exclude.\n\t */\n\trsync?: boolean;\n\t/**\n\t * Env vars to forward via `ssh -o SendEnv=...`. Most remote sshd configs\n\t * accept only `LANG` and `LC_*` by default; for app env vars use the\n\t * step `env` option instead, which prepends `KEY=value` to the command.\n\t */\n\tforwardEnv?: string[];\n};\n\nconst sshTargetString = (options: SshTargetOptions): string => {\n\tconst user = options.user ?? 'root';\n\treturn `${user}@${options.host}`;\n};\n\nconst sshBaseFlags = (options: SshTargetOptions): string[] => {\n\tconst flags: string[] = [];\n\tif (options.port !== undefined && options.port !== 22) flags.push('-p', String(options.port));\n\tif (options.identity !== undefined) flags.push('-i', options.identity);\n\t// Never get stuck on a host-key prompt; treat unknown hosts as a fatal config issue rather than a UX detour.\n\tflags.push('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new');\n\tfor (const flag of options.sshFlags ?? []) flags.push(flag);\n\treturn flags;\n};\n\nconst shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\\\''`)}'`;\n\nconst buildRemoteCmd = (cmd: string, opts: ExecOptions | undefined): string => {\n\tconst env = opts?.env;\n\tconst envPrefix = env\n\t\t? Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(' ') + ' '\n\t\t: '';\n\tif (opts?.cwd) {\n\t\treturn `cd ${shellQuote(opts.cwd)} && ${envPrefix}${cmd}`;\n\t}\n\treturn `${envPrefix}${cmd}`;\n};\n\nexport const sshTarget = (options: SshTargetOptions): Target => {\n\tconst remote = sshTargetString(options);\n\tconst useRsync = options.rsync ?? true;\n\n\treturn {\n\t\tdescription: `ssh ${remote}${options.port && options.port !== 22 ? `:${options.port}` : ''}`,\n\t\texec: async (cmd, opts) => {\n\t\t\tconst argv = ['ssh', ...sshBaseFlags(options)];\n\t\t\tfor (const name of options.forwardEnv ?? []) argv.push('-o', `SendEnv=${name}`);\n\t\t\targv.push(remote, buildRemoteCmd(cmd, opts));\n\t\t\treturn runSpawn(argv, {\n\t\t\t\tonLog: opts?.onLog,\n\t\t\t\tstdin: opts?.stdin,\n\t\t\t\ttimeoutMs: opts?.timeoutMs,\n\t\t\t});\n\t\t},\n\t\tupload: async (localPath, remotePath, opts) => {\n\t\t\tif (useRsync) {\n\t\t\t\tconst sshCmd = ['ssh', ...sshBaseFlags(options)].map((part) => part.includes(' ') ? `'${part}'` : part).join(' ');\n\t\t\t\tconst argv = ['rsync', '-az', '-e', sshCmd];\n\t\t\t\tif (opts?.deleteOrphans) argv.push('--delete');\n\t\t\t\tfor (const pattern of opts?.exclude ?? []) argv.push('--exclude', pattern);\n\t\t\t\targv.push(localPath, `${remote}:${remotePath}`);\n\t\t\t\tconst result = await runSpawn(argv, { timeoutMs: 600_000 });\n\t\t\t\tif (result.exitCode !== 0) {\n\t\t\t\t\tthrow new Error(`rsync upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// scp fallback — no exclude, no delete. We still need -r to copy directories.\n\t\t\tconst argv = ['scp', '-r', ...sshBaseFlags(options), localPath, `${remote}:${remotePath}`];\n\t\t\tconst result = await runSpawn(argv, { timeoutMs: 600_000 });\n\t\t\tif (result.exitCode !== 0) {\n\t\t\t\tthrow new Error(`scp upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);\n\t\t\t}\n\t\t},\n\t};\n};\n",
6
+ "/**\n * @absolutejs/deploy/hetzner — provision-or-reuse Target adapter for\n * Hetzner Cloud servers. Sibling to {@link digitalOceanTarget}; same\n * shape, different API.\n *\n * What it does:\n *\n * 1. Looks up a server by `name`. If present and running, reuses it.\n * 2. If not present, creates it via the Hetzner Cloud v1 API and\n * waits for `status === 'running'` with a public IPv4 assigned.\n * 3. Waits for SSH readiness (TCP connect on port 22 with backoff,\n * or a caller-supplied probe).\n * 4. Returns a Target that wraps sshTarget against the server's\n * public IPv4, plus `serverId`, `ipv4`, and a `destroy()` helper.\n *\n * Idempotent by name — Hetzner enforces unique server names per\n * project, so calling twice with the same name returns the same\n * server.\n *\n * Narrow HetznerClientLike interface keeps the official `hcloud-js`\n * SDK out as a hard dep. Default client uses `fetch` against\n * `api.hetzner.cloud/v1`; pass your own for retry / observability.\n */\n\nimport type { Target } from './targets';\nimport { sshTarget } from './targets';\n\nconst HETZNER_API_BASE = 'https://api.hetzner.cloud/v1';\n\n/**\n * Minimal subset of Hetzner Cloud API calls we make. Lets callers\n * BYO a client with retry / observability / etc.\n */\nexport type HetznerClientLike = {\n\trequest: <T = unknown>(\n\t\tmethod: 'GET' | 'POST' | 'DELETE',\n\t\tpath: string,\n\t\tbody?: unknown\n\t) => Promise<T>;\n};\n\n/** A Hetzner Cloud server, narrowed to what we inspect. */\nexport type HetznerServer = {\n\tid: number;\n\tname: string;\n\tstatus:\n\t\t| 'initializing'\n\t\t| 'starting'\n\t\t| 'running'\n\t\t| 'stopping'\n\t\t| 'off'\n\t\t| 'deleting'\n\t\t| 'migrating'\n\t\t| 'rebuilding'\n\t\t| 'unknown';\n\tpublic_net: {\n\t\tipv4: { id: number; ip: string; blocked: boolean; dns_ptr?: string } | null;\n\t\tipv6: { id: number; ip: string; blocked: boolean } | null;\n\t};\n\tserver_type?: { name: string };\n\tdatacenter?: { location: { name: string } };\n\tlabels?: Record<string, string>;\n};\n\nexport type HetznerTargetOptions = {\n\t/** API token (https://docs.hetzner.cloud/#authentication). Required unless `client` is set. */\n\ttoken?: string;\n\t/** Custom client. Overrides token-built default. */\n\tclient?: HetznerClientLike;\n\n\t// ── Server shape ─────────────────────────────────────────────────\n\t/** Server name. Hetzner-unique per project; also our idempotency key. */\n\tname: string;\n\t/** Location slug — `'nbg1'`, `'fsn1'`, `'hel1'`, `'ash'`, `'hil'`. */\n\tlocation: string;\n\t/** Server type slug — `'cx22'`, `'cpx11'`, `'ccx13'`, etc. */\n\tserverType: string;\n\t/** Image slug or numeric id, e.g. `'ubuntu-22.04'`. */\n\timage: string | number;\n\t/** SSH key fingerprints, numeric ids, or names. At least one required. */\n\tsshKeys: ReadonlyArray<string | number>;\n\t/** Labels (Hetzner's key-value tags). */\n\tlabels?: Record<string, string>;\n\t/** cloud-init user data — a shell script or YAML config. */\n\tuserData?: string;\n\t/** Attach to a Cloud Network (by id). */\n\tnetworkId?: number;\n\t/** Disable IPv4 public addressing. Default: enabled. */\n\tdisablePublicIpv4?: boolean;\n\t/** Disable IPv6 public addressing. Default: enabled. */\n\tdisablePublicIpv6?: boolean;\n\n\t// ── SSH wrap ────────────────────────────────────────────────────\n\t/** SSH login user. Default `'root'`. */\n\tuser?: string;\n\t/** Path to SSH identity file forwarded to sshTarget. */\n\tidentity?: string;\n\t/** SSH port. Default 22. */\n\tport?: number;\n\n\t// ── Timing ──────────────────────────────────────────────────────\n\t/** Max time to wait for server `running` + IPv4. Default 5 min. */\n\tprovisionTimeoutMs?: number;\n\t/** Max time to wait for SSH probe to succeed. Default 2 min. */\n\tsshReadinessTimeoutMs?: number;\n\t/** Poll interval for provision + ssh probe. Default 5 s. */\n\tpollIntervalMs?: number;\n\n\t// ── Observability + injection points ───────────────────────────\n\t/** Called with status updates (one line each). Default: noop. */\n\tonLog?: (line: string) => void;\n\t/**\n\t * Override the SSH readiness probe. Default opens a TCP socket to\n\t * `host:port`. Tests pass a fake probe to skip real network IO.\n\t */\n\tprobeSsh?: (host: string, port: number) => Promise<boolean>;\n\t/** Sleep used between polls. Tests can pass a synchronous resolver. */\n\tsleep?: (ms: number) => Promise<void>;\n\t/** Wall clock. Defaults to `Date.now`. Tests can swap. */\n\tnow?: () => number;\n};\n\nexport type HetznerTarget = Target & {\n\treadonly serverId: number;\n\treadonly ipv4: string;\n\t/** Destroy the server via the Hetzner API. */\n\tdestroy: () => Promise<void>;\n};\n\nexport class HetznerError extends Error {\n\treadonly status: number;\n\treadonly body: unknown;\n\tconstructor(message: string, status: number, body: unknown) {\n\t\tsuper(message);\n\t\tthis.name = 'HetznerError';\n\t\tthis.status = status;\n\t\tthis.body = body;\n\t}\n}\n\n/**\n * fetch-backed default client. Talks JSON to `api.hetzner.cloud/v1`.\n * Throws HetznerError on non-2xx with the response body attached so\n * the caller can switch on `err.status`.\n */\nexport const createHetznerClient = (\n\ttoken: string,\n\toptions: { baseUrl?: string; fetch?: typeof fetch } = {}\n): HetznerClientLike => {\n\tconst base = options.baseUrl ?? HETZNER_API_BASE;\n\tconst f = options.fetch ?? fetch;\n\treturn {\n\t\trequest: async <T>(\n\t\t\tmethod: 'GET' | 'POST' | 'DELETE',\n\t\t\tpath: string,\n\t\t\tbody?: unknown\n\t\t): Promise<T> => {\n\t\t\tconst init: RequestInit = {\n\t\t\t\theaders: {\n\t\t\t\t\tauthorization: `Bearer ${token}`,\n\t\t\t\t\t'content-type': 'application/json'\n\t\t\t\t},\n\t\t\t\tmethod\n\t\t\t};\n\t\t\tif (body !== undefined) init.body = JSON.stringify(body);\n\t\t\tconst response = await f(`${base}${path}`, init);\n\t\t\tif (response.status === 204) return undefined as T;\n\t\t\tconst text = await response.text();\n\t\t\tconst parsed = text.length > 0 ? JSON.parse(text) : undefined;\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new HetznerError(\n\t\t\t\t\t`Hetzner Cloud API ${method} ${path} failed: ${response.status} ${response.statusText}`,\n\t\t\t\t\tresponse.status,\n\t\t\t\t\tparsed\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn parsed as T;\n\t\t}\n\t};\n};\n\nconst resolveClient = (\n\toptions: Pick<HetznerTargetOptions, 'client' | 'token'>\n): HetznerClientLike => {\n\tif (options.client !== undefined) return options.client;\n\tif (options.token !== undefined && options.token.length > 0) {\n\t\treturn createHetznerClient(options.token);\n\t}\n\tthrow new Error(\n\t\t'[deploy/hetzner] either `token` or `client` must be provided'\n\t);\n};\n\nconst publicIpv4 = (server: HetznerServer): string | undefined =>\n\tserver.public_net.ipv4?.ip;\n\nconst defaultSleep = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => setTimeout(resolve, ms));\n\nconst defaultProbeSsh = async (host: string, port: number): Promise<boolean> => {\n\tconst PROBE_TIMEOUT_MS = 2_000;\n\treturn new Promise<boolean>((resolve) => {\n\t\tlet settled = false;\n\t\tconst settle = (value: boolean) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve(value);\n\t\t};\n\t\tconst timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);\n\t\tBun.connect({\n\t\t\thostname: host,\n\t\t\tport,\n\t\t\tsocket: {\n\t\t\t\tdata: () => {},\n\t\t\t\terror: () => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\tsettle(false);\n\t\t\t\t},\n\t\t\t\topen: (socket) => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\tsocket.end();\n\t\t\t\t\tsettle(true);\n\t\t\t\t}\n\t\t\t}\n\t\t}).catch(() => {\n\t\t\tclearTimeout(timer);\n\t\t\tsettle(false);\n\t\t});\n\t});\n};\n\n/**\n * Find a server by name. Returns undefined if absent. Hetzner\n * enforces unique server names per project, so duplicates aren't\n * possible — but if the API ever returns >1 we still surface that\n * loudly.\n */\nexport const findHetznerServer = async (\n\tclient: HetznerClientLike,\n\tname: string\n): Promise<HetznerServer | undefined> => {\n\tconst body = await client.request<{ servers: HetznerServer[] }>(\n\t\t'GET',\n\t\t`/servers?name=${encodeURIComponent(name)}`\n\t);\n\tconst matches = body.servers.filter((server) => server.name === name);\n\tif (matches.length === 0) return undefined;\n\tif (matches.length > 1) {\n\t\tthrow new Error(\n\t\t\t`[deploy/hetzner] multiple servers named \"${name}\" (${matches\n\t\t\t\t.map((server) => server.id)\n\t\t\t\t.join(', ')}). Hetzner shouldn't allow this — resolve manually.`\n\t\t);\n\t}\n\treturn matches[0];\n};\n\n/** List servers, optionally filtered by label selector. */\nexport const listHetznerServers = async (options: {\n\ttoken?: string;\n\tclient?: HetznerClientLike;\n\t/** Label selector, e.g. `'env=prod'` or `'env in (prod,staging)'`. */\n\tlabelSelector?: string;\n}): Promise<HetznerServer[]> => {\n\tconst client = resolveClient(options);\n\tconst path =\n\t\toptions.labelSelector !== undefined\n\t\t\t? `/servers?label_selector=${encodeURIComponent(options.labelSelector)}`\n\t\t\t: '/servers';\n\tconst body = await client.request<{ servers: HetznerServer[] }>('GET', path);\n\treturn body.servers;\n};\n\n/** Destroy a server by id. 404 treated as idempotent success. */\nexport const destroyHetznerServer = async (options: {\n\ttoken?: string;\n\tclient?: HetznerClientLike;\n\tid: number;\n}): Promise<void> => {\n\tconst client = resolveClient(options);\n\ttry {\n\t\tawait client.request('DELETE', `/servers/${options.id}`);\n\t} catch (error) {\n\t\tif (error instanceof HetznerError && error.status === 404) {\n\t\t\treturn; // already destroyed — idempotent\n\t\t}\n\t\tthrow error;\n\t}\n};\n\n/**\n * Provision-or-reuse a Hetzner Cloud server by name, wait for SSH,\n * return a Target. Idempotent: same name → same server.\n */\nexport const hetznerTarget = async (\n\toptions: HetznerTargetOptions\n): Promise<HetznerTarget> => {\n\tconst client = resolveClient(options);\n\tconst log = options.onLog ?? (() => {});\n\tconst probeSsh = options.probeSsh ?? defaultProbeSsh;\n\tconst sleep = options.sleep ?? defaultSleep;\n\tconst now = options.now ?? Date.now;\n\tconst pollMs = options.pollIntervalMs ?? 5_000;\n\tconst provisionTimeout = options.provisionTimeoutMs ?? 5 * 60_000;\n\tconst sshTimeout = options.sshReadinessTimeoutMs ?? 2 * 60_000;\n\tconst port = options.port ?? 22;\n\n\tconst existing = await findHetznerServer(client, options.name);\n\tlet current: HetznerServer;\n\tif (existing === undefined) {\n\t\tlog(`[hetzner] creating server \"${options.name}\" in ${options.location}`);\n\t\tconst publicIpv4Enabled = options.disablePublicIpv4 !== true;\n\t\tconst publicIpv6Enabled = options.disablePublicIpv6 !== true;\n\t\tconst created = await client.request<{ server: HetznerServer }>(\n\t\t\t'POST',\n\t\t\t'/servers',\n\t\t\t{\n\t\t\t\tname: options.name,\n\t\t\t\tlocation: options.location,\n\t\t\t\tserver_type: options.serverType,\n\t\t\t\timage: options.image,\n\t\t\t\tssh_keys: [...options.sshKeys],\n\t\t\t\tstart_after_create: true,\n\t\t\t\tpublic_net: {\n\t\t\t\t\tenable_ipv4: publicIpv4Enabled,\n\t\t\t\t\tenable_ipv6: publicIpv6Enabled\n\t\t\t\t},\n\t\t\t\t...(options.labels !== undefined ? { labels: options.labels } : {}),\n\t\t\t\t...(options.userData !== undefined\n\t\t\t\t\t? { user_data: options.userData }\n\t\t\t\t\t: {}),\n\t\t\t\t...(options.networkId !== undefined\n\t\t\t\t\t? { networks: [options.networkId] }\n\t\t\t\t\t: {})\n\t\t\t}\n\t\t);\n\t\tcurrent = created.server;\n\t} else {\n\t\tlog(\n\t\t\t`[hetzner] reusing server \"${options.name}\" (id ${existing.id}, status ${existing.status})`\n\t\t);\n\t\tcurrent = existing;\n\t}\n\n\t// Wait for status=running AND public IPv4 assigned.\n\tconst provisionStart = now();\n\tlet ipv4 = publicIpv4(current);\n\twhile (current.status !== 'running' || ipv4 === undefined) {\n\t\tif (now() - provisionStart > provisionTimeout) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/hetzner] provision timeout after ${provisionTimeout}ms — server ${current.id} status \"${current.status}\", ipv4 ${ipv4 ?? '(unassigned)'}`\n\t\t\t);\n\t\t}\n\t\tawait sleep(pollMs);\n\t\tconst refreshed: { server: HetznerServer } = await client.request(\n\t\t\t'GET',\n\t\t\t`/servers/${current.id}`\n\t\t);\n\t\tcurrent = refreshed.server;\n\t\tipv4 = publicIpv4(current);\n\t\tlog(`[hetzner] poll: status=${current.status} ipv4=${ipv4 ?? '(none yet)'}`);\n\t}\n\tlog(`[hetzner] server running at ${ipv4}`);\n\n\t// Wait for SSH readiness.\n\tconst sshStart = now();\n\twhile (!(await probeSsh(ipv4, port))) {\n\t\tif (now() - sshStart > sshTimeout) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/hetzner] SSH readiness timeout after ${sshTimeout}ms — ${ipv4}:${port} did not accept connections`\n\t\t\t);\n\t\t}\n\t\tawait sleep(pollMs);\n\t\tlog(`[hetzner] waiting on ssh ${ipv4}:${port}`);\n\t}\n\tlog(`[hetzner] ssh ready at ${ipv4}:${port}`);\n\n\tconst ssh = sshTarget({\n\t\thost: ipv4,\n\t\t...(options.user !== undefined ? { user: options.user } : {}),\n\t\t...(options.identity !== undefined ? { identity: options.identity } : {}),\n\t\t...(options.port !== undefined ? { port: options.port } : {})\n\t});\n\n\tconst serverId = current.id;\n\tconst resolvedIpv4 = ipv4;\n\n\treturn {\n\t\tdescription: `hetzner server \"${options.name}\" (${ssh.description})`,\n\t\tdestroy: () =>\n\t\t\tdestroyHetznerServer({ client, id: serverId }).then(() => {\n\t\t\t\tlog(`[hetzner] destroyed server ${serverId}`);\n\t\t\t}),\n\t\texec: ssh.exec,\n\t\tipv4: resolvedIpv4,\n\t\tserverId,\n\t\tupload: ssh.upload,\n\t\t...(ssh.close !== undefined ? { close: ssh.close } : {})\n\t};\n};\n"
7
+ ],
8
+ "mappings": "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",
9
+ "debugId": "0EE85A74FB8C508064756E2164756E21",
10
+ "names": []
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/deploy",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Generic Bun-project deploy pipeline. A Target (localTarget / sshTarget) is anywhere you can exec + upload — DigitalOcean droplets, Linode, Hetzner, Vultr, your own boxes. Bundled pipeline: prepare → upload → install → build → link → restart → verify. Atomic symlink swap, release history, prune, hooks. SSH shells out to system ssh/rsync — zero ssh2 deps.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "release"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
29
+ "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts src/hetzner.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
30
30
  "test": "bun test tests/",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "format": "prettier --write \"./**/*.{ts,json,md}\"",
@@ -51,6 +51,11 @@
51
51
  "types": "./dist/digitalocean.d.ts",
52
52
  "import": "./dist/digitalocean.js",
53
53
  "default": "./dist/digitalocean.js"
54
+ },
55
+ "./hetzner": {
56
+ "types": "./dist/hetzner.d.ts",
57
+ "import": "./dist/hetzner.js",
58
+ "default": "./dist/hetzner.js"
54
59
  }
55
60
  },
56
61
  "files": ["dist", "README.md"]