hatchkit 0.1.4 → 0.1.6

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 (71) hide show
  1. package/dist/adopt.d.ts +2 -0
  2. package/dist/adopt.d.ts.map +1 -0
  3. package/dist/adopt.js +819 -0
  4. package/dist/adopt.js.map +1 -0
  5. package/dist/completion.d.ts.map +1 -1
  6. package/dist/completion.js +3 -0
  7. package/dist/completion.js.map +1 -1
  8. package/dist/config.d.ts +30 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +108 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/deploy/coolify-app.d.ts +35 -0
  13. package/dist/deploy/coolify-app.d.ts.map +1 -0
  14. package/dist/deploy/coolify-app.js +238 -0
  15. package/dist/deploy/coolify-app.js.map +1 -0
  16. package/dist/deploy/pages.js +50 -9
  17. package/dist/deploy/pages.js.map +1 -1
  18. package/dist/deploy/rename-domain.d.ts.map +1 -1
  19. package/dist/deploy/rename-domain.js +26 -6
  20. package/dist/deploy/rename-domain.js.map +1 -1
  21. package/dist/deploy/rollback.d.ts +10 -0
  22. package/dist/deploy/rollback.d.ts.map +1 -0
  23. package/dist/deploy/rollback.js +295 -0
  24. package/dist/deploy/rollback.js.map +1 -0
  25. package/dist/deploy/terraform.d.ts +10 -1
  26. package/dist/deploy/terraform.d.ts.map +1 -1
  27. package/dist/deploy/terraform.js +177 -42
  28. package/dist/deploy/terraform.js.map +1 -1
  29. package/dist/doctor.d.ts.map +1 -1
  30. package/dist/doctor.js +25 -0
  31. package/dist/doctor.js.map +1 -1
  32. package/dist/explain.d.ts.map +1 -1
  33. package/dist/explain.js +5 -0
  34. package/dist/explain.js.map +1 -1
  35. package/dist/index.js +377 -122
  36. package/dist/index.js.map +1 -1
  37. package/dist/prompts.d.ts.map +1 -1
  38. package/dist/prompts.js +283 -11
  39. package/dist/prompts.js.map +1 -1
  40. package/dist/provision/stripe.d.ts +19 -0
  41. package/dist/provision/stripe.d.ts.map +1 -0
  42. package/dist/provision/stripe.js +58 -0
  43. package/dist/provision/stripe.js.map +1 -0
  44. package/dist/scaffold/dotenvx.d.ts.map +1 -1
  45. package/dist/scaffold/dotenvx.js +35 -11
  46. package/dist/scaffold/dotenvx.js.map +1 -1
  47. package/dist/scaffold/infra.d.ts +21 -1
  48. package/dist/scaffold/infra.d.ts.map +1 -1
  49. package/dist/scaffold/infra.js +66 -20
  50. package/dist/scaffold/infra.js.map +1 -1
  51. package/dist/status.d.ts.map +1 -1
  52. package/dist/status.js +7 -0
  53. package/dist/status.js.map +1 -1
  54. package/dist/utils/cloudflare-api.d.ts +23 -0
  55. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  56. package/dist/utils/cloudflare-api.js +31 -0
  57. package/dist/utils/cloudflare-api.js.map +1 -1
  58. package/dist/utils/coolify-api.d.ts +64 -3
  59. package/dist/utils/coolify-api.d.ts.map +1 -1
  60. package/dist/utils/coolify-api.js +99 -3
  61. package/dist/utils/coolify-api.js.map +1 -1
  62. package/dist/utils/run-ledger.d.ts +68 -0
  63. package/dist/utils/run-ledger.d.ts.map +1 -0
  64. package/dist/utils/run-ledger.js +99 -0
  65. package/dist/utils/run-ledger.js.map +1 -0
  66. package/dist/utils/secrets.d.ts +2 -0
  67. package/dist/utils/secrets.d.ts.map +1 -1
  68. package/dist/utils/secrets.js +2 -0
  69. package/dist/utils/secrets.js.map +1 -1
  70. package/package.json +2 -2
  71. package/scripts/release-prep.mjs +130 -95
