doomain 0.1.18 → 0.1.20
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 +65 -2
- package/dist/commands/auth/clerk.js +1 -1
- package/dist/commands/auth/vercel.js +10 -4
- package/dist/commands/clerk/domains/add.js +9 -3
- package/dist/commands/dns/point.d.ts +21 -0
- package/dist/commands/dns/point.js +123 -0
- package/dist/commands/domains/list.js +9 -3
- package/dist/commands/link.js +9 -3
- package/dist/commands/providers/connect.js +15 -4
- package/dist/commands/providers/disconnect.js +16 -4
- package/dist/commands/providers/list.js +1 -1
- package/dist/commands/wizard.js +50 -14
- package/dist/index.d.ts +1 -1
- package/dist/lib/clerk.js +5 -1
- package/dist/lib/command-schema.js +154 -34
- package/dist/lib/domain-provider.js +10 -3
- package/dist/lib/errors.d.ts +1 -1
- package/dist/lib/flags.js +9 -3
- package/dist/lib/link-domain.js +11 -4
- package/dist/lib/point-domain.d.ts +50 -0
- package/dist/lib/point-domain.js +212 -0
- package/dist/lib/providers/cloudflare/index.js +13 -5
- package/dist/lib/providers/core/config.js +2 -1
- package/dist/lib/providers/core/planner.js +25 -23
- package/dist/lib/providers/hostinger/index.js +30 -1
- package/dist/lib/providers/namecheap/index.js +11 -2
- package/dist/lib/providers/registry.js +6 -1
- package/dist/lib/providers/status.js +3 -1
- package/dist/lib/validate.js +9 -2
- package/dist/lib/vercel.js +5 -2
- package/oclif.manifest.json +108 -1
- package/package.json +5 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { resolve4, resolve6, resolveCname } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { resolveProviderTarget } from './domain-provider.js';
|
|
4
|
+
import { DoomainError } from './errors.js';
|
|
5
|
+
import { withProviderRecordOptions } from './link-domain.js';
|
|
6
|
+
import { createProvider } from './providers/registry.js';
|
|
7
|
+
import { normalizeDomain } from './validate.js';
|
|
8
|
+
const defaultDependencies = {
|
|
9
|
+
createProvider,
|
|
10
|
+
resolveTarget: resolveProviderTarget,
|
|
11
|
+
};
|
|
12
|
+
function cleanTarget(value) {
|
|
13
|
+
return value.trim().replace(/\.$/, '');
|
|
14
|
+
}
|
|
15
|
+
function inferredRecordType(target) {
|
|
16
|
+
const version = isIP(target);
|
|
17
|
+
if (version === 4)
|
|
18
|
+
return 'A';
|
|
19
|
+
if (version === 6)
|
|
20
|
+
return 'AAAA';
|
|
21
|
+
return 'CNAME';
|
|
22
|
+
}
|
|
23
|
+
function validateTarget(recordType, target) {
|
|
24
|
+
const version = isIP(target);
|
|
25
|
+
if (recordType === 'A' && version !== 4)
|
|
26
|
+
throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
|
|
27
|
+
if (recordType === 'AAAA' && version !== 6)
|
|
28
|
+
throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
|
|
29
|
+
if (recordType === 'CNAME' && version !== 0)
|
|
30
|
+
throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
|
|
31
|
+
}
|
|
32
|
+
export function createPointRecord(input) {
|
|
33
|
+
const target = cleanTarget(input.target);
|
|
34
|
+
if (!target)
|
|
35
|
+
throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
|
|
36
|
+
const recordType = input.recordType ?? inferredRecordType(target);
|
|
37
|
+
validateTarget(recordType, target);
|
|
38
|
+
return withProviderRecordOptions(input.provider, {
|
|
39
|
+
name: input.recordName,
|
|
40
|
+
ttl: input.ttl ?? 300,
|
|
41
|
+
type: recordType,
|
|
42
|
+
value: recordType === 'CNAME' ? normalizeDomain(target) : target,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function recordFqdn(record, zoneDomain) {
|
|
46
|
+
return record.name === '@' ? zoneDomain : `${record.name}.${zoneDomain}`;
|
|
47
|
+
}
|
|
48
|
+
function cleanDnsValue(value) {
|
|
49
|
+
return value.toLowerCase().replace(/\.$/, '');
|
|
50
|
+
}
|
|
51
|
+
function normalizeIpv6(value) {
|
|
52
|
+
return new URL(`http://[${value}]`).hostname.slice(1, -1);
|
|
53
|
+
}
|
|
54
|
+
async function isPropagated(record, zoneDomain, dependencies) {
|
|
55
|
+
const fqdn = recordFqdn(record, zoneDomain);
|
|
56
|
+
try {
|
|
57
|
+
if (record.type === 'A')
|
|
58
|
+
return (await (dependencies.resolve4 ?? resolve4)(fqdn)).includes(record.value);
|
|
59
|
+
if (record.type === 'AAAA') {
|
|
60
|
+
const expected = normalizeIpv6(record.value);
|
|
61
|
+
return (await (dependencies.resolve6 ?? resolve6)(fqdn)).some((value) => normalizeIpv6(value) === expected);
|
|
62
|
+
}
|
|
63
|
+
if (record.type === 'CNAME') {
|
|
64
|
+
return (await (dependencies.resolveCname ?? resolveCname)(fqdn))
|
|
65
|
+
.map(cleanDnsValue)
|
|
66
|
+
.includes(cleanDnsValue(record.value));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
async function waitForPropagation(record, zoneDomain, timeoutSeconds, dependencies) {
|
|
75
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
76
|
+
while (true) {
|
|
77
|
+
if (await isPropagated(record, zoneDomain, dependencies))
|
|
78
|
+
return true;
|
|
79
|
+
const remaining = deadline - Date.now();
|
|
80
|
+
if (remaining <= 0)
|
|
81
|
+
return false;
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, remaining)));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function conflictWarning(resolved, provider, record, conflicts) {
|
|
86
|
+
return {
|
|
87
|
+
account: resolved.account,
|
|
88
|
+
conflicts,
|
|
89
|
+
desired: [record],
|
|
90
|
+
domain: resolved.target.fullDomain,
|
|
91
|
+
provider: resolved.provider,
|
|
92
|
+
providerName: provider.name,
|
|
93
|
+
recordName: resolved.target.recordName,
|
|
94
|
+
zoneDomain: resolved.target.zoneDomain,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function dnsTargetConflictError(warning) {
|
|
98
|
+
const account = warning.account === 'default' ? '' : ` --account ${warning.account}`;
|
|
99
|
+
const target = warning.desired[0]?.value ?? '<ip-or-hostname>';
|
|
100
|
+
return new DoomainError('DNS_TARGET_CONFLICT', `${warning.domain} already has DNS records that point somewhere else. Re-run with --force to overwrite them.`, {
|
|
101
|
+
...warning,
|
|
102
|
+
recovery: 'Re-run with --force to overwrite conflicting DNS records, or confirm the DNS override in interactive mode.',
|
|
103
|
+
suggestedCommands: [
|
|
104
|
+
`doomain dns point ${warning.domain} --target ${target} --provider ${warning.provider}${account} --force --json`,
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function providerResolutionError(error, input) {
|
|
109
|
+
if (error.code !== 'CONFIG_NOT_FOUND' && error.code !== 'PROVIDER_ZONE_NOT_FOUND')
|
|
110
|
+
return error;
|
|
111
|
+
const details = error.details && typeof error.details === 'object' ? error.details : {};
|
|
112
|
+
const provider = input.provider ? ` --provider ${input.provider}` : '';
|
|
113
|
+
const account = input.account ? ` --account ${input.account}` : '';
|
|
114
|
+
const retry = `doomain dns point ${input.domain} --target ${input.target}${provider}${account} --json`;
|
|
115
|
+
return new DoomainError(error.code, error.message, {
|
|
116
|
+
...details,
|
|
117
|
+
recovery: `Connect or repair the DNS provider account that owns this domain, then retry \`${retry}\`.`,
|
|
118
|
+
suggestedCommands: ['doomain providers connect', retry],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function validateTtl(provider, ttl) {
|
|
122
|
+
if (ttl === undefined)
|
|
123
|
+
return;
|
|
124
|
+
const { maxTtl, minTtl } = provider.capabilities;
|
|
125
|
+
if (!Number.isInteger(ttl) || ttl <= 0) {
|
|
126
|
+
throw new DoomainError('INVALID_INPUT', 'DNS record TTL must be a positive integer.');
|
|
127
|
+
}
|
|
128
|
+
if (minTtl !== undefined && maxTtl !== undefined && (ttl < minTtl || ttl > maxTtl)) {
|
|
129
|
+
throw new DoomainError('INVALID_INPUT', `${provider.name} requires a TTL between ${minTtl} and ${maxTtl} seconds.`);
|
|
130
|
+
}
|
|
131
|
+
if (minTtl !== undefined && ttl < minTtl) {
|
|
132
|
+
throw new DoomainError('INVALID_INPUT', `${provider.name} requires a TTL of at least ${minTtl} seconds.`);
|
|
133
|
+
}
|
|
134
|
+
if (maxTtl !== undefined && ttl > maxTtl) {
|
|
135
|
+
throw new DoomainError('INVALID_INPUT', `${provider.name} requires a TTL no greater than ${maxTtl} seconds.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function pointDomain(input, dependencies = defaultDependencies) {
|
|
139
|
+
let resolved;
|
|
140
|
+
try {
|
|
141
|
+
resolved = await dependencies.resolveTarget(input);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof DoomainError)
|
|
145
|
+
throw providerResolutionError(error, input);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const record = createPointRecord({
|
|
149
|
+
provider: resolved.provider,
|
|
150
|
+
recordName: resolved.target.recordName,
|
|
151
|
+
recordType: input.recordType,
|
|
152
|
+
target: input.target,
|
|
153
|
+
ttl: input.ttl,
|
|
154
|
+
});
|
|
155
|
+
const provider = await dependencies.createProvider(resolved.provider, { account: resolved.account });
|
|
156
|
+
validateTtl(provider, record.ttl);
|
|
157
|
+
if (!provider.capabilities.recordTypes.includes(record.type)) {
|
|
158
|
+
throw new DoomainError('PROVIDER_UNSUPPORTED_RECORD', `${provider.name} does not support ${record.type} records.`);
|
|
159
|
+
}
|
|
160
|
+
if (resolved.target.isApex && record.type === 'CNAME' && !provider.capabilities.supportsApexCname) {
|
|
161
|
+
throw new DoomainError('PROVIDER_UNSUPPORTED_RECORD', `${provider.name} does not support CNAME records at the zone apex. Use an A or AAAA target instead.`);
|
|
162
|
+
}
|
|
163
|
+
if (input.dryRun) {
|
|
164
|
+
return {
|
|
165
|
+
account: resolved.account,
|
|
166
|
+
accountInferred: resolved.accountInferred,
|
|
167
|
+
domain: resolved.target.fullDomain,
|
|
168
|
+
dryRun: true,
|
|
169
|
+
isDefaultAccount: resolved.isDefaultAccount,
|
|
170
|
+
provider: resolved.provider,
|
|
171
|
+
providerInferred: resolved.providerInferred,
|
|
172
|
+
propagated: false,
|
|
173
|
+
record,
|
|
174
|
+
skipped: [],
|
|
175
|
+
updated: false,
|
|
176
|
+
zoneDomain: resolved.target.zoneDomain,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const zone = await provider.getZone(resolved.target.zoneDomain);
|
|
180
|
+
if (!zone)
|
|
181
|
+
throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', `${provider.name} does not have a DNS zone for ${resolved.target.zoneDomain}.`);
|
|
182
|
+
input.progress?.(`Planning DNS change in ${provider.name}`);
|
|
183
|
+
let force = Boolean(input.force);
|
|
184
|
+
let plan = await provider.planChanges(zone, [record], { force });
|
|
185
|
+
if (!force && plan.conflicts.length > 0) {
|
|
186
|
+
const warning = conflictWarning(resolved, provider, record, plan.conflicts);
|
|
187
|
+
force = (await input.confirmDnsOverride?.(warning)) === true;
|
|
188
|
+
if (!force)
|
|
189
|
+
throw dnsTargetConflictError(warning);
|
|
190
|
+
plan = await provider.planChanges(zone, [record], { force: true });
|
|
191
|
+
}
|
|
192
|
+
input.progress?.(`Pointing ${resolved.target.fullDomain} to ${record.value}`);
|
|
193
|
+
const result = await provider.applyChanges(zone, plan, { force });
|
|
194
|
+
const shouldWait = input.wait ?? true;
|
|
195
|
+
const propagated = shouldWait
|
|
196
|
+
? await waitForPropagation(record, resolved.target.zoneDomain, input.timeoutSeconds ?? 300, dependencies)
|
|
197
|
+
: false;
|
|
198
|
+
return {
|
|
199
|
+
account: resolved.account,
|
|
200
|
+
accountInferred: resolved.accountInferred,
|
|
201
|
+
domain: resolved.target.fullDomain,
|
|
202
|
+
dryRun: false,
|
|
203
|
+
isDefaultAccount: resolved.isDefaultAccount,
|
|
204
|
+
provider: resolved.provider,
|
|
205
|
+
providerInferred: resolved.providerInferred,
|
|
206
|
+
propagated,
|
|
207
|
+
record,
|
|
208
|
+
skipped: result.skipped,
|
|
209
|
+
updated: result.applied.length > 0,
|
|
210
|
+
zoneDomain: resolved.target.zoneDomain,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeDomain } from '../../validate.js';
|
|
2
|
+
import { ProviderError } from '../core/errors.js';
|
|
2
3
|
import { createProviderHttpClient } from '../core/http.js';
|
|
3
4
|
import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
|
|
4
|
-
import { ProviderError } from '../core/errors.js';
|
|
5
5
|
const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4';
|
|
6
6
|
const capabilities = {
|
|
7
7
|
defaultTtl: 3600,
|
|
@@ -60,7 +60,9 @@ function toCloudflareRecord(record, zone) {
|
|
|
60
60
|
ttl: record.ttl ?? capabilities.defaultTtl,
|
|
61
61
|
type: record.type,
|
|
62
62
|
...(record.priority === undefined ? {} : { priority: record.priority }),
|
|
63
|
-
...(record.proxied === undefined || !['A', 'AAAA', 'CNAME'].includes(record.type)
|
|
63
|
+
...(record.proxied === undefined || !['A', 'AAAA', 'CNAME'].includes(record.type)
|
|
64
|
+
? {}
|
|
65
|
+
: { proxied: record.proxied }),
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
68
|
function toZone(zone) {
|
|
@@ -102,7 +104,7 @@ export class CloudflareProvider {
|
|
|
102
104
|
const zones = [];
|
|
103
105
|
for (let page = 1; page <= 100; page += 1) {
|
|
104
106
|
const response = await this.request('/zones', {
|
|
105
|
-
query: { 'account.id': this.accountId, direction: 'asc', order: 'name', page,
|
|
107
|
+
query: { 'account.id': this.accountId, direction: 'asc', order: 'name', page, per_page: 50 },
|
|
106
108
|
});
|
|
107
109
|
for (const zone of response.result ?? []) {
|
|
108
110
|
const dnsZone = toZone(zone);
|
|
@@ -124,7 +126,7 @@ export class CloudflareProvider {
|
|
|
124
126
|
const records = [];
|
|
125
127
|
for (let page = 1; page <= 100; page += 1) {
|
|
126
128
|
const response = await this.request(`/zones/${zone.id}/dns_records`, {
|
|
127
|
-
query: { page,
|
|
129
|
+
query: { page, per_page: 100 },
|
|
128
130
|
});
|
|
129
131
|
for (const record of response.result ?? []) {
|
|
130
132
|
const dnsRecord = toDnsRecord(record, zone);
|
|
@@ -138,7 +140,13 @@ export class CloudflareProvider {
|
|
|
138
140
|
return records;
|
|
139
141
|
}
|
|
140
142
|
async planChanges(zone, desired, opts = {}) {
|
|
141
|
-
return planDnsChanges({
|
|
143
|
+
return planDnsChanges({
|
|
144
|
+
desired,
|
|
145
|
+
existing: await this.listRecords(zone),
|
|
146
|
+
force: opts.force,
|
|
147
|
+
providerId: this.id,
|
|
148
|
+
zone,
|
|
149
|
+
});
|
|
142
150
|
}
|
|
143
151
|
async applyChanges(zone, plan) {
|
|
144
152
|
assertNoConflicts(this.id, plan);
|
|
@@ -50,7 +50,8 @@ export function providerAccountHasCredentials(config, providerId, accountInput)
|
|
|
50
50
|
return false;
|
|
51
51
|
if (account !== DEFAULT_PROVIDER_ACCOUNT)
|
|
52
52
|
return hasCredentials(current.accounts?.[account]?.credentials);
|
|
53
|
-
return hasCredentials(current.credentials) ||
|
|
53
|
+
return (hasCredentials(current.credentials) ||
|
|
54
|
+
Boolean(legacyCredential(config, providerId, 'apiKey') || legacyCredential(config, providerId, 'apiSecret')));
|
|
54
55
|
}
|
|
55
56
|
export function withProviderAccountCredentials(current, accountInput, credentials) {
|
|
56
57
|
const account = normalizeProviderAccount(accountInput);
|
|
@@ -5,6 +5,10 @@ function cleanDnsValue(value) {
|
|
|
5
5
|
function sameRecord(a, b) {
|
|
6
6
|
if (b.proxied !== undefined && a.proxied !== b.proxied)
|
|
7
7
|
return false;
|
|
8
|
+
if (b.priority !== undefined && a.priority !== b.priority)
|
|
9
|
+
return false;
|
|
10
|
+
if (b.ttl !== undefined && a.ttl !== b.ttl)
|
|
11
|
+
return false;
|
|
8
12
|
return a.type === b.type && a.name === b.name && cleanDnsValue(a.value) === cleanDnsValue(b.value);
|
|
9
13
|
}
|
|
10
14
|
function sameDnsValue(a, b) {
|
|
@@ -13,6 +17,11 @@ function sameDnsValue(a, b) {
|
|
|
13
17
|
function sameSlot(a, b) {
|
|
14
18
|
return a.type === b.type && a.name === b.name;
|
|
15
19
|
}
|
|
20
|
+
function recordOptionsDiffer(a, b) {
|
|
21
|
+
return ((b.proxied !== undefined && a.proxied !== b.proxied) ||
|
|
22
|
+
(b.priority !== undefined && a.priority !== b.priority) ||
|
|
23
|
+
(b.ttl !== undefined && a.ttl !== b.ttl));
|
|
24
|
+
}
|
|
16
25
|
function cnameSlotConflict(a, b) {
|
|
17
26
|
return a.name === b.name && (a.type === 'CNAME' || b.type === 'CNAME');
|
|
18
27
|
}
|
|
@@ -21,38 +30,31 @@ export function planDnsChanges(input) {
|
|
|
21
30
|
const conflicts = [];
|
|
22
31
|
for (const record of input.desired) {
|
|
23
32
|
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
33
|
const sameValue = input.existing.find((existing) => sameDnsValue(existing, record));
|
|
29
|
-
if (
|
|
30
|
-
changes.push({ action: '
|
|
34
|
+
if (record.type === 'TXT') {
|
|
35
|
+
changes.push(exact ? { action: 'skip', existing: exact, reason: 'already_exists', record } : { action: 'create', record });
|
|
31
36
|
continue;
|
|
32
37
|
}
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
const sameTyped = input.existing.filter((existing) => sameSlot(existing, record) && !sameDnsValue(existing, record));
|
|
39
|
+
const cnameConflicts = input.existing.filter((existing) => cnameSlotConflict(existing, record) && !sameSlot(existing, record));
|
|
40
|
+
if (!input.force && (sameTyped.length > 0 || cnameConflicts.length > 0)) {
|
|
41
|
+
conflicts.push(...sameTyped.map((existing) => ({ existing, reason: 'same_type_record_exists', record })), ...cnameConflicts.map((existing) => ({ existing, reason: 'cname_slot_conflict', record })));
|
|
35
42
|
continue;
|
|
36
43
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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 });
|
|
44
|
+
if (exact) {
|
|
45
|
+
changes.push(...sameTyped.map((existing) => ({ action: 'delete', existing, reason: 'same_type_record_exists' })), ...cnameConflicts.map((existing) => ({ action: 'delete', existing, reason: 'cname_slot_conflict' })), { action: 'skip', existing: exact, reason: 'already_exists', record });
|
|
43
46
|
continue;
|
|
44
47
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
|
48
|
+
if (sameValue && recordOptionsDiffer(sameValue, record)) {
|
|
49
|
+
changes.push(...sameTyped.map((existing) => ({ action: 'delete', existing, reason: 'same_type_record_exists' })), ...cnameConflicts.map((existing) => ({ action: 'delete', existing, reason: 'cname_slot_conflict' })), { action: 'update', existing: sameValue, record });
|
|
53
50
|
continue;
|
|
54
51
|
}
|
|
55
|
-
|
|
52
|
+
const [replace, ...remainingSameTyped] = sameTyped;
|
|
53
|
+
changes.push(...(replace ? [{ action: 'update', existing: replace, record }] : []), ...remainingSameTyped.map((existing) => ({
|
|
54
|
+
action: 'delete',
|
|
55
|
+
existing,
|
|
56
|
+
reason: 'same_type_record_exists',
|
|
57
|
+
})), ...cnameConflicts.map((existing) => ({ action: 'delete', existing, reason: 'cname_slot_conflict' })), ...(replace ? [] : [{ action: 'create', record }]));
|
|
56
58
|
}
|
|
57
59
|
return { changes, conflicts, desired: input.desired, existing: input.existing, zone: input.zone };
|
|
58
60
|
}
|
|
@@ -67,6 +67,28 @@ function toHostingerRecord(record) {
|
|
|
67
67
|
function deleteFilter(record) {
|
|
68
68
|
return { name: record.name, type: record.type };
|
|
69
69
|
}
|
|
70
|
+
function sameRecordSet(record, desired) {
|
|
71
|
+
return record.name === desired.name && record.type === desired.type;
|
|
72
|
+
}
|
|
73
|
+
function collapseRecordSetWrites(plan) {
|
|
74
|
+
let changes = plan.changes;
|
|
75
|
+
for (const desired of plan.desired) {
|
|
76
|
+
const existing = plan.existing.filter((record) => sameRecordSet(record, desired));
|
|
77
|
+
if (existing.length === 0)
|
|
78
|
+
continue;
|
|
79
|
+
const rewritesRecordSet = changes.some((change) => (change.action === 'delete' && sameRecordSet(change.existing, desired)) ||
|
|
80
|
+
(change.action === 'update' && sameRecordSet(change.record, desired)));
|
|
81
|
+
if (!rewritesRecordSet)
|
|
82
|
+
continue;
|
|
83
|
+
changes = changes.filter((change) => {
|
|
84
|
+
if (change.action === 'delete')
|
|
85
|
+
return !sameRecordSet(change.existing, desired);
|
|
86
|
+
return !sameRecordSet(change.record, desired);
|
|
87
|
+
});
|
|
88
|
+
changes.push({ action: 'update', existing: existing[0], record: desired });
|
|
89
|
+
}
|
|
90
|
+
return { ...plan, changes };
|
|
91
|
+
}
|
|
70
92
|
export class HostingerProvider {
|
|
71
93
|
capabilities = capabilities;
|
|
72
94
|
id = 'hostinger';
|
|
@@ -107,7 +129,14 @@ export class HostingerProvider {
|
|
|
107
129
|
return records.flatMap((record) => toDnsRecords(record, zone));
|
|
108
130
|
}
|
|
109
131
|
async planChanges(zone, desired, opts = {}) {
|
|
110
|
-
|
|
132
|
+
const plan = planDnsChanges({
|
|
133
|
+
desired,
|
|
134
|
+
existing: await this.listRecords(zone),
|
|
135
|
+
force: opts.force,
|
|
136
|
+
providerId: this.id,
|
|
137
|
+
zone,
|
|
138
|
+
});
|
|
139
|
+
return collapseRecordSetWrites(plan);
|
|
111
140
|
}
|
|
112
141
|
async applyChanges(zone, plan) {
|
|
113
142
|
assertNoConflicts(this.id, plan);
|
|
@@ -53,7 +53,10 @@ function providerCodeFromNamecheapError(message) {
|
|
|
53
53
|
const lower = message.toLowerCase();
|
|
54
54
|
if (lower.includes('clientip') || lower.includes('client ip') || lower.includes('whitelist'))
|
|
55
55
|
return 'PROVIDER_PERMISSION_DENIED';
|
|
56
|
-
if (lower.includes('api key') ||
|
|
56
|
+
if (lower.includes('api key') ||
|
|
57
|
+
lower.includes('apiuser') ||
|
|
58
|
+
lower.includes('username') ||
|
|
59
|
+
lower.includes('authentication')) {
|
|
57
60
|
return 'PROVIDER_AUTH_FAILED';
|
|
58
61
|
}
|
|
59
62
|
if (lower.includes('rate'))
|
|
@@ -176,7 +179,13 @@ export class NamecheapProvider {
|
|
|
176
179
|
});
|
|
177
180
|
}
|
|
178
181
|
async planChanges(zone, desired, opts = {}) {
|
|
179
|
-
return planDnsChanges({
|
|
182
|
+
return planDnsChanges({
|
|
183
|
+
desired,
|
|
184
|
+
existing: await this.listRecords(zone),
|
|
185
|
+
force: opts.force,
|
|
186
|
+
providerId: this.id,
|
|
187
|
+
zone,
|
|
188
|
+
});
|
|
180
189
|
}
|
|
181
190
|
async applyChanges(zone, plan) {
|
|
182
191
|
assertNoConflicts(this.id, plan);
|
|
@@ -5,7 +5,12 @@ import { createProviderContext } from './core/config.js';
|
|
|
5
5
|
import { hostingerProviderDefinition } from './hostinger/index.js';
|
|
6
6
|
import { namecheapProviderDefinition } from './namecheap/index.js';
|
|
7
7
|
import { spaceshipProviderDefinition } from './spaceship/index.js';
|
|
8
|
-
const definitions = [
|
|
8
|
+
const definitions = [
|
|
9
|
+
spaceshipProviderDefinition,
|
|
10
|
+
namecheapProviderDefinition,
|
|
11
|
+
cloudflareProviderDefinition,
|
|
12
|
+
hostingerProviderDefinition,
|
|
13
|
+
];
|
|
9
14
|
export function listProviderDefinitions() {
|
|
10
15
|
return definitions;
|
|
11
16
|
}
|
|
@@ -9,7 +9,9 @@ export async function listProviderStatuses(opts = {}) {
|
|
|
9
9
|
const statuses = [];
|
|
10
10
|
for (const definition of listProviderDefinitions()) {
|
|
11
11
|
const accounts = listConfiguredProviderAccounts(config, definition);
|
|
12
|
-
const refs = accounts.length > 0
|
|
12
|
+
const refs = accounts.length > 0
|
|
13
|
+
? accounts
|
|
14
|
+
: [{ account: DEFAULT_PROVIDER_ACCOUNT, isDefaultAccount: true, providerId: definition.id }];
|
|
13
15
|
for (const ref of refs) {
|
|
14
16
|
const account = normalizeProviderAccount(ref.account);
|
|
15
17
|
const configured = accounts.some((item) => item.account === account);
|
package/dist/lib/validate.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { DoomainError } from './errors.js';
|
|
2
2
|
const DOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
3
3
|
export function normalizeDomain(input) {
|
|
4
|
-
const value = input
|
|
4
|
+
const value = input
|
|
5
|
+
.trim()
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/^https?:\/\//, '')
|
|
8
|
+
.replace(/\/$/, '');
|
|
5
9
|
const domain = value.split('/')[0];
|
|
6
10
|
if (!domain || domain.length > 253) {
|
|
7
11
|
throw new DoomainError('INVALID_INPUT', 'Domain is required.');
|
|
@@ -13,7 +17,10 @@ export function normalizeDomain(input) {
|
|
|
13
17
|
return domain;
|
|
14
18
|
}
|
|
15
19
|
export function normalizeSubdomain(input) {
|
|
16
|
-
const subdomain = input
|
|
20
|
+
const subdomain = input
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/^\.+|\.+$/g, '');
|
|
17
24
|
if (!subdomain || subdomain === '@') {
|
|
18
25
|
throw new DoomainError('INVALID_INPUT', 'Subdomain is required unless --apex is used.');
|
|
19
26
|
}
|
package/dist/lib/vercel.js
CHANGED
|
@@ -47,7 +47,8 @@ function findProjectDomainTarget(raw, domain) {
|
|
|
47
47
|
const targets = Array.isArray(raw) ? raw : [raw];
|
|
48
48
|
return targets.find((target) => target &&
|
|
49
49
|
typeof target === 'object' &&
|
|
50
|
-
(isSameDomain(target.domain, domain) ||
|
|
50
|
+
(isSameDomain(target.domain, domain) ||
|
|
51
|
+
isSameDomain(target.name, domain)));
|
|
51
52
|
}
|
|
52
53
|
export function createVercelClient(config) {
|
|
53
54
|
async function request(path, init = {}, opts = {}) {
|
|
@@ -177,7 +178,9 @@ export function createVercelClient(config) {
|
|
|
177
178
|
return result.domains ?? [];
|
|
178
179
|
},
|
|
179
180
|
async removeDomainFromProject(project, domain) {
|
|
180
|
-
await request(`/v9/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}`, {
|
|
181
|
+
await request(`/v9/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}`, {
|
|
182
|
+
method: 'DELETE',
|
|
183
|
+
});
|
|
181
184
|
},
|
|
182
185
|
async verifyProjectDomain(project, domain) {
|
|
183
186
|
return request(`/v9/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}/verify`, { method: 'POST' });
|
package/oclif.manifest.json
CHANGED
|
@@ -312,6 +312,113 @@
|
|
|
312
312
|
"vercel.js"
|
|
313
313
|
]
|
|
314
314
|
},
|
|
315
|
+
"dns:point": {
|
|
316
|
+
"aliases": [],
|
|
317
|
+
"args": {
|
|
318
|
+
"domain": {
|
|
319
|
+
"description": "Fully qualified domain to point.",
|
|
320
|
+
"name": "domain",
|
|
321
|
+
"required": true
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
"description": "Point a DNS name at an IP address or canonical hostname.",
|
|
325
|
+
"examples": [
|
|
326
|
+
"<%= config.bin %> <%= command.id %> app.example.com --target 203.0.113.10 --json",
|
|
327
|
+
"<%= config.bin %> <%= command.id %> app.example.com --target origin.example.net --dry-run --json",
|
|
328
|
+
"<%= config.bin %> <%= command.id %> example.com --target 203.0.113.10 --provider spaceship --account work --force --json"
|
|
329
|
+
],
|
|
330
|
+
"flags": {
|
|
331
|
+
"account": {
|
|
332
|
+
"description": "DNS provider profile/account alias. Defaults to the provider default account.",
|
|
333
|
+
"name": "account",
|
|
334
|
+
"hasDynamicHelp": false,
|
|
335
|
+
"multiple": false,
|
|
336
|
+
"type": "option"
|
|
337
|
+
},
|
|
338
|
+
"dry-run": {
|
|
339
|
+
"description": "Preview the DNS record without writing it.",
|
|
340
|
+
"name": "dry-run",
|
|
341
|
+
"allowNo": false,
|
|
342
|
+
"type": "boolean"
|
|
343
|
+
},
|
|
344
|
+
"force": {
|
|
345
|
+
"description": "Overwrite conflicting DNS records.",
|
|
346
|
+
"name": "force",
|
|
347
|
+
"allowNo": false,
|
|
348
|
+
"type": "boolean"
|
|
349
|
+
},
|
|
350
|
+
"json": {
|
|
351
|
+
"description": "Output a single JSON object and never prompt.",
|
|
352
|
+
"name": "json",
|
|
353
|
+
"allowNo": false,
|
|
354
|
+
"type": "boolean"
|
|
355
|
+
},
|
|
356
|
+
"provider": {
|
|
357
|
+
"description": "DNS provider id. Inferred from the target domain when omitted.",
|
|
358
|
+
"name": "provider",
|
|
359
|
+
"hasDynamicHelp": false,
|
|
360
|
+
"multiple": false,
|
|
361
|
+
"type": "option"
|
|
362
|
+
},
|
|
363
|
+
"target": {
|
|
364
|
+
"description": "IPv4, IPv6, or hostname target.",
|
|
365
|
+
"name": "target",
|
|
366
|
+
"required": true,
|
|
367
|
+
"hasDynamicHelp": false,
|
|
368
|
+
"multiple": false,
|
|
369
|
+
"type": "option"
|
|
370
|
+
},
|
|
371
|
+
"timeout": {
|
|
372
|
+
"description": "DNS propagation wait timeout in seconds.",
|
|
373
|
+
"name": "timeout",
|
|
374
|
+
"default": 300,
|
|
375
|
+
"hasDynamicHelp": false,
|
|
376
|
+
"multiple": false,
|
|
377
|
+
"type": "option"
|
|
378
|
+
},
|
|
379
|
+
"ttl": {
|
|
380
|
+
"description": "DNS record TTL in seconds.",
|
|
381
|
+
"name": "ttl",
|
|
382
|
+
"default": 300,
|
|
383
|
+
"hasDynamicHelp": false,
|
|
384
|
+
"multiple": false,
|
|
385
|
+
"type": "option"
|
|
386
|
+
},
|
|
387
|
+
"type": {
|
|
388
|
+
"description": "Record type. Inferred from the target when omitted.",
|
|
389
|
+
"name": "type",
|
|
390
|
+
"hasDynamicHelp": false,
|
|
391
|
+
"multiple": false,
|
|
392
|
+
"options": [
|
|
393
|
+
"A",
|
|
394
|
+
"AAAA",
|
|
395
|
+
"CNAME"
|
|
396
|
+
],
|
|
397
|
+
"type": "option"
|
|
398
|
+
},
|
|
399
|
+
"wait": {
|
|
400
|
+
"description": "Wait for public DNS propagation.",
|
|
401
|
+
"name": "wait",
|
|
402
|
+
"allowNo": true,
|
|
403
|
+
"type": "boolean"
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
"hasDynamicHelp": false,
|
|
407
|
+
"hiddenAliases": [],
|
|
408
|
+
"id": "dns:point",
|
|
409
|
+
"pluginAlias": "doomain",
|
|
410
|
+
"pluginName": "doomain",
|
|
411
|
+
"pluginType": "core",
|
|
412
|
+
"strict": true,
|
|
413
|
+
"enableJsonFlag": false,
|
|
414
|
+
"isESM": true,
|
|
415
|
+
"relativePath": [
|
|
416
|
+
"dist",
|
|
417
|
+
"commands",
|
|
418
|
+
"dns",
|
|
419
|
+
"point.js"
|
|
420
|
+
]
|
|
421
|
+
},
|
|
315
422
|
"domains:find": {
|
|
316
423
|
"aliases": [],
|
|
317
424
|
"args": {
|
|
@@ -894,5 +1001,5 @@
|
|
|
894
1001
|
]
|
|
895
1002
|
}
|
|
896
1003
|
},
|
|
897
|
-
"version": "0.1.
|
|
1004
|
+
"version": "0.1.20"
|
|
898
1005
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doomain",
|
|
3
|
-
"description": "Configure
|
|
4
|
-
"version": "0.1.
|
|
3
|
+
"description": "Configure production domains and DNS records in seconds",
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"author": "Crafter Station",
|
|
6
6
|
"packageManager": "bun@1.3.13",
|
|
7
7
|
"bin": {
|
|
@@ -64,6 +64,9 @@
|
|
|
64
64
|
"domains": {
|
|
65
65
|
"description": "Inspect DNS domains"
|
|
66
66
|
},
|
|
67
|
+
"dns": {
|
|
68
|
+
"description": "Manage DNS records"
|
|
69
|
+
},
|
|
67
70
|
"projects": {
|
|
68
71
|
"description": "Inspect Vercel projects"
|
|
69
72
|
},
|