doomain 0.1.16 → 0.1.18

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
@@ -337,6 +337,7 @@ Useful agent-safe commands:
337
337
  doomain link app.example.com --project my-app --json
338
338
  doomain providers list --json
339
339
  doomain providers status --no-verify --json
340
+ doomain domains find hacktheandes.com --json
340
341
  doomain domains list --provider cloudflare --domain example.com --json
341
342
  doomain projects list --search my-app --json
342
343
  doomain clerk domains add example.com --app app_123 --json
@@ -351,6 +352,19 @@ doomain schema --json
351
352
  doomain schema "providers connect" --json
352
353
  ```
353
354
 
355
+ ## Programmatic API
356
+
357
+ Use `findDomainProvider` to perform the same discovery from TypeScript or JavaScript:
358
+
359
+ ```ts
360
+ import {findDomainProvider} from 'doomain'
361
+
362
+ const match = await findDomainProvider({domain: 'api.hacktheandes.com'})
363
+ console.log(match.provider, match.account, match.zoneDomain)
364
+ ```
365
+
366
+ The API checks configured provider accounts, tolerates failures from individual providers, and returns the longest matching DNS zone. Pass `provider` or `account` to constrain the search. When a provider cannot be checked, `complete` is `false` and `warnings` identifies the affected provider account.
367
+
354
368
  ## Command Reference
355
369
 
356
370
  Run `doomain help <command>` for oclif-generated help.
@@ -497,6 +511,18 @@ doomain providers logout namecheap --json
497
511
 
498
512
  Environment variables for that provider still override local config after disconnect.
499
513
 
514
+ ### `doomain domains find [domain]`
515
+
516
+ Finds the configured DNS provider account with the longest matching zone. It checks all configured accounts and continues when an individual provider fails, so one expired credential does not hide a match from another provider.
517
+
518
+ ```bash
519
+ doomain domains find hacktheandes.com --json
520
+ doomain domains find api.example.com --json
521
+ doomain domains find --domain example.com --provider spaceship --account personal --json
522
+ ```
523
+
524
+ Successful JSON includes the provider, account, matching zone, and relative DNS record name. Check `complete` and `warnings` before treating the result as exhaustive; for example, an expired token may prevent one provider from participating in discovery.
525
+
500
526
  ### `doomain domains list`
501
527
 
502
528
  Lists DNS zones and records.
package/bin/dev.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
2
2
 
3
- import {execute} from '@oclif/core'
3
+ import { execute } from '@oclif/core'
4
4
 
5
- await execute({development: true, dir: import.meta.url})
5
+ await execute({ development: true, dir: import.meta.url })
package/bin/run.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import {execute} from '@oclif/core'
3
+ import { execute } from '@oclif/core'
4
4
 
5
5
  const args = process.argv.slice(2)
6
6
  const routedArgs = args.length === 0 || (args.length === 1 && args[0] === '--json') ? ['wizard', ...args] : args
7
7
 
8
- await execute({args: routedArgs, dir: import.meta.url})
8
+ await execute({ args: routedArgs, dir: import.meta.url })
@@ -0,0 +1,15 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class DomainsFind extends Command {
3
+ static args: {
4
+ domain: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ account: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ provider: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<void>;
15
+ }
@@ -0,0 +1,43 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { findDomainProvider } from '../../lib/domain-provider.js';
3
+ import { DoomainError } from '../../lib/errors.js';
4
+ import { accountFlag, domainFlag, jsonFlag, providerFlag } from '../../lib/flags.js';
5
+ import { createOutput, outputError } from '../../lib/output.js';
6
+ export default class DomainsFind extends Command {
7
+ static args = {
8
+ domain: Args.string({ description: 'Domain whose DNS provider should be found.', required: false }),
9
+ };
10
+ static description = 'Find the configured DNS provider and account for a domain.';
11
+ static examples = [
12
+ '<%= config.bin %> <%= command.id %> example.com --json',
13
+ '<%= config.bin %> <%= command.id %> api.example.com --json',
14
+ '<%= config.bin %> <%= command.id %> --domain example.com --provider spaceship --json',
15
+ ];
16
+ static flags = {
17
+ account: accountFlag,
18
+ domain: domainFlag,
19
+ json: jsonFlag,
20
+ provider: providerFlag,
21
+ };
22
+ async run() {
23
+ const { args, flags } = await this.parse(DomainsFind);
24
+ const out = createOutput({ json: flags.json });
25
+ try {
26
+ const domain = flags.domain ?? args.domain;
27
+ if (!domain)
28
+ throw new DoomainError('MISSING_ARGUMENT', 'Domain is required. Pass it as an argument or use --domain.');
29
+ const result = await findDomainProvider({ account: flags.account, domain, provider: flags.provider });
30
+ const account = result.isDefaultAccount ? result.provider : `${result.provider}/${result.account}`;
31
+ out.info(`${result.domain} is managed by ${account} in DNS zone ${result.zoneDomain}.`);
32
+ for (const warning of result.warnings) {
33
+ const warningAccount = warning.isDefaultAccount ? warning.provider : `${warning.provider}/${warning.account}`;
34
+ out.warn(`${warningAccount} could not be checked: ${warning.error.message}`);
35
+ }
36
+ out.result(result);
37
+ }
38
+ catch (error) {
39
+ outputError(out.json, error, 'DOMAIN_PROVIDER_DISCOVERY_FAILED');
40
+ this.exit(1);
41
+ }
42
+ }
43
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { run } from '@oclif/core';
2
+ export { findDomainProvider, type DomainProviderResult, type FindDomainProviderInput, type ProviderSearchWarning, } from './lib/domain-provider.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { run } from '@oclif/core';
2
+ export { findDomainProvider, } from './lib/domain-provider.js';
@@ -1,6 +1,7 @@
1
1
  import { createClerkPlatformClient, resolveClerkPlatformConfig } from './clerk.js';
2
+ import { resolveProviderTarget } from './domain-provider.js';
2
3
  import { DoomainError } from './errors.js';
3
- import { resolveProviderTarget, withProviderRecordOptions } from './link-domain.js';
4
+ import { withProviderRecordOptions } from './link-domain.js';
4
5
  import { createProvider } from './providers/registry.js';
5
6
  import { normalizeDomain } from './validate.js';
6
7
  function relativeRecordName(host, zoneDomain) {
@@ -195,6 +195,23 @@ export const commandSchemas = [
195
195
  { name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
196
196
  ],
197
197
  },
198
+ {
199
+ name: 'domains find',
200
+ description: 'Find the configured DNS provider and account for a domain.',
201
+ examples: [
202
+ 'doomain domains find hacktheandes.com --json',
203
+ 'doomain domains find api.example.com --json',
204
+ 'doomain domains find --domain example.com --provider spaceship --account work --json',
205
+ ],
206
+ agentHint: 'Use this command when you need to identify who manages DNS for a domain. It checks all configured provider accounts, tolerates individual provider failures, and selects the longest matching DNS zone. Inspect complete and warnings before treating the result as exhaustive.',
207
+ safeForAgents: true,
208
+ flags: [
209
+ { name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
210
+ { name: 'domain', type: 'string', description: 'Domain to find. May also be passed as the positional argument.' },
211
+ { name: 'provider', type: 'string', description: 'Limit discovery to one DNS provider id.' },
212
+ { name: 'account', type: 'string', description: 'Limit discovery to one DNS provider profile/account alias.' },
213
+ ],
214
+ },
198
215
  {
199
216
  name: 'domains list',
200
217
  description: 'List DNS zones and records for a provider.',
@@ -0,0 +1,53 @@
1
+ import { type DoomainErrorCode } from './errors.js';
2
+ export interface FindDomainProviderInput {
3
+ account?: string;
4
+ domain: string;
5
+ provider?: string;
6
+ }
7
+ export interface ProviderSearchWarning {
8
+ account: string;
9
+ error: {
10
+ code?: DoomainErrorCode;
11
+ message: string;
12
+ };
13
+ isDefaultAccount: boolean;
14
+ provider: string;
15
+ providerName: string;
16
+ }
17
+ export interface DomainProviderResult {
18
+ account: string;
19
+ accountInferred: boolean;
20
+ complete: boolean;
21
+ domain: string;
22
+ isApex: boolean;
23
+ isDefaultAccount: boolean;
24
+ provider: string;
25
+ providerInferred: boolean;
26
+ recordName: string;
27
+ warnings: ProviderSearchWarning[];
28
+ zoneDomain: string;
29
+ }
30
+ export interface ResolveProviderTargetInput extends FindDomainProviderInput {
31
+ apex?: boolean;
32
+ subdomain?: string;
33
+ }
34
+ export interface ResolveProviderTargetOptions {
35
+ tolerateProviderAccountErrors?: boolean;
36
+ }
37
+ export interface ResolvedDnsTarget {
38
+ account: string;
39
+ accountInferred: boolean;
40
+ isDefaultAccount: boolean;
41
+ provider: string;
42
+ providerInferred: boolean;
43
+ target: {
44
+ fullDomain: string;
45
+ isApex: boolean;
46
+ recordName: string;
47
+ zoneDomain: string;
48
+ };
49
+ warnings: ProviderSearchWarning[];
50
+ }
51
+ export declare function resolveProviderTarget(input: ResolveProviderTargetInput, options?: ResolveProviderTargetOptions): Promise<ResolvedDnsTarget>;
52
+ /** Find the configured DNS provider account with the longest zone match for a domain. */
53
+ export declare function findDomainProvider(input: FindDomainProviderInput): Promise<DomainProviderResult>;
@@ -0,0 +1,230 @@
1
+ import { loadConfig } from './config.js';
2
+ import { DoomainError } from './errors.js';
3
+ import { DEFAULT_PROVIDER_ACCOUNT, isDefaultProviderAccount, listConfiguredProviderAccounts, normalizeProviderAccount, } from './providers/core/config.js';
4
+ import { createProvider, getProviderDefinition, listProviderDefinitions } from './providers/registry.js';
5
+ import { listProviderStatuses } from './providers/status.js';
6
+ import { normalizeDomain, normalizeSubdomain } from './validate.js';
7
+ function resolveRequestedDomain(opts) {
8
+ if (opts.apex && opts.subdomain) {
9
+ throw new DoomainError('INVALID_INPUT', 'Use either --apex or --subdomain, not both.');
10
+ }
11
+ const domain = normalizeDomain(opts.domain);
12
+ if (opts.apex)
13
+ return { forceExactZone: true, fullDomain: domain };
14
+ if (!opts.subdomain)
15
+ return { forceExactZone: false, fullDomain: domain };
16
+ return { forceExactZone: false, fullDomain: `${normalizeSubdomain(opts.subdomain)}.${domain}` };
17
+ }
18
+ function zoneMatchesDomain(fullDomain, zoneDomain, forceExactZone) {
19
+ if (fullDomain === zoneDomain)
20
+ return true;
21
+ if (forceExactZone)
22
+ return false;
23
+ return fullDomain.endsWith(`.${zoneDomain}`);
24
+ }
25
+ function targetFromZone(fullDomain, zoneDomain) {
26
+ if (fullDomain === zoneDomain) {
27
+ return { fullDomain, isApex: true, recordName: '@', zoneDomain };
28
+ }
29
+ return {
30
+ fullDomain,
31
+ isApex: false,
32
+ recordName: fullDomain.slice(0, -(zoneDomain.length + 1)),
33
+ zoneDomain,
34
+ };
35
+ }
36
+ function candidateDetails(candidates) {
37
+ return candidates.map((candidate) => ({
38
+ account: candidate.account,
39
+ isDefaultAccount: candidate.isDefaultAccount,
40
+ provider: candidate.provider,
41
+ providerName: candidate.providerName,
42
+ zoneDomain: candidate.zone.name,
43
+ }));
44
+ }
45
+ function defaultAccountRef(providerId) {
46
+ return { account: DEFAULT_PROVIDER_ACCOUNT, isDefaultAccount: true, providerId };
47
+ }
48
+ function explicitAccountRef(providerId, account) {
49
+ const normalized = normalizeProviderAccount(account);
50
+ return { account: normalized, isDefaultAccount: isDefaultProviderAccount(normalized), providerId };
51
+ }
52
+ function searchError(error) {
53
+ return {
54
+ ...(error instanceof DoomainError ? { code: error.code } : {}),
55
+ message: error instanceof Error ? error.message : String(error),
56
+ };
57
+ }
58
+ async function loadProviderZones(definition, account) {
59
+ const provider = await createProvider(definition.id, { account: account.account });
60
+ const zones = await provider.listZones();
61
+ return {
62
+ candidates: zones.map((zone) => ({
63
+ account: account.account,
64
+ isDefaultAccount: account.isDefaultAccount,
65
+ provider: definition.id,
66
+ providerName: definition.displayName,
67
+ zone,
68
+ })),
69
+ search: {
70
+ account: account.account,
71
+ displayName: definition.displayName,
72
+ id: definition.id,
73
+ isDefaultAccount: account.isDefaultAccount,
74
+ zones: zones.map((zone) => zone.name),
75
+ },
76
+ };
77
+ }
78
+ async function loadProviderZonesSafely(definition, account) {
79
+ try {
80
+ return await loadProviderZones(definition, account);
81
+ }
82
+ catch (error) {
83
+ return {
84
+ candidates: [],
85
+ search: {
86
+ account: account.account,
87
+ displayName: definition.displayName,
88
+ error: searchError(error),
89
+ id: definition.id,
90
+ isDefaultAccount: account.isDefaultAccount,
91
+ zones: [],
92
+ },
93
+ };
94
+ }
95
+ }
96
+ async function providerConnectionDetails() {
97
+ return (await listProviderStatuses({ verify: false })).map((provider) => ({
98
+ configured: provider.configured,
99
+ account: provider.account,
100
+ default: provider.default,
101
+ displayName: provider.displayName,
102
+ docsUrl: provider.docsUrl,
103
+ id: provider.id,
104
+ isDefaultAccount: provider.isDefaultAccount,
105
+ }));
106
+ }
107
+ function searchWarnings(searches) {
108
+ return searches.flatMap((search) => search.error
109
+ ? [
110
+ {
111
+ account: search.account,
112
+ error: search.error,
113
+ isDefaultAccount: search.isDefaultAccount,
114
+ provider: search.id,
115
+ providerName: search.displayName,
116
+ },
117
+ ]
118
+ : []);
119
+ }
120
+ async function loadConfiguredProviderZones(providerId, accountInput, tolerateProviderAccountErrors = false) {
121
+ const config = await loadConfig();
122
+ const account = accountInput ? normalizeProviderAccount(accountInput) : undefined;
123
+ if (providerId) {
124
+ const definition = getProviderDefinition(providerId);
125
+ const accounts = account ? [explicitAccountRef(definition.id, account)] : listConfiguredProviderAccounts(config, definition);
126
+ const selectedAccounts = accounts.length > 0 ? accounts : [defaultAccountRef(definition.id)];
127
+ const tolerateAccountErrors = tolerateProviderAccountErrors && !account && selectedAccounts.length > 1;
128
+ const results = await Promise.all(selectedAccounts.map((ref) => tolerateAccountErrors ? loadProviderZonesSafely(definition, ref) : loadProviderZones(definition, ref)));
129
+ return {
130
+ accountInferred: account === undefined,
131
+ candidates: results.flatMap((result) => result.candidates),
132
+ providerInferred: false,
133
+ searched: results.map((result) => result.search),
134
+ };
135
+ }
136
+ const providerAccounts = listProviderDefinitions().flatMap((definition) => listConfiguredProviderAccounts(config, definition)
137
+ .filter((ref) => !account || ref.account === account)
138
+ .map((ref) => ({ definition, ref })));
139
+ if (providerAccounts.length === 0) {
140
+ const message = account
141
+ ? `No DNS provider account named ${account} is configured. Run \`doomain providers connect <provider> --account ${account}\` first.`
142
+ : 'No DNS provider is configured. Run `doomain providers connect` first.';
143
+ throw new DoomainError('CONFIG_NOT_FOUND', message, {
144
+ account,
145
+ configuredProviders: await providerConnectionDetails(),
146
+ recovery: 'Connect the DNS provider that owns this domain, then retry `doomain link <domain> --json`.',
147
+ suggestedCommands: account
148
+ ? [`doomain providers connect <provider> --account ${account}`, 'doomain link <domain> --json']
149
+ : ['doomain providers connect', 'doomain link <domain> --json'],
150
+ });
151
+ }
152
+ const results = await Promise.all(providerAccounts.map(({ definition, ref }) => loadProviderZonesSafely(definition, ref)));
153
+ return {
154
+ accountInferred: account === undefined,
155
+ candidates: results.flatMap((result) => result.candidates),
156
+ providerInferred: true,
157
+ searched: results.map((result) => result.search),
158
+ };
159
+ }
160
+ export async function resolveProviderTarget(input, options = {}) {
161
+ const requested = resolveRequestedDomain(input);
162
+ const zones = await loadConfiguredProviderZones(input.provider, input.account, options.tolerateProviderAccountErrors);
163
+ const matches = zones.candidates
164
+ .filter((candidate) => zoneMatchesDomain(requested.fullDomain, candidate.zone.name, requested.forceExactZone))
165
+ .sort((a, b) => b.zone.name.length - a.zone.name.length);
166
+ if (matches.length === 0) {
167
+ const account = input.account ? normalizeProviderAccount(input.account) : undefined;
168
+ const providerMessage = input.provider
169
+ ? `${getProviderDefinition(input.provider).displayName}${account ? ` account ${account}` : ''} does not have a matching DNS zone for ${requested.fullDomain}.`
170
+ : `No configured DNS provider has a matching DNS zone for ${requested.fullDomain}.`;
171
+ throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', providerMessage, {
172
+ account,
173
+ configuredProviders: await providerConnectionDetails(),
174
+ domain: requested.fullDomain,
175
+ recovery: 'Retry with --provider <id> --account <alias> only if another configured provider account owns this zone. Otherwise connect the DNS provider account that owns this domain.',
176
+ searchedZones: zones.searched,
177
+ suggestedCommands: [`doomain link ${requested.fullDomain} --provider <id> --account <alias> --json`, 'doomain providers connect'],
178
+ });
179
+ }
180
+ const bestLength = matches[0].zone.name.length;
181
+ const bestMatches = matches.filter((candidate) => candidate.zone.name.length === bestLength);
182
+ const uniqueBestMatches = bestMatches.filter((candidate, index, candidates) => candidates.findIndex((item) => item.provider === candidate.provider && item.account === candidate.account && item.zone.name === candidate.zone.name) === index);
183
+ if (uniqueBestMatches.length > 1) {
184
+ throw new DoomainError('PROVIDER_ZONE_AMBIGUOUS', `Multiple DNS provider accounts have a matching DNS zone for ${requested.fullDomain}. Pass --provider and --account to choose one.`, { candidates: candidateDetails(uniqueBestMatches), domain: requested.fullDomain });
185
+ }
186
+ const selected = uniqueBestMatches[0];
187
+ return {
188
+ account: selected.account,
189
+ accountInferred: zones.accountInferred,
190
+ isDefaultAccount: selected.isDefaultAccount,
191
+ provider: selected.provider,
192
+ providerInferred: zones.providerInferred,
193
+ target: targetFromZone(requested.fullDomain, selected.zone.name),
194
+ warnings: searchWarnings(zones.searched),
195
+ };
196
+ }
197
+ function discoveryError(error, domain) {
198
+ if (error.code !== 'CONFIG_NOT_FOUND' && error.code !== 'PROVIDER_ZONE_NOT_FOUND')
199
+ return error;
200
+ const details = error.details && typeof error.details === 'object' ? error.details : {};
201
+ return new DoomainError(error.code, error.message, {
202
+ ...details,
203
+ recovery: `Connect or repair the DNS provider account that owns this domain, then retry \`doomain domains find ${domain} --json\`.`,
204
+ suggestedCommands: ['doomain providers connect', `doomain domains find ${domain} --json`],
205
+ });
206
+ }
207
+ /** Find the configured DNS provider account with the longest zone match for a domain. */
208
+ export async function findDomainProvider(input) {
209
+ try {
210
+ const resolved = await resolveProviderTarget(input, { tolerateProviderAccountErrors: true });
211
+ return {
212
+ account: resolved.account,
213
+ accountInferred: resolved.accountInferred,
214
+ complete: resolved.warnings.length === 0,
215
+ domain: resolved.target.fullDomain,
216
+ isApex: resolved.target.isApex,
217
+ isDefaultAccount: resolved.isDefaultAccount,
218
+ provider: resolved.provider,
219
+ providerInferred: resolved.providerInferred,
220
+ recordName: resolved.target.recordName,
221
+ warnings: resolved.warnings,
222
+ zoneDomain: resolved.target.zoneDomain,
223
+ };
224
+ }
225
+ catch (error) {
226
+ if (error instanceof DoomainError)
227
+ throw discoveryError(error, input.domain);
228
+ throw error;
229
+ }
230
+ }
@@ -1,4 +1,4 @@
1
- export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
1
+ export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_PROVIDER_DISCOVERY_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
2
2
  export declare class DoomainError extends Error {
3
3
  readonly code: DoomainErrorCode;
4
4
  readonly details?: unknown;
@@ -59,20 +59,6 @@ export interface LinkDomainResult extends LinkDomainPlan {
59
59
  verified: boolean;
60
60
  };
61
61
  }
