doomain 0.1.15 → 0.1.17

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
@@ -1,6 +1,6 @@
1
1
  # Doomain
2
2
 
3
- Doomain links a Vercel project to a custom domain from your terminal.
3
+ Doomain links Vercel projects and first-time Clerk production instances to custom domains from your terminal.
4
4
 
5
5
  It handles the boring parts of custom-domain setup: selecting the Vercel project, finding the right DNS zone, adding the domain to Vercel, writing the Vercel DNS records, waiting for public DNS propagation, and asking Vercel to verify the domain.
6
6
 
@@ -16,6 +16,7 @@ Use the interactive wizard when working by hand. Use explicit commands with `--j
16
16
  - Safety checks before replacing DNS records that point elsewhere.
17
17
  - DNS propagation and Vercel verification wait loop.
18
18
  - DNS provider support for Spaceship, Namecheap, Cloudflare, and Hostinger.
19
+ - First-time Clerk production setup with automatic CNAME configuration.
19
20
 
20
21
  ## Install
21
22
 
@@ -86,6 +87,17 @@ Apply it:
86
87
  doomain link app.example.com --project my-vercel-project --json
87
88
  ```
88
89
 
90
+ Set up a Clerk application's first production instance and primary domain:
91
+
92
+ ```bash
93
+ doomain auth clerk --platform-api-key "$CLERK_PLATFORM_API_KEY" --app app_123 --json
94
+ doomain clerk domains add example.com --app app_123 --json
95
+ ```
96
+
97
+ Create a Platform API key from the Clerk Dashboard API keys page. Platform keys start with `ak_`; Clerk instance secret keys (`sk_`) cannot create a production instance.
98
+
99
+ This Clerk command intentionally aborts when the application already has a production instance. Change existing production domains manually in the Clerk Dashboard or with Clerk CLI.
100
+
89
101
  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
102
 
91
103
  ## Provider Setup
@@ -142,7 +154,7 @@ doomain providers connect cloudflare \
142
154
  --credential accountId=your_cloudflare_account_id
143
155
  ```
144
156
 
145
- Cloudflare records created for Vercel `A`, `AAAA`, and `CNAME` targets are set to `proxied: false` so Vercel can validate the domain.
157
+ Cloudflare records created for Vercel or Clerk `A`, `AAAA`, and `CNAME` targets are set to `proxied: false` so the service can validate the domain.
146
158
 
147
159
  ### Hostinger
148
160
 
@@ -209,6 +221,27 @@ Use `-p` as shorthand for `--project`:
209
221
  doomain link app.example.com -p my-app
210
222
  ```
211
223
 
224
+ ## Clerk Production Domains
225
+
226
+ `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.
227
+
228
+ ```bash
229
+ doomain clerk domains add example.com --app app_123
230
+ ```
231
+
232
+ Use `--dry-run` to verify that the application has no production instance and that Doomain can resolve the DNS zone. Clerk only returns the exact CNAME records after production is created, so they are not included in the dry-run result.
233
+
234
+ If a production instance already exists, the command returns `CLERK_PRODUCTION_EXISTS` without changing DNS. Doomain does not automate production-domain migrations because Clerk domain changes can cause downtime and require publishable-key, OAuth redirect, and deployment updates.
235
+
236
+ After creation, the result includes Clerk CLI follow-up commands. Run them to link the local project, pull production keys, finish production OAuth configuration, and verify provisioning:
237
+
238
+ ```bash
239
+ clerk link --app app_123
240
+ clerk env pull --app app_123 --instance prod
241
+ clerk deploy
242
+ clerk deploy status
243
+ ```
244
+
212
245
  ## What Gets Created
213
246
 
214
247
  For apex/root domains, Doomain creates Vercel's apex `A` record:
@@ -304,8 +337,10 @@ Useful agent-safe commands:
304
337
  doomain link app.example.com --project my-app --json
305
338
  doomain providers list --json
306
339
  doomain providers status --no-verify --json
340
+ doomain domains find hacktheandes.com --json
307
341
  doomain domains list --provider cloudflare --domain example.com --json
308
342
  doomain projects list --search my-app --json
343
+ doomain clerk domains add example.com --app app_123 --json
309
344
  doomain schema --json
310
345
  doomain schema link --json
311
346
  ```
@@ -317,6 +352,19 @@ doomain schema --json
317
352
  doomain schema "providers connect" --json
