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.
@@ -1,7 +1,7 @@
1
1
  import { normalizeDomain } from '../../validate.js';
2
2
  import { createProviderHttpClient } from '../core/http.js';
3
3
  import { paginateBySkip } from '../core/pagination.js';
4
- import { applyDnsChanges, planDnsChanges } from '../core/planner.js';
4
+ import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
5
5
  const SPACESHIP_API_URL = 'https://spaceship.dev/api/v1';
6
6
  const capabilities = {
7
7
  defaultTtl: 3600,
@@ -108,12 +108,21 @@ export class SpaceshipProvider {
108
108
  });
109
109
  }
110
110
  async applyChanges(zone, plan) {
111
- return applyDnsChanges({
112
- deleteRecord: (record) => this.deleteRecord(zone, record),
113
- plan,
114
- providerId: this.id,
115
- upsertRecord: (record) => this.upsertRecord(zone, record),
116
- });
111
+ assertNoConflicts(this.id, plan);
112
+ const skipped = plan.changes.flatMap((change) => (change.action === 'skip' ? [change.record] : []));
113
+ const applied = plan.changes.filter((change) => change.action !== 'skip');
114
+ // Spaceship records do not have stable ids. Delete every old value before creating
115
+ // replacements so an API that processes accepted writes out of order cannot leave
116
+ // two address records in the same slot without the reconciler noticing and retrying.
117
+ for (const change of applied) {
118
+ if (change.action === 'delete' || change.action === 'update')
119
+ await this.deleteRecord(zone, change.existing);
120
+ }
121
+ for (const change of applied) {
122
+ if (change.action === 'create' || change.action === 'update')
123
+ await this.upsertRecord(zone, change.record);
124
+ }
125
+ return { applied, skipped };
117
126
  }
