doomain 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Use the interactive wizard when working by hand. Use explicit commands with `--j
|
|
|
14
14
|
- DNS provider inference by longest matching configured zone.
|
|
15
15
|
- Dry-run plans before writing changes.
|
|
16
16
|
- DNS propagation and Vercel verification wait loop.
|
|
17
|
-
- DNS provider support for Spaceship, Namecheap, and
|
|
17
|
+
- DNS provider support for Spaceship, Namecheap, Cloudflare, and Hostinger.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -93,6 +93,7 @@ Doomain stores local credentials in `~/.doomain/config.json` with `0600` file pe
|
|
|
93
93
|
| Spaceship | `spaceship` | `apiKey`, `apiSecret` | `SPACESHIP_API_KEY`, `SPACESHIP_API_SECRET` | API key needs domain read access and DNS record read/write access. |
|
|
94
94
|
| Namecheap | `namecheap` | `apiUser`, `apiKey`, `clientIp` | `NAMECHEAP_API_USER`, `NAMECHEAP_API_KEY`, `NAMECHEAP_CLIENT_IP` | API access must be enabled and `clientIp` must be your whitelisted public IPv4. Optional: `username`, `sandbox`. |
|
|
95
95
|
| Cloudflare | `cloudflare` | `apiToken`, `accountId` | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | API token needs `Zone:Read` and `DNS:Edit`. Vercel records are written as DNS-only records, not proxied. |
|
|
96
|
+
| Hostinger | `hostinger` | `apiToken` | `HOSTINGER_API_TOKEN` | API token needs access to domain portfolio and DNS zone records. |
|
|
96
97
|
|
|
97
98
|
### Spaceship
|
|
98
99
|
|
|
@@ -139,6 +140,15 @@ doomain providers connect cloudflare \
|
|
|
139
140
|
|
|
140
141
|
Cloudflare records created for Vercel `A`, `AAAA`, and `CNAME` targets are set to `proxied: false` so Vercel can validate the domain.
|
|
141
142
|
|
|
143
|
+
### Hostinger
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
doomain providers connect hostinger \
|
|
147
|
+
--credential apiToken=your_hostinger_api_token
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Create Hostinger API tokens from hPanel Account > API. Doomain lists active zones from the Hostinger domain portfolio and updates records through the DNS zone API.
|
|
151
|
+
|
|
142
152
|
## Linking Domains
|
|
143
153
|
|
|
144
154
|
You can pass the full target domain as a positional argument:
|
|
@@ -373,6 +383,7 @@ Saves DNS provider credentials locally.
|
|
|
373
383
|
```bash
|
|
374
384
|
doomain providers connect cloudflare -c apiToken=token -c accountId=account_id
|
|
375
385
|
doomain providers connect namecheap -c apiUser=user -c apiKey=key -c clientIp=127.0.0.1 --json
|
|
386
|
+
doomain providers connect hostinger -c apiToken=token --json
|
|
376
387
|
doomain providers connect spaceship --api-key key --api-secret secret
|
|
377
388
|
```
|
|
378
389
|
|
|
@@ -495,6 +506,12 @@ CLOUDFLARE_API_TOKEN
|
|
|
495
506
|
CLOUDFLARE_ACCOUNT_ID
|
|
496
507
|
```
|
|
497
508
|
|
|
509
|
+
Hostinger:
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
HOSTINGER_API_TOKEN
|
|
513
|
+
```
|
|
514
|
+
|
|
498
515
|
Doomain defaults and config:
|
|
499
516
|
|
|
500
517
|
```bash
|
|
@@ -528,7 +545,7 @@ The selected provider does not have a DNS zone matching the target domain. Check
|
|
|
528
545
|
|
|
529
546
|
`PROVIDER_ZONE_AMBIGUOUS`
|
|
530
547
|
|
|
531
|
-
More than one configured provider has the same best matching zone. Re-run with `--provider cloudflare`, `--provider namecheap`, or `--provider
|
|
548
|
+
More than one configured provider has the same best matching zone. Re-run with `--provider cloudflare`, `--provider namecheap`, `--provider spaceship`, or `--provider hostinger`.
|
|
532
549
|
|
|
533
550
|
Namecheap authentication or permission errors
|
|
534
551
|
|
|
@@ -538,6 +555,14 @@ Cloudflare permission errors
|
|
|
538
555
|
|
|
539
556
|
Make sure the API token has `Zone:Read` and `DNS:Edit` permissions for the account that owns the zones.
|
|
540
557
|
|
|
558
|
+
Hostinger authentication errors
|
|
559
|
+
|
|
560
|
+
Make sure the API token is active and can access the domains you want Doomain to manage.
|
|
561
|
+
|
|
562
|
+
Hostinger DNS zone not found
|
|
563
|
+
|
|
564
|
+
Make sure the domain is active in Hostinger before linking it. Domains shown as `pending_setup` in Hostinger's portfolio API are not writable through the DNS zone API yet.
|
|
565
|
+
|
|
541
566
|
DNS propagation timeout
|
|
542
567
|
|
|
543
568
|
The DNS records may have been saved even if Vercel verification timed out. Check the domain in Vercel, inspect records with `doomain domains list`, or re-run verification with `doomain verify`.
|
|
@@ -43,6 +43,7 @@ export const commandSchemas = [
|
|
|
43
43
|
'doomain providers connect spaceship --credential apiKey=key --credential apiSecret=secret --json',
|
|
44
44
|
'doomain providers connect namecheap --credential apiUser=user --credential apiKey=key --credential clientIp=127.0.0.1 --json',
|
|
45
45
|
'doomain providers connect cloudflare --credential apiToken=token --credential accountId=account_id --json',
|
|
46
|
+
'doomain providers connect hostinger --credential apiToken=token --json',
|
|
46
47
|
],
|
|
47
48
|
flags: [
|
|
48
49
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
@@ -74,7 +75,7 @@ export const commandSchemas = [
|
|
|
74
75
|
{
|
|
75
76
|
name: 'providers disconnect',
|
|
76
77
|
description: 'Remove saved DNS provider credentials locally.',
|
|
77
|
-
examples: ['doomain providers disconnect namecheap --json', 'doomain providers disconnect cloudflare --json'],
|
|
78
|
+
examples: ['doomain providers disconnect namecheap --json', 'doomain providers disconnect cloudflare --json', 'doomain providers disconnect hostinger --json'],
|
|
78
79
|
flags: [
|
|
79
80
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
80
81
|
],
|
|
@@ -82,7 +83,7 @@ export const commandSchemas = [
|
|
|
82
83
|
{
|
|
83
84
|
name: 'providers verify',
|
|
84
85
|
description: 'Verify saved DNS provider credentials.',
|
|
85
|
-
examples: ['doomain providers verify spaceship --json', 'doomain providers verify namecheap --json'],
|
|
86
|
+
examples: ['doomain providers verify spaceship --json', 'doomain providers verify namecheap --json', 'doomain providers verify hostinger --json'],
|
|
86
87
|
flags: [
|
|
87
88
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
88
89
|
],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DnsChange, DnsChangePlan, DnsProvider, DnsProviderDefinition, DnsRecord, DnsRecordInput, DnsZone, ProviderCapabilities, ProviderContext, ProviderHealth } from '../types.js';
|
|
2
|
+
export declare class HostingerProvider implements DnsProvider {
|
|
3
|
+
readonly capabilities: ProviderCapabilities;
|
|
4
|
+
readonly id = "hostinger";
|
|
5
|
+
readonly name = "Hostinger";
|
|
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: DnsChange[];
|
|
17
|
+
skipped: DnsRecordInput[];
|
|
18
|
+
}>;
|
|
19
|
+
upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
|
|
20
|
+
deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
|
|
21
|
+
private putRecords;
|
|
22
|
+
}
|
|
23
|
+
export declare const hostingerProviderDefinition: DnsProviderDefinition;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { normalizeDomain } from '../../validate.js';
|
|
2
|
+
import { createProviderHttpClient } from '../core/http.js';
|
|
3
|
+
import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
|
|
4
|
+
const HOSTINGER_API_URL = 'https://developers.hostinger.com';
|
|
5
|
+
const capabilities = {
|
|
6
|
+
defaultTtl: 14_400,
|
|
7
|
+
recordTypes: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'],
|
|
8
|
+
supportsApexCname: false,
|
|
9
|
+
supportsBulkWrites: true,
|
|
10
|
+
supportsPagination: false,
|
|
11
|
+
supportsProxying: false,
|
|
12
|
+
supportsRecordIds: false,
|
|
13
|
+
};
|
|
14
|
+
function isSupportedRecordType(type) {
|
|
15
|
+
return capabilities.recordTypes.includes(type);
|
|
16
|
+
}
|
|
17
|
+
function cleanRecordName(name, zoneName) {
|
|
18
|
+
const normalized = name.trim().toLowerCase().replace(/\.$/, '');
|
|
19
|
+
const normalizedZone = normalizeDomain(zoneName);
|
|
20
|
+
if (normalized === '@' || normalized === normalizedZone)
|
|
21
|
+
return '@';
|
|
22
|
+
if (normalized.endsWith(`.${normalizedZone}`))
|
|
23
|
+
return normalized.slice(0, -(normalizedZone.length + 1)) || '@';
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
function toZone(domain) {
|
|
27
|
+
if (!domain.domain)
|
|
28
|
+
return null;
|
|
29
|
+
if (domain.status && domain.status !== 'active')
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const name = normalizeDomain(domain.domain);
|
|
33
|
+
return { id: name, metadata: { hostinger: domain }, name };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function toDnsRecords(record, zone) {
|
|
40
|
+
if (!record.name || !record.type || !isSupportedRecordType(record.type))
|
|
41
|
+
return [];
|
|
42
|
+
const name = cleanRecordName(record.name, zone.name);
|
|
43
|
+
const type = record.type;
|
|
44
|
+
return (record.records ?? []).flatMap((item) => {
|
|
45
|
+
const content = item.content;
|
|
46
|
+
if (!content || item.is_disabled)
|
|
47
|
+
return [];
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
metadata: { hostinger: { ...record, records: [item] } },
|
|
51
|
+
name,
|
|
52
|
+
ttl: record.ttl,
|
|
53
|
+
type,
|
|
54
|
+
value: content,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function toHostingerRecord(record) {
|
|
60
|
+
return {
|
|
61
|
+
name: record.name,
|
|
62
|
+
records: [{ content: record.value }],
|
|
63
|
+
ttl: record.ttl ?? capabilities.defaultTtl,
|
|
64
|
+
type: record.type,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function deleteFilter(record) {
|
|
68
|
+
return { name: record.name, type: record.type };
|
|
69
|
+
}
|
|
70
|
+
export class HostingerProvider {
|
|
71
|
+
capabilities = capabilities;
|
|
72
|
+
id = 'hostinger';
|
|
73
|
+
name = 'Hostinger';
|
|
74
|
+
http;
|
|
75
|
+
constructor(context) {
|
|
76
|
+
this.http = createProviderHttpClient({
|
|
77
|
+
baseUrl: HOSTINGER_API_URL,
|
|
78
|
+
errorMessages: {
|
|
79
|
+
401: 'Hostinger rejected the API token. Re-run `doomain providers connect hostinger` with a valid token.',
|
|
80
|
+
404: 'Hostinger could not find a writable DNS zone for this domain. Make sure the domain is active in Hostinger before linking it.',
|
|
81
|
+
422: 'Hostinger rejected the DNS record payload.',
|
|
82
|
+
429: 'Hostinger rate limit exceeded. Try again later.',
|
|
83
|
+
},
|
|
84
|
+
headers: { Authorization: `Bearer ${context.credentials.apiToken}` },
|
|
85
|
+
providerId: this.id,
|
|
86
|
+
signal: context.signal,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async verifyCredentials() {
|
|
90
|
+
await this.listZones();
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
async listZones() {
|
|
94
|
+
const domains = await this.http.request('/api/domains/v1/portfolio');
|
|
95
|
+
return domains.flatMap((domain) => {
|
|
96
|
+
const zone = toZone(domain);
|
|
97
|
+
return zone ? [zone] : [];
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async getZone(domain) {
|
|
101
|
+
const normalized = normalizeDomain(domain);
|
|
102
|
+
const zones = await this.listZones();
|
|
103
|
+
return zones.find((zone) => zone.name === normalized) ?? null;
|
|
104
|
+
}
|
|
105
|
+
async listRecords(zone) {
|
|
106
|
+
const records = await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`);
|
|
107
|
+
return records.flatMap((record) => toDnsRecords(record, zone));
|
|
108
|
+
}
|
|
109
|
+
async planChanges(zone, desired, opts = {}) {
|
|
110
|
+
return planDnsChanges({ desired, existing: await this.listRecords(zone), force: opts.force, providerId: this.id, zone });
|
|
111
|
+
}
|
|
112
|
+
async applyChanges(zone, plan) {
|
|
113
|
+
assertNoConflicts(this.id, plan);
|
|
114
|
+
const applied = [];
|
|
115
|
+
const skipped = [];
|
|
116
|
+
for (const change of plan.changes) {
|
|
117
|
+
if (change.action === 'skip') {
|
|
118
|
+
skipped.push(change.record);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (change.action === 'delete')
|
|
122
|
+
await this.deleteRecord(zone, change.existing);
|
|
123
|
+
else
|
|
124
|
+
await this.putRecords(zone, [change.record], change.action === 'update');
|
|
125
|
+
applied.push(change);
|
|
126
|
+
}
|
|
127
|
+
return { applied, skipped };
|
|
128
|
+
}
|
|
129
|
+
async upsertRecord(zone, record) {
|
|
130
|
+
await this.putRecords(zone, [record], true);
|
|
131
|
+
return { ...record, ttl: record.ttl ?? capabilities.defaultTtl };
|
|
132
|
+
}
|
|
133
|
+
async deleteRecord(zone, record) {
|
|
134
|
+
await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`, {
|
|
135
|
+
body: { filters: [deleteFilter(record)] },
|
|
136
|
+
method: 'DELETE',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async putRecords(zone, records, overwrite) {
|
|
140
|
+
await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`, {
|
|
141
|
+
body: { overwrite, zone: records.map(toHostingerRecord) },
|
|
142
|
+
method: 'PUT',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export const hostingerProviderDefinition = {
|
|
147
|
+
capabilities,
|
|
148
|
+
credentials: [{ env: 'HOSTINGER_API_TOKEN', key: 'apiToken', label: 'API token', required: true, secret: true }],
|
|
149
|
+
displayName: 'Hostinger',
|
|
150
|
+
docsUrl: 'https://developers.hostinger.com/',
|
|
151
|
+
id: 'hostinger',
|
|
152
|
+
name: 'Hostinger',
|
|
153
|
+
setup: {
|
|
154
|
+
notes: [
|
|
155
|
+
'Create a Hostinger API token from hPanel Account > API with access to the domains you want Doomain to manage.',
|
|
156
|
+
'Doomain uses the Hostinger domain portfolio API to infer zones and the DNS zone API to update records.',
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
create(context) {
|
|
160
|
+
return new HostingerProvider(context);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -2,9 +2,10 @@ import { DoomainError } from '../errors.js';
|
|
|
2
2
|
import { ensureProviderId } from '../validate.js';
|
|
3
3
|
import { cloudflareProviderDefinition } from './cloudflare/index.js';
|
|
4
4
|
import { createProviderContext } from './core/config.js';
|
|
5
|
+
import { hostingerProviderDefinition } from './hostinger/index.js';
|
|
5
6
|
import { namecheapProviderDefinition } from './namecheap/index.js';
|
|
6
7
|
import { spaceshipProviderDefinition } from './spaceship/index.js';
|
|
7
|
-
const definitions = [spaceshipProviderDefinition, namecheapProviderDefinition, cloudflareProviderDefinition];
|
|
8
|
+
const definitions = [spaceshipProviderDefinition, namecheapProviderDefinition, cloudflareProviderDefinition, hostingerProviderDefinition];
|
|
8
9
|
export function listProviderDefinitions() {
|
|
9
10
|
return definitions;
|
|
10
11
|
}
|
package/oclif.manifest.json
CHANGED