62
- export interface ResolvedDnsTarget {
63
- provider: string;
64
- providerInferred: boolean;
65
- account: string;
66
- accountInferred: boolean;
67
- isDefaultAccount: boolean;
68
- target: {
69
- fullDomain: string;
70
- isApex: boolean;
71
- recordName: string;
72
- zoneDomain: string;
73
- };
74
- }
75
- export declare function resolveProviderTarget(input: Pick<LinkDomainInput, 'account' | 'apex' | 'domain' | 'provider' | 'subdomain'>): Promise<ResolvedDnsTarget>;
76
62
  export declare function withProviderRecordOptions(provider: string, record: DnsRecordInput): DnsRecordInput;
77
63
  export declare function verificationRecords(raw: unknown, zoneDomain: string): DnsRecordInput[];
78
64
  export declare function createLinkPlan(input: LinkDomainInput): Promise<LinkDomainPlan>;
@@ -2,12 +2,10 @@ import { resolve4, resolveCname, resolveTxt } from 'node:dns/promises';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { dirname, join, parse } from 'node:path';
4
4
  import { loadConfig } from './config.js';
5
+ import { resolveProviderTarget } from './domain-provider.js';
5
6
  import { DoomainError } from './errors.js';
6
7
  import { detectLocalVercelProject } from './local-vercel.js';
