@absolutejs/deploy 0.1.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
@@ -110,7 +110,85 @@ Default is `null` (no verify). Recommend always wiring one — a green deploy th
110
110
  - `listReleases()` returns the sorted list.
111
111
  - `prune({ keep: N })` removes the N oldest.
112
112
 
113
- ## DigitalOcean Droplet first deploy
113
+ ## `@absolutejs/deploy/digitalocean`provision-or-reuse from code (0.2.0)
114
+
115
+ Skip the click-through DO dashboard. `digitalOceanTarget(options)` looks
116
+ up a droplet by name; creates it via the v2 API if absent; waits for
117
+ `status === 'active'` + IPv4; waits for SSH; returns a `Target` ready to
118
+ hand to `createDeployer`.
119
+
120
+ ```ts
121
+ import { createDeployer } from '@absolutejs/deploy';
122
+ import { digitalOceanTarget } from '@absolutejs/deploy/digitalocean';
123
+
124
+ const target = await digitalOceanTarget({
125
+ token: process.env.DO_TOKEN!,
126
+ name: 'absolutejs-prod-1', // idempotency key
127
+ region: 'nyc3',
128
+ size: 's-1vcpu-1gb',
129
+ image: 'ubuntu-22-04-x64',
130
+ sshKeys: [process.env.DO_KEY_FINGERPRINT!],
131
+ tags: ['absolutejs'],
132
+ userData: '#!/bin/bash\ncurl -fsSL https://bun.sh/install | bash',
133
+ onLog: (line) => console.log(line),
134
+ });
135
+
136
+ console.log(`droplet ${target.dropletId} at ${target.ipv4}`);
137
+
138
+ const deployer = createDeployer({ appName: 'my-app', target });
139
+ await deployer.deploy({ source: { kind: 'directory', path: './build' } });
140
+
141
+ // Tear it down when you're done:
142
+ await target.destroy();
143
+ ```
144
+
145
+ Idempotent by `name` — calling twice returns the same droplet, no
146
+ duplicates created. Pair with `cloud-init` user data to install Bun /
147
+ configure the deploy user / set up firewall rules on first boot, then
148
+ the deploy pipeline runs against an SSH-ready box.
149
+
150
+ Admin helpers: `listDigitalOceanDroplets({ token, tag? })` for
151
+ inventory; `destroyDigitalOceanDroplet({ token, id })` for cleanup
152
+ (404 is treated as idempotent success). Narrow `DigitalOceanClientLike`
153
+ interface so you can BYO `request(method, path, body?)` for retry /
154
+ observability — the bundled `createDigitalOceanClient(token)` is just
155
+ a sensible default.
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
+
191
+ ## DigitalOcean Droplet — first deploy (manual)
114
192
 
115
193
  Assuming a fresh Ubuntu/Debian Droplet:
116
194
 
@@ -129,9 +207,9 @@ bun run my-deploy-script.ts
129
207
 
130
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.
131
209
 
132
- ## What v0.0.1 does NOT include
210
+ ## What v0.3.0 does NOT include
133
211
 
134
- - Provider-specific HTTP-API adapters (Cloudflare Workers, Fly Machines, AWS Fargate, GCP Cloud Run).
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.
135
213
  - Bun installation on the remote — caller does it once, out of band.
136
214
  - Multi-target / fan-out deploys (caller iterates).
