@absolutejs/deploy 0.3.1 → 0.4.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
@@ -188,6 +188,47 @@ happen. Admin helpers: `listHetznerServers({ token, labelSelector?
188
188
  })` (Hetzner's `'env=prod'` / `'env in (prod,staging)'` syntax);
189
189
  `destroyHetznerServer({ token, id })` (404 idempotent success).
190
190
 
191
+ ## `@absolutejs/deploy/cloudflare` — DNS automation (0.4.0)
192
+
193
+ After provisioning a Target, point a hostname at its IP without
194
+ leaving the deploy script. `cloudflareProvider({ token, zoneId })`
195
+ implements the shared `DnsProvider` contract from
196
+ `@absolutejs/deploy/dns`.
197
+
198
+ ```ts
199
+ import { hetznerTarget } from '@absolutejs/deploy/hetzner';
200
+ import { cloudflareProvider } from '@absolutejs/deploy/cloudflare';
201
+ import { ensureDnsForTarget } from '@absolutejs/deploy/dns';
202
+
203
+ const target = await hetznerTarget({ /* … */ });
204
+ const dns = cloudflareProvider({
205
+ token: process.env.CLOUDFLARE_TOKEN!,
206
+ zoneId: process.env.CLOUDFLARE_ZONE_ID!,
207
+ });
208
+
209
+ // Idempotent — create or update so the A record points at target.ipv4.
210
+ await ensureDnsForTarget(dns, {
211
+ name: 'api.example.com',
212
+ target,
213
+ ttl: 60,
214
+ proxied: false,
215
+ });
216
+ ```
217
+
218
+ `upsert` is the canonical entry: finds by exact (name, type); skips
219
+ the API call entirely when the existing record already matches the
220
+ spec (no churn on TTL / proxied / comment agreement). Multiple
221
+ records sharing the same (name, type) throw with a "resolve
222
+ manually" message instead of silently picking one.
223
+
224
+ Auth uses Cloudflare API tokens with `Zone:DNS:Edit` scope (global
225
+ keys not supported). Pair with `provider.list({ name?, type? })`
226
+ for inventory, `provider.delete(id)` for tear-down (404 idempotent
227
+ success).
228
+
229
+ The same `DnsProvider` contract applies to other providers — Route
230
+ 53 / DigitalOcean DNS / etc. follow next.
231
+
191
232
  ## DigitalOcean Droplet — first deploy (manual)
192
233
 
193
234
  Assuming a fresh Ubuntu/Debian Droplet:
@@ -207,9 +248,11 @@ bun run my-deploy-script.ts
207
248
 
208
249
  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.
209
250
 
210
- ## What v0.3.0 does NOT include
251
+ ## What v0.4.0 does NOT include
211
252
 
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.
253
+ - TLS certificate automation. ACME / Let's Encrypt is a separate concern coming next.
254
+ - Cloud-provider compute targets beyond DigitalOcean + Hetzner. Linode / Vultr / Fly Machines follow the same shape and are next on the list.
255
+ - DNS providers beyond Cloudflare. Route 53 / DigitalOcean DNS / Hetzner DNS slot into the same `DnsProvider` contract.
213
256
  - Bun installation on the remote — caller does it once, out of band.
214
257
  - Multi-target / fan-out deploys (caller iterates).
215
258
  - 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,50 @@
1
+ /**
2
+ * @absolutejs/deploy/cloudflare — Cloudflare DNS adapter implementing
3
+ * the {@link DnsProvider} contract from `./dns`.
4
+ *
5
+ * Scope: one provider instance is bound to one Cloudflare zone. Multi-
6
+ * zone callers construct one provider per zone.
7
+ *
8
+ * Auth: Cloudflare API tokens with `Zone:DNS:Edit` scope. Global API
9
+ * keys are intentionally not supported — tokens are the modern path.
10
+ *
11
+ * Narrow CloudflareClientLike interface keeps `cloudflare` SDK out as
12
+ * a hard dep. Default client uses `fetch` against
13
+ * `api.cloudflare.com/client/v4`.
14
+ */
15
+ import type { DnsProvider } from './dns';
16
+ /**
17
+ * Minimal subset of Cloudflare API calls. Lets callers BYO a client
18
+ * with retry / observability / etc.
19
+ */
20
+ export type CloudflareClientLike = {
21
+ request: <T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown) => Promise<T>;
22
+ };
23
+ export declare class CloudflareError extends Error {
24
+ readonly status: number;
25
+ readonly body: unknown;
26
+ constructor(message: string, status: number, body: unknown);
27
+ }
28
+ /**
29
+ * fetch-backed default client. Throws CloudflareError on non-2xx OR
30
+ * on a 2xx with `success: false` (Cloudflare returns 200 + an errors
31
+ * array for some validation failures, hence the second check).
32
+ */
33
+ export declare const createCloudflareClient: (token: string, options?: {
34
+ baseUrl?: string;
35
+ fetch?: typeof fetch;
36
+ }) => CloudflareClientLike;
37
+ export type CloudflareProviderOptions = {
38
+ /** API token with `Zone:DNS:Edit` permission. Required unless `client` is set. */
39
+ token?: string;
40
+ /** Custom client. Overrides token-built default. */
41
+ client?: CloudflareClientLike;
42
+ /** Cloudflare Zone ID this provider operates on. */
43
+ zoneId: string;
44
+ /** Optional zone name for log copy. Default uses `zoneId` truncated. */
45
+ zoneName?: string;
46
+ };
47
+ /**
48
+ * Build a {@link DnsProvider} bound to one Cloudflare zone.
49
+ */
50
+ export declare const cloudflareProvider: (options: CloudflareProviderOptions) => DnsProvider;
@@ -0,0 +1,155 @@
1
+ // @bun
2
+ // src/cloudflare.ts
3
+ var CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
4
+
5
+ class CloudflareError extends Error {
6
+ status;
7
+ body;
8
+ constructor(message, status, body) {
9
+ super(message);
10
+ this.name = "CloudflareError";
11
+ this.status = status;
12
+ this.body = body;
13
+ }
14
+ }
15
+ var createCloudflareClient = (token, options = {}) => {
16
+ const base = options.baseUrl ?? CLOUDFLARE_API_BASE;
17
+ const f = options.fetch ?? fetch;
18
+ return {
19
+ request: async (method, path, body) => {
20
+ const init = {
21
+ headers: {
22
+ authorization: `Bearer ${token}`,
23
+ "content-type": "application/json"
24
+ },
25
+ method
26
+ };
27
+ if (body !== undefined)
28
+ init.body = JSON.stringify(body);
29
+ const response = await f(`${base}${path}`, init);
30
+ const text = await response.text();
31
+ const parsed = text.length > 0 ? JSON.parse(text) : undefined;
32
+ if (!response.ok) {
33
+ throw new CloudflareError(`Cloudflare API ${method} ${path} failed: ${response.status} ${response.statusText}`, response.status, parsed);
34
+ }
35
+ return parsed;
36
+ }
37
+ };
38
+ };
39
+ var resolveClient = (options) => {
40
+ if (options.client !== undefined)
41
+ return options.client;
42
+ if (options.token !== undefined && options.token.length > 0) {
43
+ return createCloudflareClient(options.token);
44
+ }
45
+ throw new Error("[deploy/cloudflare] either `token` or `client` must be provided");
46
+ };
47
+ var toDnsRecord = (raw) => ({
48
+ content: raw.content,
49
+ id: raw.id,
50
+ name: raw.name,
51
+ type: raw.type,
52
+ ...raw.ttl !== undefined ? { ttl: raw.ttl } : {},
53
+ ...raw.proxied !== undefined ? { proxied: raw.proxied } : {},
54
+ ...raw.comment !== undefined ? { comment: raw.comment } : {}
55
+ });
56
+ var specEqualsRecord = (spec, record) => {
57
+ if (record.content !== spec.content)
58
+ return false;
59
+ if (spec.ttl !== undefined && record.ttl !== spec.ttl)
60
+ return false;
61
+ if (spec.proxied !== undefined && record.proxied !== spec.proxied)
62
+ return false;
63
+ if (spec.comment !== undefined && record.comment !== spec.comment)
64
+ return false;
65
+ return true;
66
+ };
67
+ var buildQuery = (filter) => {
68
+ const params = [];
69
+ if (filter?.name !== undefined) {
70
+ params.push(`name=${encodeURIComponent(filter.name)}`);
71
+ }
72
+ if (filter?.type !== undefined) {
73
+ params.push(`type=${encodeURIComponent(filter.type)}`);
74
+ }
75
+ return params.length > 0 ? `?${params.join("&")}` : "";
76
+ };
77
+ var cloudflareProvider = (options) => {
78
+ const client = resolveClient(options);
79
+ const { zoneId } = options;
80
+ const zoneLabel = options.zoneName ?? zoneId.slice(0, 8);
81
+ const recordsPath = `/zones/${zoneId}/dns_records`;
82
+ const list = async (filter) => {
83
+ const response = await client.request("GET", `${recordsPath}${buildQuery(filter)}`);
84
+ return response.result.map(toDnsRecord);
85
+ };
86
+ const find = async (key) => {
87
+ const matches = await list(key);
88
+ const exact = matches.filter((record) => record.name === key.name || record.name === `${key.name}.`);
89
+ if (exact.length === 0)
90
+ return;
91
+ if (exact.length > 1) {
92
+ throw new Error(`[deploy/cloudflare] multiple ${key.type} records for "${key.name}" in zone ${zoneLabel} \u2014 drifted state; resolve manually before upsert.`);
93
+ }
94
+ return exact[0];
95
+ };
96
+ const create = async (spec) => {
97
+ const response = await client.request("POST", recordsPath, {
98
+ content: spec.content,
99
+ name: spec.name,
100
+ type: spec.type,
101
+ ...spec.ttl !== undefined ? { ttl: spec.ttl } : {},
102
+ ...spec.proxied !== undefined ? { proxied: spec.proxied } : {},
103
+ ...spec.comment !== undefined ? { comment: spec.comment } : {}
104
+ });
105
+ return toDnsRecord(response.result);
106
+ };
107
+ const update = async (id, spec) => {
108
+ const response = await client.request("PUT", `${recordsPath}/${id}`, {
109
+ content: spec.content,
110
+ name: spec.name,
111
+ type: spec.type,
112
+ ...spec.ttl !== undefined ? { ttl: spec.ttl } : {},
113
+ ...spec.proxied !== undefined ? { proxied: spec.proxied } : {},
114
+ ...spec.comment !== undefined ? { comment: spec.comment } : {}
115
+ });
116
+ return toDnsRecord(response.result);
117
+ };
118
+ const deleteRecord = async (id) => {
119
+ try {
120
+ await client.request("DELETE", `${recordsPath}/${id}`);
121
+ } catch (error) {
122
+ if (error instanceof CloudflareError && error.status === 404) {
123
+ return;
124
+ }
125
+ throw error;
126
+ }
127
+ };
128
+ const upsert = async (spec) => {
129
+ const existing = await find({ name: spec.name, type: spec.type });
130
+ if (existing === undefined) {
131
+ return create(spec);
132
+ }
133
+ if (specEqualsRecord(spec, existing)) {
134
+ return existing;
135
+ }
136
+ return update(existing.id, spec);
137
+ };
138
+ return {
139
+ create,
140
+ delete: deleteRecord,
141
+ description: `cloudflare zone "${zoneLabel}"`,
142
+ find,
143
+ list,
144
+ update,
145
+ upsert
146
+ };
147
+ };
148
+ export {
149
+ createCloudflareClient,
150
+ cloudflareProvider,
151
+ CloudflareError
152
+ };
153
+
154
+ //# debugId=CC538CE8BF2FCBDB64756E2164756E21
155
+ //# sourceMappingURL=cloudflare.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/cloudflare.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * @absolutejs/deploy/cloudflare — Cloudflare DNS adapter implementing\n * the {@link DnsProvider} contract from `./dns`.\n *\n * Scope: one provider instance is bound to one Cloudflare zone. Multi-\n * zone callers construct one provider per zone.\n *\n * Auth: Cloudflare API tokens with `Zone:DNS:Edit` scope. Global API\n * keys are intentionally not supported — tokens are the modern path.\n *\n * Narrow CloudflareClientLike interface keeps `cloudflare` SDK out as\n * a hard dep. Default client uses `fetch` against\n * `api.cloudflare.com/client/v4`.\n */\n\nimport type {\n\tDnsProvider,\n\tDnsRecord,\n\tDnsRecordFilter,\n\tDnsRecordSpec,\n\tDnsRecordType\n} from './dns';\n\nconst CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';\n\n/**\n * Minimal subset of Cloudflare API calls. Lets callers BYO a client\n * with retry / observability / etc.\n */\nexport type CloudflareClientLike = {\n\trequest: <T = unknown>(\n\t\tmethod: 'GET' | 'POST' | 'PUT' | 'DELETE',\n\t\tpath: string,\n\t\tbody?: unknown\n\t) => Promise<T>;\n};\n\n/** Cloudflare's standard envelope on every response. */\ntype CloudflareResponse<T> = {\n\tsuccess: boolean;\n\tresult: T;\n\terrors?: Array<{ code: number; message: string }>;\n\tmessages?: Array<{ code: number; message: string }>;\n};\n\n/** Raw record from Cloudflare's API. */\ntype CloudflareDnsRecord = {\n\tid: string;\n\tname: string;\n\ttype: string;\n\tcontent: string;\n\tttl?: number;\n\tproxied?: boolean;\n\tcomment?: string;\n\tzone_id?: string;\n\tzone_name?: string;\n};\n\nexport class CloudflareError 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 = 'CloudflareError';\n\t\tthis.status = status;\n\t\tthis.body = body;\n\t}\n}\n\n/**\n * fetch-backed default client. Throws CloudflareError on non-2xx OR\n * on a 2xx with `success: false` (Cloudflare returns 200 + an errors\n * array for some validation failures, hence the second check).\n */\nexport const createCloudflareClient = (\n\ttoken: string,\n\toptions: { baseUrl?: string; fetch?: typeof fetch } = {}\n): CloudflareClientLike => {\n\tconst base = options.baseUrl ?? CLOUDFLARE_API_BASE;\n\tconst f = options.fetch ?? fetch;\n\treturn {\n\t\trequest: async <T>(\n\t\t\tmethod: 'GET' | 'POST' | 'PUT' | '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\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 CloudflareError(\n\t\t\t\t\t`Cloudflare 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\nexport type CloudflareProviderOptions = {\n\t/** API token with `Zone:DNS:Edit` permission. Required unless `client` is set. */\n\ttoken?: string;\n\t/** Custom client. Overrides token-built default. */\n\tclient?: CloudflareClientLike;\n\t/** Cloudflare Zone ID this provider operates on. */\n\tzoneId: string;\n\t/** Optional zone name for log copy. Default uses `zoneId` truncated. */\n\tzoneName?: string;\n};\n\nconst resolveClient = (\n\toptions: Pick<CloudflareProviderOptions, 'client' | 'token'>\n): CloudflareClientLike => {\n\tif (options.client !== undefined) return options.client;\n\tif (options.token !== undefined && options.token.length > 0) {\n\t\treturn createCloudflareClient(options.token);\n\t}\n\tthrow new Error(\n\t\t'[deploy/cloudflare] either `token` or `client` must be provided'\n\t);\n};\n\nconst toDnsRecord = (raw: CloudflareDnsRecord): DnsRecord => ({\n\tcontent: raw.content,\n\tid: raw.id,\n\tname: raw.name,\n\ttype: raw.type as DnsRecordType,\n\t...(raw.ttl !== undefined ? { ttl: raw.ttl } : {}),\n\t...(raw.proxied !== undefined ? { proxied: raw.proxied } : {}),\n\t...(raw.comment !== undefined ? { comment: raw.comment } : {})\n});\n\nconst specEqualsRecord = (spec: DnsRecordSpec, record: DnsRecord): boolean => {\n\tif (record.content !== spec.content) return false;\n\tif (spec.ttl !== undefined && record.ttl !== spec.ttl) return false;\n\tif (spec.proxied !== undefined && record.proxied !== spec.proxied)\n\t\treturn false;\n\tif (spec.comment !== undefined && record.comment !== spec.comment)\n\t\treturn false;\n\treturn true;\n};\n\nconst buildQuery = (filter?: DnsRecordFilter): string => {\n\tconst params: string[] = [];\n\tif (filter?.name !== undefined) {\n\t\tparams.push(`name=${encodeURIComponent(filter.name)}`);\n\t}\n\tif (filter?.type !== undefined) {\n\t\tparams.push(`type=${encodeURIComponent(filter.type)}`);\n\t}\n\treturn params.length > 0 ? `?${params.join('&')}` : '';\n};\n\n/**\n * Build a {@link DnsProvider} bound to one Cloudflare zone.\n */\nexport const cloudflareProvider = (\n\toptions: CloudflareProviderOptions\n): DnsProvider => {\n\tconst client = resolveClient(options);\n\tconst { zoneId } = options;\n\tconst zoneLabel = options.zoneName ?? zoneId.slice(0, 8);\n\tconst recordsPath = `/zones/${zoneId}/dns_records`;\n\n\tconst list = async (filter?: DnsRecordFilter): Promise<DnsRecord[]> => {\n\t\tconst response = await client.request<\n\t\t\tCloudflareResponse<CloudflareDnsRecord[]>\n\t\t>('GET', `${recordsPath}${buildQuery(filter)}`);\n\t\treturn response.result.map(toDnsRecord);\n\t};\n\n\tconst find = async (key: {\n\t\tname: string;\n\t\ttype: DnsRecordType;\n\t}): Promise<DnsRecord | undefined> => {\n\t\tconst matches = await list(key);\n\t\t// Cloudflare's filter is sometimes substring-loose on `name`; pin to\n\t\t// an exact match here so a `'api.example.com'` lookup never returns\n\t\t// `'api.example.com.staging'`.\n\t\tconst exact = matches.filter(\n\t\t\t(record) => record.name === key.name || record.name === `${key.name}.`\n\t\t);\n\t\tif (exact.length === 0) return undefined;\n\t\tif (exact.length > 1) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/cloudflare] multiple ${key.type} records for \"${key.name}\" in zone ${zoneLabel} — drifted state; resolve manually before upsert.`\n\t\t\t);\n\t\t}\n\t\treturn exact[0];\n\t};\n\n\tconst create = async (spec: DnsRecordSpec): Promise<DnsRecord> => {\n\t\tconst response = await client.request<\n\t\t\tCloudflareResponse<CloudflareDnsRecord>\n\t\t>('POST', recordsPath, {\n\t\t\tcontent: spec.content,\n\t\t\tname: spec.name,\n\t\t\ttype: spec.type,\n\t\t\t...(spec.ttl !== undefined ? { ttl: spec.ttl } : {}),\n\t\t\t...(spec.proxied !== undefined ? { proxied: spec.proxied } : {}),\n\t\t\t...(spec.comment !== undefined ? { comment: spec.comment } : {})\n\t\t});\n\t\treturn toDnsRecord(response.result);\n\t};\n\n\tconst update = async (\n\t\tid: string,\n\t\tspec: DnsRecordSpec\n\t): Promise<DnsRecord> => {\n\t\tconst response = await client.request<\n\t\t\tCloudflareResponse<CloudflareDnsRecord>\n\t\t>('PUT', `${recordsPath}/${id}`, {\n\t\t\tcontent: spec.content,\n\t\t\tname: spec.name,\n\t\t\ttype: spec.type,\n\t\t\t...(spec.ttl !== undefined ? { ttl: spec.ttl } : {}),\n\t\t\t...(spec.proxied !== undefined ? { proxied: spec.proxied } : {}),\n\t\t\t...(spec.comment !== undefined ? { comment: spec.comment } : {})\n\t\t});\n\t\treturn toDnsRecord(response.result);\n\t};\n\n\tconst deleteRecord = async (id: string): Promise<void> => {\n\t\ttry {\n\t\t\tawait client.request('DELETE', `${recordsPath}/${id}`);\n\t\t} catch (error) {\n\t\t\tif (error instanceof CloudflareError && error.status === 404) {\n\t\t\t\treturn; // already gone — idempotent\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t};\n\n\tconst upsert = async (spec: DnsRecordSpec): Promise<DnsRecord> => {\n\t\tconst existing = await find({ name: spec.name, type: spec.type });\n\t\tif (existing === undefined) {\n\t\t\treturn create(spec);\n\t\t}\n\t\tif (specEqualsRecord(spec, existing)) {\n\t\t\treturn existing;\n\t\t}\n\t\treturn update(existing.id, spec);\n\t};\n\n\treturn {\n\t\tcreate,\n\t\tdelete: deleteRecord,\n\t\tdescription: `cloudflare zone \"${zoneLabel}\"`,\n\t\tfind,\n\t\tlist,\n\t\tupdate,\n\t\tupsert\n\t};\n};\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "CC538CE8BF2FCBDB64756E2164756E21",
9
+ "names": []
10
+ }
package/dist/dns.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * DNS provider contract for `@absolutejs/deploy`. Provider-specific
3
+ * adapters (Cloudflare, Route 53, etc.) implement `DnsProvider`; the
4
+ * deploy pipeline composes them with a cloud `Target` to point a
5
+ * hostname at a freshly-provisioned IP.
6
+ *
7
+ * Operations are zone-scoped: a `DnsProvider` instance is bound to
8
+ * one zone at construction time. Multi-zone hosts construct one
9
+ * provider per zone.
10
+ */
11
+ /** Record types we care about for deploy workflows. */
12
+ export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX';
13
+ /** A DNS record as it exists in the provider. */
14
+ export type DnsRecord = {
15
+ /** Provider-assigned id (Cloudflare uuid, Route 53 set name, etc.). */
16
+ id: string;
17
+ /** Fully-qualified record name (`api.example.com.`). */
18
+ name: string;
19
+ type: DnsRecordType;
20
+ /** Record value — IP for A/AAAA, hostname for CNAME, text for TXT, etc. */
21
+ content: string;
22
+ ttl?: number;
23
+ /**
24
+ * Cloudflare's orange-cloud proxy flag. Other providers ignore this
25
+ * field. Default behavior is provider-specific: Cloudflare defaults
26
+ * to `true` (proxied) for new records when omitted.
27
+ */
28
+ proxied?: boolean;
29
+ comment?: string;
30
+ };
31
+ /** Desired state for an upsert. */
32
+ export type DnsRecordSpec = {
33
+ /** Record name. May be `'@'` for the zone apex or relative ('api'). */
34
+ name: string;
35
+ type: DnsRecordType;
36
+ content: string;
37
+ ttl?: number;
38
+ proxied?: boolean;
39
+ comment?: string;
40
+ };
41
+ export type DnsRecordFilter = {
42
+ name?: string;
43
+ type?: DnsRecordType;
44
+ };
45
+ /**
46
+ * Zone-scoped DNS operations. Implementations: `cloudflareProvider`,
47
+ * future `route53Provider`, etc.
48
+ */
49
+ export type DnsProvider = {
50
+ /** Human-readable description for logs (`'cloudflare zone "example.com"'`). */
51
+ readonly description: string;
52
+ /** List records, optionally filtered by name + type. */
53
+ list: (filter?: DnsRecordFilter) => Promise<DnsRecord[]>;
54
+ /**
55
+ * Find one record by exact (name, type). Returns undefined if absent.
56
+ * Throws if multiple records share that key (drifted state — multiple
57
+ * A records pointing at different IPs, etc.).
58
+ */
59
+ find: (key: {
60
+ name: string;
61
+ type: DnsRecordType;
62
+ }) => Promise<DnsRecord | undefined>;
63
+ create: (spec: DnsRecordSpec) => Promise<DnsRecord>;
64
+ update: (id: string, spec: DnsRecordSpec) => Promise<DnsRecord>;
65
+ delete: (id: string) => Promise<void>;
66
+ /**
67
+ * Create or update so the (name, type) record matches `spec`. The
68
+ * canonical "point this DNS at this IP" entry point — idempotent.
69
+ */
70
+ upsert: (spec: DnsRecordSpec) => Promise<DnsRecord>;
71
+ };
72
+ /**
73
+ * Compose a DNS provider with a cloud Target's IPv4. Idempotently
74
+ * ensures an A record (name → target.ipv4) exists. Returns the
75
+ * resulting DNS record.
76
+ *
77
+ * Usage:
78
+ *
79
+ * const target = await digitalOceanTarget({ ... });
80
+ * const dns = cloudflareProvider({ token, zoneId });
81
+ * await ensureDnsForTarget(dns, {
82
+ * name: 'api.example.com',
83
+ * target,
84
+ * ttl: 60,
85
+ * proxied: false,
86
+ * });
87
+ */
88
+ export declare const ensureDnsForTarget: (provider: DnsProvider, options: {
89
+ /** Record name (FQDN, relative, or `'@'` for zone apex). */
90
+ name: string;
91
+ /** Cloud Target with an `ipv4` field. */
92
+ target: {
93
+ ipv4: string;
94
+ };
95
+ /** Default 300s. */
96
+ ttl?: number;
97
+ /** Cloudflare orange-cloud. Other providers ignore. */
98
+ proxied?: boolean;
99
+ /** Optional record comment for audit trails. */
100
+ comment?: string;
101
+ }) => Promise<DnsRecord>;
package/dist/dns.js ADDED
@@ -0,0 +1,16 @@
1
+ // @bun
2
+ // src/dns.ts
3
+ var ensureDnsForTarget = async (provider, options) => provider.upsert({
4
+ content: options.target.ipv4,
5
+ name: options.name,
6
+ type: "A",
7
+ ...options.ttl !== undefined ? { ttl: options.ttl } : {},
8
+ ...options.proxied !== undefined ? { proxied: options.proxied } : {},
9
+ ...options.comment !== undefined ? { comment: options.comment } : {}
10
+ });
11
+ export {
12
+ ensureDnsForTarget
13
+ };
14
+
15
+ //# debugId=9927EACBFED2D7E864756E2164756E21
16
+ //# sourceMappingURL=dns.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/dns.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * DNS provider contract for `@absolutejs/deploy`. Provider-specific\n * adapters (Cloudflare, Route 53, etc.) implement `DnsProvider`; the\n * deploy pipeline composes them with a cloud `Target` to point a\n * hostname at a freshly-provisioned IP.\n *\n * Operations are zone-scoped: a `DnsProvider` instance is bound to\n * one zone at construction time. Multi-zone hosts construct one\n * provider per zone.\n */\n\n/** Record types we care about for deploy workflows. */\nexport type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX';\n\n/** A DNS record as it exists in the provider. */\nexport type DnsRecord = {\n\t/** Provider-assigned id (Cloudflare uuid, Route 53 set name, etc.). */\n\tid: string;\n\t/** Fully-qualified record name (`api.example.com.`). */\n\tname: string;\n\ttype: DnsRecordType;\n\t/** Record value — IP for A/AAAA, hostname for CNAME, text for TXT, etc. */\n\tcontent: string;\n\tttl?: number;\n\t/**\n\t * Cloudflare's orange-cloud proxy flag. Other providers ignore this\n\t * field. Default behavior is provider-specific: Cloudflare defaults\n\t * to `true` (proxied) for new records when omitted.\n\t */\n\tproxied?: boolean;\n\tcomment?: string;\n};\n\n/** Desired state for an upsert. */\nexport type DnsRecordSpec = {\n\t/** Record name. May be `'@'` for the zone apex or relative ('api'). */\n\tname: string;\n\ttype: DnsRecordType;\n\tcontent: string;\n\tttl?: number;\n\tproxied?: boolean;\n\tcomment?: string;\n};\n\nexport type DnsRecordFilter = {\n\tname?: string;\n\ttype?: DnsRecordType;\n};\n\n/**\n * Zone-scoped DNS operations. Implementations: `cloudflareProvider`,\n * future `route53Provider`, etc.\n */\nexport type DnsProvider = {\n\t/** Human-readable description for logs (`'cloudflare zone \"example.com\"'`). */\n\treadonly description: string;\n\t/** List records, optionally filtered by name + type. */\n\tlist: (filter?: DnsRecordFilter) => Promise<DnsRecord[]>;\n\t/**\n\t * Find one record by exact (name, type). Returns undefined if absent.\n\t * Throws if multiple records share that key (drifted state — multiple\n\t * A records pointing at different IPs, etc.).\n\t */\n\tfind: (key: {\n\t\tname: string;\n\t\ttype: DnsRecordType;\n\t}) => Promise<DnsRecord | undefined>;\n\tcreate: (spec: DnsRecordSpec) => Promise<DnsRecord>;\n\tupdate: (id: string, spec: DnsRecordSpec) => Promise<DnsRecord>;\n\tdelete: (id: string) => Promise<void>;\n\t/**\n\t * Create or update so the (name, type) record matches `spec`. The\n\t * canonical \"point this DNS at this IP\" entry point — idempotent.\n\t */\n\tupsert: (spec: DnsRecordSpec) => Promise<DnsRecord>;\n};\n\n/**\n * Compose a DNS provider with a cloud Target's IPv4. Idempotently\n * ensures an A record (name → target.ipv4) exists. Returns the\n * resulting DNS record.\n *\n * Usage:\n *\n * const target = await digitalOceanTarget({ ... });\n * const dns = cloudflareProvider({ token, zoneId });\n * await ensureDnsForTarget(dns, {\n * name: 'api.example.com',\n * target,\n * ttl: 60,\n * proxied: false,\n * });\n */\nexport const ensureDnsForTarget = async (\n\tprovider: DnsProvider,\n\toptions: {\n\t\t/** Record name (FQDN, relative, or `'@'` for zone apex). */\n\t\tname: string;\n\t\t/** Cloud Target with an `ipv4` field. */\n\t\ttarget: { ipv4: string };\n\t\t/** Default 300s. */\n\t\tttl?: number;\n\t\t/** Cloudflare orange-cloud. Other providers ignore. */\n\t\tproxied?: boolean;\n\t\t/** Optional record comment for audit trails. */\n\t\tcomment?: string;\n\t}\n): Promise<DnsRecord> =>\n\tprovider.upsert({\n\t\tcontent: options.target.ipv4,\n\t\tname: options.name,\n\t\ttype: 'A',\n\t\t...(options.ttl !== undefined ? { ttl: options.ttl } : {}),\n\t\t...(options.proxied !== undefined ? { proxied: options.proxied } : {}),\n\t\t...(options.comment !== undefined ? { comment: options.comment } : {})\n\t});\n"
6
+ ],
7
+ "mappings": ";;AA6FO,IAAM,qBAAqB,OACjC,UACA,YAaA,SAAS,OAAO;AAAA,EACf,SAAS,QAAQ,OAAO;AAAA,EACxB,MAAM,QAAQ;AAAA,EACd,MAAM;AAAA,KACF,QAAQ,QAAQ,YAAY,EAAE,KAAK,QAAQ,IAAI,IAAI,CAAC;AAAA,KACpD,QAAQ,YAAY,YAAY,EAAE,SAAS,QAAQ,QAAQ,IAAI,CAAC;AAAA,KAChE,QAAQ,YAAY,YAAY,EAAE,SAAS,QAAQ,QAAQ,IAAI,CAAC;AACrE,CAAC;",
8
+ "debugId": "9927EACBFED2D7E864756E2164756E21",
9
+ "names": []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/deploy",
3
- "version": "0.3.1",
3
+ "version": "0.4.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 src/hetzner.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 src/dns.ts src/cloudflare.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}\"",
@@ -56,6 +56,16 @@
56
56
  "types": "./dist/hetzner.d.ts",
57
57
  "import": "./dist/hetzner.js",
58
58
  "default": "./dist/hetzner.js"
59
+ },
60
+ "./dns": {
61
+ "types": "./dist/dns.d.ts",
62
+ "import": "./dist/dns.js",
63
+ "default": "./dist/dns.js"
64
+ },
65
+ "./cloudflare": {
66
+ "types": "./dist/cloudflare.d.ts",
67
+ "import": "./dist/cloudflare.js",
68
+ "default": "./dist/cloudflare.js"
59
69
  }
60
70
  },
61
71
  "files": ["dist", "README.md"]