doomain 0.1.7 → 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 +27 -2
- package/dist/commands/schema.js +2 -2
- package/dist/lib/command-schema.d.ts +9 -0
- package/dist/lib/command-schema.js +38 -4
- package/dist/lib/link-domain.js +22 -3
- package/dist/lib/providers/hostinger/index.d.ts +23 -0
- package/dist/lib/providers/hostinger/index.js +162 -0
- package/dist/lib/providers/registry.js +2 -1
- package/oclif.manifest.json +36 -36
- package/package.json +1 -1
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`.
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Args, Command } from '@oclif/core';
|
|
2
|
-
import {
|
|
2
|
+
import { getCommandSchemaForAgents } from '../lib/command-schema.js';
|
|
3
3
|
import { jsonFlag } from '../lib/flags.js';
|
|
4
4
|
import { createOutput, outputError } from '../lib/output.js';
|
|
5
5
|
export default class Schema extends Command {
|
|
@@ -14,7 +14,7 @@ export default class Schema extends Command {
|
|
|
14
14
|
const { args, flags } = await this.parse(Schema);
|
|
15
15
|
const out = createOutput({ json: flags.json });
|
|
16
16
|
try {
|
|
17
|
-
const schema =
|
|
17
|
+
const schema = await getCommandSchemaForAgents(args.command);
|
|
18
18
|
if (!schema)
|
|
19
19
|
throw new Error(`Unknown command schema: ${args.command}`);
|
|
20
20
|
if (!out.json)
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { type ProviderStatus } from './providers/status.js';
|
|
2
|
+
export type ProviderConnectionStatus = Pick<ProviderStatus, 'configured' | 'default' | 'displayName' | 'docsUrl' | 'id'>;
|
|
1
3
|
export interface CommandSchema {
|
|
2
4
|
name: string;
|
|
3
5
|
description: string;
|
|
4
6
|
examples: string[];
|
|
5
7
|
agentHint?: string;
|
|
8
|
+
agentInstructions?: string[];
|
|
9
|
+
agentQuickstart?: {
|
|
10
|
+
doNotPreflight: boolean;
|
|
11
|
+
preferredFirstCommand: string;
|
|
12
|
+
};
|
|
13
|
+
configuredProviders?: ProviderConnectionStatus[];
|
|
6
14
|
mutates?: boolean;
|
|
7
15
|
safeForAgents?: boolean;
|
|
8
16
|
flags: Array<{
|
|
@@ -15,3 +23,4 @@ export interface CommandSchema {
|
|
|
15
23
|
}
|
|
16
24
|
export declare const commandSchemas: CommandSchema[];
|
|
17
25
|
export declare function getCommandSchema(name?: string): CommandSchema[] | CommandSchema | undefined;
|
|
26
|
+
export declare function getCommandSchemaForAgents(name?: string): Promise<CommandSchema[] | CommandSchema | undefined>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listProviderStatuses } from './providers/status.js';
|
|
1
2
|
export const commandSchemas = [
|
|
2
3
|
{
|
|
3
4
|
name: 'link',
|
|
@@ -9,7 +10,16 @@ export const commandSchemas = [
|
|
|
9
10
|
'doomain link --domain example.com --subdomain app --project my-app --json',
|
|
10
11
|
'doomain link --provider spaceship --domain example.com --apex --project my-app --dry-run --json',
|
|
11
12
|
],
|
|
12
|
-
agentHint: 'For agent use,
|
|
13
|
+
agentHint: 'For agent use, try `doomain link <domain> --json` first. Do not inspect project files, run provider status, or use --dry-run unless the user explicitly asks for a preview. The command infers project/provider and returns structured recovery errors when inference fails.',
|
|
14
|
+
agentInstructions: [
|
|
15
|
+
'When the user asks to add or link a domain, run `doomain link <domain> --json` first.',
|
|
16
|
+
'Do not run `providers status`, `projects list`, `--help`, or `--dry-run` before the first link attempt unless the user asks for a preview or diagnosis.',
|
|
17
|
+
'Only run follow-up commands when the JSON error includes a recovery action that requires them.',
|
|
18
|
+
],
|
|
19
|
+
agentQuickstart: {
|
|
20
|
+
doNotPreflight: true,
|
|
21
|
+
preferredFirstCommand: 'doomain link <domain> --json',
|
|
22
|
+
},
|
|
13
23
|
mutates: true,
|
|
14
24
|
safeForAgents: true,
|
|
15
25
|
flags: [
|
|
@@ -19,7 +29,7 @@ export const commandSchemas = [
|
|
|
19
29
|
{ name: 'subdomain', type: 'string', description: 'Subdomain to add.' },
|
|
20
30
|
{ name: 'apex', type: 'boolean', description: 'Use the root/apex domain.' },
|
|
21
31
|
{ name: 'project', type: 'string', description: 'Vercel project id/name. Optional when project inference succeeds.' },
|
|
22
|
-
{ name: 'dry-run', type: 'boolean', description: 'Preview changes without writing.' },
|
|
32
|
+
{ name: 'dry-run', type: 'boolean', description: 'Preview changes without writing. Intended for human previews; agents should not use this unless explicitly asked.' },
|
|
23
33
|
{ name: 'force', type: 'boolean', description: 'Overwrite conflicting DNS records.' },
|
|
24
34
|
{ name: 'wait', type: 'boolean', description: 'Wait for DNS and Vercel verification.', default: true },
|
|
25
35
|
{ name: 'timeout', type: 'integer', description: 'Wait timeout in seconds.', default: 300 },
|
|
@@ -33,6 +43,7 @@ export const commandSchemas = [
|
|
|
33
43
|
'doomain providers connect spaceship --credential apiKey=key --credential apiSecret=secret --json',
|
|
34
44
|
'doomain providers connect namecheap --credential apiUser=user --credential apiKey=key --credential clientIp=127.0.0.1 --json',
|
|
35
45
|
'doomain providers connect cloudflare --credential apiToken=token --credential accountId=account_id --json',
|
|
46
|
+
'doomain providers connect hostinger --credential apiToken=token --json',
|
|
36
47
|
],
|
|
37
48
|
flags: [
|
|
38
49
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
@@ -64,7 +75,7 @@ export const commandSchemas = [
|
|
|
64
75
|
{
|
|
65
76
|
name: 'providers disconnect',
|
|
66
77
|
description: 'Remove saved DNS provider credentials locally.',
|
|
67
|
-
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'],
|
|
68
79
|
flags: [
|
|
69
80
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
70
81
|
],
|
|
@@ -72,7 +83,7 @@ export const commandSchemas = [
|
|
|
72
83
|
{
|
|
73
84
|
name: 'providers verify',
|
|
74
85
|
description: 'Verify saved DNS provider credentials.',
|
|
75
|
-
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'],
|
|
76
87
|
flags: [
|
|
77
88
|
{ name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
|
|
78
89
|
],
|
|
@@ -101,3 +112,26 @@ export function getCommandSchema(name) {
|
|
|
101
112
|
return commandSchemas;
|
|
102
113
|
return commandSchemas.find((schema) => schema.name === name);
|
|
103
114
|
}
|
|
115
|
+
async function configuredProviders() {
|
|
116
|
+
return (await listProviderStatuses({ verify: false })).map((provider) => ({
|
|
117
|
+
configured: provider.configured,
|
|
118
|
+
default: provider.default,
|
|
119
|
+
displayName: provider.displayName,
|
|
120
|
+
docsUrl: provider.docsUrl,
|
|
121
|
+
id: provider.id,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
function withProviderConnections(schema, providers) {
|
|
125
|
+
if (schema.name !== 'link')
|
|
126
|
+
return schema;
|
|
127
|
+
return { ...schema, configuredProviders: providers };
|
|
128
|
+
}
|
|
129
|
+
export async function getCommandSchemaForAgents(name) {
|
|
130
|
+
const schema = getCommandSchema(name);
|
|
131
|
+
if (!schema)
|
|
132
|
+
return undefined;
|
|
133
|
+
const providers = await configuredProviders();
|
|
134
|
+
if (Array.isArray(schema))
|
|
135
|
+
return schema.map((item) => withProviderConnections(item, providers));
|
|
136
|
+
return withProviderConnections(schema, providers);
|
|
137
|
+
}
|
package/dist/lib/link-domain.js
CHANGED
|
@@ -5,7 +5,7 @@ import { loadConfig } from './config.js';
|
|
|
5
5
|
import { DoomainError } from './errors.js';
|
|
6
6
|
import { detectLocalVercelProject } from './local-vercel.js';
|
|
7
7
|
import { createProvider, getProviderDefinition, listProviderDefinitions } from './providers/registry.js';
|
|
8
|
-
import { isProviderConfigured } from './providers/status.js';
|
|
8
|
+
import { isProviderConfigured, listProviderStatuses } from './providers/status.js';
|
|
9
9
|
import { normalizeDomain, normalizeSubdomain } from './validate.js';
|
|
10
10
|
import { createVercelClient, resolveVercelConfig, VERCEL_APEX_A_RECORD, VERCEL_CNAME_RECORD } from './vercel.js';
|
|
11
11
|
function cleanDnsValue(value) {
|
|
@@ -115,6 +115,15 @@ async function resolveZone(provider, zoneDomain) {
|
|
|
115
115
|
}
|
|
116
116
|
return zone;
|
|
117
117
|
}
|
|
118
|
+
async function providerConnectionDetails() {
|
|
119
|
+
return (await listProviderStatuses({ verify: false })).map((provider) => ({
|
|
120
|
+
configured: provider.configured,
|
|
121
|
+
default: provider.default,
|
|
122
|
+
displayName: provider.displayName,
|
|
123
|
+
docsUrl: provider.docsUrl,
|
|
124
|
+
id: provider.id,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
118
127
|
function resolveRequestedDomain(opts) {
|
|
119
128
|
if (opts.apex && opts.subdomain) {
|
|
120
129
|
throw new DoomainError('INVALID_INPUT', 'Use either --apex or --subdomain, not both.');
|
|
@@ -168,7 +177,11 @@ async function loadConfiguredProviderZones(providerId) {
|
|
|
168
177
|
const config = await loadConfig();
|
|
169
178
|
const definitions = listProviderDefinitions().filter((definition) => isProviderConfigured(definition, config));
|
|
170
179
|
if (definitions.length === 0) {
|
|
171
|
-
throw new DoomainError('CONFIG_NOT_FOUND', 'No DNS provider is configured. Run `doomain providers connect` first.'
|
|
180
|
+
throw new DoomainError('CONFIG_NOT_FOUND', 'No DNS provider is configured. Run `doomain providers connect` first.', {
|
|
181
|
+
configuredProviders: await providerConnectionDetails(),
|
|
182
|
+
recovery: 'Connect the DNS provider that owns this domain, then retry `doomain link <domain> --json`.',
|
|
183
|
+
suggestedCommands: ['doomain providers connect', 'doomain link <domain> --json'],
|
|
184
|
+
});
|
|
172
185
|
}
|
|
173
186
|
const results = await Promise.all(definitions.map(async (definition) => {
|
|
174
187
|
try {
|
|
@@ -206,7 +219,13 @@ async function resolveProviderTarget(input) {
|
|
|
206
219
|
const providerMessage = input.provider
|
|
207
220
|
? `${getProviderDefinition(input.provider).displayName} does not have a matching DNS zone for ${requested.fullDomain}.`
|
|
208
221
|
: `No configured DNS provider has a matching DNS zone for ${requested.fullDomain}.`;
|
|
209
|
-
throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', providerMessage, {
|
|
222
|
+
throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', providerMessage, {
|
|
223
|
+
configuredProviders: await providerConnectionDetails(),
|
|
224
|
+
domain: requested.fullDomain,
|
|
225
|
+
recovery: 'Retry with --provider <id> only if another configured provider owns this zone. Otherwise connect the DNS provider that owns this domain.',
|
|
226
|
+
searchedZones: zones.searched,
|
|
227
|
+
suggestedCommands: [`doomain link ${requested.fullDomain} --provider <id> --json`, 'doomain providers connect'],
|
|
228
|
+
});
|
|
210
229
|
}
|
|
211
230
|
const bestLength = matches[0].zone.name.length;
|
|
212
231
|
const bestMatches = matches.filter((candidate) => candidate.zone.name.length === bestLength);
|
|
@@ -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
|
@@ -301,41 +301,6 @@
|
|
|
301
301
|
"list.js"
|
|
302
302
|
]
|
|
303
303
|
},
|
|
304
|
-
"projects:list": {
|
|
305
|
-
"aliases": [],
|
|
306
|
-
"args": {},
|
|
307
|
-
"description": "List Vercel projects.",
|
|
308
|
-
"flags": {
|
|
309
|
-
"json": {
|
|
310
|
-
"description": "Output a single JSON object and never prompt.",
|
|
311
|
-
"name": "json",
|
|
312
|
-
"allowNo": false,
|
|
313
|
-
"type": "boolean"
|
|
314
|
-
},
|
|
315
|
-
"search": {
|
|
316
|
-
"description": "Filter projects by search term.",
|
|
317
|
-
"name": "search",
|
|
318
|
-
"hasDynamicHelp": false,
|
|
319
|
-
"multiple": false,
|
|
320
|
-
"type": "option"
|
|
321
|
-
}
|
|
322
|
-
},
|
|
323
|
-
"hasDynamicHelp": false,
|
|
324
|
-
"hiddenAliases": [],
|
|
325
|
-
"id": "projects:list",
|
|
326
|
-
"pluginAlias": "doomain",
|
|
327
|
-
"pluginName": "doomain",
|
|
328
|
-
"pluginType": "core",
|
|
329
|
-
"strict": true,
|
|
330
|
-
"enableJsonFlag": false,
|
|
331
|
-
"isESM": true,
|
|
332
|
-
"relativePath": [
|
|
333
|
-
"dist",
|
|
334
|
-
"commands",
|
|
335
|
-
"projects",
|
|
336
|
-
"list.js"
|
|
337
|
-
]
|
|
338
|
-
},
|
|
339
304
|
"providers:add": {
|
|
340
305
|
"aliases": [],
|
|
341
306
|
"args": {
|
|
@@ -596,6 +561,41 @@
|
|
|
596
561
|
"verify.js"
|
|
597
562
|
]
|
|
598
563
|
},
|
|
564
|
+
"projects:list": {
|
|
565
|
+
"aliases": [],
|
|
566
|
+
"args": {},
|
|
567
|
+
"description": "List Vercel projects.",
|
|
568
|
+
"flags": {
|
|
569
|
+
"json": {
|
|
570
|
+
"description": "Output a single JSON object and never prompt.",
|
|
571
|
+
"name": "json",
|
|
572
|
+
"allowNo": false,
|
|
573
|
+
"type": "boolean"
|
|
574
|
+
},
|
|
575
|
+
"search": {
|
|
576
|
+
"description": "Filter projects by search term.",
|
|
577
|
+
"name": "search",
|
|
578
|
+
"hasDynamicHelp": false,
|
|
579
|
+
"multiple": false,
|
|
580
|
+
"type": "option"
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
"hasDynamicHelp": false,
|
|
584
|
+
"hiddenAliases": [],
|
|
585
|
+
"id": "projects:list",
|
|
586
|
+
"pluginAlias": "doomain",
|
|
587
|
+
"pluginName": "doomain",
|
|
588
|
+
"pluginType": "core",
|
|
589
|
+
"strict": true,
|
|
590
|
+
"enableJsonFlag": false,
|
|
591
|
+
"isESM": true,
|
|
592
|
+
"relativePath": [
|
|
593
|
+
"dist",
|
|
594
|
+
"commands",
|
|
595
|
+
"projects",
|
|
596
|
+
"list.js"
|
|
597
|
+
]
|
|
598
|
+
},
|
|
599
599
|
"auth:logout:vercel": {
|
|
600
600
|
"aliases": [],
|
|
601
601
|
"args": {},
|
|
@@ -630,5 +630,5 @@
|
|
|
630
630
|
]
|
|
631
631
|
}
|
|
632
632
|
},
|
|
633
|
-
"version": "0.1.
|
|
633
|
+
"version": "0.1.9"
|
|
634
634
|
}
|