137
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,146 @@
1
+ /**
2
+ * @absolutejs/deploy/digitalocean — provision-or-reuse Target adapter
3
+ * for DigitalOcean droplets.
4
+ *
5
+ * What it does:
6
+ *
7
+ * 1. Looks up a droplet by `name`. If present and active, reuses it.
8
+ * 2. If not present, creates it via the DO v2 API and waits for
9
+ * `status === 'active'` with a public IPv4 assigned.
10
+ * 3. Waits for SSH readiness (TCP connect on port 22 with backoff,
11
+ * or a caller-supplied probe).
12
+ * 4. Returns a Target that wraps sshTarget against the droplet's
13
+ * public IPv4, plus `dropletId`, `ipv4`, and a `destroy()` helper.
14
+ *
15
+ * Idempotent by name — calling twice with the same name returns the
16
+ * same droplet, no duplicates created. If multiple droplets share
17
+ * the name, throws (the caller has drifted state to clean up).
18
+ *
19
+ * Narrow DigitalOceanClientLike interface keeps the dots-on-the-i
20
+ * SDK out as a hard dep. Default client uses `fetch` against
21
+ * `api.digitalocean.com`; pass your own for retry / observability.
22
+ */
23
+ import type { Target } from './targets';
24
+ /**
25
+ * Minimal subset of DO API calls we make. Lets callers BYO a client
26
+ * with retry / observability / etc. (e.g. wrap got, undici, or a
27
+ * tenant-scoped client that injects different tokens per call).
28
+ */
29
+ export type DigitalOceanClientLike = {
30
+ request: <T = unknown>(method: 'GET' | 'POST' | 'DELETE', path: string, body?: unknown) => Promise<T>;
31
+ };
32
+ /** A DigitalOcean droplet record, narrowed to what we inspect. */
33
+ export type DigitalOceanDroplet = {
34
+ id: number;
35
+ name: string;
36
+ status: 'new' | 'active' | 'off' | 'archive';
37
+ region?: {
38
+ slug: string;
39
+ };
40
+ size_slug?: string;
41
+ networks: {
42
+ v4: Array<{
43
+ ip_address: string;
44
+ type: 'public' | 'private';
45
+ }>;
46
+ v6?: Array<{
47
+ ip_address: string;
48
+ type: 'public' | 'private';
49
+ }>;
50
+ };
51
+ tags?: string[];
52
+ };
53
+ export type DigitalOceanTargetOptions = {
54
+ /** API token (https://cloud.digitalocean.com/account/api/tokens). Required unless `client` is set. */
55
+ token?: string;
56
+ /** Custom client. Overrides token-built default. */
57
+ client?: DigitalOceanClientLike;
58
+ /** Droplet name. Also the idempotency key. */
59
+ name: string;
60
+ /** Region slug — `'nyc3'`, `'sfo3'`, `'ams3'`, etc. */
61
+ region: string;
62
+ /** Size slug — `'s-1vcpu-1gb'`, `'s-2vcpu-4gb'`, etc. */
63
+ size: string;
64
+ /** Image slug, snapshot id, or backup id. e.g. `'ubuntu-22-04-x64'`. */
65
+ image: string | number;
66
+ /** SSH key fingerprints OR numeric ids. At least one required to ssh in. */
67
+ sshKeys: ReadonlyArray<string | number>;
68
+ /** Tags applied at creation. Useful for `listDroplets({ tag })`. */
69
+ tags?: ReadonlyArray<string>;
70
+ /** cloud-init user data — a shell script or YAML config. */
71
+ userData?: string;
72
+ /** VPC UUID. Defaults to the account's default VPC for the region. */
73
+ vpcUuid?: string;
74
+ /** Enable IPv6. Default false. */
75
+ ipv6?: boolean;
76
+ /** Enable monitoring agent. Default false. */
77
+ monitoring?: boolean;
78
+ /** SSH login user. Default `'root'`. */
79
+ user?: string;
80
+ /** Path to SSH identity file forwarded to sshTarget. */
81
+ identity?: string;
82
+ /** SSH port. Default 22. */
83
+ port?: number;
84
+ /** Max time to wait for droplet `active` + IPv4. Default 5 min. */
85
+ provisionTimeoutMs?: number;
86
+ /** Max time to wait for SSH probe to succeed. Default 2 min. */
87
+ sshReadinessTimeoutMs?: number;
88
+ /** Poll interval for provision + ssh probe. Default 5 s. */
89
+ pollIntervalMs?: number;
90
+ /** Called with status updates (one line each). Default: noop. */
91
+ onLog?: (line: string) => void;
92
+ /**
93
+ * Override the SSH readiness probe. Default opens a TCP socket to
94
+ * `host:port`. Tests pass a fake probe to skip real network IO.
95
+ */
96
+ probeSsh?: (host: string, port: number) => Promise<boolean>;
97
+ /**
98
+ * Sleep used between polls. Default `setTimeout`-based. Tests can
99
+ * pass a synchronous resolver to skip real waits.
100
+ */
101
+ sleep?: (ms: number) => Promise<void>;
102
+ /** Wall clock. Defaults to `Date.now`. Tests can swap. */
103
+ now?: () => number;
104
+ };
105
+ export type DigitalOceanTarget = Target & {
106
+ readonly dropletId: number;
107
+ readonly ipv4: string;
108
+ /** Destroy the droplet via the DO API. */
109
+ destroy: () => Promise<void>;
110
+ };
111
+ export declare class DigitalOceanError extends Error {
112
+ readonly status: number;
113
+ readonly body: unknown;
114
+ constructor(message: string, status: number, body: unknown);
115
+ }
116
+ /**
117
+ * fetch-backed default client. Talks JSON to `api.digitalocean.com/v2`.
118
+ * Throws DigitalOceanError on non-2xx with the response body attached
119
+ * so the caller can switch on `err.status`.
120
+ */
121
+ export declare const createDigitalOceanClient: (token: string, options?: {
122
+ baseUrl?: string;
123
+ fetch?: typeof fetch;
124
+ }) => DigitalOceanClientLike;
125
+ /**
126
+ * Find a droplet by name. Returns undefined if absent.
127
+ * Throws if more than one droplet shares the name (drifted state).
128
+ */
129
+ export declare const findDigitalOceanDroplet: (client: DigitalOceanClientLike, name: string) => Promise<DigitalOceanDroplet | undefined>;
130
+ /** List droplets, optionally filtered by tag. Useful for cleanup tasks. */
131
+ export declare const listDigitalOceanDroplets: (options: {
132
+ token?: string;
133
+ client?: DigitalOceanClientLike;
134
+ tag?: string;
135
+ }) => Promise<DigitalOceanDroplet[]>;
136
+ /** Destroy a droplet by id. No-op if already gone. */
137
+ export declare const destroyDigitalOceanDroplet: (options: {
138
+ token?: string;
139
+ client?: DigitalOceanClientLike;
140
+ id: number;
141
+ }) => Promise<void>;
142
+ /**
143
+ * Provision-or-reuse a DO droplet by name, wait for SSH, return a
144
+ * Target. Idempotent: same name → same droplet.
145
+ */
146
+ export declare const digitalOceanTarget: (options: DigitalOceanTargetOptions) => Promise<DigitalOceanTarget>;
@@ -0,0 +1,369 @@
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/digitalocean.ts
179
+ var DO_API_BASE = "https://api.digitalocean.com/v2";
180
+
181
+ class DigitalOceanError extends Error {
182
+ status;
183
+ body;
184
+ constructor(message, status, body) {
185
+ super(message);
186
+ this.name = "DigitalOceanError";
187
+ this.status = status;
188
+ this.body = body;
189
+ }
190
+ }
191
+ var createDigitalOceanClient = (token, options = {}) => {
192
+ const base = options.baseUrl ?? DO_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 DigitalOceanError(`DigitalOcean 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 createDigitalOceanClient(options.token);
222
+ }
223
+ throw new Error("[deploy/digitalocean] either `token` or `client` must be provided");
224
+ };
225
+ var publicIpv4 = (droplet) => droplet.networks.v4.find((net) => net.type === "public")?.ip_address;
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 findDigitalOceanDroplet = async (client, name) => {
260
+ const body = await client.request("GET", `/droplets?name=${encodeURIComponent(name)}`);
261
+ const matches = body.droplets.filter((droplet) => droplet.name === name);
262
+ if (matches.length === 0)
263
+ return;
264
+ if (matches.length > 1) {
265
+ throw new Error(`[deploy/digitalocean] multiple droplets named "${name}" (${matches.map((droplet) => droplet.id).join(", ")}). Resolve manually before adopting.`);
266
+ }
267
+ return matches[0];
268
+ };
269
+ var listDigitalOceanDroplets = async (options) => {
270
+ const client = resolveClient(options);
271
+ const path = options.tag !== undefined ? `/droplets?tag_name=${encodeURIComponent(options.tag)}` : "/droplets";
272
+ const body = await client.request("GET", path);
273
+ return body.droplets;
274
+ };
275
+ var destroyDigitalOceanDroplet = async (options) => {
276
+ const client = resolveClient(options);
277
+ try {
278
+ await client.request("DELETE", `/droplets/${options.id}`);
279
+ } catch (error) {
280
+ if (error instanceof DigitalOceanError && error.status === 404) {
281
+ return;
282
+ }
283
+ throw error;
284
+ }
285
+ };
286
+ var digitalOceanTarget = 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 findDigitalOceanDroplet(client, options.name);
297
+ let current;
298
+ if (existing === undefined) {
299
+ log(`[do] creating droplet "${options.name}" in ${options.region}`);
300
+ const created = await client.request("POST", "/droplets", {
301
+ name: options.name,
302
+ region: options.region,
303
+ size: options.size,
304
+ image: options.image,
305
+ ssh_keys: [...options.sshKeys],
306
+ ...options.tags !== undefined ? { tags: [...options.tags] } : {},
307
+ ...options.userData !== undefined ? { user_data: options.userData } : {},
308
+ ...options.vpcUuid !== undefined ? { vpc_uuid: options.vpcUuid } : {},
309
+ ...options.ipv6 === true ? { ipv6: true } : {},
310
+ ...options.monitoring === true ? { monitoring: true } : {}
311
+ });
312
+ current = created.droplet;
313
+ } else {
314
+ log(`[do] reusing droplet "${options.name}" (id ${existing.id}, status ${existing.status})`);
315
+ current = existing;
316
+ }
317
+ const provisionStart = now();
318
+ let ipv4 = publicIpv4(current);
319
+ while (current.status !== "active" || ipv4 === undefined) {
320
+ if (now() - provisionStart > provisionTimeout) {
321
+ throw new Error(`[deploy/digitalocean] provision timeout after ${provisionTimeout}ms \u2014 droplet ${current.id} status "${current.status}", ipv4 ${ipv4 ?? "(unassigned)"}`);
322
+ }
323
+ await sleep(pollMs);
324
+ const refreshed = await client.request("GET", `/droplets/${current.id}`);
325
+ current = refreshed.droplet;
326
+ ipv4 = publicIpv4(current);
327
+ log(`[do] poll: status=${current.status} ipv4=${ipv4 ?? "(none yet)"}`);
328
+ }
329
+ log(`[do] droplet active at ${ipv4}`);
330
+ const sshStart = now();
331
+ while (!await probeSsh(ipv4, port)) {
332
+ if (now() - sshStart > sshTimeout) {
333
+ throw new Error(`[deploy/digitalocean] SSH readiness timeout after ${sshTimeout}ms \u2014 ${ipv4}:${port} did not accept connections`);
334
+ }
335
+ await sleep(pollMs);
336
+ log(`[do] waiting on ssh ${ipv4}:${port}`);
337
+ }
338
+ log(`[do] ssh ready at ${ipv4}:${port}`);
339
+ const ssh = sshTarget({
340
+ host: ipv4,
341
+ ...options.user !== undefined ? { user: options.user } : {},
342
+ ...options.identity !== undefined ? { identity: options.identity } : {},
343
+ ...options.port !== undefined ? { port: options.port } : {}
344
+ });
345
+ const dropletId = current.id;
346
+ const resolvedIpv4 = ipv4;
347
+ return {
348
+ description: `digitalocean droplet "${options.name}" (${ssh.description})`,
349
+ dropletId,
350
+ ipv4: resolvedIpv4,
351
+ destroy: () => destroyDigitalOceanDroplet({ client, id: dropletId }).then(() => {
352
+ log(`[do] destroyed droplet ${dropletId}`);
353
+ }),
354
+ exec: ssh.exec,
355
+ upload: ssh.upload,
356
+ ...ssh.close !== undefined ? { close: ssh.close } : {}
357
+ };
358
+ };
359
+ export {
360
+ listDigitalOceanDroplets,
361
+ findDigitalOceanDroplet,
362
+ digitalOceanTarget,
363
+ destroyDigitalOceanDroplet,
364
+ createDigitalOceanClient,
365
+ DigitalOceanError
366
+ };
367
+
368
+ //# debugId=54E293FAC3F67A4164756E2164756E21
369
+ //# sourceMappingURL=digitalocean.js.map