doomain 0.1.12 → 0.1.14

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,7 @@ 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
  - Dry-run plans before writing changes.
16
+ - Safety checks before replacing DNS records that point elsewhere.
16
17
  - DNS propagation and Vercel verification wait loop.
17
18
  - DNS provider support for Spaceship, Namecheap, Cloudflare, and Hostinger.
18
19
 
@@ -40,7 +41,8 @@ The wizard will:
40
41
  4. Connect a DNS provider if none is configured.
41
42
  5. List domains from configured DNS providers.
42
43
  6. Preview the Vercel and DNS changes.
43
- 7. Apply the changes and request Vercel verification.
44
+ 7. Warn if the current DNS target points elsewhere and ask before overriding it.
45
+ 8. Apply the changes and request Vercel verification.
44
46
 
45
47
  If you already know the target project and domain, run the link command directly:
46
48
 
@@ -84,6 +86,8 @@ Apply it:
84
86
  doomain link app.example.com --project my-vercel-project --json
85
87
  ```
86
88
 
89
+ If JSON mode returns `DNS_TARGET_CONFLICT`, the current DNS target appears to point to another project or site. Re-run with `--force` only when you intend to replace that DNS target.
90
+
87
91
  ## Provider Setup
88
92
 
89
93
  Doomain stores local credentials in `~/.doomain/config.json` with `0600` file permissions. Environment variables override saved config values.
@@ -257,8 +261,10 @@ DNS conflict rules:
257
261
 
258
262
  - Existing exact records are skipped.
259
263
  - TXT records can coexist at the same name.
260
- - Same-name, same-type conflicts require `--force`.
261
- - CNAME slot conflicts require `--force` because a CNAME cannot share a name with most other record types.
264
+ - Same-name, same-type conflicts trigger an interactive override prompt or require `--force` in JSON/non-interactive mode.
265
+ - CNAME slot conflicts trigger an interactive override prompt or require `--force` in JSON/non-interactive mode because a CNAME cannot share a name with most other record types.
266
+
267
+ For real links, Doomain inspects the target `A` or `CNAME` DNS slot before adding the domain to Vercel. In interactive mode, it shows the existing and desired records and asks whether to override. In JSON mode, it fails with `DNS_TARGET_CONFLICT` instead of prompting.
262
268
 
263
269
  Use `--force` only when you intend to replace conflicting DNS records or move an existing Vercel alias:
264
270
 
@@ -268,6 +274,8 @@ doomain link app.example.com --project my-app --force
268
274
 
269
275
  `--force` can remove an existing Vercel alias from another project and add it to the target project.
270
276
 
277
+ Confirming the interactive DNS override only forces DNS writes. If Vercel says the domain is already assigned to another project, re-run with `--force` to move that Vercel alias.
278
+
271
279
  Namecheap note: Namecheap's API writes DNS through `setHosts`, which replaces the full host list. Doomain reads all existing records first, applies planned changes in memory, preserves unrelated records, then submits the complete final record set.
272
280
 
273
281
  ## JSON And Agent Usage
@@ -333,7 +341,7 @@ Common flags:
333
341
  - `-p, --project <project>`: Vercel project id or name.
334
342
  - `--provider <id>`: DNS provider id.
335
343
  - `--dry-run`: preview without writing.
336
- - `--force`: overwrite DNS conflicts and allow Vercel alias moves.
344
+ - `--force`: overwrite DNS conflicts without prompting and allow Vercel alias moves.
337
345
  - `--wait`, `--no-wait`: wait for DNS and Vercel verification. Default is `--wait`.
338
346
  - `--timeout <seconds>`: wait timeout. Default is `300`.
339
347
  - `--json`: output one JSON object.
@@ -345,6 +353,7 @@ doomain link app.example.com --project my-app
345
353
  doomain link --domain example.com --subdomain app --project my-app
346
354
  doomain link --domain example.com --apex --project my-app
347
355
  doomain link app.example.com --project my-app --dry-run --json
356
+ doomain link app.example.com --project my-app --force
348
357
  ```
349
358
 
350
359
  ### `doomain auth vercel`
