doomain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +8 -0
- package/dist/commands/auth/logout/vercel.d.ts +9 -0
- package/dist/commands/auth/logout/vercel.js +35 -0
- package/dist/commands/auth/vercel.d.ts +10 -0
- package/dist/commands/auth/vercel.js +69 -0
- package/dist/commands/domains/list.d.ts +10 -0
- package/dist/commands/domains/list.js +36 -0
- package/dist/commands/link.d.ts +21 -0
- package/dist/commands/link.js +63 -0
- package/dist/commands/projects/list.d.ts +9 -0
- package/dist/commands/projects/list.js +26 -0
- package/dist/commands/providers/add.d.ts +1 -0
- package/dist/commands/providers/add.js +1 -0
- package/dist/commands/providers/connect.d.ts +15 -0
- package/dist/commands/providers/connect.js +183 -0
- package/dist/commands/providers/disconnect.d.ts +13 -0
- package/dist/commands/providers/disconnect.js +52 -0
- package/dist/commands/providers/list.d.ts +8 -0
- package/dist/commands/providers/list.js +25 -0
- package/dist/commands/providers/status.d.ts +9 -0
- package/dist/commands/providers/status.js +31 -0
- package/dist/commands/providers/verify.d.ts +11 -0
- package/dist/commands/providers/verify.js +27 -0
- package/dist/commands/schema.d.ts +11 -0
- package/dist/commands/schema.js +29 -0
- package/dist/commands/verify.d.ts +12 -0
- package/dist/commands/verify.js +36 -0
- package/dist/commands/wizard.d.ts +9 -0
- package/dist/commands/wizard.js +322 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/command-schema.d.ts +14 -0
- package/dist/lib/command-schema.js +99 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +49 -0
- package/dist/lib/errors.d.ts +7 -0
- package/dist/lib/errors.js +17 -0
- package/dist/lib/flags.d.ts +6 -0
- package/dist/lib/flags.js +10 -0
- package/dist/lib/link-domain.d.ts +47 -0
- package/dist/lib/link-domain.js +336 -0
- package/dist/lib/local-vercel.d.ts +6 -0
- package/dist/lib/local-vercel.js +27 -0
- package/dist/lib/output.d.ts +36 -0
- package/dist/lib/output.js +51 -0
- package/dist/lib/providers/cloudflare/index.d.ts +26 -0
- package/dist/lib/providers/cloudflare/index.js +219 -0
- package/dist/lib/providers/core/config.d.ts +5 -0
- package/dist/lib/providers/core/config.js +33 -0
- package/dist/lib/providers/core/errors.d.ts +6 -0
- package/dist/lib/providers/core/errors.js +18 -0
- package/dist/lib/providers/core/http.d.ts +17 -0
- package/dist/lib/providers/core/http.js +43 -0
- package/dist/lib/providers/core/pagination.d.ts +12 -0
- package/dist/lib/providers/core/pagination.js +15 -0
- package/dist/lib/providers/core/planner.d.ts +19 -0
- package/dist/lib/providers/core/planner.js +79 -0
- package/dist/lib/providers/core/types.d.ts +123 -0
- package/dist/lib/providers/core/types.js +1 -0
- package/dist/lib/providers/namecheap/index.d.ts +28 -0
- package/dist/lib/providers/namecheap/index.js +289 -0
- package/dist/lib/providers/registry.d.ts +4 -0
- package/dist/lib/providers/registry.js +21 -0
- package/dist/lib/providers/spaceship/index.d.ts +22 -0
- package/dist/lib/providers/spaceship/index.js +148 -0
- package/dist/lib/providers/status.d.ts +16 -0
- package/dist/lib/providers/status.js +33 -0
- package/dist/lib/providers/types.d.ts +1 -0
- package/dist/lib/providers/types.js +1 -0
- package/dist/lib/validate.d.ts +15 -0
- package/dist/lib/validate.js +62 -0
- package/dist/lib/vercel.d.ts +32 -0
- package/dist/lib/vercel.js +131 -0
- package/oclif.manifest.json +633 -0
- package/package.json +86 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { normalizeDomain } from '../../validate.js';
|
|
2
|
+
import { createProviderHttpClient } from '../core/http.js';
|
|
3
|
+
import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
|
|
4
|
+
import { ProviderError } from '../core/errors.js';
|
|
5
|
+
const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4';
|
|
6
|
+
const capabilities = {
|
|
7
|
+
defaultTtl: 3600,
|
|
8
|
+
maxTtl: 86_400,
|
|
9
|
+
minTtl: 60,
|
|
10
|
+
recordTypes: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'],
|
|
11
|
+
supportsApexCname: false,
|
|
12
|
+
supportsBulkWrites: false,
|
|
13
|
+
supportsPagination: true,
|
|
14
|
+
supportsProxying: true,
|
|
15
|
+
supportsRecordIds: true,
|
|
16
|
+
};
|
|
17
|
+
function cloudflareErrorMessage(response) {
|
|
18
|
+
return response.errors?.find((error) => error.message)?.message ?? 'Cloudflare API error.';
|
|
19
|
+
}
|
|
20
|
+
function isSupportedRecordType(type) {
|
|
21
|
+
return capabilities.recordTypes.includes(type);
|
|
22
|
+
}
|
|
23
|
+
function cleanDnsRecordName(name) {
|
|
24
|
+
return name.trim().toLowerCase().replace(/\.$/, '');
|
|
25
|
+
}
|
|
26
|
+
function relativeRecordName(name, zoneName) {
|
|
27
|
+
const normalized = cleanDnsRecordName(name);
|
|
28
|
+
const normalizedZone = normalizeDomain(zoneName);
|
|
29
|
+
if (normalized === normalizedZone)
|
|
30
|
+
return '@';
|
|
31
|
+
if (normalized.endsWith(`.${normalizedZone}`))
|
|
32
|
+
return normalized.slice(0, -(normalizedZone.length + 1));
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
function absoluteRecordName(record, zone) {
|
|
36
|
+
return record.name === '@' ? zone.name : `${record.name}.${zone.name}`;
|
|
37
|
+
}
|
|
38
|
+
function toDnsRecord(record, zone) {
|
|
39
|
+
if (!record.id || !record.name || !record.type || !record.content || !isSupportedRecordType(record.type))
|
|
40
|
+
return null;
|
|
41
|
+
const dnsRecord = {
|
|
42
|
+
id: record.id,
|
|
43
|
+
metadata: { cloudflare: record },
|
|
44
|
+
name: relativeRecordName(record.name, zone.name),
|
|
45
|
+
type: record.type,
|
|
46
|
+
value: record.content,
|
|
47
|
+
};
|
|
48
|
+
if (record.priority !== undefined)
|
|
49
|
+
dnsRecord.priority = record.priority;
|
|
50
|
+
if (record.proxied !== undefined)
|
|
51
|
+
dnsRecord.proxied = record.proxied;
|
|
52
|
+
if (record.ttl !== undefined)
|
|
53
|
+
dnsRecord.ttl = record.ttl;
|
|
54
|
+
return dnsRecord;
|
|
55
|
+
}
|
|
56
|
+
function toCloudflareRecord(record, zone) {
|
|
57
|
+
return {
|
|
58
|
+
content: record.value,
|
|
59
|
+
name: absoluteRecordName(record, zone),
|
|
60
|
+
ttl: record.ttl ?? capabilities.defaultTtl,
|
|
61
|
+
type: record.type,
|
|
62
|
+
...(record.priority === undefined ? {} : { priority: record.priority }),
|
|
63
|
+
...(record.proxied === undefined || !['A', 'AAAA', 'CNAME'].includes(record.type) ? {} : { proxied: record.proxied }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function toZone(zone) {
|
|
67
|
+
if (!zone.id || !zone.name)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const name = normalizeDomain(zone.name);
|
|
71
|
+
return { id: zone.id, metadata: { cloudflare: zone }, name };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export class CloudflareProvider {
|
|
78
|
+
capabilities = capabilities;
|
|
79
|
+
id = 'cloudflare';
|
|
80
|
+
name = 'Cloudflare';
|
|
81
|
+
accountId;
|
|
82
|
+
http;
|
|
83
|
+
constructor(context) {
|
|
84
|
+
this.accountId = context.credentials.accountId;
|
|
85
|
+
this.http = createProviderHttpClient({
|
|
86
|
+
baseUrl: CLOUDFLARE_API_URL,
|
|
87
|
+
errorMessages: {
|
|
88
|
+
401: 'Cloudflare rejected the API token.',
|
|
89
|
+
403: 'Cloudflare API token is missing required permissions. Enable Zone:Read and DNS:Edit for the account.',
|
|
90
|
+
429: 'Cloudflare rate limit exceeded. Try again later.',
|
|
91
|
+
},
|
|
92
|
+
headers: { Authorization: `Bearer ${context.credentials.apiToken}` },
|
|
93
|
+
providerId: this.id,
|
|
94
|
+
signal: context.signal,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async verifyCredentials() {
|
|
98
|
+
await this.listZones();
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
async listZones() {
|
|
102
|
+
const zones = [];
|
|
103
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
104
|
+
const response = await this.request('/zones', {
|
|
105
|
+
query: { 'account.id': this.accountId, direction: 'asc', order: 'name', page, 'per_page': 50 },
|
|
106
|
+
});
|
|
107
|
+
for (const zone of response.result ?? []) {
|
|
108
|
+
const dnsZone = toZone(zone);
|
|
109
|
+
if (dnsZone)
|
|
110
|
+
zones.push(dnsZone);
|
|
111
|
+
}
|
|
112
|
+
const totalPages = response.result_info?.total_pages ?? page;
|
|
113
|
+
if (page >= totalPages || (response.result ?? []).length === 0)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return zones;
|
|
117
|
+
}
|
|
118
|
+
async getZone(domain) {
|
|
119
|
+
const normalized = normalizeDomain(domain);
|
|
120
|
+
const zones = await this.listZones();
|
|
121
|
+
return zones.find((zone) => zone.name === normalized) ?? null;
|
|
122
|
+
}
|
|
123
|
+
async listRecords(zone) {
|
|
124
|
+
const records = [];
|
|
125
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
126
|
+
const response = await this.request(`/zones/${zone.id}/dns_records`, {
|
|
127
|
+
query: { page, 'per_page': 100 },
|
|
128
|
+
});
|
|
129
|
+
for (const record of response.result ?? []) {
|
|
130
|
+
const dnsRecord = toDnsRecord(record, zone);
|
|
131
|
+
if (dnsRecord)
|
|
132
|
+
records.push(dnsRecord);
|
|
133
|
+
}
|
|
134
|
+
const totalPages = response.result_info?.total_pages ?? page;
|
|
135
|
+
if (page >= totalPages || (response.result ?? []).length === 0)
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
return records;
|
|
139
|
+
}
|
|
140
|
+
async planChanges(zone, desired, opts = {}) {
|
|
141
|
+
return planDnsChanges({ desired, existing: await this.listRecords(zone), force: opts.force, providerId: this.id, zone });
|
|
142
|
+
}
|
|
143
|
+
async applyChanges(zone, plan) {
|
|
144
|
+
assertNoConflicts(this.id, plan);
|
|
145
|
+
const applied = [];
|
|
146
|
+
const skipped = [];
|
|
147
|
+
for (const change of plan.changes) {
|
|
148
|
+
if (change.action === 'skip') {
|
|
149
|
+
skipped.push(change.record);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (change.action === 'delete')
|
|
153
|
+
await this.deleteRecord(zone, change.existing);
|
|
154
|
+
else if (change.action === 'update')
|
|
155
|
+
await this.updateRecord(zone, change.existing, change.record);
|
|
156
|
+
else
|
|
157
|
+
await this.createRecord(zone, change.record);
|
|
158
|
+
applied.push(change);
|
|
159
|
+
}
|
|
160
|
+
return { applied, skipped };
|
|
161
|
+
}
|
|
162
|
+
async upsertRecord(zone, record) {
|
|
163
|
+
const existing = (await this.listRecords(zone)).find((item) => item.name === record.name && item.type === record.type);
|
|
164
|
+
if (existing)
|
|
165
|
+
return this.updateRecord(zone, existing, record);
|
|
166
|
+
return this.createRecord(zone, record);
|
|
167
|
+
}
|
|
168
|
+
async deleteRecord(zone, record) {
|
|
169
|
+
if (!record.id)
|
|
170
|
+
throw new ProviderError(this.id, 'PROVIDER_API_ERROR', 'Cloudflare DNS record id is required to delete a record.');
|
|
171
|
+
await this.request(`/zones/${zone.id}/dns_records/${record.id}`, { method: 'DELETE' });
|
|
172
|
+
}
|
|
173
|
+
async createRecord(zone, record) {
|
|
174
|
+
const response = await this.request(`/zones/${zone.id}/dns_records`, {
|
|
175
|
+
body: toCloudflareRecord(record, zone),
|
|
176
|
+
method: 'POST',
|
|
177
|
+
});
|
|
178
|
+
const created = response.result ? toDnsRecord(response.result, zone) : null;
|
|
179
|
+
return created ?? { ...record, ttl: record.ttl ?? capabilities.defaultTtl };
|
|
180
|
+
}
|
|
181
|
+
async updateRecord(zone, existing, record) {
|
|
182
|
+
if (!existing.id)
|
|
183
|
+
throw new ProviderError(this.id, 'PROVIDER_API_ERROR', 'Cloudflare DNS record id is required to update a record.');
|
|
184
|
+
const response = await this.request(`/zones/${zone.id}/dns_records/${existing.id}`, {
|
|
185
|
+
body: toCloudflareRecord(record, zone),
|
|
186
|
+
method: 'PUT',
|
|
187
|
+
});
|
|
188
|
+
const updated = response.result ? toDnsRecord(response.result, zone) : null;
|
|
189
|
+
return updated ?? { ...record, id: existing.id, ttl: record.ttl ?? capabilities.defaultTtl };
|
|
190
|
+
}
|
|
191
|
+
async request(path, init = {}) {
|
|
192
|
+
const response = await this.http.request(path, init);
|
|
193
|
+
if (response.success === false) {
|
|
194
|
+
throw new ProviderError(this.id, 'PROVIDER_API_ERROR', cloudflareErrorMessage(response), response.errors);
|
|
195
|
+
}
|
|
196
|
+
return response;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export const cloudflareProviderDefinition = {
|
|
200
|
+
capabilities,
|
|
201
|
+
credentials: [
|
|
202
|
+
{ env: 'CLOUDFLARE_API_TOKEN', key: 'apiToken', label: 'API token', required: true, secret: true },
|
|
203
|
+
{ env: 'CLOUDFLARE_ACCOUNT_ID', key: 'accountId', label: 'Account ID', required: true },
|
|
204
|
+
],
|
|
205
|
+
displayName: 'Cloudflare',
|
|
206
|
+
docsUrl: 'https://developers.cloudflare.com/api/',
|
|
207
|
+
id: 'cloudflare',
|
|
208
|
+
name: 'Cloudflare',
|
|
209
|
+
setup: {
|
|
210
|
+
notes: [
|
|
211
|
+
'Create a Cloudflare API token with Zone:Read and DNS:Edit permissions for the account.',
|
|
212
|
+
'Use the Cloudflare account ID that owns the DNS zones you want Doomain to manage.',
|
|
213
|
+
'Doomain creates Vercel records as DNS-only records, not proxied records.',
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
create(context) {
|
|
217
|
+
return new CloudflareProvider(context);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type DoomainConfig } from '../../config.js';
|
|
2
|
+
import type { CredentialDefinition, DnsProviderDefinition, ProviderContext } from './types.js';
|
|
3
|
+
export declare function getProviderCredentials(config: DoomainConfig, providerId: string): Record<string, string>;
|
|
4
|
+
export declare function getProviderCredential(config: DoomainConfig, providerId: string, credential: CredentialDefinition): string | undefined;
|
|
5
|
+
export declare function createProviderContext(definition: DnsProviderDefinition): Promise<ProviderContext>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadConfig } from '../../config.js';
|
|
2
|
+
import { DoomainError } from '../../errors.js';
|
|
3
|
+
function legacyCredential(config, providerId, key) {
|
|
4
|
+
if (providerId !== 'spaceship')
|
|
5
|
+
return undefined;
|
|
6
|
+
const legacy = config.providers?.spaceship;
|
|
7
|
+
if (!legacy || typeof legacy !== 'object')
|
|
8
|
+
return undefined;
|
|
9
|
+
return legacy[key];
|
|
10
|
+
}
|
|
11
|
+
export function getProviderCredentials(config, providerId) {
|
|
12
|
+
const providerConfig = config.providers?.[providerId];
|
|
13
|
+
const credentials = providerConfig && typeof providerConfig === 'object' && 'credentials' in providerConfig
|
|
14
|
+
? (providerConfig.credentials ?? {})
|
|
15
|
+
: {};
|
|
16
|
+
return { ...credentials };
|
|
17
|
+
}
|
|
18
|
+
export function getProviderCredential(config, providerId, credential) {
|
|
19
|
+
return process.env[credential.env] || getProviderCredentials(config, providerId)[credential.key] || legacyCredential(config, providerId, credential.key);
|
|
20
|
+
}
|
|
21
|
+
export async function createProviderContext(definition) {
|
|
22
|
+
const config = await loadConfig();
|
|
23
|
+
const credentials = {};
|
|
24
|
+
for (const credential of definition.credentials) {
|
|
25
|
+
const value = getProviderCredential(config, definition.id, credential);
|
|
26
|
+
if (value)
|
|
27
|
+
credentials[credential.key] = value;
|
|
28
|
+
else if (credential.required !== false) {
|
|
29
|
+
throw new DoomainError('MISSING_CREDENTIALS', `Missing ${definition.displayName} ${credential.label}. Run \`doomain providers connect ${definition.id}\` or set ${credential.env}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { credentials, debug: process.env.DOOMAIN_DEBUG === '1' };
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { DoomainError, type DoomainErrorCode } from '../../errors.js';
|
|
2
|
+
export declare class ProviderError extends DoomainError {
|
|
3
|
+
readonly providerId: string;
|
|
4
|
+
constructor(providerId: string, code: DoomainErrorCode, message: string, details?: unknown);
|
|
5
|
+
}
|
|
6
|
+
export declare function providerCodeFromStatus(status: number): DoomainErrorCode;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DoomainError } from '../../errors.js';
|
|
2
|
+
export class ProviderError extends DoomainError {
|
|
3
|
+
providerId;
|
|
4
|
+
constructor(providerId, code, message, details) {
|
|
5
|
+
super(code, message, details);
|
|
6
|
+
this.name = 'ProviderError';
|
|
7
|
+
this.providerId = providerId;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function providerCodeFromStatus(status) {
|
|
11
|
+
if (status === 401)
|
|
12
|
+
return 'PROVIDER_AUTH_FAILED';
|
|
13
|
+
if (status === 403)
|
|
14
|
+
return 'PROVIDER_PERMISSION_DENIED';
|
|
15
|
+
if (status === 429)
|
|
16
|
+
return 'PROVIDER_RATE_LIMITED';
|
|
17
|
+
return 'PROVIDER_API_ERROR';
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ProviderHttpClientOptions {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
errorMessages?: Partial<Record<number, string>>;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
providerId: string;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export interface ProviderRequestOptions extends Omit<RequestInit, 'body'> {
|
|
9
|
+
body?: unknown;
|
|
10
|
+
query?: Record<string, number | string | undefined>;
|
|
11
|
+
}
|
|
12
|
+
export declare class ProviderHttpClient {
|
|
13
|
+
private readonly opts;
|
|
14
|
+
constructor(opts: ProviderHttpClientOptions);
|
|
15
|
+
request<T>(path: string, init?: ProviderRequestOptions): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createProviderHttpClient(opts: ProviderHttpClientOptions): ProviderHttpClient;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ProviderError, providerCodeFromStatus } from './errors.js';
|
|
2
|
+
function appendQuery(path, query) {
|
|
3
|
+
if (!query)
|
|
4
|
+
return path;
|
|
5
|
+
const params = new URLSearchParams();
|
|
6
|
+
for (const [key, value] of Object.entries(query)) {
|
|
7
|
+
if (value !== undefined)
|
|
8
|
+
params.set(key, String(value));
|
|
9
|
+
}
|
|
10
|
+
if (params.size === 0)
|
|
11
|
+
return path;
|
|
12
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
13
|
+
return `${path}${separator}${params.toString()}`;
|
|
14
|
+
}
|
|
15
|
+
export class ProviderHttpClient {
|
|
16
|
+
opts;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
}
|
|
20
|
+
async request(path, init = {}) {
|
|
21
|
+
const { body, headers, query, ...rest } = init;
|
|
22
|
+
const response = await fetch(`${this.opts.baseUrl}${appendQuery(path, query)}`, {
|
|
23
|
+
...rest,
|
|
24
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...this.opts.headers,
|
|
28
|
+
...headers,
|
|
29
|
+
},
|
|
30
|
+
signal: init.signal ?? this.opts.signal,
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const details = await response.json().catch(() => undefined);
|
|
34
|
+
throw new ProviderError(this.opts.providerId, providerCodeFromStatus(response.status), this.opts.errorMessages?.[response.status] ?? `${this.opts.providerId} API error (${response.status}).`, details);
|
|
35
|
+
}
|
|
36
|
+
if (response.status === 204)
|
|
37
|
+
return undefined;
|
|
38
|
+
return (await response.json().catch(() => undefined));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function createProviderHttpClient(opts) {
|
|
42
|
+
return new ProviderHttpClient(opts);
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SkipPaginationInput<T> {
|
|
2
|
+
fetchPage(input: {
|
|
3
|
+
skip: number;
|
|
4
|
+
take: number;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
items: T[];
|
|
7
|
+
total: number;
|
|
8
|
+
}>;
|
|
9
|
+
maxPages?: number;
|
|
10
|
+
take: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function paginateBySkip<T>(input: SkipPaginationInput<T>): Promise<T[]>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function paginateBySkip(input) {
|
|
2
|
+
const items = [];
|
|
3
|
+
const maxPages = input.maxPages ?? 100;
|
|
4
|
+
let skip = 0;
|
|
5
|
+
let total = Number.POSITIVE_INFINITY;
|
|
6
|
+
for (let page = 0; skip < total && page < maxPages; page += 1) {
|
|
7
|
+
const result = await input.fetchPage({ skip, take: input.take });
|
|
8
|
+
items.push(...result.items);
|
|
9
|
+
total = result.total;
|
|
10
|
+
if (result.items.length === 0)
|
|
11
|
+
break;
|
|
12
|
+
skip += result.items.length;
|
|
13
|
+
}
|
|
14
|
+
return items;
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DnsChange, DnsChangePlan, DnsRecord, DnsRecordInput, DnsZone, PlanOptions } from './types.js';
|
|
2
|
+
export declare function planDnsChanges(input: {
|
|
3
|
+
desired: DnsRecordInput[];
|
|
4
|
+
existing: DnsRecord[];
|
|
5
|
+
force?: boolean;
|
|
6
|
+
providerId: string;
|
|
7
|
+
zone: DnsZone;
|
|
8
|
+
}): DnsChangePlan;
|
|
9
|
+
export declare function assertNoConflicts(providerId: string, plan: DnsChangePlan): void;
|
|
10
|
+
export declare function applyDnsChanges(input: {
|
|
11
|
+
deleteRecord(record: DnsRecord): Promise<void>;
|
|
12
|
+
plan: DnsChangePlan;
|
|
13
|
+
providerId: string;
|
|
14
|
+
upsertRecord(record: DnsRecordInput): Promise<DnsRecord>;
|
|
15
|
+
opts?: PlanOptions;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
applied: DnsChange[];
|
|
18
|
+
skipped: DnsRecordInput[];
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ProviderError } from './errors.js';
|
|
2
|
+
function cleanDnsValue(value) {
|
|
3
|
+
return value.toLowerCase().replace(/\.$/, '');
|
|
4
|
+
}
|
|
5
|
+
function sameRecord(a, b) {
|
|
6
|
+
if (b.proxied !== undefined && a.proxied !== b.proxied)
|
|
7
|
+
return false;
|
|
8
|
+
return a.type === b.type && a.name === b.name && cleanDnsValue(a.value) === cleanDnsValue(b.value);
|
|
9
|
+
}
|
|
10
|
+
function sameDnsValue(a, b) {
|
|
11
|
+
return a.type === b.type && a.name === b.name && cleanDnsValue(a.value) === cleanDnsValue(b.value);
|
|
12
|
+
}
|
|
13
|
+
function sameSlot(a, b) {
|
|
14
|
+
return a.type === b.type && a.name === b.name;
|
|
15
|
+
}
|
|
16
|
+
function cnameSlotConflict(a, b) {
|
|
17
|
+
return a.name === b.name && (a.type === 'CNAME' || b.type === 'CNAME');
|
|
18
|
+
}
|
|
19
|
+
export function planDnsChanges(input) {
|
|
20
|
+
const changes = [];
|
|
21
|
+
const conflicts = [];
|
|
22
|
+
for (const record of input.desired) {
|
|
23
|
+
const exact = input.existing.find((existing) => sameRecord(existing, record));
|
|
24
|
+
if (exact) {
|
|
25
|
+
changes.push({ action: 'skip', existing: exact, reason: 'already_exists', record });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const sameValue = input.existing.find((existing) => sameDnsValue(existing, record));
|
|
29
|
+
if (sameValue && record.proxied !== undefined && sameValue.proxied !== record.proxied) {
|
|
30
|
+
changes.push({ action: 'update', existing: sameValue, record });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (record.type === 'TXT') {
|
|
34
|
+
changes.push({ action: 'create', record });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const sameTyped = input.existing.find((existing) => sameSlot(existing, record));
|
|
38
|
+
if (sameTyped) {
|
|
39
|
+
if (input.force)
|
|
40
|
+
changes.push({ action: 'update', existing: sameTyped, record });
|
|
41
|
+
else
|
|
42
|
+
conflicts.push({ existing: sameTyped, reason: 'same_type_record_exists', record });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const cnameConflict = input.existing.find((existing) => cnameSlotConflict(existing, record));
|
|
46
|
+
if (cnameConflict) {
|
|
47
|
+
if (input.force) {
|
|
48
|
+
changes.push({ action: 'delete', existing: cnameConflict, reason: 'cname_slot_conflict' }, { action: 'create', record });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
conflicts.push({ existing: cnameConflict, reason: 'cname_slot_conflict', record });
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
changes.push({ action: 'create', record });
|
|
56
|
+
}
|
|
57
|
+
return { changes, conflicts, desired: input.desired, existing: input.existing, zone: input.zone };
|
|
58
|
+
}
|
|
59
|
+
export function assertNoConflicts(providerId, plan) {
|
|
60
|
+
if (plan.conflicts.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
throw new ProviderError(providerId, 'PROVIDER_RECORD_CONFLICT', 'DNS record conflicts must be resolved before applying changes.', {
|
|
63
|
+
conflicts: plan.conflicts,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function applyDnsChanges(input) {
|
|
67
|
+
assertNoConflicts(input.providerId, input.plan);
|
|
68
|
+
const applied = [];
|
|
69
|
+
const skipped = [];
|
|
70
|
+
for (const change of input.plan.changes) {
|
|
71
|
+
if (change.action === 'skip') {
|
|
72
|
+
skipped.push(change.record);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
await (change.action === 'delete' ? input.deleteRecord(change.existing) : input.upsertRecord(change.record));
|
|
76
|
+
applied.push(change);
|
|
77
|
+
}
|
|
78
|
+
return { applied, skipped };
|
|
79
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT';
|
|
2
|
+
export interface CredentialDefinition {
|
|
3
|
+
description?: string;
|
|
4
|
+
env: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
secret?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderSetupGuide {
|
|
13
|
+
notes?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface ProviderCapabilities {
|
|
16
|
+
defaultTtl: number;
|
|
17
|
+
maxTtl?: number;
|
|
18
|
+
minTtl?: number;
|
|
19
|
+
recordTypes: DnsRecordType[];
|
|
20
|
+
supportsApexCname: boolean;
|
|
21
|
+
supportsBulkWrites: boolean;
|
|
22
|
+
supportsPagination: boolean;
|
|
23
|
+
supportsProxying: boolean;
|
|
24
|
+
supportsRecordIds: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface DnsZone {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface DnsRecord {
|
|
32
|
+
id?: string;
|
|
33
|
+
metadata?: Record<string, unknown>;
|
|
34
|
+
name: string;
|
|
35
|
+
priority?: number;
|
|
36
|
+
proxied?: boolean;
|
|
37
|
+
ttl?: number;
|
|
38
|
+
type: DnsRecordType;
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
export interface DnsRecordInput {
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
name: string;
|
|
44
|
+
priority?: number;
|
|
45
|
+
proxied?: boolean;
|
|
46
|
+
ttl?: number;
|
|
47
|
+
type: DnsRecordType;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
export type DnsChange = {
|
|
51
|
+
action: 'create';
|
|
52
|
+
record: DnsRecordInput;
|
|
53
|
+
} | {
|
|
54
|
+
action: 'delete';
|
|
55
|
+
existing: DnsRecord;
|
|
56
|
+
reason?: string;
|
|
57
|
+
} | {
|
|
58
|
+
action: 'skip';
|
|
59
|
+
existing: DnsRecord;
|
|
60
|
+
reason: string;
|
|
61
|
+
record: DnsRecordInput;
|
|
62
|
+
} | {
|
|
63
|
+
action: 'update';
|
|
64
|
+
existing: DnsRecord;
|
|
65
|
+
record: DnsRecordInput;
|
|
66
|
+
};
|
|
67
|
+
export interface DnsConflict {
|
|
68
|
+
existing: DnsRecord;
|
|
69
|
+
reason: string;
|
|
70
|
+
record: DnsRecordInput;
|
|
71
|
+
}
|
|
72
|
+
export interface DnsChangePlan {
|
|
73
|
+
changes: DnsChange[];
|
|
74
|
+
conflicts: DnsConflict[];
|
|
75
|
+
desired: DnsRecordInput[];
|
|
76
|
+
existing: DnsRecord[];
|
|
77
|
+
zone: DnsZone;
|
|
78
|
+
}
|
|
79
|
+
export interface DnsChangeResult {
|
|
80
|
+
applied: DnsChange[];
|
|
81
|
+
skipped: DnsRecordInput[];
|
|
82
|
+
}
|
|
83
|
+
export interface ListZonesInput {
|
|
84
|
+
search?: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ProviderHealth {
|
|
87
|
+
ok: boolean;
|
|
88
|
+
message?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface PlanOptions {
|
|
91
|
+
force?: boolean;
|
|
92
|
+
}
|
|
93
|
+
export interface ApplyOptions {
|
|
94
|
+
force?: boolean;
|
|
95
|
+
}
|
|
96
|
+
export interface ProviderContext {
|
|
97
|
+
credentials: Record<string, string>;
|
|
98
|
+
debug?: boolean;
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
}
|
|
101
|
+
export interface DnsProvider {
|
|
102
|
+
capabilities: ProviderCapabilities;
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
applyChanges(zone: DnsZone, plan: DnsChangePlan, opts?: ApplyOptions): Promise<DnsChangeResult>;
|
|
106
|
+
deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
|
|
107
|
+
getZone(domain: string): Promise<DnsZone | null>;
|
|
108
|
+
listRecords(zone: DnsZone): Promise<DnsRecord[]>;
|
|
109
|
+
listZones(input?: ListZonesInput): Promise<DnsZone[]>;
|
|
110
|
+
planChanges(zone: DnsZone, desired: DnsRecordInput[], opts?: PlanOptions): Promise<DnsChangePlan>;
|
|
111
|
+
upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
|
|
112
|
+
verifyCredentials(): Promise<ProviderHealth>;
|
|
113
|
+
}
|
|
114
|
+
export interface DnsProviderDefinition {
|
|
115
|
+
capabilities: ProviderCapabilities;
|
|
116
|
+
credentials: CredentialDefinition[];
|
|
117
|
+
displayName: string;
|
|
118
|
+
docsUrl?: string;
|
|
119
|
+
id: string;
|
|
120
|
+
name: string;
|
|
121
|
+
setup?: ProviderSetupGuide;
|
|
122
|
+
create(context: ProviderContext): DnsProvider;
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DnsChange, DnsChangePlan, DnsProvider, DnsProviderDefinition, DnsRecord, DnsRecordInput, DnsZone, ProviderCapabilities, ProviderContext, ProviderHealth } from '../types.js';
|
|
2
|
+
export declare class NamecheapProvider implements DnsProvider {
|
|
3
|
+
readonly capabilities: ProviderCapabilities;
|
|
4
|
+
readonly id = "namecheap";
|
|
5
|
+
readonly name = "Namecheap";
|
|
6
|
+
private readonly apiKey;
|
|
7
|
+
private readonly apiUser;
|
|
8
|
+
private readonly baseUrl;
|
|
9
|
+
private readonly clientIp;
|
|
10
|
+
private readonly username;
|
|
11
|
+
constructor(context: ProviderContext);
|
|
12
|
+
verifyCredentials(): Promise<ProviderHealth>;
|
|
13
|
+
listZones(): Promise<DnsZone[]>;
|
|
14
|
+
getZone(domain: string): Promise<DnsZone | null>;
|
|
15
|
+
listRecords(zone: DnsZone): Promise<DnsRecord[]>;
|
|
16
|
+
planChanges(zone: DnsZone, desired: DnsRecordInput[], opts?: {
|
|
17
|
+
force?: boolean;
|
|
18
|
+
}): Promise<DnsChangePlan>;
|
|
19
|
+
applyChanges(zone: DnsZone, plan: DnsChangePlan): Promise<{
|
|
20
|
+
applied: DnsChange[];
|
|
21
|
+
skipped: DnsRecordInput[];
|
|
22
|
+
}>;
|
|
23
|
+
upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
|
|
24
|
+
deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
|
|
25
|
+
private setHosts;
|
|
26
|
+
private request;
|
|
27
|
+
}
|
|
28
|
+
export declare const namecheapProviderDefinition: DnsProviderDefinition;
|