318
353
  ```
319
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
+
320
368
  ## Command Reference
321
369
 
322
370
  Run `doomain help <command>` for oclif-generated help.
@@ -376,6 +424,28 @@ doomain auth logout vercel --json
376
424
 
377
425
  If `VERCEL_TOKEN` or `VERCEL_TEAM_ID` are still set, they continue to override local config.
378
426
 
427
+ ### `doomain auth clerk`
428
+
429
+ Saves and verifies a Clerk Platform API key and default application id.
430
+
431
+ ```bash
432
+ doomain auth clerk --platform-api-key ak_123 --app app_123
433
+ ```
434
+
435
+ ### `doomain auth logout clerk`
436
+
437
+ Removes saved Clerk credentials. `CLERK_PLATFORM_API_KEY` and `CLERK_APPLICATION_ID` continue to override local config when set.
438
+
439
+ ### `doomain clerk domains add <domain>`
440
+
441
+ Creates the application's first Clerk production instance and primary domain, then configures Clerk's returned CNAME records.
442
+
443
+ ```bash
444
+ doomain clerk domains add example.com --app app_123
445
+ doomain clerk domains add example.com --app app_123 --dry-run --json
446
+ doomain clerk domains add example.com --app app_123 --no-wait --json
447
+ ```
448
+
379
449
  ### `doomain providers list`
380
450
 
381
451
  Lists supported DNS providers.
@@ -441,6 +511,18 @@ doomain providers logout namecheap --json
441
511
 
442
512
  Environment variables for that provider still override local config after disconnect.
443
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
+
444
526
  ### `doomain domains list`
445
527
 
446
528
  Lists DNS zones and records.
@@ -491,6 +573,13 @@ VERCEL_TOKEN
491
573
  VERCEL_TEAM_ID
492
574
  ```
493
575
 
576
+ Clerk:
577
+
578
+ ```bash
579
+ CLERK_PLATFORM_API_KEY
580
+ CLERK_APPLICATION_ID
581
+ ```
582
+
494
583
  Spaceship:
495
584
 
