hatchkit 0.1.2 → 0.1.4

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.
Files changed (113) hide show
  1. package/dist/completion.d.ts +2 -0
  2. package/dist/completion.d.ts.map +1 -0
  3. package/dist/completion.js +207 -0
  4. package/dist/completion.js.map +1 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +439 -127
  8. package/dist/config.js.map +1 -1
  9. package/dist/deploy/coolify-mongo.d.ts +12 -0
  10. package/dist/deploy/coolify-mongo.d.ts.map +1 -0
  11. package/dist/deploy/coolify-mongo.js +109 -0
  12. package/dist/deploy/coolify-mongo.js.map +1 -0
  13. package/dist/deploy/gpu.d.ts +9 -2
  14. package/dist/deploy/gpu.d.ts.map +1 -1
  15. package/dist/deploy/gpu.js +63 -39
  16. package/dist/deploy/gpu.js.map +1 -1
  17. package/dist/deploy/keys.d.ts +6 -2
  18. package/dist/deploy/keys.d.ts.map +1 -1
  19. package/dist/deploy/keys.js +16 -2
  20. package/dist/deploy/keys.js.map +1 -1
  21. package/dist/deploy/pages.d.ts +2 -0
  22. package/dist/deploy/pages.d.ts.map +1 -0
  23. package/dist/deploy/pages.js +537 -0
  24. package/dist/deploy/pages.js.map +1 -0
  25. package/dist/deploy/rename-domain.d.ts +55 -0
  26. package/dist/deploy/rename-domain.d.ts.map +1 -0
  27. package/dist/deploy/rename-domain.js +290 -0
  28. package/dist/deploy/rename-domain.js.map +1 -0
  29. package/dist/deploy/terraform.d.ts.map +1 -1
  30. package/dist/deploy/terraform.js +90 -0
  31. package/dist/deploy/terraform.js.map +1 -1
  32. package/dist/dns.d.ts +7 -0
  33. package/dist/dns.d.ts.map +1 -0
  34. package/dist/dns.js +124 -0
  35. package/dist/dns.js.map +1 -0
  36. package/dist/doctor.d.ts +13 -0
  37. package/dist/doctor.d.ts.map +1 -0
  38. package/dist/doctor.js +368 -0
  39. package/dist/doctor.js.map +1 -0
  40. package/dist/explain.d.ts +4 -0
  41. package/dist/explain.d.ts.map +1 -0
  42. package/dist/explain.js +173 -0
  43. package/dist/explain.js.map +1 -0
  44. package/dist/index.js +521 -61
  45. package/dist/index.js.map +1 -1
  46. package/dist/prompts.d.ts +15 -2
  47. package/dist/prompts.d.ts.map +1 -1
  48. package/dist/prompts.js +52 -7
  49. package/dist/prompts.js.map +1 -1
  50. package/dist/provision/glitchtip.d.ts +3 -0
  51. package/dist/provision/glitchtip.d.ts.map +1 -1
  52. package/dist/provision/glitchtip.js +18 -0
  53. package/dist/provision/glitchtip.js.map +1 -1
  54. package/dist/provision/index.d.ts +26 -0
  55. package/dist/provision/index.d.ts.map +1 -1
  56. package/dist/provision/index.js +435 -60
  57. package/dist/provision/index.js.map +1 -1
  58. package/dist/provision/openpanel.d.ts +7 -0
  59. package/dist/provision/openpanel.d.ts.map +1 -1
  60. package/dist/provision/openpanel.js +113 -48
  61. package/dist/provision/openpanel.js.map +1 -1
  62. package/dist/provision/resend.d.ts +23 -1
  63. package/dist/provision/resend.d.ts.map +1 -1
  64. package/dist/provision/resend.js +62 -1
  65. package/dist/provision/resend.js.map +1 -1
  66. package/dist/provision/write-env.d.ts +31 -0
  67. package/dist/provision/write-env.d.ts.map +1 -0
  68. package/dist/provision/write-env.js +94 -0
  69. package/dist/provision/write-env.js.map +1 -0
  70. package/dist/scaffold/app.js +8 -0
  71. package/dist/scaffold/app.js.map +1 -1
  72. package/dist/scaffold/dotenvx.d.ts.map +1 -1
  73. package/dist/scaffold/dotenvx.js +8 -1
  74. package/dist/scaffold/dotenvx.js.map +1 -1
  75. package/dist/scaffold/infra.d.ts.map +1 -1
  76. package/dist/scaffold/infra.js +18 -1
  77. package/dist/scaffold/infra.js.map +1 -1
  78. package/dist/scaffold/manifest.d.ts +4 -2
  79. package/dist/scaffold/manifest.d.ts.map +1 -1
  80. package/dist/scaffold/manifest.js +1 -1
  81. package/dist/scaffold/manifest.js.map +1 -1
  82. package/dist/scaffold/ml-client.d.ts +9 -2
  83. package/dist/scaffold/ml-client.d.ts.map +1 -1
  84. package/dist/scaffold/ml-client.js +11 -1
  85. package/dist/scaffold/ml-client.js.map +1 -1
  86. package/dist/status.d.ts +30 -0
  87. package/dist/status.d.ts.map +1 -0
  88. package/dist/status.js +169 -0
  89. package/dist/status.js.map +1 -0
  90. package/dist/templates/addons/analytics/sentry.ts.hbs +6 -0
  91. package/dist/templates/base/env.example.hbs +10 -0
  92. package/dist/templates/base/src/config.ts.hbs +24 -4
  93. package/dist/utils/cloudflare-api.d.ts +30 -0
  94. package/dist/utils/cloudflare-api.d.ts.map +1 -0
  95. package/dist/utils/cloudflare-api.js +85 -0
  96. package/dist/utils/cloudflare-api.js.map +1 -0
  97. package/dist/utils/coolify-api.d.ts +47 -1
  98. package/dist/utils/coolify-api.d.ts.map +1 -1
  99. package/dist/utils/coolify-api.js +75 -4
  100. package/dist/utils/coolify-api.js.map +1 -1
  101. package/dist/utils/flags.d.ts.map +1 -1
  102. package/dist/utils/flags.js +4 -0
  103. package/dist/utils/flags.js.map +1 -1
  104. package/dist/utils/inwx-api.d.ts +36 -0
  105. package/dist/utils/inwx-api.d.ts.map +1 -0
  106. package/dist/utils/inwx-api.js +105 -0
  107. package/dist/utils/inwx-api.js.map +1 -0
  108. package/dist/utils/secrets.d.ts +8 -1
  109. package/dist/utils/secrets.d.ts.map +1 -1
  110. package/dist/utils/secrets.js +8 -1
  111. package/dist/utils/secrets.js.map +1 -1
  112. package/package.json +5 -4
  113. package/scripts/release-prep.mjs +130 -0
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
- const token = (await password({
141
- message: "Coolify API token (from Settings API Tokens):",
142
- })).trim();
143
- const spinner = ora("Testing Coolify connection...").start();
144
- try {
145
- const version = await verifyCoolify(url, token);
146
- spinner.succeed(`Connected to Coolify v${version}`);
147
- }
148
- catch (error) {
149
- spinner.fail("Could not connect to Coolify");
150
- throw error;
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
- const token = await password({
190
- message: "Hetzner Cloud API token:",
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 password({ message: "INWX password:" });
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
- const apiToken = await password({ message: "Cloudflare API token:" });
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
- return { ...meta, apiToken };
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
- const promptedAccessKey = await password({ message: `${provider} S3 access key:` });
298
- const promptedSecretKey = await password({ message: `${provider} S3 secret key:` });
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 === "r2") {
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
- apiKey = await password({ message: "RunPod API key:" });
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
- apiKey = await password({ message: "HuggingFace token (from hf.co/settings/tokens):" });
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
- apiKey = await password({ message: "Replicate API token:" });
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
- const token = (await password({
446
- message: "GlitchTip auth token (Profile → Auth Tokens, needs project:admin):",
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 existingToken = await getSecret(SECRET_KEYS.openpanelToken);
485
- if (existing?.status === "configured" && existingToken) {
486
- return { ...existing, token: existingToken };
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 base URL:",
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
- const token = (await password({
495
- message: "OpenPanel personal access token (Settings Access Tokens):",
496
- })).trim();
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.openpanelToken, token);
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, token };
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 token = await getSecret(SECRET_KEYS.openpanelToken);
518
- if (!token)
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, token };
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
- const apiKey = (await password({
533
- message: "Resend API key (resend.com/api-keys, needs 'full access'):",
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
- export async function runOnboarding() {
574
- console.log(chalk.bold("\n Welcome! Let's set up your development infrastructure."));
575
- console.log(chalk.dim(" This is a one-time setup — metadata is stored locally in"));
576
- console.log(chalk.dim(` ${getConfigPath()}`));
577
- console.log(chalk.dim(` Secrets (tokens, passwords) go to your OS keychain.\n`));
578
- // Core providers
579
- console.log(chalk.bold(" ── Core Providers (required) ──────────────────────────────\n"));
580
- await ensureGitHub();
581
- await ensureCoolify();
582
- // Infrastructure providers
583
- console.log(chalk.bold("\n ── Infrastructure Providers ───────────────────────────────\n"));
584
- const configHetzner = await confirm({
585
- message: "Configure Hetzner Cloud? (needed for new servers)",
586
- default: true,
587
- });
588
- if (configHetzner) {
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
- await ensureDns();
592
- // Storage — all skippable
593
- console.log(chalk.bold("\n ── Storage Providers (configure as needed) ────────────────\n"));
594
- const configStorage = await confirm({
595
- message: "Configure any S3 storage provider now?",
596
- default: false,
597
- });
598
- if (configStorage) {
599
- const s3Provider = await select({
600
- message: "Which S3 provider?",
601
- choices: [
602
- { name: "Hetzner Object Storage", value: "hetzner" },
603
- { name: "AWS S3", value: "aws" },
604
- { name: "Cloudflare R2", value: "r2" },
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
- await ensureS3(s3Provider);
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.dim(" Skipped will prompt when you first need S3 storage."));
959
+ console.log(chalk.green(` Configured: ${configured.map((s) => s.label).join(", ")}`));
611
960
  }
612
- // Observability & email — all skippable
613
- console.log(chalk.bold("\n ── Observability & Email (configure as needed) ────────────\n"));
614
- const configGlitchtip = await confirm({
615
- message: "Configure GlitchTip (error tracking)?",
616
- default: false,
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