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 Cloudflare.
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 spaceship`.
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
  }
@@ -630,5 +630,5 @@
630
630
  ]
631
631
  }
632
632
  },
633
- "version": "0.1.8"
633
+ "version": "0.1.9"
634
634
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doomain",
3
3
  "description": "Link your vercel project and domain in seconds",
4
- "version": "0.1.8",
4
+ "version": "0.1.9",
5
5
  "author": "Crafter Station",
6
6
  "packageManager": "bun@1.3.13",
7
7
  "bin": {