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.
- package/dist/adopt.d.ts +2 -0
- package/dist/adopt.d.ts.map +1 -0
- package/dist/adopt.js +819 -0
- package/dist/adopt.js.map +1 -0
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js +3 -0
- package/dist/completion.js.map +1 -1
- package/dist/config.d.ts +30 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -1
- package/dist/deploy/coolify-app.d.ts +35 -0
- package/dist/deploy/coolify-app.d.ts.map +1 -0
- package/dist/deploy/coolify-app.js +238 -0
- package/dist/deploy/coolify-app.js.map +1 -0
- package/dist/deploy/pages.js +50 -9
- package/dist/deploy/pages.js.map +1 -1
- package/dist/deploy/rename-domain.d.ts.map +1 -1
- package/dist/deploy/rename-domain.js +26 -6
- package/dist/deploy/rename-domain.js.map +1 -1
- package/dist/deploy/rollback.d.ts +10 -0
- package/dist/deploy/rollback.d.ts.map +1 -0
- package/dist/deploy/rollback.js +295 -0
- package/dist/deploy/rollback.js.map +1 -0
- package/dist/deploy/terraform.d.ts +10 -1
- package/dist/deploy/terraform.d.ts.map +1 -1
- package/dist/deploy/terraform.js +177 -42
- package/dist/deploy/terraform.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +25 -0
- package/dist/doctor.js.map +1 -1
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +5 -0
- package/dist/explain.js.map +1 -1
- package/dist/index.js +377 -122
- package/dist/index.js.map +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +283 -11
- package/dist/prompts.js.map +1 -1
- package/dist/provision/stripe.d.ts +19 -0
- package/dist/provision/stripe.d.ts.map +1 -0
- package/dist/provision/stripe.js +58 -0
- package/dist/provision/stripe.js.map +1 -0
- package/dist/scaffold/dotenvx.d.ts.map +1 -1
- package/dist/scaffold/dotenvx.js +35 -11
- package/dist/scaffold/dotenvx.js.map +1 -1
- package/dist/scaffold/infra.d.ts +21 -1
- package/dist/scaffold/infra.d.ts.map +1 -1
- package/dist/scaffold/infra.js +66 -20
- package/dist/scaffold/infra.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +7 -0
- package/dist/status.js.map +1 -1
- package/dist/utils/cloudflare-api.d.ts +23 -0
- package/dist/utils/cloudflare-api.d.ts.map +1 -1
- package/dist/utils/cloudflare-api.js +31 -0
- package/dist/utils/cloudflare-api.js.map +1 -1
- package/dist/utils/coolify-api.d.ts +64 -3
- package/dist/utils/coolify-api.d.ts.map +1 -1
- package/dist/utils/coolify-api.js +99 -3
- package/dist/utils/coolify-api.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +68 -0
- package/dist/utils/run-ledger.d.ts.map +1 -0
- package/dist/utils/run-ledger.js +99 -0
- package/dist/utils/run-ledger.js.map +1 -0
- package/dist/utils/secrets.d.ts +2 -0
- package/dist/utils/secrets.d.ts.map +1 -1
- package/dist/utils/secrets.js +2 -0
- package/dist/utils/secrets.js.map +1 -1
- package/package.json +2 -2
- 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
|