@@ -567,6 +576,10 @@ DNS propagation timeout
567
576
 
568
577
  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`.
569
578
 
579
+ DNS target conflict
580
+
581
+ The domain already has a conflicting `A` or `CNAME` record, which usually means it points to another project or site. In interactive mode, confirm the override only if you intend to replace that target. In JSON mode, re-run `doomain link` with `--force` to overwrite DNS.
582
+
570
583
  Domain already assigned to another Vercel project
571
584
 
572
585
  If you intend to move it, re-run `doomain link` with `--force`. This can remove the alias from the previous Vercel project.
@@ -1,8 +1,32 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import * as p from '@clack/prompts';
2
3
  import { DoomainError } from '../lib/errors.js';
3
4
  import { accountFlag, apexFlag, domainFlag, jsonFlag, projectFlag, providerFlag, subdomainFlag } from '../lib/flags.js';
4
5
  import { linkDomain } from '../lib/link-domain.js';
5
6
  import { createOutput, outputError } from '../lib/output.js';
7
+ function recordName(name) {
8
+ return name === '@' ? 'root' : name;
9
+ }
10
+ function recordLine(record) {
11
+ const details = [
12
+ record.priority === undefined ? undefined : `priority ${record.priority}`,
13
+ record.proxied === undefined ? undefined : `proxied ${record.proxied}`,
14
+ record.ttl === undefined ? undefined : `ttl ${record.ttl}`,
15
+ ].filter(Boolean);
16
+ return `${record.type} ${recordName(record.name)} -> ${record.value}${details.length > 0 ? ` (${details.join(', ')})` : ''}`;
17
+ }
18
+ function dnsOverrideNote(warning) {
19
+ const account = warning.account === 'default' ? warning.providerName : `${warning.providerName}/${warning.account}`;
20
+ return [
21
+ `${warning.domain} already has DNS records in ${account} (${warning.zoneDomain}) that do not match the Vercel target.`,
22
+ '',
23
+ 'Existing:',
24
+ ...warning.conflicts.map((conflict) => `- ${recordLine(conflict.existing)}`),
25
+ '',
26
+ 'Desired:',
27
+ ...warning.desired.map((record) => `- ${recordLine(record)}`),
28
+ ].join('\n');
29
+ }
6
30
  export default class Link extends Command {
7
31
  static description = 'Link a Vercel project to a domain and create DNS records.';
8
32
  static examples = [
@@ -51,6 +75,22 @@ export default class Link extends Command {
51
75
  const result = await linkDomain({
52
76
  ...flags,
53
77
  domain,
78
+ confirmDnsOverride: out.json
79
+ ? undefined
80
+ : async (warning) => {
81
+ spinner?.stop('Existing DNS target found');
82
+ p.note(dnsOverrideNote(warning), 'DNS already points elsewhere');
83
+ const confirmed = await p.confirm({
84
+ message: `Override existing DNS records for ${warning.domain}?`,
85
+ initialValue: false,
86
+ });
87
+ if (confirmed === true) {
88
+ spinner?.start('Continuing domain link');
89
+ return true;
90
+ }
91
+ spinner = undefined;
92
+ return false;
93
+ },
54
94
  dryRun: false,
55
95
  progress: out.json ? undefined : ({ message }) => spinner?.message(message),
56
96
  timeoutSeconds: flags.timeout,
@@ -157,9 +157,31 @@ async function promptVercelToken(globalTokens) {
157
157
  }
158
158
  return promptRequired('Vercel token', { password: true });
159
159
  }
160
+ function recordName(name) {
161
+ return name === '@' ? 'root' : name;
162
+ }
163
+ function recordLine(record) {
164
+ const details = [
165
+ record.priority === undefined ? undefined : `priority ${record.priority}`,
166
+ record.proxied === undefined ? undefined : `proxied ${record.proxied}`,
167
+ record.ttl === undefined ? undefined : `ttl ${record.ttl}`,
168
+ ].filter(Boolean);
169
+ return `${record.type} ${recordName(record.name)} -> ${record.value}${details.length > 0 ? ` (${details.join(', ')})` : ''}`;
170
+ }
160
171
  function recordPreview(record, providerName) {
161
- const name = record.name === '@' ? 'root' : record.name;
162
- return `DNS: ${record.type} ${name} -> ${record.value} in ${providerName}`;
172
+ return `DNS: ${recordLine(record)} in ${providerName}`;
173
+ }
174
+ function dnsOverrideNote(warning) {
175
+ const account = warning.account === DEFAULT_PROVIDER_ACCOUNT ? warning.providerName : `${warning.providerName}/${warning.account}`;
176
+ return [
177
+ `${warning.domain} already has DNS records in ${account} (${warning.zoneDomain}) that do not match the Vercel target.`,
178
+ '',
179
+ 'Existing:',
180
+ ...warning.conflicts.map((conflict) => `- ${recordLine(conflict.existing)}`),
181
+ '',
182
+ 'Desired:',
183
+ ...warning.desired.map((record) => `- ${recordLine(record)}`),
184
+ ].join('\n');
163
185
  }
164
186
  export default class Wizard extends Command {
165
187
  static description = 'Interactive Vercel domain linker.';
@@ -389,6 +411,20 @@ export default class Wizard extends Command {
389
411
  subdomain,
390
412
  apex,
391
413
  project,
414
+ confirmDnsOverride: async (warning) => {
415
+ spinner.stop('Existing DNS target found');
416
+ p.note(dnsOverrideNote(warning), 'DNS already points elsewhere');
417
+ const confirmed = await p.confirm({
418
+ message: `Override existing DNS records for ${warning.domain}?`,
419
+ initialValue: false,
420
+ });
421
+ if (confirmed === true) {
422
+ spinner.start('Continuing domain link');
423
+ return true;
424
+ }
425
+ activeSpinner = undefined;
426
+ return false;
427
+ },
392
428
  progress: ({ message }) => spinner.message(message),
393
429
  wait: true,
394
430
  });
@@ -1,4 +1,4 @@
1
- export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | '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' | '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';
2
2
  export declare class DoomainError extends Error {
3
3
  readonly code: DoomainErrorCode;
4
4
  readonly details?: unknown;
@@ -1,4 +1,4 @@
1
- import type { DnsRecordInput } from './providers/types.js';
1
+ import type { DnsConflict, DnsRecordInput } from './providers/types.js';
2
2
  export interface LinkDomainInput {
3
3
  provider?: string;
4
4
  account?: string;
@@ -11,14 +11,25 @@ export interface LinkDomainInput {
11
11
  wait?: boolean;
12
12
  timeoutSeconds?: number;
13
13
  progress?: LinkDomainProgressCallback;
14
+ confirmDnsOverride?: (warning: DnsOverrideWarning) => Promise<boolean>;
14
15
  }
15
16
  export type LinkDomainProjectSource = 'config' | 'env' | 'flag' | 'packageJson' | 'vercelProjectFile';
16
- export type LinkDomainProgressStage = 'dns:apply' | 'dns:plan' | 'dns:resolve-zone' | 'dns:wait' | 'vercel:add-domain' | 'vercel:get-domain' | 'vercel:get-target' | 'vercel:verify';
17
+ export type LinkDomainProgressStage = 'dns:apply' | 'dns:inspect' | 'dns:override-confirm' | 'dns:plan' | 'dns:resolve-zone' | 'dns:wait' | 'vercel:add-domain' | 'vercel:get-domain' | 'vercel:get-target' | 'vercel:verify';
17
18
  export interface LinkDomainProgress {
18
19
  message: string;
19
20
  stage: LinkDomainProgressStage;
20
21
  }
21
22
  export type LinkDomainProgressCallback = (progress: LinkDomainProgress) => void;
23
+ export interface DnsOverrideWarning {
24
+ account: string;
25
+ conflicts: DnsConflict[];
26
+ desired: DnsRecordInput[];
27
+ domain: string;
28
+ provider: string;
29
+ providerName: string;
30
+ recordName: string;
31
+ zoneDomain: string;
32
+ }
22
33
  export interface LinkDomainPlan {
23
34
  provider: string;
24
35
  providerInferred: boolean;
@@ -459,6 +459,41 @@ async function waitForVercelDomainReady(opts) {
459
459
  function reportProgress(input, stage, message) {
460
460
  input.progress?.({ message, stage });
461
461
  }
462
+ function dnsTargetConflictError(warning) {
463
+ return new DoomainError('DNS_TARGET_CONFLICT', `${warning.domain} already has DNS records that point somewhere else. Re-run with --force to overwrite them.`, {
464
+ account: warning.account,
465
+ conflicts: warning.conflicts,
466
+ desired: warning.desired,
467
+ domain: warning.domain,
468
+ provider: warning.provider,
469
+ providerName: warning.providerName,
470
+ recovery: 'Confirm the DNS override in interactive mode, or re-run with --force to overwrite conflicting DNS records.',
471
+ recordName: warning.recordName,
472
+ suggestedCommands: [`doomain link ${warning.domain} --project <project> --force --json`],
473
+ zoneDomain: warning.zoneDomain,
474
+ });
475
+ }
476
+ async function resolveDnsForce(input, opts) {
477
+ reportProgress(input, 'dns:inspect', `Checking existing DNS records in ${opts.provider.name}`);
478
+ const dnsPlan = await opts.provider.planChanges(opts.zone, [opts.baseRecord], { force: input.force });
479
+ if (input.force || dnsPlan.conflicts.length === 0)
480
+ return Boolean(input.force);
481
+ const warning = {
482
+ account: opts.plan.account,
483
+ conflicts: dnsPlan.conflicts,
484
+ desired: [opts.baseRecord],
485
+ domain: opts.plan.domain,
486
+ provider: opts.plan.provider,
487
+ providerName: opts.provider.name,
488
+ recordName: opts.plan.recordName,
489
+ zoneDomain: opts.plan.zoneDomain,
490
+ };
491
+ reportProgress(input, 'dns:override-confirm', `Existing DNS records point ${opts.plan.domain} somewhere else`);
492
+ const confirmed = await input.confirmDnsOverride?.(warning);
493
+ if (!confirmed)
494
+ throw dnsTargetConflictError(warning);
495
+ return true;
496
+ }
462
497
  export async function createLinkPlan(input) {
463
498
  const project = await resolveProject(input.project);
464
499
  const resolved = await resolveProviderTarget(input);
@@ -495,22 +530,23 @@ export async function linkDomain(input) {
495
530
  const provider = await createProvider(plan.provider, { account: plan.account });
496
531
  reportProgress(input, 'dns:resolve-zone', `Finding ${provider.name} DNS zone`);
497
532
  const zone = await resolveZone(provider, plan.zoneDomain);
498
- reportProgress(input, 'vercel:add-domain', 'Adding domain to Vercel');
499
- const addResult = await vercel.addDomainToProject(plan.project, plan.domain, { force: input.force });
500
533
  reportProgress(input, 'vercel:get-target', 'Reading Vercel DNS target');
501
534
  const cname = plan.isApex ? undefined : await vercel.getRecommendedCname(plan.domain);
535
+ const baseRecord = planBaseRecord({ isApex: plan.isApex, provider: plan.provider, recordName: plan.recordName, cname });
536
+ const forceDns = await resolveDnsForce(input, { baseRecord, plan, provider, zone });
537
+ reportProgress(input, 'vercel:add-domain', 'Adding domain to Vercel');
538
+ const addResult = await vercel.addDomainToProject(plan.project, plan.domain, { force: input.force });
502
539
  reportProgress(input, 'vercel:get-domain', 'Reading Vercel verification records');
503
540
  const projectDomain = await vercel.getProjectDomain(plan.project, plan.domain);
504
- const baseRecord = planBaseRecord({ isApex: plan.isApex, provider: plan.provider, recordName: plan.recordName, cname });
505
541
  const verificationDnsRecords = uniqueRecords([
506
542
  ...planVerificationRecords(plan.provider, addResult.raw, plan.zoneDomain),
507
543
  ...planVerificationRecords(plan.provider, projectDomain, plan.zoneDomain),
508
544
  ]);
509
545
  const records = [baseRecord, ...verificationDnsRecords];
510
546
  reportProgress(input, 'dns:plan', `Reading ${provider.name} DNS records`);
511
- const dnsPlan = await provider.planChanges(zone, records, { force: input.force });
547
+ const dnsPlan = await provider.planChanges(zone, records, { force: forceDns });
512
548
  reportProgress(input, 'dns:apply', `Updating DNS records in ${provider.name}`);
513
- const dnsResult = await provider.applyChanges(zone, dnsPlan, { force: input.force });
549
+ const dnsResult = await provider.applyChanges(zone, dnsPlan, { force: forceDns });
514
550
  const shouldWait = input.wait ?? true;
515
551
  if (shouldWait) {
516
552
  reportProgress(input, 'dns:wait', verificationDnsRecords.length > 0 ? 'DNS records saved; asking Vercel to verify ownership' : 'DNS records saved; asking Vercel to verify');
@@ -518,7 +554,7 @@ export async function linkDomain(input) {
518
554
  const waitResult = shouldWait
519
555
  ? await waitForVercelDomainReady({
520
556
  domain: plan.domain,
521
- force: input.force,
557
+ force: forceDns,
522
558
  input,
523
559
  project: plan.project,
524
560
  provider,
@@ -316,41 +316,6 @@
316
316
  "list.js"
317
317
  ]
318
318
  },
319
- "projects:list": {
320
- "aliases": [],
321
- "args": {},
322
- "description": "List Vercel projects.",
323
- "flags": {
324
- "json": {
325
- "description": "Output a single JSON object and never prompt.",
326
- "name": "json",
327
- "allowNo": false,
328
- "type": "boolean"
329
- },
330
- "search": {
331
- "description": "Filter projects by search term.",
332
- "name": "search",
333
- "hasDynamicHelp": false,
334
- "multiple": false,
335
- "type": "option"
336
- }
337
- },
338
- "hasDynamicHelp": false,
339
- "hiddenAliases": [],
340
- "id": "projects:list",
341
- "pluginAlias": "doomain",
342
- "pluginName": "doomain",
343
- "pluginType": "core",
344
- "strict": true,
345
- "enableJsonFlag": false,
346
- "isESM": true,
347
- "relativePath": [
348
- "dist",
349
- "commands",
350
- "projects",
351
- "list.js"
352
- ]
353
- },
354
319
  "providers:add": {
355
320
  "aliases": [],
356
321
  "args": {
@@ -639,6 +604,41 @@
639
604
  "verify.js"
640
605
  ]
641
606
  },
607
+ "projects:list": {
608
+ "aliases": [],
609
+ "args": {},
610
+ "description": "List Vercel projects.",
611
+ "flags": {
612
+ "json": {
613
+ "description": "Output a single JSON object and never prompt.",
614
+ "name": "json",
615
+ "allowNo": false,
616
+ "type": "boolean"
617
+ },
618
+ "search": {
619
+ "description": "Filter projects by search term.",
620
+ "name": "search",
621
+ "hasDynamicHelp": false,
622
+ "multiple": false,
623
+ "type": "option"
624
+ }
625
+ },
626
+ "hasDynamicHelp": false,
627
+ "hiddenAliases": [],
628
+ "id": "projects:list",
629
+ "pluginAlias": "doomain",
630
+ "pluginName": "doomain",
631
+ "pluginType": "core",
632
+ "strict": true,
633
+ "enableJsonFlag": false,
634
+ "isESM": true,
635
+ "relativePath": [
636
+ "dist",
637
+ "commands",
638
+ "projects",
639
+ "list.js"
640
+ ]
641
+ },
642
642
  "auth:logout:vercel": {
643
643
  "aliases": [],
644
644
  "args": {},
@@ -673,5 +673,5 @@
673
673
  ]
674
674
  }
675
675
  },
676
- "version": "0.1.12"
676
+ "version": "0.1.14"
677
677
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doomain",
3
3
  "description": "Link your vercel project and domain in seconds",
4
- "version": "0.1.12",
4
+ "version": "0.1.14",
5
5
  "author": "Crafter Station",
6
6
  "packageManager": "bun@1.3.13",
7
7
  "bin": {