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,289 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
import { normalizeDomain } from '../../validate.js';
|
|
3
|
+
import { ProviderError } from '../core/errors.js';
|
|
4
|
+
import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
|
|
5
|
+
const NAMECHEAP_PRODUCTION_URL = 'https://api.namecheap.com/xml.response';
|
|
6
|
+
const NAMECHEAP_SANDBOX_URL = 'https://api.sandbox.namecheap.com/xml.response';
|
|
7
|
+
const capabilities = {
|
|
8
|
+
defaultTtl: 1800,
|
|
9
|
+
recordTypes: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'],
|
|
10
|
+
supportsApexCname: false,
|
|
11
|
+
supportsBulkWrites: true,
|
|
12
|
+
supportsPagination: true,
|
|
13
|
+
supportsProxying: false,
|
|
14
|
+
supportsRecordIds: true,
|
|
15
|
+
};
|
|
16
|
+
const parser = new XMLParser({
|
|
17
|
+
attributeNamePrefix: '',
|
|
18
|
+
ignoreAttributes: false,
|
|
19
|
+
});
|
|
20
|
+
function asArray(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return [];
|
|
23
|
+
return Array.isArray(value) ? value : [value];
|
|
24
|
+
}
|
|
25
|
+
function bool(value) {
|
|
26
|
+
if (typeof value === 'boolean')
|
|
27
|
+
return value;
|
|
28
|
+
if (typeof value === 'number')
|
|
29
|
+
return value === 1;
|
|
30
|
+
if (typeof value === 'string')
|
|
31
|
+
return value === '1' || value.toLowerCase() === 'true';
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
function getErrorMessage(error) {
|
|
35
|
+
if (typeof error === 'string')
|
|
36
|
+
return error;
|
|
37
|
+
if (error && typeof error === 'object' && '#text' in error)
|
|
38
|
+
return String(error['#text']);
|
|
39
|
+
return JSON.stringify(error);
|
|
40
|
+
}
|
|
41
|
+
function numberValue(value) {
|
|
42
|
+
if (typeof value === 'number')
|
|
43
|
+
return value;
|
|
44
|
+
if (typeof value === 'string' && value.trim())
|
|
45
|
+
return Number(value);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function pagingTotal(commandResponse, result, fallback) {
|
|
49
|
+
const paging = (commandResponse.Paging ?? result.Paging);
|
|
50
|
+
return numberValue(paging?.TotalItems) ?? numberValue(paging?.totalItems) ?? fallback;
|
|
51
|
+
}
|
|
52
|
+
function providerCodeFromNamecheapError(message) {
|
|
53
|
+
const lower = message.toLowerCase();
|
|
54
|
+
if (lower.includes('clientip') || lower.includes('client ip') || lower.includes('whitelist'))
|
|
55
|
+
return 'PROVIDER_PERMISSION_DENIED';
|
|
56
|
+
if (lower.includes('api key') || lower.includes('apiuser') || lower.includes('username') || lower.includes('authentication')) {
|
|
57
|
+
return 'PROVIDER_AUTH_FAILED';
|
|
58
|
+
}
|
|
59
|
+
if (lower.includes('rate'))
|
|
60
|
+
return 'PROVIDER_RATE_LIMITED';
|
|
61
|
+
return 'PROVIDER_API_ERROR';
|
|
62
|
+
}
|
|
63
|
+
function namecheapSetupHelp(code) {
|
|
64
|
+
if (code !== 'PROVIDER_AUTH_FAILED' && code !== 'PROVIDER_PERMISSION_DENIED')
|
|
65
|
+
return undefined;
|
|
66
|
+
return 'Make sure API access is enabled and your current public IPv4 is whitelisted at https://ap.www.namecheap.com/settings/tools/apiaccess/.';
|
|
67
|
+
}
|
|
68
|
+
function splitDomain(domain) {
|
|
69
|
+
const normalized = normalizeDomain(domain);
|
|
70
|
+
const [sld, ...rest] = normalized.split('.');
|
|
71
|
+
if (!sld || rest.length === 0)
|
|
72
|
+
throw new ProviderError('namecheap', 'PROVIDER_ZONE_NOT_FOUND', `Invalid Namecheap domain: ${domain}`);
|
|
73
|
+
return { sld, tld: rest.join('.') };
|
|
74
|
+
}
|
|
75
|
+
function toZone(domain) {
|
|
76
|
+
if (!domain.Name)
|
|
77
|
+
return null;
|
|
78
|
+
try {
|
|
79
|
+
const name = normalizeDomain(domain.Name);
|
|
80
|
+
return { id: name, name };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function toDnsRecord(host) {
|
|
87
|
+
if (!host.Name || !host.Type || !host.Address)
|
|
88
|
+
return null;
|
|
89
|
+
const record = {
|
|
90
|
+
metadata: { namecheap: host },
|
|
91
|
+
name: host.Name,
|
|
92
|
+
type: host.Type,
|
|
93
|
+
value: host.Address,
|
|
94
|
+
};
|
|
95
|
+
if (host.HostId !== undefined)
|
|
96
|
+
record.id = host.HostId;
|
|
97
|
+
if (host.MXPref !== undefined)
|
|
98
|
+
record.priority = Number(host.MXPref);
|
|
99
|
+
if (host.TTL !== undefined)
|
|
100
|
+
record.ttl = Number(host.TTL);
|
|
101
|
+
return record;
|
|
102
|
+
}
|
|
103
|
+
function sameRecord(a, b) {
|
|
104
|
+
return a.name === b.name && a.type === b.type && a.value === b.value;
|
|
105
|
+
}
|
|
106
|
+
function inputToRecord(record) {
|
|
107
|
+
return { ...record };
|
|
108
|
+
}
|
|
109
|
+
function hostParams(records) {
|
|
110
|
+
const params = {};
|
|
111
|
+
let position = 1;
|
|
112
|
+
for (const record of records) {
|
|
113
|
+
params[`HostName${position}`] = record.name;
|
|
114
|
+
params[`RecordType${position}`] = record.type;
|
|
115
|
+
params[`Address${position}`] = record.value;
|
|
116
|
+
params[`TTL${position}`] = String(record.ttl ?? capabilities.defaultTtl);
|
|
117
|
+
if (record.priority !== undefined)
|
|
118
|
+
params[`MXPref${position}`] = String(record.priority);
|
|
119
|
+
position += 1;
|
|
120
|
+
}
|
|
121
|
+
return params;
|
|
122
|
+
}
|
|
123
|
+
export class NamecheapProvider {
|
|
124
|
+
capabilities = capabilities;
|
|
125
|
+
id = 'namecheap';
|
|
126
|
+
name = 'Namecheap';
|
|
127
|
+
apiKey;
|
|
128
|
+
apiUser;
|
|
129
|
+
baseUrl;
|
|
130
|
+
clientIp;
|
|
131
|
+
username;
|
|
132
|
+
constructor(context) {
|
|
133
|
+
this.apiUser = context.credentials.apiUser;
|
|
134
|
+
this.apiKey = context.credentials.apiKey;
|
|
135
|
+
this.username = context.credentials.username || context.credentials.apiUser;
|
|
136
|
+
this.clientIp = context.credentials.clientIp;
|
|
137
|
+
this.baseUrl = bool(context.credentials.sandbox) ? NAMECHEAP_SANDBOX_URL : NAMECHEAP_PRODUCTION_URL;
|
|
138
|
+
}
|
|
139
|
+
async verifyCredentials() {
|
|
140
|
+
await this.listZones();
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}
|
|
143
|
+
async listZones() {
|
|
144
|
+
const zones = [];
|
|
145
|
+
let currentPage = 1;
|
|
146
|
+
let totalItems = Number.POSITIVE_INFINITY;
|
|
147
|
+
while (zones.length < totalItems) {
|
|
148
|
+
const response = await this.request('namecheap.domains.getList', { Page: String(currentPage), PageSize: '100' });
|
|
149
|
+
const commandResponse = response.ApiResponse.CommandResponse;
|
|
150
|
+
const result = commandResponse.DomainGetListResult;
|
|
151
|
+
const domains = asArray(result.Domain);
|
|
152
|
+
for (const domain of domains) {
|
|
153
|
+
const zone = toZone(domain);
|
|
154
|
+
if (zone)
|
|
155
|
+
zones.push(zone);
|
|
156
|
+
}
|
|
157
|
+
totalItems = pagingTotal(commandResponse, result, zones.length);
|
|
158
|
+
if (domains.length === 0)
|
|
159
|
+
break;
|
|
160
|
+
currentPage += 1;
|
|
161
|
+
}
|
|
162
|
+
return zones;
|
|
163
|
+
}
|
|
164
|
+
async getZone(domain) {
|
|
165
|
+
const normalized = normalizeDomain(domain);
|
|
166
|
+
const zones = await this.listZones();
|
|
167
|
+
return zones.find((zone) => zone.name === normalized) ?? null;
|
|
168
|
+
}
|
|
169
|
+
async listRecords(zone) {
|
|
170
|
+
const { sld, tld } = splitDomain(zone.name);
|
|
171
|
+
const response = await this.request('namecheap.domains.dns.getHosts', { SLD: sld, TLD: tld });
|
|
172
|
+
const hosts = asArray(response.ApiResponse.CommandResponse.DomainDNSGetHostsResult.host);
|
|
173
|
+
return hosts.flatMap((host) => {
|
|
174
|
+
const record = toDnsRecord(host);
|
|
175
|
+
return record ? [record] : [];
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async planChanges(zone, desired, opts = {}) {
|
|
179
|
+
return planDnsChanges({ desired, existing: await this.listRecords(zone), force: opts.force, providerId: this.id, zone });
|
|
180
|
+
}
|
|
181
|
+
async applyChanges(zone, plan) {
|
|
182
|
+
assertNoConflicts(this.id, plan);
|
|
183
|
+
const finalRecords = [...plan.existing];
|
|
184
|
+
const applied = [];
|
|
185
|
+
const skipped = [];
|
|
186
|
+
for (const change of plan.changes) {
|
|
187
|
+
if (change.action === 'skip') {
|
|
188
|
+
skipped.push(change.record);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (change.action === 'delete') {
|
|
192
|
+
const index = finalRecords.findIndex((record) => sameRecord(record, change.existing));
|
|
193
|
+
if (index !== -1)
|
|
194
|
+
finalRecords.splice(index, 1);
|
|
195
|
+
}
|
|
196
|
+
else if (change.action === 'update') {
|
|
197
|
+
const index = finalRecords.findIndex((record) => sameRecord(record, change.existing));
|
|
198
|
+
if (index !== -1)
|
|
199
|
+
finalRecords[index] = { ...change.existing, ...change.record };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
finalRecords.push(inputToRecord(change.record));
|
|
203
|
+
}
|
|
204
|
+
applied.push(change);
|
|
205
|
+
}
|
|
206
|
+
if (applied.length > 0)
|
|
207
|
+
await this.setHosts(zone, finalRecords);
|
|
208
|
+
return { applied, skipped };
|
|
209
|
+
}
|
|
210
|
+
async upsertRecord(zone, record) {
|
|
211
|
+
const plan = await this.planChanges(zone, [record], { force: true });
|
|
212
|
+
await this.applyChanges(zone, plan);
|
|
213
|
+
return { ...record, ttl: record.ttl ?? capabilities.defaultTtl };
|
|
214
|
+
}
|
|
215
|
+
async deleteRecord(zone, record) {
|
|
216
|
+
const records = (await this.listRecords(zone)).filter((item) => !sameRecord(item, record));
|
|
217
|
+
await this.setHosts(zone, records);
|
|
218
|
+
}
|
|
219
|
+
async setHosts(zone, records) {
|
|
220
|
+
const { sld, tld } = splitDomain(zone.name);
|
|
221
|
+
const response = await this.request('namecheap.domains.dns.setHosts', { SLD: sld, TLD: tld, ...hostParams(records) }, { method: 'POST' });
|
|
222
|
+
const result = response.ApiResponse.CommandResponse.DomainDNSSetHostsResult;
|
|
223
|
+
if (!bool(result?.IsSuccess)) {
|
|
224
|
+
throw new ProviderError('namecheap', 'PROVIDER_API_ERROR', 'Namecheap did not confirm DNS host records were updated.', result);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async request(command, params, opts = {}) {
|
|
228
|
+
if (!this.clientIp) {
|
|
229
|
+
throw new ProviderError('namecheap', 'MISSING_CREDENTIALS', 'Namecheap requires a whitelisted IPv4 ClientIp credential.');
|
|
230
|
+
}
|
|
231
|
+
const query = new URLSearchParams({
|
|
232
|
+
ApiKey: this.apiKey,
|
|
233
|
+
ApiUser: this.apiUser,
|
|
234
|
+
ClientIp: this.clientIp,
|
|
235
|
+
Command: command,
|
|
236
|
+
UserName: this.username,
|
|
237
|
+
...params,
|
|
238
|
+
});
|
|
239
|
+
const method = opts.method ?? 'GET';
|
|
240
|
+
const response = await fetch(method === 'POST' ? this.baseUrl : `${this.baseUrl}?${query.toString()}`, {
|
|
241
|
+
...(method === 'POST'
|
|
242
|
+
? { body: query.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method }
|
|
243
|
+
: {}),
|
|
244
|
+
});
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
throw new ProviderError('namecheap', 'PROVIDER_API_ERROR', `Namecheap API error (${response.status}).`);
|
|
247
|
+
}
|
|
248
|
+
const data = parser.parse(await response.text());
|
|
249
|
+
const status = data.ApiResponse?.Status;
|
|
250
|
+
if (status !== 'OK') {
|
|
251
|
+
const message = getErrorMessage(asArray(data.ApiResponse?.Errors?.Error)[0] ?? 'Namecheap API error.');
|
|
252
|
+
const code = providerCodeFromNamecheapError(message);
|
|
253
|
+
const help = namecheapSetupHelp(code);
|
|
254
|
+
throw new ProviderError('namecheap', code, help ? `${message} ${help}` : message, data.ApiResponse?.Errors);
|
|
255
|
+
}
|
|
256
|
+
return data;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export const namecheapProviderDefinition = {
|
|
260
|
+
capabilities,
|
|
261
|
+
credentials: [
|
|
262
|
+
{ env: 'NAMECHEAP_API_USER', key: 'apiUser', label: 'API user', required: true },
|
|
263
|
+
{ env: 'NAMECHEAP_API_KEY', key: 'apiKey', label: 'API key', required: true, secret: true },
|
|
264
|
+
{ env: 'NAMECHEAP_USERNAME', key: 'username', label: 'Username', required: false },
|
|
265
|
+
{
|
|
266
|
+
env: 'NAMECHEAP_CLIENT_IP',
|
|
267
|
+
hint: 'Must match the IPv4 address whitelisted in Namecheap API Access settings.',
|
|
268
|
+
key: 'clientIp',
|
|
269
|
+
label: 'Whitelisted client IP',
|
|
270
|
+
required: true,
|
|
271
|
+
},
|
|
272
|
+
{ env: 'NAMECHEAP_SANDBOX', key: 'sandbox', label: 'Use sandbox', required: false },
|
|
273
|
+
],
|
|
274
|
+
displayName: 'Namecheap',
|
|
275
|
+
docsUrl: 'https://www.namecheap.com/support/api/methods/',
|
|
276
|
+
id: 'namecheap',
|
|
277
|
+
name: 'Namecheap',
|
|
278
|
+
setup: {
|
|
279
|
+
notes: [
|
|
280
|
+
'Open Account Dashboard > Profile > Tools > API Access:',
|
|
281
|
+
'https://ap.www.namecheap.com/settings/tools/apiaccess/',
|
|
282
|
+
'Enable API access, copy your API key, and add your current public IPv4 to Whitelisted IPs.',
|
|
283
|
+
'Doomain preserves existing records when updating Namecheap DNS, but Namecheap host writes replace the full host list.',
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
create(context) {
|
|
287
|
+
return new NamecheapProvider(context);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DnsProvider, DnsProviderDefinition } from './types.js';
|
|
2
|
+
export declare function listProviderDefinitions(): DnsProviderDefinition[];
|
|
3
|
+
export declare function getProviderDefinition(id: string): DnsProviderDefinition;
|
|
4
|
+
export declare function createProvider(id: string): Promise<DnsProvider>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DoomainError } from '../errors.js';
|
|
2
|
+
import { ensureProviderId } from '../validate.js';
|
|
3
|
+
import { cloudflareProviderDefinition } from './cloudflare/index.js';
|
|
4
|
+
import { createProviderContext } from './core/config.js';
|
|
5
|
+
import { namecheapProviderDefinition } from './namecheap/index.js';
|
|
6
|
+
import { spaceshipProviderDefinition } from './spaceship/index.js';
|
|
7
|
+
const definitions = [spaceshipProviderDefinition, namecheapProviderDefinition, cloudflareProviderDefinition];
|
|
8
|
+
export function listProviderDefinitions() {
|
|
9
|
+
return definitions;
|
|
10
|
+
}
|
|
11
|
+
export function getProviderDefinition(id) {
|
|
12
|
+
const providerId = ensureProviderId(id);
|
|
13
|
+
const definition = definitions.find((provider) => provider.id === providerId);
|
|
14
|
+
if (!definition)
|
|
15
|
+
throw new DoomainError('PROVIDER_NOT_FOUND', `Unsupported DNS provider: ${id}`);
|
|
16
|
+
return definition;
|
|
17
|
+
}
|
|
18
|
+
export async function createProvider(id) {
|
|
19
|
+
const definition = getProviderDefinition(id);
|
|
20
|
+
return definition.create(await createProviderContext(definition));
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DnsChangePlan, DnsProvider, DnsProviderDefinition, DnsRecord, DnsRecordInput, DnsZone, ProviderCapabilities, ProviderContext, ProviderHealth } from '../types.js';
|
|
2
|
+
export declare class SpaceshipProvider implements DnsProvider {
|
|
3
|
+
readonly capabilities: ProviderCapabilities;
|
|
4
|
+
readonly id = "spaceship";
|
|
5
|
+
readonly name = "Spaceship";
|
|
6
|
+
private readonly http;
|
|
7
|
+
constructor(context: ProviderContext);
|
|
8
|
+
verifyCredentials(): Promise<ProviderHealth>;
|
|
9
|
+
listZones(): Promise<DnsZone[]>;
|
|
10
|
+
getZone(domain: string): Promise<DnsZone | null>;
|
|
11
|
+
listRecords(zone: DnsZone): Promise<DnsRecord[]>;
|
|
12
|
+
planChanges(zone: DnsZone, desired: DnsRecordInput[], opts?: {
|
|
13
|
+
force?: boolean;
|
|
14
|
+
}): Promise<DnsChangePlan>;
|
|
15
|
+
applyChanges(zone: DnsZone, plan: DnsChangePlan): Promise<{
|
|
16
|
+
applied: DnsChangePlan['changes'];
|
|
17
|
+
skipped: DnsRecordInput[];
|
|
18
|
+
}>;
|
|
19
|
+
upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
|
|
20
|
+
deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare const spaceshipProviderDefinition: DnsProviderDefinition;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { normalizeDomain } from '../../validate.js';
|
|
2
|
+
import { createProviderHttpClient } from '../core/http.js';
|
|
3
|
+
import { paginateBySkip } from '../core/pagination.js';
|
|
4
|
+
import { applyDnsChanges, planDnsChanges } from '../core/planner.js';
|
|
5
|
+
const SPACESHIP_API_URL = 'https://spaceship.dev/api/v1';
|
|
6
|
+
const capabilities = {
|
|
7
|
+
defaultTtl: 3600,
|
|
8
|
+
recordTypes: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'],
|
|
9
|
+
supportsApexCname: false,
|
|
10
|
+
supportsBulkWrites: true,
|
|
11
|
+
supportsPagination: true,
|
|
12
|
+
supportsProxying: false,
|
|
13
|
+
supportsRecordIds: false,
|
|
14
|
+
};
|
|
15
|
+
function recordValue(record) {
|
|
16
|
+
return record.cname ?? record.address ?? record.value ?? '';
|
|
17
|
+
}
|
|
18
|
+
function toSpaceshipItem(record) {
|
|
19
|
+
const base = {
|
|
20
|
+
name: record.name,
|
|
21
|
+
ttl: record.ttl ?? capabilities.defaultTtl,
|
|
22
|
+
type: record.type,
|
|
23
|
+
};
|
|
24
|
+
if (record.type === 'CNAME')
|
|
25
|
+
return { ...base, cname: record.value };
|
|
26
|
+
if (record.type === 'A' || record.type === 'AAAA')
|
|
27
|
+
return { ...base, address: record.value };
|
|
28
|
+
return { ...base, value: record.value };
|
|
29
|
+
}
|
|
30
|
+
function toDnsRecord(record) {
|
|
31
|
+
return {
|
|
32
|
+
name: record.name,
|
|
33
|
+
ttl: record.ttl,
|
|
34
|
+
type: record.type,
|
|
35
|
+
value: recordValue(record),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function toZone(domain) {
|
|
39
|
+
const name = domain.name ?? domain.unicodeName;
|
|
40
|
+
if (!name)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const normalized = normalizeDomain(name);
|
|
44
|
+
return { id: normalized, name: normalized };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class SpaceshipProvider {
|
|
51
|
+
capabilities = capabilities;
|
|
52
|
+
id = 'spaceship';
|
|
53
|
+
name = 'Spaceship';
|
|
54
|
+
http;
|
|
55
|
+
constructor(context) {
|
|
56
|
+
this.http = createProviderHttpClient({
|
|
57
|
+
baseUrl: SPACESHIP_API_URL,
|
|
58
|
+
errorMessages: {
|
|
59
|
+
401: 'Spaceship rejected the API key/secret. Re-run `doomain providers connect spaceship` with valid credentials.',
|
|
60
|
+
403: 'Spaceship API key is missing required scopes. Enable domains:read and dnsrecords:read/write.',
|
|
61
|
+
429: 'Spaceship rate limit exceeded. Try again later.',
|
|
62
|
+
},
|
|
63
|
+
headers: {
|
|
64
|
+
'X-Api-Key': context.credentials.apiKey,
|
|
65
|
+
'X-Api-Secret': context.credentials.apiSecret,
|
|
66
|
+
},
|
|
67
|
+
providerId: this.id,
|
|
68
|
+
signal: context.signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async verifyCredentials() {
|
|
72
|
+
await this.listZones();
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
async listZones() {
|
|
76
|
+
const domains = await paginateBySkip({
|
|
77
|
+
take: 100,
|
|
78
|
+
fetchPage: ({ skip, take }) => this.http.request('/domains', {
|
|
79
|
+
query: { orderBy: 'name', skip, take },
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
return domains.flatMap((domain) => {
|
|
83
|
+
const zone = toZone(domain);
|
|
84
|
+
return zone ? [zone] : [];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async getZone(domain) {
|
|
88
|
+
const normalized = normalizeDomain(domain);
|
|
89
|
+
const zones = await this.listZones();
|
|
90
|
+
return zones.find((zone) => zone.name === normalized) ?? null;
|
|
91
|
+
}
|
|
92
|
+
async listRecords(zone) {
|
|
93
|
+
const records = await paginateBySkip({
|
|
94
|
+
take: 500,
|
|
95
|
+
fetchPage: ({ skip, take }) => this.http.request(`/dns/records/${zone.name}`, {
|
|
96
|
+
query: { skip, take },
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
return records.map(toDnsRecord);
|
|
100
|
+
}
|
|
101
|
+
async planChanges(zone, desired, opts = {}) {
|
|
102
|
+
return planDnsChanges({
|
|
103
|
+
desired,
|
|
104
|
+
existing: await this.listRecords(zone),
|
|
105
|
+
force: opts.force,
|
|
106
|
+
providerId: this.id,
|
|
107
|
+
zone,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async applyChanges(zone, plan) {
|
|
111
|
+
return applyDnsChanges({
|
|
112
|
+
deleteRecord: (record) => this.deleteRecord(zone, record),
|
|
113
|
+
plan,
|
|
114
|
+
providerId: this.id,
|
|
115
|
+
upsertRecord: (record) => this.upsertRecord(zone, record),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async upsertRecord(zone, record) {
|
|
119
|
+
await this.http.request(`/dns/records/${zone.name}`, {
|
|
120
|
+
body: { force: true, items: [toSpaceshipItem(record)] },
|
|
121
|
+
method: 'PUT',
|
|
122
|
+
});
|
|
123
|
+
return { ...record, ttl: record.ttl ?? capabilities.defaultTtl };
|
|
124
|
+
}
|
|
125
|
+
async deleteRecord(zone, record) {
|
|
126
|
+
await this.http.request(`/dns/records/${zone.name}`, {
|
|
127
|
+
body: [toSpaceshipItem(record)],
|
|
128
|
+
method: 'DELETE',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export const spaceshipProviderDefinition = {
|
|
133
|
+
capabilities,
|
|
134
|
+
credentials: [
|
|
135
|
+
{ env: 'SPACESHIP_API_KEY', key: 'apiKey', label: 'API key', required: true, secret: true },
|
|
136
|
+
{ env: 'SPACESHIP_API_SECRET', key: 'apiSecret', label: 'API secret', required: true, secret: true },
|
|
137
|
+
],
|
|
138
|
+
displayName: 'Spaceship',
|
|
139
|
+
docsUrl: 'https://docs.spaceship.dev/',
|
|
140
|
+
id: 'spaceship',
|
|
141
|
+
name: 'Spaceship',
|
|
142
|
+
setup: {
|
|
143
|
+
notes: ['Create a Spaceship API key with domain and DNS record access before connecting.'],
|
|
144
|
+
},
|
|
145
|
+
create(context) {
|
|
146
|
+
return new SpaceshipProvider(context);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DoomainConfig } from '../config.js';
|
|
2
|
+
import type { DnsProviderDefinition } from './types.js';
|
|
3
|
+
export interface ProviderStatus {
|
|
4
|
+
configured: boolean;
|
|
5
|
+
default: boolean;
|
|
6
|
+
displayName: string;
|
|
7
|
+
docsUrl?: string;
|
|
8
|
+
domainCount?: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
id: string;
|
|
11
|
+
verified?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function isProviderConfigured(definition: DnsProviderDefinition, config: DoomainConfig): boolean;
|
|
14
|
+
export declare function listProviderStatuses(opts?: {
|
|
15
|
+
verify?: boolean;
|
|
16
|
+
}): Promise<ProviderStatus[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { getProviderCredential } from './core/config.js';
|
|
3
|
+
import { createProvider, listProviderDefinitions } from './registry.js';
|
|
4
|
+
export function isProviderConfigured(definition, config) {
|
|
5
|
+
return definition.credentials.every((credential) => credential.required === false || Boolean(getProviderCredential(config, definition.id, credential)));
|
|
6
|
+
}
|
|
7
|
+
export async function listProviderStatuses(opts = {}) {
|
|
8
|
+
const config = await loadConfig();
|
|
9
|
+
const statuses = [];
|
|
10
|
+
for (const definition of listProviderDefinitions()) {
|
|
11
|
+
const configured = isProviderConfigured(definition, config);
|
|
12
|
+
const status = {
|
|
13
|
+
configured,
|
|
14
|
+
default: config.defaults?.provider === definition.id,
|
|
15
|
+
displayName: definition.displayName,
|
|
16
|
+
docsUrl: definition.docsUrl,
|
|
17
|
+
id: definition.id,
|
|
18
|
+
};
|
|
19
|
+
if (configured && opts.verify) {
|
|
20
|
+
try {
|
|
21
|
+
const zones = await (await createProvider(definition.id)).listZones();
|
|
22
|
+
status.domainCount = zones.length;
|
|
23
|
+
status.verified = true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
status.error = error instanceof Error ? error.message : String(error);
|
|
27
|
+
status.verified = false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
statuses.push(status);
|
|
31
|
+
}
|
|
32
|
+
return statuses;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type * from './core/types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface DomainTarget {
|
|
2
|
+
zoneDomain: string;
|
|
3
|
+
fullDomain: string;
|
|
4
|
+
recordName: string;
|
|
5
|
+
isApex: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function normalizeDomain(input: string): string;
|
|
8
|
+
export declare function normalizeSubdomain(input: string): string;
|
|
9
|
+
export declare function resolveDomainTarget(opts: {
|
|
10
|
+
domain: string;
|
|
11
|
+
subdomain?: string;
|
|
12
|
+
apex?: boolean;
|
|
13
|
+
}): DomainTarget;
|
|
14
|
+
export declare function ensureProviderId(value: string): string;
|
|
15
|
+
export declare function ensureProject(value?: string): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DoomainError } from './errors.js';
|
|
2
|
+
const DOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
3
|
+
export function normalizeDomain(input) {
|
|
4
|
+
const value = input.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
5
|
+
const domain = value.split('/')[0];
|
|
6
|
+
if (!domain || domain.length > 253) {
|
|
7
|
+
throw new DoomainError('INVALID_INPUT', 'Domain is required.');
|
|
8
|
+
}
|
|
9
|
+
const labels = domain.split('.');
|
|
10
|
+
if (labels.length < 2 || labels.some((label) => !DOMAIN_LABEL.test(label))) {
|
|
11
|
+
throw new DoomainError('INVALID_INPUT', `Invalid domain: ${input}`);
|
|
12
|
+
}
|
|
13
|
+
return domain;
|
|
14
|
+
}
|
|
15
|
+
export function normalizeSubdomain(input) {
|
|
16
|
+
const subdomain = input.trim().toLowerCase().replace(/^\.+|\.+$/g, '');
|
|
17
|
+
if (!subdomain || subdomain === '@') {
|
|
18
|
+
throw new DoomainError('INVALID_INPUT', 'Subdomain is required unless --apex is used.');
|
|
19
|
+
}
|
|
20
|
+
const labels = subdomain.split('.');
|
|
21
|
+
if (labels.some((label) => !DOMAIN_LABEL.test(label))) {
|
|
22
|
+
throw new DoomainError('INVALID_INPUT', `Invalid subdomain: ${input}`);
|
|
23
|
+
}
|
|
24
|
+
return subdomain;
|
|
25
|
+
}
|
|
26
|
+
export function resolveDomainTarget(opts) {
|
|
27
|
+
const zoneDomain = normalizeDomain(opts.domain);
|
|
28
|
+
if (opts.apex) {
|
|
29
|
+
if (opts.subdomain) {
|
|
30
|
+
throw new DoomainError('INVALID_INPUT', 'Use either --apex or --subdomain, not both.');
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
zoneDomain,
|
|
34
|
+
fullDomain: zoneDomain,
|
|
35
|
+
recordName: '@',
|
|
36
|
+
isApex: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (!opts.subdomain) {
|
|
40
|
+
throw new DoomainError('MISSING_ARGUMENT', 'Provide --subdomain or use --apex.');
|
|
41
|
+
}
|
|
42
|
+
const subdomain = normalizeSubdomain(opts.subdomain);
|
|
43
|
+
return {
|
|
44
|
+
zoneDomain,
|
|
45
|
+
fullDomain: `${subdomain}.${zoneDomain}`,
|
|
46
|
+
recordName: subdomain,
|
|
47
|
+
isApex: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function ensureProviderId(value) {
|
|
51
|
+
const provider = value.trim().toLowerCase();
|
|
52
|
+
if (!/^[a-z][a-z0-9-]*$/.test(provider)) {
|
|
53
|
+
throw new DoomainError('INVALID_INPUT', `Invalid provider id: ${value}`);
|
|
54
|
+
}
|
|
55
|
+
return provider;
|
|
56
|
+
}
|
|
57
|
+
export function ensureProject(value) {
|
|
58
|
+
const project = value?.trim();
|
|
59
|
+
if (!project)
|
|
60
|
+
throw new DoomainError('MISSING_ARGUMENT', 'Vercel project is required.');
|
|
61
|
+
return project;
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const VERCEL_APEX_A_RECORD = "76.76.21.21";
|
|
2
|
+
export declare const VERCEL_CNAME_RECORD = "cname.vercel-dns.com";
|
|
3
|
+
export interface VercelProject {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
framework: string | null;
|
|
7
|
+
updatedAt: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface VercelTeam {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string | null;
|
|
12
|
+
role: string | null;
|
|
13
|
+
slug: string;
|
|
14
|
+
}
|
|
15
|
+
export interface VercelConfig {
|
|
16
|
+
token: string;
|
|
17
|
+
teamId?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveVercelConfig(): Promise<VercelConfig>;
|
|
20
|
+
export declare function createVercelClient(config: VercelConfig): {
|
|
21
|
+
listTeams(): Promise<VercelTeam[]>;
|
|
22
|
+
listProjects(search?: string): Promise<VercelProject[]>;
|
|
23
|
+
addDomainToProject(project: string, domain: string): Promise<{
|
|
24
|
+
alreadyAdded: boolean;
|
|
25
|
+
raw?: unknown;
|
|
26
|
+
}>;
|
|
27
|
+
getDomainConfig(domain: string): Promise<Record<string, unknown>>;
|
|
28
|
+
getRecommendedCname(domain: string): Promise<string>;
|
|
29
|
+
getProjectDomain(project: string, domain: string): Promise<Record<string, unknown>>;
|
|
30
|
+
verifyProjectDomain(project: string, domain: string): Promise<Record<string, unknown>>;
|
|
31
|
+
};
|
|
32
|
+
export type VercelClient = ReturnType<typeof createVercelClient>;
|