doomain 0.1.0 → 0.1.1

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,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import { DoomainError } from '../lib/errors.js';
2
3
  import { apexFlag, domainFlag, jsonFlag, projectFlag, providerFlag, subdomainFlag } from '../lib/flags.js';
3
4
  import { linkDomain } from '../lib/link-domain.js';
4
5
  import { createOutput, outputError } from '../lib/output.js';
@@ -17,7 +18,7 @@ export default class Link extends Command {
17
18
  apex: apexFlag,
18
19
  domain: domainFlag,
19
20
  'dry-run': Flags.boolean({ description: 'Preview changes without writing to Vercel or DNS.' }),
20
- force: Flags.boolean({ description: 'Overwrite conflicting DNS records.' }),
21
+ force: Flags.boolean({ description: 'Move existing Vercel aliases and overwrite conflicting DNS records.' }),
21
22
  json: jsonFlag,
22
23
  project: projectFlag,
23
24
  provider: providerFlag,
@@ -31,6 +32,9 @@ export default class Link extends Command {
31
32
  let spinner;
32
33
  const domain = flags.domain ?? args.domain;
33
34
  try {
35
+ if (!domain) {
36
+ throw new DoomainError('MISSING_ARGUMENT', 'Domain is required. Use `doomain link <domain>` or pass --domain.');
37
+ }
34
38
  if (flags['dry-run']) {
35
39
  const result = await linkDomain({ ...flags, domain, dryRun: true, timeoutSeconds: flags.timeout });
36
40
  out.result(result);
@@ -127,8 +127,9 @@ export default class Wizard extends Command {
127
127
  p.intro('Doomain');
128
128
  const config = await loadConfig();
129
129
  const providerDefinitions = listProviderDefinitions();
130
+ const localProject = detectLocalVercelProject();
130
131
  let vercelToken = process.env.VERCEL_TOKEN || config.vercel?.token;
131
- let vercelTeamId = process.env.VERCEL_TEAM_ID || config.vercel?.teamId;
132
+ let vercelTeamId = process.env.VERCEL_TEAM_ID || localProject?.orgId || config.vercel?.teamId;
132
133
  const defaultProvider = process.env.DOOMAIN_PROVIDER || config.defaults?.provider;
133
134
  const defaultDomain = process.env.DOOMAIN_DOMAIN || config.defaults?.domain;
134
135
  if (!vercelToken) {
@@ -136,7 +137,6 @@ export default class Wizard extends Command {
136
137
  if (!vercelToken)
137
138
  return;
138
139
  }
139
- const localProject = detectLocalVercelProject();
140
140
  const teamSpinner = p.spinner();
141
141
  let teams = [];
142
142
  if (process.env.VERCEL_TEAM_ID) {
@@ -164,34 +164,33 @@ export default class Wizard extends Command {
164
164
  const selectedTeam = teams.find((team) => team.id === vercelTeamId);
165
165
  const teamDisplay = vercelTeamId ? teamLabel(selectedTeam ?? { id: vercelTeamId, name: null, role: null, slug: vercelTeamId }) : 'Personal account';
166
166
  p.log.success(`Vercel account ready: ${teamDisplay}`);
167
- const localProjectMatchesTeam = localProject && (vercelTeamId ? localProject.orgId === vercelTeamId : !localProject.orgId);
168
- let project = localProjectMatchesTeam ? localProject.projectId : undefined;
169
- let projectDisplay = project ? projectLabel({ id: project }) : '';
170
- if (project) {
171
- p.log.info(`Using linked Vercel project ${projectDisplay}.`);
172
- }
173
- else {
174
- const spinner = p.spinner();
175
- activeSpinner = spinner;
176
- spinner.start('Loading Vercel projects');
177
- const projects = await createVercelClient({ token: vercelToken, teamId: vercelTeamId }).listProjects();
178
- spinner.stop(`Loaded ${projects.length} projects`);
179
- activeSpinner = undefined;
180
- if (projects.length === 0) {
181
- throw new DoomainError('PROJECT_NOT_FOUND', `No Vercel projects found in ${teamDisplay}.`);
182
- }
183
- const selected = await p.autocomplete({
184
- message: 'Select Vercel project',
185
- placeholder: 'Type to filter projects...',
186
- maxItems: 10,
187
- options: projects.map((item) => ({ label: projectLabel(item), value: item.id, hint: item.framework ?? undefined })),
188
- });
189
- const resolved = cancelIfNeeded(selected);
190
- if (resolved === null)
191
- return;
192
- project = resolved;
193
- projectDisplay = projectLabel(projects.find((item) => item.id === project) ?? { id: project });
167
+ const projectSpinner = p.spinner();
168
+ activeSpinner = projectSpinner;
169
+ projectSpinner.start('Loading Vercel projects');
170
+ const projects = await createVercelClient({ token: vercelToken, teamId: vercelTeamId }).listProjects();
171
+ projectSpinner.stop(`Loaded ${projects.length} projects`);
172
+ activeSpinner = undefined;
173
+ if (projects.length === 0) {
174
+ throw new DoomainError('PROJECT_NOT_FOUND', `No Vercel projects found in ${teamDisplay}.`);
194
175
  }
176
+ const localProjectMatchesTeam = localProject && (vercelTeamId ? localProject.orgId === vercelTeamId : !localProject.orgId);
177
+ const initialProject = localProjectMatchesTeam && projects.some((item) => item.id === localProject.projectId) ? localProject.projectId : undefined;
178
+ const selected = await p.autocomplete({
179
+ message: 'Select Vercel project',
180
+ placeholder: 'Type to filter projects...',
181
+ maxItems: 10,
182
+ initialValue: initialProject,
183
+ options: projects.map((item) => ({
184
+ label: projectLabel(item),
185
+ value: item.id,
186
+ hint: item.id === initialProject ? 'linked in .vercel' : (item.framework ?? undefined),
187
+ })),
188
+ });
189
+ const resolved = cancelIfNeeded(selected);
190
+ if (resolved === null)
191
+ return;
192
+ const project = resolved;
193
+ const projectDisplay = projectLabel(projects.find((item) => item.id === project) ?? { id: project });
195
194
  p.log.success(`Vercel ready: ${projectDisplay}`);
196
195
  const configuredProviderDefinitions = providerDefinitions.filter((definition) => isProviderConfigured(definition, config));
197
196
  const providerFailures = [];
@@ -1,4 +1,4 @@
1
- export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'DOMAIN_LINK_FAILED' | '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_PROJECT_NOT_LINKED';
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_PROJECT_NOT_LINKED';
2
2
  export declare class DoomainError extends Error {
3
3
  readonly code: DoomainErrorCode;
4
4
  readonly details?: unknown;
@@ -304,7 +304,7 @@ export async function linkDomain(input) {
304
304
  reportProgress(input, 'dns:resolve-zone', `Finding ${provider.name} DNS zone`);
305
305
  const zone = await resolveZone(provider, plan.zoneDomain);
306
306
  reportProgress(input, 'vercel:add-domain', 'Adding domain to Vercel');
307
- const addResult = await vercel.addDomainToProject(plan.project, plan.domain);
307
+ const addResult = await vercel.addDomainToProject(plan.project, plan.domain, { force: input.force });
308
308
  reportProgress(input, 'vercel:get-target', 'Reading Vercel DNS target');
309
309
  const cname = plan.isApex ? undefined : await vercel.getRecommendedCname(plan.domain);
310
310
  reportProgress(input, 'vercel:get-domain', 'Reading Vercel verification records');
@@ -16,17 +16,33 @@ export interface VercelConfig {
16
16
  token: string;
17
17
  teamId?: string;
18
18
  }
19
+ interface VercelProjectDomainOwner {
20
+ domain: Record<string, unknown> & {
21
+ name?: string;
22
+ projectId?: string;
23
+ };
24
+ project: VercelProject;
25
+ }
19
26
  export declare function resolveVercelConfig(): Promise<VercelConfig>;
20
27
  export declare function createVercelClient(config: VercelConfig): {
21
28
  listTeams(): Promise<VercelTeam[]>;
22
29
  listProjects(search?: string): Promise<VercelProject[]>;
23
- addDomainToProject(project: string, domain: string): Promise<{
30
+ addDomainToProject(project: string, domain: string, opts?: {
31
+ force?: boolean;
32
+ }): Promise<{
24
33
  alreadyAdded: boolean;
25
34
  raw?: unknown;
26
35
  }>;
36
+ findProjectDomainOwner(domain: string): Promise<VercelProjectDomainOwner | undefined>;
27
37
  getDomainConfig(domain: string): Promise<Record<string, unknown>>;
28
38
  getRecommendedCname(domain: string): Promise<string>;
29
39
  getProjectDomain(project: string, domain: string): Promise<Record<string, unknown>>;
40
+ listProjectDomains(project: string): Promise<Array<Record<string, unknown> & {
41
+ name?: string;
42
+ projectId?: string;
43
+ }>>;
44
+ removeDomainFromProject(project: string, domain: string): Promise<void>;
30
45
  verifyProjectDomain(project: string, domain: string): Promise<Record<string, unknown>>;
31
46
  };
32
47
  export type VercelClient = ReturnType<typeof createVercelClient>;
48
+ export {};
@@ -21,12 +21,23 @@ function appendTeam(path, teamId) {
21
21
  function apiErrorMessage(status, body) {
22
22
  return body?.error?.message ?? `Vercel API error (${status}).`;
23
23
  }
24
- function isAlreadyAddedError(error) {
24
+ function isDomainConflictError(error) {
25
25
  if (!(error instanceof DoomainError))
26
26
  return false;
27
27
  const details = error.details;
28
28
  const text = `${error.message} ${details?.error?.code ?? ''}`.toLowerCase();
29
- return text.includes('already') || text.includes('conflict') || text.includes('domain_already');
29
+ return (text.includes('already') ||
30
+ text.includes('conflict') ||
31
+ text.includes('domain_already') ||
32
+ text.includes('already assigned') ||
33
+ text.includes('already in use'));
34
+ }
35
+ function isSameDomain(value, domain) {
36
+ return typeof value === 'string' && value.toLowerCase() === domain.toLowerCase();
37
+ }
38
+ function findAliasTarget(raw, domain) {
39
+ const targets = Array.isArray(raw) ? raw : [raw];
40
+ return targets.find((target) => target && typeof target === 'object' && isSameDomain(target.domain, domain));
30
41
  }
31
42
  export function createVercelClient(config) {
32
43
  async function request(path, init = {}, opts = {}) {
@@ -99,20 +110,44 @@ export function createVercelClient(config) {
99
110
  }
100
111
  return [...projectsById.values()].sort((a, b) => a.name.localeCompare(b.name));
101
112
  },
102
- async addDomainToProject(project, domain) {
113
+ async addDomainToProject(project, domain, opts = {}) {
103
114
  try {
104
- const raw = await request(`/v10/projects/${encodeURIComponent(project)}/domains`, {
115
+ const raw = await request(`/projects/${encodeURIComponent(project)}/alias`, {
105
116
  method: 'POST',
106
- body: JSON.stringify({ name: domain }),
117
+ body: JSON.stringify({ domain, target: 'PRODUCTION' }),
107
118
  });
108
- return { alreadyAdded: false, raw };
119
+ const aliasTarget = findAliasTarget(raw, domain);
120
+ if (!aliasTarget) {
121
+ throw new DoomainError('DOMAIN_LINK_FAILED', `Vercel did not return ${domain} after adding it to project ${project}.`, raw);
122
+ }
123
+ return { alreadyAdded: false, raw: aliasTarget };
109
124
  }
110
125
  catch (error) {
111
- if (isAlreadyAddedError(error))
112
- return { alreadyAdded: true, raw: error };
126
+ if (isDomainConflictError(error)) {
127
+ const projectDomain = await this.getProjectDomain(project, domain).catch(() => undefined);
128
+ if (projectDomain)
129
+ return { alreadyAdded: true, raw: projectDomain };
130
+ if (opts.force) {
131
+ const owner = await this.findProjectDomainOwner(domain);
132
+ if (owner && owner.project.id !== project) {
133
+ await this.removeDomainFromProject(owner.project.id, domain);
134
+ return this.addDomainToProject(project, domain);
135
+ }
136
+ }
137
+ throw new DoomainError('DOMAIN_ALREADY_ASSIGNED', `Vercel reports ${domain} is already assigned to another project. Re-run with --force if you intend to move it to ${project}.`, error instanceof DoomainError ? error.details : undefined);
138
+ }
113
139
  throw error;
114
140
  }
115
141
  },
142
+ async findProjectDomainOwner(domain) {
143
+ for (const project of await this.listProjects()) {
144
+ const domains = await this.listProjectDomains(project.id).catch(() => []);
145
+ const match = domains.find((item) => isSameDomain(item.name, domain));
146
+ if (match)
147
+ return { domain: match, project };
148
+ }
149
+ return undefined;
150
+ },
116
151
  async getDomainConfig(domain) {
117
152
  return request(`/v6/domains/${encodeURIComponent(domain)}/config`);
118
153
  },
@@ -124,6 +159,13 @@ export function createVercelClient(config) {
124
159
  async getProjectDomain(project, domain) {
125
160
  return request(`/v9/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}`);
126
161
  },
162
+ async listProjectDomains(project) {
163
+ const result = await request(`/v9/projects/${encodeURIComponent(project)}/domains`);
164
+ return result.domains ?? [];
165
+ },
166
+ async removeDomainFromProject(project, domain) {
167
+ await request(`/projects/${encodeURIComponent(project)}/alias?domain=${encodeURIComponent(domain)}`, { method: 'DELETE' });
168
+ },
127
169
  async verifyProjectDomain(project, domain) {
128
170
  return request(`/v9/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}/verify`, { method: 'POST' });
129
171
  },
@@ -37,7 +37,7 @@
37
37
  "type": "boolean"
38
38
  },
39
39
  "force": {
40
- "description": "Overwrite conflicting DNS records.",
40
+ "description": "Move existing Vercel aliases and overwrite conflicting DNS records.",
41
41
  "name": "force",
42
42
  "allowNo": false,
43
43
  "type": "boolean"
@@ -629,5 +629,5 @@
629
629
  ]
630
630
  }
631
631
  },
632
- "version": "0.1.0"
632
+ "version": "0.1.1"
633
633
  }
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.0",
4
+ "version": "0.1.1",
5
5
  "author": "Crafter Station",
6
6
  "packageManager": "bun@1.3.13",
7
7
  "bin": {