118
127
  async upsertRecord(zone, record) {
119
128
  await this.http.request(`/dns/records/${zone.name}`, {
@@ -2,7 +2,9 @@ import { type DoomainConfig } from '../config.js';
2
2
  import type { DnsProviderDefinition } from './types.js';
3
3
  export interface ProviderStatus {
4
4
  account: string;
5
+ accountLabel: string;
5
6
  configured: boolean;
7
+ /** @deprecated Use isPreferredProvider. */
6
8
  default: boolean;
7
9
  displayName: string;
8
10
  docsUrl?: string;
@@ -10,6 +12,7 @@ export interface ProviderStatus {
10
12
  error?: string;
11
13
  id: string;
12
14
  isDefaultAccount: boolean;
15
+ isPreferredProvider: boolean;
13
16
  verified?: boolean;
14
17
  }
15
18
  export declare function isProviderConfigured(definition: DnsProviderDefinition, config: DoomainConfig, opts?: {
@@ -17,12 +17,14 @@ export async function listProviderStatuses(opts = {}) {
17
17
  const configured = accounts.some((item) => item.account === account);
18
18
  const status = {
19
19
  account,
20
+ accountLabel: ref.isDefaultAccount ? `${definition.id}/default` : `${definition.id}/${account}`,
20
21
  configured,
21
22
  default: config.defaults?.provider === definition.id,
22
23
  displayName: definition.displayName,
23
24
  docsUrl: definition.docsUrl,
24
25
  id: definition.id,
25
26
  isDefaultAccount: ref.isDefaultAccount,
27
+ isPreferredProvider: config.defaults?.provider === definition.id,
26
28
  };
27
29
  if (configured && opts.verify) {
28
30
  try {
@@ -0,0 +1,38 @@
1
+ import { type DnsRecordSelector } from './dns-records.js';
2
+ import { type ResolvedDnsTarget } from './domain-provider.js';
3
+ import type { DnsProvider, DnsRecord, DnsRecordType } from './providers/types.js';
4
+ export interface RemoveDomainInput {
5
+ account?: string;
6
+ allMatching?: boolean;
7
+ domain: string;
8
+ dryRun?: boolean;
9
+ provider?: string;
10
+ recordType: DnsRecordType;
11
+ reconcileTimeoutSeconds?: number;
12
+ value?: string;
13
+ confirmMultiple?: (records: DnsRecord[]) => Promise<boolean>;
14
+ progress?: (message: string) => void;
15
+ }
16
+ export interface RemoveDomainResult {
17
+ account: string;
18
+ accountInferred: boolean;
19
+ domain: string;
20
+ dryRun: boolean;
21
+ isDefaultAccount: boolean;
22
+ matched: DnsRecord[];
23
+ provider: string;
24
+ providerInferred: boolean;
25
+ reconciled: boolean;
26
+ reconciliationAttempts: number;
27
+ removed: number;
28
+ selector: DnsRecordSelector;
29
+ zoneDomain: string;
30
+ }
31
+ interface RemoveDomainDependencies {
32
+ createProvider: (provider: string, opts: {
33
+ account?: string;
34
+ }) => Promise<DnsProvider>;
35
+ resolveTarget: (input: Pick<RemoveDomainInput, 'account' | 'domain' | 'provider'>) => Promise<ResolvedDnsTarget>;
36
+ }
37
+ export declare function removeDomain(input: RemoveDomainInput, dependencies?: RemoveDomainDependencies): Promise<RemoveDomainResult>;
38
+ export {};
@@ -0,0 +1,125 @@
1
+ import { reconcileRecordRemoval } from './dns-reconciliation.js';
2
+ import { recordMatchesSelector } from './dns-records.js';
3
+ import { resolveProviderTarget } from './domain-provider.js';
4
+ import { DoomainError } from './errors.js';
5
+ import { createProvider } from './providers/registry.js';
6
+ const defaultDependencies = { createProvider, resolveTarget: resolveProviderTarget };
7
+ function quoteCommandArgument(value) {
8
+ return /^[a-zA-Z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
9
+ }
10
+ function removalCommand(input, options) {
11
+ const arguments_ = ['doomain', 'dns', 'remove', quoteCommandArgument(input.domain)];
12
+ if (input.provider)
13
+ arguments_.push('--provider', quoteCommandArgument(input.provider));
14
+ if (input.account)
15
+ arguments_.push('--account', quoteCommandArgument(input.account));
16
+ arguments_.push('--type', input.recordType);
17
+ if (input.value !== undefined)
18
+ arguments_.push('--value', quoteCommandArgument(input.value));
19
+ if (options?.allMatching ?? input.allMatching)
20
+ arguments_.push('--all-matching');
21
+ if (options?.dryRun ?? input.dryRun)
22
+ arguments_.push('--dry-run');
23
+ arguments_.push('--json');
24
+ return arguments_.join(' ');
25
+ }
26
+ function removalResolutionError(error, input) {
27
+ if (error.code !== 'CONFIG_NOT_FOUND' && error.code !== 'PROVIDER_ZONE_NOT_FOUND')
28
+ return error;
29
+ const details = error.details && typeof error.details === 'object' ? error.details : {};
30
+ const retry = removalCommand(input);
31
+ return new DoomainError(error.code, error.message, {
32
+ ...details,
33
+ recovery: `Connect or repair the DNS provider account that owns this domain, then retry \`${retry}\`.`,
34
+ suggestedCommands: ['doomain providers connect', retry],
35
+ });
36
+ }
37
+ function ambiguousDeletionError(input, records) {
38
+ return new DoomainError('DNS_DELETE_AMBIGUOUS', `${records.length} DNS records match this deletion. Pass --all-matching to delete all of them.`, {
39
+ matched: records,
40
+ recovery: 'Narrow the deletion with --value, or explicitly approve all matching records with --all-matching.',
41
+ suggestedCommands: [removalCommand(input, { allMatching: true, dryRun: true })],
42
+ });
43
+ }
44
+ export async function removeDomain(input, dependencies = defaultDependencies) {
45
+ if (!input.allMatching && input.value === undefined) {
46
+ throw new DoomainError('INVALID_INPUT', 'An exact --value is required unless --all-matching is passed.', {
47
+ recovery: 'Pass --value <expected-value>, or preview --all-matching with --dry-run.',
48
+ });
49
+ }
50
+ let resolved;
51
+ try {
52
+ resolved = await dependencies.resolveTarget(input);
53
+ }
54
+ catch (error) {
55
+ if (error instanceof DoomainError)
56
+ throw removalResolutionError(error, input);
57
+ throw error;
58
+ }
59
+ const provider = await dependencies.createProvider(resolved.provider, { account: resolved.account });
60
+ if (!provider.capabilities.recordTypes.includes(input.recordType)) {
61
+ throw new DoomainError('PROVIDER_UNSUPPORTED_RECORD', `${provider.name} does not support ${input.recordType} records.`);
62
+ }
63
+ const zone = await provider.getZone(resolved.target.zoneDomain);
64
+ if (!zone) {
65
+ throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', `${provider.name} does not have a DNS zone for ${resolved.target.zoneDomain}.`);
66
+ }
67
+ const selector = {
68
+ name: resolved.target.recordName,
69
+ type: input.recordType,
70
+ ...(input.value === undefined ? {} : { value: input.value }),
71
+ };
72
+ const existing = await provider.listRecords(zone);
73
+ const matched = existing.filter((record) => recordMatchesSelector(record, selector));
74
+ if (matched.length > 1 && !input.allMatching) {
75
+ const confirmed = (await input.confirmMultiple?.(matched)) === true;
76
+ if (!confirmed)
77
+ throw ambiguousDeletionError(input, matched);
78
+ }
79
+ const base = {
80
+ account: resolved.account,
81
+ accountInferred: resolved.accountInferred,
82
+ domain: resolved.target.fullDomain,
83
+ isDefaultAccount: resolved.isDefaultAccount,
84
+ matched,
85
+ provider: resolved.provider,
86
+ providerInferred: resolved.providerInferred,
87
+ selector,
88
+ zoneDomain: resolved.target.zoneDomain,
89
+ };
90
+ if (input.dryRun) {
91
+ return {
92
+ ...base,
93
+ dryRun: true,
94
+ reconciled: false,
95
+ reconciliationAttempts: 0,
96
+ removed: 0,
97
+ };
98
+ }
99
+ if (matched.length === 0) {
100
+ return { ...base, dryRun: false, reconciled: true, reconciliationAttempts: 1, removed: 0 };
101
+ }
102
+ input.progress?.(`Deleting ${matched.length} DNS record${matched.length === 1 ? '' : 's'}`);
103
+ const plan = {
104
+ changes: matched.map((record) => ({ action: 'delete', existing: record })),
105
+ conflicts: [],
106
+ desired: [],
107
+ existing,
108
+ zone,
109
+ };
110
+ await provider.applyChanges(zone, plan, { force: true });
111
+ const reconciliation = await reconcileRecordRemoval({
112
+ progress: input.progress,
113
+ provider,
114
+ selector,
115
+ timeoutMs: (input.reconcileTimeoutSeconds ?? 30) * 1000,
116
+ zone,
117
+ });
118
+ return {
119
+ ...base,
120
+ dryRun: false,
121
+ reconciled: reconciliation.reconciled,
122
+ reconciliationAttempts: reconciliation.attempts,
123
+ removed: matched.length,
124
+ };
125
+ }
@@ -374,6 +374,163 @@
374
374
  "vercel.js"
375
375
  ]
376
376
  },
377
+ "dns:delete": {
378
+ "aliases": [],
379
+ "args": {
380
+ "domain": {
381
+ "description": "Fully qualified DNS name whose record should be removed.",
382
+ "name": "domain",
383
+ "required": true
384
+ }
385
+ },
386
+ "description": "Safely remove exact DNS records and verify their absence.",
387
+ "examples": [
388
+ "<%= config.bin %> <%= command.id %> app.example.com --type A --value 203.0.113.10 --dry-run --json",
389
+ "<%= config.bin %> <%= command.id %> app.example.com --type A --all-matching --json"
390
+ ],
391
+ "flags": {
392
+ "account": {
393
+ "description": "DNS provider profile/account alias. Defaults to the provider default account.",
394
+ "name": "account",
395
+ "hasDynamicHelp": false,
396
+ "multiple": false,
397
+ "type": "option"
398
+ },
399
+ "all-matching": {
400
+ "description": "Delete every record matching the name and type (and value, if set).",
401
+ "name": "all-matching",
402
+ "allowNo": false,
403
+ "type": "boolean"
404
+ },
405
+ "dry-run": {
406
+ "description": "Preview the exact records without deleting them.",
407
+ "name": "dry-run",
408
+ "allowNo": false,
409
+ "type": "boolean"
410
+ },
411
+ "json": {
412
+ "description": "Output a single JSON object and never prompt.",
413
+ "name": "json",
414
+ "allowNo": false,
415
+ "type": "boolean"
416
+ },
417
+ "provider": {
418
+ "description": "DNS provider id. Inferred from the target domain when omitted.",
419
+ "name": "provider",
420
+ "hasDynamicHelp": false,
421
+ "multiple": false,
422
+ "type": "option"
423
+ },
424
+ "type": {
425
+ "description": "DNS record type to remove.",
426
+ "name": "type",
427
+ "required": true,
428
+ "hasDynamicHelp": false,
429
+ "multiple": false,
430
+ "options": [
431
+ "A",
432
+ "AAAA",
433
+ "CNAME",
434
+ "MX",
435
+ "TXT"
436
+ ],
437
+ "type": "option"
438
+ },
439
+ "value": {
440
+ "description": "Expected record value. Required unless --all-matching is passed.",
441
+ "name": "value",
442
+ "hasDynamicHelp": false,
443
+ "multiple": false,
444
+ "type": "option"
445
+ }
446
+ },
447
+ "hasDynamicHelp": false,
448
+ "hiddenAliases": [],
449
+ "id": "dns:delete",
450
+ "pluginAlias": "doomain",
451
+ "pluginName": "doomain",
452
+ "pluginType": "core",
453
+ "strict": true,
454
+ "enableJsonFlag": false,
455
+ "isESM": true,
456
+ "relativePath": [
457
+ "dist",
458
+ "commands",
459
+ "dns",
460
+ "delete.js"
461
+ ]
462
+ },
463
+ "dns:diagnose": {
464
+ "aliases": [],
465
+ "args": {
466
+ "domain": {
467
+ "description": "Fully qualified DNS name to diagnose.",
468
+ "name": "domain",
469
+ "required": true
470
+ }
471
+ },
472
+ "description": "Compare DNS provider records with system and public resolver answers.",
473
+ "examples": [
474
+ "<%= config.bin %> <%= command.id %> example.com --json",
475
+ "<%= config.bin %> <%= command.id %> app.example.com --type A --target 203.0.113.10 --json"
476
+ ],
477
+ "flags": {
478
+ "account": {
479
+ "description": "DNS provider profile/account alias. Defaults to the provider default account.",
480
+ "name": "account",
481
+ "hasDynamicHelp": false,
482
+ "multiple": false,
483
+ "type": "option"
484
+ },
485
+ "json": {
486
+ "description": "Output a single JSON object and never prompt.",
487
+ "name": "json",
488
+ "allowNo": false,
489
+ "type": "boolean"
490
+ },
491
+ "provider": {
492
+ "description": "DNS provider id. Inferred from the target domain when omitted.",
493
+ "name": "provider",
494
+ "hasDynamicHelp": false,
495
+ "multiple": false,
496
+ "type": "option"
497
+ },
498
+ "target": {
499
+ "description": "Expected IP address or canonical hostname.",
500
+ "name": "target",
501
+ "hasDynamicHelp": false,
502
+ "multiple": false,
503
+ "type": "option"
504
+ },
505
+ "type": {
506
+ "description": "Record type to compare.",
507
+ "name": "type",
508
+ "hasDynamicHelp": false,
509
+ "multiple": false,
510
+ "options": [
511
+ "A",
512
+ "AAAA",
513
+ "CNAME"
514
+ ],
515
+ "type": "option"
516
+ }
517
+ },
518
+ "hasDynamicHelp": false,
519
+ "hiddenAliases": [],
520
+ "id": "dns:diagnose",
521
+ "pluginAlias": "doomain",
522
+ "pluginName": "doomain",
523
+ "pluginType": "core",
524
+ "strict": true,
525
+ "enableJsonFlag": false,
526
+ "isESM": true,
527
+ "relativePath": [
528
+ "dist",
529
+ "commands",
530
+ "dns",
531
+ "diagnose.js"
532
+ ]
533
+ },
377
534
  "dns:point": {
378
535
  "aliases": [],
379
536
  "args": {
@@ -481,6 +638,92 @@
481
638
  "point.js"
482
639
  ]
483
640
  },
641
+ "dns:remove": {
642
+ "aliases": [],
643
+ "args": {
644
+ "domain": {
645
+ "description": "Fully qualified DNS name whose record should be removed.",
646
+ "name": "domain",
647
+ "required": true
648
+ }
649
+ },
650
+ "description": "Safely remove exact DNS records and verify their absence.",
651
+ "examples": [
652
+ "<%= config.bin %> <%= command.id %> app.example.com --type A --value 203.0.113.10 --dry-run --json",
653
+ "<%= config.bin %> <%= command.id %> app.example.com --type A --all-matching --json"
654
+ ],
655
+ "flags": {
656
+ "account": {
657
+ "description": "DNS provider profile/account alias. Defaults to the provider default account.",
658
+ "name": "account",
659
+ "hasDynamicHelp": false,
660
+ "multiple": false,
661
+ "type": "option"
662
+ },
663
+ "all-matching": {
664
+ "description": "Delete every record matching the name and type (and value, if set).",
665
+ "name": "all-matching",
666
+ "allowNo": false,
667
+ "type": "boolean"
668
+ },
669
+ "dry-run": {
670
+ "description": "Preview the exact records without deleting them.",
671
+ "name": "dry-run",
672
+ "allowNo": false,
673
+ "type": "boolean"
674
+ },
675
+ "json": {
676
+ "description": "Output a single JSON object and never prompt.",
677
+ "name": "json",
678
+ "allowNo": false,
679
+ "type": "boolean"
680
+ },
681
+ "provider": {
682
+ "description": "DNS provider id. Inferred from the target domain when omitted.",
683
+ "name": "provider",
684
+ "hasDynamicHelp": false,
685
+ "multiple": false,
686
+ "type": "option"
687
+ },
688
+ "type": {
689
+ "description": "DNS record type to remove.",
690
+ "name": "type",
691
+ "required": true,
692
+ "hasDynamicHelp": false,
693
+ "multiple": false,
694
+ "options": [
695
+ "A",
696
+ "AAAA",
697
+ "CNAME",
698
+ "MX",
699
+ "TXT"
700
+ ],
701
+ "type": "option"
702
+ },
703
+ "value": {
704
+ "description": "Expected record value. Required unless --all-matching is passed.",
705
+ "name": "value",
706
+ "hasDynamicHelp": false,
707
+ "multiple": false,
708
+ "type": "option"
709
+ }
710
+ },
711
+ "hasDynamicHelp": false,
712
+ "hiddenAliases": [],
713
+ "id": "dns:remove",
714
+ "pluginAlias": "doomain",
715
+ "pluginName": "doomain",
716
+ "pluginType": "core",
717
+ "strict": true,
718
+ "enableJsonFlag": false,
719
+ "isESM": true,
720
+ "relativePath": [
721
+ "dist",
722
+ "commands",
723
+ "dns",
724
+ "delete.js"
725
+ ]
726
+ },
484
727
  "domains:find": {
485
728
  "aliases": [],
486
729
  "args": {
@@ -1063,5 +1306,5 @@
1063
1306
  ]
1064
1307
  }
1065
1308
  },
1066
- "version": "0.1.21"
1309
+ "version": "0.1.22"
1067
1310
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doomain",
3
3
  "description": "Configure production domains and DNS records in seconds",
4
- "version": "0.1.21",
4
+ "version": "0.1.22",
5
5
  "author": "Crafter Station",
6
6
  "packageManager": "bun@1.3.13",
7
7
  "bin": {
@@ -82,12 +82,13 @@
82
82
  "scripts": {
83
83
  "build": "shx rm -rf dist && tsc -b",
84
84
  "check": "biome lint .",
85
+ "check:manifest": "node scripts/check-manifest-version.js",
85
86
  "format": "biome format --write .",
86
87
  "lint": "biome lint .",
87
88
  "postpack": "shx rm -f oclif.manifest.json",
88
89
  "posttest": "bun run lint",
89
90
  "prepare": "husky",
90
- "prepack": "oclif manifest && oclif readme",
91
+ "prepack": "oclif manifest && bun run check:manifest && oclif readme",
91
92
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
92
93
  "version": "oclif readme && git add README.md"
93
94
  },