7
- import { DEFAULT_PROVIDER_ACCOUNT, isDefaultProviderAccount, listConfiguredProviderAccounts, normalizeProviderAccount, } from './providers/core/config.js';
8
- import { createProvider, getProviderDefinition, listProviderDefinitions } from './providers/registry.js';
9
- import { listProviderStatuses } from './providers/status.js';
10
- import { normalizeDomain, normalizeSubdomain } from './validate.js';
8
+ import { createProvider } from './providers/registry.js';
11
9
  import { createVercelClient, resolveVercelConfig, VERCEL_APEX_A_RECORD, VERCEL_CNAME_RECORD } from './vercel.js';
12
10
  function cleanDnsValue(value) {
13
11
  return value.toLowerCase().replace(/\.$/, '');
@@ -116,178 +114,6 @@ async function resolveZone(provider, zoneDomain) {
116
114
  }
117
115
  return zone;
118
116
  }
119
- async function providerConnectionDetails() {
120
- return (await listProviderStatuses({ verify: false })).map((provider) => ({
121
- configured: provider.configured,
122
- account: provider.account,
123
- default: provider.default,
124
- displayName: provider.displayName,
125
- docsUrl: provider.docsUrl,
126
- id: provider.id,
127
- isDefaultAccount: provider.isDefaultAccount,
128
- }));
129
- }
130
- function resolveRequestedDomain(opts) {
131
- if (opts.apex && opts.subdomain) {
132
- throw new DoomainError('INVALID_INPUT', 'Use either --apex or --subdomain, not both.');
133
- }
134
- const domain = normalizeDomain(opts.domain);
135
- if (opts.apex)
136
- return { forceExactZone: true, fullDomain: domain };
137
- if (!opts.subdomain)
138
- return { forceExactZone: false, fullDomain: domain };
139
- return { forceExactZone: false, fullDomain: `${normalizeSubdomain(opts.subdomain)}.${domain}` };
140
- }
141
- function zoneMatchesDomain(fullDomain, zoneDomain, forceExactZone) {
142
- if (fullDomain === zoneDomain)
143
- return true;
144
- if (forceExactZone)
145
- return false;
146
- return fullDomain.endsWith(`.${zoneDomain}`);
147
- }
148
- function targetFromZone(fullDomain, zoneDomain) {
149
- if (fullDomain === zoneDomain) {
150
- return { fullDomain, isApex: true, recordName: '@', zoneDomain };
151
- }
152
- return {
153
- fullDomain,
154
- isApex: false,
155
- recordName: fullDomain.slice(0, -(zoneDomain.length + 1)),
156
- zoneDomain,
157
- };
158
- }
159
- function candidateDetails(candidates) {
160
- return candidates.map((candidate) => ({
161
- account: candidate.account,
162
- isDefaultAccount: candidate.isDefaultAccount,
163
- provider: candidate.provider,
164
- providerName: candidate.providerName,
165
- zoneDomain: candidate.zone.name,
166
- }));
167
- }
168
- function defaultAccountRef(providerId) {
169
- return { account: DEFAULT_PROVIDER_ACCOUNT, isDefaultAccount: true, providerId };
170
- }
171
- function explicitAccountRef(providerId, account) {
172
- const normalized = normalizeProviderAccount(account);
173
- return { account: normalized, isDefaultAccount: isDefaultProviderAccount(normalized), providerId };
174
- }
175
- async function loadProviderZones(definition, account) {
176
- const provider = await createProvider(definition.id, { account: account.account });
177
- const zones = await provider.listZones();
178
- return {
179
- candidates: zones.map((zone) => ({
180
- account: account.account,
181
- isDefaultAccount: account.isDefaultAccount,
182
- provider: definition.id,
183
- providerName: definition.displayName,
184
- zone,
185
- })),
186
- search: {
187
- account: account.account,
188
- displayName: definition.displayName,
189
- id: definition.id,
190
- isDefaultAccount: account.isDefaultAccount,
191
- zones: zones.map((zone) => zone.name),
192
- },
193
- };
194
- }
195
- async function loadConfiguredProviderZones(providerId, accountInput) {
196
- const config = await loadConfig();
197
- const account = accountInput ? normalizeProviderAccount(accountInput) : undefined;
198
- if (providerId) {
199
- const definition = getProviderDefinition(providerId);
200
- const accounts = account ? [explicitAccountRef(definition.id, account)] : listConfiguredProviderAccounts(config, definition);
201
- const selectedAccounts = accounts.length > 0 ? accounts : [defaultAccountRef(definition.id)];
202
- const results = await Promise.all(selectedAccounts.map((ref) => loadProviderZones(definition, ref)));
203
- return {
204
- accountInferred: account === undefined,
205
- candidates: results.flatMap((result) => result.candidates),
206
- providerInferred: false,
207
- searched: results.map((result) => result.search),
208
- };
209
- }
210
- const providerAccounts = listProviderDefinitions().flatMap((definition) => listConfiguredProviderAccounts(config, definition)
211
- .filter((ref) => !account || ref.account === account)
212
- .map((ref) => ({ definition, ref })));
213
- if (providerAccounts.length === 0) {
214
- const message = account
215
- ? `No DNS provider account named ${account} is configured. Run \`doomain providers connect <provider> --account ${account}\` first.`
216
- : 'No DNS provider is configured. Run `doomain providers connect` first.';
217
- throw new DoomainError('CONFIG_NOT_FOUND', message, {
218
- account,
219
- configuredProviders: await providerConnectionDetails(),
220
- recovery: 'Connect the DNS provider that owns this domain, then retry `doomain link <domain> --json`.',
221
- suggestedCommands: account
222
- ? [`doomain providers connect <provider> --account ${account}`, 'doomain link <domain> --json']
223
- : ['doomain providers connect', 'doomain link <domain> --json'],
224
- });
225
- }
226
- const results = await Promise.all(providerAccounts.map(async ({ definition, ref }) => {
227
- try {
228
- return await loadProviderZones(definition, ref);
229
- }
230
- catch (error) {
231
- return {
232
- candidates: [],
233
- search: {
234
- account: ref.account,
235
- displayName: definition.displayName,
236
- error: error instanceof Error ? error.message : String(error),
237
- id: definition.id,
238
- isDefaultAccount: ref.isDefaultAccount,
239
- zones: [],
240
- },
241
- };
242
- }
243
- }));
244
- return {
245
- accountInferred: account === undefined,
246
- candidates: results.flatMap((result) => result.candidates),
247
- providerInferred: true,
248
- searched: results.map((result) => result.search),
249
- };
250
- }
251
- export async function resolveProviderTarget(input) {
252
- const requested = resolveRequestedDomain({
253
- apex: input.apex,
254
- domain: await resolveConfiguredDomain(input.domain),
255
- subdomain: input.subdomain,
256
- });
257
- const zones = await loadConfiguredProviderZones(input.provider, input.account);
258
- const matches = zones.candidates
259
- .filter((candidate) => zoneMatchesDomain(requested.fullDomain, candidate.zone.name, requested.forceExactZone))
260
- .sort((a, b) => b.zone.name.length - a.zone.name.length);
261
- if (matches.length === 0) {
262
- const account = input.account ? normalizeProviderAccount(input.account) : undefined;
263
- const providerMessage = input.provider
264
- ? `${getProviderDefinition(input.provider).displayName}${account ? ` account ${account}` : ''} does not have a matching DNS zone for ${requested.fullDomain}.`
265
- : `No configured DNS provider has a matching DNS zone for ${requested.fullDomain}.`;
266
- throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', providerMessage, {
267
- account,
268
- configuredProviders: await providerConnectionDetails(),
269
- domain: requested.fullDomain,
270
- recovery: 'Retry with --provider <id> --account <alias> only if another configured provider account owns this zone. Otherwise connect the DNS provider account that owns this domain.',
271
- searchedZones: zones.searched,
272
- suggestedCommands: [`doomain link ${requested.fullDomain} --provider <id> --account <alias> --json`, 'doomain providers connect'],
273
- });
274
- }
275
- const bestLength = matches[0].zone.name.length;
276
- const bestMatches = matches.filter((candidate) => candidate.zone.name.length === bestLength);
277
- const uniqueBestMatches = bestMatches.filter((candidate, index, candidates) => candidates.findIndex((item) => item.provider === candidate.provider && item.account === candidate.account && item.zone.name === candidate.zone.name) === index);
278
- if (uniqueBestMatches.length > 1) {
279
- throw new DoomainError('PROVIDER_ZONE_AMBIGUOUS', `Multiple DNS provider accounts have a matching DNS zone for ${requested.fullDomain}. Pass --provider and --account to choose one.`, { candidates: candidateDetails(uniqueBestMatches), domain: requested.fullDomain });
280
- }
281
- const selected = uniqueBestMatches[0];
282
- return {
283
- account: selected.account,
284
- accountInferred: zones.accountInferred,
285
- isDefaultAccount: selected.isDefaultAccount,
286
- provider: selected.provider,
287
- providerInferred: zones.providerInferred,
288
- target: targetFromZone(requested.fullDomain, selected.zone.name),
289
- };
290
- }
291
117
  export function withProviderRecordOptions(provider, record) {
292
118
  if (provider !== 'cloudflare' || !['A', 'AAAA', 'CNAME'].includes(record.type))
293
119
  return record;
@@ -312,6 +312,66 @@
312
312
  "vercel.js"
313
313
  ]
