@harly/cli 0.1.4 → 0.2.1
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/README.md +25 -39
- package/dist/index.js +1071 -1
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -2,60 +2,46 @@
|
|
|
2
2
|
<img src="https://cdn.molret.dev/banner-harly.webp" alt="Harly — self-hosted applicant tracking system" width="1200" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
<p align="center">
|
|
6
|
-
The shortest way to install, operate, and upgrade a self-hosted Harly deployment.
|
|
7
|
-
</p>
|
|
8
|
-
|
|
9
5
|
# @harly/cli
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
npx @harly/cli
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
Upgrades preserve configuration and Docker volumes, create a backup first,
|
|
19
|
-
run migrations once, wait for health checks, and pin the pulled image digest.
|
|
20
|
-
|
|
21
|
-
## Requirements
|
|
7
|
+
Guided, auditable Node 20.12+ bootstrap CLI for immutable Harly Docker Compose
|
|
8
|
+
installs.
|
|
22
9
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- A Linux VPS with 2 GB RAM plus swap minimum; 4 GB recommended
|
|
10
|
+
The interactive wizard checks Docker, ports, DNS and free disk, detects the
|
|
11
|
+
host's CPU/RAM, recommends a resource profile, masks provider secrets and shows
|
|
12
|
+
the complete installation plan before writing anything.
|
|
27
13
|
|
|
28
|
-
|
|
14
|
+
Use the official user-facing entrypoint:
|
|
29
15
|
|
|
30
16
|
```bash
|
|
31
17
|
npx @harly/cli
|
|
32
18
|
```
|
|
33
19
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
## Upgrade safely
|
|
20
|
+
Npm's name-similarity protections prevent an unscoped `harly` package. The
|
|
21
|
+
scoped package still exposes the `harly` executable for global installs; with
|
|
22
|
+
`npx`, use the concise official scope. A normal update is simply:
|
|
38
23
|
|
|
39
24
|
```bash
|
|
40
25
|
cd harly
|
|
41
26
|
npx @harly/cli update
|
|
42
27
|
```
|
|
43
28
|
|
|
44
|
-
Use `--to edge` for preview builds or `--to
|
|
45
|
-
|
|
46
|
-
|
|
29
|
+
Use `--to edge` for preview builds or `--to 0.1.0-beta.2` for a fixed release.
|
|
30
|
+
The command creates a private local rollback point first, pulls the target,
|
|
31
|
+
pins its immutable digest, runs migrations once, recreates the services, and
|
|
32
|
+
waits for readiness. This works without extra system dependencies. Use
|
|
33
|
+
`--encrypt` only when you have configured the optional advanced `age` backup
|
|
34
|
+
encryption.
|
|
47
35
|
|
|
48
|
-
|
|
36
|
+
The generated resource profiles are:
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
harly uninstall [directory] [--remove-data]
|
|
58
|
-
```
|
|
38
|
+
- `compact`: 2 GB RAM plus swap, for trials and light traffic.
|
|
39
|
+
- `standard`: 4 GB RAM, recommended for small-team production.
|
|
40
|
+
- `performance`: 8 GB RAM or more.
|
|
41
|
+
|
|
42
|
+
For automation, provide configuration through environment variables. `launch`
|
|
43
|
+
requires `--yes` whenever stdin is not interactive and exits with code `2`
|
|
44
|
+
without it.
|
|
59
45
|
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
Secrets are generated independently, written only to `.env` with mode `0600`,
|
|
47
|
+
and never accepted as CLI arguments or emitted by `doctor --json`.
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,1072 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { promises as dns } from "node:dns";
|
|
6
|
+
import { chmod, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, statfs, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import * as p from "@clack/prompts";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
var resourceProfiles = {
|
|
15
|
+
compact: {
|
|
16
|
+
app: "1024m",
|
|
17
|
+
appNodeOptions: "--max-old-space-size=640",
|
|
18
|
+
postgres: "384m",
|
|
19
|
+
migrate: "512m",
|
|
20
|
+
scheduler: "192m",
|
|
21
|
+
caddy: "128m",
|
|
22
|
+
cacheMb: 256
|
|
23
|
+
},
|
|
24
|
+
standard: {
|
|
25
|
+
app: "1536m",
|
|
26
|
+
appNodeOptions: "--max-old-space-size=1024",
|
|
27
|
+
postgres: "768m",
|
|
28
|
+
migrate: "768m",
|
|
29
|
+
scheduler: "256m",
|
|
30
|
+
caddy: "256m",
|
|
31
|
+
cacheMb: 512
|
|
32
|
+
},
|
|
33
|
+
performance: {
|
|
34
|
+
app: "3072m",
|
|
35
|
+
appNodeOptions: "--max-old-space-size=2304",
|
|
36
|
+
postgres: "1536m",
|
|
37
|
+
migrate: "1024m",
|
|
38
|
+
scheduler: "512m",
|
|
39
|
+
caddy: "256m",
|
|
40
|
+
cacheMb: 1024
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function detectResourceProfile() {
|
|
44
|
+
const memoryGb = os.totalmem() / 1024 ** 3;
|
|
45
|
+
if (memoryGb < 3.5) return "compact";
|
|
46
|
+
if (memoryGb < 7.5) return "standard";
|
|
47
|
+
return "performance";
|
|
48
|
+
}
|
|
49
|
+
var CliError = class extends Error {
|
|
50
|
+
constructor(message, exitCode = 1) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.exitCode = exitCode;
|
|
53
|
+
}
|
|
54
|
+
exitCode;
|
|
55
|
+
};
|
|
56
|
+
var args = process.argv.slice(2);
|
|
57
|
+
var command = args.shift() ?? "menu";
|
|
58
|
+
var flags = new Set(args.filter((arg) => arg.startsWith("--") && !["--to"].includes(arg)));
|
|
59
|
+
var positionals = args.filter((arg, index) => !arg.startsWith("--") && args[index - 1] !== "--to");
|
|
60
|
+
var toIndex = args.indexOf("--to");
|
|
61
|
+
var toVersion = toIndex >= 0 ? args[toIndex + 1] : void 0;
|
|
62
|
+
var force = flags.has("--force");
|
|
63
|
+
var yes = flags.has("--yes");
|
|
64
|
+
var json = flags.has("--json");
|
|
65
|
+
var interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
|
|
66
|
+
var cliVersion = "0.1.4";
|
|
67
|
+
var logo = `
|
|
68
|
+
\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557
|
|
69
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D
|
|
70
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D
|
|
71
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
|
|
72
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551
|
|
73
|
+
\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D`;
|
|
74
|
+
function usage() {
|
|
75
|
+
process.stdout.write(`Harly \u2014 self-hosted ATS
|
|
76
|
+
|
|
77
|
+
Run without arguments for the guided experience.
|
|
78
|
+
|
|
79
|
+
Advanced commands:
|
|
80
|
+
harly init [directory] [--force]
|
|
81
|
+
harly launch [directory] [--yes]
|
|
82
|
+
harly doctor [directory] [--json]
|
|
83
|
+
harly backup [directory] [--encrypt]
|
|
84
|
+
harly restore <archive> [directory] --force
|
|
85
|
+
harly update [directory] [--to version|edge] [--yes] [--encrypt]
|
|
86
|
+
harly uninstall [directory] [--remove-data] [--yes]
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
function showBrand() {
|
|
90
|
+
process.stdout.write(`${pc.cyan(logo)}
|
|
91
|
+
|
|
92
|
+
${pc.bold("Self-hosted ATS")} ${pc.dim(`\xB7 v${cliVersion}`)}
|
|
93
|
+
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
function unwrapPrompt(value) {
|
|
97
|
+
if (!p.isCancel(value)) return value;
|
|
98
|
+
p.cancel("Installation cancelled.");
|
|
99
|
+
throw new CliError("", 2);
|
|
100
|
+
}
|
|
101
|
+
function run(program, commandArgs, options = {}) {
|
|
102
|
+
const result = spawnSync(program, commandArgs, {
|
|
103
|
+
cwd: options.cwd,
|
|
104
|
+
input: options.input,
|
|
105
|
+
// Database dumps are binary. Never decode them as UTF-8 on the way out
|
|
106
|
+
// of Docker, otherwise pg_restore receives a silently corrupted archive.
|
|
107
|
+
encoding: options.input || options.binary ? void 0 : "utf8",
|
|
108
|
+
stdio: options.input || options.binary ? ["pipe", "pipe", "pipe"] : "pipe",
|
|
109
|
+
maxBuffer: 1024 * 1024 * 512
|
|
110
|
+
});
|
|
111
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
112
|
+
const message = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr;
|
|
113
|
+
throw new CliError(`${program} ${commandArgs.join(" ")} failed${message ? `: ${message.trim()}` : "."}`);
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
function compose(cwd, composeArgs, options = {}) {
|
|
118
|
+
return run("docker", ["compose", ...composeArgs], { cwd, ...options });
|
|
119
|
+
}
|
|
120
|
+
function parseVersion(value) {
|
|
121
|
+
return (value.match(/\d+(?:\.\d+)+/)?.[0] ?? "0").split(".").map(Number);
|
|
122
|
+
}
|
|
123
|
+
function atLeast(actual, expected) {
|
|
124
|
+
return expected.every((part, index) => (actual[index] ?? 0) === part ? true : (actual[index] ?? 0) > part ? true : expected.slice(0, index).every((item, i) => item === (actual[i] ?? 0)) ? false : true);
|
|
125
|
+
}
|
|
126
|
+
async function portAvailable(port) {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const server = createServer();
|
|
129
|
+
server.once("error", (error) => {
|
|
130
|
+
resolve(error.code === "EACCES");
|
|
131
|
+
});
|
|
132
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function freeDiskGb(directory) {
|
|
136
|
+
let current = path.resolve(directory);
|
|
137
|
+
while (!await exists(current)) {
|
|
138
|
+
const parent = path.dirname(current);
|
|
139
|
+
if (parent === current) break;
|
|
140
|
+
current = parent;
|
|
141
|
+
}
|
|
142
|
+
const filesystem = await statfs(current);
|
|
143
|
+
return filesystem.bavail * filesystem.bsize / 1024 ** 3;
|
|
144
|
+
}
|
|
145
|
+
async function preflight(mode, checkPorts = true, requestedPort, directory = process.cwd()) {
|
|
146
|
+
if (!atLeast(parseVersion(process.versions.node), [20, 12, 0])) throw new CliError("Node.js 20.12 or newer is required.");
|
|
147
|
+
const docker = run("docker", ["version", "--format", "{{.Server.Version}}"], { allowFailure: true });
|
|
148
|
+
if (docker.status !== 0 || !atLeast(parseVersion(String(docker.stdout)), [24, 0, 0])) throw new CliError("Docker Engine 24 or newer is required and must be running.");
|
|
149
|
+
const plugin = run("docker", ["compose", "version", "--short"], { allowFailure: true });
|
|
150
|
+
if (plugin.status !== 0 || !atLeast(parseVersion(String(plugin.stdout)), [2, 20, 0])) throw new CliError("Docker Compose 2.20 or newer is required.");
|
|
151
|
+
if (checkPorts) {
|
|
152
|
+
const ports = mode === "caddy" ? [80, 443] : [requestedPort ?? Number(process.env.HARLY_PORT ?? 3e3)];
|
|
153
|
+
for (const port of ports) if (!await portAvailable(port)) throw new CliError(`Port ${port} is already in use.`);
|
|
154
|
+
}
|
|
155
|
+
const diskGb = await freeDiskGb(directory);
|
|
156
|
+
if (diskGb < 5) throw new CliError(`At least 5 GB of free disk is required (${diskGb.toFixed(1)} GB available).`);
|
|
157
|
+
return { cpuCount: os.cpus().length, memoryGb: os.totalmem() / 1024 ** 3, diskGb };
|
|
158
|
+
}
|
|
159
|
+
function requiredEnvironment(name, value) {
|
|
160
|
+
if (value?.trim()) return value.trim();
|
|
161
|
+
throw new CliError(`${name} is required in non-interactive mode.`, 2);
|
|
162
|
+
}
|
|
163
|
+
async function confirm2(question) {
|
|
164
|
+
if (yes) return true;
|
|
165
|
+
if (!interactive) return false;
|
|
166
|
+
return unwrapPrompt(await p.confirm({ message: question, initialValue: false }));
|
|
167
|
+
}
|
|
168
|
+
function progressStep(message, success, action) {
|
|
169
|
+
if (!interactive) {
|
|
170
|
+
action();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const step = p.spinner();
|
|
174
|
+
step.start(message);
|
|
175
|
+
try {
|
|
176
|
+
action();
|
|
177
|
+
step.stop(success);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
step.stop(`${message} failed`);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function normalizeUrl(value, mode) {
|
|
184
|
+
const candidate = value.includes("://") ? value : `${mode === "local" ? "http" : "https"}://${value}`;
|
|
185
|
+
const url = new URL(candidate);
|
|
186
|
+
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/") throw new CliError("Public URL must be an HTTP(S) origin without a path.", 2);
|
|
187
|
+
if (mode !== "local" && url.protocol !== "https:") throw new CliError("Caddy and external proxy modes require an HTTPS public URL.", 2);
|
|
188
|
+
return url;
|
|
189
|
+
}
|
|
190
|
+
function validateDatabaseUrl(value) {
|
|
191
|
+
if (!value?.trim()) return "A DigitalOcean Managed PostgreSQL connection URL is required.";
|
|
192
|
+
try {
|
|
193
|
+
const url = new URL(value.trim());
|
|
194
|
+
if (!["postgres:", "postgresql:"].includes(url.protocol) || !url.hostname || !url.pathname || url.pathname === "/") {
|
|
195
|
+
return "Enter a postgresql:// connection URL that includes a database name.";
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
return "Enter a valid postgresql:// connection URL.";
|
|
199
|
+
}
|
|
200
|
+
return void 0;
|
|
201
|
+
}
|
|
202
|
+
function secret() {
|
|
203
|
+
return randomBytes(32).toString("base64url");
|
|
204
|
+
}
|
|
205
|
+
function envLine(value) {
|
|
206
|
+
return JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
function shellQuote(value) {
|
|
209
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
210
|
+
}
|
|
211
|
+
function validateEmail(value) {
|
|
212
|
+
if (!value || !/^\S+@\S+\.\S+$/.test(value)) return "Enter a valid email address.";
|
|
213
|
+
}
|
|
214
|
+
function validatePublicOrigin(value) {
|
|
215
|
+
if (!value?.trim()) return "Enter a domain or public URL.";
|
|
216
|
+
try {
|
|
217
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
218
|
+
const parsed = new URL(candidate);
|
|
219
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) return "Use an origin without a path, query, or hash.";
|
|
220
|
+
} catch {
|
|
221
|
+
return "Enter a valid domain or public URL.";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function hostSummary(host) {
|
|
225
|
+
return `${host.cpuCount} CPU \xB7 ${host.memoryGb.toFixed(1)} GB RAM \xB7 ${host.diskGb.toFixed(1)} GB free`;
|
|
226
|
+
}
|
|
227
|
+
async function collectNonInteractiveAnswers(directory) {
|
|
228
|
+
const mode = process.env.HARLY_PROXY_MODE ?? "caddy";
|
|
229
|
+
if (!["caddy", "external", "local"].includes(mode)) throw new CliError("Invalid proxy mode.", 2);
|
|
230
|
+
const url = normalizeUrl(requiredEnvironment("HARLY_URL", process.env.HARLY_URL), mode);
|
|
231
|
+
const requestedPort = Number(process.env.HARLY_PORT ?? (mode === "local" && url.port ? url.port : 3e3));
|
|
232
|
+
await preflight(mode, true, requestedPort, directory);
|
|
233
|
+
if (mode !== "local") await dns.lookup(url.hostname);
|
|
234
|
+
const email = requiredEnvironment("HARLY_INITIAL_ADMIN_EMAIL", process.env.HARLY_INITIAL_ADMIN_EMAIL).toLowerCase();
|
|
235
|
+
if (validateEmail(email)) throw new CliError("Invalid owner email.", 2);
|
|
236
|
+
const organization = process.env.HARLY_ORGANIZATION?.trim() || "My organization";
|
|
237
|
+
const storage = process.env.STORAGE_PROVIDER ?? "local";
|
|
238
|
+
if (!["local", "s3"].includes(storage)) throw new CliError("Invalid storage provider.", 2);
|
|
239
|
+
const resourceProfile = process.env.HARLY_RESOURCE_PROFILE ?? detectResourceProfile();
|
|
240
|
+
if (!Object.hasOwn(resourceProfiles, resourceProfile)) throw new CliError("Invalid resource profile.", 2);
|
|
241
|
+
const s3 = storage === "s3" ? {
|
|
242
|
+
bucket: requiredEnvironment("S3_BUCKET", process.env.S3_BUCKET),
|
|
243
|
+
region: process.env.S3_REGION?.trim() || "auto",
|
|
244
|
+
accessKeyId: requiredEnvironment("S3_ACCESS_KEY_ID", process.env.S3_ACCESS_KEY_ID),
|
|
245
|
+
secretAccessKey: requiredEnvironment("S3_SECRET_ACCESS_KEY", process.env.S3_SECRET_ACCESS_KEY),
|
|
246
|
+
endpoint: process.env.S3_ENDPOINT?.trim() || "",
|
|
247
|
+
publicUrl: process.env.S3_PUBLIC_URL?.trim() || ""
|
|
248
|
+
} : null;
|
|
249
|
+
const image = process.env.HARLY_IMAGE_REF ?? "ghcr.io/vytral/harly:0.1.0-beta.2";
|
|
250
|
+
if (image.endsWith(":latest")) throw new CliError("Installations must pin a version or digest, never latest.", 2);
|
|
251
|
+
return { mode, url, email, organization, storage, s3, resourceProfile, image };
|
|
252
|
+
}
|
|
253
|
+
async function collectInteractiveAnswers(directory) {
|
|
254
|
+
showBrand();
|
|
255
|
+
p.intro(pc.bgCyan(pc.black(" Welcome to Harly ")));
|
|
256
|
+
const publicOrigin = unwrapPrompt(await p.text({
|
|
257
|
+
message: "Public domain or URL",
|
|
258
|
+
placeholder: "harly.example.com",
|
|
259
|
+
initialValue: process.env.HARLY_URL,
|
|
260
|
+
validate: validatePublicOrigin
|
|
261
|
+
}));
|
|
262
|
+
const mode = unwrapPrompt(await p.select({
|
|
263
|
+
message: "Reverse proxy",
|
|
264
|
+
initialValue: process.env.HARLY_PROXY_MODE ?? "caddy",
|
|
265
|
+
options: [
|
|
266
|
+
{ value: "caddy", label: "Automatic HTTPS with Caddy", hint: "recommended" },
|
|
267
|
+
{ value: "external", label: "External Nginx or Traefik" },
|
|
268
|
+
{ value: "local", label: "Local HTTP only", hint: "development" }
|
|
269
|
+
]
|
|
270
|
+
}));
|
|
271
|
+
const url = normalizeUrl(publicOrigin, mode);
|
|
272
|
+
const preflightSpinner = p.spinner();
|
|
273
|
+
preflightSpinner.start("Checking Docker, ports, and DNS");
|
|
274
|
+
let host;
|
|
275
|
+
try {
|
|
276
|
+
const requestedPort = Number(process.env.HARLY_PORT ?? (mode === "local" && url.port ? url.port : 3e3));
|
|
277
|
+
host = await preflight(mode, true, requestedPort, directory);
|
|
278
|
+
if (mode !== "local") await dns.lookup(url.hostname);
|
|
279
|
+
preflightSpinner.stop("Host preflight passed");
|
|
280
|
+
} catch (error) {
|
|
281
|
+
preflightSpinner.stop("Host preflight failed");
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
const email = unwrapPrompt(await p.text({
|
|
285
|
+
message: "Initial owner email",
|
|
286
|
+
placeholder: "owner@example.com",
|
|
287
|
+
initialValue: process.env.HARLY_INITIAL_ADMIN_EMAIL,
|
|
288
|
+
validate: validateEmail
|
|
289
|
+
})).toLowerCase();
|
|
290
|
+
const organization = unwrapPrompt(await p.text({
|
|
291
|
+
message: "Organization name",
|
|
292
|
+
placeholder: "Acme Inc.",
|
|
293
|
+
initialValue: process.env.HARLY_ORGANIZATION ?? "My organization",
|
|
294
|
+
validate: (value) => value?.trim() ? void 0 : "Enter an organization name."
|
|
295
|
+
}));
|
|
296
|
+
const storage = unwrapPrompt(await p.select({
|
|
297
|
+
message: "File storage",
|
|
298
|
+
initialValue: process.env.STORAGE_PROVIDER ?? "local",
|
|
299
|
+
options: [
|
|
300
|
+
{ value: "local", label: "Local persistent volume", hint: "simple" },
|
|
301
|
+
{ value: "s3", label: "S3, R2, or MinIO", hint: "recommended for growth" }
|
|
302
|
+
]
|
|
303
|
+
}));
|
|
304
|
+
const s3 = storage === "s3" ? {
|
|
305
|
+
bucket: unwrapPrompt(await p.text({ message: "S3 bucket", initialValue: process.env.S3_BUCKET, validate: (value) => value?.trim() ? void 0 : "Enter the bucket name." })),
|
|
306
|
+
region: unwrapPrompt(await p.text({ message: "S3 region", initialValue: process.env.S3_REGION ?? "auto" })),
|
|
307
|
+
accessKeyId: unwrapPrompt(await p.text({ message: "S3 access key ID", initialValue: process.env.S3_ACCESS_KEY_ID, validate: (value) => value?.trim() ? void 0 : "Enter the access key ID." })),
|
|
308
|
+
secretAccessKey: unwrapPrompt(await p.password({ message: "S3 secret access key", mask: "\u2022", validate: (value) => value?.trim() ? void 0 : "Enter the secret access key." })),
|
|
309
|
+
endpoint: unwrapPrompt(await p.text({ message: "S3 endpoint", placeholder: "Leave blank for AWS", initialValue: process.env.S3_ENDPOINT ?? "" })),
|
|
310
|
+
publicUrl: unwrapPrompt(await p.text({ message: "S3 public URL", placeholder: "Optional", initialValue: process.env.S3_PUBLIC_URL ?? "" }))
|
|
311
|
+
} : null;
|
|
312
|
+
const detectedProfile = detectResourceProfile();
|
|
313
|
+
p.note(hostSummary(host), `Detected host \xB7 ${detectedProfile}`);
|
|
314
|
+
const resourceProfile = unwrapPrompt(await p.select({
|
|
315
|
+
message: "Resource profile",
|
|
316
|
+
initialValue: process.env.HARLY_RESOURCE_PROFILE ?? detectedProfile,
|
|
317
|
+
options: [
|
|
318
|
+
{ value: "compact", label: "Compact", hint: "2 GB RAM + swap" },
|
|
319
|
+
{ value: "standard", label: "Standard", hint: "4 GB RAM, recommended" },
|
|
320
|
+
{ value: "performance", label: "Performance", hint: "8 GB RAM or more" }
|
|
321
|
+
]
|
|
322
|
+
}));
|
|
323
|
+
const image = process.env.HARLY_IMAGE_REF ?? "ghcr.io/vytral/harly:0.1.0-beta.2";
|
|
324
|
+
if (image.endsWith(":latest")) throw new CliError("Installations must pin a version or digest, never latest.", 2);
|
|
325
|
+
const services = `PostgreSQL, migrator, app, scheduler${mode === "caddy" ? ", Caddy" : ""}`;
|
|
326
|
+
const localPort = process.env.HARLY_PORT ?? (mode === "local" && url.port ? url.port : "3000");
|
|
327
|
+
p.note([
|
|
328
|
+
`Directory ${directory}`,
|
|
329
|
+
`URL ${url.origin}`,
|
|
330
|
+
`Image ${image}`,
|
|
331
|
+
`Services ${services}`,
|
|
332
|
+
`Ports ${mode === "caddy" ? "80, 443" : `127.0.0.1:${localPort}`}`,
|
|
333
|
+
`Storage ${storage === "local" ? "Local persistent volume" : "S3-compatible"}`,
|
|
334
|
+
`Resources ${resourceProfile}`,
|
|
335
|
+
`HTTPS ${mode === "caddy" ? "Managed automatically by Caddy" : mode === "external" ? "Managed by external proxy" : "Disabled"}`
|
|
336
|
+
].join("\n"), "Installation plan");
|
|
337
|
+
const approved = unwrapPrompt(await p.confirm({ message: "Generate this installation?", initialValue: true }));
|
|
338
|
+
if (!approved) {
|
|
339
|
+
p.cancel("No files were changed.");
|
|
340
|
+
throw new CliError("", 2);
|
|
341
|
+
}
|
|
342
|
+
return { mode, url, email, organization, storage, s3, resourceProfile, image };
|
|
343
|
+
}
|
|
344
|
+
async function atomicWrite(file, contents, mode) {
|
|
345
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
346
|
+
const temp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
347
|
+
await writeFile(temp, contents, { mode });
|
|
348
|
+
await rename(temp, file);
|
|
349
|
+
if (mode) await chmod(file, mode);
|
|
350
|
+
}
|
|
351
|
+
async function exists(file) {
|
|
352
|
+
return stat(file).then(() => true, () => false);
|
|
353
|
+
}
|
|
354
|
+
var composeTemplate = `name: harly
|
|
355
|
+
x-image: &image \${HARLY_IMAGE:?Set HARLY_IMAGE}
|
|
356
|
+
x-logging: &logging
|
|
357
|
+
driver: json-file
|
|
358
|
+
options: { max-size: "\${HARLY_LOG_MAX_SIZE:-10m}", max-file: "\${HARLY_LOG_MAX_FILES:-3}" }
|
|
359
|
+
x-env: &env
|
|
360
|
+
NODE_ENV: production
|
|
361
|
+
HARLY_URL: \${HARLY_URL:?Set HARLY_URL}
|
|
362
|
+
NEXT_PUBLIC_APP_URL: \${HARLY_URL:?Set HARLY_URL}
|
|
363
|
+
BETTER_AUTH_URL: \${HARLY_URL:?Set HARLY_URL}
|
|
364
|
+
HARLY_VERSION: \${HARLY_VERSION:?Set HARLY_VERSION}
|
|
365
|
+
DATABASE_URL: postgresql://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@postgres:5432/\${POSTGRES_DB}
|
|
366
|
+
BETTER_AUTH_SECRET: \${BETTER_AUTH_SECRET}
|
|
367
|
+
AI_ENCRYPTION_KEY: \${AI_ENCRYPTION_KEY}
|
|
368
|
+
STORAGE_UPLOAD_SECRET: \${STORAGE_UPLOAD_SECRET}
|
|
369
|
+
CRON_SECRET: \${CRON_SECRET}
|
|
370
|
+
HARLY_SETUP_SECRET: \${HARLY_SETUP_SECRET}
|
|
371
|
+
HARLY_INITIAL_ADMIN_EMAIL: \${HARLY_INITIAL_ADMIN_EMAIL}
|
|
372
|
+
STORAGE_PROVIDER: \${STORAGE_PROVIDER:-local}
|
|
373
|
+
UPLOADS_DIR: /data/uploads
|
|
374
|
+
S3_BUCKET: \${S3_BUCKET:-}
|
|
375
|
+
S3_REGION: \${S3_REGION:-}
|
|
376
|
+
S3_ACCESS_KEY_ID: \${S3_ACCESS_KEY_ID:-}
|
|
377
|
+
S3_SECRET_ACCESS_KEY: \${S3_SECRET_ACCESS_KEY:-}
|
|
378
|
+
S3_ENDPOINT: \${S3_ENDPOINT:-}
|
|
379
|
+
S3_PUBLIC_URL: \${S3_PUBLIC_URL:-}
|
|
380
|
+
HARLY_ALLOW_PRIVATE_WEBHOOKS: \${HARLY_ALLOW_PRIVATE_WEBHOOKS:-false}
|
|
381
|
+
RESEND_API_KEY: \${RESEND_API_KEY:-}
|
|
382
|
+
EMAIL_FROM: \${EMAIL_FROM:-}
|
|
383
|
+
HARLY_CACHE_MAX_MB: \${HARLY_CACHE_MAX_MB:-512}
|
|
384
|
+
HARLY_CACHE_MAX_AGE_DAYS: \${HARLY_CACHE_MAX_AGE_DAYS:-7}
|
|
385
|
+
services:
|
|
386
|
+
postgres:
|
|
387
|
+
image: postgres:16-bookworm
|
|
388
|
+
restart: unless-stopped
|
|
389
|
+
mem_limit: \${HARLY_POSTGRES_MEMORY:-768m}
|
|
390
|
+
cpus: \${HARLY_POSTGRES_CPUS:-1.0}
|
|
391
|
+
shm_size: 128m
|
|
392
|
+
logging: *logging
|
|
393
|
+
environment: { POSTGRES_USER: "\${POSTGRES_USER}", POSTGRES_PASSWORD: "\${POSTGRES_PASSWORD}", POSTGRES_DB: "\${POSTGRES_DB}" }
|
|
394
|
+
healthcheck: { test: [CMD-SHELL, "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"], interval: 5s, timeout: 3s, retries: 20 }
|
|
395
|
+
volumes: [postgres-data:/var/lib/postgresql/data]
|
|
396
|
+
migrate:
|
|
397
|
+
image: *image
|
|
398
|
+
command: [migrate]
|
|
399
|
+
environment: *env
|
|
400
|
+
depends_on: { postgres: { condition: service_healthy } }
|
|
401
|
+
restart: "no"
|
|
402
|
+
mem_limit: \${HARLY_MIGRATE_MEMORY:-768m}
|
|
403
|
+
cpus: \${HARLY_MIGRATE_CPUS:-1.0}
|
|
404
|
+
logging: *logging
|
|
405
|
+
app:
|
|
406
|
+
image: *image
|
|
407
|
+
command: [serve]
|
|
408
|
+
restart: unless-stopped
|
|
409
|
+
stop_grace_period: 30s
|
|
410
|
+
mem_limit: \${HARLY_APP_MEMORY:-1536m}
|
|
411
|
+
cpus: \${HARLY_APP_CPUS:-2.0}
|
|
412
|
+
logging: *logging
|
|
413
|
+
read_only: true
|
|
414
|
+
tmpfs: [/tmp]
|
|
415
|
+
environment: { <<: *env, NODE_OPTIONS: "\${HARLY_APP_NODE_OPTIONS:---max-old-space-size=1024}" }
|
|
416
|
+
depends_on: { migrate: { condition: service_completed_successfully } }
|
|
417
|
+
ports: ["127.0.0.1:\${HARLY_PORT:-3000}:3000"]
|
|
418
|
+
volumes: [uploads:/data/uploads, next-cache:/app/apps/web/.next/cache]
|
|
419
|
+
healthcheck: { test: [CMD, node, -e, "fetch('http://127.0.0.1:3000/api/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"], interval: 10s, timeout: 4s, retries: 12 }
|
|
420
|
+
scheduler:
|
|
421
|
+
image: *image
|
|
422
|
+
command: [scheduler]
|
|
423
|
+
restart: unless-stopped
|
|
424
|
+
stop_grace_period: 30s
|
|
425
|
+
mem_limit: \${HARLY_SCHEDULER_MEMORY:-256m}
|
|
426
|
+
cpus: \${HARLY_SCHEDULER_CPUS:-0.5}
|
|
427
|
+
logging: *logging
|
|
428
|
+
read_only: true
|
|
429
|
+
tmpfs: [/tmp]
|
|
430
|
+
environment: { <<: *env, HARLY_INTERNAL_URL: http://app:3000, NODE_OPTIONS: "\${HARLY_SCHEDULER_NODE_OPTIONS:---max-old-space-size=160}" }
|
|
431
|
+
depends_on: { app: { condition: service_healthy } }
|
|
432
|
+
healthcheck: { test: [CMD, node, /app/runtime.mjs, doctor], interval: 30s, timeout: 10s, start_period: 45s, retries: 3 }
|
|
433
|
+
caddy:
|
|
434
|
+
image: caddy:2.10-alpine
|
|
435
|
+
profiles: [proxy]
|
|
436
|
+
restart: unless-stopped
|
|
437
|
+
mem_limit: \${HARLY_CADDY_MEMORY:-256m}
|
|
438
|
+
cpus: \${HARLY_CADDY_CPUS:-0.5}
|
|
439
|
+
logging: *logging
|
|
440
|
+
environment: { HARLY_DOMAIN: "\${HARLY_DOMAIN:-localhost}" }
|
|
441
|
+
ports: ["80:80", "443:443", "443:443/udp"]
|
|
442
|
+
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", caddy-data:/data, caddy-config:/config]
|
|
443
|
+
depends_on: { app: { condition: service_healthy } }
|
|
444
|
+
volumes: { postgres-data: {}, uploads: {}, next-cache: {}, caddy-data: {}, caddy-config: {} }
|
|
445
|
+
`;
|
|
446
|
+
var envExample = `HARLY_IMAGE=ghcr.io/vytral/harly:<version-or-digest>
|
|
447
|
+
HARLY_VERSION=<version>
|
|
448
|
+
HARLY_URL=https://harly.example.com
|
|
449
|
+
HARLY_PORT=3000
|
|
450
|
+
HARLY_DOMAIN=harly.example.com
|
|
451
|
+
COMPOSE_PROFILES=proxy
|
|
452
|
+
POSTGRES_USER=harly
|
|
453
|
+
POSTGRES_PASSWORD=<secret>
|
|
454
|
+
POSTGRES_DB=harly
|
|
455
|
+
BETTER_AUTH_SECRET=<secret>
|
|
456
|
+
AI_ENCRYPTION_KEY=<secret>
|
|
457
|
+
STORAGE_UPLOAD_SECRET=<secret>
|
|
458
|
+
CRON_SECRET=<secret>
|
|
459
|
+
HARLY_SETUP_SECRET=<secret>
|
|
460
|
+
HARLY_INITIAL_ADMIN_EMAIL=owner@example.com
|
|
461
|
+
STORAGE_PROVIDER=local
|
|
462
|
+
RESEND_API_KEY=
|
|
463
|
+
EMAIL_FROM=
|
|
464
|
+
# Optional resource tuning (defaults target a 4 GB VPS)
|
|
465
|
+
HARLY_APP_MEMORY=1536m
|
|
466
|
+
HARLY_POSTGRES_MEMORY=768m
|
|
467
|
+
HARLY_SCHEDULER_MEMORY=256m
|
|
468
|
+
HARLY_SCHEDULER_STALE_AFTER_SECONDS=300
|
|
469
|
+
HARLY_CADDY_MEMORY=256m
|
|
470
|
+
HARLY_CACHE_MAX_MB=512
|
|
471
|
+
HARLY_CACHE_MAX_AGE_DAYS=7
|
|
472
|
+
HARLY_LOG_MAX_SIZE=10m
|
|
473
|
+
HARLY_LOG_MAX_FILES=3
|
|
474
|
+
`;
|
|
475
|
+
async function init() {
|
|
476
|
+
const directory = path.resolve(positionals[0] ?? "harly");
|
|
477
|
+
const answers = interactive ? await collectInteractiveAnswers(directory) : await collectNonInteractiveAnswers(directory);
|
|
478
|
+
const { mode, url, email, organization, storage, s3, resourceProfile, image } = answers;
|
|
479
|
+
const resources = resourceProfiles[resourceProfile];
|
|
480
|
+
const generationSpinner = interactive ? p.spinner() : null;
|
|
481
|
+
generationSpinner?.start("Generating secure configuration");
|
|
482
|
+
try {
|
|
483
|
+
await mkdir(directory, { recursive: true });
|
|
484
|
+
const envPath = path.join(directory, ".env");
|
|
485
|
+
if (!await exists(envPath)) {
|
|
486
|
+
const env = [
|
|
487
|
+
`HARLY_IMAGE=${envLine(image)}`,
|
|
488
|
+
`HARLY_VERSION=${envLine(image.includes("@sha256:") ? "digest" : image.split(":").at(-1) ?? "unknown")}`,
|
|
489
|
+
`HARLY_URL=${envLine(url.origin)}`,
|
|
490
|
+
`HARLY_PORT=${envLine(process.env.HARLY_PORT ?? (mode === "local" && url.port ? url.port : "3000"))}`,
|
|
491
|
+
`HARLY_DOMAIN=${envLine(url.hostname)}`,
|
|
492
|
+
`COMPOSE_PROFILES=${envLine(mode === "caddy" ? "proxy" : "")}`,
|
|
493
|
+
`POSTGRES_USER=${envLine("harly")}`,
|
|
494
|
+
`POSTGRES_PASSWORD=${envLine(secret())}`,
|
|
495
|
+
`POSTGRES_DB=${envLine("harly")}`,
|
|
496
|
+
`BETTER_AUTH_SECRET=${envLine(secret())}`,
|
|
497
|
+
`AI_ENCRYPTION_KEY=${envLine(secret())}`,
|
|
498
|
+
`STORAGE_UPLOAD_SECRET=${envLine(secret())}`,
|
|
499
|
+
`CRON_SECRET=${envLine(secret())}`,
|
|
500
|
+
`HARLY_SETUP_SECRET=${envLine(secret())}`,
|
|
501
|
+
`HARLY_INITIAL_ADMIN_EMAIL=${envLine(email)}`,
|
|
502
|
+
`STORAGE_PROVIDER=${envLine(storage)}`,
|
|
503
|
+
...s3 ? [
|
|
504
|
+
`S3_BUCKET=${envLine(s3.bucket)}`,
|
|
505
|
+
`S3_REGION=${envLine(s3.region)}`,
|
|
506
|
+
`S3_ACCESS_KEY_ID=${envLine(s3.accessKeyId)}`,
|
|
507
|
+
`S3_SECRET_ACCESS_KEY=${envLine(s3.secretAccessKey)}`,
|
|
508
|
+
`S3_ENDPOINT=${envLine(s3.endpoint)}`,
|
|
509
|
+
`S3_PUBLIC_URL=${envLine(s3.publicUrl)}`
|
|
510
|
+
] : [],
|
|
511
|
+
"RESEND_API_KEY=",
|
|
512
|
+
"EMAIL_FROM=",
|
|
513
|
+
`HARLY_APP_MEMORY=${resources.app}`,
|
|
514
|
+
`HARLY_APP_NODE_OPTIONS=${resources.appNodeOptions}`,
|
|
515
|
+
`HARLY_POSTGRES_MEMORY=${resources.postgres}`,
|
|
516
|
+
`HARLY_MIGRATE_MEMORY=${resources.migrate}`,
|
|
517
|
+
`HARLY_SCHEDULER_MEMORY=${resources.scheduler}`,
|
|
518
|
+
"HARLY_SCHEDULER_STALE_AFTER_SECONDS=300",
|
|
519
|
+
`HARLY_CADDY_MEMORY=${resources.caddy}`,
|
|
520
|
+
`HARLY_CACHE_MAX_MB=${resources.cacheMb}`,
|
|
521
|
+
"HARLY_CACHE_MAX_AGE_DAYS=7",
|
|
522
|
+
"HARLY_LOG_MAX_SIZE=10m",
|
|
523
|
+
"HARLY_LOG_MAX_FILES=3",
|
|
524
|
+
""
|
|
525
|
+
].join("\n");
|
|
526
|
+
await atomicWrite(envPath, env, 384);
|
|
527
|
+
}
|
|
528
|
+
const config = { version: 1, proxyMode: mode, publicUrl: url.origin, image, organizationName: organization, initialAdminEmail: email, storage, resourceProfile };
|
|
529
|
+
const templates = [
|
|
530
|
+
["compose.yaml", composeTemplate],
|
|
531
|
+
["Caddyfile", "{$HARLY_DOMAIN} {\n encode zstd gzip\n reverse_proxy app:3000\n}\n"],
|
|
532
|
+
[".env.example", envExample],
|
|
533
|
+
[".gitignore", ".env\nbackups/\n"],
|
|
534
|
+
["harly.config.json", `${JSON.stringify(config, null, 2)}
|
|
535
|
+
`],
|
|
536
|
+
["README.md", `# Harly self-host
|
|
537
|
+
|
|
538
|
+
- Open management: \`npx @harly/cli\`
|
|
539
|
+
- Diagnose: \`npx @harly/cli doctor\`
|
|
540
|
+
- Backup before every update.
|
|
541
|
+
- Complete the first owner at ${url.origin}/setup using HARLY_SETUP_SECRET from .env.
|
|
542
|
+
`]
|
|
543
|
+
];
|
|
544
|
+
for (const [name, contents, modeBits] of templates) {
|
|
545
|
+
const target = path.join(directory, name);
|
|
546
|
+
if (!await exists(target) || force) await atomicWrite(target, contents, modeBits);
|
|
547
|
+
}
|
|
548
|
+
} catch (error) {
|
|
549
|
+
generationSpinner?.stop("Configuration generation failed");
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
generationSpinner?.stop("Configuration generated");
|
|
553
|
+
if (interactive) {
|
|
554
|
+
p.log.success(`${pc.bold(directory)} is ready`);
|
|
555
|
+
const launchNow = unwrapPrompt(await p.confirm({
|
|
556
|
+
message: "Pull the image and launch Harly now?",
|
|
557
|
+
initialValue: true
|
|
558
|
+
}));
|
|
559
|
+
if (launchNow) {
|
|
560
|
+
await launch(directory, true);
|
|
561
|
+
p.outro(`Harly is ready at ${pc.cyan(url.origin)} \xB7 run ${pc.cyan("npx @harly/cli")}`);
|
|
562
|
+
} else {
|
|
563
|
+
p.outro(`Next: ${pc.cyan(`cd ${shellQuote(directory)} && npx @harly/cli`)}`);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
process.stdout.write(`
|
|
567
|
+
Generated ${directory}
|
|
568
|
+
Image: ${image}
|
|
569
|
+
Mode: ${mode}
|
|
570
|
+
Resource profile: ${resourceProfile}
|
|
571
|
+
Services: postgres, migrate, app, scheduler${mode === "caddy" ? ", caddy" : ""}
|
|
572
|
+
Volumes: postgres-data, uploads, next-cache${mode === "caddy" ? ", caddy-data, caddy-config" : ""}
|
|
573
|
+
|
|
574
|
+
`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function readConfig(directory) {
|
|
578
|
+
try {
|
|
579
|
+
return JSON.parse(await readFile(path.join(directory, "harly.config.json"), "utf8"));
|
|
580
|
+
} catch {
|
|
581
|
+
throw new CliError("harly.config.json is missing or invalid. Run init first.");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async function findInstallation(start = process.cwd()) {
|
|
585
|
+
let directory = path.resolve(start);
|
|
586
|
+
while (true) {
|
|
587
|
+
const configPath = path.join(directory, "harly.config.json");
|
|
588
|
+
if (await exists(configPath)) {
|
|
589
|
+
try {
|
|
590
|
+
return { directory, config: await readConfig(directory) };
|
|
591
|
+
} catch {
|
|
592
|
+
throw new CliError(`Found an invalid Harly configuration at ${configPath}. Fix or remove it before starting a new installation.`, 2);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const parent = path.dirname(directory);
|
|
596
|
+
if (parent === directory) return null;
|
|
597
|
+
directory = parent;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function launch(explicitDirectory, confirmed = false) {
|
|
601
|
+
const directory = path.resolve(explicitDirectory ?? positionals[0] ?? ".");
|
|
602
|
+
const config = await readConfig(directory);
|
|
603
|
+
if (interactive && !confirmed) {
|
|
604
|
+
showBrand();
|
|
605
|
+
p.intro(pc.bgCyan(pc.black(" Launch Harly ")));
|
|
606
|
+
p.note(`Image ${config.image}
|
|
607
|
+
Mode ${config.proxyMode}
|
|
608
|
+
URL ${config.publicUrl}`, "Launch plan");
|
|
609
|
+
} else if (!interactive) {
|
|
610
|
+
process.stdout.write(`Image: ${config.image}
|
|
611
|
+
Mode: ${config.proxyMode}
|
|
612
|
+
Commands: docker compose pull; docker compose up -d --wait
|
|
613
|
+
`);
|
|
614
|
+
}
|
|
615
|
+
if (!confirmed && !yes && !await confirm2("Continue?")) {
|
|
616
|
+
if (!process.stdin.isTTY) throw new CliError("--yes is required in non-interactive mode.", 2);
|
|
617
|
+
throw new CliError("Launch cancelled.", 2);
|
|
618
|
+
}
|
|
619
|
+
progressStep("Validating Docker Compose", "Compose configuration is valid", () => compose(directory, ["config", "--quiet"]));
|
|
620
|
+
progressStep("Pulling immutable container images", "Container images downloaded", () => compose(directory, ["pull"]));
|
|
621
|
+
progressStep("Starting Harly and waiting for healthchecks", "Harly services are healthy", () => {
|
|
622
|
+
compose(directory, ["up", "-d", "--wait", "--wait-timeout", "180"]);
|
|
623
|
+
});
|
|
624
|
+
if (interactive && !confirmed) {
|
|
625
|
+
p.outro(`Harly is ready at ${pc.cyan(config.publicUrl)} \xB7 run ${pc.cyan("npx @harly/cli doctor .")}`);
|
|
626
|
+
} else if (!interactive) {
|
|
627
|
+
process.stdout.write(`Harly is ready at ${config.publicUrl}. Run doctor to verify the public route.
|
|
628
|
+
`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async function doctor(explicitDirectory, print = true) {
|
|
632
|
+
const directory = path.resolve(explicitDirectory ?? positionals[0] ?? ".");
|
|
633
|
+
const config = await readConfig(directory);
|
|
634
|
+
const checks = [];
|
|
635
|
+
const valid = compose(directory, ["config", "--quiet"], { allowFailure: true });
|
|
636
|
+
checks.push({ name: "compose", ok: valid.status === 0 });
|
|
637
|
+
const servicesResult = compose(directory, ["ps", "--status", "running", "--services"], { allowFailure: true });
|
|
638
|
+
const services = String(servicesResult.stdout ?? "").trim().split(/\s+/).filter(Boolean);
|
|
639
|
+
for (const name of ["postgres", "app", "scheduler", ...config.proxyMode === "caddy" ? ["caddy"] : []]) checks.push({ name: `service:${name}`, ok: services.includes(name) });
|
|
640
|
+
checks.push({ name: "profile:caddy", ok: config.proxyMode === "caddy" ? services.includes("caddy") : !services.includes("caddy") });
|
|
641
|
+
try {
|
|
642
|
+
const response = await fetch(`${config.publicUrl}/api/health/ready`, { signal: AbortSignal.timeout(5e3) });
|
|
643
|
+
checks.push({ name: "readiness", ok: response.ok, detail: `HTTP ${response.status}` });
|
|
644
|
+
} catch {
|
|
645
|
+
checks.push({ name: "readiness", ok: false, detail: "unreachable" });
|
|
646
|
+
}
|
|
647
|
+
const result = { ok: checks.every((check) => check.ok), proxyMode: config.proxyMode, image: config.image, checks };
|
|
648
|
+
if (print) process.stdout.write(json ? `${JSON.stringify(result)}
|
|
649
|
+
` : `${checks.map((check) => `${check.ok ? "\u2713" : "\u2717"} ${check.name}${check.detail ? ` \u2014 ${check.detail}` : ""}`).join("\n")}
|
|
650
|
+
`);
|
|
651
|
+
if (!result.ok && print) process.exitCode = 1;
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
function sha256(buffer) {
|
|
655
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
656
|
+
}
|
|
657
|
+
async function checksums(directory, prefix = "") {
|
|
658
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
659
|
+
const result = {};
|
|
660
|
+
for (const entry of entries) {
|
|
661
|
+
const relative = path.join(prefix, entry.name);
|
|
662
|
+
const absolute = path.join(directory, entry.name);
|
|
663
|
+
if (entry.isDirectory()) Object.assign(result, await checksums(absolute, relative));
|
|
664
|
+
else if (entry.isFile()) result[relative] = sha256(await readFile(absolute));
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
async function deploymentEnvironment(directory) {
|
|
669
|
+
const contents = await readFile(path.join(directory, ".env"), "utf8");
|
|
670
|
+
const values = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const line of contents.split("\n")) {
|
|
672
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
673
|
+
if (!match) continue;
|
|
674
|
+
try {
|
|
675
|
+
values.set(match[1], JSON.parse(match[2]));
|
|
676
|
+
} catch {
|
|
677
|
+
values.set(match[1], match[2]);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return values;
|
|
681
|
+
}
|
|
682
|
+
async function deploymentDatabase(directory) {
|
|
683
|
+
const values = await deploymentEnvironment(directory);
|
|
684
|
+
return { user: values.get("POSTGRES_USER") || "harly", database: values.get("POSTGRES_DB") || "harly" };
|
|
685
|
+
}
|
|
686
|
+
async function backup(explicitDirectory) {
|
|
687
|
+
const directory = path.resolve(explicitDirectory ?? positionals[0] ?? ".");
|
|
688
|
+
const config = await readConfig(directory);
|
|
689
|
+
const database = await deploymentDatabase(directory);
|
|
690
|
+
const recipient = process.env.AGE_RECIPIENT ?? (await deploymentEnvironment(directory)).get("AGE_RECIPIENT");
|
|
691
|
+
const encrypt = flags.has("--encrypt");
|
|
692
|
+
if (encrypt && !recipient) throw new CliError("--encrypt requires AGE_RECIPIENT. See the advanced encryption guide.");
|
|
693
|
+
const temp = await mkdtemp(path.join(os.tmpdir(), "harly-backup-"));
|
|
694
|
+
const outputDirectory = path.join(directory, "backups");
|
|
695
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
696
|
+
compose(directory, ["stop", "app", "scheduler"], { allowFailure: true });
|
|
697
|
+
try {
|
|
698
|
+
const dump = compose(directory, ["exec", "-T", "postgres", "pg_dump", "-U", database.user, "-d", database.database, "-Fc"], { binary: true });
|
|
699
|
+
const bytes = Buffer.isBuffer(dump.stdout) ? dump.stdout : Buffer.from(dump.stdout);
|
|
700
|
+
await writeFile(path.join(temp, "database.dump"), bytes);
|
|
701
|
+
await cp(path.join(directory, ".env"), path.join(temp, ".env"));
|
|
702
|
+
await cp(path.join(directory, "harly.config.json"), path.join(temp, "harly.config.json"));
|
|
703
|
+
if (config.storage === "local") compose(directory, ["cp", "app:/data/uploads/.", path.join(temp, "uploads")]);
|
|
704
|
+
const manifest = {
|
|
705
|
+
version: config.image,
|
|
706
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
707
|
+
storage: config.storage,
|
|
708
|
+
uploads: config.storage === "local" ? "included" : "external-s3-not-included",
|
|
709
|
+
files: await checksums(temp)
|
|
710
|
+
};
|
|
711
|
+
await writeFile(path.join(temp, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
712
|
+
`);
|
|
713
|
+
const archive = path.join(outputDirectory, `harly-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.tar.gz`);
|
|
714
|
+
run("tar", ["-czf", archive, "-C", temp, "."]);
|
|
715
|
+
await chmod(archive, 384);
|
|
716
|
+
if (!encrypt) {
|
|
717
|
+
process.stdout.write(`${archive}
|
|
718
|
+
`);
|
|
719
|
+
return archive;
|
|
720
|
+
}
|
|
721
|
+
const encrypted = `${archive}.age`;
|
|
722
|
+
run("age", ["-r", recipient, "-o", encrypted, archive]);
|
|
723
|
+
await chmod(encrypted, 384);
|
|
724
|
+
await rm(archive, { force: true });
|
|
725
|
+
process.stdout.write(`${encrypted}
|
|
726
|
+
`);
|
|
727
|
+
return encrypted;
|
|
728
|
+
} finally {
|
|
729
|
+
compose(directory, ["up", "-d", "app", "scheduler"], { allowFailure: true });
|
|
730
|
+
await rm(temp, { recursive: true, force: true });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async function restore() {
|
|
734
|
+
const archiveArg = positionals[0];
|
|
735
|
+
if (!archiveArg) throw new CliError("restore requires an archive path.", 2);
|
|
736
|
+
if (!force) throw new CliError("restore requires --force after you verify the destination and backup.", 2);
|
|
737
|
+
const archive = path.resolve(archiveArg);
|
|
738
|
+
const directory = path.resolve(positionals[1] ?? ".");
|
|
739
|
+
const config = await readConfig(directory);
|
|
740
|
+
const database = await deploymentDatabase(directory);
|
|
741
|
+
const temp = await mkdtemp(path.join(os.tmpdir(), "harly-restore-"));
|
|
742
|
+
let plaintext = archive;
|
|
743
|
+
if (archive.endsWith(".age")) {
|
|
744
|
+
plaintext = path.join(temp, "backup.tar.gz");
|
|
745
|
+
const identity = process.env.AGE_IDENTITY;
|
|
746
|
+
if (!identity) throw new CliError("AGE_IDENTITY is required to decrypt this backup.");
|
|
747
|
+
run("age", ["-d", "-i", identity, "-o", plaintext, archive]);
|
|
748
|
+
}
|
|
749
|
+
run("tar", ["-xzf", plaintext, "-C", temp]);
|
|
750
|
+
const manifest = JSON.parse(await readFile(path.join(temp, "manifest.json"), "utf8"));
|
|
751
|
+
const actual = await checksums(temp);
|
|
752
|
+
for (const [file, checksum] of Object.entries(manifest.files)) {
|
|
753
|
+
if (actual[file] !== checksum) throw new CliError(`Backup checksum verification failed for ${file}.`);
|
|
754
|
+
}
|
|
755
|
+
if (manifest.storage === "s3" || config.storage === "s3") {
|
|
756
|
+
process.stderr.write("This backup does not include S3 objects. Verify the bucket backup/version history before restoring.\n");
|
|
757
|
+
}
|
|
758
|
+
process.stdout.write("Creating a safety backup of the current deployment\u2026\n");
|
|
759
|
+
await backup(directory);
|
|
760
|
+
process.stdout.write("Safety backup saved. Preparing restore\u2026\n");
|
|
761
|
+
compose(directory, ["stop", "app", "scheduler", "migrate"], { allowFailure: true });
|
|
762
|
+
try {
|
|
763
|
+
process.stdout.write("Restoring database\u2026\n");
|
|
764
|
+
compose(directory, ["cp", path.join(temp, "database.dump"), "postgres:/tmp/harly-restore.dump"]);
|
|
765
|
+
compose(directory, ["exec", "-T", "postgres", "pg_restore", "-U", database.user, "-d", database.database, "--clean", "--if-exists", "/tmp/harly-restore.dump"]);
|
|
766
|
+
compose(directory, ["exec", "-T", "postgres", "rm", "-f", "/tmp/harly-restore.dump"], { allowFailure: true });
|
|
767
|
+
if (await exists(path.join(temp, "uploads"))) {
|
|
768
|
+
process.stdout.write("Restoring local uploads\u2026\n");
|
|
769
|
+
compose(directory, ["run", "--rm", "--entrypoint", "sh", "app", "-c", "rm -rf /data/uploads/* /data/uploads/.[!.]* /data/uploads/..?*"]);
|
|
770
|
+
compose(directory, ["cp", `${path.join(temp, "uploads")}/.`, "app:/data/uploads"]);
|
|
771
|
+
}
|
|
772
|
+
process.stdout.write("Applying migrations\u2026\n");
|
|
773
|
+
compose(directory, ["run", "--rm", "migrate"]);
|
|
774
|
+
} finally {
|
|
775
|
+
compose(directory, ["up", "-d", "app", "scheduler"], { allowFailure: true });
|
|
776
|
+
}
|
|
777
|
+
const result = await doctor(directory, false);
|
|
778
|
+
if (!result.ok) throw new CliError("Restore completed but Harly did not become healthy. Your safety backup was preserved; run `harly doctor` and inspect `docker compose logs` before retrying.");
|
|
779
|
+
process.stdout.write("Restore complete. Harly is healthy.\n");
|
|
780
|
+
}
|
|
781
|
+
async function upgrade(explicitDirectory) {
|
|
782
|
+
const directory = path.resolve(explicitDirectory ?? positionals[0] ?? ".");
|
|
783
|
+
const config = await readConfig(directory);
|
|
784
|
+
await preflight(config.proxyMode, false);
|
|
785
|
+
if (toVersion === "latest") throw new CliError("latest is not allowed. Use edge for previews or a fixed version.", 2);
|
|
786
|
+
const requestedImage = toVersion ? toVersion.startsWith("ghcr.io/") ? toVersion : `ghcr.io/vytral/harly:${toVersion}` : config.requestedImage ?? config.image;
|
|
787
|
+
if (!yes && !await confirm2(`Back up and upgrade ${config.image} to ${requestedImage}?`)) {
|
|
788
|
+
if (!interactive) throw new CliError("--yes is required in non-interactive mode.", 2);
|
|
789
|
+
throw new CliError("Upgrade cancelled.", 2);
|
|
790
|
+
}
|
|
791
|
+
if (interactive) {
|
|
792
|
+
showBrand();
|
|
793
|
+
p.intro(pc.bgCyan(pc.black(" Upgrade Harly ")));
|
|
794
|
+
p.note(`Current ${config.image}
|
|
795
|
+
Target ${requestedImage}
|
|
796
|
+
Data preserved`, "Upgrade plan");
|
|
797
|
+
}
|
|
798
|
+
await backup(directory);
|
|
799
|
+
const envPath = path.join(directory, ".env");
|
|
800
|
+
const configPath = path.join(directory, "harly.config.json");
|
|
801
|
+
const originalEnv = await readFile(envPath, "utf8");
|
|
802
|
+
const setImage = (contents, image, version) => contents.replace(/^HARLY_IMAGE=.*$/m, `HARLY_IMAGE=${envLine(image)}`).replace(/^HARLY_VERSION=.*$/m, `HARLY_VERSION=${envLine(version)}`);
|
|
803
|
+
let migrationsAttempted = false;
|
|
804
|
+
await atomicWrite(envPath, setImage(originalEnv, requestedImage, toVersion ?? "current"), 384);
|
|
805
|
+
try {
|
|
806
|
+
progressStep("Pulling the requested image", "Image downloaded", () => compose(directory, ["pull", "app", "migrate", "scheduler"]));
|
|
807
|
+
} catch (error) {
|
|
808
|
+
await atomicWrite(envPath, originalEnv, 384);
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
const inspected = run("docker", ["image", "inspect", requestedImage, "--format", '{{join .RepoDigests "\\n"}}'], { allowFailure: true });
|
|
812
|
+
const repository = requestedImage.split("@")[0].replace(/:[^/:]+$/, "");
|
|
813
|
+
const digest = String(inspected.stdout ?? "").split(/\s+/).find((value) => value.startsWith(`${repository}@sha256:`));
|
|
814
|
+
const deployedImage = digest ?? requestedImage;
|
|
815
|
+
const deployedVersion = deployedImage.includes("@sha256:") ? deployedImage.split("@sha256:")[1].slice(0, 12) : toVersion ?? "current";
|
|
816
|
+
await atomicWrite(envPath, setImage(await readFile(envPath, "utf8"), deployedImage, deployedVersion), 384);
|
|
817
|
+
const markTargetConfigured = async () => {
|
|
818
|
+
config.requestedImage = requestedImage;
|
|
819
|
+
config.image = deployedImage;
|
|
820
|
+
config.deployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
821
|
+
await atomicWrite(configPath, `${JSON.stringify(config, null, 2)}
|
|
822
|
+
`);
|
|
823
|
+
};
|
|
824
|
+
try {
|
|
825
|
+
migrationsAttempted = true;
|
|
826
|
+
progressStep("Applying database migrations", "Migrations applied", () => compose(directory, ["run", "--rm", "migrate"]));
|
|
827
|
+
progressStep("Recreating services and waiting for healthchecks", "Services are healthy", () => {
|
|
828
|
+
compose(directory, ["up", "-d", "--wait", "--wait-timeout", "180"]);
|
|
829
|
+
});
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (migrationsAttempted) await markTargetConfigured();
|
|
832
|
+
else await atomicWrite(envPath, originalEnv, 384);
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
await markTargetConfigured();
|
|
836
|
+
let result = await doctor(directory, false);
|
|
837
|
+
for (let attempt = 0; !result.ok && attempt < 15; attempt += 1) {
|
|
838
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
839
|
+
result = await doctor(directory, false);
|
|
840
|
+
}
|
|
841
|
+
if (result.ok) await doctor(directory);
|
|
842
|
+
if (!result.ok) throw new CliError("Upgrade completed but health checks failed. The new image remains selected because migrations are forward-only; restore the pre-upgrade backup if recovery is required.");
|
|
843
|
+
if (interactive) p.outro(`Harly is running ${pc.cyan(deployedImage)} at ${pc.cyan(config.publicUrl)}`);
|
|
844
|
+
}
|
|
845
|
+
async function uninstall(explicitDirectory) {
|
|
846
|
+
const directory = path.resolve(explicitDirectory ?? positionals[0] ?? ".");
|
|
847
|
+
await readConfig(directory);
|
|
848
|
+
if (!yes && !await confirm2("Stop Harly and remove its containers? Data volumes will be kept.")) {
|
|
849
|
+
throw new CliError("Uninstall cancelled.", 2);
|
|
850
|
+
}
|
|
851
|
+
if (flags.has("--remove-data")) {
|
|
852
|
+
if (!yes && !await confirm2("Permanently delete PostgreSQL, uploads, cache, and proxy volumes?")) {
|
|
853
|
+
compose(directory, ["down"]);
|
|
854
|
+
process.stdout.write("Containers removed; data volumes kept.\n");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
process.stdout.write("Creating a final backup before deleting data volumes.\n");
|
|
858
|
+
await backup(directory);
|
|
859
|
+
compose(directory, ["down", "--volumes"]);
|
|
860
|
+
process.stdout.write("Harly containers and data volumes were removed. Local backup archives were kept.\n");
|
|
861
|
+
} else {
|
|
862
|
+
compose(directory, ["down"]);
|
|
863
|
+
process.stdout.write("Harly containers were removed. PostgreSQL, uploads, and backups were kept.\n");
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function cloudGuide(provider) {
|
|
867
|
+
showBrand();
|
|
868
|
+
const providerName = provider === "railway" ? "Railway" : provider === "fly" ? "Fly.io" : "DigitalOcean";
|
|
869
|
+
p.intro(pc.bgCyan(pc.black(` Deploy Harly on ${providerName} `)));
|
|
870
|
+
const url = normalizeUrl(unwrapPrompt(await p.text({
|
|
871
|
+
message: "Public URL",
|
|
872
|
+
placeholder: "https://hiring.example.com",
|
|
873
|
+
validate: validatePublicOrigin
|
|
874
|
+
})), "external");
|
|
875
|
+
const email = unwrapPrompt(await p.text({ message: "Initial owner email", validate: validateEmail })).toLowerCase();
|
|
876
|
+
const bucket = unwrapPrompt(await p.text({
|
|
877
|
+
message: "S3-compatible bucket (required for cloud uploads)",
|
|
878
|
+
validate: (value) => value?.trim() ? void 0 : "S3 storage is required on cloud platforms."
|
|
879
|
+
}));
|
|
880
|
+
const region = unwrapPrompt(await p.text({ message: "S3 region", initialValue: "auto" }));
|
|
881
|
+
const accessKey = unwrapPrompt(await p.password({ message: "S3 access key", validate: (value) => value ? void 0 : "Required." }));
|
|
882
|
+
const secretKey = unwrapPrompt(await p.password({ message: "S3 secret key", validate: (value) => value ? void 0 : "Required." }));
|
|
883
|
+
const databaseUrl = provider === "digitalocean" ? unwrapPrompt(await p.password({ message: "DigitalOcean Managed PostgreSQL connection URL", validate: validateDatabaseUrl })) : void 0;
|
|
884
|
+
const directory = path.resolve(`harly-${provider}`);
|
|
885
|
+
await mkdir(directory, { recursive: true });
|
|
886
|
+
const runtimeSecrets = {
|
|
887
|
+
betterAuth: secret(),
|
|
888
|
+
aiEncryption: secret(),
|
|
889
|
+
storageUpload: secret(),
|
|
890
|
+
cron: secret(),
|
|
891
|
+
setup: secret()
|
|
892
|
+
};
|
|
893
|
+
const env = [
|
|
894
|
+
`HARLY_URL=${envLine(url.origin)}`,
|
|
895
|
+
`HARLY_INITIAL_ADMIN_EMAIL=${envLine(email)}`,
|
|
896
|
+
`BETTER_AUTH_SECRET=${envLine(runtimeSecrets.betterAuth)}`,
|
|
897
|
+
`AI_ENCRYPTION_KEY=${envLine(runtimeSecrets.aiEncryption)}`,
|
|
898
|
+
`STORAGE_UPLOAD_SECRET=${envLine(runtimeSecrets.storageUpload)}`,
|
|
899
|
+
`CRON_SECRET=${envLine(runtimeSecrets.cron)}`,
|
|
900
|
+
`HARLY_SETUP_SECRET=${envLine(runtimeSecrets.setup)}`,
|
|
901
|
+
'STORAGE_PROVIDER="s3"',
|
|
902
|
+
`S3_BUCKET=${envLine(bucket)}`,
|
|
903
|
+
`S3_REGION=${envLine(region)}`,
|
|
904
|
+
`S3_ACCESS_KEY_ID=${envLine(accessKey)}`,
|
|
905
|
+
`S3_SECRET_ACCESS_KEY=${envLine(secretKey)}`,
|
|
906
|
+
...databaseUrl ? [`DATABASE_URL=${envLine(databaseUrl)}`] : []
|
|
907
|
+
].join("\n") + "\n";
|
|
908
|
+
await atomicWrite(path.join(directory, ".env"), env, 384);
|
|
909
|
+
const image = "ghcr.io/vytral/harly:0.1.0-beta.2";
|
|
910
|
+
if (provider === "fly") {
|
|
911
|
+
await atomicWrite(path.join(directory, "fly.toml"), `app = "replace-with-your-harly-app-name"
|
|
912
|
+
primary_region = "iad"
|
|
913
|
+
|
|
914
|
+
[build]
|
|
915
|
+
image = "${image}"
|
|
916
|
+
|
|
917
|
+
[processes]
|
|
918
|
+
web = "serve"
|
|
919
|
+
scheduler = "scheduler"
|
|
920
|
+
|
|
921
|
+
[deploy]
|
|
922
|
+
release_command = "migrate"
|
|
923
|
+
|
|
924
|
+
[http_service]
|
|
925
|
+
processes = ["web"]
|
|
926
|
+
internal_port = 3000
|
|
927
|
+
force_https = true
|
|
928
|
+
auto_stop_machines = "off"
|
|
929
|
+
auto_start_machines = true
|
|
930
|
+
min_machines_running = 1
|
|
931
|
+
|
|
932
|
+
[[http_service.checks]]
|
|
933
|
+
grace_period = "20s"
|
|
934
|
+
interval = "30s"
|
|
935
|
+
timeout = "5s"
|
|
936
|
+
method = "GET"
|
|
937
|
+
path = "/api/health/ready"
|
|
938
|
+
`);
|
|
939
|
+
}
|
|
940
|
+
if (provider === "digitalocean") {
|
|
941
|
+
const yaml = (value) => JSON.stringify(value);
|
|
942
|
+
const secretEnv = (key, value) => ` - { key: ${key}, scope: RUN_TIME, type: SECRET, value: ${yaml(value)} }`;
|
|
943
|
+
const publicEnv = (key, value) => ` - { key: ${key}, scope: RUN_TIME, type: GENERAL, value: ${yaml(value)} }`;
|
|
944
|
+
const appSpec = [
|
|
945
|
+
"# Generated by the Harly CLI. This file contains secrets: keep it outside Git.",
|
|
946
|
+
"name: harly",
|
|
947
|
+
"region: nyc",
|
|
948
|
+
"",
|
|
949
|
+
"envs:",
|
|
950
|
+
secretEnv("DATABASE_URL", databaseUrl),
|
|
951
|
+
publicEnv("HARLY_URL", url.origin),
|
|
952
|
+
publicEnv("HARLY_INITIAL_ADMIN_EMAIL", email),
|
|
953
|
+
publicEnv("STORAGE_PROVIDER", "s3"),
|
|
954
|
+
publicEnv("S3_BUCKET", bucket),
|
|
955
|
+
publicEnv("S3_REGION", region),
|
|
956
|
+
secretEnv("S3_ACCESS_KEY_ID", accessKey),
|
|
957
|
+
secretEnv("S3_SECRET_ACCESS_KEY", secretKey),
|
|
958
|
+
secretEnv("BETTER_AUTH_SECRET", runtimeSecrets.betterAuth),
|
|
959
|
+
secretEnv("AI_ENCRYPTION_KEY", runtimeSecrets.aiEncryption),
|
|
960
|
+
secretEnv("STORAGE_UPLOAD_SECRET", runtimeSecrets.storageUpload),
|
|
961
|
+
secretEnv("CRON_SECRET", runtimeSecrets.cron),
|
|
962
|
+
secretEnv("HARLY_SETUP_SECRET", runtimeSecrets.setup),
|
|
963
|
+
"services:",
|
|
964
|
+
" - name: web",
|
|
965
|
+
" image: { registry_type: GHCR, registry: vytral, repository: harly, tag: 0.1.0-beta.1 }",
|
|
966
|
+
" run_command: node /app/runtime.mjs serve",
|
|
967
|
+
" http_port: 3000",
|
|
968
|
+
" instance_count: 1",
|
|
969
|
+
" instance_size_slug: apps-s-1vcpu-1gb",
|
|
970
|
+
" health_check: { http_path: /api/health/ready, port: 3000, initial_delay_seconds: 20, period_seconds: 30, timeout_seconds: 5, failure_threshold: 5 }",
|
|
971
|
+
"workers:",
|
|
972
|
+
" - name: scheduler",
|
|
973
|
+
" image: { registry_type: GHCR, registry: vytral, repository: harly, tag: 0.1.0-beta.1 }",
|
|
974
|
+
" run_command: node /app/runtime.mjs scheduler",
|
|
975
|
+
" instance_count: 1",
|
|
976
|
+
" instance_size_slug: apps-s-1vcpu-0.5gb",
|
|
977
|
+
"jobs:",
|
|
978
|
+
" - name: migrate",
|
|
979
|
+
" kind: PRE_DEPLOY",
|
|
980
|
+
" image: { registry_type: GHCR, registry: vytral, repository: harly, tag: 0.1.0-beta.1 }",
|
|
981
|
+
" run_command: node /app/runtime.mjs migrate",
|
|
982
|
+
" instance_size_slug: apps-s-1vcpu-0.5gb",
|
|
983
|
+
""
|
|
984
|
+
].join("\n");
|
|
985
|
+
await atomicWrite(path.join(directory, "app.yaml"), appSpec, 384);
|
|
986
|
+
}
|
|
987
|
+
const next = provider === "railway" ? "Create the app, scheduler, and managed PostgreSQL services in Railway; enter the values saved in .env and use the cloud deployment guide for their commands." : provider === "fly" ? `Run \`fly launch --no-deploy\` in ${shellQuote(directory)}, attach Managed Postgres, import .env as Fly secrets, then run \`fly deploy\`.` : `The generated app spec already includes your Managed PostgreSQL URL as an encrypted app-level secret. Deploy with \`doctl apps create --spec ${shellQuote(path.join(directory, "app.yaml"))}\`.`;
|
|
988
|
+
p.note(`Image ${image}
|
|
989
|
+
Secrets ${path.join(directory, ".env")} (mode 0600)${provider === "digitalocean" ? `
|
|
990
|
+
App spec ${path.join(directory, "app.yaml")} (mode 0600)` : ""}
|
|
991
|
+
Storage S3 required
|
|
992
|
+
|
|
993
|
+
${next}`, "Cloud deployment prepared");
|
|
994
|
+
p.outro("Your configuration is ready. Never commit generated secret files.");
|
|
995
|
+
}
|
|
996
|
+
async function menu() {
|
|
997
|
+
const installation = await findInstallation();
|
|
998
|
+
if (!interactive) {
|
|
999
|
+
if (installation) return doctor(installation.directory);
|
|
1000
|
+
usage();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
showBrand();
|
|
1004
|
+
if (!installation) {
|
|
1005
|
+
p.intro(pc.bgCyan(pc.black(" Welcome to Harly ")));
|
|
1006
|
+
const choice2 = unwrapPrompt(await p.select({ message: "What would you like to do?", options: [
|
|
1007
|
+
{ value: "install", label: "Install Harly on this server", hint: "Docker + automatic HTTPS" },
|
|
1008
|
+
{ value: "railway", label: "Deploy on Railway", hint: "managed Postgres + S3" },
|
|
1009
|
+
{ value: "fly", label: "Deploy on Fly.io", hint: "managed Postgres + S3" },
|
|
1010
|
+
{ value: "digitalocean", label: "Deploy on DigitalOcean", hint: "App Platform + managed Postgres + S3" },
|
|
1011
|
+
{ value: "help", label: "Show advanced commands" }
|
|
1012
|
+
] }));
|
|
1013
|
+
if (choice2 === "install") return init();
|
|
1014
|
+
if (choice2 === "railway" || choice2 === "fly" || choice2 === "digitalocean") return cloudGuide(choice2);
|
|
1015
|
+
usage();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
p.intro(pc.bgCyan(pc.black(" Harly management ")));
|
|
1019
|
+
p.note(`${installation.config.publicUrl}
|
|
1020
|
+
${installation.config.image}
|
|
1021
|
+
${installation.directory}`, "Detected installation");
|
|
1022
|
+
const choice = unwrapPrompt(await p.select({ message: "Choose an action", options: [
|
|
1023
|
+
{ value: "status", label: "Status" },
|
|
1024
|
+
{ value: "update", label: "Update Harly" },
|
|
1025
|
+
{ value: "backup", label: "Create backup" },
|
|
1026
|
+
{ value: "restore", label: "Restore backup" },
|
|
1027
|
+
{ value: "uninstall", label: "Stop or uninstall Harly" }
|
|
1028
|
+
] }));
|
|
1029
|
+
if (choice === "status") return doctor(installation.directory);
|
|
1030
|
+
if (choice === "update") return upgrade(installation.directory);
|
|
1031
|
+
if (choice === "backup") return backup(installation.directory).then(() => void 0);
|
|
1032
|
+
if (choice === "restore") {
|
|
1033
|
+
p.log.info("Use `harly restore <archive> --force` for restore. Local rollback archives work without extra dependencies; encrypted `.age` archives are an advanced option.");
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
return uninstall(installation.directory);
|
|
1037
|
+
}
|
|
1038
|
+
async function main() {
|
|
1039
|
+
switch (command) {
|
|
1040
|
+
case "menu":
|
|
1041
|
+
return menu();
|
|
1042
|
+
case "init":
|
|
1043
|
+
return init();
|
|
1044
|
+
case "launch":
|
|
1045
|
+
return launch();
|
|
1046
|
+
case "doctor":
|
|
1047
|
+
return doctor();
|
|
1048
|
+
case "backup":
|
|
1049
|
+
return backup();
|
|
1050
|
+
case "restore":
|
|
1051
|
+
return restore();
|
|
1052
|
+
case "upgrade":
|
|
1053
|
+
case "update":
|
|
1054
|
+
return upgrade();
|
|
1055
|
+
case "uninstall":
|
|
1056
|
+
return uninstall();
|
|
1057
|
+
case "help":
|
|
1058
|
+
case "--help":
|
|
1059
|
+
case "-h":
|
|
1060
|
+
usage();
|
|
1061
|
+
return;
|
|
1062
|
+
default:
|
|
1063
|
+
usage();
|
|
1064
|
+
throw new CliError(`Unknown command: ${command}`, 2);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
main().catch((error) => {
|
|
1068
|
+
const cliError = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : "Unexpected failure.");
|
|
1069
|
+
if (cliError.message) process.stderr.write(`${cliError.message}
|
|
1070
|
+
`);
|
|
1071
|
+
process.exitCode = cliError.exitCode;
|
|
1072
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harly/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Official operations CLI for self-hosted Harly.",
|
|
@@ -14,10 +14,19 @@
|
|
|
14
14
|
"files": ["dist", "README.md"],
|
|
15
15
|
"bin": { "harly": "dist/index.js" },
|
|
16
16
|
"scripts": {
|
|
17
|
-
"build": "node
|
|
18
|
-
"typecheck": "
|
|
19
|
-
"test": "node --test test/*.test.mjs",
|
|
20
|
-
"
|
|
17
|
+
"build": "esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --target=node20 --outfile=dist/index.js",
|
|
18
|
+
"typecheck": "tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --types node src/index.ts",
|
|
19
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
20
|
+
"test:backup-restore": "pnpm build && ./test/backup-restore.destructive.sh",
|
|
21
|
+
"pack:check": "pnpm build && npm pack --dry-run"
|
|
21
22
|
},
|
|
22
|
-
"
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20",
|
|
25
|
+
"esbuild": "^0.27.7",
|
|
26
|
+
"typescript": "^6.0.3"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@clack/prompts": "^1.7.0",
|
|
30
|
+
"picocolors": "^1.1.1"
|
|
31
|
+
}
|
|
23
32
|
}
|