package/dist/adopt.js ADDED
@@ -0,0 +1,819 @@
1
+ /*
2
+ * `hatchkit adopt` — onboard an existing project into hatchkit.
3
+ *
4
+ * Inverse of `hatchkit create`: instead of generating a project from
5
+ * the starter, point hatchkit at a repo that already exists and bring
6
+ * it under management. The flow:
7
+ *
8
+ * 1. Detect — read package.json, sniff repo layout (packages/server,
9
+ * apps/server, root), check for dotenvx-encrypted .env.production
10
+ * and an existing .env.keys, look up a Coolify app by project
11
+ * name, infer features from package deps + env vars present.
12
+ * 2. Review — stepper UI mirroring `hatchkit setup` so the user can
13
+ * step back through each detected value before we touch anything.
14
+ * Same Separator-grouped layout, same ✓/· marks.
15
+ * 3. Execute —
16
+ * a. If .env.production isn't already dotenvx-encrypted, encrypt
17
+ * it (this generates packages/server/.env.keys with the
18
+ * private key).
19
+ * b. Read DOTENV_PRIVATE_KEY_PRODUCTION out of .env.keys and
20
+ * mirror it into the OS keychain (so `hatchkit keys push`
21
+ * works going forward).
22
+ * c. Write .hatchkit.json so the project is recognized by
23
+ * `update`, `add`, `keys`, etc.
24
+ * d. Optionally run the same observability/email provisioning
25
+ * that `hatchkit add` does (GlitchTip, OpenPanel, Resend),
26
+ * scoped to whichever surfaces (server/client/both) the user
27
+ * picked. DSN/clientId/keys land encrypted into the existing
28
+ * .env.production.
29
+ * e. Optionally push the dotenvx private key to Coolify so the
30
+ * deployed app can decrypt env at runtime.
31
+ *
32
+ * Adopt is intentionally idempotent on the parts that can be made so:
33
+ * a second run on the same dir notices the existing manifest and
34
+ * exits early with a "use `hatchkit update` instead" hint.
35
+ */
36
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
37
+ import { join, relative } from "node:path";
38
+ import { Separator, checkbox, confirm, input, select } from "@inquirer/prompts";
39
+ import chalk from "chalk";
40
+ import { ensureGitHub, getCoolifyConfig } from "./config.js";
41
+ import { pushProjectKeyToCoolify } from "./deploy/keys.js";
42
+ import { runProvision } from "./provision/index.js";
43
+ import { MANIFEST_FILENAME, writeManifest } from "./scaffold/manifest.js";
44
+ import { CoolifyApi } from "./utils/coolify-api.js";
45
+ import { exec, execOk } from "./utils/exec.js";
46
+ import { SECRET_KEYS, setSecret } from "./utils/secrets.js";
47
+ import { validateDomain, validateProjectName } from "./utils/validate.js";
48
+ import { getCliVersion } from "./utils/version.js";
49
+ export async function runAdopt(cwd) {
50
+ const state = await detectProject(cwd);
51
+ if (state.hasManifest) {
52
+ console.log(chalk.yellow(`\n ${MANIFEST_FILENAME} already exists in ${relativeTo(state.projectDir)}.`));
53
+ console.log(chalk.dim(" This project is already adopted. Use `hatchkit update` to add features, or\n" +
54
+ " `hatchkit add <project>` to (re-)provision per-project clients.\n"));
55
+ return;
56
+ }
57
+ console.log(chalk.bold("\n hatchkit adopt"));
58
+ printDetected(state);
59
+ // Initial plan — pre-filled from detection.
60
+ // bootstrapDotenvx: default ON when there's no encrypted prod env —
61
+ // adopt's whole point is "make this manageable", and that needs a
62
+ // dotenvx keypair so everything else (key push, encrypted writes
63
+ // by `add`) has something to work with.
64
+ // setupGitHub: default ON when there's no origin remote yet.
65
+ let plan = {
66
+ name: state.packageName ?? "",
67
+ domain: "",
68
+ features: state.features,
69
+ serverDir: state.serverDir ?? state.projectDir,
70
+ clientDir: state.clientDir,
71
+ bootstrapDotenvx: !state.prodEnvIsEncrypted,
72
+ setupGitHub: !state.gitRemoteUrl,
73
+ wireCoolify: !state.coolifyAppMatch,
74
+ appPort: "3000",
75
+ services: ["glitchtip", "openpanel", "resend"],
76
+ // Default the push only when there's already a Coolify app to push to.
77
+ // When wireCoolify creates a fresh app, it sets the baseline env
78
+ // itself (including the dotenvx key), so a separate push is
79
+ // redundant in that branch.
80
+ pushKey: !!state.coolifyAppMatch,
81
+ };
82
+ plan = await reviewLoop(state, plan);
83
+ await executePlan(state, plan);
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Detection
87
+ // ---------------------------------------------------------------------------
88
+ async function detectProject(projectDir) {
89
+ const hasManifest = existsSync(join(projectDir, MANIFEST_FILENAME));
90
+ let packageName;
91
+ try {
92
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8"));
93
+ packageName = pkg.name?.replace(/^@[^/]+\//, ""); // strip scope
94
+ }
95
+ catch {
96
+ // No package.json at root — that's fine for a non-Node project.
97
+ }
98
+ // Walk a generous set of common monorepo layouts.
99
+ const serverDir = firstExisting(projectDir, [
100
+ "packages/server",
101
+ "apps/server",
102
+ "apps/api",
103
+ "apps/backend",
104
+ "server",
105
+ "backend",
106
+ "api",
107
+ "src/server",
108
+ "services/server",
109
+ ]);
110
+ const clientDir = firstExisting(projectDir, [
111
+ "packages/client",
112
+ "packages/web",
113
+ "packages/frontend",
114
+ "apps/web",
115
+ "apps/client",
116
+ "apps/frontend",
117
+ "client",
118
+ "frontend",
119
+ "web",
120
+ "src/client",
121
+ ]);
122
+ // Feature detection: cheap heuristics from package.json deps + env files.
123
+ const features = detectFeatures(projectDir, serverDir);
124
+ // dotenvx state. The encrypted file starts with a generated header
125
+ // + a DOTENV_PUBLIC_KEY_PRODUCTION line; .env.keys has the private
126
+ // key. Either being present means we're already in dotenvx land.
127
+ const prodEnvPath = serverDir
128
+ ? join(serverDir, ".env.production")
129
+ : join(projectDir, ".env.production");
130
+ const envKeysPath = serverDir
131
+ ? join(serverDir, ".env.keys")
132
+ : join(projectDir, ".env.keys");
133
+ let prodEnvIsEncrypted = false;
134
+ if (existsSync(prodEnvPath)) {
135
+ const head = readFileSync(prodEnvPath, "utf-8").slice(0, 2000);
136
+ prodEnvIsEncrypted = /DOTENV_PUBLIC_KEY_PRODUCTION/.test(head);
137
+ }
138
+ const hasEnvKeys = existsSync(envKeysPath);
139
+ // Coolify app match — best-effort, requires Coolify configured. If
140
+ // it isn't, leave it undefined; the user can still adopt without it.
141
+ let coolifyAppMatch;
142
+ try {
143
+ const cfg = await getCoolifyConfig();
144
+ if (cfg && packageName) {
145
+ const api = new CoolifyApi({ url: cfg.url, token: cfg.token });
146
+ const apps = await api.listApplications();
147
+ const wanted = [packageName, `${packageName}-web`, `${packageName}-server`];
148
+ const match = apps.find((a) => wanted.includes(a.name));
149
+ if (match)
150
+ coolifyAppMatch = { uuid: match.uuid, name: match.name };
151
+ }
152
+ }
153
+ catch {
154
+ // Best-effort only.
155
+ }
156
+ // Git state — is this a repo? Does it already have an origin remote?
157
+ // We only auto-init + create a remote when the user opts in via the
158
+ // stepper; here we just gather state for the summary.
159
+ const isGitRepo = existsSync(join(projectDir, ".git"));
160
+ let gitRemoteUrl;
161
+ if (isGitRepo) {
162
+ try {
163
+ const res = await exec("git", ["remote", "get-url", "origin"], {
164
+ cwd: projectDir,
165
+ // No spinner — this is a sub-second silent check.
166
+ });
167
+ const url = res.stdout.trim();
168
+ if (res.exitCode === 0 && url)
169
+ gitRemoteUrl = url;
170
+ }
171
+ catch {
172
+ // Either no `origin` set yet (exit 128) or git failed — fine.
173
+ }
174
+ }
175
+ return {
176
+ projectDir,
177
+ packageName,
178
+ hasManifest,
179
+ serverDir,
180
+ clientDir,
181
+ features,
182
+ prodEnvIsEncrypted,
183
+ hasEnvKeys,
184
+ coolifyAppMatch,
185
+ isGitRepo,
186
+ gitRemoteUrl,
187
+ };
188
+ }
189
+ function firstExisting(root, candidates) {
190
+ for (const c of candidates) {
191
+ const full = join(root, c);
192
+ if (existsSync(full))
193
+ return full;
194
+ }
195
+ return undefined;
196
+ }
197
+ function detectFeatures(projectDir, serverDir) {
198
+ const found = new Set();
199
+ // Cast a wider net than just <root> + <serverDir>: also walk the
200
+ // first level of the common monorepo package roots so a project
201
+ // organized as e.g. `apps/web` + `apps/server` doesn't end up with
202
+ // "no features detected" when serverDir resolves to a sibling.
203
+ const pkgJsonPaths = new Set();
204
+ pkgJsonPaths.add(join(projectDir, "package.json"));
205
+ if (serverDir)
206
+ pkgJsonPaths.add(join(serverDir, "package.json"));
207
+ for (const root of ["packages", "apps", "services"]) {
208
+ const dir = join(projectDir, root);
209
+ if (!existsSync(dir))
210
+ continue;
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(dir);
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ for (const e of entries)
219
+ pkgJsonPaths.add(join(dir, e, "package.json"));
220
+ }
221
+ for (const p of pkgJsonPaths) {
222
+ if (!existsSync(p))
223
+ continue;
224
+ let json;
225
+ try {
226
+ json = JSON.parse(readFileSync(p, "utf-8"));
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const deps = {
232
+ ...(json.dependencies ?? {}),
233
+ ...(json.devDependencies ?? {}),
234
+ ...(json.peerDependencies ?? {}),
235
+ ...(json.optionalDependencies ?? {}),
236
+ };
237
+ if ("stripe" in deps || "@stripe/stripe-js" in deps || "@stripe/react-stripe-js" in deps) {
238
+ found.add("stripe");
239
+ }
240
+ if ("socket.io" in deps || "socket.io-client" in deps || "ws" in deps) {
241
+ found.add("websocket");
242
+ }
243
+ if ("@sentry/node" in deps ||
244
+ "@sentry/browser" in deps ||
245
+ "@sentry/react" in deps ||
246
+ "@sentry/nextjs" in deps ||
247
+ "@openpanel/web" in deps ||
248
+ "@openpanel/sdk" in deps ||
249
+ "@openpanel/nextjs" in deps) {
250
+ found.add("analytics");
251
+ }
252
+ if ("@aws-sdk/client-s3" in deps || "minio" in deps)
253
+ found.add("s3");
254
+ if ("electron" in deps || "electron-builder" in deps)
255
+ found.add("desktop");
256
+ if ("@capacitor/core" in deps || "@capacitor/cli" in deps)
257
+ found.add("mobile");
258
+ }
259
+ // .env.production / .env.example as a hint when package.json is sparse.
260
+ const envHints = [
261
+ serverDir ? join(serverDir, ".env.production") : undefined,
262
+ serverDir ? join(serverDir, ".env.example") : undefined,
263
+ join(projectDir, ".env.example"),
264
+ ].filter((p) => !!p);
265
+ for (const p of envHints) {
266
+ if (!existsSync(p))
267
+ continue;
268
+ const text = readFileSync(p, "utf-8");
269
+ if (/STRIPE_SECRET_KEY/.test(text))
270
+ found.add("stripe");
271
+ if (/REDIS_URL/.test(text))
272
+ found.add("websocket");
273
+ if (/GLITCHTIP_DSN|SENTRY_DSN|OPENPANEL_/.test(text))
274
+ found.add("analytics");
275
+ if (/S3_BUCKET|S3_ENDPOINT/.test(text))
276
+ found.add("s3");
277
+ }
278
+ return [...found];
279
+ }
280
+ function printDetected(state) {
281
+ const lines = [];
282
+ const row = (label, value) => ` ${chalk.dim(label.padEnd(18))} ${value}`;
283
+ lines.push(chalk.bold("\n Detected:\n"));
284
+ lines.push(row("project dir", chalk.cyan(relativeTo(state.projectDir))));
285
+ if (state.packageName)
286
+ lines.push(row("package.json", chalk.cyan(state.packageName)));
287
+ if (state.serverDir) {
288
+ lines.push(row("server dir", chalk.cyan(relativeTo(state.serverDir))));
289
+ }
290
+ else {
291
+ lines.push(row("server dir", chalk.dim("(not detected — falls back to project root)")));
292
+ }
293
+ if (state.clientDir) {
294
+ lines.push(row("client dir", chalk.cyan(relativeTo(state.clientDir))));
295
+ }
296
+ lines.push(row(".env.production", state.prodEnvIsEncrypted
297
+ ? chalk.green("dotenvx-encrypted ✓")
298
+ : state.serverDir && existsSync(join(state.serverDir, ".env.production"))
299
+ ? chalk.yellow("present, plain text — will encrypt")
300
+ : chalk.dim("not present")));
301
+ lines.push(row(".env.keys", state.hasEnvKeys ? chalk.green("present ✓") : chalk.dim("missing")));
302
+ lines.push(row("Coolify app", state.coolifyAppMatch
303
+ ? chalk.green(`${state.coolifyAppMatch.name} ✓`)
304
+ : chalk.dim("(no match)")));
305
+ lines.push(row("git remote", state.gitRemoteUrl
306
+ ? chalk.green(state.gitRemoteUrl)
307
+ : state.isGitRepo
308
+ ? chalk.yellow("repo present, no `origin` set")
309
+ : chalk.dim("not a git repo yet")));
310
+ lines.push(row("features (guess)", state.features.length > 0 ? state.features.join(", ") : chalk.dim("none detected")));
311
+ for (const l of lines)
312
+ console.log(l);
313
+ console.log();
314
+ }
315
+ async function reviewLoop(state, initial) {
316
+ let plan = initial;
317
+ console.log(chalk.dim(" Step through each row to confirm or change. Choose 'Adopt' when ready.\n"));
318
+ for (;;) {
319
+ const groups = buildAdoptGroups(state, plan);
320
+ const allSteps = groups.flatMap((g) => g.steps);
321
+ const firstUnset = allSteps.find((s) => !s.set);
322
+ const defaultKey = firstUnset?.key ?? "__adopt__";
323
+ const choices = [];
324
+ for (const group of groups) {
325
+ choices.push(new Separator(chalk.bold(`── ${group.title} ──`)));
326
+ for (const step of group.steps) {
327
+ const mark = step.set ? chalk.green("✓") : chalk.dim("·");
328
+ choices.push({
329
+ name: `${mark} ${step.label.padEnd(18)}${chalk.dim(` — ${step.summary}`)}`,
330
+ value: step.key,
331
+ });
332
+ }
333
+ }
334
+ choices.push(new Separator(" "));
335
+ choices.push({
336
+ name: chalk.bold(chalk.green("✓ Adopt — apply changes")),
337
+ value: "__adopt__",
338
+ });
339
+ choices.push({ name: chalk.dim("✗ Cancel"), value: "__cancel__" });
340
+ const picked = await select({
341
+ message: "Next step:",
342
+ default: defaultKey,
343
+ pageSize: Math.min(30, choices.length),
344
+ choices,
345
+ });
346
+ if (picked === "__adopt__")
347
+ return plan;
348
+ if (picked === "__cancel__") {
349
+ console.log(chalk.dim("\n Cancelled. Nothing was changed.\n"));
350
+ throw new Error("Adopt cancelled by user");
351
+ }
352
+ plan = await editAdoptStep(state, plan, picked);
353
+ }
354
+ }
355
+ function buildAdoptGroups(state, plan) {
356
+ return [
357
+ {
358
+ title: "Project",
359
+ steps: [
360
+ { key: "name", label: "Project name", set: !!plan.name, summary: plan.name || "(unset)" },
361
+ {
362
+ key: "domain",
363
+ label: "Domain",
364
+ set: !!plan.domain,
365
+ summary: plan.domain
366
+ ? `${plan.domain} ${chalk.dim("→")} https://${plan.domain}/api`
367
+ : "(unset)",
368
+ },
369
+ ],
370
+ },
371
+ {
372
+ title: "Layout",
373
+ steps: [
374
+ {
375
+ key: "serverDir",
376
+ label: "Server env dir",
377
+ set: !!plan.serverDir,
378
+ summary: plan.serverDir ? relativeTo(plan.serverDir) : "(unset)",
379
+ },
380
+ {
381
+ key: "clientDir",
382
+ label: "Client env dir",
383
+ set: true, // optional — empty is fine
384
+ summary: plan.clientDir ? relativeTo(plan.clientDir) : chalk.dim("(none — server only)"),
385
+ },
386
+ ],
387
+ },
388
+ {
389
+ title: "Stack",
390
+ steps: [
391
+ {
392
+ key: "features",
393
+ label: "Features",
394
+ set: true,
395
+ summary: plan.features.length > 0 ? plan.features.join(", ") : chalk.dim("none"),
396
+ },
397
+ ],
398
+ },
399
+ {
400
+ title: "Bootstrap",
401
+ steps: [
402
+ {
403
+ key: "bootstrapDotenvx",
404
+ label: "Initialize dotenvx",
405
+ set: true,
406
+ summary: plan.bootstrapDotenvx
407
+ ? state.prodEnvIsEncrypted
408
+ ? chalk.dim("already encrypted — will skip")
409
+ : "yes — generate keypair + encrypt .env.production"
410
+ : chalk.dim("no"),
411
+ },
412
+ {
413
+ key: "setupGitHub",
414
+ label: "GitHub remote",
415
+ set: true,
416
+ summary: plan.setupGitHub
417
+ ? state.gitRemoteUrl
418
+ ? chalk.dim("already set — will skip")
419
+ : "yes — `gh repo create` + push"
420
+ : state.gitRemoteUrl
421
+ ? chalk.dim(state.gitRemoteUrl)
422
+ : chalk.dim("no"),
423
+ },
424
+ ],
425
+ },
426
+ {
427
+ title: "Deploy",
428
+ steps: [
429
+ {
430
+ key: "wireCoolify",
431
+ label: "Coolify + DNS",
432
+ set: true,
433
+ summary: plan.wireCoolify
434
+ ? state.coolifyAppMatch
435
+ ? chalk.dim(`existing app "${state.coolifyAppMatch.name}" — will skip create`)
436
+ : `yes — create app + upsert DNS (port ${plan.appPort})`
437
+ : state.coolifyAppMatch
438
+ ? chalk.dim(`already exists: ${state.coolifyAppMatch.name}`)
439
+ : chalk.dim("no"),
440
+ },
441
+ ],
442
+ },
443
+ {
444
+ title: "Provisioning",
445
+ steps: [
446
+ {
447
+ key: "services",
448
+ label: "Provision clients",
449
+ set: true,
450
+ summary: plan.services.length > 0 ? plan.services.join(", ") : chalk.dim("skip provisioning"),
451
+ },
452
+ {
453
+ key: "pushKey",
454
+ label: "Push key to Coolify",
455
+ set: true,
456
+ summary: plan.pushKey
457
+ ? state.coolifyAppMatch
458
+ ? `yes (${state.coolifyAppMatch.name})`
459
+ : "yes — Coolify app must exist by name"
460
+ : chalk.dim("no"),
461
+ },
462
+ ],
463
+ },
464
+ ];
465
+ }
466
+ async function editAdoptStep(state, plan, step) {
467
+ if (step === "name") {
468
+ const name = (await input({
469
+ message: "Project name (used for the Coolify app, manifest, keychain):",
470
+ default: plan.name || state.packageName,
471
+ validate: validateProjectName,
472
+ })).trim();
473
+ return { ...plan, name };
474
+ }
475
+ if (step === "domain") {
476
+ const domain = (await input({
477
+ message: "Domain (e.g. ai.trebeljahr.com):",
478
+ default: plan.domain,
479
+ validate: validateDomain,
480
+ })).trim();
481
+ return { ...plan, domain };
482
+ }
483
+ if (step === "serverDir") {
484
+ const picked = (await input({
485
+ message: "Server env directory (relative to project root):",
486
+ default: plan.serverDir ? relative(state.projectDir, plan.serverDir) || "." : ".",
487
+ validate: (v) => {
488
+ const abs = join(state.projectDir, v.trim());
489
+ return existsSync(abs) ? true : `No such directory: ${abs}`;
490
+ },
491
+ })).trim();
492
+ return { ...plan, serverDir: join(state.projectDir, picked) };
493
+ }
494
+ if (step === "clientDir") {
495
+ const useClient = await confirm({
496
+ message: "Does this project have a separate browser bundle?",
497
+ default: !!plan.clientDir,
498
+ });
499
+ if (!useClient)
500
+ return { ...plan, clientDir: undefined };
501
+ const picked = (await input({
502
+ message: "Client env directory (relative to project root):",
503
+ default: plan.clientDir
504
+ ? relative(state.projectDir, plan.clientDir) || "."
505
+ : "packages/client",
506
+ validate: (v) => {
507
+ const abs = join(state.projectDir, v.trim());
508
+ return existsSync(abs) ? true : `No such directory: ${abs}`;
509
+ },
510
+ })).trim();
511
+ return { ...plan, clientDir: join(state.projectDir, picked) };
512
+ }
513
+ if (step === "features") {
514
+ const features = await checkbox({
515
+ message: "Features active in this project:",
516
+ choices: [
517
+ { name: "websocket", value: "websocket", checked: plan.features.includes("websocket") },
518
+ { name: "stripe", value: "stripe", checked: plan.features.includes("stripe") },
519
+ { name: "analytics", value: "analytics", checked: plan.features.includes("analytics") },
520
+ { name: "s3", value: "s3", checked: plan.features.includes("s3") },
521
+ { name: "desktop", value: "desktop", checked: plan.features.includes("desktop") },
522
+ { name: "mobile", value: "mobile", checked: plan.features.includes("mobile") },
523
+ ],
524
+ });
525
+ return { ...plan, features };
526
+ }
527
+ if (step === "services") {
528
+ const services = await checkbox({
529
+ message: "Provision per-project clients now?",
530
+ choices: [
531
+ {
532
+ name: "GlitchTip (error tracking)",
533
+ value: "glitchtip",
534
+ checked: plan.services.includes("glitchtip") && plan.features.includes("analytics"),
535
+ },
536
+ {
537
+ name: "OpenPanel (analytics)",
538
+ value: "openpanel",
539
+ checked: plan.services.includes("openpanel") && plan.features.includes("analytics"),
540
+ },
541
+ {
542
+ name: "Resend (email)",
543
+ value: "resend",
544
+ checked: plan.services.includes("resend"),
545
+ },
546
+ ],
547
+ });
548
+ return { ...plan, services };
549
+ }
550
+ if (step === "pushKey") {
551
+ const pushKey = await confirm({
552
+ message: state.coolifyAppMatch
553
+ ? `Push dotenvx private key to Coolify (${state.coolifyAppMatch.name})?`
554
+ : "Push dotenvx private key to Coolify (app must exist by project name)?",
555
+ default: plan.pushKey,
556
+ });
557
+ return { ...plan, pushKey };
558
+ }
559
+ if (step === "bootstrapDotenvx") {
560
+ const bootstrapDotenvx = await confirm({
561
+ message: state.prodEnvIsEncrypted
562
+ ? ".env.production is already encrypted — re-encrypt anyway?"
563
+ : "Initialize dotenvx (creates an encrypted .env.production + .env.keys)?",
564
+ default: plan.bootstrapDotenvx,
565
+ });
566
+ return { ...plan, bootstrapDotenvx };
567
+ }
568
+ if (step === "setupGitHub") {
569
+ if (state.gitRemoteUrl) {
570
+ console.log(chalk.dim(`\n origin already set to ${state.gitRemoteUrl} — adopt won't replace it.\n`));
571
+ return { ...plan, setupGitHub: false };
572
+ }
573
+ const setupGitHub = await confirm({
574
+ message: state.isGitRepo
575
+ ? "Create a GitHub repo and push this project to it?"
576
+ : "Initialize git, create a GitHub repo, and push?",
577
+ default: plan.setupGitHub,
578
+ });
579
+ return { ...plan, setupGitHub };
580
+ }
581
+ if (step === "wireCoolify") {
582
+ const wireCoolify = await confirm({
583
+ message: state.coolifyAppMatch
584
+ ? `App "${state.coolifyAppMatch.name}" already exists — re-wire (will create a duplicate)?`
585
+ : "Create a Coolify app + upsert DNS now?",
586
+ default: plan.wireCoolify,
587
+ });
588
+ if (!wireCoolify)
589
+ return { ...plan, wireCoolify };
590
+ const appPort = (await input({
591
+ message: "Container port the server listens on:",
592
+ default: plan.appPort,
593
+ validate: (v) => /^\d+$/.test(v.trim()) || "Must be an integer port number.",
594
+ })).trim();
595
+ return { ...plan, wireCoolify, appPort };
596
+ }
597
+ return plan;
598
+ }
599
+ // ---------------------------------------------------------------------------
600
+ // Execution
601
+ // ---------------------------------------------------------------------------
602
+ async function executePlan(state, plan) {
603
+ console.log(chalk.bold("\n ── Adopting ──────────────────────────────────────────────\n"));
604
+ // Step 1: bootstrap / encrypt dotenvx so a key actually exists.
605
+ if (plan.bootstrapDotenvx) {
606
+ await bootstrapDotenvxNow(state, plan);
607
+ }
608
+ else {
609
+ console.log(chalk.dim(" · Skipping dotenvx bootstrap (per stepper choice)."));
610
+ }
611
+ await importKeyToKeychain(state, plan);
612
+ // Step 2: write the manifest. Done after key import so a partial
613
+ // failure doesn't leave a manifest pointing at no key. The
614
+ // manifest lives at the project ROOT (not under packages/server).
615
+ writeAdoptManifest(state.projectDir, plan);
616
+ console.log(chalk.green(` ✓ Wrote ${MANIFEST_FILENAME} at ${relativeTo(state.projectDir)}`));
617
+ // Step 3: GitHub remote (init + create + push). Skipped if origin is
618
+ // already set or the user opted out.
619
+ let remoteUrl = state.gitRemoteUrl;
620
+ if (plan.setupGitHub && !state.gitRemoteUrl) {
621
+ remoteUrl = await setupGitHubRemote(state, plan);
622
+ }
623
+ else if (state.gitRemoteUrl) {
624
+ console.log(chalk.dim(` · git origin already set → ${state.gitRemoteUrl}`));
625
+ }
626
+ // Step 3b: Wire the repo into Coolify + DNS via direct API calls.
627
+ // No infra/ submodule, no Terraform — just hits the Coolify and
628
+ // DNS-provider REST endpoints with credentials we already have in
629
+ // keychain. Idempotent on the DNS side (upsert); not yet on the
630
+ // app-create side (Coolify accepts duplicate app names).
631
+ let coolifyResult;
632
+ if (plan.wireCoolify && remoteUrl) {
633
+ try {
634
+ const { wireProjectIntoCoolify } = await import("./deploy/coolify-app.js");
635
+ coolifyResult = await wireProjectIntoCoolify({
636
+ projectName: plan.name,
637
+ domain: plan.domain,
638
+ gitRepository: remoteUrl,
639
+ portsExposes: plan.appPort,
640
+ // Default assumption: anything we just `gh repo create --private`d
641
+ // is private. If origin was already set we don't know for sure;
642
+ // try public first (cheaper auth) and let the orchestrator handle
643
+ // the fallback.
644
+ isPrivate: plan.setupGitHub,
645
+ });
646
+ }
647
+ catch (err) {
648
+ console.log(chalk.yellow(`\n Couldn't wire Coolify: ${err.message}`));
649
+ console.log(chalk.dim(` Create the app manually in the Coolify dashboard pointing at\n` +
650
+ ` ${remoteUrl}\n` +
651
+ ` with domain ${plan.domain} and port ${plan.appPort}, then run\n` +
652
+ ` hatchkit keys push ${plan.name}`));
653
+ }
654
+ }
655
+ else if (plan.wireCoolify && !remoteUrl) {
656
+ console.log(chalk.yellow(" Coolify wiring needs a git remote URL — skipping (no `origin` set and the GitHub step\n" +
657
+ " was off). Set the remote yourself or re-run with `setup GitHub remote = yes`."));
658
+ }
659
+ // Step 4: provision clients via the existing `add` machinery so the
660
+ // surfaces stepper, idempotency, and env writes behave identically
661
+ // to a normal `hatchkit add`.
662
+ if (plan.services.length > 0) {
663
+ console.log();
664
+ await runProvision({
665
+ baseName: plan.name,
666
+ services: plan.services,
667
+ surfaces: {
668
+ mode: plan.clientDir ? "shared" : "server-only",
669
+ serverEnvDir: plan.serverDir,
670
+ clientEnvDir: plan.clientDir,
671
+ },
672
+ });
673
+ }
674
+ // Step 5: push key to Coolify.
675
+ if (plan.pushKey) {
676
+ try {
677
+ await pushProjectKeyToCoolify(plan.name);
678
+ console.log(chalk.green(`\n ✓ Pushed dotenvx key to Coolify`));
679
+ }
680
+ catch (err) {
681
+ console.log(chalk.yellow(`\n Couldn't push dotenvx key to Coolify: ${err.message}`));
682
+ console.log(chalk.dim(` Once the app exists, run: \`hatchkit keys push ${plan.name}\``));
683
+ }
684
+ }
685
+ console.log(chalk.bold("\n ── Adopted ───────────────────────────────────────────────\n"));
686
+ console.log(` Project: ${chalk.cyan(plan.name)}`);
687
+ console.log(` Domain: ${chalk.cyan(plan.domain)}`);
688
+ console.log(` Server: ${chalk.cyan(relativeTo(plan.serverDir))}`);
689
+ if (plan.clientDir)
690
+ console.log(` Client: ${chalk.cyan(relativeTo(plan.clientDir))}`);
691
+ console.log(` Manifest: ${chalk.dim(join(state.projectDir, MANIFEST_FILENAME))}`);
692
+ if (remoteUrl)
693
+ console.log(` Git: ${chalk.cyan(remoteUrl)}`);
694
+ if (coolifyResult) {
695
+ console.log(` Coolify: ${chalk.cyan(coolifyResult.appUuid)} ${chalk.dim(`@ ${coolifyResult.serverIp}`)}`);
696
+ if (coolifyResult.dnsManaged) {
697
+ console.log(` DNS: ${chalk.green("✓")} ${chalk.dim(`A ${plan.domain} → ${coolifyResult.serverIp}`)}`);
698
+ }
699
+ else if (plan.domain && coolifyResult.serverIp) {
700
+ console.log(` DNS: ${chalk.yellow("✗")} ${chalk.dim(`add A ${plan.domain} → ${coolifyResult.serverIp} manually`)}`);
701
+ }
702
+ }
703
+ console.log();
704
+ }
705
+ async function bootstrapDotenvxNow(state, plan) {
706
+ const prodPath = join(plan.serverDir, ".env.production");
707
+ const ora = (await import("ora")).default;
708
+ const label = state.prodEnvIsEncrypted
709
+ ? "Re-encrypting .env.production with dotenvx..."
710
+ : existsSync(prodPath)
711
+ ? "Encrypting .env.production with dotenvx..."
712
+ : "Generating .env.production + .env.keys with dotenvx...";
713
+ const spinner = ora(label).start();
714
+ try {
715
+ // First call to `dotenvx set` with encrypt: true creates the file
716
+ // (if missing), generates the keypair, and writes .env.keys.
717
+ // Subsequent calls reuse the existing keypair. Using HATCHKIT_ADOPTED
718
+ // as the sentinel keeps the file non-empty so the keypair survives.
719
+ const { set: dotenvxSet } = await import("@dotenvx/dotenvx");
720
+ dotenvxSet("HATCHKIT_ADOPTED", new Date().toISOString(), {
721
+ path: prodPath,
722
+ encrypt: true,
723
+ });
724
+ spinner.succeed(existsSync(prodPath)
725
+ ? "dotenvx initialized — .env.production is now encrypted"
726
+ : "dotenvx initialized");
727
+ }
728
+ catch (err) {
729
+ spinner.fail("Failed to initialize dotenvx");
730
+ throw err;
731
+ }
732
+ }
733
+ async function setupGitHubRemote(state, plan) {
734
+ // Pre-flight gh CLI auth. ensureGitHub prompts the user to log in
735
+ // when needed; if they cancel, surface a clear "you can do this
736
+ // later" rather than crashing the whole adopt run.
737
+ try {
738
+ await ensureGitHub();
739
+ }
740
+ catch (err) {
741
+ console.log(chalk.yellow(`\n Couldn't reach GitHub (${err.message}). Skipping remote creation.`));
742
+ return undefined;
743
+ }
744
+ console.log(chalk.bold("\n ── GitHub ────────────────────────────────────────────────\n"));
745
+ if (!state.isGitRepo) {
746
+ await exec("git", ["init"], {
747
+ cwd: state.projectDir,
748
+ spinner: "Initializing git repo...",
749
+ });
750
+ }
751
+ // Stage everything + commit when there's anything staged.
752
+ // `git diff --cached --quiet` exits 0 → no diff (nothing staged)
753
+ // 1 → diff present (commit needed)
754
+ // execOk returns true on exit 0, so the inverse is "something to commit".
755
+ await exec("git", ["add", "-A"], { cwd: state.projectDir });
756
+ const cleanIndex = await execOk("git", ["diff", "--cached", "--quiet"], {
757
+ cwd: state.projectDir,
758
+ });
759
+ if (!cleanIndex) {
760
+ await exec("git", ["commit", "-m", "Adopt under hatchkit management"], {
761
+ cwd: state.projectDir,
762
+ spinner: "Creating commit...",
763
+ });
764
+ }
765
+ // `gh repo create` with --source=. + --push handles remote creation
766
+ // and the initial push in one shot. --private matches the default
767
+ // behaviour of `hatchkit create`.
768
+ const create = await exec("gh", ["repo", "create", plan.name, "--private", "--source=.", "--push"], { cwd: state.projectDir, spinner: `Creating GitHub repo: ${plan.name}...` });
769
+ if (create.exitCode !== 0) {
770
+ console.log(chalk.yellow(" Could not create GitHub repo. Push manually once it exists:"));
771
+ console.log(chalk.dim(` cd ${state.projectDir}`));
772
+ console.log(chalk.dim(` gh repo create ${plan.name} --private --source=. --push`));
773
+ return undefined;
774
+ }
775
+ const urlRes = await exec("gh", ["repo", "view", "--json", "url", "-q", ".url"], {
776
+ cwd: state.projectDir,
777
+ });
778
+ const url = urlRes.stdout.trim();
779
+ console.log(chalk.green(` ✓ GitHub repo: ${url}`));
780
+ return url || undefined;
781
+ }
782
+ async function importKeyToKeychain(state, plan) {
783
+ const envKeysPath = join(plan.serverDir, ".env.keys");
784
+ if (!existsSync(envKeysPath)) {
785
+ console.log(chalk.yellow(` · No .env.keys at ${relativeTo(envKeysPath)} — nothing to import to keychain.`));
786
+ return;
787
+ }
788
+ const text = readFileSync(envKeysPath, "utf-8");
789
+ const m = text.match(/^DOTENV_PRIVATE_KEY_PRODUCTION="?([0-9a-fA-F]+)"?/m);
790
+ if (!m) {
791
+ console.log(chalk.yellow(` · ${relativeTo(envKeysPath)} doesn't contain DOTENV_PRIVATE_KEY_PRODUCTION — skipping import.`));
792
+ return;
793
+ }
794
+ await setSecret(SECRET_KEYS.dotenvxPrivateKey(plan.name), m[1]);
795
+ console.log(chalk.green(` ✓ Imported dotenvx private key into the OS keychain (service: hatchkit)`));
796
+ }
797
+ function writeAdoptManifest(projectDir, plan) {
798
+ // Unknown bits (ports, deployTarget specifics) get conservative
799
+ // defaults — adopt's role is to take inventory, not to make
800
+ // infra decisions. The user can edit the manifest later.
801
+ const manifest = {
802
+ version: 1,
803
+ cliVersion: getCliVersion(),
804
+ scaffoldedAt: new Date().toISOString(),
805
+ name: plan.name,
806
+ domain: plan.domain,
807
+ features: plan.features,
808
+ mlServices: [],
809
+ s3Provider: (() => (plan.features.includes("s3") ? "existing" : "none"))(),
810
+ deployTarget: "existing",
811
+ ports: { server: 3000, client: 3001 },
812
+ };
813
+ writeManifest(projectDir, manifest);
814
+ }
815
+ function relativeTo(p, from = process.cwd()) {
816
+ const rel = relative(from, p);
817
+ return rel === "" ? "." : rel.startsWith("..") ? p : `./${rel}`;
818
+ }
819
+ //# sourceMappingURL=adopt.js.map