496
585
  ```bash
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AuthClerk extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ app: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ 'platform-api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,55 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import * as p from '@clack/prompts';
3
+ import { createClerkPlatformClient } from '../../lib/clerk.js';
4
+ import { getConfigPath, maskSecret, updateConfig } from '../../lib/config.js';
5
+ import { jsonFlag } from '../../lib/flags.js';
6
+ import { createOutput, outputError } from '../../lib/output.js';
7
+ function value(input, message) {
8
+ const resolved = input?.trim();
9
+ if (!resolved)
10
+ throw new Error(message);
11
+ return resolved;
12
+ }
13
+ export default class AuthClerk extends Command {
14
+ static description = 'Save Clerk Platform API credentials locally.';
15
+ static examples = ['<%= config.bin %> <%= command.id %> --platform-api-key ak_123 --app app_123 --json'];
16
+ static flags = {
17
+ app: Flags.string({ description: 'Default Clerk application id.' }),
18
+ json: jsonFlag,
19
+ 'platform-api-key': Flags.string({ description: 'Clerk Platform API key (ak_...).' }),
20
+ };
21
+ async run() {
22
+ const { flags } = await this.parse(AuthClerk);
23
+ const out = createOutput({ json: flags.json });
24
+ try {
25
+ let platformApiKey = flags['platform-api-key'] ?? process.env.CLERK_PLATFORM_API_KEY;
26
+ let appId = flags.app ?? process.env.CLERK_APPLICATION_ID;
27
+ if (!out.json) {
28
+ if (!platformApiKey) {
29
+ const entered = await p.password({ message: 'Clerk Platform API key' });
30
+ if (p.isCancel(entered))
31
+ return;
32
+ platformApiKey = entered;
33
+ }
34
+ if (!appId) {
35
+ const entered = await p.text({ message: 'Default Clerk application id' });
36
+ if (p.isCancel(entered))
37
+ return;
38
+ appId = entered;
39
+ }
40
+ }
41
+ platformApiKey = value(platformApiKey, 'Missing Clerk Platform API key. Pass --platform-api-key or set CLERK_PLATFORM_API_KEY.');
42
+ appId = value(appId, 'Missing Clerk application id. Pass --app or set CLERK_APPLICATION_ID.');
43
+ if (!platformApiKey.startsWith('ak_'))
44
+ throw new Error('Clerk Platform API keys must start with ak_.');
45
+ await createClerkPlatformClient({ platformApiKey }).fetchApplication(appId);
46
+ await updateConfig((config) => ({ ...config, clerk: { appId, platformApiKey } }));
47
+ out.result({ clerk: { appId, platformApiKey: maskSecret(platformApiKey) }, configPath: getConfigPath() });
48
+ out.success(`Clerk credentials saved to ${getConfigPath()}.`);
49
+ }
50
+ catch (error) {
51
+ outputError(out.json, error, 'CLERK_AUTH_FAILED');
52
+ this.exit(1);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AuthLogoutClerk extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
+ };
7
+ run(): Promise<void>;
8
+ }
@@ -0,0 +1,29 @@
1
+ import { Command } from '@oclif/core';
2
+ import { getConfigPath, updateConfig } from '../../../lib/config.js';
3
+ import { jsonFlag } from '../../../lib/flags.js';
4
+ import { createOutput, outputError } from '../../../lib/output.js';
5
+ export default class AuthLogoutClerk extends Command {
6
+ static description = 'Remove saved Clerk credentials locally.';
7
+ static flags = { json: jsonFlag };
8
+ async run() {
9
+ const { flags } = await this.parse(AuthLogoutClerk);
10
+ const out = createOutput({ json: flags.json });
11
+ try {
12
+ let removed = false;
13
+ await updateConfig((config) => {
14
+ removed = config.clerk !== undefined;
15
+ const { clerk: _clerk, ...next } = config;
16
+ return next;
17
+ });
18
+ const environmentOverrides = ['CLERK_PLATFORM_API_KEY', 'CLERK_APPLICATION_ID'].filter((key) => process.env[key]);
19
+ out.result({ configPath: getConfigPath(), environmentOverrides, removed, service: 'clerk' });
20
+ if (environmentOverrides.length > 0)
21
+ out.warn(`Clerk environment credentials are still set: ${environmentOverrides.join(', ')}.`);
22
+ out.success(removed ? `Clerk credentials removed from ${getConfigPath()}.` : 'Clerk credentials were not saved.');
23
+ }
24
+ catch (error) {
25
+ outputError(out.json, error, 'MISSING_CREDENTIALS');
26
+ this.exit(1);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,19 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class ClerkDomainsAdd 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
+ app: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ provider: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
16
+ wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ };
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,92 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import * as p from '@clack/prompts';
3
+ import { addClerkProductionDomain } from '../../../lib/clerk-domain.js';
4
+ import { accountFlag, jsonFlag, providerFlag } from '../../../lib/flags.js';
5
+ import { createOutput, outputError } from '../../../lib/output.js';
6
+ function recordLine(record) {
7
+ return `${record.type} ${record.name} -> ${record.value}${record.proxied === undefined ? '' : ` (proxied ${record.proxied})`}`;
8
+ }
9
+ function conflictNote(warning) {
10
+ return [
11
+ 'Existing:',
12
+ ...warning.conflicts.map((conflict) => `- ${recordLine(conflict.existing)}`),
13
+ '',
14
+ 'Required by Clerk:',
15
+ ...warning.desired.map((record) => `- ${recordLine(record)}`),
16
+ ].join('\n');
17
+ }
18
+ function resultMessage(result) {
19
+ if (result.clerk.verified)
20
+ return `${result.domain} is configured and verified for Clerk production.`;
21
+ return `${result.domain} is configured for Clerk production. Run ${result.nextSteps.at(-1)} to check provisioning.`;
22
+ }
23
+ export default class ClerkDomainsAdd extends Command {
24
+ static description = 'Create a Clerk production instance with its primary domain and configure DNS.';
25
+ static examples = [
26
+ '<%= config.bin %> <%= command.id %> example.com --app app_123 --json',
27
+ '<%= config.bin %> <%= command.id %> example.com --app app_123 --provider cloudflare --no-wait',
28
+ '<%= config.bin %> <%= command.id %> example.com --app app_123 --dry-run --json',
29
+ ];
30
+ static args = {
31
+ domain: Args.string({ description: 'Production primary domain, for example example.com.', required: true }),
32
+ };
33
+ static flags = {
34
+ account: accountFlag,
35
+ app: Flags.string({ description: 'Clerk application id. Defaults to CLERK_APPLICATION_ID or saved Clerk config.' }),
36
+ 'dry-run': Flags.boolean({ description: 'Check the Clerk application and DNS zone without creating the production instance.' }),
37
+ force: Flags.boolean({ description: 'Overwrite DNS records that conflict with Clerk requirements.' }),
38
+ json: jsonFlag,
39
+ provider: providerFlag,
40
+ timeout: Flags.integer({ default: 300, description: 'Wait timeout in seconds.' }),
41
+ wait: Flags.boolean({ allowNo: true, default: true, description: 'Wait for Clerk DNS, SSL, and email DNS verification.' }),
42
+ };
43
+ async run() {
44
+ const { args, flags } = await this.parse(ClerkDomainsAdd);
45
+ const out = createOutput({ json: flags.json });
46
+ let spinner;
47
+ try {
48
+ if (!out.json && !flags['dry-run']) {
49
+ p.note(`This creates the first production instance for Clerk application ${flags.app ?? 'the configured app'} and sets ${args.domain} as its primary domain. Existing production instances are never modified.`, 'Clerk production setup');
50
+ const confirmed = await p.confirm({ message: 'Create the Clerk production instance?', initialValue: false });
51
+ if (confirmed !== true) {
52
+ p.cancel('Cancelled');
53
+ return;
54
+ }
55
+ }
56
+ spinner = out.spinner();
57
+ spinner.start(flags['dry-run'] ? 'Checking Clerk production setup' : 'Creating Clerk production setup');
58
+ const result = await addClerkProductionDomain({
59
+ account: flags.account,
60
+ app: flags.app,
61
+ confirmDnsOverride: out.json
62
+ ? undefined
63
+ : async (warning) => {
64
+ spinner?.stop('DNS conflict found');
65
+ p.note(conflictNote(warning), 'DNS records point elsewhere');
66
+ const confirmed = await p.confirm({ message: 'Overwrite these DNS records?', initialValue: false });
67
+ if (confirmed === true)
68
+ spinner?.start('Continuing Clerk production setup');
69
+ return confirmed === true;
70
+ },
71
+ domain: args.domain,
72
+ dryRun: flags['dry-run'],
73
+ force: flags.force,
74
+ progress: out.json ? undefined : (message) => spinner?.message(message),
75
+ provider: flags.provider,
76
+ timeoutSeconds: flags.timeout,
77
+ wait: flags.wait,
78
+ });
79
+ spinner.stop(flags['dry-run'] ? 'Clerk production setup is available' : 'Clerk production domain configured');
80
+ out.result(result);
81
+ if (!out.json) {
82
+ p.note(result.nextSteps.map((step) => `- ${step}`).join('\n'), 'Next steps');
83
+ out.outro(flags['dry-run'] ? `Ready to create Clerk production for ${result.domain}.` : resultMessage(result));
84
+ }
85
+ }
86
+ catch (error) {
87
+ spinner?.error('Clerk production setup failed');
88
+ outputError(out.json, error, 'DOMAIN_LINK_FAILED');
89
+ this.exit(1);
90
+ }
91
+ }
92
+ }
@@ -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';
@@ -0,0 +1,39 @@
1
+ import { type ClerkDomainStatus } from './clerk.js';
2
+ import { type DnsOverrideWarning } from './link-domain.js';
3
+ import type { DnsRecordInput } from './providers/types.js';
4
+ export interface AddClerkDomainInput {
5
+ account?: string;
6
+ app?: string;
7
+ domain?: string;
8
+ dryRun?: boolean;
9
+ force?: boolean;
10
+ provider?: string;
11
+ wait?: boolean;
12
+ timeoutSeconds?: number;
13
+ confirmDnsOverride?: (warning: DnsOverrideWarning) => Promise<boolean>;
14
+ progress?: (message: string) => void;
15
+ }
16
+ export interface AddClerkDomainResult {
17
+ account: string;
18
+ app: string;
19
+ clerk: {
20
+ domainId?: string;
21
+ productionInstanceCreated: boolean;
22
+ productionInstanceId?: string;
23
+ status?: ClerkDomainStatus;
24
+ verified: boolean;
25
+ };
26
+ dns: {
27
+ propagated: boolean;
28
+ skipped: DnsRecordInput[];
29
+ updated: boolean;
30
+ };
31
+ domain: string;
32
+ dryRun: boolean;
33
+ isDefaultAccount: boolean;
34
+ provider: string;
35
+ records: DnsRecordInput[];
36
+ zoneDomain: string;
37
+ nextSteps: string[];
38
+ }
39
+ export declare function addClerkProductionDomain(input: AddClerkDomainInput): Promise<AddClerkDomainResult>;
@@ -0,0 +1,163 @@
1
+ import { createClerkPlatformClient, resolveClerkPlatformConfig } from './clerk.js';
2
+ import { resolveProviderTarget } from './domain-provider.js';
3
+ import { DoomainError } from './errors.js';
4
+ import { withProviderRecordOptions } from './link-domain.js';
5
+ import { createProvider } from './providers/registry.js';
6
+ import { normalizeDomain } from './validate.js';
7
+ function relativeRecordName(host, zoneDomain) {
8
+ const normalized = host.trim().toLowerCase().replace(/\.$/, '');
9
+ if (normalized === zoneDomain)
10
+ return '@';
11
+ if (!normalized.endsWith(`.${zoneDomain}`)) {
12
+ throw new DoomainError('INVALID_INPUT', `Clerk returned DNS host ${host}, which is outside the selected zone ${zoneDomain}.`);
13
+ }
14
+ return normalized.slice(0, -(zoneDomain.length + 1));
15
+ }
16
+ function recordsFromTargets(provider, zoneDomain, targets) {
17
+ return targets.map((target) => withProviderRecordOptions(provider, {
18
+ name: relativeRecordName(target.host, zoneDomain),
19
+ ttl: 300,
20
+ type: 'CNAME',
21
+ value: target.value.trim().replace(/\.$/, ''),
22
+ }));
23
+ }
24
+ function productionExistsError(app, instanceId) {
25
+ return new DoomainError('CLERK_PRODUCTION_EXISTS', `Clerk application ${app} already has a production instance. Configure domain changes manually in Clerk; Doomain will not modify an existing production domain.`, {
26
+ app,
27
+ instanceId,
28
+ recovery: 'Use the Clerk Dashboard Domains page or `clerk deploy` to inspect and configure the existing production instance.',
29
+ suggestedCommands: [`clerk link --app ${app}`, 'clerk deploy status', 'clerk open domains'],
30
+ });
31
+ }
32
+ function dnsConflictError(warning, productionInstanceId) {
33
+ return new DoomainError('DNS_TARGET_CONFLICT', `${warning.domain} has DNS records that conflict with Clerk's required records. Re-run with --force to overwrite them.`, {
34
+ ...warning,
35
+ partialState: { productionInstanceCreated: true, productionInstanceId },
36
+ recovery: 'The Clerk production instance now exists. Resolve the DNS conflict, then finish setup with `clerk deploy`.',
37
+ });
38
+ }
39
+ function statusComplete(check) {
40
+ return check?.required === false || check?.status === 'complete';
41
+ }
42
+ async function waitForStatus(client, app, domainId, timeoutSeconds, progress) {
43
+ await client.triggerDomainDnsCheck(app, domainId).catch((error) => {
44
+ const conflict = error instanceof DoomainError && JSON.stringify(error.details).toLowerCase().includes('conflict');
45
+ if (!conflict)
46
+ throw error;
47
+ });
48
+ const deadline = Date.now() + timeoutSeconds * 1000;
49
+ let status = { status: 'incomplete' };
50
+ let attempt = 1;
51
+ while (Date.now() <= deadline) {
52
+ progress?.(`Checking Clerk DNS, SSL, and email DNS status (attempt ${attempt})`);
53
+ status = await client.getDomainStatus(app, domainId);
54
+ if (status.status === 'complete')
55
+ return status;
56
+ attempt += 1;
57
+ if (Date.now() > deadline)
58
+ break;
59
+ await new Promise((resolve) => setTimeout(resolve, 5000));
60
+ }
61
+ return status;
62
+ }
63
+ export async function addClerkProductionDomain(input) {
64
+ const domain = normalizeDomain(input.domain ?? '');
65
+ const config = await resolveClerkPlatformConfig(input.app);
66
+ const client = createClerkPlatformClient(config);
67
+ input.progress?.('Checking Clerk application');
68
+ const application = await client.fetchApplication(config.appId);
69
+ const production = application.instances.find((instance) => instance.environment_type === 'production');
70
+ if (production)
71
+ throw productionExistsError(config.appId, production.instance_id);
72
+ const development = application.instances.find((instance) => instance.environment_type === 'development');
73
+ if (!development)
74
+ throw new DoomainError('PROJECT_NOT_FOUND', `Clerk application ${config.appId} does not have a development instance to clone.`);
75
+ input.progress?.('Finding the DNS provider and zone');
76
+ const resolved = await resolveProviderTarget({ account: input.account, domain, provider: input.provider });
77
+ const provider = await createProvider(resolved.provider, { account: resolved.account });
78
+ const zone = await provider.getZone(resolved.target.zoneDomain);
79
+ if (!zone)
80
+ throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', `${provider.name} does not have a DNS zone for ${resolved.target.zoneDomain}.`);
81
+ const nextSteps = [
82
+ `clerk link --app ${config.appId}`,
83
+ `clerk env pull --app ${config.appId} --instance prod`,
84
+ 'clerk deploy',
85
+ 'clerk deploy status',
86
+ ];
87
+ if (input.dryRun) {
88
+ return {
89
+ account: resolved.account,
90
+ app: config.appId,
91
+ clerk: { productionInstanceCreated: false, verified: false },
92
+ dns: { propagated: false, skipped: [], updated: false },
93
+ domain,
94
+ dryRun: true,
95
+ isDefaultAccount: resolved.isDefaultAccount,
96
+ nextSteps,
97
+ provider: resolved.provider,
98
+ records: [],
99
+ zoneDomain: resolved.target.zoneDomain,
100
+ };
101
+ }
102
+ input.progress?.('Creating Clerk production instance and primary domain');
103
+ const created = await client.createProductionInstance(config.appId, domain, development.instance_id);
104
+ const clerkDomain = created.active_domain;
105
+ if (!clerkDomain) {
106
+ throw new DoomainError('DOMAIN_LINK_FAILED', 'Clerk created the production instance but did not return its primary domain.', {
107
+ productionInstanceId: created.id,
108
+ });
109
+ }
110
+ const records = recordsFromTargets(resolved.provider, resolved.target.zoneDomain, clerkDomain.cname_targets ?? []);
111
+ if (records.length === 0) {
112
+ throw new DoomainError('DOMAIN_LINK_FAILED', 'Clerk created the production instance but did not return any DNS records.', {
113
+ domainId: clerkDomain.id,
114
+ productionInstanceId: created.id,
115
+ });
116
+ }
117
+ input.progress?.(`Checking existing DNS records in ${provider.name}`);
118
+ let forceDns = Boolean(input.force);
119
+ let dnsPlan = await provider.planChanges(zone, records, { force: forceDns });
120
+ if (!forceDns && dnsPlan.conflicts.length > 0) {
121
+ const warning = {
122
+ account: resolved.account,
123
+ conflicts: dnsPlan.conflicts,
124
+ desired: records,
125
+ domain,
126
+ provider: resolved.provider,
127
+ providerName: provider.name,
128
+ recordName: records[0]?.name ?? '@',
129
+ zoneDomain: resolved.target.zoneDomain,
130
+ };
131
+ forceDns = (await input.confirmDnsOverride?.(warning)) === true;
132
+ if (!forceDns)
133
+ throw dnsConflictError(warning, created.id);
134
+ dnsPlan = await provider.planChanges(zone, records, { force: true });
135
+ }
136
+ input.progress?.(`Creating Clerk DNS records in ${provider.name}`);
137
+ const dnsResult = await provider.applyChanges(zone, dnsPlan, { force: forceDns });
138
+ const shouldWait = input.wait ?? true;
139
+ const status = shouldWait
140
+ ? await waitForStatus(client, config.appId, clerkDomain.id, input.timeoutSeconds ?? 300, input.progress)
141
+ : undefined;
142
+ const verified = status?.status === 'complete';
143
+ const propagated = Boolean(status && statusComplete(status.dns) && statusComplete(status.mail));
144
+ return {
145
+ account: resolved.account,
146
+ app: config.appId,
147
+ clerk: {
148
+ domainId: clerkDomain.id,
149
+ productionInstanceCreated: true,
150
+ productionInstanceId: created.id,
151
+ status,
152
+ verified,
153
+ },
154
+ dns: { propagated, skipped: dnsResult.skipped, updated: dnsResult.applied.length > 0 },
155
+ domain,
156
+ dryRun: false,
157
+ isDefaultAccount: resolved.isDefaultAccount,
158
+ nextSteps,
159
+ provider: resolved.provider,
160
+ records,
161
+ zoneDomain: resolved.target.zoneDomain,
162
+ };
163
+ }