hatchkit 0.1.2 → 0.1.3
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/dist/completion.d.ts +2 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +207 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +439 -127
- package/dist/config.js.map +1 -1
- package/dist/deploy/keys.d.ts +6 -2
- package/dist/deploy/keys.d.ts.map +1 -1
- package/dist/deploy/keys.js +16 -2
- package/dist/deploy/keys.js.map +1 -1
- package/dist/deploy/pages.d.ts +2 -0
- package/dist/deploy/pages.d.ts.map +1 -0
- package/dist/deploy/pages.js +537 -0
- package/dist/deploy/pages.js.map +1 -0
- package/dist/deploy/rename-domain.d.ts +55 -0
- package/dist/deploy/rename-domain.d.ts.map +1 -0
- package/dist/deploy/rename-domain.js +290 -0
- package/dist/deploy/rename-domain.js.map +1 -0
- package/dist/deploy/terraform.d.ts.map +1 -1
- package/dist/deploy/terraform.js +90 -0
- package/dist/deploy/terraform.js.map +1 -1
- package/dist/dns.d.ts +7 -0
- package/dist/dns.d.ts.map +1 -0
- package/dist/dns.js +124 -0
- package/dist/dns.js.map +1 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +368 -0
- package/dist/doctor.js.map +1 -0
- package/dist/explain.d.ts +4 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +173 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.js +477 -46
- package/dist/index.js.map +1 -1
- package/dist/provision/glitchtip.d.ts +3 -0
- package/dist/provision/glitchtip.d.ts.map +1 -1
- package/dist/provision/glitchtip.js +18 -0
- package/dist/provision/glitchtip.js.map +1 -1
- package/dist/provision/index.d.ts +26 -0
- package/dist/provision/index.d.ts.map +1 -1
- package/dist/provision/index.js +435 -60
- package/dist/provision/index.js.map +1 -1
- package/dist/provision/openpanel.d.ts +7 -0
- package/dist/provision/openpanel.d.ts.map +1 -1
- package/dist/provision/openpanel.js +113 -48
- package/dist/provision/openpanel.js.map +1 -1
- package/dist/provision/resend.d.ts +23 -1
- package/dist/provision/resend.d.ts.map +1 -1
- package/dist/provision/resend.js +62 -1
- package/dist/provision/resend.js.map +1 -1
- package/dist/provision/write-env.d.ts +31 -0
- package/dist/provision/write-env.d.ts.map +1 -0
- package/dist/provision/write-env.js +94 -0
- package/dist/provision/write-env.js.map +1 -0
- package/dist/scaffold/infra.d.ts.map +1 -1
- package/dist/scaffold/infra.js +18 -1
- package/dist/scaffold/infra.js.map +1 -1
- package/dist/status.d.ts +30 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +169 -0
- package/dist/status.js.map +1 -0
- package/dist/templates/addons/analytics/sentry.ts.hbs +6 -0
- package/dist/utils/cloudflare-api.d.ts +30 -0
- package/dist/utils/cloudflare-api.d.ts.map +1 -0
- package/dist/utils/cloudflare-api.js +85 -0
- package/dist/utils/cloudflare-api.js.map +1 -0
- package/dist/utils/coolify-api.d.ts +3 -1
- package/dist/utils/coolify-api.d.ts.map +1 -1
- package/dist/utils/coolify-api.js +29 -4
- package/dist/utils/coolify-api.js.map +1 -1
- package/dist/utils/inwx-api.d.ts +36 -0
- package/dist/utils/inwx-api.d.ts.map +1 -0
- package/dist/utils/inwx-api.js +105 -0
- package/dist/utils/inwx-api.js.map +1 -0
- package/dist/utils/secrets.d.ts +8 -1
- package/dist/utils/secrets.d.ts.map +1 -1
- package/dist/utils/secrets.js +8 -1
- package/dist/utils/secrets.js.map +1 -1
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
1
|
-
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
1
|
+
import { Separator, confirm, input, password, select } from "@inquirer/prompts";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import Conf from "conf";
|
|
4
4
|
import ora from "ora";
|
|
5
5
|
import { verifyCoolify } from "./utils/coolify-api.js";
|
|
6
6
|
import { execOk } from "./utils/exec.js";
|
|
7
|
-
import { SECRET_KEYS, clearAllSecrets, getSecret, setSecret } from "./utils/secrets.js";
|
|
7
|
+
import { SECRET_KEYS, clearAllSecrets, deleteSecret, getSecret, setSecret, } from "./utils/secrets.js";
|
|
8
8
|
import { validateRequired, validateUrl } from "./utils/validate.js";
|
|
9
|
+
/** Pretty-print "where to create this token" hint before a password prompt. */
|
|
10
|
+
function tokenHint(url, scope) {
|
|
11
|
+
console.log(chalk.dim(` → Create at: ${chalk.cyan(url)}`));
|
|
12
|
+
console.log(chalk.dim(` Permissions: ${scope}`));
|
|
13
|
+
}
|
|
14
|
+
/** Sanitize pasted secret: strip bracketed-paste escapes + non-printable
|
|
15
|
+
* ASCII that some terminals inject on paste. Plain `.trim()` misses these. */
|
|
16
|
+
function sanitizePastedSecret(raw) {
|
|
17
|
+
return raw
|
|
18
|
+
.replace(/\x1b\[2\d\d~/g, "")
|
|
19
|
+
.replace(/[^\x20-\x7e]/g, "")
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
/** Prompt for a secret, show a masked preview (`abcd…wxyz, 50 chars`),
|
|
23
|
+
* and let the user re-enter if the paste looks wrong. Loops until the
|
|
24
|
+
* user confirms. Values are never echoed in full. */
|
|
25
|
+
async function confirmPastedSecret(label) {
|
|
26
|
+
for (;;) {
|
|
27
|
+
const raw = await password({ message: `${label}:` });
|
|
28
|
+
const value = sanitizePastedSecret(raw);
|
|
29
|
+
if (!value) {
|
|
30
|
+
console.log(chalk.yellow(" (empty — please paste again)"));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const preview = value.length <= 8
|
|
34
|
+
? `${"*".repeat(value.length)} (${value.length} chars — looks short?)`
|
|
35
|
+
: `${value.slice(0, 4)}…${value.slice(-4)} (${value.length} chars)`;
|
|
36
|
+
const ok = await confirm({
|
|
37
|
+
message: `Looks like: ${chalk.cyan(preview)} — use this?`,
|
|
38
|
+
default: true,
|
|
39
|
+
});
|
|
40
|
+
if (ok)
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
9
44
|
// ---------------------------------------------------------------------------
|
|
10
45
|
// Config store
|
|
11
46
|
// ---------------------------------------------------------------------------
|
|
@@ -54,6 +89,12 @@ function createStore() {
|
|
|
54
89
|
}
|
|
55
90
|
}
|
|
56
91
|
const store = createStore();
|
|
92
|
+
/** Exposed for internal modules that need raw access (e.g. the `doctor`
|
|
93
|
+
* command). External consumers should prefer the typed `getXConfig()`
|
|
94
|
+
* helpers. */
|
|
95
|
+
export function getStore() {
|
|
96
|
+
return store;
|
|
97
|
+
}
|
|
57
98
|
export function getConfig() {
|
|
58
99
|
return store.store;
|
|
59
100
|
}
|
|
@@ -137,19 +178,29 @@ export async function ensureCoolify() {
|
|
|
137
178
|
default: existing?.url,
|
|
138
179
|
validate: (v) => validateUrl(v.trim()),
|
|
139
180
|
})).trim();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
spinner
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
181
|
+
// Loop on the token until it authenticates — pasting the wrong token
|
|
182
|
+
// is easy, and re-running the whole onboarding just to retry is rude.
|
|
183
|
+
let token = "";
|
|
184
|
+
tokenHint(`${url.replace(/\/$/, "")}/security/api-tokens`, "root (full access)");
|
|
185
|
+
for (;;) {
|
|
186
|
+
token = await confirmPastedSecret("Coolify API token");
|
|
187
|
+
const spinner = ora("Testing Coolify connection...").start();
|
|
188
|
+
try {
|
|
189
|
+
const version = await verifyCoolify(url, token);
|
|
190
|
+
spinner.succeed(`Connected to Coolify v${version}`);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
spinner.fail("Could not connect to Coolify");
|
|
195
|
+
console.log(chalk.dim(` ${error instanceof Error ? error.message : String(error)}`));
|
|
196
|
+
const retry = await confirm({
|
|
197
|
+
message: "Try a different token?",
|
|
198
|
+
default: true,
|
|
199
|
+
});
|
|
200
|
+
if (!retry)
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
151
203
|
}
|
|
152
|
-
// Cache server list
|
|
153
204
|
const { CoolifyApi } = await import("./utils/coolify-api.js");
|
|
154
205
|
const api = new CoolifyApi({ url, token });
|
|
155
206
|
const servers = await api.listServers();
|
|
@@ -186,9 +237,8 @@ export async function ensureHetzner() {
|
|
|
186
237
|
if (existing?.status === "configured" && existingToken) {
|
|
187
238
|
return { ...existing, token: existingToken };
|
|
188
239
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
});
|
|
240
|
+
tokenHint("https://console.hetzner.cloud/projects → Security → API Tokens", "Read & Write (needed to create servers)");
|
|
241
|
+
const token = await confirmPastedSecret("Hetzner Cloud API token");
|
|
192
242
|
const spinner = ora("Testing Hetzner connection...").start();
|
|
193
243
|
try {
|
|
194
244
|
const res = await fetch("https://api.hetzner.cloud/v1/servers", {
|
|
@@ -214,6 +264,15 @@ export async function getHetznerToken() {
|
|
|
214
264
|
await migrateSecret("providers.hetzner.token", SECRET_KEYS.hetznerToken);
|
|
215
265
|
return getSecret(SECRET_KEYS.hetznerToken);
|
|
216
266
|
}
|
|
267
|
+
export async function getHetznerConfig() {
|
|
268
|
+
const meta = store.get("providers.hetzner");
|
|
269
|
+
if (!meta || meta.status !== "configured")
|
|
270
|
+
return null;
|
|
271
|
+
const token = await getHetznerToken();
|
|
272
|
+
if (!token)
|
|
273
|
+
return null;
|
|
274
|
+
return { ...meta, token };
|
|
275
|
+
}
|
|
217
276
|
// ---------------------------------------------------------------------------
|
|
218
277
|
// Provider: DNS
|
|
219
278
|
// ---------------------------------------------------------------------------
|
|
@@ -224,10 +283,12 @@ export async function ensureDns() {
|
|
|
224
283
|
if (existing?.status === "configured") {
|
|
225
284
|
const password = await getSecret(SECRET_KEYS.dnsInwxPassword);
|
|
226
285
|
const apiToken = await getSecret(SECRET_KEYS.dnsCloudflareToken);
|
|
286
|
+
const registrarPassword = await getSecret(SECRET_KEYS.dnsInwxRegistrarPassword);
|
|
227
287
|
return {
|
|
228
288
|
...existing,
|
|
229
289
|
password: password ?? undefined,
|
|
230
290
|
apiToken: apiToken ?? undefined,
|
|
291
|
+
registrarPassword: registrarPassword ?? undefined,
|
|
231
292
|
};
|
|
232
293
|
}
|
|
233
294
|
const provider = await select({
|
|
@@ -245,7 +306,7 @@ export async function ensureDns() {
|
|
|
245
306
|
}
|
|
246
307
|
if (provider === "inwx") {
|
|
247
308
|
const username = await input({ message: "INWX username:", validate: validateRequired });
|
|
248
|
-
const pwd = await
|
|
309
|
+
const pwd = await confirmPastedSecret("INWX password");
|
|
249
310
|
const meta = {
|
|
250
311
|
status: "configured",
|
|
251
312
|
provider: "inwx",
|
|
@@ -257,15 +318,45 @@ export async function ensureDns() {
|
|
|
257
318
|
return { ...meta, password: pwd };
|
|
258
319
|
}
|
|
259
320
|
// Cloudflare
|
|
260
|
-
|
|
321
|
+
tokenHint("https://dash.cloudflare.com/profile/api-tokens → Create Token", "Zone:DNS:Edit + Zone:Zone:Read (scope to the zones you'll use)");
|
|
322
|
+
const apiToken = await confirmPastedSecret("Cloudflare API token");
|
|
323
|
+
const accountId = await input({
|
|
324
|
+
message: "Cloudflare account ID (optional — leave blank to span all accounts):",
|
|
325
|
+
default: "",
|
|
326
|
+
});
|
|
327
|
+
// Cross-provider case: DNS on Cloudflare, but the domain is still
|
|
328
|
+
// registered at INWX. Offer to store INWX registrar creds so deploys
|
|
329
|
+
// (and the `dns link-to-cloudflare` command) can flip the delegated NS
|
|
330
|
+
// to Cloudflare automatically without a UI click-through per domain.
|
|
331
|
+
const wireInwxRegistrar = await confirm({
|
|
332
|
+
message: "Is INWX your domain registrar? (if yes, hatchkit will auto-point NS at Cloudflare on deploy)",
|
|
333
|
+
default: false,
|
|
334
|
+
});
|
|
335
|
+
let registrarUsername;
|
|
336
|
+
let registrarPassword;
|
|
337
|
+
if (wireInwxRegistrar) {
|
|
338
|
+
registrarUsername = await input({
|
|
339
|
+
message: "INWX username (registrar):",
|
|
340
|
+
validate: validateRequired,
|
|
341
|
+
});
|
|
342
|
+
registrarPassword = await confirmPastedSecret("INWX password (registrar)");
|
|
343
|
+
}
|
|
261
344
|
const meta = {
|
|
262
345
|
status: "configured",
|
|
263
346
|
provider: "cloudflare",
|
|
347
|
+
accountId: accountId.trim() || undefined,
|
|
348
|
+
registrarUsername,
|
|
264
349
|
};
|
|
265
350
|
store.set("providers.dns", meta);
|
|
266
351
|
await setSecret(SECRET_KEYS.dnsCloudflareToken, apiToken);
|
|
352
|
+
if (registrarPassword) {
|
|
353
|
+
await setSecret(SECRET_KEYS.dnsInwxRegistrarPassword, registrarPassword);
|
|
354
|
+
}
|
|
267
355
|
console.log(chalk.green(" ✓ Cloudflare DNS configured"));
|
|
268
|
-
|
|
356
|
+
if (registrarUsername) {
|
|
357
|
+
console.log(chalk.green(" ✓ INWX registrar wired for auto-NS updates"));
|
|
358
|
+
}
|
|
359
|
+
return { ...meta, apiToken, registrarPassword };
|
|
269
360
|
}
|
|
270
361
|
export async function getDnsConfig() {
|
|
271
362
|
await migrateSecret("providers.dns.password", SECRET_KEYS.dnsInwxPassword);
|
|
@@ -275,10 +366,12 @@ export async function getDnsConfig() {
|
|
|
275
366
|
return null;
|
|
276
367
|
const password = await getSecret(SECRET_KEYS.dnsInwxPassword);
|
|
277
368
|
const apiToken = await getSecret(SECRET_KEYS.dnsCloudflareToken);
|
|
369
|
+
const registrarPassword = await getSecret(SECRET_KEYS.dnsInwxRegistrarPassword);
|
|
278
370
|
return {
|
|
279
371
|
...meta,
|
|
280
372
|
password: password ?? undefined,
|
|
281
373
|
apiToken: apiToken ?? undefined,
|
|
374
|
+
registrarPassword: registrarPassword ?? undefined,
|
|
282
375
|
};
|
|
283
376
|
}
|
|
284
377
|
// ---------------------------------------------------------------------------
|
|
@@ -294,11 +387,39 @@ export async function ensureS3(provider) {
|
|
|
294
387
|
return { ...existing, accessKey, secretKey };
|
|
295
388
|
}
|
|
296
389
|
console.log(chalk.yellow(`\n ${provider.toUpperCase()} S3 is not configured yet. Let's set it up.`));
|
|
297
|
-
|
|
298
|
-
|
|
390
|
+
// For R2 we need the account id BEFORE showing the create-token URL, so
|
|
391
|
+
// we can deep-link to the account-scoped page.
|
|
299
392
|
let endpoint;
|
|
300
393
|
let region;
|
|
301
394
|
let location;
|
|
395
|
+
let accountId;
|
|
396
|
+
if (provider === "r2") {
|
|
397
|
+
console.log(chalk.dim(" Your Cloudflare account ID is in the dashboard URL:\n" +
|
|
398
|
+
" dash.cloudflare.com/<account-id>/home/overview"));
|
|
399
|
+
accountId = (await input({
|
|
400
|
+
message: "Cloudflare account ID:",
|
|
401
|
+
validate: validateRequired,
|
|
402
|
+
})).trim();
|
|
403
|
+
endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
|
|
404
|
+
region = "auto";
|
|
405
|
+
}
|
|
406
|
+
const s3Hints = {
|
|
407
|
+
hetzner: {
|
|
408
|
+
url: "https://console.hetzner.cloud → your project → Security → S3 credentials",
|
|
409
|
+
scope: "any (credentials are per-project)",
|
|
410
|
+
},
|
|
411
|
+
aws: {
|
|
412
|
+
url: "https://console.aws.amazon.com/iam → Users → Security credentials → Create access key",
|
|
413
|
+
scope: "s3:PutObject, s3:GetObject, s3:DeleteObject on the target bucket",
|
|
414
|
+
},
|
|
415
|
+
r2: {
|
|
416
|
+
url: `https://dash.cloudflare.com/${accountId ?? ""}/r2/api-tokens → Create Token`,
|
|
417
|
+
scope: "Object Read & Write — then copy from the 'Use the following credentials for S3 clients' section (NOT the 'Token value' at the top)",
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
tokenHint(s3Hints[provider].url, s3Hints[provider].scope);
|
|
421
|
+
const promptedAccessKey = await confirmPastedSecret(`${provider} S3 Access Key ID`);
|
|
422
|
+
const promptedSecretKey = await confirmPastedSecret(`${provider} S3 Secret Access Key`);
|
|
302
423
|
if (provider === "hetzner") {
|
|
303
424
|
location = await select({
|
|
304
425
|
message: "Hetzner Object Storage location:",
|
|
@@ -311,15 +432,7 @@ export async function ensureS3(provider) {
|
|
|
311
432
|
endpoint = `https://${location}.your-objectstorage.com`;
|
|
312
433
|
region = location;
|
|
313
434
|
}
|
|
314
|
-
else if (provider === "
|
|
315
|
-
const accountId = await input({
|
|
316
|
-
message: "Cloudflare account ID:",
|
|
317
|
-
validate: validateRequired,
|
|
318
|
-
});
|
|
319
|
-
endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
|
|
320
|
-
region = "auto";
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
435
|
+
else if (provider === "aws") {
|
|
323
436
|
region = await input({ message: "AWS region:", default: "us-east-1" });
|
|
324
437
|
endpoint = `https://s3.${region}.amazonaws.com`;
|
|
325
438
|
}
|
|
@@ -372,13 +485,16 @@ export async function ensureGpuProvider(platform) {
|
|
|
372
485
|
break;
|
|
373
486
|
}
|
|
374
487
|
case "runpod":
|
|
375
|
-
|
|
488
|
+
tokenHint("https://runpod.io/user/settings → API Keys", "Read & Write");
|
|
489
|
+
apiKey = await confirmPastedSecret("RunPod API key");
|
|
376
490
|
break;
|
|
377
491
|
case "hf":
|
|
378
|
-
|
|
492
|
+
tokenHint("https://huggingface.co/settings/tokens", "Read (or Write if you'll push models)");
|
|
493
|
+
apiKey = await confirmPastedSecret("HuggingFace token");
|
|
379
494
|
break;
|
|
380
495
|
case "replicate":
|
|
381
|
-
|
|
496
|
+
tokenHint("https://replicate.com/account/api-tokens", "any (account-scoped)");
|
|
497
|
+
apiKey = await confirmPastedSecret("Replicate API token");
|
|
382
498
|
break;
|
|
383
499
|
}
|
|
384
500
|
const meta = {
|
|
@@ -442,9 +558,8 @@ export async function ensureGlitchtip() {
|
|
|
442
558
|
default: existing?.url ?? "https://glitchtip.trebeljahr.com",
|
|
443
559
|
validate: (v) => validateUrl(v.trim()),
|
|
444
560
|
})).trim();
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
})).trim();
|
|
561
|
+
tokenHint(`${url.replace(/\/$/, "")}/profile/auth-tokens`, "project:admin (read + write projects & teams)");
|
|
562
|
+
const token = await confirmPastedSecret("GlitchTip auth token");
|
|
448
563
|
const organizationSlug = (await input({
|
|
449
564
|
message: "GlitchTip organization slug:",
|
|
450
565
|
default: existing?.organizationSlug,
|
|
@@ -481,43 +596,79 @@ export async function getGlitchtipConfig() {
|
|
|
481
596
|
// ---------------------------------------------------------------------------
|
|
482
597
|
export async function ensureOpenpanel() {
|
|
483
598
|
const existing = store.get("providers.openpanel");
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
599
|
+
const existingId = await getSecret(SECRET_KEYS.openpanelRootClientId);
|
|
600
|
+
const existingSecret = await getSecret(SECRET_KEYS.openpanelRootClientSecret);
|
|
601
|
+
// Short-circuit only if *every* field is present. `apiUrl` was added
|
|
602
|
+
// after 0.1.x — configs written by earlier versions lack it, which is
|
|
603
|
+
// why a previously-"configured" setup now hits the dashboard URL
|
|
604
|
+
// instead of the API host. Fall through to the prompt flow so we can
|
|
605
|
+
// top it up without losing the rest of the config.
|
|
606
|
+
if (existing?.status === "configured" && existing.apiUrl && existingId && existingSecret) {
|
|
607
|
+
return { ...existing, rootClientId: existingId, rootClientSecret: existingSecret };
|
|
608
|
+
}
|
|
609
|
+
if (existing?.status === "configured" && !existing.apiUrl) {
|
|
610
|
+
console.log(chalk.yellow("\n OpenPanel config is missing the Management API URL — let's fill that in."));
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
console.log(chalk.yellow("\n OpenPanel is not configured yet. Let's set it up."));
|
|
487
614
|
}
|
|
488
|
-
console.log(chalk.yellow("\n OpenPanel is not configured yet. Let's set it up."));
|
|
489
615
|
const url = (await input({
|
|
490
|
-
message: "OpenPanel
|
|
616
|
+
message: "OpenPanel dashboard URL:",
|
|
491
617
|
default: existing?.url ?? "https://analytics.trebeljahr.com",
|
|
492
618
|
validate: (v) => validateUrl(v.trim()),
|
|
493
619
|
})).trim();
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
620
|
+
// Self-hosted OpenPanel exposes the Management API on a separate
|
|
621
|
+
// subdomain (e.g. `api.op.example.com`). Default by prepending `api.`
|
|
622
|
+
// to the dashboard host, which matches the docs' recommended layout.
|
|
623
|
+
const defaultApiUrl = existing?.apiUrl ?? url.replace(/^https?:\/\//, (m) => `${m}api.`).replace(/\/$/, "");
|
|
624
|
+
const apiUrl = (await input({
|
|
625
|
+
message: "OpenPanel API URL (Management API base — usually api.<dashboard>):",
|
|
626
|
+
default: defaultApiUrl,
|
|
627
|
+
validate: (v) => validateUrl(v.trim()),
|
|
628
|
+
}))
|
|
629
|
+
.trim()
|
|
630
|
+
.replace(/\/$/, "");
|
|
497
631
|
const organizationSlug = (await input({
|
|
498
632
|
message: "OpenPanel organization slug:",
|
|
499
633
|
default: existing?.organizationSlug,
|
|
500
634
|
validate: validateRequired,
|
|
501
635
|
})).trim();
|
|
636
|
+
console.log(chalk.dim(`\n OpenPanel auth uses a client id/secret pair, not a bearer token.\n` +
|
|
637
|
+
` Create a root-mode client once so hatchkit can auto-create\n` +
|
|
638
|
+
` per-project clients via the Management API.\n\n` +
|
|
639
|
+
` Where to create it:\n` +
|
|
640
|
+
` 1. Open ${chalk.cyan(`${url.replace(/\/$/, "")}/${organizationSlug}`)}\n` +
|
|
641
|
+
` 2. Pick any project (or create a placeholder "hatchkit-root" project)\n` +
|
|
642
|
+
` 3. Project → Settings → Clients → New client\n` +
|
|
643
|
+
` 4. Type: ${chalk.cyan("root")} (Management API access — full org-wide)\n` +
|
|
644
|
+
` 5. Copy the clientId and clientSecret (secret is shown once)\n`));
|
|
645
|
+
const rootClientId = (await input({
|
|
646
|
+
message: "OpenPanel root clientId:",
|
|
647
|
+
validate: validateRequired,
|
|
648
|
+
})).trim();
|
|
649
|
+
const rootClientSecret = await confirmPastedSecret("OpenPanel root clientSecret (shown once at creation)");
|
|
502
650
|
const meta = {
|
|
503
651
|
status: "configured",
|
|
504
652
|
url: url.replace(/\/$/, ""),
|
|
653
|
+
apiUrl,
|
|
505
654
|
organizationSlug,
|
|
506
655
|
lastVerified: new Date().toISOString(),
|
|
507
656
|
};
|
|
508
657
|
store.set("providers.openpanel", meta);
|
|
509
|
-
await setSecret(SECRET_KEYS.
|
|
658
|
+
await setSecret(SECRET_KEYS.openpanelRootClientId, rootClientId);
|
|
659
|
+
await setSecret(SECRET_KEYS.openpanelRootClientSecret, rootClientSecret);
|
|
510
660
|
console.log(chalk.green(" ✓ OpenPanel configured"));
|
|
511
|
-
return { ...meta,
|
|
661
|
+
return { ...meta, rootClientId, rootClientSecret };
|
|
512
662
|
}
|
|
513
663
|
export async function getOpenpanelConfig() {
|
|
514
664
|
const meta = store.get("providers.openpanel");
|
|
515
665
|
if (!meta || meta.status !== "configured")
|
|
516
666
|
return null;
|
|
517
|
-
const
|
|
518
|
-
|
|
667
|
+
const rootClientId = await getSecret(SECRET_KEYS.openpanelRootClientId);
|
|
668
|
+
const rootClientSecret = await getSecret(SECRET_KEYS.openpanelRootClientSecret);
|
|
669
|
+
if (!rootClientId || !rootClientSecret)
|
|
519
670
|
return null;
|
|
520
|
-
return { ...meta,
|
|
671
|
+
return { ...meta, rootClientId, rootClientSecret };
|
|
521
672
|
}
|
|
522
673
|
// ---------------------------------------------------------------------------
|
|
523
674
|
// Provider: Resend (transactional email SaaS)
|
|
@@ -529,9 +680,8 @@ export async function ensureResend() {
|
|
|
529
680
|
return { ...existing, apiKey: existingKey };
|
|
530
681
|
}
|
|
531
682
|
console.log(chalk.yellow("\n Resend is not configured yet. Let's set it up."));
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
})).trim();
|
|
683
|
+
tokenHint("https://resend.com/api-keys", "Full access (needed to create domain-scoped keys)");
|
|
684
|
+
const apiKey = await confirmPastedSecret("Resend API key");
|
|
535
685
|
const spinner = ora("Verifying Resend API key...").start();
|
|
536
686
|
try {
|
|
537
687
|
const res = await fetch("https://api.resend.com/domains", {
|
|
@@ -570,86 +720,248 @@ export async function isFirstRun() {
|
|
|
570
720
|
const config = getConfig();
|
|
571
721
|
return config.providers.github.status === "unconfigured" && !config.providers.coolify;
|
|
572
722
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
723
|
+
/** Wipe a provider's stored meta + secret keys so its ensureFn re-prompts. */
|
|
724
|
+
async function wipeProvider(storeKey, secretKeys) {
|
|
725
|
+
store.delete(storeKey);
|
|
726
|
+
for (const k of secretKeys)
|
|
727
|
+
await deleteSecret(k);
|
|
728
|
+
}
|
|
729
|
+
/** Wipe + re-prompt for a single provider. Shared by the stepper and by
|
|
730
|
+
* `hatchkit config add <provider>` so both paths always re-prompt rather
|
|
731
|
+
* than silently no-op on already-configured providers. */
|
|
732
|
+
export async function reconfigureProvider(name) {
|
|
733
|
+
if (name === "coolify") {
|
|
734
|
+
await wipeProvider("providers.coolify", [SECRET_KEYS.coolifyToken]);
|
|
735
|
+
await ensureCoolify();
|
|
736
|
+
}
|
|
737
|
+
else if (name === "hetzner") {
|
|
738
|
+
await wipeProvider("providers.hetzner", [SECRET_KEYS.hetznerToken]);
|
|
589
739
|
await ensureHetzner();
|
|
590
740
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
741
|
+
else if (name === "dns") {
|
|
742
|
+
await wipeProvider("providers.dns", [
|
|
743
|
+
SECRET_KEYS.dnsInwxPassword,
|
|
744
|
+
SECRET_KEYS.dnsCloudflareToken,
|
|
745
|
+
]);
|
|
746
|
+
await ensureDns();
|
|
747
|
+
}
|
|
748
|
+
else if (name === "glitchtip") {
|
|
749
|
+
await wipeProvider("providers.glitchtip", [SECRET_KEYS.glitchtipToken]);
|
|
750
|
+
await ensureGlitchtip();
|
|
751
|
+
}
|
|
752
|
+
else if (name === "openpanel") {
|
|
753
|
+
await wipeProvider("providers.openpanel", [
|
|
754
|
+
SECRET_KEYS.openpanelRootClientId,
|
|
755
|
+
SECRET_KEYS.openpanelRootClientSecret,
|
|
756
|
+
]);
|
|
757
|
+
await ensureOpenpanel();
|
|
758
|
+
}
|
|
759
|
+
else if (name === "resend") {
|
|
760
|
+
await wipeProvider("providers.resend", [SECRET_KEYS.resendApiKey]);
|
|
761
|
+
await ensureResend();
|
|
762
|
+
}
|
|
763
|
+
else if (name.startsWith("s3.")) {
|
|
764
|
+
const p = name.slice(3);
|
|
765
|
+
await wipeProvider(`providers.s3.${p}`, [
|
|
766
|
+
SECRET_KEYS.s3AccessKey(p),
|
|
767
|
+
SECRET_KEYS.s3SecretKey(p),
|
|
768
|
+
]);
|
|
769
|
+
await ensureS3(p);
|
|
770
|
+
}
|
|
771
|
+
else if (name.startsWith("gpu.")) {
|
|
772
|
+
const p = name.slice(4);
|
|
773
|
+
await wipeProvider(`providers.gpu.${p}`, [SECRET_KEYS.gpuApiKey(p)]);
|
|
774
|
+
await ensureGpuProvider(p);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function buildSetupGroups() {
|
|
778
|
+
return [
|
|
779
|
+
{
|
|
780
|
+
title: "Core",
|
|
781
|
+
steps: [
|
|
782
|
+
{
|
|
783
|
+
key: "github",
|
|
784
|
+
label: "GitHub (gh CLI)",
|
|
785
|
+
status: () => {
|
|
786
|
+
const s = store.get("providers.github.status");
|
|
787
|
+
return { configured: s === "configured" };
|
|
788
|
+
},
|
|
789
|
+
run: async () => {
|
|
790
|
+
store.set("providers.github.status", "unconfigured");
|
|
791
|
+
await ensureGitHub();
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
key: "coolify",
|
|
796
|
+
label: "Coolify",
|
|
797
|
+
status: () => {
|
|
798
|
+
const m = store.get("providers.coolify");
|
|
799
|
+
return { configured: m?.status === "configured", summary: m?.url };
|
|
800
|
+
},
|
|
801
|
+
run: () => reconfigureProvider("coolify"),
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
title: "Infrastructure",
|
|
807
|
+
steps: [
|
|
808
|
+
{
|
|
809
|
+
key: "hetzner",
|
|
810
|
+
label: "Hetzner Cloud",
|
|
811
|
+
status: () => {
|
|
812
|
+
const m = store.get("providers.hetzner");
|
|
813
|
+
return { configured: m?.status === "configured" };
|
|
814
|
+
},
|
|
815
|
+
run: () => reconfigureProvider("hetzner"),
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
key: "dns",
|
|
819
|
+
label: "DNS",
|
|
820
|
+
status: () => {
|
|
821
|
+
const m = store.get("providers.dns");
|
|
822
|
+
return {
|
|
823
|
+
configured: m?.status === "configured",
|
|
824
|
+
summary: m?.provider && m.provider !== "manual" ? m.provider : undefined,
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
run: () => reconfigureProvider("dns"),
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
title: "S3 Storage",
|
|
833
|
+
steps: ["hetzner", "aws", "r2"].map((p) => ({
|
|
834
|
+
key: `s3.${p}`,
|
|
835
|
+
label: p === "hetzner" ? "Hetzner Object Storage" : p === "aws" ? "AWS S3" : "Cloudflare R2",
|
|
836
|
+
status: () => {
|
|
837
|
+
const m = store.get(`providers.s3.${p}`);
|
|
838
|
+
return { configured: m?.status === "configured", summary: m?.endpoint };
|
|
839
|
+
},
|
|
840
|
+
run: () => reconfigureProvider(`s3.${p}`),
|
|
841
|
+
})),
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
title: "Observability & Email",
|
|
845
|
+
steps: [
|
|
846
|
+
{
|
|
847
|
+
key: "glitchtip",
|
|
848
|
+
label: "GlitchTip (error tracking)",
|
|
849
|
+
status: () => {
|
|
850
|
+
const m = store.get("providers.glitchtip");
|
|
851
|
+
return { configured: m?.status === "configured", summary: m?.url };
|
|
852
|
+
},
|
|
853
|
+
run: () => reconfigureProvider("glitchtip"),
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
key: "openpanel",
|
|
857
|
+
label: "OpenPanel (product analytics)",
|
|
858
|
+
status: () => {
|
|
859
|
+
const m = store.get("providers.openpanel");
|
|
860
|
+
return { configured: m?.status === "configured", summary: m?.url };
|
|
861
|
+
},
|
|
862
|
+
run: () => reconfigureProvider("openpanel"),
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
key: "resend",
|
|
866
|
+
label: "Resend (transactional email)",
|
|
867
|
+
status: () => {
|
|
868
|
+
const m = store.get("providers.resend");
|
|
869
|
+
return { configured: m?.status === "configured" };
|
|
870
|
+
},
|
|
871
|
+
run: () => reconfigureProvider("resend"),
|
|
872
|
+
},
|
|
605
873
|
],
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
title: "GPU / ML Providers",
|
|
877
|
+
steps: [
|
|
878
|
+
{ key: "modal", name: "Modal" },
|
|
879
|
+
{ key: "runpod", name: "RunPod" },
|
|
880
|
+
{ key: "hf", name: "HuggingFace Inference" },
|
|
881
|
+
{ key: "replicate", name: "Replicate" },
|
|
882
|
+
].map((p) => ({
|
|
883
|
+
key: `gpu.${p.key}`,
|
|
884
|
+
label: p.name,
|
|
885
|
+
status: () => {
|
|
886
|
+
const m = store.get(`providers.gpu.${p.key}`);
|
|
887
|
+
return { configured: m?.status === "configured" };
|
|
888
|
+
},
|
|
889
|
+
run: () => reconfigureProvider(`gpu.${p.key}`),
|
|
890
|
+
})),
|
|
891
|
+
},
|
|
892
|
+
];
|
|
893
|
+
}
|
|
894
|
+
function renderStepLabel(step) {
|
|
895
|
+
const { configured, summary } = step.status();
|
|
896
|
+
const mark = configured ? chalk.green("✓") : chalk.dim("·");
|
|
897
|
+
const tail = configured
|
|
898
|
+
? chalk.dim(` — ${summary ?? "configured"}`)
|
|
899
|
+
: chalk.dim(" — not configured");
|
|
900
|
+
return `${mark} ${step.label}${tail}`;
|
|
901
|
+
}
|
|
902
|
+
function renderGroupHeader(group) {
|
|
903
|
+
const total = group.steps.length;
|
|
904
|
+
const done = group.steps.filter((s) => s.status().configured).length;
|
|
905
|
+
const count = total > 1 ? chalk.dim(` ${done}/${total}`) : "";
|
|
906
|
+
return chalk.bold(`── ${group.title} ──${count}`);
|
|
907
|
+
}
|
|
908
|
+
export async function runOnboarding() {
|
|
909
|
+
console.log(chalk.bold("\n hatchkit setup"));
|
|
910
|
+
console.log(chalk.dim(` Metadata: ${getConfigPath()}`));
|
|
911
|
+
console.log(chalk.dim(" Secrets: OS keychain"));
|
|
912
|
+
console.log(chalk.dim(" Pick any step to (re)configure. Choose 'Done' to exit.\n"));
|
|
913
|
+
const groups = buildSetupGroups();
|
|
914
|
+
const allSteps = groups.flatMap((g) => g.steps);
|
|
915
|
+
for (;;) {
|
|
916
|
+
// Default the cursor to the first unconfigured step so Enter advances
|
|
917
|
+
// naturally on a first-time setup.
|
|
918
|
+
const firstUnconfigured = allSteps.find((s) => !s.status().configured);
|
|
919
|
+
const defaultKey = firstUnconfigured?.key ?? "__done__";
|
|
920
|
+
const choices = [];
|
|
921
|
+
for (const group of groups) {
|
|
922
|
+
choices.push(new Separator(renderGroupHeader(group)));
|
|
923
|
+
for (const step of group.steps) {
|
|
924
|
+
choices.push({ name: renderStepLabel(step), value: step.key });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
choices.push(new Separator(" "));
|
|
928
|
+
choices.push({ name: chalk.bold("Done — exit setup"), value: "__done__" });
|
|
929
|
+
const picked = await select({
|
|
930
|
+
message: "Next step:",
|
|
931
|
+
default: defaultKey,
|
|
932
|
+
pageSize: Math.min(30, choices.length),
|
|
933
|
+
choices,
|
|
606
934
|
});
|
|
607
|
-
|
|
935
|
+
if (picked === "__done__")
|
|
936
|
+
break;
|
|
937
|
+
const step = allSteps.find((s) => s.key === picked);
|
|
938
|
+
if (!step)
|
|
939
|
+
continue;
|
|
940
|
+
console.log();
|
|
941
|
+
try {
|
|
942
|
+
await step.run();
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
console.log(chalk.red(`\n ✗ ${step.label} failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
946
|
+
}
|
|
947
|
+
console.log();
|
|
948
|
+
}
|
|
949
|
+
// Summary — show both what's configured and what's still missing so
|
|
950
|
+
// the user notices optional-but-important steps (GlitchTip / OpenPanel
|
|
951
|
+
// / Resend) they may have skipped.
|
|
952
|
+
const configured = allSteps.filter((s) => s.status().configured);
|
|
953
|
+
const unconfigured = allSteps.filter((s) => !s.status().configured);
|
|
954
|
+
console.log(chalk.bold("\n ── Done ───────────────────────────────────────────────────\n"));
|
|
955
|
+
if (configured.length === 0) {
|
|
956
|
+
console.log(chalk.yellow(" Nothing configured yet. Run `hatchkit setup` again anytime.\n"));
|
|
608
957
|
}
|
|
609
958
|
else {
|
|
610
|
-
console.log(chalk.
|
|
959
|
+
console.log(chalk.green(` ✓ Configured: ${configured.map((s) => s.label).join(", ")}`));
|
|
611
960
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
});
|
|
618
|
-
if (configGlitchtip)
|
|
619
|
-
await ensureGlitchtip();
|
|
620
|
-
const configOpenpanel = await confirm({
|
|
621
|
-
message: "Configure OpenPanel (product analytics)?",
|
|
622
|
-
default: false,
|
|
623
|
-
});
|
|
624
|
-
if (configOpenpanel)
|
|
625
|
-
await ensureOpenpanel();
|
|
626
|
-
const configResend = await confirm({
|
|
627
|
-
message: "Configure Resend (transactional email)?",
|
|
628
|
-
default: false,
|
|
629
|
-
});
|
|
630
|
-
if (configResend)
|
|
631
|
-
await ensureResend();
|
|
632
|
-
// GPU — all skippable
|
|
633
|
-
console.log(chalk.bold("\n ── GPU / ML Providers (configure when needed) ─────────────\n"));
|
|
634
|
-
console.log(chalk.dim(" Skipped — will prompt when you first add an ML service.\n"));
|
|
635
|
-
// Done
|
|
636
|
-
console.log(chalk.bold(" ── Done! ──────────────────────────────────────────────────\n"));
|
|
637
|
-
const configuredProviders = ["GitHub"];
|
|
638
|
-
if (store.get("providers.coolify"))
|
|
639
|
-
configuredProviders.push("Coolify");
|
|
640
|
-
if (store.get("providers.hetzner"))
|
|
641
|
-
configuredProviders.push("Hetzner Cloud");
|
|
642
|
-
const dnsMeta = store.get("providers.dns");
|
|
643
|
-
if (dnsMeta?.provider && dnsMeta.provider !== "manual") {
|
|
644
|
-
configuredProviders.push(dnsMeta.provider.toUpperCase());
|
|
645
|
-
}
|
|
646
|
-
if (store.get("providers.glitchtip"))
|
|
647
|
-
configuredProviders.push("GlitchTip");
|
|
648
|
-
if (store.get("providers.openpanel"))
|
|
649
|
-
configuredProviders.push("OpenPanel");
|
|
650
|
-
if (store.get("providers.resend"))
|
|
651
|
-
configuredProviders.push("Resend");
|
|
652
|
-
console.log(chalk.green(` ✓ Providers configured: ${configuredProviders.join(", ")}`));
|
|
653
|
-
console.log(chalk.dim(" ✓ Skipped providers will be prompted when first needed.\n"));
|
|
961
|
+
if (unconfigured.length > 0) {
|
|
962
|
+
console.log(chalk.dim(` · Still unconfigured: ${unconfigured.map((s) => s.label).join(", ")}`));
|
|
963
|
+
console.log(chalk.dim(" (optional — add later via `hatchkit setup` or `hatchkit config add <provider>`)"));
|
|
964
|
+
}
|
|
965
|
+
console.log(chalk.dim("\n ✓ Run `hatchkit doctor` to verify every configured provider.\n"));
|
|
654
966
|
}
|
|
655
967
|
//# sourceMappingURL=config.js.map
|