@indigoai-us/hq-cli 5.345.162 → 5.345.163
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/CHANGELOG.md +5 -0
- package/dist/command-catalog.generated.d.ts +341 -1
- package/dist/command-catalog.generated.js +422 -1
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/deploy-domains.d.ts +51 -0
- package/dist/commands/deploy-domains.js +787 -0
- package/dist/commands/feedback-triage.d.ts +67 -0
- package/dist/commands/feedback-triage.js +294 -0
- package/dist/commands/feedback.d.ts +13 -1
- package/dist/commands/feedback.js +32 -8
- package/package.json +7 -7
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { domainToASCII } from "node:url";
|
|
6
|
+
import { deployApiRequest } from "../lib/deploy-api.js";
|
|
7
|
+
import { listDeployApps } from "../lib/deploy-apps.js";
|
|
8
|
+
import { DEFAULT_HQ_ROOT, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
9
|
+
import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic } from "../utils/vault-api.js";
|
|
10
|
+
import { displayText, safeJson } from "./deploy-comments.js";
|
|
11
|
+
import { readActiveCompanySlug } from "./skill.js";
|
|
12
|
+
/**
|
|
13
|
+
* `hq deploy domains` manages an app's custom domains and buys new domains.
|
|
14
|
+
*
|
|
15
|
+
* list/add/refresh/remove call hq-deploy's /api/apps/:id/domains routes, the
|
|
16
|
+
* same routes the desktop Deployments panel uses (hq-desktop-app #1734). The
|
|
17
|
+
* add-on gate (the billing.custom-domain-addon flag, the card check and the
|
|
18
|
+
* admin check) runs on the server, so this command shows the same answers the
|
|
19
|
+
* panel shows.
|
|
20
|
+
*
|
|
21
|
+
* search/buy call hq-pro's /v1/billing/companies/:uid/domain-purchase routes
|
|
22
|
+
* (hq-pro #5133, #5156). The server gates them on the same flag and charges
|
|
23
|
+
* the price it publishes in GET /v1/pricing (usage.domains.purchase). Buying
|
|
24
|
+
* needs the Name.com credentials on the server; until those exist the server
|
|
25
|
+
* answers REGISTRAR_UNAVAILABLE and nothing is charged.
|
|
26
|
+
*
|
|
27
|
+
* Project: hq-deploy-custom-domain-tiers, US-014.
|
|
28
|
+
*/
|
|
29
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
30
|
+
/** hq-deploy app ids: UUIDs today, `app_…` in older records and tests. */
|
|
31
|
+
const APP_ID_PATTERN = /^(?:app_[A-Za-z0-9_-]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
32
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,64}$/;
|
|
33
|
+
const BINDING_FILES = [path.join(".hq", "project.json"), "hq-deploy.json"];
|
|
34
|
+
const PRICING_TIMEOUT_MS = 5000;
|
|
35
|
+
/** Exit code for `refresh` when the domain is not serving yet, so scripts can poll. */
|
|
36
|
+
export const REFRESH_NOT_SERVING_EXIT_CODE = 2;
|
|
37
|
+
export class DeployDomainsError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
status;
|
|
40
|
+
extra;
|
|
41
|
+
constructor(message, code, status, extra = {}) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.extra = extra;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Terminal hooks for the confirmation prompt. Tests replace these; production
|
|
50
|
+
* reads the real TTY state and asks on stdin.
|
|
51
|
+
*/
|
|
52
|
+
export const deployDomainsPrompt = {
|
|
53
|
+
// The prompt writes to stderr so `--json` on stdout stays parseable.
|
|
54
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
55
|
+
ask: async (question) => {
|
|
56
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
57
|
+
try {
|
|
58
|
+
return await rl.question(question);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rl.close();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Messages
|
|
67
|
+
/** Plain wording for hq-deploy domain error codes. Billing codes prefer the server's text. */
|
|
68
|
+
const DEPLOY_MESSAGES = {
|
|
69
|
+
DOMAIN_NOT_ENTITLED: "Custom domains are not enabled for this company yet.",
|
|
70
|
+
BILLING_ADMIN_REQUIRED: "Only a company owner or admin can add or remove custom domains, because each one is billed to the company. Ask an admin to do it.",
|
|
71
|
+
PAID_PLAN_REQUIRED: "Custom domains are a usage add-on and need a card on file for the company.",
|
|
72
|
+
PAYMENT_METHOD_REQUIRED: "Custom domains need a card on file for the company. Add a card on the company billing page, then run the add again.",
|
|
73
|
+
BILLING_UNAVAILABLE: "Domain billing is temporarily unavailable. Try again in a minute.",
|
|
74
|
+
DOMAIN_ALREADY_CLAIMED: "That domain is already linked to another app.",
|
|
75
|
+
DOMAIN_CROSS_TENANT: "That domain belongs to another company.",
|
|
76
|
+
DOMAIN_INVALID: "That is not a valid domain name.",
|
|
77
|
+
VALIDATION_ERROR: "That is not a valid domain name.",
|
|
78
|
+
DOMAIN_RESERVED: "That domain is reserved by HQ and cannot be added.",
|
|
79
|
+
DOMAIN_NOT_FOUND: "That domain is not linked to this app. Run 'hq deploy domains list' to see its domains.",
|
|
80
|
+
DOMAIN_TARGET_AMBIGUOUS: "That domain points at more than one environment. Pass --env <name>.",
|
|
81
|
+
ENVIRONMENT_NOT_SERVED: "Only the production environment can have a custom domain.",
|
|
82
|
+
DOMAIN_PROVISIONING_FAILED: "HQ could not finish setting up the domain. Run 'hq deploy domains refresh <domain>' to try again.",
|
|
83
|
+
};
|
|
84
|
+
const SERVER_WORDED_CODES = new Set(["PAID_PLAN_REQUIRED", "PAYMENT_METHOD_REQUIRED", "BILLING_UNAVAILABLE"]);
|
|
85
|
+
/** Buy codes the server returns before any hold or order, so `buy` says nothing was charged. */
|
|
86
|
+
const NOT_CHARGED_CODES = new Set(["REGISTRAR_UNAVAILABLE"]);
|
|
87
|
+
/**
|
|
88
|
+
* Buy codes with a settled outcome: the order was refused, failed without a
|
|
89
|
+
* charge, or belongs to another order. Any other answer (a network error, a
|
|
90
|
+
* 5xx, BILLING_UNAVAILABLE, PURCHASE_PENDING, an unknown code) may have left
|
|
91
|
+
* this order held or registered, so `buy` prints the order's key and says to
|
|
92
|
+
* re-run with it instead of starting a new order (hq-cli#2160 review).
|
|
93
|
+
*/
|
|
94
|
+
const SETTLED_PURCHASE_CODES = new Set([
|
|
95
|
+
"DOMAIN_PURCHASE_NOT_ENABLED",
|
|
96
|
+
"REGISTRAR_UNAVAILABLE",
|
|
97
|
+
"BILLING_ADMIN_REQUIRED",
|
|
98
|
+
"FORBIDDEN",
|
|
99
|
+
"AUTH_REQUIRED",
|
|
100
|
+
"COMPANY_NOT_FOUND",
|
|
101
|
+
"PAYMENT_METHOD_REQUIRED",
|
|
102
|
+
"BILLING_PAST_DUE",
|
|
103
|
+
"PAYMENT_FAILED",
|
|
104
|
+
"PRICE_CHANGED",
|
|
105
|
+
"DOMAIN_NOT_AVAILABLE",
|
|
106
|
+
"DOMAIN_ALREADY_PURCHASED",
|
|
107
|
+
"PURCHASE_IN_PROGRESS",
|
|
108
|
+
"PURCHASE_ALREADY_FAILED",
|
|
109
|
+
"REGISTRATION_FAILED",
|
|
110
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
111
|
+
"INVALID_DOMAIN",
|
|
112
|
+
"INVALID_EXPECTED_PRICE",
|
|
113
|
+
"INVALID_IDEMPOTENCY_KEY",
|
|
114
|
+
"INVALID_REGISTRANT",
|
|
115
|
+
]);
|
|
116
|
+
/** The sentence `buy` adds when the order's outcome is not known. */
|
|
117
|
+
export function resumeOrderSentence(idempotencyKey) {
|
|
118
|
+
return `The order may still be in progress. Run the same command again with --idempotency-key ${idempotencyKey} to finish or check it. Do not start a new order.`;
|
|
119
|
+
}
|
|
120
|
+
/** The registrant fields hq-pro accepts; nothing else from the file is sent. */
|
|
121
|
+
const REGISTRANT_FIELDS = [
|
|
122
|
+
"firstName", "lastName", "companyName", "address1", "address2",
|
|
123
|
+
"city", "state", "zip", "country", "email", "phone",
|
|
124
|
+
];
|
|
125
|
+
/** Plain wording for hq-pro domain purchase error codes. */
|
|
126
|
+
const PURCHASE_MESSAGES = {
|
|
127
|
+
DOMAIN_PURCHASE_NOT_ENABLED: "Buying domains is not enabled for this company yet.",
|
|
128
|
+
REGISTRAR_UNAVAILABLE: "Buying domains is not available right now. Try again later.",
|
|
129
|
+
BILLING_ADMIN_REQUIRED: "Only a company owner or admin can buy a domain.",
|
|
130
|
+
FORBIDDEN: "You are not a member of that company.",
|
|
131
|
+
AUTH_REQUIRED: "HQ rejected your sign-in. Run 'hq login' and retry.",
|
|
132
|
+
COMPANY_NOT_FOUND: "That company was not found.",
|
|
133
|
+
BILLING_UNAVAILABLE: "Billing is temporarily unavailable. Try again in a minute.",
|
|
134
|
+
PAYMENT_METHOD_REQUIRED: "Buying a domain needs a card on file for the company. Add a card, then run buy again.",
|
|
135
|
+
BILLING_PAST_DUE: "The company's billing is past due. Update billing, then run buy again.",
|
|
136
|
+
PAYMENT_FAILED: "The card was declined. Update the card, then run buy again.",
|
|
137
|
+
DOMAIN_NOT_AVAILABLE: "That domain is not available to buy.",
|
|
138
|
+
DOMAIN_ALREADY_PURCHASED: "Your company already bought that domain.",
|
|
139
|
+
PURCHASE_IN_PROGRESS: "An order for that domain is already in progress. Wait a minute, then check again.",
|
|
140
|
+
PURCHASE_ALREADY_FAILED: "This order already failed and was not charged. Run buy again without --idempotency-key to start a new order.",
|
|
141
|
+
REGISTRATION_FAILED: "The registrar refused the registration. The card was not charged.",
|
|
142
|
+
IDEMPOTENCY_KEY_REUSED: "That --idempotency-key belongs to another order (a different domain or price). Run buy again without it.",
|
|
143
|
+
INVALID_DOMAIN: "Name one domain to buy, for example example.com.",
|
|
144
|
+
INVALID_DOMAIN_QUERY: "That search is not valid. Use a name like example or example.com.",
|
|
145
|
+
INVALID_EXPECTED_PRICE: "--price must be the first-year price shown by 'hq deploy domains search'.",
|
|
146
|
+
INVALID_IDEMPOTENCY_KEY: "--idempotency-key must be 16 to 64 letters, digits, '-' or '_'.",
|
|
147
|
+
INVALID_REGISTRANT: "The registrant details are missing or invalid. The file needs firstName, lastName, address1, city, state, zip, a 2-letter country, email, and phone in +15551234567 form.",
|
|
148
|
+
};
|
|
149
|
+
function apiCode(body, status) {
|
|
150
|
+
const nested = body.error && typeof body.error === "object" ? body.error.code : undefined;
|
|
151
|
+
const raw = typeof body.code === "string" ? body.code : nested;
|
|
152
|
+
return typeof raw === "string" && /^[A-Z0-9_]+$/.test(raw) ? raw : `HTTP_${status}`;
|
|
153
|
+
}
|
|
154
|
+
function apiField(body, key) {
|
|
155
|
+
if (key in body)
|
|
156
|
+
return body[key];
|
|
157
|
+
const nested = body.error && typeof body.error === "object" ? body.error : undefined;
|
|
158
|
+
return nested?.[key];
|
|
159
|
+
}
|
|
160
|
+
function httpsUrl(value) {
|
|
161
|
+
if (typeof value !== "string")
|
|
162
|
+
return undefined;
|
|
163
|
+
try {
|
|
164
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function readBody(response) {
|
|
171
|
+
const body = await response.json().catch(() => null);
|
|
172
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
173
|
+
}
|
|
174
|
+
async function deployError(response, orgSlug) {
|
|
175
|
+
const body = await readBody(response);
|
|
176
|
+
const code = apiCode(body, response.status);
|
|
177
|
+
const billingUrl = httpsUrl(apiField(body, "billingUrl"));
|
|
178
|
+
const serverMessage = apiField(body, "message");
|
|
179
|
+
let message;
|
|
180
|
+
if (SERVER_WORDED_CODES.has(code) && typeof serverMessage === "string" && serverMessage.trim()) {
|
|
181
|
+
message = displayText(serverMessage);
|
|
182
|
+
}
|
|
183
|
+
else if (DEPLOY_MESSAGES[code]) {
|
|
184
|
+
message = DEPLOY_MESSAGES[code];
|
|
185
|
+
}
|
|
186
|
+
else if (response.status === 401) {
|
|
187
|
+
message = "hq-deploy rejected your sign-in. Run 'hq login' and retry.";
|
|
188
|
+
}
|
|
189
|
+
else if (response.status === 403) {
|
|
190
|
+
message = `You need to be the app owner or a company admin in '${orgSlug}' to manage its domains.`;
|
|
191
|
+
}
|
|
192
|
+
else if (response.status === 404) {
|
|
193
|
+
message = `App not found in '${orgSlug}'.`;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
message = `The domain request failed (HTTP ${response.status}).`;
|
|
197
|
+
}
|
|
198
|
+
if (code === "PAYMENT_METHOD_REQUIRED" && billingUrl && !message.includes(billingUrl)) {
|
|
199
|
+
message = `${message} Add a card here: ${billingUrl}`;
|
|
200
|
+
}
|
|
201
|
+
return new DeployDomainsError(message, code, response.status, billingUrl ? { billingUrl } : {});
|
|
202
|
+
}
|
|
203
|
+
async function purchaseError(response, idempotencyKey) {
|
|
204
|
+
const body = await readBody(response);
|
|
205
|
+
const code = apiCode(body, response.status);
|
|
206
|
+
const billingUrl = httpsUrl(apiField(body, "billingUrl"));
|
|
207
|
+
const extra = {};
|
|
208
|
+
if (billingUrl)
|
|
209
|
+
extra.billingUrl = billingUrl;
|
|
210
|
+
let message = PURCHASE_MESSAGES[code] ?? `The domain purchase request failed (HTTP ${response.status}).`;
|
|
211
|
+
if (code === "PRICE_CHANGED") {
|
|
212
|
+
const price = apiField(body, "priceUsd");
|
|
213
|
+
if (typeof price === "number")
|
|
214
|
+
extra.priceUsd = price;
|
|
215
|
+
message = typeof price === "number"
|
|
216
|
+
? `The price changed to ${usd(price)} for the first year. Nothing was charged. Check it with 'hq deploy domains search', then run buy with --price ${price}.`
|
|
217
|
+
: "The price changed. Nothing was charged. Run 'hq deploy domains search' and buy at the new price.";
|
|
218
|
+
}
|
|
219
|
+
if (idempotencyKey) {
|
|
220
|
+
extra.idempotencyKey = idempotencyKey;
|
|
221
|
+
if (NOT_CHARGED_CODES.has(code))
|
|
222
|
+
message = `${message} Nothing was charged.`;
|
|
223
|
+
if (code === "PURCHASE_PENDING") {
|
|
224
|
+
message = `The registrar has not confirmed the registration yet. The card is held, not charged. Run the same command again with --idempotency-key ${idempotencyKey} to finish this order.`;
|
|
225
|
+
}
|
|
226
|
+
else if (!SETTLED_PURCHASE_CODES.has(code)) {
|
|
227
|
+
message = `${message} ${resumeOrderSentence(idempotencyKey)}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (billingUrl)
|
|
231
|
+
message = `${message} Billing: ${billingUrl}`;
|
|
232
|
+
return new DeployDomainsError(message, code, response.status, extra);
|
|
233
|
+
}
|
|
234
|
+
function usd(value) {
|
|
235
|
+
return Number.isInteger(value) ? `$${value}` : `$${value.toFixed(2)}`;
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Target resolution
|
|
239
|
+
function resolveSlug(companyFlag, bindingSlug) {
|
|
240
|
+
const slug = companyFlag ?? bindingSlug ?? readActiveCompanySlug(DEFAULT_HQ_ROOT);
|
|
241
|
+
if (slug === undefined)
|
|
242
|
+
throw new DeployDomainsError("No active company. Pass --company <slug>.", "NO_COMPANY");
|
|
243
|
+
if (!SLUG_PATTERN.test(slug))
|
|
244
|
+
throw new DeployDomainsError("The company slug is not valid.", "INVALID_COMPANY");
|
|
245
|
+
return slug;
|
|
246
|
+
}
|
|
247
|
+
/** Read the app id from the folder's project link (.hq/project.json, or the older hq-deploy.json). */
|
|
248
|
+
export function readLinkedApp(dir) {
|
|
249
|
+
for (const name of BINDING_FILES) {
|
|
250
|
+
const file = path.join(dir, name);
|
|
251
|
+
if (!fs.existsSync(file))
|
|
252
|
+
continue;
|
|
253
|
+
let parsed;
|
|
254
|
+
try {
|
|
255
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new DeployDomainsError(`${name} is not valid JSON. Re-link the folder, or pass --app <app>.`, "BINDING_INVALID");
|
|
259
|
+
}
|
|
260
|
+
const record = parsed && typeof parsed === "object" ? parsed : {};
|
|
261
|
+
const appId = typeof record.appId === "string" && APP_ID_PATTERN.test(record.appId) ? record.appId : null;
|
|
262
|
+
if (!appId) {
|
|
263
|
+
throw new DeployDomainsError(`${name} has no app id. Re-link the folder, or pass --app <app>.`, "BINDING_INVALID");
|
|
264
|
+
}
|
|
265
|
+
const orgSlug = typeof record.orgSlug === "string" && SLUG_PATTERN.test(record.orgSlug) ? record.orgSlug : undefined;
|
|
266
|
+
return { appId, ...(orgSlug ? { orgSlug } : {}) };
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
async function request(token, orgSlug, route, method, body) {
|
|
271
|
+
try {
|
|
272
|
+
return await deployApiRequest(token, route, method, body, { "X-Org-Slug": orgSlug });
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new DeployDomainsError("Could not reach hq-deploy.", "NETWORK");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function resolveAppId(token, orgSlug, app) {
|
|
279
|
+
if (APP_ID_PATTERN.test(app))
|
|
280
|
+
return app;
|
|
281
|
+
const apps = await listDeployApps(async (cursor) => {
|
|
282
|
+
const route = cursor ? `/api/apps?cursor=${encodeURIComponent(cursor)}` : "/api/apps";
|
|
283
|
+
const response = await request(token, orgSlug, route, "GET");
|
|
284
|
+
if (!response.ok)
|
|
285
|
+
throw await deployError(response, orgSlug);
|
|
286
|
+
return readBody(response);
|
|
287
|
+
});
|
|
288
|
+
const needle = app.toLowerCase();
|
|
289
|
+
const ids = [...new Set(apps
|
|
290
|
+
.filter((item) => typeof item === "object" && item !== null)
|
|
291
|
+
.filter((item) => [item.id, item.subdomain, item.name].some((field) => typeof field === "string" && field.toLowerCase() === needle))
|
|
292
|
+
.map((item) => item.id)
|
|
293
|
+
.filter((id) => typeof id === "string"))];
|
|
294
|
+
if (ids.length === 0)
|
|
295
|
+
throw new DeployDomainsError(`No deploy app '${displayText(app)}' in '${orgSlug}'.`, "APP_NOT_FOUND");
|
|
296
|
+
if (ids.length > 1) {
|
|
297
|
+
throw new DeployDomainsError(`'${displayText(app)}' matches ${ids.length} apps in '${orgSlug}'. Use the app id.`, "APP_AMBIGUOUS");
|
|
298
|
+
}
|
|
299
|
+
return ids[0];
|
|
300
|
+
}
|
|
301
|
+
async function resolveTarget(token, options) {
|
|
302
|
+
if (options.app) {
|
|
303
|
+
const orgSlug = resolveSlug(options.company, undefined);
|
|
304
|
+
return { appId: await resolveAppId(token, orgSlug, options.app), orgSlug, appLabel: options.app };
|
|
305
|
+
}
|
|
306
|
+
const binding = readLinkedApp(path.resolve(options.path ?? process.cwd()));
|
|
307
|
+
if (!binding) {
|
|
308
|
+
throw new DeployDomainsError("This folder is not linked to a deployment. Pass --app <name or id>, or run the command in the linked app folder.", "APP_NOT_LINKED");
|
|
309
|
+
}
|
|
310
|
+
// The link names its own company. Never pair its app with a different
|
|
311
|
+
// company silently: refuse a mismatch, and a link that names no company.
|
|
312
|
+
if (!binding.orgSlug && options.company === undefined) {
|
|
313
|
+
throw new DeployDomainsError("The folder's link does not name a company. Pass --company <slug>, or re-link the folder.", "COMPANY_REQUIRED");
|
|
314
|
+
}
|
|
315
|
+
if (binding.orgSlug && options.company !== undefined && options.company !== binding.orgSlug) {
|
|
316
|
+
throw new DeployDomainsError(`This folder is linked to an app in '${binding.orgSlug}', not '${displayText(options.company)}'. Drop --company, or pass --app <name or id>.`, "COMPANY_MISMATCH");
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
appId: binding.appId,
|
|
320
|
+
orgSlug: resolveSlug(options.company, binding.orgSlug),
|
|
321
|
+
appLabel: binding.appId,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const HOSTNAME_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
325
|
+
/**
|
|
326
|
+
* Normalize a domain to the ASCII form the server stores, using the same
|
|
327
|
+
* hostname rule hq-deploy's validateAppHostname enforces: at least two
|
|
328
|
+
* labels, each letters/digits/hyphens, 1 to 63 characters, no leading or
|
|
329
|
+
* trailing hyphen. A unicode name is converted to punycode first.
|
|
330
|
+
*
|
|
331
|
+
* The rule also keeps a domain from ever being a URL path segment such as
|
|
332
|
+
* "." or "..": fetch resolves those, so `remove ...` would otherwise send
|
|
333
|
+
* DELETE /api/apps/<id>/, next to the delete-app route (hq-cli#2160 review).
|
|
334
|
+
*/
|
|
335
|
+
export function normalizeDomain(raw) {
|
|
336
|
+
const trimmed = raw.trim().toLowerCase().replace(/\.$/, "");
|
|
337
|
+
const invalid = () => new DeployDomainsError(DEPLOY_MESSAGES.DOMAIN_INVALID, "DOMAIN_INVALID");
|
|
338
|
+
if (!trimmed || /[\s/\\:?#@%]/.test(trimmed))
|
|
339
|
+
throw invalid();
|
|
340
|
+
const domain = domainToASCII(trimmed);
|
|
341
|
+
if (!domain || domain.length > 253)
|
|
342
|
+
throw invalid();
|
|
343
|
+
const labels = domain.split(".");
|
|
344
|
+
if (labels.length < 2 || labels.some((label) => label.length > 63 || !HOSTNAME_LABEL.test(label))) {
|
|
345
|
+
throw invalid();
|
|
346
|
+
}
|
|
347
|
+
return domain;
|
|
348
|
+
}
|
|
349
|
+
function domainRoute(appId, domain, suffix = "") {
|
|
350
|
+
const base = `/api/apps/${encodeURIComponent(appId)}/domains`;
|
|
351
|
+
return domain ? `${base}/${encodeURIComponent(domain)}${suffix}` : base;
|
|
352
|
+
}
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Pricing (GET /v1/pricing, the published source the server charges from)
|
|
355
|
+
async function fetchPricingBody() {
|
|
356
|
+
let timer;
|
|
357
|
+
const timeout = new Promise((resolve) => {
|
|
358
|
+
timer = setTimeout(() => resolve(null), PRICING_TIMEOUT_MS);
|
|
359
|
+
timer.unref?.();
|
|
360
|
+
});
|
|
361
|
+
const read = (async () => {
|
|
362
|
+
const res = await vaultApiFetchPublic({ path: "/v1/pricing" });
|
|
363
|
+
if (!res.ok)
|
|
364
|
+
return null;
|
|
365
|
+
const body = await res.json().catch(() => null);
|
|
366
|
+
return body && typeof body === "object" ? body : null;
|
|
367
|
+
})().catch(() => null);
|
|
368
|
+
try {
|
|
369
|
+
return await Promise.race([read, timeout]);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
if (timer)
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function domainsBlock(pricing) {
|
|
377
|
+
const usage = pricing?.usage;
|
|
378
|
+
const domains = usage && typeof usage === "object" ? usage.domains : undefined;
|
|
379
|
+
return domains && typeof domains === "object" ? domains : null;
|
|
380
|
+
}
|
|
381
|
+
/** "This custom domain adds $20/month." from the published add-on price. */
|
|
382
|
+
export function addOnPriceLine(pricing) {
|
|
383
|
+
const standard = domainsBlock(pricing)?.standardUsd;
|
|
384
|
+
return typeof standard === "number" && standard > 0
|
|
385
|
+
? `This custom domain adds ${usd(standard)}/month to the company bill.`
|
|
386
|
+
: "This custom domain is billed to the company as a monthly add-on.";
|
|
387
|
+
}
|
|
388
|
+
/** The approval question for `add`, with the published add-on price. */
|
|
389
|
+
export function addOnConfirmQuestion(domain, environment, target, pricing) {
|
|
390
|
+
const standard = domainsBlock(pricing)?.standardUsd;
|
|
391
|
+
const company = `'${target.orgSlug}'`;
|
|
392
|
+
const price = typeof standard === "number" && standard > 0
|
|
393
|
+
? `Each custom domain costs ${usd(standard)}/month, billed to ${company}, unless its plan includes custom domains.`
|
|
394
|
+
: `Each custom domain is a monthly add-on billed to ${company}, unless its plan includes custom domains.`;
|
|
395
|
+
return `Add ${displayText(domain)} to ${displayText(environment)} of app '${displayText(target.appLabel)}' in ${company}? ${price}`;
|
|
396
|
+
}
|
|
397
|
+
/** Shown in the buy prompt when GET /v1/pricing does not answer. */
|
|
398
|
+
export const PURCHASE_TERMS_UNAVAILABLE = "The renewal and refund terms could not be loaded. Check them on the HQ pricing page before you buy.";
|
|
399
|
+
/** The purchase terms published as usage.domains.purchase. Null when not published. */
|
|
400
|
+
export function purchaseTermsLine(pricing) {
|
|
401
|
+
const purchase = domainsBlock(pricing)?.purchase;
|
|
402
|
+
if (!purchase || typeof purchase !== "object")
|
|
403
|
+
return null;
|
|
404
|
+
const p = purchase;
|
|
405
|
+
const markup = typeof p.markupUsdPerYear === "number" ? p.markupUsdPerYear : null;
|
|
406
|
+
const grace = typeof p.refundGraceDays === "number" ? p.refundGraceDays : null;
|
|
407
|
+
if (markup === null || grace === null)
|
|
408
|
+
return null;
|
|
409
|
+
const renewal = p.renewal === "yearly" ? "yearly" : String(p.renewal ?? "yearly");
|
|
410
|
+
const before = p.chargedBeforeRenewal === true ? ", charged before each renewal" : "";
|
|
411
|
+
return `It renews ${renewal} at the registrar's renewal price plus ${usd(markup)}${before}. There are no refunds after a ${grace}-day grace period.`;
|
|
412
|
+
}
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Output
|
|
415
|
+
function printDnsRecords(records) {
|
|
416
|
+
if (records.length === 0)
|
|
417
|
+
return;
|
|
418
|
+
console.log("Create these DNS records at your DNS host:");
|
|
419
|
+
for (const record of records) {
|
|
420
|
+
console.log(` ${displayText(record.type)} ${displayText(record.name)} -> ${displayText(record.value)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function dnsRecords(value) {
|
|
424
|
+
if (!Array.isArray(value))
|
|
425
|
+
return [];
|
|
426
|
+
return value
|
|
427
|
+
.filter((item) => typeof item === "object" && item !== null)
|
|
428
|
+
.filter((item) => typeof item.type === "string" && typeof item.name === "string" && typeof item.value === "string")
|
|
429
|
+
.map((item) => ({ type: item.type, name: item.name, value: item.value }));
|
|
430
|
+
}
|
|
431
|
+
function emitError(error, json) {
|
|
432
|
+
if (error instanceof DeployDomainsError) {
|
|
433
|
+
if (json) {
|
|
434
|
+
console.log(safeJson({
|
|
435
|
+
ok: false,
|
|
436
|
+
code: error.code,
|
|
437
|
+
message: error.message,
|
|
438
|
+
...(error.status === undefined ? {} : { status: error.status }),
|
|
439
|
+
...error.extra,
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
console.error(error.message);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const detail = error instanceof Error ? `${error.name}: ${displayText(error.message)}` : "unknown error";
|
|
448
|
+
if (json) {
|
|
449
|
+
console.log(safeJson({ ok: false, code: "UNEXPECTED", message: `Deploy domains command failed: ${detail}` }));
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
console.error(`Deploy domains command failed: ${detail}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
process.exitCode = 1;
|
|
456
|
+
}
|
|
457
|
+
async function run(json, action) {
|
|
458
|
+
try {
|
|
459
|
+
await action();
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
emitError(error, json === true);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async function confirm(question, yes, what) {
|
|
466
|
+
if (yes)
|
|
467
|
+
return;
|
|
468
|
+
if (!deployDomainsPrompt.isInteractive()) {
|
|
469
|
+
throw new DeployDomainsError(`Pass --yes to ${what}. Nothing was changed.`, "CONFIRMATION_REQUIRED");
|
|
470
|
+
}
|
|
471
|
+
const answer = (await deployDomainsPrompt.ask(`${question} Type 'yes' to continue: `)).trim().toLowerCase();
|
|
472
|
+
if (answer !== "yes")
|
|
473
|
+
throw new DeployDomainsError("Cancelled. Nothing was changed.", "CANCELLED");
|
|
474
|
+
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Actions
|
|
477
|
+
async function listAction(options) {
|
|
478
|
+
const token = await ensureCognitoToken();
|
|
479
|
+
const target = await resolveTarget(token, options);
|
|
480
|
+
const response = await request(token, target.orgSlug, domainRoute(target.appId), "GET");
|
|
481
|
+
if (!response.ok)
|
|
482
|
+
throw await deployError(response, target.orgSlug);
|
|
483
|
+
const body = await readBody(response);
|
|
484
|
+
const domains = Array.isArray(body.domains)
|
|
485
|
+
? body.domains.filter((item) => typeof item === "object" && item !== null)
|
|
486
|
+
: [];
|
|
487
|
+
if (options.json) {
|
|
488
|
+
console.log(safeJson({ ok: true, appId: target.appId, domains }));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (domains.length === 0) {
|
|
492
|
+
console.log("No custom domains on this app.");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
for (const domain of domains) {
|
|
496
|
+
console.log([
|
|
497
|
+
displayText(domain.domain),
|
|
498
|
+
displayText(domain.environment ?? "production"),
|
|
499
|
+
displayText(domain.validationState ?? "unknown"),
|
|
500
|
+
].join(" | "));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function addAction(domainArg, options) {
|
|
504
|
+
const domain = normalizeDomain(domainArg);
|
|
505
|
+
const token = await ensureCognitoToken();
|
|
506
|
+
const target = await resolveTarget(token, options);
|
|
507
|
+
const environment = options.env ?? "production";
|
|
508
|
+
// The add can start a monthly charge, so the price is shown and approved first.
|
|
509
|
+
const pricing = await fetchPricingBody();
|
|
510
|
+
await confirm(addOnConfirmQuestion(domain, environment, target, pricing), options.yes, "add the domain");
|
|
511
|
+
const response = await request(token, target.orgSlug, domainRoute(target.appId), "POST", { domain, environment });
|
|
512
|
+
if (!response.ok)
|
|
513
|
+
throw await deployError(response, target.orgSlug);
|
|
514
|
+
const body = await readBody(response);
|
|
515
|
+
const records = dnsRecords(body.dnsInstructions);
|
|
516
|
+
const apex = body.apex === true;
|
|
517
|
+
const apexGuidance = typeof body.apexGuidance === "string" ? body.apexGuidance : undefined;
|
|
518
|
+
const paid = body.billingBasis === "paid";
|
|
519
|
+
const priceLine = paid ? addOnPriceLine(pricing) : undefined;
|
|
520
|
+
if (options.json) {
|
|
521
|
+
console.log(safeJson({
|
|
522
|
+
ok: true,
|
|
523
|
+
appId: target.appId,
|
|
524
|
+
domain,
|
|
525
|
+
environment,
|
|
526
|
+
validationState: body.domain?.validationState ?? "pending",
|
|
527
|
+
serving: body.serving === true,
|
|
528
|
+
dnsInstructions: records,
|
|
529
|
+
apex,
|
|
530
|
+
...(apexGuidance ? { apexGuidance } : {}),
|
|
531
|
+
...(typeof body.billingBasis === "string" ? { billingBasis: body.billingBasis } : {}),
|
|
532
|
+
...(priceLine ? { billingMessage: priceLine } : {}),
|
|
533
|
+
}));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
console.log(`Added ${displayText(domain)} to ${displayText(environment)}. It is not live yet.`);
|
|
537
|
+
if (priceLine)
|
|
538
|
+
console.log(priceLine);
|
|
539
|
+
printDnsRecords(records);
|
|
540
|
+
if (apex) {
|
|
541
|
+
console.log(apexGuidance
|
|
542
|
+
? displayText(apexGuidance)
|
|
543
|
+
: "This is a bare domain, so most DNS hosts cannot take a CNAME for it. Use an ALIAS or ANAME record, or CNAME flattening, for the traffic record.");
|
|
544
|
+
}
|
|
545
|
+
console.log(`When the records are in place, run: hq deploy domains refresh ${displayText(domain)}`);
|
|
546
|
+
}
|
|
547
|
+
async function refreshAction(domainArg, options) {
|
|
548
|
+
const domain = normalizeDomain(domainArg);
|
|
549
|
+
const token = await ensureCognitoToken();
|
|
550
|
+
const target = await resolveTarget(token, options);
|
|
551
|
+
const response = await request(token, target.orgSlug, domainRoute(target.appId, domain, "/refresh"), "POST");
|
|
552
|
+
if (!response.ok)
|
|
553
|
+
throw await deployError(response, target.orgSlug);
|
|
554
|
+
const body = await readBody(response);
|
|
555
|
+
const serving = body.serving === true;
|
|
556
|
+
const records = dnsRecords(body.dnsInstructions);
|
|
557
|
+
if (!serving)
|
|
558
|
+
process.exitCode = REFRESH_NOT_SERVING_EXIT_CODE;
|
|
559
|
+
if (options.json) {
|
|
560
|
+
console.log(safeJson({ ok: true, appId: target.appId, ...body, domain, serving, dnsInstructions: records }));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (serving) {
|
|
564
|
+
console.log(`${displayText(domain)} is live.`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const certStatus = typeof body.certStatus === "string" ? ` (certificate ${displayText(body.certStatus)})` : "";
|
|
568
|
+
console.log(`${displayText(domain)} is waiting for DNS validation${certStatus}.`);
|
|
569
|
+
printDnsRecords(records);
|
|
570
|
+
if (body.apex === true && typeof body.apexGuidance === "string")
|
|
571
|
+
console.log(displayText(body.apexGuidance));
|
|
572
|
+
console.log(`Run this again once the records are in place: hq deploy domains refresh ${displayText(domain)}`);
|
|
573
|
+
}
|
|
574
|
+
async function removeAction(domainArg, options) {
|
|
575
|
+
const domain = normalizeDomain(domainArg);
|
|
576
|
+
const token = await ensureCognitoToken();
|
|
577
|
+
const target = await resolveTarget(token, options);
|
|
578
|
+
await confirm(`Remove ${displayText(domain)} from app '${displayText(target.appLabel)}' in '${target.orgSlug}'? It stops serving, and if it is billed, its monthly charge stops.`, options.yes, "remove the domain");
|
|
579
|
+
const query = options.env ? `?env=${encodeURIComponent(options.env)}` : "";
|
|
580
|
+
const response = await request(token, target.orgSlug, domainRoute(target.appId, domain, query), "DELETE");
|
|
581
|
+
if (!response.ok)
|
|
582
|
+
throw await deployError(response, target.orgSlug);
|
|
583
|
+
const body = await readBody(response);
|
|
584
|
+
const billingRemovalPending = body.billingRemovalPending === true;
|
|
585
|
+
const warning = typeof body.warning === "string" ? body.warning : undefined;
|
|
586
|
+
if (options.json) {
|
|
587
|
+
console.log(safeJson({
|
|
588
|
+
ok: true,
|
|
589
|
+
appId: target.appId,
|
|
590
|
+
domain,
|
|
591
|
+
billingRemovalPending,
|
|
592
|
+
...(typeof body.remaining === "number" ? { remaining: body.remaining } : {}),
|
|
593
|
+
...(warning ? { warning } : {}),
|
|
594
|
+
}));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
console.log(`Removed ${displayText(domain)}.`);
|
|
598
|
+
if (billingRemovalPending) {
|
|
599
|
+
console.log("Its charge could not be stopped right away. HQ keeps retrying until the charge is removed.");
|
|
600
|
+
}
|
|
601
|
+
else if (warning) {
|
|
602
|
+
console.log(displayText(warning));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async function purchaseRequest(token, companyUid, suffix, body) {
|
|
606
|
+
try {
|
|
607
|
+
return await vaultApiFetch({
|
|
608
|
+
token,
|
|
609
|
+
path: `/v1/billing/companies/${encodeURIComponent(companyUid)}/domain-purchase${suffix}`,
|
|
610
|
+
method: "POST",
|
|
611
|
+
body,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
throw new DeployDomainsError("Could not reach HQ.", "NETWORK");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** The company that pays: --company, else the active company. */
|
|
619
|
+
function purchaseSlug(companyFlag) {
|
|
620
|
+
const slug = companyFlag ?? readActiveCompanySlug(DEFAULT_HQ_ROOT);
|
|
621
|
+
if (slug === undefined)
|
|
622
|
+
throw new DeployDomainsError("No active company. Pass --company <slug>.", "NO_COMPANY");
|
|
623
|
+
if (!SLUG_PATTERN.test(slug))
|
|
624
|
+
throw new DeployDomainsError("The company slug is not valid.", "INVALID_COMPANY");
|
|
625
|
+
return slug;
|
|
626
|
+
}
|
|
627
|
+
async function purchaseCompanyUid(token, slug) {
|
|
628
|
+
try {
|
|
629
|
+
return await getCompanyUid(token, slug);
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const cause = error instanceof Error ? displayText(error.message) : "";
|
|
633
|
+
if (/was not found/i.test(cause)) {
|
|
634
|
+
throw new DeployDomainsError(`Could not find the company '${displayText(slug)}'.`, "COMPANY_NOT_FOUND");
|
|
635
|
+
}
|
|
636
|
+
throw new DeployDomainsError(`Could not look up the company '${displayText(slug)}'${cause ? `: ${cause}` : "."}`, "COMPANY_LOOKUP_FAILED");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async function searchAction(query, options) {
|
|
640
|
+
const token = await ensureCognitoToken();
|
|
641
|
+
const companyUid = await purchaseCompanyUid(token, purchaseSlug(options.company));
|
|
642
|
+
const response = await purchaseRequest(token, companyUid, "/search", { query });
|
|
643
|
+
if (!response.ok)
|
|
644
|
+
throw await purchaseError(response);
|
|
645
|
+
const body = await readBody(response);
|
|
646
|
+
const results = Array.isArray(body.results)
|
|
647
|
+
? body.results.filter((item) => typeof item === "object" && item !== null)
|
|
648
|
+
: [];
|
|
649
|
+
if (options.json) {
|
|
650
|
+
console.log(safeJson({ ok: true, results }));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (results.length === 0) {
|
|
654
|
+
console.log("No domains found.");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
for (const result of results) {
|
|
658
|
+
if (result.available !== true || typeof result.priceUsd !== "number") {
|
|
659
|
+
console.log(`${displayText(result.domain)} | not available`);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const renewal = typeof result.renewalPriceUsd === "number" ? `, renews at ${usd(result.renewalPriceUsd)}/year` : "";
|
|
663
|
+
console.log(`${displayText(result.domain)} | ${usd(result.priceUsd)} for the first year${renewal}`);
|
|
664
|
+
}
|
|
665
|
+
console.log("To buy one: hq deploy domains buy <domain> --price <first-year price> --registrant-file <contact.json>");
|
|
666
|
+
}
|
|
667
|
+
function readRegistrant(file) {
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
throw new DeployDomainsError("Could not read the registrant file as JSON.", "INVALID_REGISTRANT");
|
|
674
|
+
}
|
|
675
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
676
|
+
throw new DeployDomainsError(PURCHASE_MESSAGES.INVALID_REGISTRANT, "INVALID_REGISTRANT");
|
|
677
|
+
}
|
|
678
|
+
const source = parsed;
|
|
679
|
+
const registrant = {};
|
|
680
|
+
for (const field of REGISTRANT_FIELDS) {
|
|
681
|
+
if (source[field] !== undefined)
|
|
682
|
+
registrant[field] = source[field];
|
|
683
|
+
}
|
|
684
|
+
return registrant;
|
|
685
|
+
}
|
|
686
|
+
export function parsePrice(raw) {
|
|
687
|
+
const trimmed = raw.trim().replace(/^\$/, "");
|
|
688
|
+
const value = /^\d+(\.\d{1,2})?$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
|
689
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
690
|
+
throw new DeployDomainsError(PURCHASE_MESSAGES.INVALID_EXPECTED_PRICE, "INVALID_EXPECTED_PRICE");
|
|
691
|
+
}
|
|
692
|
+
return value;
|
|
693
|
+
}
|
|
694
|
+
async function buyAction(domainArg, options) {
|
|
695
|
+
const domain = normalizeDomain(domainArg);
|
|
696
|
+
const expectedPriceUsd = parsePrice(options.price);
|
|
697
|
+
if (options.idempotencyKey !== undefined && !IDEMPOTENCY_KEY_PATTERN.test(options.idempotencyKey)) {
|
|
698
|
+
throw new DeployDomainsError(PURCHASE_MESSAGES.INVALID_IDEMPOTENCY_KEY, "INVALID_IDEMPOTENCY_KEY");
|
|
699
|
+
}
|
|
700
|
+
const registrant = readRegistrant(options.registrantFile);
|
|
701
|
+
const idempotencyKey = options.idempotencyKey ?? `cli_${randomUUID().replace(/-/g, "")}`;
|
|
702
|
+
// Resolve the paying company before the prompt so the approval names it.
|
|
703
|
+
const slug = purchaseSlug(options.company);
|
|
704
|
+
const token = await ensureCognitoToken();
|
|
705
|
+
const companyUid = await purchaseCompanyUid(token, slug);
|
|
706
|
+
const terms = purchaseTermsLine(await fetchPricingBody());
|
|
707
|
+
await confirm(`Buy ${displayText(domain)} for ${usd(expectedPriceUsd)} for the first year, charged to the card on file for '${slug}'? ${terms ?? PURCHASE_TERMS_UNAVAILABLE}`, options.yes, "buy the domain");
|
|
708
|
+
let response;
|
|
709
|
+
try {
|
|
710
|
+
response = await purchaseRequest(token, companyUid, "", { domain, expectedPriceUsd, idempotencyKey, registrant });
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
// The request may have reached hq-pro, so the order's key is the only safe retry.
|
|
714
|
+
const reason = error instanceof DeployDomainsError ? error.message : "Could not reach HQ.";
|
|
715
|
+
throw new DeployDomainsError(`${reason} ${resumeOrderSentence(idempotencyKey)}`, "NETWORK", undefined, { idempotencyKey });
|
|
716
|
+
}
|
|
717
|
+
if (!response.ok)
|
|
718
|
+
throw await purchaseError(response, idempotencyKey);
|
|
719
|
+
const body = await readBody(response);
|
|
720
|
+
if (options.json) {
|
|
721
|
+
console.log(safeJson({ ok: true, idempotencyKey, ...body }));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const expires = typeof body.expiresAt === "string" ? ` It is registered until ${displayText(body.expiresAt)}.` : "";
|
|
725
|
+
if (body.replayed === true) {
|
|
726
|
+
console.log(`${displayText(domain)} was already bought by this order.${expires}`);
|
|
727
|
+
}
|
|
728
|
+
else if (body.paymentCaptured === false) {
|
|
729
|
+
// hq-pro registered the name, but capturing the card authorization failed.
|
|
730
|
+
console.log(`Registered ${displayText(domain)}.${expires} The card was authorized for ${usd(expectedPriceUsd)}, ` +
|
|
731
|
+
`but the charge has not completed yet. HQ billing follows up on it. Do not buy the domain again.`);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
console.log(`Bought ${displayText(domain)} for ${usd(expectedPriceUsd)}.${expires}`);
|
|
735
|
+
}
|
|
736
|
+
if (terms)
|
|
737
|
+
console.log(terms);
|
|
738
|
+
console.log(`To serve an app on it: hq deploy domains add ${displayText(domain)} --app <app>`);
|
|
739
|
+
}
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// Registration
|
|
742
|
+
function targetOptions(command) {
|
|
743
|
+
return command
|
|
744
|
+
.option("--app <app>", "app id, subdomain or name (default: the app linked to this folder)")
|
|
745
|
+
.option("--path <dir>", "folder linked to the app (default: current folder)")
|
|
746
|
+
.option("--company <slug>", "use this company instead of the linked or active company")
|
|
747
|
+
.option("--json", "print machine-readable JSON");
|
|
748
|
+
}
|
|
749
|
+
export function registerDeployDomainsCommand(deploy) {
|
|
750
|
+
const domains = deploy
|
|
751
|
+
.command("domains")
|
|
752
|
+
.description("Manage custom domains on your deployed apps, and buy domains")
|
|
753
|
+
.addHelpText("after", "\nadd, remove and buy ask for approval in an interactive terminal. Without one (a script, CI, " +
|
|
754
|
+
"or a Windows shell that does not report a terminal, such as Git Bash under mintty), pass --yes.");
|
|
755
|
+
targetOptions(domains.command("list").description("List the custom domains on an app"))
|
|
756
|
+
.action((options) => run(options.json, () => listAction(options)));
|
|
757
|
+
targetOptions(domains
|
|
758
|
+
.command("add <domain>")
|
|
759
|
+
.description("Link a custom domain to an app and print the DNS records to create")
|
|
760
|
+
.option("--env <name>", "environment to serve", "production")
|
|
761
|
+
.option("--yes", "add without a prompt (the domain can add a monthly charge)")).action((domain, options) => run(options.json, () => addAction(domain, options)));
|
|
762
|
+
targetOptions(domains
|
|
763
|
+
.command("refresh <domain>")
|
|
764
|
+
.description(`Check DNS validation and start serving once the certificate is issued (exits ${REFRESH_NOT_SERVING_EXIT_CODE} while not live)`)).action((domain, options) => run(options.json, () => refreshAction(domain, options)));
|
|
765
|
+
targetOptions(domains
|
|
766
|
+
.command("remove <domain>")
|
|
767
|
+
.description("Unlink a custom domain from an app")
|
|
768
|
+
.option("--env <name>", "pick the environment when the domain points at more than one")
|
|
769
|
+
.option("--yes", "remove without a prompt")).action((domain, options) => run(options.json, () => removeAction(domain, options)));
|
|
770
|
+
domains
|
|
771
|
+
.command("search <query>")
|
|
772
|
+
.description("Check whether a domain can be bought, and its price")
|
|
773
|
+
.option("--company <slug>", "use this company instead of the active company")
|
|
774
|
+
.option("--json", "print machine-readable JSON")
|
|
775
|
+
.action((query, options) => run(options.json, () => searchAction(query, options)));
|
|
776
|
+
domains
|
|
777
|
+
.command("buy <domain>")
|
|
778
|
+
.description("Buy a domain for the company (owner or admin)")
|
|
779
|
+
.requiredOption("--price <usd>", "first-year price shown by 'hq deploy domains search'")
|
|
780
|
+
.requiredOption("--registrant-file <path>", "JSON file with the registrant contact")
|
|
781
|
+
.option("--idempotency-key <key>", "retry an earlier order that did not finish")
|
|
782
|
+
.option("--company <slug>", "use this company instead of the active company")
|
|
783
|
+
.option("--yes", "buy without a prompt")
|
|
784
|
+
.option("--json", "print machine-readable JSON")
|
|
785
|
+
.action((domain, options) => run(options.json, () => buyAction(domain, options)));
|
|
786
|
+
}
|
|
787
|
+
//# sourceMappingURL=deploy-domains.js.map
|