doomain 0.1.21 → 0.1.22

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
@@ -13,6 +13,8 @@ Use the interactive wizard when working by hand. Use explicit commands with `--j
13
13
  - Vercel project detection from `.vercel/project.json`.
14
14
  - DNS provider inference by longest matching configured zone.
15
15
  - Generic DNS pointing for VPS, load balancer, and canonical-hostname targets.
16
+ - Exact-match DNS removal with dry-run and post-delete verification.
17
+ - DNS diagnosis across provider, system/VPN, Cloudflare, and Google views.
16
18
  - Dry-run plans before writing changes.
17
19
  - Safety checks before replacing DNS records that point elsewhere.
18
20
  - DNS propagation and Vercel verification wait loop.
@@ -257,10 +259,38 @@ doomain dns point app.example.com --target 203.0.113.10 --dry-run --json
257
259
 
258
260
  The preview resolves the provider, account, zone, record name, type, and value without reading or writing current DNS records. Conflicts are checked when a real write is attempted.
259
261
 
260
- By default, a successful write waits up to 300 seconds for public DNS. Use `--no-wait` to return immediately or `--timeout <seconds>` to change the limit. Skipping the wait, a dry run, or reaching the timeout returns `propagated: false`; a propagation timeout does not turn a successful provider write into an error.
262
+ Every real write is re-read from the provider. Success is returned only when `reconciled: true` and the non-TXT slot contains only the desired record. If an eventually consistent provider does not reach that postcondition, the command fails with `DNS_RECONCILIATION_INCOMPLETE` and includes the observed records; do not treat accepted API requests as a completed cutover.
263
+
264
+ By default, a successful write waits up to 300 seconds and compares the system resolver with Cloudflare and Google public DNS. Use `--no-wait` to skip resolver verification or `--timeout <seconds>` to change the limit. The JSON `propagation` object includes each resolver's answers, TTLs, expected target, elapsed time, status, and timeout reason. Node's resolver API does not expose CNAME TTLs, so those answers use `ttl: null` with `ttlUnavailableReason` instead of silently omitting the field.
265
+
266
+ - `propagated`: public and system DNS match.
267
+ - `local_or_vpn_cache_stale`: public DNS matches but the system/VPN resolver still serves cached data. The mutation is deployed, and `propagated` is `true`.
268
+ - `system_resolver_unavailable`: public DNS matches, but the system resolver query failed. Public propagation is complete, and the resolver error is preserved for diagnosis.
269
+ - `public_propagation_pending`: public DNS did not match before the timeout. The reconciled provider mutation succeeded, but `propagated` is `false`; this remains an exit-code-0 partial verification result.
270
+ - `not_checked`: resolver verification was skipped or this was a dry run.
261
271
 
262
272
  Existing exact records are skipped. Conflicting records fail with `DNS_TARGET_CONFLICT` in JSON/non-interactive mode. Use `--force` only after approving replacement of the existing target.
263
273
 
274
+ ## Removing And Diagnosing DNS Records
275
+
276
+ Removal defaults to an exact name/type/value match and verifies the record is absent after deletion:
277
+
278
+ ```bash
279
+ doomain dns remove app.example.com --type A --value 203.0.113.10 --dry-run --json
280
+ doomain dns remove app.example.com --type A --value 203.0.113.10 --json
281
+ ```
282
+
283
+ `dns delete` is an alias. If more than one record matches, non-interactive mode fails with `DNS_DELETE_AMBIGUOUS`. Pass `--all-matching` only after reviewing the dry-run plan; interactive mode asks before deleting multiple records.
284
+
285
+ Use the read-only diagnosis command to compare provider control-plane records with system/VPN and public DNS answers:
286
+
287
+ ```bash
288
+ doomain dns diagnose example.com --json
289
+ doomain dns diagnose app.example.com --type A --target 203.0.113.10 --json
290
+ ```
291
+
292
+ Diagnosis reports record conflicts, answer TTLs, resolver addresses, active interface names, scoped macOS resolver/interface metadata when available, and one of `provider_not_updated`, `public_propagation_pending`, `local_or_vpn_cache_stale`, `system_resolver_unavailable`, or `consistent`.
293
+
264
294
  ## Clerk Production Domains
265
295
 
266
296
  `doomain clerk domains add` mirrors Clerk CLI's initial production deployment API. It creates a production instance by cloning the application's development instance, sets the requested primary domain, writes every CNAME returned by Clerk, and optionally waits for Clerk's DNS, SSL, and email DNS status.
@@ -476,6 +506,33 @@ Common flags:
476
506
  - `--timeout <seconds>`: propagation wait timeout. Default is `300`.
477
507
  - `--json`: output one JSON object.
478
508
 
509
+ ### `doomain dns remove <domain>`
510
+
511
+ Removes exact DNS records and verifies their absence. `dns delete` is an alias.
512
+
513
+ ```bash
514
+ doomain dns remove app.example.com --type A --value 203.0.113.10 --dry-run --json
515
+ doomain dns remove app.example.com --type A --value 203.0.113.10 --json
516
+ ```
517
+
518
+ Common flags:
519
+
520
+ - `--type <A|AAAA|CNAME|MX|TXT>`: required record type.
521
+ - `--value <value>`: expected exact value; required unless `--all-matching` is used.
522
+ - `--all-matching`: explicitly select every record matching the name and type.
523
+ - `--provider <id>`, `--account <alias>`: select a configured provider account.
524
+ - `--dry-run`: list selected records without deleting them.
525
+ - `--json`: output one JSON object and never prompt.
526
+
527
+ ### `doomain dns diagnose <domain>`
528
+
529
+ Compares DNS provider records with the system resolver and independent public resolvers.
530
+
531
+ ```bash
532
+ doomain dns diagnose example.com --json
533
+ doomain dns diagnose app.example.com --type A --target 203.0.113.10 --json
534
+ ```
535
+
479
536
  ### `doomain auth logout vercel`
480
537
 
481
538
  Removes saved Vercel credentials from the local config file.
package/bin/dev.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
2
2
 
3
- import { execute } from '@oclif/core'
3
+ import { removeStaleDevelopmentManifest } from './ensure-current-manifest.js'
4
4
 
5
+ await removeStaleDevelopmentManifest(import.meta.url)
6
+ const { execute } = await import('@oclif/core')
5
7
  await execute({ development: true, dir: import.meta.url })
@@ -0,0 +1,18 @@
1
+ import { readFile, unlink } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export async function removeStaleDevelopmentManifest(binUrl) {
6
+ const root = dirname(dirname(fileURLToPath(binUrl)))
7
+ const packagePath = join(root, 'package.json')
8
+ const manifestPath = join(root, 'oclif.manifest.json')
9
+ try {
10
+ const [packageJson, manifest] = await Promise.all([
11
+ readFile(packagePath, 'utf8').then(JSON.parse),
12
+ readFile(manifestPath, 'utf8').then(JSON.parse),
13
+ ])
14
+ if (packageJson.version !== manifest.version) await unlink(manifestPath)
15
+ } catch (error) {
16
+ if (error?.code !== 'ENOENT') throw error
17
+ }
18
+ }
package/bin/run.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execute } from '@oclif/core'
3
+ import { removeStaleDevelopmentManifest } from './ensure-current-manifest.js'
4
+
5
+ await removeStaleDevelopmentManifest(import.meta.url)
6
+ const { execute } = await import('@oclif/core')
4
7
 
5
8
  const args = process.argv.slice(2)
6
9
  const routedArgs = args.length === 0 || (args.length === 1 && args[0] === '--json') ? ['wizard', ...args] : args
@@ -0,0 +1 @@
1
+ export { default } from './remove.js';
@@ -0,0 +1 @@
1
+ export { default } from './remove.js';
@@ -0,0 +1,16 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class DnsDiagnose extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ domain: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ account: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ provider: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ target: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ type: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ }
@@ -0,0 +1,49 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { diagnoseDns } from '../../lib/diagnose-dns.js';
3
+ import { accountFlag, jsonFlag, providerFlag } from '../../lib/flags.js';
4
+ import { createOutput, outputError } from '../../lib/output.js';
5
+ export default class DnsDiagnose extends Command {
6
+ static description = 'Compare DNS provider records with system and public resolver answers.';
7
+ static examples = [
8
+ '<%= config.bin %> <%= command.id %> example.com --json',
9
+ '<%= config.bin %> <%= command.id %> app.example.com --type A --target 203.0.113.10 --json',
10
+ ];
11
+ static args = {
12
+ domain: Args.string({ description: 'Fully qualified DNS name to diagnose.', required: true }),
13
+ };
14
+ static flags = {
15
+ account: accountFlag,
16
+ json: jsonFlag,
17
+ provider: providerFlag,
18
+ target: Flags.string({ description: 'Expected IP address or canonical hostname.' }),
19
+ type: Flags.string({ description: 'Record type to compare.', options: ['A', 'AAAA', 'CNAME'] }),
20
+ };
21
+ async run() {
22
+ const { args, flags } = await this.parse(DnsDiagnose);
23
+ const out = createOutput({ json: flags.json });
24
+ const spinner = out.json ? undefined : out.spinner();
25
+ try {
26
+ spinner?.start(`Diagnosing ${args.domain}`);
27
+ const result = await diagnoseDns({
28
+ account: flags.account,
29
+ domain: args.domain,
30
+ provider: flags.provider,
31
+ recordType: flags.type,
32
+ target: flags.target,
33
+ });
34
+ spinner?.stop('DNS diagnosis complete');
35
+ out.result(result);
36
+ out.info(`Provider: ${result.provider}/${result.account} (${result.zoneDomain})`);
37
+ out.info(`Status: ${result.status}`);
38
+ for (const observation of result.observations) {
39
+ const answers = observation.answers.map((answer) => `${answer.value}${answer.ttl == null ? '' : ` (TTL ${answer.ttl}s)`}`);
40
+ out.info(`${observation.resolver}: ${answers.join(', ') || observation.error || 'no answer'}`);
41
+ }
42
+ }
43
+ catch (error) {
44
+ spinner?.error('DNS diagnosis failed');
45
+ outputError(out.json, error, 'DNS_DIAGNOSE_FAILED');
46
+ this.exit(1);
47
+ }
48
+ }
49
+ }
@@ -1,5 +1,6 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import { Args, Command, Flags } from '@oclif/core';
3
+ import { normalizeDnsValue } from '../../lib/dns-records.js';
3
4
  import { accountFlag, jsonFlag, providerFlag } from '../../lib/flags.js';
4
5
  import { createOutput, outputError } from '../../lib/output.js';
5
6
  import { pointDomain } from '../../lib/point-domain.js';
@@ -22,6 +23,22 @@ function conflictNote(warning) {
22
23
  ].join('\n');
23
24
  }
24
25
  function successMessages(result, waited) {
26
+ if (result.propagation.status === 'system_resolver_unavailable') {
27
+ return {
28
+ outro: 'Public DNS is deployed, but the system resolver could not be queried. See resolver observations for details.',
29
+ spinner: 'Public DNS deployed; system resolver unavailable',
30
+ };
31
+ }
32
+ if (result.propagation.status === 'local_or_vpn_cache_stale') {
33
+ const system = result.propagation.observations.find((observation) => observation.kind === 'system');
34
+ const remainingTtl = system?.answers
35
+ .filter((answer) => normalizeDnsValue(answer.value) !== normalizeDnsValue(result.propagation.expected))
36
+ .reduce((longest, answer) => (answer.ttl == null ? longest : Math.max(longest ?? 0, answer.ttl)), undefined);
37
+ return {
38
+ outro: `DNS is deployed; your active VPN/local resolver is serving a cached record${remainingTtl === undefined ? '' : ` for up to ${remainingTtl} more seconds`}.`,
39
+ spinner: 'DNS deployed; local resolver cache is stale',
40
+ };
41
+ }
25
42
  if (result.propagated) {
26
43
  return {
27
44
  outro: result.updated
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class DnsRemove extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ domain: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ account: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'all-matching': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ provider: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ value: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ };
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,77 @@
1
+ import * as p from '@clack/prompts';
2
+ import { Args, Command, Flags } from '@oclif/core';
3
+ import { accountFlag, jsonFlag, providerFlag } from '../../lib/flags.js';
4
+ import { createOutput, outputError } from '../../lib/output.js';
5
+ import { removeDomain } from '../../lib/remove-domain.js';
6
+ function recordLines(records) {
7
+ return records.map((record) => `- ${record.type} ${record.name} -> ${record.value}`).join('\n');
8
+ }
9
+ export default class DnsRemove extends Command {
10
+ static description = 'Safely remove exact DNS records and verify their absence.';
11
+ static examples = [
12
+ '<%= config.bin %> <%= command.id %> app.example.com --type A --value 203.0.113.10 --dry-run --json',
13
+ '<%= config.bin %> <%= command.id %> app.example.com --type A --all-matching --json',
14
+ ];
15
+ static args = {
16
+ domain: Args.string({ description: 'Fully qualified DNS name whose record should be removed.', required: true }),
17
+ };
18
+ static flags = {
19
+ account: accountFlag,
20
+ 'all-matching': Flags.boolean({
21
+ description: 'Delete every record matching the name and type (and value, if set).',
22
+ }),
23
+ 'dry-run': Flags.boolean({ description: 'Preview the exact records without deleting them.' }),
24
+ json: jsonFlag,
25
+ provider: providerFlag,
26
+ type: Flags.string({
27
+ description: 'DNS record type to remove.',
28
+ options: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'],
29
+ required: true,
30
+ }),
31
+ value: Flags.string({ description: 'Expected record value. Required unless --all-matching is passed.' }),
32
+ };
33
+ async run() {
34
+ const { args, flags } = await this.parse(DnsRemove);
35
+ const out = createOutput({ json: flags.json });
36
+ const spinner = flags['dry-run'] || out.json ? undefined : out.spinner();
37
+ try {
38
+ spinner?.start(`Inspecting ${args.domain}`);
39
+ const result = await removeDomain({
40
+ account: flags.account,
41
+ allMatching: flags['all-matching'],
42
+ confirmMultiple: out.json
43
+ ? undefined
44
+ : async (records) => {
45
+ spinner?.stop('Multiple matching records found');
46
+ p.note(recordLines(records), 'Records selected for deletion');
47
+ const confirmed = await p.confirm({
48
+ initialValue: false,
49
+ message: `Delete all ${records.length} matching records?`,
50
+ });
51
+ if (confirmed === true)
52
+ spinner?.start(`Deleting records from ${args.domain}`);
53
+ return confirmed === true;
54
+ },
55
+ domain: args.domain,
56
+ dryRun: flags['dry-run'],
57
+ progress: out.json ? undefined : (message) => spinner?.message(message),
58
+ provider: flags.provider,
59
+ recordType: flags.type,
60
+ value: flags.value,
61
+ });
62
+ spinner?.stop(result.removed === 0 ? 'No matching records found' : 'DNS records removed');
63
+ out.result(result);
64
+ if (flags['dry-run'] && !out.json)
65
+ p.note(recordLines(result.matched) || 'No matching records.', 'Dry run');
66
+ else
67
+ out.outro(result.removed === 0
68
+ ? `No matching DNS record exists for ${result.domain}.`
69
+ : `Removed ${result.removed} DNS record${result.removed === 1 ? '' : 's'} from ${result.domain}.`);
70
+ }
71
+ catch (error) {
72
+ spinner?.error('DNS removal failed');
73
+ outputError(out.json, error, 'DNS_REMOVE_FAILED');
74
+ this.exit(1);
75
+ }
76
+ }
77
+ }
@@ -25,8 +25,7 @@ export default class ProvidersStatus extends Command {
25
25
  const providers = await listProviderStatuses({ verify: !flags['no-verify'] });
26
26
  spinner?.stop('Checked DNS providers');
27
27
  for (const provider of providers) {
28
- const account = provider.isDefaultAccount ? provider.id : `${provider.id}/${provider.account}`;
29
- out.info(`${provider.displayName} (${account}) - ${formatStatus(provider)}${provider.default ? ' [default]' : ''}`);
28
+ out.info(`${provider.displayName} (${provider.accountLabel}) - ${formatStatus(provider)}${provider.isPreferredProvider ? ' [preferred provider]' : ''}`);
30
29
  }
31
30
  out.result({ providers });
32
31
  }
@@ -1,5 +1,5 @@
1
1
  import { type ProviderStatus } from './providers/status.js';
2
- export type ProviderConnectionStatus = Pick<ProviderStatus, 'account' | 'configured' | 'default' | 'displayName' | 'docsUrl' | 'id' | 'isDefaultAccount'>;
2
+ export type ProviderConnectionStatus = Pick<ProviderStatus, 'account' | 'accountLabel' | 'configured' | 'default' | 'displayName' | 'docsUrl' | 'id' | 'isDefaultAccount' | 'isPreferredProvider'>;
3
3
  export interface CommandSchema {
4
4
  name: string;
5
5
  description: string;
@@ -1,4 +1,14 @@
1
1
  import { listProviderStatuses } from './providers/status.js';
2
+ const dnsRemovalFlags = [
3
+ { name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
4
+ { name: 'domain', type: 'string', description: 'Fully qualified DNS name.', required: true },
5
+ { name: 'type', type: 'string', description: 'A, AAAA, CNAME, MX, or TXT.', required: true },
6
+ { name: 'value', type: 'string', description: 'Expected exact value. Required unless --all-matching.' },
7
+ { name: 'provider', type: 'string', description: 'DNS provider id. Inferred when omitted.' },
8
+ { name: 'account', type: 'string', description: 'DNS provider profile/account alias.' },
9
+ { name: 'all-matching', type: 'boolean', description: 'Delete every matching record after explicit approval.' },
10
+ { name: 'dry-run', type: 'boolean', description: 'Preview exact records without deleting.' },
11
+ ];
2
12
  export const commandSchemas = [
3
13
  {
4
14
  name: 'dns point',
@@ -12,6 +22,7 @@ export const commandSchemas = [
12
22
  agentInstructions: [
13
23
  'When a user asks to point a domain at a VPS or hostname, run `doomain dns point <domain> --target <ip-or-hostname> --json`.',
14
24
  'Pass --force only after the user has approved replacing an existing DNS target.',
25
+ 'A successful mutation always has reconciled=true. propagated=false with public_propagation_pending means the provider mutation succeeded but public verification timed out; system_resolver_unavailable means public DNS matches but the local resolver check failed. Inspect propagation.observations before retrying.',
15
26
  ],
16
27
  agentQuickstart: {
17
28
  doNotPreflight: true,
@@ -42,6 +53,49 @@ export const commandSchemas = [
42
53
  { name: 'timeout', type: 'integer', description: 'DNS propagation wait timeout in seconds.', default: 300 },
43
54
  ],
44
55
  },
56
+ {
57
+ name: 'dns remove',
58
+ description: 'Safely remove exact DNS records and verify their absence.',
59
+ examples: [
60
+ 'doomain dns remove app.example.com --type A --value 203.0.113.10 --dry-run --json',
61
+ 'doomain dns remove app.example.com --type A --value 203.0.113.10 --json',
62
+ 'doomain dns delete app.example.com --type A --all-matching --json',
63
+ ],
64
+ agentHint: 'Default to exact name/type/value deletion. Preview with --dry-run when the expected value is unknown. Never pass --all-matching without explicit approval.',
65
+ mutates: true,
66
+ safeForAgents: true,
67
+ flags: dnsRemovalFlags,
68
+ },
69
+ {
70
+ name: 'dns diagnose',
71
+ description: 'Compare provider control-plane records with system and public DNS resolvers.',
72
+ examples: [
73
+ 'doomain dns diagnose example.com --json',
74
+ 'doomain dns diagnose app.example.com --type A --target 203.0.113.10 --json',
75
+ ],
76
+ agentHint: 'Use this read-only command to distinguish provider_not_updated, public_propagation_pending, and local_or_vpn_cache_stale. Resolver observations include answers and TTLs.',
77
+ safeForAgents: true,
78
+ flags: [
79
+ { name: 'json', type: 'boolean', description: 'Output a single JSON object and never prompt.' },
80
+ { name: 'domain', type: 'string', description: 'Fully qualified DNS name.', required: true },
81
+ { name: 'type', type: 'string', description: 'A, AAAA, or CNAME. Defaults to A.' },
82
+ { name: 'target', type: 'string', description: 'Optional expected IP address or canonical hostname.' },
83
+ { name: 'provider', type: 'string', description: 'DNS provider id. Inferred when omitted.' },
84
+ { name: 'account', type: 'string', description: 'DNS provider profile/account alias.' },
85
+ ],
86
+ },
87
+ {
88
+ name: 'dns delete',
89
+ description: 'Alias for dns remove.',
90
+ examples: [
91
+ 'doomain dns delete app.example.com --type A --value 203.0.113.10 --dry-run --json',
92
+ 'doomain dns delete app.example.com --type A --all-matching --json',
93
+ ],
94
+ agentHint: 'Alias for dns remove; use the same exact-match and explicit --all-matching safety rules.',
95
+ mutates: true,
96
+ safeForAgents: true,
97
+ flags: dnsRemovalFlags,
98
+ },
45
99
  {
46
100
  name: 'link',
47
101
  description: 'Link a Vercel project to a domain and create DNS records.',
@@ -391,16 +445,23 @@ export function getCommandSchema(name) {
391
445
  async function configuredProviders() {
392
446
  return (await listProviderStatuses({ verify: false })).map((provider) => ({
393
447
  account: provider.account,
448
+ accountLabel: provider.accountLabel,
394
449
  configured: provider.configured,
395
450
  default: provider.default,
396
451
  displayName: provider.displayName,
397
452
  docsUrl: provider.docsUrl,
398
453
  id: provider.id,
399
454
  isDefaultAccount: provider.isDefaultAccount,
455
+ isPreferredProvider: provider.isPreferredProvider,
400
456
  }));
401
457
  }
402
458
  function withProviderConnections(schema, providers) {
403
- if (schema.name !== 'link' && schema.name !== 'clerk domains add' && schema.name !== 'dns point')
459
+ if (schema.name !== 'link' &&
460
+ schema.name !== 'clerk domains add' &&
461
+ schema.name !== 'dns point' &&
462
+ schema.name !== 'dns remove' &&
463
+ schema.name !== 'dns delete' &&
464
+ schema.name !== 'dns diagnose')
404
465
  return schema;
405
466
  return { ...schema, configuredProviders: providers };
406
467
  }
@@ -0,0 +1,53 @@
1
+ import { type DnsResolverObservation } from './dns-propagation.js';
2
+ import { type ResolvedDnsTarget } from './domain-provider.js';
3
+ import type { DnsProvider, DnsRecord, DnsRecordInput, DnsRecordType } from './providers/types.js';
4
+ export type DnsDiagnosisStatus = 'consistent' | 'local_or_vpn_cache_stale' | 'provider_not_updated' | 'public_propagation_pending' | 'system_resolver_unavailable';
5
+ export interface DiagnoseDnsInput {
6
+ account?: string;
7
+ domain: string;
8
+ provider?: string;
9
+ recordType?: 'A' | 'AAAA' | 'CNAME';
10
+ target?: string;
11
+ }
12
+ export interface DnsRecordConflict {
13
+ reason: 'cname_slot_conflict' | 'multiple_values';
14
+ records: DnsRecord[];
15
+ type?: DnsRecordType;
16
+ }
17
+ export interface MacOsResolverMetadata {
18
+ flags?: string;
19
+ interfaceIndex?: number;
20
+ interfaceName?: string;
21
+ nameservers: string[];
22
+ resolver: number;
23
+ }
24
+ export interface DiagnoseDnsResult {
25
+ account: string;
26
+ accountInferred: boolean;
27
+ conflicts: DnsRecordConflict[];
28
+ domain: string;
29
+ expected?: string;
30
+ interfaces: string[];
31
+ isDefaultAccount: boolean;
32
+ macOsResolvers?: MacOsResolverMetadata[];
33
+ observations: DnsResolverObservation[];
34
+ platform: NodeJS.Platform;
35
+ provider: string;
36
+ providerInferred: boolean;
37
+ providerRecords: DnsRecord[];
38
+ recordType: 'A' | 'AAAA' | 'CNAME';
39
+ status: DnsDiagnosisStatus;
40
+ systemResolverServers: string[];
41
+ warnings: ResolvedDnsTarget['warnings'];
42
+ zoneDomain: string;
43
+ }
44
+ interface DiagnoseDnsDependencies {
45
+ createProvider: (provider: string, opts: {
46
+ account?: string;
47
+ }) => Promise<DnsProvider>;
48
+ observeDns: (fqdn: string, target: Pick<DnsRecordInput, 'type' | 'value'>, elapsedMs: number) => Promise<DnsResolverObservation[]>;
49
+ resolveTarget: (input: Pick<DiagnoseDnsInput, 'account' | 'domain' | 'provider'>) => Promise<ResolvedDnsTarget>;
50
+ macOsResolvers?: () => Promise<MacOsResolverMetadata[]>;
51
+ }
52
+ export declare function diagnoseDns(input: DiagnoseDnsInput, dependencies?: DiagnoseDnsDependencies): Promise<DiagnoseDnsResult>;
53
+ export {};
@@ -0,0 +1,156 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getServers } from 'node:dns';
3
+ import { networkInterfaces, platform } from 'node:os';
4
+ import { promisify } from 'node:util';
5
+ import { classifyDnsPropagation, isNegativeDnsObservation, observeDnsRecord, } from './dns-propagation.js';
6
+ import { desiredSlotPostcondition, dnsRecordNamesEqual, inferAddressRecordType, normalizeAddressRecordTarget, normalizeDnsValue, } from './dns-records.js';
7
+ import { resolveProviderTarget } from './domain-provider.js';
8
+ import { DoomainError } from './errors.js';
9
+ import { createProvider } from './providers/registry.js';
10
+ const execFileAsync = promisify(execFile);
11
+ async function readMacOsResolvers() {
12
+ if (platform() !== 'darwin')
13
+ return [];
14
+ try {
15
+ const { stdout } = await execFileAsync('scutil', ['--dns'], { maxBuffer: 1024 * 1024 });
16
+ return String(stdout)
17
+ .split(/\n(?=resolver #\d+)/)
18
+ .flatMap((block) => {
19
+ const resolver = block.match(/resolver #(\d+)/)?.[1];
20
+ if (!resolver)
21
+ return [];
22
+ const interfaceMatch = block.match(/if_index\s*:\s*(\d+)(?:\s*\(([^)]+)\))?/);
23
+ const nameservers = [...block.matchAll(/nameserver\[\d+\]\s*:\s*(\S+)/g)].map((match) => match[1]);
24
+ const flags = block.match(/flags\s*:\s*(.+)/)?.[1]?.trim();
25
+ return [
26
+ {
27
+ ...(flags ? { flags } : {}),
28
+ ...(interfaceMatch ? { interfaceIndex: Number(interfaceMatch[1]) } : {}),
29
+ ...(interfaceMatch?.[2] ? { interfaceName: interfaceMatch[2] } : {}),
30
+ nameservers,
31
+ resolver: Number(resolver),
32
+ },
33
+ ];
34
+ });
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
40
+ const defaultDependencies = {
41
+ createProvider,
42
+ macOsResolvers: readMacOsResolvers,
43
+ observeDns: (fqdn, target, elapsedMs) => observeDnsRecord(fqdn, target, undefined, elapsedMs),
44
+ resolveTarget: (input) => resolveProviderTarget(input, { tolerateProviderAccountErrors: true }),
45
+ };
46
+ function recordConflicts(records) {
47
+ const conflicts = [];
48
+ for (const type of ['A', 'AAAA', 'CNAME']) {
49
+ const typed = records.filter((record) => record.type === type);
50
+ const values = new Set(typed.map((record) => normalizeDnsValue(record.value)));
51
+ if (values.size > 1)
52
+ conflicts.push({ reason: 'multiple_values', records: typed, type });
53
+ }
54
+ const cnames = records.filter((record) => record.type === 'CNAME');
55
+ const otherRecords = records.filter((record) => record.type !== 'CNAME');
56
+ if (cnames.length > 0 && otherRecords.length > 0) {
57
+ conflicts.push({ reason: 'cname_slot_conflict', records: [...cnames, ...otherRecords] });
58
+ }
59
+ return conflicts;
60
+ }
61
+ function activeInterfaceNames() {
62
+ return Object.entries(networkInterfaces())
63
+ .filter(([, addresses]) => addresses?.some((address) => !address.internal))
64
+ .map(([name]) => name)
65
+ .sort();
66
+ }
67
+ function diagnosisStatus(status) {
68
+ if (status === 'propagated')
69
+ return 'consistent';
70
+ if (status === 'local_or_vpn_cache_stale')
71
+ return status;
72
+ if (status === 'system_resolver_unavailable')
73
+ return status;
74
+ return 'public_propagation_pending';
75
+ }
76
+ function diagnosisResolutionError(error, input) {
77
+ if (error.code !== 'CONFIG_NOT_FOUND' && error.code !== 'PROVIDER_ZONE_NOT_FOUND')
78
+ return error;
79
+ const details = error.details && typeof error.details === 'object' ? error.details : {};
80
+ const provider = input.provider ? ` --provider ${input.provider}` : '';
81
+ const account = input.account ? ` --account ${input.account}` : '';
82
+ const type = input.recordType ? ` --type ${input.recordType}` : '';
83
+ const target = input.target ? ` --target ${input.target}` : '';
84
+ const retry = `doomain dns diagnose ${input.domain}${provider}${account}${type}${target} --json`;
85
+ return new DoomainError(error.code, error.message, {
86
+ ...details,
87
+ recovery: `Connect or repair the DNS provider account that owns this domain, then retry \`${retry}\`.`,
88
+ suggestedCommands: ['doomain providers connect', retry],
89
+ });
90
+ }
91
+ export async function diagnoseDns(input, dependencies = defaultDependencies) {
92
+ const recordType = input.recordType ?? (input.target ? inferAddressRecordType(input.target.trim()) : undefined) ?? 'A';
93
+ const requestedTarget = input.target === undefined ? undefined : normalizeAddressRecordTarget(recordType, input.target);
94
+ let resolved;
95
+ try {
96
+ resolved = await dependencies.resolveTarget(input);
97
+ }
98
+ catch (error) {
99
+ if (error instanceof DoomainError)
100
+ throw diagnosisResolutionError(error, input);
101
+ throw error;
102
+ }
103
+ const provider = await dependencies.createProvider(resolved.provider, { account: resolved.account });
104
+ const zone = await provider.getZone(resolved.target.zoneDomain);
105
+ if (!zone) {
106
+ throw diagnosisResolutionError(new DoomainError('PROVIDER_ZONE_NOT_FOUND', `${provider.name} does not have a DNS zone for ${resolved.target.zoneDomain}.`), input);
107
+ }
108
+ const allRecords = await provider.listRecords(zone);
109
+ const providerRecords = allRecords.filter((record) => dnsRecordNamesEqual(record.name, resolved.target.recordName));
110
+ const recordsOfType = providerRecords.filter((record) => record.type === recordType);
111
+ const expected = requestedTarget ?? (recordsOfType.length === 1 ? recordsOfType[0].value : undefined);
112
+ const queryTarget = { type: recordType, value: expected ?? recordsOfType[0]?.value ?? '' };
113
+ const observations = await dependencies.observeDns(resolved.target.fullDomain, queryTarget, 0);
114
+ const macOsResolvers = await dependencies.macOsResolvers?.();
115
+ let status;
116
+ if (expected) {
117
+ const postcondition = desiredSlotPostcondition(allRecords, {
118
+ name: resolved.target.recordName,
119
+ type: recordType,
120
+ value: expected,
121
+ });
122
+ status = postcondition.reconciled ? diagnosisStatus(classifyDnsPropagation(observations)) : 'provider_not_updated';
123
+ }
124
+ else {
125
+ const providerValues = new Set(recordsOfType.map((record) => normalizeDnsValue(record.value)));
126
+ const compared = observations.map((observation) => ({
127
+ ...observation,
128
+ matches: providerValues.size === 0
129
+ ? observation.answers.length === 0 && isNegativeDnsObservation(observation)
130
+ : observation.answers.length === providerValues.size &&
131
+ observation.answers.every((answer) => providerValues.has(normalizeDnsValue(answer.value))),
132
+ }));
133
+ observations.splice(0, observations.length, ...compared);
134
+ status = diagnosisStatus(classifyDnsPropagation(observations));
135
+ }
136
+ return {
137
+ account: resolved.account,
138
+ accountInferred: resolved.accountInferred,
139
+ conflicts: recordConflicts(providerRecords),
140
+ domain: resolved.target.fullDomain,
141
+ ...(expected === undefined ? {} : { expected }),
142
+ interfaces: activeInterfaceNames(),
143
+ isDefaultAccount: resolved.isDefaultAccount,
144
+ ...(macOsResolvers && macOsResolvers.length > 0 ? { macOsResolvers } : {}),
145
+ observations,
146
+ platform: platform(),
147
+ provider: resolved.provider,
148
+ providerInferred: resolved.providerInferred,
149
+ providerRecords,
150
+ recordType,
151
+ status,
152
+ systemResolverServers: getServers(),
153
+ warnings: resolved.warnings,
154
+ zoneDomain: resolved.target.zoneDomain,
155
+ };
156
+ }