314
314
  },
315
+ "domains:find": {
316
+ "aliases": [],
317
+ "args": {
318
+ "domain": {
319
+ "description": "Domain whose DNS provider should be found.",
320
+ "name": "domain",
321
+ "required": false
322
+ }
323
+ },
324
+ "description": "Find the configured DNS provider and account for a domain.",
325
+ "examples": [
326
+ "<%= config.bin %> <%= command.id %> example.com --json",
327
+ "<%= config.bin %> <%= command.id %> api.example.com --json",
328
+ "<%= config.bin %> <%= command.id %> --domain example.com --provider spaceship --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
+ "domain": {
339
+ "description": "Target domain or base zone, for example app.example.com or example.com.",
340
+ "name": "domain",
341
+ "hasDynamicHelp": false,
342
+ "multiple": false,
343
+ "type": "option"
344
+ },
345
+ "json": {
346
+ "description": "Output a single JSON object and never prompt.",
347
+ "name": "json",
348
+ "allowNo": false,
349
+ "type": "boolean"
350
+ },
351
+ "provider": {
352
+ "description": "DNS provider id. Inferred from the target domain when omitted.",
353
+ "name": "provider",
354
+ "hasDynamicHelp": false,
355
+ "multiple": false,
356
+ "type": "option"
357
+ }
358
+ },
359
+ "hasDynamicHelp": false,
360
+ "hiddenAliases": [],
361
+ "id": "domains:find",
362
+ "pluginAlias": "doomain",
363
+ "pluginName": "doomain",
364
+ "pluginType": "core",
365
+ "strict": true,
366
+ "enableJsonFlag": false,
367
+ "isESM": true,
368
+ "relativePath": [
369
+ "dist",
370
+ "commands",
371
+ "domains",
372
+ "find.js"
373
+ ]
374
+ },
315
375
  "domains:list": {
316
376
  "aliases": [],
317
377
  "args": {},
@@ -834,5 +894,5 @@
834
894
  ]
835
895
  }
836
896
  },
837
- "version": "0.1.16"
897
+ "version": "0.1.18"
838
898
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doomain",
3
3
  "description": "Configure Vercel and Clerk production domains in seconds",
4
- "version": "0.1.16",
4
+ "version": "0.1.18",
5
5
  "author": "Crafter Station",
6
6
  "packageManager": "bun@1.3.13",
7
7
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "@types/mocha": "^10",
22
22
  "@types/node": "^18",
23
23
  "chai": "^4",
24
+ "husky": "^9.1.7",
24
25
  "mocha": "^11",
25
26
  "oclif": "^4",
26
27
  "shx": "^0.3.3",
@@ -82,6 +83,7 @@
82
83
  "lint": "biome lint .",
83
84
  "postpack": "shx rm -f oclif.manifest.json",
84
85
  "posttest": "bun run lint",
86
+ "prepare": "husky",
85
87
  "prepack": "oclif manifest && oclif readme",
86
88
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
87
89
  "version": "oclif readme && git add README.md"