@fabioplunser/epd 0.1.0

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/cli.js ADDED
@@ -0,0 +1,3855 @@
1
+ // @bun
2
+ // src/cli.ts
3
+ import { parseArgs } from "util";
4
+
5
+ // src/util/log.ts
6
+ var useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
7
+ var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
8
+ var c = {
9
+ dim: wrap("2"),
10
+ bold: wrap("1"),
11
+ red: wrap("31"),
12
+ green: wrap("32"),
13
+ yellow: wrap("33"),
14
+ blue: wrap("34"),
15
+ magenta: wrap("35"),
16
+ cyan: wrap("36"),
17
+ gray: wrap("90")
18
+ };
19
+ var verbose = false;
20
+ function setVerbose(v) {
21
+ verbose = v;
22
+ }
23
+ var started = Date.now();
24
+ function stamp() {
25
+ const s = ((Date.now() - started) / 1000).toFixed(1).padStart(5, " ");
26
+ return c.gray(`${s}s`);
27
+ }
28
+ var log = {
29
+ step(msg) {
30
+ console.log(`${stamp()} ${c.blue("\u203A")} ${c.bold(msg)}`);
31
+ },
32
+ info(msg) {
33
+ console.log(`${stamp()} ${msg}`);
34
+ },
35
+ ok(msg) {
36
+ console.log(`${stamp()} ${c.green("\u2713")} ${msg}`);
37
+ },
38
+ warn(msg) {
39
+ console.log(`${stamp()} ${c.yellow("!")} ${msg}`);
40
+ },
41
+ error(msg) {
42
+ console.error(`${stamp()} ${c.red("\u2717")} ${msg}`);
43
+ },
44
+ debug(msg) {
45
+ if (verbose)
46
+ console.log(`${stamp()} ${c.gray("\xB7")} ${c.gray(msg)}`);
47
+ },
48
+ plain(msg) {
49
+ console.log(msg);
50
+ },
51
+ host(host, msg) {
52
+ const p = c.magenta(host.padEnd(15).slice(0, 15));
53
+ for (const line of msg.split(`
54
+ `))
55
+ if (line.length)
56
+ console.log(`${stamp()} ${p} ${line}`);
57
+ }
58
+ };
59
+
60
+ class EpdError extends Error {
61
+ hint;
62
+ constructor(message, hint) {
63
+ super(message);
64
+ this.hint = hint;
65
+ this.name = "EpdError";
66
+ }
67
+ }
68
+
69
+ // src/config/load.ts
70
+ import { existsSync, readFileSync } from "fs";
71
+ import { dirname, join, resolve } from "path";
72
+ import { homedir } from "os";
73
+
74
+ // src/util/misc.ts
75
+ function hash32(s) {
76
+ let h = 2166136261;
77
+ for (let i = 0;i < s.length; i++) {
78
+ h ^= s.charCodeAt(i);
79
+ h = Math.imul(h, 16777619) >>> 0;
80
+ }
81
+ return h >>> 0;
82
+ }
83
+ function shq(s) {
84
+ return `'${String(s).replaceAll("'", `'\\''`)}'`;
85
+ }
86
+ async function pool(items, limit, fn) {
87
+ const out = new Array(items.length);
88
+ let next = 0;
89
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
90
+ for (;; ) {
91
+ const i = next++;
92
+ if (i >= items.length)
93
+ return;
94
+ out[i] = await fn(items[i], i);
95
+ }
96
+ });
97
+ await Promise.all(workers);
98
+ return out;
99
+ }
100
+ function timestamp() {
101
+ const d = new Date;
102
+ const p = (n, w = 2) => String(n).padStart(w, "0");
103
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` + `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
104
+ }
105
+ function dur(ms) {
106
+ if (ms < 1000)
107
+ return `${ms}ms`;
108
+ const s = ms / 1000;
109
+ if (s < 60)
110
+ return `${s.toFixed(1)}s`;
111
+ return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
112
+ }
113
+
114
+ // src/config/load.ts
115
+ var CONFIG_NAMES = ["epd.yml", "epd.yaml", ".epd.yml", "config/epd.yml"];
116
+ function findConfigFile(startDir) {
117
+ let dir = resolve(startDir);
118
+ for (;; ) {
119
+ for (const name of CONFIG_NAMES) {
120
+ const p = join(dir, name);
121
+ if (existsSync(p))
122
+ return p;
123
+ }
124
+ const parent = dirname(dir);
125
+ if (parent === dir)
126
+ return null;
127
+ dir = parent;
128
+ }
129
+ }
130
+ function parseDotenv(text) {
131
+ const out = {};
132
+ for (let line of text.split(`
133
+ `)) {
134
+ line = line.trim();
135
+ if (!line || line.startsWith("#"))
136
+ continue;
137
+ if (line.startsWith("export "))
138
+ line = line.slice(7).trim();
139
+ const eq = line.indexOf("=");
140
+ if (eq === -1)
141
+ continue;
142
+ const key = line.slice(0, eq).trim();
143
+ let val = line.slice(eq + 1).trim();
144
+ if (val.startsWith('"') && val.endsWith('"') && val.length > 1) {
145
+ val = val.slice(1, -1).replaceAll("\\n", `
146
+ `).replaceAll("\\\"", '"');
147
+ } else if (val.startsWith("'") && val.endsWith("'") && val.length > 1) {
148
+ val = val.slice(1, -1);
149
+ } else {
150
+ const hash = val.indexOf(" #");
151
+ if (hash !== -1)
152
+ val = val.slice(0, hash).trim();
153
+ }
154
+ if (key)
155
+ out[key] = val;
156
+ }
157
+ return out;
158
+ }
159
+ function loadEnvironment(root, destination) {
160
+ const files = [".env", ".env.local"];
161
+ if (destination)
162
+ files.push(`.env.${destination}`, `.env.${destination}.local`);
163
+ const env = {};
164
+ for (const [k, v] of Object.entries(process.env))
165
+ if (v !== undefined)
166
+ env[k] = v;
167
+ for (const f of files) {
168
+ const p = join(root, f);
169
+ if (!existsSync(p))
170
+ continue;
171
+ for (const [k, v] of Object.entries(parseDotenv(readFileSync(p, "utf8")))) {
172
+ if (process.env[k] === undefined)
173
+ env[k] = v;
174
+ }
175
+ }
176
+ return env;
177
+ }
178
+ function interpolateText(text, env, file) {
179
+ return text.replace(/\$(\$?)\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_m, escape, name, dflt) => {
180
+ if (escape)
181
+ return `\${${name}${dflt !== undefined ? `:-${dflt}` : ""}}`;
182
+ const v = env[name];
183
+ if (v !== undefined && v !== "")
184
+ return v;
185
+ if (dflt !== undefined)
186
+ return dflt;
187
+ throw new EpdError(`${file}: environment variable \${${name}} is not set`, `Export it, add it to .env, or write \${${name}:-fallback}.`);
188
+ });
189
+ }
190
+ function deepMerge(base, overlay) {
191
+ const out = Array.isArray(base) ? [...base] : { ...base };
192
+ for (const [k, v] of Object.entries(overlay)) {
193
+ const prev = out[k];
194
+ if (v && typeof v === "object" && !Array.isArray(v) && prev && typeof prev === "object" && !Array.isArray(prev)) {
195
+ out[k] = deepMerge(prev, v);
196
+ } else {
197
+ out[k] = v;
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ function fail(path, msg, hint) {
203
+ throw new EpdError(`${path}: ${msg}`, hint);
204
+ }
205
+ function asString(v, path, dflt) {
206
+ if (v === undefined || v === null) {
207
+ if (dflt !== undefined)
208
+ return dflt;
209
+ fail(path, "is required");
210
+ }
211
+ if (typeof v === "string")
212
+ return v;
213
+ if (typeof v === "number" || typeof v === "boolean")
214
+ return String(v);
215
+ fail(path, `expected a string, got ${Array.isArray(v) ? "a list" : typeof v}`);
216
+ }
217
+ function asNumber(v, path, dflt) {
218
+ if (v === undefined || v === null) {
219
+ if (dflt !== undefined)
220
+ return dflt;
221
+ fail(path, "is required");
222
+ }
223
+ const n = typeof v === "number" ? v : Number(v);
224
+ if (!Number.isFinite(n))
225
+ fail(path, `expected a number, got ${JSON.stringify(v)}`);
226
+ return n;
227
+ }
228
+ function asBool(v, path, dflt) {
229
+ if (v === undefined || v === null)
230
+ return dflt;
231
+ if (typeof v === "boolean")
232
+ return v;
233
+ if (v === "true" || v === "yes" || v === 1)
234
+ return true;
235
+ if (v === "false" || v === "no" || v === 0)
236
+ return false;
237
+ fail(path, `expected true or false, got ${JSON.stringify(v)}`);
238
+ }
239
+ function asList(v, path) {
240
+ if (v === undefined || v === null)
241
+ return [];
242
+ if (typeof v === "string")
243
+ return [v];
244
+ if (Array.isArray(v))
245
+ return v.map((x, i) => asString(x, `${path}[${i}]`));
246
+ fail(path, "expected a string or a list of strings");
247
+ }
248
+ function asDict(v, path) {
249
+ if (v === undefined || v === null)
250
+ return {};
251
+ if (Array.isArray(v)) {
252
+ const out = {};
253
+ for (const [i, item] of v.entries()) {
254
+ const s = asString(item, `${path}[${i}]`);
255
+ const eq = s.indexOf("=");
256
+ if (eq === -1)
257
+ fail(`${path}[${i}]`, `expected "KEY=value", got ${JSON.stringify(s)}`);
258
+ out[s.slice(0, eq)] = s.slice(eq + 1);
259
+ }
260
+ return out;
261
+ }
262
+ if (typeof v === "object") {
263
+ const out = {};
264
+ for (const [k, val] of Object.entries(v))
265
+ out[k] = asString(val, `${path}.${k}`);
266
+ return out;
267
+ }
268
+ fail(path, "expected a mapping");
269
+ }
270
+ function checkKeys(obj, allowed, path) {
271
+ for (const k of Object.keys(obj)) {
272
+ if (!allowed.includes(k)) {
273
+ const near = allowed.map((a) => [a, levenshtein(a, k)]).sort((a, b) => a[1] - b[1]).filter(([, d]) => d <= 3)[0];
274
+ fail(`${path}.${k}`, "is not a known option", near ? `Did you mean "${near[0]}"?` : `Known options: ${allowed.join(", ")}`);
275
+ }
276
+ }
277
+ }
278
+ function levenshtein(a, b) {
279
+ const m = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
280
+ for (let i = 0;i <= a.length; i++)
281
+ m[i][0] = i;
282
+ for (let j = 0;j <= b.length; j++)
283
+ m[0][j] = j;
284
+ for (let i = 1;i <= a.length; i++)
285
+ for (let j = 1;j <= b.length; j++)
286
+ m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
287
+ return m[a.length][b.length];
288
+ }
289
+ function expandHome(p) {
290
+ return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
291
+ }
292
+ function parseRoute(raw, path, defaults) {
293
+ if (typeof raw === "string") {
294
+ const s = raw.trim();
295
+ const slash = s.indexOf("/");
296
+ const host = slash === -1 ? s : s.slice(0, slash);
297
+ const p = slash === -1 ? undefined : s.slice(slash);
298
+ return {
299
+ hosts: host === "*" || host === "" ? [] : [host],
300
+ path: p && p !== "/" ? p : undefined,
301
+ stripPath: false,
302
+ port: defaults.port,
303
+ ssl: defaults.ssl && host !== "*" && host !== "",
304
+ headers: {},
305
+ basicAuth: [],
306
+ sticky: false
307
+ };
308
+ }
309
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
310
+ fail(path, "expected a domain string or a mapping");
311
+ const o = raw;
312
+ checkKeys(o, [
313
+ "host",
314
+ "hosts",
315
+ "domain",
316
+ "domains",
317
+ "path",
318
+ "strip_path",
319
+ "port",
320
+ "entrypoint",
321
+ "ssl",
322
+ "redirect",
323
+ "permanent",
324
+ "headers",
325
+ "basic_auth",
326
+ "priority",
327
+ "sticky"
328
+ ], path);
329
+ const hosts = [
330
+ ...asList(o.host ?? o.domain, `${path}.host`),
331
+ ...asList(o.hosts ?? o.domains, `${path}.hosts`)
332
+ ].filter((h) => h !== "*");
333
+ const redirectTo = o.redirect ? asString(o.redirect, `${path}.redirect`) : undefined;
334
+ return {
335
+ hosts,
336
+ path: o.path ? asString(o.path, `${path}.path`) : undefined,
337
+ stripPath: asBool(o.strip_path, `${path}.strip_path`, false),
338
+ port: asNumber(o.port, `${path}.port`, defaults.port),
339
+ entrypoint: o.entrypoint ? asString(o.entrypoint, `${path}.entrypoint`) : undefined,
340
+ ssl: asBool(o.ssl, `${path}.ssl`, defaults.ssl && hosts.length > 0),
341
+ redirect: redirectTo ? { to: redirectTo, permanent: asBool(o.permanent, `${path}.permanent`, true) } : undefined,
342
+ headers: asDict(o.headers, `${path}.headers`),
343
+ basicAuth: asList(o.basic_auth, `${path}.basic_auth`),
344
+ priority: o.priority !== undefined ? asNumber(o.priority, `${path}.priority`) : undefined,
345
+ sticky: asBool(o.sticky, `${path}.sticky`, false)
346
+ };
347
+ }
348
+ function parseHealth(raw, path, dflt) {
349
+ if (raw === undefined || raw === null)
350
+ return dflt;
351
+ if (raw === false)
352
+ return { ...dflt, path: null };
353
+ if (typeof raw === "string")
354
+ return { ...dflt, path: raw };
355
+ if (typeof raw !== "object")
356
+ fail(path, "expected a mapping, a path string, or false");
357
+ const o = raw;
358
+ checkKeys(o, ["path", "port", "status", "timeout", "interval", "delay", "host"], path);
359
+ return {
360
+ path: o.path === false || o.path === null ? null : asString(o.path, `${path}.path`, dflt.path ?? "/"),
361
+ port: o.port !== undefined ? asNumber(o.port, `${path}.port`) : dflt.port,
362
+ status: asString(o.status, `${path}.status`, dflt.status),
363
+ timeout: asNumber(o.timeout, `${path}.timeout`, dflt.timeout),
364
+ interval: asNumber(o.interval, `${path}.interval`, dflt.interval),
365
+ delay: asNumber(o.delay, `${path}.delay`, dflt.delay),
366
+ hostHeader: o.host ? asString(o.host, `${path}.host`) : dflt.hostHeader
367
+ };
368
+ }
369
+ function loadConfig(opts = {}) {
370
+ const cwd = opts.cwd ?? process.cwd();
371
+ const configPath = opts.file ? resolve(cwd, opts.file) : findConfigFile(cwd) ?? fail("epd.yml", "not found", "Run `epd init` to create one.");
372
+ if (!existsSync(configPath))
373
+ throw new EpdError(`config file not found: ${configPath}`);
374
+ const root = dirname(configPath);
375
+ const destination = opts.destination;
376
+ const env = loadEnvironment(root, destination);
377
+ let raw = parseYaml(configPath, env);
378
+ if (destination) {
379
+ const overlays = [
380
+ join(root, `epd.${destination}.yml`),
381
+ join(root, `epd.${destination}.yaml`),
382
+ join(root, "config", `epd.${destination}.yml`)
383
+ ].filter(existsSync);
384
+ if (!overlays.length) {
385
+ throw new EpdError(`no config found for destination "${destination}"`, `Create ${join(root, `epd.${destination}.yml`)} with the values that differ.`);
386
+ }
387
+ raw = deepMerge(raw, parseYaml(overlays[0], env));
388
+ }
389
+ return { config: normalize(raw, configPath, root, destination), env, raw };
390
+ }
391
+ function parseYaml(file, env) {
392
+ const text = interpolateText(readFileSync(file, "utf8"), env, file);
393
+ let parsed;
394
+ try {
395
+ parsed = Bun.YAML.parse(text);
396
+ } catch (e) {
397
+ throw new EpdError(`${file}: invalid YAML`, e instanceof Error ? e.message : String(e));
398
+ }
399
+ if (parsed === null || parsed === undefined)
400
+ return {};
401
+ if (typeof parsed !== "object" || Array.isArray(parsed))
402
+ throw new EpdError(`${file}: expected a mapping at the top level`);
403
+ return parsed;
404
+ }
405
+ var TOP_LEVEL_KEYS = [
406
+ "name",
407
+ "mode",
408
+ "image",
409
+ "registry",
410
+ "build",
411
+ "ssh",
412
+ "servers",
413
+ "services",
414
+ "proxy",
415
+ "accessories",
416
+ "env",
417
+ "volumes",
418
+ "hooks",
419
+ "process",
420
+ "port_base",
421
+ "strategy",
422
+ "keep_releases",
423
+ "remote_root",
424
+ "hosts",
425
+ "host",
426
+ "replicas",
427
+ "port",
428
+ "domain",
429
+ "domains",
430
+ "routes",
431
+ "command",
432
+ "healthcheck",
433
+ "health",
434
+ "labels",
435
+ "drain",
436
+ "stop_timeout",
437
+ "docker_options"
438
+ ];
439
+ function normalize(raw, configPath, root, destination) {
440
+ checkKeys(raw, TOP_LEVEL_KEYS, "epd.yml");
441
+ const name = asString(raw.name, "epd.yml.name");
442
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
443
+ fail("epd.yml.name", "must be alphanumeric with dashes or underscores", `Got ${JSON.stringify(name)}.`);
444
+ }
445
+ const mode = asString(raw.mode, "epd.yml.mode", "docker");
446
+ if (mode !== "docker" && mode !== "process")
447
+ fail("epd.yml.mode", `expected "docker" or "process", got ${JSON.stringify(mode)}`);
448
+ const proxyRaw = raw.proxy ?? {};
449
+ checkKeys(proxyRaw, [
450
+ "enabled",
451
+ "image",
452
+ "version",
453
+ "network",
454
+ "ssl",
455
+ "email",
456
+ "challenge",
457
+ "dns_provider",
458
+ "dns_env",
459
+ "acme_server",
460
+ "entrypoints",
461
+ "https_redirect",
462
+ "dashboard",
463
+ "dashboard_host",
464
+ "log_level",
465
+ "access_log",
466
+ "cross_host",
467
+ "trusted_ips",
468
+ "private_ips",
469
+ "reload_wait",
470
+ "extra"
471
+ ], "epd.yml.proxy");
472
+ const ssl = asBool(proxyRaw.ssl, "epd.yml.proxy.ssl", true);
473
+ const entrypoints = {
474
+ web: 80,
475
+ ...ssl ? { websecure: 443 } : {}
476
+ };
477
+ for (const [k, v] of Object.entries(asDict(proxyRaw.entrypoints, "epd.yml.proxy.entrypoints"))) {
478
+ entrypoints[k] = asNumber(v, `epd.yml.proxy.entrypoints.${k}`);
479
+ }
480
+ const proxy = {
481
+ enabled: asBool(proxyRaw.enabled, "epd.yml.proxy.enabled", true),
482
+ image: asString(proxyRaw.image, "epd.yml.proxy.image", "traefik:v3.3"),
483
+ version: proxyRaw.version ? asString(proxyRaw.version, "epd.yml.proxy.version") : undefined,
484
+ network: asString(proxyRaw.network, "epd.yml.proxy.network", "epd"),
485
+ ssl,
486
+ email: proxyRaw.email ? asString(proxyRaw.email, "epd.yml.proxy.email") : undefined,
487
+ challenge: asString(proxyRaw.challenge, "epd.yml.proxy.challenge", "http"),
488
+ dnsProvider: proxyRaw.dns_provider ? asString(proxyRaw.dns_provider, "epd.yml.proxy.dns_provider") : undefined,
489
+ dnsEnv: asList(proxyRaw.dns_env, "epd.yml.proxy.dns_env"),
490
+ acmeServer: proxyRaw.acme_server ? asString(proxyRaw.acme_server, "epd.yml.proxy.acme_server") : undefined,
491
+ entrypoints,
492
+ httpsRedirect: asBool(proxyRaw.https_redirect, "epd.yml.proxy.https_redirect", ssl),
493
+ dashboard: asBool(proxyRaw.dashboard, "epd.yml.proxy.dashboard", false),
494
+ dashboardHost: proxyRaw.dashboard_host ? asString(proxyRaw.dashboard_host, "epd.yml.proxy.dashboard_host") : undefined,
495
+ logLevel: asString(proxyRaw.log_level, "epd.yml.proxy.log_level", "INFO"),
496
+ accessLog: asBool(proxyRaw.access_log, "epd.yml.proxy.access_log", false),
497
+ crossHost: asBool(proxyRaw.cross_host, "epd.yml.proxy.cross_host", false),
498
+ reloadWait: asNumber(proxyRaw.reload_wait, "epd.yml.proxy.reload_wait", 3),
499
+ trustedIps: asList(proxyRaw.trusted_ips, "epd.yml.proxy.trusted_ips"),
500
+ privateIps: asDict(proxyRaw.private_ips, "epd.yml.proxy.private_ips"),
501
+ extra: proxyRaw.extra ?? {}
502
+ };
503
+ if (!["http", "dns", "tlsalpn"].includes(proxy.challenge)) {
504
+ fail("epd.yml.proxy.challenge", `expected "http", "dns" or "tlsalpn", got ${JSON.stringify(proxy.challenge)}`);
505
+ }
506
+ if (proxy.challenge === "dns" && !proxy.dnsProvider) {
507
+ fail("epd.yml.proxy.dns_provider", 'is required when challenge is "dns"', "For example: dns_provider: cloudflare");
508
+ }
509
+ const globalEnv = raw.env;
510
+ const envClear = asDict(globalEnv?.clear ?? (globalEnv && !("clear" in globalEnv) && !("secret" in globalEnv) ? globalEnv : {}), "epd.yml.env");
511
+ const envSecret = asList(globalEnv?.secret, "epd.yml.env.secret");
512
+ const defaultHealth = {
513
+ path: "/",
514
+ status: "200-399",
515
+ timeout: 60,
516
+ interval: 2,
517
+ delay: 1
518
+ };
519
+ const rootHealth = parseHealth(raw.healthcheck ?? raw.health, "epd.yml.healthcheck", defaultHealth);
520
+ let servicesRaw = raw.services ?? raw.servers;
521
+ const inlineKeys = ["hosts", "host", "replicas", "port", "domain", "domains", "routes", "command", "labels", "volumes", "drain", "stop_timeout", "docker_options"];
522
+ const hasInline = inlineKeys.some((k) => raw[k] !== undefined);
523
+ if (hasInline) {
524
+ const inline = {};
525
+ for (const k of inlineKeys)
526
+ if (raw[k] !== undefined)
527
+ inline[k] = raw[k];
528
+ if (raw.healthcheck ?? raw.health)
529
+ inline.healthcheck = raw.healthcheck ?? raw.health;
530
+ servicesRaw = { web: inline, ...servicesRaw ?? {} };
531
+ }
532
+ if (!servicesRaw || !Object.keys(servicesRaw).length) {
533
+ fail("epd.yml.servers", "no servers defined", `Add:
534
+ servers:
535
+ web:
536
+ hosts: [1.2.3.4]
537
+ port: 3000
538
+ domains: [example.com]`);
539
+ }
540
+ const services = Object.entries(servicesRaw).map(([svcName, rawSvc]) => {
541
+ const p = `epd.yml.servers.${svcName}`;
542
+ let o;
543
+ if (Array.isArray(rawSvc))
544
+ o = { hosts: rawSvc };
545
+ else if (typeof rawSvc === "string")
546
+ o = { hosts: [rawSvc] };
547
+ else if (rawSvc && typeof rawSvc === "object")
548
+ o = rawSvc;
549
+ else
550
+ return fail(p, "expected a mapping or a list of hosts");
551
+ checkKeys(o, [
552
+ "hosts",
553
+ "host",
554
+ "replicas",
555
+ "port",
556
+ "command",
557
+ "routes",
558
+ "domain",
559
+ "domains",
560
+ "env",
561
+ "volumes",
562
+ "labels",
563
+ "healthcheck",
564
+ "health",
565
+ "docker_options",
566
+ "cpus",
567
+ "memory",
568
+ "drain",
569
+ "stop_timeout"
570
+ ], p);
571
+ const hosts = [...asList(o.host, `${p}.host`), ...asList(o.hosts, `${p}.hosts`)];
572
+ if (!hosts.length)
573
+ fail(`${p}.hosts`, "at least one host is required");
574
+ const port = asNumber(o.port, `${p}.port`, asNumber(raw.port, "epd.yml.port", 3000));
575
+ const routes = [];
576
+ for (const d of [...asList(o.domain, `${p}.domain`), ...asList(o.domains, `${p}.domains`)]) {
577
+ routes.push(parseRoute(d, `${p}.domains`, { port, ssl: proxy.ssl }));
578
+ }
579
+ const rawRoutes = o.routes === undefined ? [] : Array.isArray(o.routes) ? o.routes : [o.routes];
580
+ rawRoutes.forEach((r, i) => routes.push(parseRoute(r, `${p}.routes[${i}]`, { port, ssl: proxy.ssl })));
581
+ const svcEnvRaw = o.env;
582
+ const svcEnv = asDict(svcEnvRaw?.clear ?? (svcEnvRaw && !("clear" in svcEnvRaw) && !("secret" in svcEnvRaw) ? svcEnvRaw : {}), `${p}.env`);
583
+ return {
584
+ name: svcName,
585
+ hosts,
586
+ replicas: asNumber(o.replicas, `${p}.replicas`, 1),
587
+ port,
588
+ command: o.command === undefined ? undefined : asString(o.command, `${p}.command`),
589
+ routes,
590
+ env: { ...envClear, ...svcEnv },
591
+ secrets: [...envSecret, ...asList(svcEnvRaw?.secret, `${p}.env.secret`)],
592
+ volumes: [...asList(raw.volumes, "epd.yml.volumes"), ...asList(o.volumes, `${p}.volumes`)],
593
+ labels: { ...asDict(raw.labels, "epd.yml.labels"), ...asDict(o.labels, `${p}.labels`) },
594
+ health: parseHealth(o.healthcheck ?? o.health, `${p}.healthcheck`, { ...rootHealth, port: rootHealth.port ?? port }),
595
+ dockerOptions: [...asList(raw.docker_options, "epd.yml.docker_options"), ...asList(o.docker_options, `${p}.docker_options`)],
596
+ cpus: o.cpus ? asString(o.cpus, `${p}.cpus`) : undefined,
597
+ memory: o.memory ? asString(o.memory, `${p}.memory`) : undefined,
598
+ proxied: routes.length > 0,
599
+ drain: asNumber(o.drain, `${p}.drain`, asNumber(raw.drain, "epd.yml.drain", 10)),
600
+ stopTimeout: asNumber(o.stop_timeout, `${p}.stop_timeout`, asNumber(raw.stop_timeout, "epd.yml.stop_timeout", 30))
601
+ };
602
+ });
603
+ const accessories = Object.entries(raw.accessories ?? {}).map(([accName, rawAcc]) => {
604
+ const p = `epd.yml.accessories.${accName}`;
605
+ const o = rawAcc ?? {};
606
+ checkKeys(o, ["image", "host", "env", "volumes", "ports", "command", "docker_options", "routes", "domain", "domains", "port"], p);
607
+ const accPort = o.port !== undefined ? asNumber(o.port, `${p}.port`) : undefined;
608
+ const accEnvRaw = o.env;
609
+ const routes = [];
610
+ for (const d of [...asList(o.domain, `${p}.domain`), ...asList(o.domains, `${p}.domains`)]) {
611
+ routes.push(parseRoute(d, `${p}.domains`, { port: accPort ?? 80, ssl: proxy.ssl }));
612
+ }
613
+ const rawRoutes = o.routes === undefined ? [] : Array.isArray(o.routes) ? o.routes : [o.routes];
614
+ rawRoutes.forEach((r, i) => routes.push(parseRoute(r, `${p}.routes[${i}]`, { port: accPort ?? 80, ssl: proxy.ssl })));
615
+ return {
616
+ name: accName,
617
+ image: asString(o.image, `${p}.image`),
618
+ host: asString(o.host, `${p}.host`),
619
+ env: asDict(accEnvRaw?.clear ?? (accEnvRaw && !("clear" in accEnvRaw) && !("secret" in accEnvRaw) ? accEnvRaw : {}), `${p}.env`),
620
+ secrets: asList(accEnvRaw?.secret, `${p}.env.secret`),
621
+ volumes: asList(o.volumes, `${p}.volumes`),
622
+ ports: asList(o.ports, `${p}.ports`),
623
+ command: o.command === undefined ? undefined : asString(o.command, `${p}.command`),
624
+ dockerOptions: asList(o.docker_options, `${p}.docker_options`),
625
+ routes,
626
+ port: accPort
627
+ };
628
+ });
629
+ const regRaw = raw.registry;
630
+ let registry;
631
+ if (regRaw) {
632
+ checkKeys(regRaw, ["server", "username", "password"], "epd.yml.registry");
633
+ const pw = regRaw.password;
634
+ const passwordEnv = Array.isArray(pw) ? asString(pw[0], "epd.yml.registry.password[0]") : asString(pw, "epd.yml.registry.password");
635
+ registry = {
636
+ server: regRaw.server ? asString(regRaw.server, "epd.yml.registry.server") : undefined,
637
+ username: asString(regRaw.username, "epd.yml.registry.username"),
638
+ passwordEnv
639
+ };
640
+ }
641
+ const image = asString(raw.image, "epd.yml.image", registry ? `${registry.server ? registry.server + "/" : ""}${registry.username}/${name}` : name);
642
+ const buildRaw = raw.build ?? {};
643
+ checkKeys(buildRaw, ["dockerfile", "context", "platform", "target", "args", "secrets", "cache", "remote"], "epd.yml.build");
644
+ const sshRaw = raw.ssh ?? {};
645
+ checkKeys(sshRaw, ["user", "port", "key", "options", "proxy_jump", "control_persist"], "epd.yml.ssh");
646
+ const procRaw = raw.process ?? {};
647
+ checkKeys(procRaw, ["exclude", "install", "build", "start", "path", "path_prepend", "source"], "epd.yml.process");
648
+ const processCfg = {
649
+ exclude: asList(procRaw.exclude, "epd.yml.process.exclude").length ? asList(procRaw.exclude, "epd.yml.process.exclude") : [".git", "node_modules", ".env", ".env.*", "dist", ".next/cache", "tmp", "*.log"],
650
+ install: procRaw.install ? asString(procRaw.install, "epd.yml.process.install") : undefined,
651
+ build: procRaw.build ? asString(procRaw.build, "epd.yml.process.build") : undefined,
652
+ start: asString(procRaw.start, "epd.yml.process.start", mode === "process" ? "" : "-"),
653
+ path: procRaw.path ? asString(procRaw.path, "epd.yml.process.path") : undefined,
654
+ pathPrepend: asList(procRaw.path_prepend, "epd.yml.process.path_prepend").length ? asList(procRaw.path_prepend, "epd.yml.process.path_prepend") : ["$HOME/.bun/bin", "$HOME/.local/bin", "/usr/local/bin"],
655
+ source: asString(procRaw.source, "epd.yml.process.source", "rsync")
656
+ };
657
+ if (mode === "process" && !processCfg.start) {
658
+ fail("epd.yml.process.start", "is required in process mode", `For example:
659
+ process:
660
+ start: bun run start`);
661
+ }
662
+ const hooksRaw = raw.hooks ?? {};
663
+ checkKeys(hooksRaw, ["pre_build", "pre_deploy", "post_deploy", "on_failure"], "epd.yml.hooks");
664
+ const strategy = asString(raw.strategy, "epd.yml.strategy", "rolling");
665
+ if (strategy !== "rolling" && strategy !== "parallel")
666
+ fail("epd.yml.strategy", 'expected "rolling" or "parallel"');
667
+ const cfg = {
668
+ name,
669
+ mode,
670
+ destination,
671
+ configPath,
672
+ root,
673
+ image,
674
+ registry,
675
+ build: {
676
+ dockerfile: asString(buildRaw.dockerfile, "epd.yml.build.dockerfile", "Dockerfile"),
677
+ context: asString(buildRaw.context, "epd.yml.build.context", "."),
678
+ platform: asString(buildRaw.platform, "epd.yml.build.platform", "linux/amd64"),
679
+ target: buildRaw.target ? asString(buildRaw.target, "epd.yml.build.target") : undefined,
680
+ args: asDict(buildRaw.args, "epd.yml.build.args"),
681
+ secrets: asDict(buildRaw.secrets, "epd.yml.build.secrets"),
682
+ cache: asBool(buildRaw.cache, "epd.yml.build.cache", true),
683
+ remote: asBool(buildRaw.remote, "epd.yml.build.remote", false)
684
+ },
685
+ ssh: {
686
+ user: asString(sshRaw.user, "epd.yml.ssh.user", "root"),
687
+ port: asNumber(sshRaw.port, "epd.yml.ssh.port", 22),
688
+ key: sshRaw.key ? expandHome(asString(sshRaw.key, "epd.yml.ssh.key")) : undefined,
689
+ options: asList(sshRaw.options, "epd.yml.ssh.options"),
690
+ proxyJump: sshRaw.proxy_jump ? asString(sshRaw.proxy_jump, "epd.yml.ssh.proxy_jump") : undefined,
691
+ controlPersist: asString(sshRaw.control_persist, "epd.yml.ssh.control_persist", "60s")
692
+ },
693
+ services,
694
+ proxy,
695
+ accessories,
696
+ env: envClear,
697
+ secrets: envSecret,
698
+ volumes: asList(raw.volumes, "epd.yml.volumes"),
699
+ hooks: {
700
+ preBuild: hooksRaw.pre_build ? asString(hooksRaw.pre_build, "epd.yml.hooks.pre_build") : undefined,
701
+ preDeploy: hooksRaw.pre_deploy ? asString(hooksRaw.pre_deploy, "epd.yml.hooks.pre_deploy") : undefined,
702
+ postDeploy: hooksRaw.post_deploy ? asString(hooksRaw.post_deploy, "epd.yml.hooks.post_deploy") : undefined,
703
+ onFailure: hooksRaw.on_failure ? asString(hooksRaw.on_failure, "epd.yml.hooks.on_failure") : undefined
704
+ },
705
+ process: processCfg,
706
+ portBase: asNumber(raw.port_base, "epd.yml.port_base", 20000 + hash32(name) % 117 * 256),
707
+ strategy,
708
+ keepReleases: asNumber(raw.keep_releases, "epd.yml.keep_releases", 5),
709
+ remoteRoot: asString(raw.remote_root, "epd.yml.remote_root", "/var/lib/epd")
710
+ };
711
+ validateCrossReferences(cfg);
712
+ return cfg;
713
+ }
714
+ function validateCrossReferences(cfg) {
715
+ const entrypointNames = Object.keys(cfg.proxy.entrypoints);
716
+ const seen = new Map;
717
+ for (const svc of cfg.services) {
718
+ for (const route of svc.routes) {
719
+ if (route.entrypoint && !entrypointNames.includes(route.entrypoint)) {
720
+ fail(`epd.yml.servers.${svc.name}.routes`, `entrypoint "${route.entrypoint}" is not defined`, `Define it under proxy.entrypoints, e.g.
721
+ proxy:
722
+ entrypoints:
723
+ ${route.entrypoint}: 8080`);
724
+ }
725
+ if (route.ssl && !cfg.proxy.ssl) {
726
+ fail(`epd.yml.servers.${svc.name}.routes`, "route requests TLS but proxy.ssl is false");
727
+ }
728
+ for (const host of route.hosts) {
729
+ const key = `${host}${route.path ?? ""}`;
730
+ const owner = `${svc.name}`;
731
+ if (seen.has(key) && seen.get(key) !== owner) {
732
+ fail("epd.yml.servers", `"${key}" is routed by both ${seen.get(key)} and ${owner}`, "Give each route a distinct host or path.");
733
+ }
734
+ seen.set(key, owner);
735
+ }
736
+ }
737
+ }
738
+ if (cfg.mode === "process" || cfg.proxy.crossHost) {
739
+ for (const svc of cfg.services) {
740
+ for (const route of svc.routes) {
741
+ if (route.port !== svc.port) {
742
+ fail(`epd.yml.servers.${svc.name}.routes`, `route port ${route.port} differs from the server port ${svc.port}`, `${cfg.mode === "process" ? "Process mode" : "proxy.cross_host"} publishes one port per replica.
743
+ ` + `Give the route port ${svc.port}, or add a second server entry that listens on ${route.port}.`);
744
+ }
745
+ }
746
+ }
747
+ }
748
+ if (cfg.proxy.ssl && cfg.proxy.challenge !== "dns" && !cfg.proxy.email) {
749
+ const anySsl = cfg.services.some((s) => s.routes.some((r) => r.ssl));
750
+ if (anySsl)
751
+ fail("epd.yml.proxy.email", "is required for Let's Encrypt certificates", `proxy:
752
+ email: you@example.com`);
753
+ }
754
+ if (cfg.mode === "docker" && !cfg.registry && cfg.services.some((s) => s.hosts.length > 1)) {}
755
+ }
756
+
757
+ // src/commands/init.ts
758
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
759
+ import { basename, join as join2 } from "path";
760
+
761
+ // src/util/prompt.ts
762
+ var isTTY = process.stdin.isTTY && process.stdout.isTTY;
763
+ function interactive() {
764
+ return Boolean(isTTY);
765
+ }
766
+ async function readLine() {
767
+ for await (const line of console)
768
+ return line;
769
+ return "";
770
+ }
771
+ async function ask(question, dflt) {
772
+ if (!isTTY)
773
+ return dflt ?? "";
774
+ process.stdout.write(`${c.cyan("?")} ${question}${dflt ? c.gray(` (${dflt})`) : ""} `);
775
+ const answer = (await readLine()).trim();
776
+ return answer || dflt || "";
777
+ }
778
+ async function confirm(question, dflt = false) {
779
+ if (!isTTY)
780
+ return dflt;
781
+ const answer = (await ask(`${question} ${dflt ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
782
+ if (!answer)
783
+ return dflt;
784
+ return answer === "y" || answer === "yes";
785
+ }
786
+ async function choose(question, options, dflt) {
787
+ if (!isTTY)
788
+ return dflt;
789
+ console.log(`${c.cyan("?")} ${question}`);
790
+ options.forEach((o, i) => console.log(` ${i + 1}) ${o.label}${o.value === dflt ? c.gray(" (default)") : ""}`));
791
+ const answer = (await ask("Choose", String(options.findIndex((o) => o.value === dflt) + 1))).trim();
792
+ const idx = Number(answer) - 1;
793
+ return options[idx]?.value ?? dflt;
794
+ }
795
+
796
+ // src/commands/init.ts
797
+ function detect(root) {
798
+ const name = basename(root).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
799
+ const hasDockerfile = existsSync2(join2(root, "Dockerfile"));
800
+ const pkgPath = join2(root, "package.json");
801
+ if (existsSync2(pkgPath)) {
802
+ let pkg = {};
803
+ try {
804
+ pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
805
+ } catch {}
806
+ const bun = existsSync2(join2(root, "bun.lockb")) || existsSync2(join2(root, "bun.lock"));
807
+ const scripts = pkg.scripts ?? {};
808
+ const runner = bun ? "bun" : "npm";
809
+ return {
810
+ name: (pkg.name ?? name).replace(/^@[^/]+\//, "").replace(/[^a-z0-9_-]/gi, "-"),
811
+ runtime: bun ? "bun" : "node",
812
+ port: 3000,
813
+ install: bun ? "bun install --frozen-lockfile" : "npm ci --omit=dev",
814
+ build: scripts.build ? `${runner} run build` : undefined,
815
+ start: scripts.start ? `${runner} run start` : bun ? "bun run index.ts" : "node index.js",
816
+ hasDockerfile
817
+ };
818
+ }
819
+ if (existsSync2(join2(root, "pyproject.toml")) || existsSync2(join2(root, "requirements.txt"))) {
820
+ return { name, runtime: "python", port: 8000, install: "pip install -r requirements.txt", start: "python -m uvicorn main:app --host 127.0.0.1 --port $PORT", hasDockerfile };
821
+ }
822
+ if (existsSync2(join2(root, "Gemfile"))) {
823
+ return { name, runtime: "ruby", port: 3000, install: "bundle install --deployment", start: "bundle exec rails server -p $PORT", hasDockerfile };
824
+ }
825
+ if (existsSync2(join2(root, "go.mod"))) {
826
+ return { name, runtime: "go", port: 8080, build: "go build -o server .", start: "./server", hasDockerfile };
827
+ }
828
+ return { name, runtime: "unknown", port: 3000, hasDockerfile };
829
+ }
830
+ function template(o) {
831
+ const list = (xs) => `[${xs.map((x) => JSON.stringify(x)).join(", ")}]`;
832
+ const portBase = 20000 + hash32(o.name) % 117 * 256;
833
+ const registry = o.registryUser ? `
834
+ # Where built images live. Leave this out entirely and epd will copy the image
835
+ # to each server over SSH instead (slower, but no registry account needed).
836
+ registry:
837
+ ${o.registryServer ? `server: ${o.registryServer}
838
+ ` : ""}username: ${o.registryUser}
839
+ password: \${EPD_REGISTRY_PASSWORD}
840
+ ` : `
841
+ # No registry configured: epd ships the built image to each server over SSH.
842
+ # Add one once you have more than a couple of servers:
843
+ #
844
+ # registry:
845
+ # server: ghcr.io
846
+ # username: your-user
847
+ # password: \${GITHUB_TOKEN}
848
+ `;
849
+ const processBlock = o.mode === "process" ? `
850
+ # No Docker: each replica is started with pm2 and gets a unique $PORT.
851
+ process:
852
+ ${o.detected.install ? `install: ${o.detected.install}` : "# install: bun install --frozen-lockfile"}
853
+ ${o.detected.build ? `build: ${o.detected.build}` : "# build: bun run build"}
854
+ start: ${o.detected.start ?? "bun run start"}
855
+ # Files that never need to reach the server.
856
+ exclude: [".git", "node_modules", ".env", "dist", "*.log"]
857
+ ` : "";
858
+ return `# ${o.name} \u2014 deployed with epd (https://github.com/you/epd)
859
+ #
860
+ # epd setup prepare the servers and deploy for the first time
861
+ # epd deploy build and deploy the current commit
862
+ # epd status see what is running where
863
+ # epd rollback go back to the previous version
864
+ #
865
+ name: ${o.name}
866
+
867
+ # "docker" builds an image from your Dockerfile. "process" uploads the project
868
+ # and runs it with pm2 \u2014 no Docker on the server at all.
869
+ mode: ${o.mode}
870
+ ${o.mode === "docker" ? `
871
+ # Image name. Tagged with the git sha of every deploy.
872
+ image: ${o.image ?? o.name}
873
+ ${registry}` : ""}${processBlock}
874
+ # One entry per group of processes. Add more (worker, cron, \u2026) as you need them.
875
+ servers:
876
+ web:
877
+ hosts: ${list(o.hosts)}
878
+ # Replicas per host. Traefik load balances across all of them, and across
879
+ # every host when proxy.cross_host is on.
880
+ replicas: ${o.replicas}
881
+ port: ${o.port}
882
+ domains: ${list(o.domains)}
883
+ # Routes give you finer control \u2014 several sites, paths and ports per app:
884
+ #
885
+ # routes:
886
+ # - host: ${o.domains[0] ?? "example.com"}
887
+ # - host: api.${o.domains[0] ?? "example.com"}
888
+ # port: 8080
889
+ # - host: ${o.domains[0] ?? "example.com"}
890
+ # path: /admin
891
+ # port: 4000
892
+ # basic_auth: ["admin:$apr1$..."]
893
+
894
+ # worker:
895
+ # hosts: ${list(o.hosts.slice(0, 1))}
896
+ # command: bun run worker.ts
897
+
898
+ # Traefik runs once per server and is shared by every epd app on it, so you can
899
+ # host as many sites on one machine as you like.
900
+ proxy:
901
+ ssl: true
902
+ email: ${o.email || "you@example.com"}
903
+ # Route to replicas on *other* servers too, so one machine can serve while
904
+ # another is down. Needs the app port reachable between servers.
905
+ cross_host: false
906
+ # private_ips: { "203.0.113.10": "10.0.0.10" }
907
+
908
+ # Zero-downtime health check for the new version before traffic moves to it.
909
+ healthcheck:
910
+ path: /
911
+ timeout: 60
912
+
913
+ env:
914
+ clear:
915
+ NODE_ENV: production
916
+ # Read from your shell or .env at deploy time and written to the server
917
+ # as a 0600 file. Never committed.
918
+ secret:
919
+ # - DATABASE_URL
920
+
921
+ # Long-lived containers epd starts but never rebuilds, like a database.
922
+ # accessories:
923
+ # db:
924
+ # image: postgres:17
925
+ # host: ${o.hosts[0] ?? "203.0.113.10"}
926
+ # env:
927
+ # clear:
928
+ # POSTGRES_DB: ${o.name}
929
+ # secret:
930
+ # - POSTGRES_PASSWORD
931
+ # volumes:
932
+ # - /var/lib/epd/volumes/${o.name}-db:/var/lib/postgresql/data
933
+
934
+ # hooks:
935
+ # pre_deploy: ./bin/run-tests
936
+ # post_deploy: ./bin/notify-slack
937
+
938
+ # Host ports epd reserves on each server (only used in process mode or with
939
+ # cross_host). Change it if two apps ever collide.
940
+ port_base: ${portBase}
941
+ `;
942
+ }
943
+ var DOCKERFILES = {
944
+ bun: `# syntax=docker/dockerfile:1
945
+ FROM oven/bun:1 AS base
946
+ WORKDIR /app
947
+
948
+ FROM base AS deps
949
+ COPY package.json bun.lock* bun.lockb* ./
950
+ RUN bun install --frozen-lockfile
951
+
952
+ FROM base AS build
953
+ COPY --from=deps /app/node_modules ./node_modules
954
+ COPY . .
955
+ RUN bun run build || true
956
+
957
+ FROM base AS run
958
+ ENV NODE_ENV=production
959
+ COPY --from=build /app ./
960
+ EXPOSE 3000
961
+ CMD ["bun", "run", "start"]
962
+ `,
963
+ node: `# syntax=docker/dockerfile:1
964
+ FROM node:22-slim AS base
965
+ WORKDIR /app
966
+
967
+ FROM base AS deps
968
+ COPY package*.json ./
969
+ RUN npm ci
970
+
971
+ FROM base AS build
972
+ COPY --from=deps /app/node_modules ./node_modules
973
+ COPY . .
974
+ RUN npm run build --if-present
975
+
976
+ FROM base AS run
977
+ ENV NODE_ENV=production
978
+ COPY --from=build /app ./
979
+ RUN npm prune --omit=dev
980
+ EXPOSE 3000
981
+ CMD ["npm", "run", "start"]
982
+ `
983
+ };
984
+ var init = {
985
+ name: "init",
986
+ summary: "Create an epd.yml for this project",
987
+ options: {
988
+ name: { type: "string" },
989
+ mode: { type: "string" },
990
+ host: { type: "string", multiple: true },
991
+ domain: { type: "string", multiple: true },
992
+ port: { type: "string" },
993
+ email: { type: "string" },
994
+ replicas: { type: "string" },
995
+ force: { type: "boolean" },
996
+ dockerfile: { type: "boolean" }
997
+ },
998
+ help: () => `${c.bold("epd init")} \u2014 create an epd.yml for this project
999
+
1000
+ Options
1001
+ --name <name> App name (default: the directory name)
1002
+ --mode <docker|process>
1003
+ --host <ip> Server to deploy to (repeatable)
1004
+ --domain <domain> Domain to serve (repeatable)
1005
+ --port <port> Port your app listens on
1006
+ --email <email> Contact address for Let's Encrypt
1007
+ --replicas <n> Replicas per host (default 1)
1008
+ --dockerfile Also write a starter Dockerfile
1009
+ --force Overwrite an existing epd.yml`,
1010
+ async run(ctx) {
1011
+ const root = process.cwd();
1012
+ const target = join2(root, "epd.yml");
1013
+ if (existsSync2(target) && !ctx.values.force) {
1014
+ throw new EpdError("epd.yml already exists", "Pass --force to overwrite it.");
1015
+ }
1016
+ const detected = detect(root);
1017
+ log.info(`Detected ${c.bold(detected.runtime)} project${detected.hasDockerfile ? " with a Dockerfile" : ""}`);
1018
+ const name = ctx.values.name ?? await ask("App name", detected.name);
1019
+ const mode = ctx.values.mode ?? await choose("How should it run on the server?", [
1020
+ { value: "docker", label: "Docker \u2014 build an image, run containers" },
1021
+ { value: "process", label: "Process \u2014 upload the code, run it with pm2" }
1022
+ ], detected.hasDockerfile || detected.runtime === "unknown" ? "docker" : "docker");
1023
+ if (mode !== "docker" && mode !== "process")
1024
+ throw new EpdError(`--mode must be "docker" or "process"`);
1025
+ const hosts = (ctx.values.host ?? []).length ? ctx.values.host : (await ask("Server IPs or hostnames (comma separated)", "203.0.113.10")).split(",").map((s) => s.trim()).filter(Boolean);
1026
+ const domains = (ctx.values.domain ?? []).length ? ctx.values.domain : (await ask("Domains to serve (comma separated)", `${name}.example.com`)).split(",").map((s) => s.trim()).filter(Boolean);
1027
+ const port = Number(ctx.values.port ?? await ask("Port your app listens on", String(detected.port)));
1028
+ const email = ctx.values.email ?? await ask("Email for Let's Encrypt", "");
1029
+ const replicas = Number(ctx.values.replicas ?? 1);
1030
+ let registryUser;
1031
+ let registryServer;
1032
+ let image;
1033
+ if (mode === "docker" && interactive() && await confirm("Push images to a registry (ghcr.io, Docker Hub, \u2026)?", false)) {
1034
+ registryServer = await ask("Registry server", "ghcr.io") || undefined;
1035
+ registryUser = await ask("Registry username", process.env.USER ?? "");
1036
+ image = `${registryServer ? `${registryServer}/` : ""}${registryUser}/${name}`;
1037
+ }
1038
+ writeFileSync(target, template({ name, mode, hosts, domains, port, email, image, registryServer, registryUser, detected, replicas }));
1039
+ log.ok(`wrote ${c.bold("epd.yml")}`);
1040
+ const wantDockerfile = mode === "docker" && !detected.hasDockerfile && (Boolean(ctx.values.dockerfile) || await confirm("No Dockerfile here. Write a starter one?", true));
1041
+ if (wantDockerfile) {
1042
+ const df = DOCKERFILES[detected.runtime];
1043
+ if (df) {
1044
+ writeFileSync(join2(root, "Dockerfile"), df);
1045
+ log.ok(`wrote ${c.bold("Dockerfile")} \u2014 check the build and start commands`);
1046
+ } else {
1047
+ log.warn(`no Dockerfile template for ${detected.runtime}; write one yourself or use \`mode: process\``);
1048
+ }
1049
+ }
1050
+ console.log(`
1051
+ ${c.bold("Next")}
1052
+ 1. Edit ${c.cyan("epd.yml")} \u2014 servers, domains, env.
1053
+ 2. Point your domains' A records at ${hosts.join(", ")}.
1054
+ 3. ${c.cyan("epd setup")} installs what the servers need and deploys.
1055
+ 4. ${c.cyan("epd deploy")} every time after that.
1056
+ `);
1057
+ }
1058
+ };
1059
+
1060
+ // src/util/proc.ts
1061
+ async function run(cmd, opts = {}) {
1062
+ const label = opts.label ?? cmd.join(" ");
1063
+ log.debug(`local: ${cmd.join(" ")}`);
1064
+ const proc = Bun.spawn(cmd, {
1065
+ cwd: opts.cwd,
1066
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
1067
+ stdin: opts.input !== undefined ? new TextEncoder().encode(opts.input) : "inherit",
1068
+ stdout: opts.stream ? "inherit" : "pipe",
1069
+ stderr: opts.stream ? "inherit" : "pipe"
1070
+ });
1071
+ const [stdout, stderr, code] = await Promise.all([
1072
+ opts.stream ? Promise.resolve("") : new Response(proc.stdout).text(),
1073
+ opts.stream ? Promise.resolve("") : new Response(proc.stderr).text(),
1074
+ proc.exited
1075
+ ]);
1076
+ const result = { code, stdout: stdout.trim(), stderr: stderr.trim() };
1077
+ if (code !== 0 && !opts.allowFailure) {
1078
+ throw new EpdError(`command failed (exit ${code}): ${label}`, result.stderr || result.stdout || undefined);
1079
+ }
1080
+ return result;
1081
+ }
1082
+ async function runShell(script, opts = {}) {
1083
+ return run(["/bin/sh", "-c", script], { ...opts, label: opts.label ?? script });
1084
+ }
1085
+ async function which(bin) {
1086
+ const r = await run(["/bin/sh", "-c", `command -v ${bin}`], { allowFailure: true });
1087
+ return r.code === 0 && r.stdout ? r.stdout.split(`
1088
+ `)[0].trim() : null;
1089
+ }
1090
+ async function requireLocal(bin, hint) {
1091
+ if (!await which(bin))
1092
+ throw new EpdError(`\`${bin}\` was not found on this machine`, hint);
1093
+ }
1094
+
1095
+ // src/config/types.ts
1096
+ function allHosts(cfg) {
1097
+ const s = new Set;
1098
+ for (const svc of cfg.services)
1099
+ for (const h of svc.hosts)
1100
+ s.add(h);
1101
+ for (const a of cfg.accessories)
1102
+ s.add(a.host);
1103
+ return [...s];
1104
+ }
1105
+ function proxyHosts(cfg) {
1106
+ if (!cfg.proxy.enabled)
1107
+ return [];
1108
+ const s = new Set;
1109
+ for (const svc of cfg.services)
1110
+ if (svc.routes.length)
1111
+ for (const h of svc.hosts)
1112
+ s.add(h);
1113
+ for (const a of cfg.accessories)
1114
+ if (a.routes.length)
1115
+ s.add(a.host);
1116
+ return [...s];
1117
+ }
1118
+
1119
+ // src/core/names.ts
1120
+ var SLOTS = ["blue", "green"];
1121
+ var otherSlot = (s) => s === "blue" ? "green" : "blue";
1122
+ var MAX_REPLICAS = 16;
1123
+ var PORTS_PER_SERVICE = 32;
1124
+ var MAX_SERVICES = 8;
1125
+ var containerName = (app, svc, slot, i) => `epd-${app}-${svc}-${slot}-${i}`;
1126
+ var accessoryContainer = (app, name) => `epd-${app}-acc-${name}`;
1127
+ var processName = (app, svc, slot, i) => `epd-${app}-${svc}-${slot}-${i}`;
1128
+ var PROXY_CONTAINER = "epd-proxy";
1129
+ var PROXY_PROCESS = "epd-proxy";
1130
+ function paths(cfg) {
1131
+ const root = cfg.remoteRoot;
1132
+ const app = `${root}/apps/${cfg.name}`;
1133
+ return {
1134
+ root,
1135
+ app,
1136
+ envFile: `${app}/env`,
1137
+ state: `${app}/state.json`,
1138
+ lock: `${app}/lock`,
1139
+ releases: `${app}/releases`,
1140
+ current: `${app}/current`,
1141
+ shared: `${app}/shared`,
1142
+ proxyDir: `${root}/proxy`,
1143
+ proxyStatic: `${root}/proxy/traefik.yml`,
1144
+ proxyDynamicDir: `${root}/proxy/dynamic`,
1145
+ proxyAppsDir: `${root}/proxy/apps`,
1146
+ proxyApp: `${root}/proxy/apps/${cfg.name}.json`,
1147
+ proxyEnv: `${root}/proxy/env`,
1148
+ proxyDynamic: `${root}/proxy/dynamic/${cfg.name}.yml`,
1149
+ acmeDir: `${root}/proxy/acme`,
1150
+ portsDir: `${root}/ports`,
1151
+ proxyBin: `${root}/proxy/bin/traefik`,
1152
+ proxyEcosystem: `${root}/proxy/pm2.json`,
1153
+ proxyLogDir: `${root}/proxy/log`
1154
+ };
1155
+ }
1156
+ function serviceIndex(cfg, svc) {
1157
+ const names = cfg.services.map((s) => s.name).sort();
1158
+ const idx = names.indexOf(svc.name);
1159
+ if (idx >= MAX_SERVICES) {
1160
+ throw new EpdError(`too many servers: epd reserves ports for ${MAX_SERVICES} per app`, "Split the extra ones into their own epd.yml.");
1161
+ }
1162
+ return idx;
1163
+ }
1164
+ function hostPort(cfg, svc, slot, replica) {
1165
+ if (replica >= MAX_REPLICAS) {
1166
+ throw new EpdError(`${svc.name}: at most ${MAX_REPLICAS} replicas per server are supported`, "Add more servers instead, or run several epd apps.");
1167
+ }
1168
+ const j = serviceIndex(cfg, svc);
1169
+ const s = SLOTS.indexOf(slot);
1170
+ return cfg.portBase + j * PORTS_PER_SERVICE + s * MAX_REPLICAS + replica;
1171
+ }
1172
+ function portBlock(cfg) {
1173
+ return { from: cfg.portBase, to: cfg.portBase + MAX_SERVICES * PORTS_PER_SERVICE - 1 };
1174
+ }
1175
+ function usesHostPorts(cfg) {
1176
+ return cfg.mode === "process" || cfg.proxy.crossHost;
1177
+ }
1178
+ function privateAddress(cfg, host) {
1179
+ return cfg.proxy.privateIps[host] ?? host;
1180
+ }
1181
+ function bindAddress(cfg, host) {
1182
+ return cfg.proxy.privateIps[host] ?? "0.0.0.0";
1183
+ }
1184
+ function endpointsFor(cfg, svc, slot, serverHost, containerIp) {
1185
+ const out = [];
1186
+ for (let i = 0;i < svc.replicas; i++) {
1187
+ if (cfg.mode === "process") {
1188
+ const port = hostPort(cfg, svc, slot, i);
1189
+ out.push({
1190
+ url: `http://127.0.0.1:${port}`,
1191
+ probeHost: "127.0.0.1",
1192
+ probePort: port,
1193
+ id: processName(cfg.name, svc.name, slot, i),
1194
+ replica: i
1195
+ });
1196
+ } else if (cfg.proxy.crossHost) {
1197
+ const port = hostPort(cfg, svc, slot, i);
1198
+ out.push({
1199
+ url: `http://${privateAddress(cfg, serverHost)}:${port}`,
1200
+ probeHost: "127.0.0.1",
1201
+ probePort: port,
1202
+ id: containerName(cfg.name, svc.name, slot, i),
1203
+ replica: i
1204
+ });
1205
+ } else {
1206
+ const id = containerName(cfg.name, svc.name, slot, i);
1207
+ out.push({
1208
+ url: `http://${id}:${svc.port}`,
1209
+ probeHost: containerIp?.(id) ?? id,
1210
+ probePort: svc.port,
1211
+ id,
1212
+ replica: i
1213
+ });
1214
+ }
1215
+ }
1216
+ return out;
1217
+ }
1218
+
1219
+ // src/core/bootstrap.ts
1220
+ async function ensureDirs(host, cfg) {
1221
+ const p = paths(cfg);
1222
+ await host.exec(`mkdir -p ${shq(p.app)} ${shq(p.proxyDynamicDir)} ${shq(p.proxyAppsDir)} ${shq(p.acmeDir)} ${shq(p.portsDir)} ${shq(p.releases)} ${shq(p.shared)}
1223
+ chmod 700 ${shq(p.acmeDir)}`);
1224
+ }
1225
+ async function reservePorts(host, cfg) {
1226
+ if (!usesHostPorts(cfg))
1227
+ return;
1228
+ const { from, to } = portBlock(cfg);
1229
+ const file = `${paths(cfg).portsDir}/${from}`;
1230
+ const owner = await host.capture(`mkdir -p ${shq(paths(cfg).portsDir)}
1231
+ if [ -f ${shq(file)} ]; then cat ${shq(file)}; else printf '%s' ${shq(cfg.name)} > ${shq(file)}; printf '%s' ${shq(cfg.name)}; fi`);
1232
+ if (owner.trim() !== cfg.name) {
1233
+ throw new EpdError(`${host.name}: ports ${from}-${to} are already reserved by "${owner.trim()}"`, `Pick a different block in epd.yml, for example:
1234
+ port_base: ${from + 256}`);
1235
+ }
1236
+ }
1237
+ async function hasDocker(host) {
1238
+ return host.test("command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1");
1239
+ }
1240
+ async function installDocker(host) {
1241
+ if (await hasDocker(host)) {
1242
+ log.host(host.name, "docker is already installed");
1243
+ return;
1244
+ }
1245
+ log.host(host.name, "installing docker (get.docker.com)\u2026");
1246
+ await host.exec(`if command -v docker >/dev/null 2>&1; then
1247
+ systemctl enable --now docker 2>/dev/null || true
1248
+ else
1249
+ export DEBIAN_FRONTEND=noninteractive
1250
+ if command -v curl >/dev/null 2>&1; then
1251
+ curl -fsSL https://get.docker.com -o /tmp/epd-get-docker.sh
1252
+ else
1253
+ (apt-get update -qq && apt-get install -y -qq curl) >/dev/null 2>&1 || true
1254
+ curl -fsSL https://get.docker.com -o /tmp/epd-get-docker.sh
1255
+ fi
1256
+ sh /tmp/epd-get-docker.sh
1257
+ rm -f /tmp/epd-get-docker.sh
1258
+ systemctl enable --now docker 2>/dev/null || true
1259
+ fi
1260
+ docker info >/dev/null`, { stream: true });
1261
+ log.host(host.name, "docker ready");
1262
+ }
1263
+ async function ensureNetwork(host, cfg) {
1264
+ const net = cfg.proxy.network;
1265
+ await host.exec(`docker network inspect ${shq(net)} >/dev/null 2>&1 || docker network create ${shq(net)} >/dev/null`);
1266
+ }
1267
+ async function verifyHost(host, cfg) {
1268
+ await host.ping();
1269
+ if (cfg.mode === "docker") {
1270
+ if (!await hasDocker(host)) {
1271
+ throw new EpdError(`${host.name}: docker is not installed or not usable by ${host.target}`, "Run `epd setup` to install it.");
1272
+ }
1273
+ } else {
1274
+ const missing = [];
1275
+ for (const bin of ["rsync", "pm2"]) {
1276
+ if (!await host.test(`${pathPrelude(cfg)} command -v ${bin} >/dev/null 2>&1`))
1277
+ missing.push(bin);
1278
+ }
1279
+ if (missing.length) {
1280
+ throw new EpdError(`${host.name}: missing ${missing.join(", ")}`, "Run `epd setup` to install the process-mode runtime.");
1281
+ }
1282
+ }
1283
+ await ensureDirs(host, cfg);
1284
+ await reservePorts(host, cfg);
1285
+ }
1286
+ function pathPrelude(cfg) {
1287
+ const extra = cfg.process.pathPrepend.join(":");
1288
+ return `PATH="${extra}:$PATH"`;
1289
+ }
1290
+ async function installProcessRuntime(host, cfg) {
1291
+ log.host(host.name, "preparing process-mode runtime\u2026");
1292
+ await host.exec(`export DEBIAN_FRONTEND=noninteractive
1293
+ ${pathPrelude(cfg)}
1294
+
1295
+ # bun runs most process-mode apps and can install pm2 without npm's huge
1296
+ # dependency tree. node is installed too because pm2 itself is a node program.
1297
+ if ! command -v bun >/dev/null 2>&1; then
1298
+ curl -fsSL https://bun.sh/install | bash >/dev/null 2>&1 || echo "note: bun could not be installed; your start command must use another runtime" >&2
1299
+ fi
1300
+
1301
+ ${pathPrelude(cfg)}
1302
+ if ! command -v pm2 >/dev/null 2>&1; then
1303
+ if command -v bun >/dev/null 2>&1; then bun install -g pm2 >/dev/null
1304
+ elif command -v npm >/dev/null 2>&1; then npm install -g pm2 >/dev/null
1305
+ else echo "neither bun nor npm is available to install pm2" >&2; exit 1
1306
+ fi
1307
+ fi
1308
+
1309
+ ${pathPrelude(cfg)}
1310
+ command -v node >/dev/null 2>&1 || { echo "node is required to run pm2" >&2; exit 1; }
1311
+ command -v pm2 >/dev/null 2>&1 || { echo "pm2 was installed but is not on PATH" >&2; exit 1; }
1312
+ pm2 ping >/dev/null 2>&1 || true
1313
+ echo "node $(node --version), pm2 $(pm2 --version 2>/dev/null | tail -n 1)$(command -v bun >/dev/null 2>&1 && echo ", bun $(bun --version)")"`, { sudo: false, stream: true, label: `${host.name}: install process runtime` });
1314
+ log.host(host.name, "process runtime ready");
1315
+ }
1316
+ async function installSystemPackages(host) {
1317
+ await host.exec(`export DEBIAN_FRONTEND=noninteractive
1318
+ missing=""
1319
+ for bin in rsync curl tar unzip; do command -v "$bin" >/dev/null 2>&1 || missing="$missing $bin"; done
1320
+ command -v node >/dev/null 2>&1 || missing="$missing nodejs"
1321
+ if [ -n "$missing" ]; then
1322
+ if command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq $missing
1323
+ elif command -v dnf >/dev/null 2>&1; then dnf install -y -q $missing
1324
+ elif command -v apk >/dev/null 2>&1; then apk add --no-cache $missing
1325
+ elif command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm $missing
1326
+ fi
1327
+ fi
1328
+ true`, { allowFailure: true, stream: true, label: `${host.name}: install packages` });
1329
+ }
1330
+
1331
+ // src/core/ssh.ts
1332
+ import { mkdirSync } from "fs";
1333
+ import { tmpdir } from "os";
1334
+ import { join as join3 } from "path";
1335
+ var controlDir = join3(tmpdir(), `epd-${process.getuid?.() ?? 0}`);
1336
+ mkdirSync(controlDir, { recursive: true, mode: 448 });
1337
+
1338
+ class Host {
1339
+ name;
1340
+ cfg;
1341
+ sudo;
1342
+ constructor(name, cfg) {
1343
+ this.name = name;
1344
+ this.cfg = cfg;
1345
+ this.sudo = cfg.user !== "root";
1346
+ }
1347
+ sshOptions() {
1348
+ const socket = join3(controlDir, `${hash32(`${this.cfg.user}@${this.name}:${this.cfg.port}`).toString(36)}.sock`);
1349
+ const args = [
1350
+ "-o",
1351
+ "BatchMode=yes",
1352
+ "-o",
1353
+ "StrictHostKeyChecking=accept-new",
1354
+ "-o",
1355
+ "ConnectTimeout=10",
1356
+ "-o",
1357
+ "ServerAliveInterval=15",
1358
+ "-o",
1359
+ "ControlMaster=auto",
1360
+ "-o",
1361
+ `ControlPath=${socket}`,
1362
+ "-o",
1363
+ `ControlPersist=${this.cfg.controlPersist}`,
1364
+ "-p",
1365
+ String(this.cfg.port)
1366
+ ];
1367
+ if (this.cfg.key)
1368
+ args.push("-i", this.cfg.key, "-o", "IdentitiesOnly=yes");
1369
+ if (this.cfg.proxyJump)
1370
+ args.push("-J", this.cfg.proxyJump);
1371
+ for (const o of this.cfg.options)
1372
+ args.push("-o", o);
1373
+ return args;
1374
+ }
1375
+ get target() {
1376
+ return `${this.cfg.user}@${this.name}`;
1377
+ }
1378
+ rsyncShell() {
1379
+ return ["ssh", ...this.sshOptions()].map((a) => /[^\w@%+=:,./-]/.test(a) ? shq(a) : a).join(" ");
1380
+ }
1381
+ async exec(script, opts = {}) {
1382
+ const prelude = ["set -euo pipefail"];
1383
+ for (const [k, v] of Object.entries(opts.env ?? {}))
1384
+ prelude.push(`export ${k}=${shq(v)}`);
1385
+ const body = `${prelude.join(`
1386
+ `)}
1387
+ ${script}
1388
+ `;
1389
+ log.debug(`${this.name}: ${script.split(`
1390
+ `).filter(Boolean)[0] ?? ""}`);
1391
+ const useSudo = opts.sudo ?? this.sudo;
1392
+ const remote = useSudo ? ["sudo", "-n", "bash", "-s"] : ["bash", "-s"];
1393
+ const res = await run(["ssh", ...this.sshOptions(), this.target, "--", ...remote], {
1394
+ input: body,
1395
+ stream: opts.stream,
1396
+ allowFailure: true,
1397
+ label: opts.label ?? `${this.name}: ${script.split(`
1398
+ `).filter(Boolean)[0]}`
1399
+ });
1400
+ if (res.code !== 0 && !opts.allowFailure) {
1401
+ const detail = [res.stdout, res.stderr].filter(Boolean).join(`
1402
+ `).trim();
1403
+ throw new EpdError(`${this.name}: remote command failed (exit ${res.code})`, detail || (res.code === 255 ? "ssh could not connect. Check the host, port, user and key." : undefined));
1404
+ }
1405
+ return res;
1406
+ }
1407
+ async capture(script, opts = {}) {
1408
+ return (await this.exec(script, opts)).stdout;
1409
+ }
1410
+ async test(script) {
1411
+ return (await this.exec(script, { allowFailure: true })).code === 0;
1412
+ }
1413
+ async interactive(command, opts = {}) {
1414
+ const wrapped = opts.sudo ?? this.sudo ? `sudo -n bash -lc ${shq(command)}` : `bash -lc ${shq(command)}`;
1415
+ const proc = Bun.spawn(["ssh", "-tt", ...this.sshOptions(), this.target, "--", wrapped], {
1416
+ stdin: "inherit",
1417
+ stdout: "inherit",
1418
+ stderr: "inherit"
1419
+ });
1420
+ return await proc.exited;
1421
+ }
1422
+ async writeFile(path, content, mode = "644") {
1423
+ const marker = `EPD_EOF_${hash32(path + content.length).toString(36)}`;
1424
+ await this.exec(`mkdir -p ${shq(dirnameOf(path))}
1425
+ umask 077
1426
+ cat > ${shq(path)}.tmp <<'${marker}'
1427
+ ${content}
1428
+ ${marker}
1429
+ chmod ${mode} ${shq(path)}.tmp
1430
+ mv -f ${shq(path)}.tmp ${shq(path)}`, { label: `${this.name}: write ${path}` });
1431
+ }
1432
+ async readFile(path) {
1433
+ const res = await this.exec(`cat ${shq(path)} 2>/dev/null || true`, { allowFailure: true });
1434
+ return res.stdout || null;
1435
+ }
1436
+ async pipeInto(remoteCommand, localCommand) {
1437
+ const remote = this.sudo ? `sudo -n bash -c ${shq(remoteCommand)}` : `bash -c ${shq(remoteCommand)}`;
1438
+ const sshArgs = ["ssh", ...this.sshOptions(), this.target, "--", remote];
1439
+ const script = `set -euo pipefail; ${localCommand.map((a) => shq(a)).join(" ")} | ${sshArgs.map((a) => shq(a)).join(" ")}`;
1440
+ await run(["/bin/sh", "-c", script], { stream: true, label: `pipe \u2192 ${this.name}` });
1441
+ }
1442
+ async ping() {
1443
+ const res = await this.exec("echo epd-ok", { allowFailure: true });
1444
+ if (res.code !== 0 || !res.stdout.includes("epd-ok")) {
1445
+ throw new EpdError(`cannot reach ${this.target}:${this.cfg.port}`, res.stderr || "Check that the host is up and your SSH key is authorized.");
1446
+ }
1447
+ }
1448
+ }
1449
+ function dirnameOf(p) {
1450
+ const i = p.lastIndexOf("/");
1451
+ return i <= 0 ? "/" : p.slice(0, i);
1452
+ }
1453
+ var cache = new Map;
1454
+ function host(name, cfg) {
1455
+ const key = `${cfg.user}@${name}:${cfg.port}`;
1456
+ let h = cache.get(key);
1457
+ if (!h) {
1458
+ h = new Host(name, cfg);
1459
+ cache.set(key, h);
1460
+ }
1461
+ return h;
1462
+ }
1463
+
1464
+ // src/proxy/traefik.ts
1465
+ var CERT_RESOLVER = "epd";
1466
+ function declFor(cfg) {
1467
+ return {
1468
+ app: cfg.name,
1469
+ image: cfg.proxy.image,
1470
+ version: cfg.proxy.version,
1471
+ network: cfg.proxy.network,
1472
+ entrypoints: cfg.proxy.entrypoints,
1473
+ ssl: cfg.proxy.ssl,
1474
+ email: cfg.proxy.email,
1475
+ challenge: cfg.proxy.challenge,
1476
+ dnsProvider: cfg.proxy.dnsProvider,
1477
+ dnsEnv: cfg.proxy.dnsEnv,
1478
+ acmeServer: cfg.proxy.acmeServer,
1479
+ logLevel: cfg.proxy.logLevel,
1480
+ accessLog: cfg.proxy.accessLog,
1481
+ dashboard: cfg.proxy.dashboard,
1482
+ dashboardHost: cfg.proxy.dashboardHost,
1483
+ trustedIps: cfg.proxy.trustedIps,
1484
+ extra: cfg.proxy.extra
1485
+ };
1486
+ }
1487
+ async function readDecls(host2, cfg) {
1488
+ const dir = paths(cfg).proxyAppsDir;
1489
+ const out = await host2.capture(`for f in ${shq(dir)}/*.json; do [ -f "$f" ] || continue; echo "###EPD_FILE"; cat "$f"; done`, { allowFailure: true });
1490
+ const decls = [];
1491
+ for (const chunk of out.split("###EPD_FILE")) {
1492
+ const t = chunk.trim();
1493
+ if (!t)
1494
+ continue;
1495
+ try {
1496
+ decls.push(JSON.parse(t));
1497
+ } catch {
1498
+ log.warn(`${host2.name}: ignoring an unreadable proxy declaration`);
1499
+ }
1500
+ }
1501
+ return decls;
1502
+ }
1503
+ function mergeDecls(decls) {
1504
+ const base = {
1505
+ app: "*",
1506
+ image: "traefik:v3.3",
1507
+ network: "epd",
1508
+ entrypoints: {},
1509
+ ssl: false,
1510
+ challenge: "http",
1511
+ dnsEnv: [],
1512
+ logLevel: "INFO",
1513
+ accessLog: false,
1514
+ dashboard: false,
1515
+ trustedIps: [],
1516
+ extra: {}
1517
+ };
1518
+ const owners = {};
1519
+ for (const d of decls) {
1520
+ for (const [name, port] of Object.entries(d.entrypoints)) {
1521
+ const existing = base.entrypoints[name];
1522
+ if (existing !== undefined && existing !== port) {
1523
+ throw new EpdError(`proxy entrypoint "${name}" is port ${existing} for ${owners[name]} but ${port} for ${d.app}`, "Entrypoints are shared by every app on the server. Give this one a different name.");
1524
+ }
1525
+ base.entrypoints[name] = port;
1526
+ owners[name] ??= d.app;
1527
+ }
1528
+ base.ssl ||= d.ssl;
1529
+ base.email ??= d.email;
1530
+ base.acmeServer ??= d.acmeServer;
1531
+ base.dnsProvider ??= d.dnsProvider;
1532
+ base.dnsEnv = [...new Set([...base.dnsEnv, ...d.dnsEnv])];
1533
+ base.trustedIps = [...new Set([...base.trustedIps, ...d.trustedIps])];
1534
+ base.accessLog ||= d.accessLog;
1535
+ base.dashboard ||= d.dashboard;
1536
+ base.dashboardHost ??= d.dashboardHost;
1537
+ base.extra = { ...base.extra, ...d.extra };
1538
+ base.image = d.image;
1539
+ base.version = d.version ?? base.version;
1540
+ base.network = d.network;
1541
+ base.logLevel = d.logLevel;
1542
+ if (d.ssl)
1543
+ base.challenge = d.challenge;
1544
+ }
1545
+ return base;
1546
+ }
1547
+ var CONTAINER_PATHS = { dynamicDir: "/etc/traefik/dynamic", acmeFile: "/acme/acme.json" };
1548
+ function staticConfig(d, where = CONTAINER_PATHS) {
1549
+ const entryPoints = {};
1550
+ for (const [name, port] of Object.entries(d.entrypoints)) {
1551
+ entryPoints[name] = {
1552
+ address: `:${port}`,
1553
+ ...d.trustedIps.length ? { forwardedHeaders: { trustedIPs: d.trustedIps } } : {}
1554
+ };
1555
+ }
1556
+ const acme = {
1557
+ storage: where.acmeFile,
1558
+ ...d.email ? { email: d.email } : {},
1559
+ ...d.acmeServer ? { caServer: d.acmeServer } : {}
1560
+ };
1561
+ if (d.challenge === "dns") {
1562
+ acme.dnsChallenge = { provider: d.dnsProvider, resolvers: ["1.1.1.1:53", "8.8.8.8:53"] };
1563
+ } else if (d.challenge === "tlsalpn") {
1564
+ acme.tlsChallenge = {};
1565
+ } else {
1566
+ acme.httpChallenge = { entryPoint: "web" };
1567
+ }
1568
+ return {
1569
+ global: { checkNewVersion: false, sendAnonymousUsage: false },
1570
+ log: { level: d.logLevel },
1571
+ ...d.accessLog ? { accessLog: {} } : {},
1572
+ ...d.dashboard ? { api: { dashboard: true } } : {},
1573
+ entryPoints,
1574
+ providers: {
1575
+ file: { directory: where.dynamicDir, watch: true },
1576
+ providersThrottleDuration: "1s"
1577
+ },
1578
+ ...d.ssl ? { certificatesResolvers: { [CERT_RESOLVER]: { acme } } } : {},
1579
+ ...d.extra
1580
+ };
1581
+ }
1582
+ function ruleFor(route) {
1583
+ const parts = [];
1584
+ if (route.hosts.length) {
1585
+ const hosts = route.hosts.map((h) => h.startsWith("*.") ? `HostRegexp(\`^[a-z0-9-]+\\.${escapeRegex(h.slice(2))}$\`)` : `Host(\`${h}\`)`);
1586
+ parts.push(hosts.length > 1 ? `(${hosts.join(" || ")})` : hosts[0]);
1587
+ }
1588
+ if (route.path)
1589
+ parts.push(`PathPrefix(\`${route.path}\`)`);
1590
+ if (!parts.length)
1591
+ return "PathPrefix(`/`)";
1592
+ return parts.join(" && ");
1593
+ }
1594
+ function escapeRegex(s) {
1595
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1596
+ }
1597
+ function dynamicConfig(cfg, endpoints, accessories = []) {
1598
+ const routers = {};
1599
+ const services = {};
1600
+ const middlewares = {};
1601
+ const app = cfg.name;
1602
+ const owners = [];
1603
+ for (const svc of cfg.services) {
1604
+ if (!svc.routes.length)
1605
+ continue;
1606
+ owners.push({
1607
+ key: `${app}-${svc.name}`,
1608
+ routes: svc.routes,
1609
+ defaultPort: svc.port,
1610
+ health: svc.health,
1611
+ urlsFor: (port) => endpoints[svc.name]?.[port] ?? []
1612
+ });
1613
+ }
1614
+ for (const { acc, urls } of accessories) {
1615
+ if (!acc.routes.length)
1616
+ continue;
1617
+ owners.push({
1618
+ key: `${app}-acc-${acc.name}`,
1619
+ routes: acc.routes,
1620
+ defaultPort: acc.port ?? 80,
1621
+ urlsFor: (port) => urls[port] ?? []
1622
+ });
1623
+ }
1624
+ for (const owner of owners) {
1625
+ owner.routes.forEach((route, i) => {
1626
+ const routerKey = `${owner.key}-${i}`;
1627
+ const entrypoint = route.entrypoint ?? (route.ssl ? "websecure" : "web");
1628
+ const mws = [];
1629
+ if (route.path && route.stripPath) {
1630
+ const key = `${routerKey}-strip`;
1631
+ middlewares[key] = { stripPrefix: { prefixes: [route.path] } };
1632
+ mws.push(key);
1633
+ }
1634
+ if (Object.keys(route.headers).length) {
1635
+ const key = `${routerKey}-headers`;
1636
+ middlewares[key] = { headers: { customRequestHeaders: route.headers } };
1637
+ mws.push(key);
1638
+ }
1639
+ if (route.basicAuth.length) {
1640
+ const key = `${routerKey}-auth`;
1641
+ middlewares[key] = { basicAuth: { users: route.basicAuth } };
1642
+ mws.push(key);
1643
+ }
1644
+ const rule = ruleFor(route);
1645
+ if (route.redirect) {
1646
+ const key = `${routerKey}-redirect`;
1647
+ middlewares[key] = {
1648
+ redirectRegex: { regex: "^https?://[^/]+(.*)", replacement: `${route.redirect.to.replace(/\/$/, "")}$1`, permanent: route.redirect.permanent }
1649
+ };
1650
+ const svcKey2 = `${owner.key}-${route.port}`;
1651
+ ensureService(services, svcKey2, owner.urlsFor(route.port), route, owner.health);
1652
+ routers[routerKey] = {
1653
+ rule,
1654
+ entryPoints: [entrypoint],
1655
+ middlewares: [...mws, key],
1656
+ service: svcKey2,
1657
+ ...route.ssl ? { tls: tlsFor(route) } : {},
1658
+ ...route.priority !== undefined ? { priority: route.priority } : {}
1659
+ };
1660
+ if (route.ssl && cfg.proxy.httpsRedirect) {
1661
+ routers[`${routerKey}-http`] = { rule, entryPoints: ["web"], middlewares: [key], service: svcKey2 };
1662
+ }
1663
+ return;
1664
+ }
1665
+ const svcKey = `${owner.key}-${route.port}`;
1666
+ ensureService(services, svcKey, owner.urlsFor(route.port), route, owner.health);
1667
+ routers[routerKey] = {
1668
+ rule,
1669
+ entryPoints: [entrypoint],
1670
+ service: svcKey,
1671
+ ...mws.length ? { middlewares: mws } : {},
1672
+ ...route.ssl ? { tls: tlsFor(route) } : {},
1673
+ ...route.priority !== undefined ? { priority: route.priority } : {}
1674
+ };
1675
+ if (route.ssl && cfg.proxy.httpsRedirect && entrypoint !== "web") {
1676
+ const key = `${app}-to-https`;
1677
+ middlewares[key] = { redirectScheme: { scheme: "https", permanent: true } };
1678
+ routers[`${routerKey}-http`] = {
1679
+ rule,
1680
+ entryPoints: ["web"],
1681
+ middlewares: [key],
1682
+ service: svcKey,
1683
+ ...route.priority !== undefined ? { priority: route.priority } : {}
1684
+ };
1685
+ }
1686
+ });
1687
+ }
1688
+ const http = {};
1689
+ if (Object.keys(routers).length)
1690
+ http.routers = routers;
1691
+ if (Object.keys(services).length)
1692
+ http.services = services;
1693
+ if (Object.keys(middlewares).length)
1694
+ http.middlewares = middlewares;
1695
+ return { http };
1696
+ }
1697
+ function tlsFor(route) {
1698
+ const wildcards = route.hosts.filter((h) => h.startsWith("*."));
1699
+ return {
1700
+ certResolver: CERT_RESOLVER,
1701
+ ...wildcards.length ? { domains: wildcards.map((w) => ({ main: w.slice(2), sans: [w] })) } : {}
1702
+ };
1703
+ }
1704
+ function ensureService(services, key, urls, route, health) {
1705
+ if (services[key])
1706
+ return;
1707
+ services[key] = {
1708
+ loadBalancer: {
1709
+ servers: urls.map((url) => ({ url })),
1710
+ passHostHeader: true,
1711
+ ...route.sticky ? { sticky: { cookie: { name: "epd_lb", httpOnly: true, secure: route.ssl } } } : {},
1712
+ ...health?.path ? { healthCheck: { path: health.path, interval: "10s", timeout: "3s", ...health.hostHeader ? { hostname: health.hostHeader } : {} } } : {}
1713
+ }
1714
+ };
1715
+ }
1716
+ function containerSpec(d, cfg, staticHash) {
1717
+ const p = paths(cfg);
1718
+ const args = [
1719
+ "docker",
1720
+ "run",
1721
+ "-d",
1722
+ "--name",
1723
+ PROXY_CONTAINER,
1724
+ "--restart",
1725
+ "unless-stopped",
1726
+ "--network",
1727
+ d.network,
1728
+ "--label",
1729
+ "epd.role=proxy",
1730
+ "--label",
1731
+ `epd.spec=${staticHash}`,
1732
+ "-v",
1733
+ `${p.proxyStatic}:/etc/traefik/traefik.yml:ro`,
1734
+ "-v",
1735
+ `${p.proxyDynamicDir}:/etc/traefik/dynamic:ro`,
1736
+ "-v",
1737
+ `${p.acmeDir}:/acme`
1738
+ ];
1739
+ for (const port of Object.values(d.entrypoints))
1740
+ args.push("-p", `${port}:${port}`);
1741
+ if (d.dnsEnv.length)
1742
+ args.push("--env-file", p.proxyEnv);
1743
+ args.push(d.image);
1744
+ return args;
1745
+ }
1746
+ async function ensureProxy(host2, cfg, opts = {}) {
1747
+ if (!cfg.proxy.enabled)
1748
+ return;
1749
+ const p = paths(cfg);
1750
+ const containerMode = cfg.mode === "docker";
1751
+ await host2.writeFile(p.proxyApp, JSON.stringify(declFor(cfg), null, 2), "644");
1752
+ const merged = mergeDecls(await readDecls(host2, cfg));
1753
+ if (!Object.keys(merged.entrypoints).length)
1754
+ merged.entrypoints = cfg.proxy.entrypoints;
1755
+ const where = containerMode ? CONTAINER_PATHS : { dynamicDir: p.proxyDynamicDir, acmeFile: `${p.acmeDir}/acme.json` };
1756
+ const staticJson = JSON.stringify(staticConfig(merged, where), null, 2);
1757
+ await host2.writeFile(p.proxyStatic, staticJson, "644");
1758
+ if (merged.dnsEnv.length) {
1759
+ const lines = merged.dnsEnv.map((name) => {
1760
+ const value = opts.secrets?.[name];
1761
+ if (value === undefined) {
1762
+ throw new EpdError(`proxy.dns_env: ${name} is not set locally`, "Export it or add it to .env \u2014 traefik needs it to answer the DNS challenge.");
1763
+ }
1764
+ return `${name}=${value}`;
1765
+ });
1766
+ await host2.writeFile(p.proxyEnv, lines.join(`
1767
+ `), "600");
1768
+ }
1769
+ if (merged.dashboard && merged.dashboardHost) {
1770
+ const dash = {
1771
+ http: {
1772
+ routers: {
1773
+ "epd-dashboard": {
1774
+ rule: `Host(\`${merged.dashboardHost}\`)`,
1775
+ entryPoints: [merged.ssl ? "websecure" : "web"],
1776
+ service: "api@internal",
1777
+ ...merged.ssl ? { tls: { certResolver: CERT_RESOLVER } } : {}
1778
+ }
1779
+ }
1780
+ }
1781
+ };
1782
+ await host2.writeFile(`${p.proxyDynamicDir}/_dashboard.yml`, JSON.stringify(dash, null, 2), "644");
1783
+ }
1784
+ const specHash = hash32(staticJson + JSON.stringify(merged)).toString(36);
1785
+ if (containerMode)
1786
+ await ensureProxyContainer(host2, cfg, merged, specHash, opts);
1787
+ else
1788
+ await ensureProxyProcess(host2, cfg, merged, specHash, opts);
1789
+ }
1790
+ async function ensureProxyContainer(host2, cfg, merged, specHash, opts) {
1791
+ const spec = containerSpec(merged, cfg, specHash);
1792
+ const state = await host2.capture(`docker inspect -f '{{.State.Running}} {{index .Config.Labels "epd.spec"}}' ${PROXY_CONTAINER} 2>/dev/null || echo "missing"`);
1793
+ const [running, currentHash] = state.split(/\s+/);
1794
+ if (!opts.force && running === "true" && currentHash === specHash) {
1795
+ log.host(host2.name, "proxy is up to date");
1796
+ return;
1797
+ }
1798
+ await host2.exec(`docker network inspect ${shq(merged.network)} >/dev/null 2>&1 || docker network create ${shq(merged.network)} >/dev/null
1799
+ docker rm -f ${PROXY_CONTAINER} >/dev/null 2>&1 || true
1800
+ docker pull ${shq(merged.image)} >/dev/null
1801
+ ${spec.map((a) => shq(a)).join(" ")} >/dev/null
1802
+ sleep 1
1803
+ docker inspect -f '{{.State.Running}}' ${PROXY_CONTAINER} | grep -q true || {
1804
+ echo "traefik failed to start:" >&2
1805
+ docker logs --tail 40 ${PROXY_CONTAINER} >&2 || true
1806
+ exit 1
1807
+ }`, { label: `${host2.name}: start proxy` });
1808
+ log.host(host2.name, `proxy running (${entrypointSummary(merged)})`);
1809
+ }
1810
+ var TRAEFIK_FALLBACK_VERSION = "v3.3.7";
1811
+ async function ensureProxyProcess(host2, cfg, merged, specHash, opts) {
1812
+ const p = paths(cfg);
1813
+ const wanted = merged.version ?? (/^traefik:(v\d+\.\d+\.\d+)$/.exec(merged.image)?.[1] ?? "");
1814
+ const needsPrivilegedPort = Object.values(merged.entrypoints).some((port) => port < 1024);
1815
+ await host2.exec(`mkdir -p ${shq(`${p.proxyDir}/bin`)} ${shq(p.proxyLogDir)}
1816
+ want=${shq(wanted)}
1817
+ if [ -z "$want" ]; then
1818
+ # Piping curl straight into grep -m1 closes the pipe early, which pipefail
1819
+ # reports as a failure \u2014 capture first, then match.
1820
+ release_json=$(curl -fsSL --max-time 10 https://api.github.com/repos/traefik/traefik/releases/latest 2>/dev/null || true)
1821
+ want=$(printf '%s' "$release_json" | grep -m1 '"tag_name"' | cut -d'"' -f4 || true)
1822
+ fi
1823
+ [ -z "$want" ] && want=${TRAEFIK_FALLBACK_VERSION}
1824
+
1825
+ have=""
1826
+ if [ -x ${shq(p.proxyBin)} ]; then
1827
+ version_out=$(${shq(p.proxyBin)} version 2>/dev/null || true)
1828
+ have=$(printf '%s' "$version_out" | awk '/[Vv]ersion:/ {print $2; exit}' || true)
1829
+ [ -n "$have" ] && have="v\${have#v}"
1830
+ fi
1831
+
1832
+ if [ "$have" != "$want" ]; then
1833
+ case "$(uname -m)" in
1834
+ x86_64|amd64) arch=amd64 ;;
1835
+ aarch64|arm64) arch=arm64 ;;
1836
+ armv7l) arch=armv7 ;;
1837
+ *) echo "unsupported architecture $(uname -m) for the traefik binary" >&2; exit 1 ;;
1838
+ esac
1839
+ tmp=$(mktemp -d)
1840
+ url="https://github.com/traefik/traefik/releases/download/\${want}/traefik_\${want}_linux_\${arch}.tar.gz"
1841
+ echo "downloading traefik \${want} (\${arch})"
1842
+ curl -fsSL "$url" -o "$tmp/traefik.tgz"
1843
+ tar -xzf "$tmp/traefik.tgz" -C "$tmp" traefik
1844
+ install -m 0755 "$tmp/traefik" ${shq(p.proxyBin)}
1845
+ rm -rf "$tmp"
1846
+ fi
1847
+ ${needsPrivilegedPort ? `command -v setcap >/dev/null 2>&1 && setcap 'cap_net_bind_service=+ep' ${shq(p.proxyBin)} 2>/dev/null || echo "note: could not grant the traefik binary permission to bind ports below 1024" >&2` : ""}
1848
+ true`, { stream: true, label: `${host2.name}: install traefik` });
1849
+ const ecosystem = {
1850
+ apps: [
1851
+ {
1852
+ name: PROXY_PROCESS,
1853
+ script: p.proxyBin,
1854
+ args: [`--configFile=${p.proxyStatic}`],
1855
+ interpreter: "none",
1856
+ exec_mode: "fork",
1857
+ autorestart: true,
1858
+ max_restarts: 20,
1859
+ restart_delay: 2000,
1860
+ merge_logs: true,
1861
+ time: true,
1862
+ out_file: `${p.proxyLogDir}/out.log`,
1863
+ error_file: `${p.proxyLogDir}/err.log`,
1864
+ env: { EPD_PROXY_SPEC: specHash, ...Object.fromEntries(merged.dnsEnv.map((n) => [n, opts.secrets?.[n] ?? ""])) }
1865
+ }
1866
+ ]
1867
+ };
1868
+ await host2.writeFile(p.proxyEcosystem, JSON.stringify(ecosystem, null, 2), "600");
1869
+ const jlist = await host2.capture(`${pathPrelude(cfg)} pm2 jlist 2>/dev/null || echo '[]'`, {
1870
+ sudo: false,
1871
+ allowFailure: true
1872
+ });
1873
+ const running = await host2.test(`${pathPrelude(cfg)} pm2 describe ${PROXY_PROCESS} >/dev/null 2>&1`);
1874
+ if (!opts.force && running && jlist.includes(specHash)) {
1875
+ log.host(host2.name, "proxy is up to date");
1876
+ return;
1877
+ }
1878
+ await host2.exec(`${pathPrelude(cfg)} pm2 delete ${PROXY_PROCESS} >/dev/null 2>&1 || true
1879
+ ${pathPrelude(cfg)} pm2 start ${shq(p.proxyEcosystem)} --update-env
1880
+ ${pathPrelude(cfg)} pm2 save --force >/dev/null 2>&1 || true
1881
+ sleep 2
1882
+ ${pathPrelude(cfg)} pm2 describe ${PROXY_PROCESS} | grep -q online || {
1883
+ echo "traefik failed to start:" >&2
1884
+ tail -n 40 ${shq(`${p.proxyLogDir}/err.log`)} >&2 2>/dev/null || true
1885
+ exit 1
1886
+ }`, { sudo: false, label: `${host2.name}: start proxy` });
1887
+ log.host(host2.name, `proxy running (${entrypointSummary(merged)})`);
1888
+ }
1889
+ function entrypointSummary(d) {
1890
+ return Object.entries(d.entrypoints).map(([name, port]) => `${name}:${port}`).join(" ");
1891
+ }
1892
+ async function writeRoutes(host2, cfg, endpoints, accessories = []) {
1893
+ if (!cfg.proxy.enabled)
1894
+ return;
1895
+ const doc = dynamicConfig(cfg, endpoints, accessories);
1896
+ await host2.writeFile(paths(cfg).proxyDynamic, JSON.stringify(doc, null, 2), "644");
1897
+ await host2.exec(`sleep ${Math.max(1, cfg.proxy.reloadWait)}`);
1898
+ }
1899
+ async function removeRoutes(host2, cfg) {
1900
+ await host2.exec(`rm -f ${shq(paths(cfg).proxyDynamic)} ${shq(paths(cfg).proxyApp)}`, { allowFailure: true });
1901
+ }
1902
+ async function proxyState(host2, cfg) {
1903
+ if (cfg.mode === "docker") {
1904
+ const out2 = await host2.capture(`docker inspect -f '{{.State.Running}}|{{.Config.Image}}|{{.State.Status}}' ${PROXY_CONTAINER} 2>/dev/null || echo 'false||absent'`, { allowFailure: true });
1905
+ const [running, image, status2] = out2.split("|");
1906
+ return { running: running === "true", image: image ?? "", status: status2 ?? "absent" };
1907
+ }
1908
+ const out = await host2.capture(`describe=$(${pathPrelude(cfg)} pm2 describe ${PROXY_PROCESS} 2>/dev/null || true)
1909
+ printf '%s' "$describe" | awk -F'\u2502' '/status/ {gsub(/ /,"",$3); print $3; exit}' || true
1910
+ version_out=$(${shq(paths(cfg).proxyBin)} version 2>/dev/null || true)
1911
+ printf '%s' "$version_out" | awk '/[Vv]ersion:/ {print "traefik " $2; exit}' || true`, { sudo: false, allowFailure: true });
1912
+ const lines = out.split(`
1913
+ `).map((l) => l.trim()).filter(Boolean);
1914
+ const status = lines.find((l) => !l.startsWith("traefik")) ?? "absent";
1915
+ return { running: status === "online", image: lines.find((l) => l.startsWith("traefik")) ?? "", status };
1916
+ }
1917
+
1918
+ // src/core/lock.ts
1919
+ function who() {
1920
+ return `${process.env.USER ?? process.env.LOGNAME ?? "someone"}@${process.env.HOSTNAME ?? "local"}`;
1921
+ }
1922
+
1923
+ class DeployLock {
1924
+ cfg;
1925
+ hosts;
1926
+ held = [];
1927
+ constructor(cfg, hosts) {
1928
+ this.cfg = cfg;
1929
+ this.hosts = hosts;
1930
+ }
1931
+ async acquire(what) {
1932
+ const dir = paths(this.cfg).lock;
1933
+ const info = { by: who(), at: new Date().toISOString(), what };
1934
+ for (const host2 of this.hosts) {
1935
+ const res = await host2.exec(`mkdir -p ${shq(paths(this.cfg).app)}
1936
+ if mkdir ${shq(dir)} 2>/dev/null; then
1937
+ printf '%s' ${shq(JSON.stringify(info))} > ${shq(dir)}/info
1938
+ echo acquired
1939
+ else
1940
+ cat ${shq(dir)}/info 2>/dev/null || echo '{}'
1941
+ exit 9
1942
+ fi`, { allowFailure: true });
1943
+ if (res.code !== 0) {
1944
+ await this.release();
1945
+ let held = {};
1946
+ try {
1947
+ held = JSON.parse(res.stdout || "{}");
1948
+ } catch {}
1949
+ throw new EpdError(`${this.cfg.name} is locked on ${host2.name}`, held.by ? `Held by ${held.by} since ${held.at} (${held.what}).
1950
+ If that deploy died, run: epd lock release` : "Run `epd lock release` if a previous deploy was interrupted.");
1951
+ }
1952
+ this.held.push(host2);
1953
+ }
1954
+ log.debug(`lock acquired on ${this.held.length} host(s)`);
1955
+ }
1956
+ async release() {
1957
+ const dir = paths(this.cfg).lock;
1958
+ for (const host2 of this.held.splice(0)) {
1959
+ await host2.exec(`rm -rf ${shq(dir)}`, { allowFailure: true });
1960
+ }
1961
+ }
1962
+ static async forceRelease(cfg, hosts) {
1963
+ for (const host2 of hosts)
1964
+ await host2.exec(`rm -rf ${shq(paths(cfg).lock)}`, { allowFailure: true });
1965
+ }
1966
+ static async status(cfg, hosts) {
1967
+ const out = [];
1968
+ for (const host2 of hosts) {
1969
+ const raw = await host2.readFile(`${paths(cfg).lock}/info`);
1970
+ let info = null;
1971
+ if (raw) {
1972
+ try {
1973
+ info = JSON.parse(raw);
1974
+ } catch {
1975
+ info = { by: "unknown", at: "unknown", what: "unknown" };
1976
+ }
1977
+ }
1978
+ out.push({ host: host2.name, info });
1979
+ }
1980
+ return out;
1981
+ }
1982
+ }
1983
+ async function withLock(cfg, hosts, what, skip, fn) {
1984
+ if (skip)
1985
+ return fn();
1986
+ const lock = new DeployLock(cfg, hosts);
1987
+ await lock.acquire(what);
1988
+ try {
1989
+ return await fn();
1990
+ } finally {
1991
+ await lock.release();
1992
+ }
1993
+ }
1994
+
1995
+ // src/core/probe.ts
1996
+ function statusCondition(spec) {
1997
+ const parts = spec.split(",").map((s) => s.trim()).filter(Boolean);
1998
+ if (!parts.length)
1999
+ throw new EpdError(`healthcheck.status is empty`);
2000
+ const tests = parts.map((p) => {
2001
+ const m = /^(\d{3})\s*-\s*(\d{3})$/.exec(p);
2002
+ if (m)
2003
+ return `{ [ "$code" -ge ${m[1]} ] && [ "$code" -le ${m[2]} ]; }`;
2004
+ if (/^\d{3}$/.test(p))
2005
+ return `[ "$code" -eq ${p} ]`;
2006
+ throw new EpdError(`healthcheck.status: cannot parse ${JSON.stringify(p)}`, 'Use "200", "200-399" or a comma separated list.');
2007
+ });
2008
+ return tests.join(" || ");
2009
+ }
2010
+ function healthScript(targets, health) {
2011
+ const list = targets.map((t) => `${t.label} ${t.address} ${t.port}`).join(`
2012
+ `);
2013
+ const tcpOnly = health.path === null;
2014
+ const path = health.path ?? "/";
2015
+ const hostHeader = health.hostHeader ?? "";
2016
+ return `
2017
+ EPD_TIMEOUT=${health.timeout}
2018
+ EPD_INTERVAL=${health.interval}
2019
+ EPD_PATH=${shq(path)}
2020
+ EPD_HOSTHDR=${shq(hostHeader)}
2021
+
2022
+ check_tcp() {
2023
+ (exec 3<>"/dev/tcp/$1/$2") >/dev/null 2>&1 && echo 200 || echo 000
2024
+ }
2025
+
2026
+ check_http() {
2027
+ addr="$1"; port="$2"
2028
+ hdr_host="\${EPD_HOSTHDR:-$addr}"
2029
+ if command -v curl >/dev/null 2>&1; then
2030
+ curl -s -o /dev/null -w '%{http_code}' --max-time 5 -H "Host: $hdr_host" \\
2031
+ "http://$addr:$port$EPD_PATH" 2>/dev/null || echo 000
2032
+ elif command -v wget >/dev/null 2>&1; then
2033
+ code=$(wget -q -S -O /dev/null --timeout=5 --header="Host: $hdr_host" \\
2034
+ "http://$addr:$port$EPD_PATH" 2>&1 | awk '/^[[:space:]]*HTTP\\//{c=$2} END{print c+0}')
2035
+ echo "\${code:-000}"
2036
+ else
2037
+ if ! (exec 3<>"/dev/tcp/$addr/$port") 2>/dev/null; then echo 000; return; fi
2038
+ exec 3<>"/dev/tcp/$addr/$port"
2039
+ printf 'GET %s HTTP/1.1\\r\\nHost: %s\\r\\nConnection: close\\r\\nUser-Agent: epd\\r\\n\\r\\n' \\
2040
+ "$EPD_PATH" "$hdr_host" >&3
2041
+ line=$(head -n 1 <&3 2>/dev/null || true)
2042
+ exec 3<&- 3>&- 2>/dev/null || true
2043
+ echo "$line" | awk '{print $2+0}'
2044
+ fi
2045
+ }
2046
+
2047
+ deadline=$(( $(date +%s) + EPD_TIMEOUT ))
2048
+ sleep ${health.delay}
2049
+ last=""
2050
+ while :; do
2051
+ pending=""
2052
+ last=""
2053
+ while read -r name addr port; do
2054
+ [ -z "\${name:-}" ] && continue
2055
+ code=$(${tcpOnly ? 'check_tcp "$addr" "$port"' : 'check_http "$addr" "$port"'})
2056
+ if ${statusCondition(health.status)}; then
2057
+ :
2058
+ else
2059
+ pending="$pending $name"
2060
+ last="$name -> $code"
2061
+ fi
2062
+ done <<'EPD_TARGETS'
2063
+ ${list}
2064
+ EPD_TARGETS
2065
+ if [ -z "$pending" ]; then
2066
+ echo "EPD_HEALTHY"
2067
+ exit 0
2068
+ fi
2069
+ if [ "$(date +%s)" -ge "$deadline" ]; then
2070
+ echo "EPD_UNHEALTHY:$pending (last: $last)"
2071
+ exit 1
2072
+ fi
2073
+ sleep $EPD_INTERVAL
2074
+ done
2075
+ `;
2076
+ }
2077
+ async function waitHealthy(host2, targets, health) {
2078
+ if (!targets.length)
2079
+ return;
2080
+ const res = await host2.exec(healthScript(targets, health), {
2081
+ allowFailure: true,
2082
+ label: `${host2.name}: health check`
2083
+ });
2084
+ if (res.code === 0 && res.stdout.includes("EPD_HEALTHY"))
2085
+ return;
2086
+ const detail = res.stdout.replace("EPD_UNHEALTHY:", "").trim() || res.stderr;
2087
+ throw new EpdError(`${host2.name}: new version did not become healthy within ${health.timeout}s`, `Still failing:${detail ? ` ${detail}` : ""}
2088
+ Check \`epd logs\` \u2014 the old version is still serving traffic.`);
2089
+ }
2090
+
2091
+ // src/core/state.ts
2092
+ async function readState(host2, cfg) {
2093
+ const raw = await host2.readFile(paths(cfg).state);
2094
+ if (!raw)
2095
+ return null;
2096
+ try {
2097
+ return JSON.parse(raw);
2098
+ } catch {
2099
+ log.warn(`${host2.name}: state file is corrupt, treating this host as fresh`);
2100
+ return null;
2101
+ }
2102
+ }
2103
+ async function writeState(host2, cfg, state) {
2104
+ await host2.writeFile(paths(cfg).state, JSON.stringify(state, null, 2), "644");
2105
+ }
2106
+ function emptyState(cfg) {
2107
+ return {
2108
+ app: cfg.name,
2109
+ mode: cfg.mode,
2110
+ version: "",
2111
+ updatedAt: new Date().toISOString(),
2112
+ destination: cfg.destination,
2113
+ services: {},
2114
+ history: []
2115
+ };
2116
+ }
2117
+ function recordDeploy(state, version, by, keep) {
2118
+ const history = [{ version, at: new Date().toISOString(), by }, ...state.history.filter((h) => h.version !== version)];
2119
+ return { ...state, version, updatedAt: new Date().toISOString(), history: history.slice(0, Math.max(keep, 2) * 2) };
2120
+ }
2121
+
2122
+ // src/deploy/image.ts
2123
+ import { existsSync as existsSync3 } from "fs";
2124
+ import { join as join4 } from "path";
2125
+ async function resolveVersion(cfg, explicit) {
2126
+ if (explicit)
2127
+ return { id: explicit, source: "explicit", dirty: false };
2128
+ if (existsSync3(join4(cfg.root, ".git")) && await which("git")) {
2129
+ const sha = await run(["git", "-C", cfg.root, "rev-parse", "--short=12", "HEAD"], { allowFailure: true });
2130
+ if (sha.code === 0 && sha.stdout) {
2131
+ const status = await run(["git", "-C", cfg.root, "status", "--porcelain"], { allowFailure: true });
2132
+ const dirty = status.stdout.length > 0;
2133
+ return { id: dirty ? `${sha.stdout}-dirty-${timestamp()}` : sha.stdout, source: "git", dirty };
2134
+ }
2135
+ }
2136
+ return { id: timestamp(), source: "timestamp", dirty: false };
2137
+ }
2138
+ var imageRef = (cfg, version) => `${cfg.image}:${version}`;
2139
+ async function hasBuildx() {
2140
+ return (await run(["docker", "buildx", "version"], { allowFailure: true })).code === 0;
2141
+ }
2142
+ async function buildImage(cfg, version, env) {
2143
+ const dockerfile = join4(cfg.root, cfg.build.dockerfile);
2144
+ if (!existsSync3(dockerfile)) {
2145
+ throw new EpdError(`${cfg.build.dockerfile} not found`, "Add a Dockerfile, point build.dockerfile at yours, or switch to `mode: process`.");
2146
+ }
2147
+ const ref = imageRef(cfg, version);
2148
+ log.step(`Building ${ref}`);
2149
+ if (await hasBuildx())
2150
+ await buildWithBuildx(cfg, ref, version, env);
2151
+ else
2152
+ await buildWithLegacyDocker(cfg, ref, version, env);
2153
+ }
2154
+ async function buildWithBuildx(cfg, ref, version, env) {
2155
+ const args = ["docker", "buildx", "build"];
2156
+ args.push("--platform", cfg.build.platform);
2157
+ args.push("--file", join4(cfg.root, cfg.build.dockerfile));
2158
+ args.push("--tag", ref, "--tag", `${cfg.image}:latest`);
2159
+ if (cfg.build.target)
2160
+ args.push("--target", cfg.build.target);
2161
+ for (const [k, v] of Object.entries(cfg.build.args))
2162
+ args.push("--build-arg", `${k}=${v}`);
2163
+ for (const [id, envName] of Object.entries(cfg.build.secrets)) {
2164
+ if (env[envName] === undefined)
2165
+ throw new EpdError(`build.secrets: ${envName} is not set`);
2166
+ args.push("--secret", `id=${id},env=${envName}`);
2167
+ }
2168
+ args.push("--label", `epd.app=${cfg.name}`, "--label", `epd.version=${version}`);
2169
+ if (cfg.registry) {
2170
+ args.push("--push");
2171
+ if (cfg.build.cache) {
2172
+ args.push("--cache-from", `type=registry,ref=${cfg.image}:buildcache`);
2173
+ args.push("--cache-to", `type=registry,ref=${cfg.image}:buildcache,mode=max`);
2174
+ }
2175
+ } else {
2176
+ args.push("--load");
2177
+ }
2178
+ args.push(cfg.build.context);
2179
+ await ensureBuilder();
2180
+ await run(args, { cwd: cfg.root, stream: true, env, label: "docker buildx build" });
2181
+ }
2182
+ async function buildWithLegacyDocker(cfg, ref, version, env) {
2183
+ if (Object.keys(cfg.build.secrets).length) {
2184
+ throw new EpdError("build.secrets needs BuildKit", "Install the docker buildx plugin: https://docs.docker.com/go/buildx/");
2185
+ }
2186
+ const localPlatform = `linux/${process.arch === "arm64" ? "arm64" : "amd64"}`;
2187
+ if (cfg.build.platform !== localPlatform) {
2188
+ log.warn(`buildx is not installed, so the image is built for ${localPlatform} rather than ${cfg.build.platform}. ` + `Install buildx to cross-build, or set build.platform: ${localPlatform}.`);
2189
+ }
2190
+ const args = ["docker", "build", "--file", join4(cfg.root, cfg.build.dockerfile), "--tag", ref, "--tag", `${cfg.image}:latest`];
2191
+ if (cfg.build.target)
2192
+ args.push("--target", cfg.build.target);
2193
+ for (const [k, v] of Object.entries(cfg.build.args))
2194
+ args.push("--build-arg", `${k}=${v}`);
2195
+ args.push("--label", `epd.app=${cfg.name}`, "--label", `epd.version=${version}`);
2196
+ args.push(cfg.build.context);
2197
+ await run(args, { cwd: cfg.root, stream: true, env, label: "docker build" });
2198
+ if (cfg.registry) {
2199
+ log.step(`Pushing ${ref}`);
2200
+ await run(["docker", "push", ref], { stream: true });
2201
+ await run(["docker", "push", `${cfg.image}:latest`], { stream: true, allowFailure: true });
2202
+ }
2203
+ }
2204
+ async function ensureBuilder() {
2205
+ const res = await run(["docker", "buildx", "inspect", "epd"], { allowFailure: true });
2206
+ if (res.code === 0) {
2207
+ await run(["docker", "buildx", "use", "epd"], { allowFailure: true });
2208
+ return;
2209
+ }
2210
+ log.info('creating buildx builder "epd"');
2211
+ await run(["docker", "buildx", "create", "--name", "epd", "--use", "--bootstrap"], { stream: true });
2212
+ }
2213
+ async function loginRegistry(host2, cfg, env) {
2214
+ if (!cfg.registry)
2215
+ return;
2216
+ const password = env[cfg.registry.passwordEnv];
2217
+ if (!password) {
2218
+ throw new EpdError(`registry password: ${cfg.registry.passwordEnv} is not set`, "Export it, or put it in .env next to epd.yml.");
2219
+ }
2220
+ const server = cfg.registry.server ?? "";
2221
+ await host2.exec(`printf '%s' ${shq(password)} | docker login ${server ? shq(server) + " " : ""}--username ${shq(cfg.registry.username)} --password-stdin >/dev/null`, { label: `${host2.name}: docker login` });
2222
+ }
2223
+ async function ensureImage(host2, cfg, version) {
2224
+ const ref = imageRef(cfg, version);
2225
+ if (await host2.test(`docker image inspect ${shq(ref)} >/dev/null 2>&1`)) {
2226
+ log.host(host2.name, `image ${version} already present`);
2227
+ return;
2228
+ }
2229
+ if (cfg.registry) {
2230
+ log.host(host2.name, `pulling ${ref}`);
2231
+ await host2.exec(`docker pull ${shq(ref)} >/dev/null`, { label: `${host2.name}: docker pull` });
2232
+ } else {
2233
+ log.host(host2.name, `shipping ${ref} over ssh (no registry configured)`);
2234
+ await host2.pipeInto("gunzip | docker load", ["/bin/sh", "-c", `docker save ${shq(ref)} | gzip -1`]);
2235
+ }
2236
+ }
2237
+ async function pruneImages(host2, cfg, keep, inUse) {
2238
+ const keepRefs = inUse.map((v) => imageRef(cfg, v));
2239
+ await host2.exec(`keep=${shq(keepRefs.join(" "))}
2240
+ docker images --filter "label=epd.app=${cfg.name}" --format '{{.Repository}}:{{.Tag}} {{.CreatedAt}}' 2>/dev/null \\
2241
+ | sort -k2 -r | tail -n +$(( ${keep} + 1 )) | awk '{print $1}' | while read -r img; do
2242
+ case " $keep " in *" $img "*) continue;; esac
2243
+ case "$img" in *:latest|*:buildcache) continue;; esac
2244
+ docker rmi "$img" >/dev/null 2>&1 || true
2245
+ done
2246
+ docker image prune -f --filter "label=epd.app=${cfg.name}" >/dev/null 2>&1 || true`, { allowFailure: true });
2247
+ }
2248
+
2249
+ // src/deploy/docker.ts
2250
+ function parseCommand(cmd) {
2251
+ if (/[|&;<>$`(){}[\]*?~\\]/.test(cmd))
2252
+ return ["sh", "-c", cmd];
2253
+ return cmd.trim().split(/\s+/);
2254
+ }
2255
+ function resolveSecrets(names, env, where) {
2256
+ const out = {};
2257
+ const missing = [];
2258
+ for (const name of names) {
2259
+ const v = env[name];
2260
+ if (v === undefined)
2261
+ missing.push(name);
2262
+ else
2263
+ out[name] = v;
2264
+ }
2265
+ if (missing.length) {
2266
+ throw new EpdError(`${where}: missing secret${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`, "Export them, or add them to .env next to epd.yml. epd never stores secrets in the repo.");
2267
+ }
2268
+ return out;
2269
+ }
2270
+ function envFileContent(vars) {
2271
+ return Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(`
2272
+ `);
2273
+ }
2274
+ async function writeServiceEnv(host2, cfg, svc, env) {
2275
+ const secrets = resolveSecrets(svc.secrets, env, `servers.${svc.name}`);
2276
+ const file = `${paths(cfg).app}/env.${svc.name}`;
2277
+ await host2.writeFile(file, envFileContent({ ...svc.env, ...secrets }), "600");
2278
+ return file;
2279
+ }
2280
+ function dockerRunArgs(o) {
2281
+ const { cfg, svc, slot, version, replica, serverHost, envFile } = o;
2282
+ const name = containerName(cfg.name, svc.name, slot, replica);
2283
+ const args = ["docker", "run"];
2284
+ if (o.detach !== false)
2285
+ args.push("-d");
2286
+ args.push("--name", name, "--hostname", `${name}.${cfg.name}`, "--restart", "unless-stopped", "--network", cfg.proxy.network, "--label", `epd.app=${cfg.name}`, "--label", `epd.service=${svc.name}`, "--label", `epd.slot=${slot}`, "--label", `epd.version=${version}`, "--label", `epd.replica=${replica}`, "--label", "epd.managed=true", "--env-file", envFile, "--env", `PORT=${svc.port}`, "--env", `EPD_APP=${cfg.name}`, "--env", `EPD_SERVICE=${svc.name}`, "--env", `EPD_REPLICA=${replica}`, "--env", `EPD_VERSION=${version}`, "--stop-timeout", String(svc.stopTimeout), "--log-opt", "max-size=10m", "--log-opt", "max-file=3");
2287
+ for (const [k, v] of Object.entries(svc.labels))
2288
+ args.push("--label", `${k}=${v}`);
2289
+ for (const v of svc.volumes)
2290
+ args.push("--volume", v);
2291
+ if (svc.cpus)
2292
+ args.push("--cpus", svc.cpus);
2293
+ if (svc.memory)
2294
+ args.push("--memory", svc.memory);
2295
+ if (usesHostPorts(cfg)) {
2296
+ args.push("--publish", `${bindAddress(cfg, serverHost)}:${hostPort(cfg, svc, slot, replica)}:${svc.port}`);
2297
+ }
2298
+ args.push(...svc.dockerOptions);
2299
+ args.push(imageRef(cfg, version));
2300
+ if (svc.command)
2301
+ args.push(...parseCommand(svc.command));
2302
+ return args;
2303
+ }
2304
+ async function startSlot(host2, cfg, svc, slot, version, envFile) {
2305
+ const names = [];
2306
+ const lines = [
2307
+ `old=$(docker ps -a -q --filter "label=epd.app=${cfg.name}" --filter "label=epd.service=${svc.name}" --filter "label=epd.slot=${slot}")`,
2308
+ `[ -n "$old" ] && docker rm -f $old >/dev/null 2>&1 || true`
2309
+ ];
2310
+ for (let i = 0;i < svc.replicas; i++) {
2311
+ const name = containerName(cfg.name, svc.name, slot, i);
2312
+ names.push(name);
2313
+ const args = dockerRunArgs({ cfg, svc, slot, version, replica: i, serverHost: host2.name, envFile });
2314
+ lines.push(`docker rm -f ${shq(name)} >/dev/null 2>&1 || true`);
2315
+ lines.push(`${args.map((a) => shq(a)).join(" ")} >/dev/null`);
2316
+ }
2317
+ await host2.exec(lines.join(`
2318
+ `), { label: `${host2.name}: start ${svc.name}/${slot}` });
2319
+ return names;
2320
+ }
2321
+ async function containerIps(host2, cfg, names) {
2322
+ if (!names.length)
2323
+ return {};
2324
+ const tpl = `{{ with (index .NetworkSettings.Networks ${JSON.stringify(cfg.proxy.network)}) }}{{ .IPAddress }}{{ end }}`;
2325
+ const out = await host2.capture(names.map((n) => `echo "${n} $(docker inspect -f ${shq(tpl)} ${shq(n)} 2>/dev/null)"`).join(`
2326
+ `));
2327
+ const map = {};
2328
+ for (const line of out.split(`
2329
+ `)) {
2330
+ const [name, ip] = line.trim().split(/\s+/);
2331
+ if (name && ip)
2332
+ map[name] = ip;
2333
+ }
2334
+ return map;
2335
+ }
2336
+ async function stopSlot(host2, cfg, svc, slot, opts) {
2337
+ const teardown = `names=$(docker ps -a -q --filter "label=epd.app=${cfg.name}" --filter "label=epd.service=${svc.name}" --filter "label=epd.slot=${slot}")
2338
+ if [ -n "$names" ]; then
2339
+ sleep ${opts.drain}
2340
+ docker stop --timeout ${svc.stopTimeout} $names >/dev/null 2>&1 || true
2341
+ ${opts.remove ? "docker rm -f $names >/dev/null 2>&1 || true" : ""}
2342
+ fi`;
2343
+ await host2.exec(opts.detach ? `nohup setsid bash -c ${shq(teardown)} >/dev/null 2>&1 < /dev/null &
2344
+ disown 2>/dev/null || true` : teardown, { allowFailure: true, label: `${host2.name}: stop ${svc.name}/${slot}` });
2345
+ }
2346
+ async function removeApp(host2, cfg) {
2347
+ await host2.exec(`ids=$(docker ps -a -q --filter "label=epd.app=${cfg.name}")
2348
+ [ -n "$ids" ] && docker rm -f $ids >/dev/null 2>&1 || true`, { allowFailure: true });
2349
+ }
2350
+ async function listContainers(host2, cfg) {
2351
+ const fmt = [
2352
+ "{{.Names}}",
2353
+ '{{.Label "epd.service"}}',
2354
+ '{{.Label "epd.slot"}}',
2355
+ '{{.Label "epd.version"}}',
2356
+ '{{.Label "epd.replica"}}',
2357
+ "{{.Status}}",
2358
+ "{{.State}}",
2359
+ "{{.Image}}",
2360
+ "{{.CreatedAt}}"
2361
+ ].join("\t");
2362
+ const out = await host2.capture(`docker ps -a --filter "label=epd.app=${cfg.name}" --format ${shq(fmt)} 2>/dev/null || true`, { allowFailure: true });
2363
+ return out.split(`
2364
+ `).filter(Boolean).map((line) => {
2365
+ const [name, service, slot, version, replica, status, state, image, created] = line.split("\t");
2366
+ return {
2367
+ name: name ?? "",
2368
+ service: service ?? "",
2369
+ slot: slot ?? "",
2370
+ version: version ?? "",
2371
+ replica: replica ?? "",
2372
+ status: status ?? "",
2373
+ running: state === "running",
2374
+ image: image ?? "",
2375
+ created: created ?? ""
2376
+ };
2377
+ });
2378
+ }
2379
+ async function startAccessory(host2, cfg, acc, env, opts) {
2380
+ const name = accessoryContainer(cfg.name, acc.name);
2381
+ const secrets = resolveSecrets(acc.secrets, env, `accessories.${acc.name}`);
2382
+ const envFile = `${paths(cfg).app}/env.acc.${acc.name}`;
2383
+ await host2.writeFile(envFile, envFileContent({ ...acc.env, ...secrets }), "600");
2384
+ const args = [
2385
+ "docker",
2386
+ "run",
2387
+ "-d",
2388
+ "--name",
2389
+ name,
2390
+ "--restart",
2391
+ "unless-stopped",
2392
+ "--network",
2393
+ cfg.proxy.network,
2394
+ "--label",
2395
+ `epd.app=${cfg.name}`,
2396
+ "--label",
2397
+ `epd.accessory=${acc.name}`,
2398
+ "--label",
2399
+ "epd.managed=true",
2400
+ "--env-file",
2401
+ envFile,
2402
+ "--log-opt",
2403
+ "max-size=10m",
2404
+ "--log-opt",
2405
+ "max-file=3"
2406
+ ];
2407
+ for (const v of acc.volumes)
2408
+ args.push("--volume", v);
2409
+ for (const p of acc.ports)
2410
+ args.push("--publish", p);
2411
+ args.push(...acc.dockerOptions, acc.image);
2412
+ if (acc.command)
2413
+ args.push(...parseCommand(acc.command));
2414
+ await host2.exec(`if docker inspect ${shq(name)} >/dev/null 2>&1; then
2415
+ ${opts.recreate ? `docker rm -f ${shq(name)} >/dev/null` : `docker start ${shq(name)} >/dev/null 2>&1 || true; exit 0`}
2416
+ fi
2417
+ docker pull ${shq(acc.image)} >/dev/null
2418
+ ${args.map((a) => shq(a)).join(" ")} >/dev/null`, { label: `${host2.name}: accessory ${acc.name}` });
2419
+ log.host(host2.name, `accessory ${acc.name} running`);
2420
+ }
2421
+
2422
+ // src/deploy/process.ts
2423
+ import { existsSync as existsSync4 } from "fs";
2424
+ import { join as join5 } from "path";
2425
+ var asUser = { sudo: false };
2426
+ var pm2 = (cfg, args) => `${pathPrelude(cfg)} pm2 ${args}`;
2427
+ function releaseDir(cfg, version) {
2428
+ return `${paths(cfg).releases}/${version}`;
2429
+ }
2430
+ async function uploadSource(host2, cfg, version) {
2431
+ const dest = releaseDir(cfg, version);
2432
+ const p = paths(cfg);
2433
+ await host2.exec(`mkdir -p ${shq(p.releases)} ${shq(p.shared)}`, asUser);
2434
+ if (cfg.process.source === "git") {
2435
+ if (!existsSync4(join5(cfg.root, ".git")))
2436
+ throw new EpdError('process.source is "git" but this is not a git repository');
2437
+ log.host(host2.name, `uploading HEAD as ${version}`);
2438
+ await host2.pipeInto(`mkdir -p ${shq(dest)} && tar -x -C ${shq(dest)}`, ["/bin/sh", "-c", `git -C ${shq(cfg.root)} archive --format=tar HEAD`]);
2439
+ return;
2440
+ }
2441
+ const previous = await host2.capture(`ls -1dt ${shq(p.releases)}/*/ 2>/dev/null | head -n 1 || true`, { ...asUser, allowFailure: true });
2442
+ const args = ["rsync", "-az", "--delete", "--human-readable", "--info=stats1"];
2443
+ for (const ex of cfg.process.exclude)
2444
+ args.push("--exclude", ex);
2445
+ if (previous)
2446
+ args.push("--link-dest", previous.replace(/\/$/, ""));
2447
+ args.push("-e", host2.rsyncShell());
2448
+ args.push(`${cfg.root.replace(/\/$/, "")}/`, `${host2.target}:${dest}/`);
2449
+ await host2.exec(`mkdir -p ${shq(dest)}`, asUser);
2450
+ log.host(host2.name, `uploading ${version}`);
2451
+ await run(args, { cwd: cfg.root, label: `rsync \u2192 ${host2.name}` });
2452
+ }
2453
+ async function buildRelease(host2, cfg, version, env) {
2454
+ const dest = releaseDir(cfg, version);
2455
+ const secrets = resolveSecrets(cfg.secrets, env, "env.secret");
2456
+ const envFile = paths(cfg).envFile;
2457
+ await host2.writeFile(envFile, Object.entries({ ...cfg.env, ...secrets }).map(([k, v]) => `${k}=${v}`).join(`
2458
+ `), "600");
2459
+ const steps = [`cd ${shq(dest)}`, pathPrelude(cfg), `set -a; . ${shq(envFile)}; set +a`];
2460
+ if (cfg.process.install)
2461
+ steps.push(cfg.process.install);
2462
+ if (cfg.process.build)
2463
+ steps.push(cfg.process.build);
2464
+ if (steps.length > 3) {
2465
+ log.host(host2.name, "installing and building");
2466
+ await host2.exec(steps.join(`
2467
+ `), { ...asUser, stream: true, label: `${host2.name}: build` });
2468
+ }
2469
+ }
2470
+ function ecosystem(cfg, svc, slot, version, env) {
2471
+ const dir = releaseDir(cfg, version);
2472
+ const command = svc.command ?? cfg.process.start;
2473
+ const argv = parseCommand(command);
2474
+ const apps = [];
2475
+ for (let i = 0;i < svc.replicas; i++) {
2476
+ apps.push({
2477
+ name: processName(cfg.name, svc.name, slot, i),
2478
+ cwd: dir,
2479
+ script: argv[0],
2480
+ args: argv.slice(1),
2481
+ interpreter: "none",
2482
+ exec_mode: "fork",
2483
+ autorestart: true,
2484
+ max_restarts: 10,
2485
+ restart_delay: 1000,
2486
+ kill_timeout: svc.stopTimeout * 1000,
2487
+ merge_logs: true,
2488
+ time: true,
2489
+ env: {
2490
+ ...env,
2491
+ NODE_ENV: env.NODE_ENV ?? "production",
2492
+ PORT: String(hostPort(cfg, svc, slot, i)),
2493
+ HOST: "127.0.0.1",
2494
+ EPD_APP: cfg.name,
2495
+ EPD_SERVICE: svc.name,
2496
+ EPD_REPLICA: String(i),
2497
+ EPD_VERSION: version,
2498
+ ...svc.memory ? {} : {}
2499
+ },
2500
+ ...svc.memory ? { max_memory_restart: svc.memory } : {}
2501
+ });
2502
+ }
2503
+ return { apps };
2504
+ }
2505
+ async function startSlot2(host2, cfg, svc, slot, version, env) {
2506
+ const secrets = resolveSecrets(svc.secrets, env, `servers.${svc.name}`);
2507
+ const doc = ecosystem(cfg, svc, slot, version, { ...svc.env, ...secrets });
2508
+ const file = `${paths(cfg).app}/pm2.${svc.name}.${slot}.json`;
2509
+ await host2.writeFile(file, JSON.stringify(doc, null, 2), "600");
2510
+ const stale = Array.from({ length: 16 }, (_, i) => processName(cfg.name, svc.name, slot, i));
2511
+ await host2.exec(`for name in ${stale.map((n) => shq(n)).join(" ")}; do ${pathPrelude(cfg)} pm2 delete "$name" >/dev/null 2>&1 || true; done
2512
+ ${pm2(cfg, `start ${shq(file)} --update-env`)}
2513
+ ${pm2(cfg, "save --force")} >/dev/null 2>&1 || true`, { ...asUser, label: `${host2.name}: pm2 start ${svc.name}/${slot}` });
2514
+ }
2515
+ async function stopSlot2(host2, cfg, svc, slot, drain, opts = {}) {
2516
+ const names = Array.from({ length: 16 }, (_, i) => processName(cfg.name, svc.name, slot, i));
2517
+ const teardown = `sleep ${drain}
2518
+ for name in ${names.map((n) => shq(n)).join(" ")}; do
2519
+ ${pathPrelude(cfg)} pm2 delete "$name" >/dev/null 2>&1 || true
2520
+ done
2521
+ ${pm2(cfg, "save --force")} >/dev/null 2>&1 || true`;
2522
+ await host2.exec(opts.detach ? `nohup setsid bash -c ${shq(teardown)} >/dev/null 2>&1 < /dev/null &
2523
+ disown 2>/dev/null || true` : teardown, { ...asUser, allowFailure: true, label: `${host2.name}: pm2 stop ${svc.name}/${slot}` });
2524
+ }
2525
+ async function removeApp2(host2, cfg) {
2526
+ await host2.exec(`${pathPrelude(cfg)} pm2 jlist 2>/dev/null | tr ',' '\\n' | grep -o '"name":"epd-${cfg.name}-[^"]*"' | cut -d'"' -f4 | sort -u | while read -r n; do
2527
+ ${pathPrelude(cfg)} pm2 delete "$n" >/dev/null 2>&1 || true
2528
+ done
2529
+ ${pm2(cfg, "save --force")} >/dev/null 2>&1 || true`, { ...asUser, allowFailure: true });
2530
+ }
2531
+ async function listProcesses(host2, cfg) {
2532
+ const out = await host2.capture(`${pm2(cfg, "jlist")} 2>/dev/null || echo '[]'`, { ...asUser, allowFailure: true });
2533
+ const jsonStart = out.indexOf("[");
2534
+ let parsed = [];
2535
+ try {
2536
+ parsed = JSON.parse(jsonStart >= 0 ? out.slice(jsonStart) : "[]");
2537
+ } catch {
2538
+ return [];
2539
+ }
2540
+ return parsed.filter((p) => typeof p?.name === "string" && p.name.startsWith(`epd-${cfg.name}-`)).map((p) => ({
2541
+ name: p.name,
2542
+ status: p.pm2_env?.status ?? "unknown",
2543
+ restarts: p.pm2_env?.restart_time ?? 0,
2544
+ uptime: p.pm2_env?.pm_uptime ?? 0,
2545
+ cpu: p.monit?.cpu ?? 0,
2546
+ memory: p.monit?.memory ?? 0
2547
+ }));
2548
+ }
2549
+ async function finalizeRelease(host2, cfg, version) {
2550
+ const p = paths(cfg);
2551
+ await host2.exec(`ln -sfn ${shq(releaseDir(cfg, version))} ${shq(p.current)}.tmp && mv -Tf ${shq(p.current)}.tmp ${shq(p.current)}
2552
+ ls -1dt ${shq(p.releases)}/*/ 2>/dev/null | tail -n +$(( ${cfg.keepReleases} + 1 )) | while read -r d; do
2553
+ case "$d" in *${shq(version)}*) continue;; esac
2554
+ rm -rf "$d"
2555
+ done`, { ...asUser, allowFailure: true });
2556
+ }
2557
+
2558
+ // src/deploy/deploy.ts
2559
+ async function deploy(loaded, opts = {}) {
2560
+ const { config: cfg, env } = loaded;
2561
+ const started2 = Date.now();
2562
+ const services = selectServices(cfg, opts.onlyServices);
2563
+ const targetHosts = selectHosts(cfg, services, opts.onlyHosts);
2564
+ const hosts = targetHosts.map((h) => host(h, cfg.ssh));
2565
+ const version = (await resolveVersion(cfg, opts.version)).id;
2566
+ log.step(`Deploying ${c.bold(cfg.name)} ${c.cyan(version)}${cfg.destination ? ` \u2192 ${cfg.destination}` : ""} to ${targetHosts.length} host(s)`);
2567
+ if (!opts.skipHooks && cfg.hooks.preBuild)
2568
+ await hook(cfg, "pre_build", cfg.hooks.preBuild, version);
2569
+ if (cfg.mode === "docker" && !opts.reuse) {
2570
+ await buildImage(cfg, version, env);
2571
+ if (!cfg.registry)
2572
+ log.warn("no registry configured \u2014 the image is copied to each server over SSH");
2573
+ }
2574
+ if (!opts.skipHooks && cfg.hooks.preDeploy)
2575
+ await hook(cfg, "pre_deploy", cfg.hooks.preDeploy, version);
2576
+ await withLock(cfg, hosts, `deploy ${version}`, opts.skipLock ?? false, async () => {
2577
+ log.step("Preparing servers");
2578
+ await pool(hosts, 8, async (host2) => {
2579
+ await verifyHost(host2, cfg);
2580
+ if (cfg.mode === "docker")
2581
+ await ensureNetwork(host2, cfg);
2582
+ });
2583
+ await deployAccessories(cfg, env, opts.recreateAccessories ?? false);
2584
+ if (cfg.proxy.enabled && proxyHosts(cfg).length) {
2585
+ log.step("Checking proxy");
2586
+ await pool(proxyHosts(cfg).filter((h) => targetHosts.includes(h)).map((h) => host(h, cfg.ssh)), 4, (host2) => ensureProxy(host2, cfg, { secrets: env }));
2587
+ }
2588
+ const live = new Map;
2589
+ for (const h of allHosts(cfg)) {
2590
+ const state = await readState(host(h, cfg.ssh), cfg);
2591
+ const m = new Map;
2592
+ for (const [svc, s] of Object.entries(state?.services ?? {}))
2593
+ m.set(svc, s.slot);
2594
+ live.set(h, m);
2595
+ }
2596
+ const deployHost = async (host2) => {
2597
+ await deployToHost({ cfg, env, host: host2, services, version, opts, live });
2598
+ };
2599
+ if (cfg.strategy === "parallel") {
2600
+ await pool(hosts, hosts.length, deployHost);
2601
+ } else {
2602
+ for (const host2 of hosts)
2603
+ await deployHost(host2);
2604
+ }
2605
+ if (cfg.proxy.crossHost) {
2606
+ log.step("Syncing routes across hosts");
2607
+ await pool(proxyHosts(cfg).map((h) => host(h, cfg.ssh)), 4, async (host2) => {
2608
+ await writeRoutes(host2, cfg, buildEndpoints(cfg, host2.name, live), buildAccessoryEndpoints(cfg, host2.name));
2609
+ });
2610
+ }
2611
+ });
2612
+ if (!opts.skipHooks && cfg.hooks.postDeploy)
2613
+ await hook(cfg, "post_deploy", cfg.hooks.postDeploy, version);
2614
+ log.ok(`Deployed ${cfg.name} ${version} in ${dur(Date.now() - started2)}`);
2615
+ printUrls(cfg);
2616
+ }
2617
+ async function deployToHost(d) {
2618
+ const { cfg, env, host: host2, version, opts, live } = d;
2619
+ const services = d.services.filter((s) => s.hosts.includes(host2.name));
2620
+ if (!services.length)
2621
+ return;
2622
+ log.step(`${host2.name}: deploying ${services.map((s) => s.name).join(", ")}`);
2623
+ if (cfg.mode === "docker") {
2624
+ await loginRegistry(host2, cfg, env);
2625
+ await ensureImage(host2, cfg, version);
2626
+ } else if (!opts.reuse) {
2627
+ await uploadSource(host2, cfg, version);
2628
+ await buildRelease(host2, cfg, version, env);
2629
+ } else {
2630
+ const dir = releaseDir(cfg, version);
2631
+ if (!await host2.test(`[ -d ${JSON.stringify(dir)} ]`)) {
2632
+ throw new EpdError(`${host2.name}: release ${version} is not on this server`, "Deploy it normally first.");
2633
+ }
2634
+ }
2635
+ const state = await readState(host2, cfg) ?? emptyState(cfg);
2636
+ const previous = new Map;
2637
+ const started2 = [];
2638
+ try {
2639
+ for (const svc of services) {
2640
+ const current = state.services[svc.name]?.slot;
2641
+ if (current)
2642
+ previous.set(svc.name, current);
2643
+ const slot = current ? otherSlot(current) : "blue";
2644
+ log.host(host2.name, `${svc.name}: starting ${svc.replicas} replica(s) on ${slot}`);
2645
+ let probes = [];
2646
+ if (cfg.mode === "docker") {
2647
+ const envFile = await writeServiceEnv(host2, cfg, svc, env);
2648
+ const names = await startSlot(host2, cfg, svc, slot, version, envFile);
2649
+ started2.push({ svc, slot });
2650
+ const ips = usesHostPorts(cfg) ? {} : await containerIps(host2, cfg, names);
2651
+ probes = names.map((name, i) => ({
2652
+ label: name,
2653
+ address: usesHostPorts(cfg) ? "127.0.0.1" : ips[name] ?? "",
2654
+ port: usesHostPorts(cfg) ? hostPort(cfg, svc, slot, i) : svc.health.port ?? svc.port
2655
+ }));
2656
+ const noIp = probes.find((p) => !p.address);
2657
+ if (noIp)
2658
+ throw new EpdError(`${host2.name}: ${noIp.label} did not get an IP on the ${cfg.proxy.network} network`, "Check `epd logs`.");
2659
+ } else {
2660
+ await startSlot2(host2, cfg, svc, slot, version, env);
2661
+ started2.push({ svc, slot });
2662
+ probes = Array.from({ length: svc.replicas }, (_, i) => ({
2663
+ label: `${svc.name}#${i}`,
2664
+ address: "127.0.0.1",
2665
+ port: hostPort(cfg, svc, slot, i)
2666
+ }));
2667
+ }
2668
+ if (!opts.skipHealth && svc.proxied) {
2669
+ log.host(host2.name, `${svc.name}: waiting for health check${svc.health.path ? ` ${svc.health.path}` : " (tcp)"}`);
2670
+ await waitHealthy(host2, probes, svc.health);
2671
+ log.host(host2.name, `${svc.name}: healthy`);
2672
+ } else if (!opts.skipHealth) {
2673
+ await confirmUp(host2, cfg, svc, slot);
2674
+ }
2675
+ live.get(host2.name)?.set(svc.name, slot) ?? live.set(host2.name, new Map([[svc.name, slot]]));
2676
+ state.services[svc.name] = {
2677
+ slot,
2678
+ replicas: svc.replicas,
2679
+ version,
2680
+ endpoints: endpointsFor(cfg, svc, slot, host2.name).map((e) => e.url)
2681
+ };
2682
+ }
2683
+ const hasSvcRoutes = services.some((s) => s.routes.length);
2684
+ const hasAccRoutes = cfg.accessories.some((a) => a.routes.length && (cfg.proxy.crossHost || a.host === host2.name));
2685
+ if (cfg.proxy.enabled && (hasSvcRoutes || hasAccRoutes)) {
2686
+ await writeRoutes(host2, cfg, buildEndpoints(cfg, host2.name, live), buildAccessoryEndpoints(cfg, host2.name));
2687
+ log.host(host2.name, "routes updated");
2688
+ }
2689
+ for (const svc of services) {
2690
+ const old = previous.get(svc.name);
2691
+ if (!old)
2692
+ continue;
2693
+ const drain = Math.max(svc.drain, cfg.proxy.reloadWait + 2);
2694
+ log.host(host2.name, `${svc.name}: retiring ${old} (${drain}s drain, in the background)`);
2695
+ if (cfg.mode === "docker")
2696
+ await stopSlot(host2, cfg, svc, old, { drain, remove: true, detach: true });
2697
+ else
2698
+ await stopSlot2(host2, cfg, svc, old, drain, { detach: true });
2699
+ }
2700
+ await writeState(host2, cfg, recordDeploy(state, version, process.env.USER ?? "epd", cfg.keepReleases));
2701
+ if (cfg.mode === "docker") {
2702
+ await pruneImages(host2, cfg, cfg.keepReleases, [version, ...state.history.slice(0, 2).map((h) => h.version)]);
2703
+ } else {
2704
+ await finalizeRelease(host2, cfg, version);
2705
+ }
2706
+ log.host(host2.name, c.green("done"));
2707
+ } catch (error) {
2708
+ log.error(`${host2.name}: deploy failed, rolling back this host`);
2709
+ for (const { svc, slot } of started2) {
2710
+ try {
2711
+ if (cfg.mode === "docker")
2712
+ await stopSlot(host2, cfg, svc, slot, { drain: 0, remove: true });
2713
+ else
2714
+ await stopSlot2(host2, cfg, svc, slot, 0);
2715
+ } catch {}
2716
+ }
2717
+ if (!d.opts.skipHooks && cfg.hooks.onFailure) {
2718
+ await hook(cfg, "on_failure", cfg.hooks.onFailure, version).catch(() => {
2719
+ return;
2720
+ });
2721
+ }
2722
+ throw error;
2723
+ }
2724
+ }
2725
+ async function confirmUp(host2, cfg, svc, slot) {
2726
+ if (cfg.mode === "docker") {
2727
+ const containers = await listContainers(host2, cfg);
2728
+ const bad = containers.filter((ct) => ct.service === svc.name && ct.slot === slot && !ct.running);
2729
+ if (bad.length) {
2730
+ throw new EpdError(`${host2.name}: ${bad.map((b) => b.name).join(", ")} exited right after starting`, `Run: epd logs --service ${svc.name}`);
2731
+ }
2732
+ } else {
2733
+ const procs = await listProcesses(host2, cfg);
2734
+ const bad = procs.filter((p) => p.name.includes(`-${svc.name}-${slot}-`) && p.status !== "online");
2735
+ if (bad.length) {
2736
+ throw new EpdError(`${host2.name}: ${bad.map((b) => b.name).join(", ")} is ${bad[0].status}`, `Run: epd logs --service ${svc.name}`);
2737
+ }
2738
+ }
2739
+ }
2740
+ function urlsForPort(cfg, svc, slot, serverHost, port) {
2741
+ const eps = endpointsFor(cfg, svc, slot, serverHost);
2742
+ if (port === svc.port || !usesHostPorts(cfg)) {
2743
+ return eps.map((e) => port === svc.port ? e.url : e.url.replace(/:\d+$/, `:${port}`));
2744
+ }
2745
+ throw new EpdError(`servers.${svc.name}: route port ${port} differs from the service port ${svc.port}`, "With process mode or proxy.cross_host, epd publishes a single port per replica. Give the route the same port, or run a second server entry.");
2746
+ }
2747
+ function buildEndpoints(cfg, targetHost, live) {
2748
+ const out = {};
2749
+ for (const svc of cfg.services) {
2750
+ if (!svc.routes.length)
2751
+ continue;
2752
+ const sourceHosts = cfg.proxy.crossHost ? svc.hosts : svc.hosts.filter((h) => h === targetHost);
2753
+ const ports = new Set([svc.port, ...svc.routes.map((r) => r.port)]);
2754
+ const byPort = {};
2755
+ for (const port of ports) {
2756
+ const urls = [];
2757
+ for (const h of sourceHosts) {
2758
+ const slot = live.get(h)?.get(svc.name);
2759
+ if (!slot)
2760
+ continue;
2761
+ urls.push(...urlsForPort(cfg, svc, slot, h, port));
2762
+ }
2763
+ byPort[port] = urls;
2764
+ }
2765
+ out[svc.name] = byPort;
2766
+ }
2767
+ return out;
2768
+ }
2769
+ function buildAccessoryEndpoints(cfg, targetHost) {
2770
+ return cfg.accessories.filter((acc) => acc.routes.length > 0 && (cfg.proxy.crossHost || acc.host === targetHost)).map((acc) => {
2771
+ const ports = new Set([acc.port ?? 80, ...acc.routes.map((r) => r.port)]);
2772
+ const urls = {};
2773
+ const name = accessoryContainer(cfg.name, acc.name);
2774
+ for (const port of ports) {
2775
+ if (acc.host === targetHost) {
2776
+ urls[port] = [`http://${name}:${port}`];
2777
+ } else {
2778
+ const addr = cfg.proxy.privateIps[acc.host] ?? acc.host;
2779
+ urls[port] = [`http://${addr}:${port}`];
2780
+ }
2781
+ }
2782
+ return { acc, urls };
2783
+ });
2784
+ }
2785
+ async function deployAccessories(cfg, env, recreate) {
2786
+ if (!cfg.accessories.length)
2787
+ return;
2788
+ log.step("Accessories");
2789
+ for (const acc of cfg.accessories) {
2790
+ const host2 = host(acc.host, cfg.ssh);
2791
+ if (cfg.mode === "process" && !await hasDocker(host2)) {
2792
+ throw new EpdError(`accessories.${acc.name} needs docker on ${acc.host}`, "Accessories always run as containers. Install docker there, or run this dependency yourself.");
2793
+ }
2794
+ await ensureDirs(host2, cfg);
2795
+ await ensureNetwork(host2, cfg);
2796
+ await startAccessory(host2, cfg, acc, env, { recreate });
2797
+ }
2798
+ }
2799
+ function selectServices(cfg, only) {
2800
+ if (!only?.length)
2801
+ return cfg.services;
2802
+ const known = cfg.services.map((s) => s.name);
2803
+ for (const name of only) {
2804
+ if (!known.includes(name))
2805
+ throw new EpdError(`unknown server "${name}"`, `Known: ${known.join(", ")}`);
2806
+ }
2807
+ return cfg.services.filter((s) => only.includes(s.name));
2808
+ }
2809
+ function selectHosts(cfg, services, only) {
2810
+ const all = [...new Set(services.flatMap((s) => s.hosts))];
2811
+ if (!only?.length)
2812
+ return all;
2813
+ for (const h of only) {
2814
+ if (!all.includes(h))
2815
+ throw new EpdError(`unknown host "${h}"`, `Known: ${all.join(", ")}`);
2816
+ }
2817
+ return all.filter((h) => only.includes(h));
2818
+ }
2819
+ async function hook(cfg, name, script, version) {
2820
+ log.step(`Hook ${name}`);
2821
+ await runShell(script, {
2822
+ cwd: cfg.root,
2823
+ stream: true,
2824
+ env: {
2825
+ EPD_APP: cfg.name,
2826
+ EPD_VERSION: version,
2827
+ EPD_DESTINATION: cfg.destination ?? "",
2828
+ EPD_MODE: cfg.mode,
2829
+ EPD_HOSTS: allHosts(cfg).join(",")
2830
+ },
2831
+ label: `hook ${name}`
2832
+ });
2833
+ }
2834
+ function printUrls(cfg) {
2835
+ const urls = new Set;
2836
+ for (const svc of cfg.services) {
2837
+ for (const route of svc.routes) {
2838
+ for (const h of route.hosts)
2839
+ urls.add(`${route.ssl ? "https" : "http"}://${h}${route.path ?? ""}`);
2840
+ }
2841
+ }
2842
+ for (const acc of cfg.accessories) {
2843
+ for (const route of acc.routes) {
2844
+ for (const h of route.hosts)
2845
+ urls.add(`${route.ssl ? "https" : "http"}://${h}${route.path ?? ""}`);
2846
+ }
2847
+ }
2848
+ for (const u of urls)
2849
+ log.info(c.cyan(u));
2850
+ }
2851
+
2852
+ // src/commands/setup.ts
2853
+ var setup = {
2854
+ name: "setup",
2855
+ summary: "Prepare the servers, then deploy for the first time",
2856
+ options: {
2857
+ "skip-deploy": { type: "boolean" },
2858
+ host: { type: "string", multiple: true }
2859
+ },
2860
+ help: () => `${c.bold("epd setup")} \u2014 get servers ready and run the first deploy
2861
+
2862
+ Installs Docker (or rsync + pm2 in process mode), creates the shared network,
2863
+ starts Traefik and then deploys. Safe to run again at any time.
2864
+
2865
+ Options
2866
+ --skip-deploy Only prepare the servers
2867
+ --host <ip> Only this host (repeatable)`,
2868
+ async run(ctx) {
2869
+ const loaded = ctx.load();
2870
+ const cfg = loaded.config;
2871
+ const only = ctx.values.host;
2872
+ const names = allHosts(cfg).filter((h) => !only?.length || only.includes(h));
2873
+ const hosts = names.map((h) => host(h, cfg.ssh));
2874
+ log.step(`Preparing ${hosts.length} host(s) for ${c.bold(cfg.name)}`);
2875
+ if (cfg.mode === "docker")
2876
+ await requireLocal("docker", "Install Docker Desktop or the docker engine to build images.");
2877
+ await pool(hosts, 4, async (host2) => {
2878
+ await host2.ping();
2879
+ log.host(host2.name, "reachable");
2880
+ if (cfg.mode === "docker") {
2881
+ await installDocker(host2);
2882
+ await ensureNetwork(host2, cfg);
2883
+ } else {
2884
+ await installSystemPackages(host2);
2885
+ await installProcessRuntime(host2, cfg);
2886
+ }
2887
+ await ensureDirs(host2, cfg);
2888
+ await reservePorts(host2, cfg);
2889
+ });
2890
+ if (cfg.proxy.enabled && proxyHosts(cfg).length) {
2891
+ log.step("Starting the proxy");
2892
+ await pool(proxyHosts(cfg).filter((h) => names.includes(h)).map((h) => host(h, cfg.ssh)), 4, (host2) => ensureProxy(host2, cfg, { secrets: loaded.env, force: true }));
2893
+ }
2894
+ log.ok("servers are ready");
2895
+ if (ctx.values["skip-deploy"]) {
2896
+ log.info(`run ${c.cyan("epd deploy")} when you are ready`);
2897
+ return;
2898
+ }
2899
+ await deploy(loaded, { onlyHosts: only });
2900
+ }
2901
+ };
2902
+
2903
+ // src/commands/deploy.ts
2904
+ var shared = {
2905
+ version: { type: "string" },
2906
+ service: { type: "string", multiple: true },
2907
+ host: { type: "string", multiple: true },
2908
+ "skip-health": { type: "boolean" },
2909
+ "skip-hooks": { type: "boolean" },
2910
+ "skip-lock": { type: "boolean" },
2911
+ "recreate-accessories": { type: "boolean" }
2912
+ };
2913
+ var deploy2 = {
2914
+ name: "deploy",
2915
+ summary: "Build and deploy the current project",
2916
+ options: { ...shared, build: { type: "boolean", default: true } },
2917
+ help: () => `${c.bold("epd deploy")} \u2014 build and deploy the current project
2918
+
2919
+ Zero downtime: the new version starts alongside the old one, has to pass its
2920
+ health check, and only then does the proxy move traffic over.
2921
+
2922
+ Options
2923
+ --version <v> Deploy this version instead of the current git sha
2924
+ --no-build Skip the build and reuse a version already on the servers
2925
+ --service <name> Only this server group (repeatable)
2926
+ --host <ip> Only this host (repeatable)
2927
+ --skip-health Do not wait for the health check
2928
+ --skip-hooks Do not run pre/post deploy hooks
2929
+ --skip-lock Do not take the deploy lock
2930
+ --recreate-accessories Recreate accessory containers instead of leaving them alone
2931
+ -d, --destination <d> Use the epd.<d>.yml overlay`,
2932
+ async run(ctx) {
2933
+ const loaded = ctx.load();
2934
+ await deploy(loaded, {
2935
+ version: ctx.values.version,
2936
+ reuse: ctx.values.build === false,
2937
+ onlyServices: ctx.values.service,
2938
+ onlyHosts: ctx.values.host,
2939
+ skipHealth: Boolean(ctx.values["skip-health"]),
2940
+ skipHooks: Boolean(ctx.values["skip-hooks"]),
2941
+ skipLock: Boolean(ctx.values["skip-lock"]),
2942
+ recreateAccessories: Boolean(ctx.values["recreate-accessories"])
2943
+ });
2944
+ }
2945
+ };
2946
+ var redeploy = {
2947
+ name: "redeploy",
2948
+ summary: "Deploy again without rebuilding",
2949
+ options: shared,
2950
+ help: () => `${c.bold("epd redeploy")} \u2014 restart the current version everywhere
2951
+
2952
+ Same as \`epd deploy --no-build\`: no build, no upload, just a fresh set of
2953
+ containers or processes from the version already on the servers.`,
2954
+ async run(ctx) {
2955
+ const loaded = ctx.load();
2956
+ await deploy(loaded, {
2957
+ version: ctx.values.version,
2958
+ reuse: true,
2959
+ onlyServices: ctx.values.service,
2960
+ onlyHosts: ctx.values.host,
2961
+ skipHealth: Boolean(ctx.values["skip-health"]),
2962
+ skipHooks: true,
2963
+ skipLock: Boolean(ctx.values["skip-lock"])
2964
+ });
2965
+ }
2966
+ };
2967
+
2968
+ // src/commands/rollback.ts
2969
+ var rollback = {
2970
+ name: "rollback",
2971
+ summary: "Deploy the previous version again",
2972
+ options: {
2973
+ service: { type: "string", multiple: true },
2974
+ host: { type: "string", multiple: true },
2975
+ "skip-health": { type: "boolean" },
2976
+ list: { type: "boolean" }
2977
+ },
2978
+ help: () => `${c.bold("epd rollback [version]")} \u2014 go back to a version already on the servers
2979
+
2980
+ With no argument it picks the version deployed before the current one. Nothing
2981
+ is rebuilt: the image (or release directory) is still there, so this is fast.
2982
+
2983
+ Options
2984
+ --list Show the versions available to roll back to
2985
+ --service <name> Only this server group
2986
+ --host <ip> Only this host`,
2987
+ async run(ctx) {
2988
+ const loaded = ctx.load();
2989
+ const cfg = loaded.config;
2990
+ const hosts = allHosts(cfg).map((h) => host(h, cfg.ssh));
2991
+ const first = hosts[0];
2992
+ if (!first)
2993
+ throw new EpdError("no hosts configured");
2994
+ const state = await readState(first, cfg);
2995
+ if (!state?.history.length) {
2996
+ throw new EpdError(`${cfg.name} has no deploy history on ${first.name}`, "Deploy it at least twice before rolling back.");
2997
+ }
2998
+ if (ctx.values.list) {
2999
+ log.plain(`${c.bold("Versions on")} ${first.name}`);
3000
+ state.history.forEach((h, i) => {
3001
+ const marker = h.version === state.version ? c.green(" \u2190 current") : "";
3002
+ log.plain(` ${String(i + 1).padStart(2)}. ${c.cyan(h.version)} ${c.gray(h.at)}${h.by ? c.gray(` by ${h.by}`) : ""}${marker}`);
3003
+ });
3004
+ return;
3005
+ }
3006
+ const explicit = ctx.positionals[0];
3007
+ const target = explicit ?? state.history.find((h) => h.version !== state.version)?.version;
3008
+ if (!target)
3009
+ throw new EpdError("no previous version to roll back to", "Run `epd rollback --list` to see what is available.");
3010
+ if (!explicit)
3011
+ log.info(`rolling back ${c.cyan(state.version)} \u2192 ${c.cyan(target)}`);
3012
+ await deploy(loaded, {
3013
+ version: target,
3014
+ reuse: true,
3015
+ skipHooks: true,
3016
+ onlyServices: ctx.values.service,
3017
+ onlyHosts: ctx.values.host,
3018
+ skipHealth: Boolean(ctx.values["skip-health"])
3019
+ });
3020
+ }
3021
+ };
3022
+
3023
+ // src/commands/status.ts
3024
+ function table(rows, head) {
3025
+ const all = [head, ...rows];
3026
+ const widths = head.map((_, i) => Math.max(...all.map((r) => stripAnsi(r[i] ?? "").length)));
3027
+ const line = (r) => r.map((cell, i) => cell + " ".repeat(Math.max(0, widths[i] - stripAnsi(cell).length))).join(" ").trimEnd();
3028
+ return [c.gray(line(head)), ...rows.map(line)].join(`
3029
+ `);
3030
+ }
3031
+ function stripAnsi(s) {
3032
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
3033
+ }
3034
+ var status = {
3035
+ name: "status",
3036
+ aliases: ["ps"],
3037
+ summary: "Show what is running on every server",
3038
+ options: { json: { type: "boolean" } },
3039
+ help: () => `${c.bold("epd status")} \u2014 what is running, where, and on which version
3040
+
3041
+ Shows every replica, the live blue/green slot, the proxy state and the URLs
3042
+ each server answers on.
3043
+
3044
+ Options
3045
+ --json Machine readable output`,
3046
+ async run(ctx) {
3047
+ const { config: cfg } = ctx.load();
3048
+ const names = allHosts(cfg);
3049
+ const proxied = new Set(proxyHosts(cfg));
3050
+ const report = await pool(names, 8, async (name) => {
3051
+ const host2 = host(name, cfg.ssh);
3052
+ try {
3053
+ const state = await readState(host2, cfg);
3054
+ const containers = cfg.mode === "docker" ? await listContainers(host2, cfg) : [];
3055
+ const processes = cfg.mode === "process" ? await listProcesses(host2, cfg) : [];
3056
+ const proxy = proxied.has(name) && cfg.proxy.enabled ? await proxyState(host2, cfg) : null;
3057
+ return { name, ok: true, state, containers, processes, proxy };
3058
+ } catch (error) {
3059
+ return { name, ok: false, error: error instanceof Error ? error.message : String(error) };
3060
+ }
3061
+ });
3062
+ if (ctx.values.json) {
3063
+ console.log(JSON.stringify({ app: cfg.name, mode: cfg.mode, hosts: report }, null, 2));
3064
+ return;
3065
+ }
3066
+ log.plain(`${c.bold(cfg.name)} ${c.gray(`(${cfg.mode}${cfg.destination ? `, ${cfg.destination}` : ""})`)}`);
3067
+ for (const r of report) {
3068
+ log.plain("");
3069
+ if (!r.ok) {
3070
+ log.plain(`${c.magenta(r.name)} ${c.red("unreachable")} ${c.gray(r.error)}`);
3071
+ continue;
3072
+ }
3073
+ const version = r.state?.version ? c.cyan(r.state.version) : c.gray("nothing deployed");
3074
+ const proxyBit = r.proxy ? r.proxy.running ? c.green("proxy up") : c.red(`proxy ${r.proxy.status}`) : c.gray("no proxy");
3075
+ log.plain(`${c.magenta(r.name)} ${version} ${proxyBit}`);
3076
+ if (cfg.mode === "docker") {
3077
+ const live = new Set(Object.values(r.state?.services ?? {}).map((s) => s.slot));
3078
+ const rows = r.containers.filter((ct) => ct.service).sort((a, b) => a.name.localeCompare(b.name)).map((ct) => [
3079
+ ct.running ? c.green("\u25CF") : c.red("\u25CB"),
3080
+ ct.service,
3081
+ live.has(ct.slot) ? c.bold(ct.slot) : c.gray(ct.slot),
3082
+ ct.version,
3083
+ ct.status
3084
+ ]);
3085
+ const accessories = r.containers.filter((ct) => !ct.service && ct.name.includes("-acc-"));
3086
+ for (const a of accessories)
3087
+ rows.push([a.running ? c.green("\u25CF") : c.red("\u25CB"), c.gray("accessory"), a.name.split("-acc-")[1] ?? "", "", a.status]);
3088
+ log.plain(rows.length ? table(rows, ["", "server", "slot", "version", "status"]) : c.gray(" no containers"));
3089
+ } else {
3090
+ const rows = r.processes.map((p) => [
3091
+ p.status === "online" ? c.green("\u25CF") : c.red("\u25CB"),
3092
+ p.name.replace(`epd-${cfg.name}-`, ""),
3093
+ p.status,
3094
+ `${p.restarts} restarts`,
3095
+ `${Math.round(p.memory / 1024 / 1024)}MB`
3096
+ ]);
3097
+ log.plain(rows.length ? table(rows, ["", "process", "status", "", "memory"]) : c.gray(" no processes"));
3098
+ }
3099
+ }
3100
+ const urls = new Set;
3101
+ for (const svc of cfg.services)
3102
+ for (const route of svc.routes)
3103
+ for (const h of route.hosts)
3104
+ urls.add(`${route.ssl ? "https" : "http"}://${h}${route.path ?? ""} ${c.gray(`\u2192 ${svc.name}:${route.port}`)}`);
3105
+ for (const acc of cfg.accessories)
3106
+ for (const route of acc.routes)
3107
+ for (const h of route.hosts)
3108
+ urls.add(`${route.ssl ? "https" : "http"}://${h}${route.path ?? ""} ${c.gray(`\u2192 ${acc.name}:${route.port}`)}`);
3109
+ if (urls.size) {
3110
+ log.plain(`
3111
+ ${c.bold("Routes")}`);
3112
+ for (const u of urls)
3113
+ log.plain(` ${u}`);
3114
+ }
3115
+ }
3116
+ };
3117
+
3118
+ // src/commands/logs.ts
3119
+ var logs = {
3120
+ name: "logs",
3121
+ summary: "Show or follow logs from every replica",
3122
+ options: {
3123
+ follow: { type: "boolean", short: "f" },
3124
+ lines: { type: "string", short: "n" },
3125
+ service: { type: "string" },
3126
+ accessory: { type: "string" },
3127
+ host: { type: "string" },
3128
+ grep: { type: "string" },
3129
+ since: { type: "string" }
3130
+ },
3131
+ help: () => `${c.bold("epd logs")} \u2014 logs from every replica, prefixed with where they came from
3132
+
3133
+ Options
3134
+ -f, --follow Stream new lines (one host at a time)
3135
+ -n, --lines <n> Lines per replica (default 100)
3136
+ --service <s> Only this server group
3137
+ --accessory <a> Only this accessory
3138
+ --host <ip> Only this host
3139
+ --grep <text> Only lines containing this
3140
+ --since <time> docker --since value, e.g. 10m`,
3141
+ async run(ctx) {
3142
+ const { config: cfg } = ctx.load();
3143
+ const lines = String(ctx.values.lines ?? "100");
3144
+ const service = ctx.values.service;
3145
+ const accessory = ctx.values.accessory;
3146
+ const grep = ctx.values.grep;
3147
+ const since = ctx.values.since;
3148
+ const follow = Boolean(ctx.values.follow);
3149
+ let names = allHosts(cfg);
3150
+ if (accessory) {
3151
+ const acc = cfg.accessories.find((a) => a.name === accessory);
3152
+ if (!acc) {
3153
+ const known = cfg.accessories.map((a) => a.name);
3154
+ throw new EpdError(`no such accessory "${accessory}"`, known.length ? `Known: ${known.join(", ")}` : "No accessories configured.");
3155
+ }
3156
+ if (!ctx.values.host)
3157
+ names = [acc.host];
3158
+ }
3159
+ if (ctx.values.host)
3160
+ names = names.filter((h) => h === ctx.values.host);
3161
+ if (!names.length)
3162
+ throw new EpdError(`no host matches "${ctx.values.host}"`);
3163
+ if (follow && names.length > 1 && !ctx.values.host) {
3164
+ log.warn(`following ${names[0]} only \u2014 pass --host to pick another`);
3165
+ names = [names[0]];
3166
+ }
3167
+ const filter = [
3168
+ `--filter "label=epd.app=${cfg.name}"`,
3169
+ service ? `--filter "label=epd.service=${service}"` : "",
3170
+ accessory ? `--filter "label=epd.accessory=${accessory}"` : ""
3171
+ ].filter(Boolean).join(" ");
3172
+ for (const name of names) {
3173
+ const host2 = host(name, cfg.ssh);
3174
+ if (cfg.mode === "docker") {
3175
+ const script = `for ct in $(docker ps -q ${filter}); do
3176
+ n=$(docker inspect -f '{{.Name}}' "$ct" | sed 's|^/||')
3177
+ docker logs ${follow ? "-f " : ""}--tail ${lines} ${since ? `--since ${shq(since)} ` : ""}"$ct" 2>&1 | sed "s|^|$n | " &
3178
+ done
3179
+ wait`;
3180
+ const piped = grep ? `{ ${script} ; } | grep --line-buffered ${shq(grep)}` : script;
3181
+ if (follow) {
3182
+ log.info(`following ${name} \u2014 Ctrl-C to stop`);
3183
+ await host2.interactive(piped);
3184
+ } else {
3185
+ const out = await host2.exec(piped, { allowFailure: true });
3186
+ log.plain(`${c.magenta(name)}`);
3187
+ log.plain(out.stdout || c.gray(" no output"));
3188
+ }
3189
+ } else {
3190
+ const pattern = `epd-${cfg.name}-${service ? `${service}-` : ""}`;
3191
+ const cmd = `${pathPrelude(cfg)} pm2 logs --nostream --lines ${lines} 2>/dev/null | grep -a ${shq(pattern)}${grep ? ` | grep -a ${shq(grep)}` : ""} || true`;
3192
+ if (follow) {
3193
+ log.info(`following ${name} \u2014 Ctrl-C to stop`);
3194
+ await host2.interactive(`${pathPrelude(cfg)} pm2 logs --lines ${lines}`, { sudo: false });
3195
+ } else {
3196
+ const out = await host2.exec(cmd, { allowFailure: true, sudo: false });
3197
+ log.plain(`${c.magenta(name)}`);
3198
+ log.plain(out.stdout || c.gray(" no output"));
3199
+ }
3200
+ }
3201
+ }
3202
+ }
3203
+ };
3204
+
3205
+ // src/commands/exec.ts
3206
+ async function pickHost(ctx, cfg) {
3207
+ const names = allHosts(cfg);
3208
+ const chosen = ctx.values.host ?? names[0];
3209
+ if (!chosen || !names.includes(chosen))
3210
+ throw new EpdError(`no such host "${chosen}"`, `Known: ${names.join(", ")}`);
3211
+ return host(chosen, cfg.ssh);
3212
+ }
3213
+ var exec = {
3214
+ name: "exec",
3215
+ summary: "Run a one-off command with the app's image and environment",
3216
+ options: {
3217
+ host: { type: "string" },
3218
+ service: { type: "string" },
3219
+ accessory: { type: "string" },
3220
+ reuse: { type: "boolean" },
3221
+ interactive: { type: "boolean", short: "i" }
3222
+ },
3223
+ help: () => `${c.bold("epd exec [--] <command\u2026>")} \u2014 run a command against the deployed app
3224
+
3225
+ By default this starts a throwaway container from the deployed image with the
3226
+ same environment. With --reuse it runs inside a container that is already
3227
+ serving traffic.
3228
+
3229
+ Options
3230
+ --host <ip> Which host (default: the first one)
3231
+ --service <s> Which server group (default: the first one)
3232
+ --accessory <a> Run inside an accessory container
3233
+ --reuse Run inside the running container instead of a new one
3234
+ -i, --interactive Attach a terminal
3235
+
3236
+ Examples
3237
+ epd exec -- bun run db:migrate
3238
+ epd exec --reuse -i -- sh
3239
+ epd exec --accessory db -i -- psql -U blog`,
3240
+ async run(ctx) {
3241
+ const { config: cfg } = ctx.load();
3242
+ const command = ctx.positionals.join(" ").trim();
3243
+ if (!command)
3244
+ throw new EpdError("nothing to run", "epd exec -- <command>");
3245
+ const cmdArgs = ctx.positionals.map(shq).join(" ");
3246
+ const accName = ctx.values.accessory;
3247
+ if (accName) {
3248
+ const acc = cfg.accessories.find((a) => a.name === accName);
3249
+ if (!acc) {
3250
+ const known = cfg.accessories.map((a) => a.name);
3251
+ throw new EpdError(`no such accessory "${accName}"`, known.length ? `Known: ${known.join(", ")}` : "No accessories configured.");
3252
+ }
3253
+ const host3 = host(acc.host, cfg.ssh);
3254
+ const container = accessoryContainer(cfg.name, acc.name);
3255
+ log.info(`${host3.name}: ${container} $ ${command}`);
3256
+ const code2 = await host3.interactive(`docker exec ${ctx.values.interactive ? "-it" : ""} ${shq(container)} ${cmdArgs}`);
3257
+ if (code2 !== 0)
3258
+ process.exitCode = code2;
3259
+ return;
3260
+ }
3261
+ const host2 = await pickHost(ctx, cfg);
3262
+ const svc = cfg.services.find((s) => s.name === (ctx.values.service ?? cfg.services[0]?.name));
3263
+ if (!svc)
3264
+ throw new EpdError(`no such server group "${ctx.values.service}"`);
3265
+ const state = await readState(host2, cfg);
3266
+ const version = state?.version;
3267
+ if (!version)
3268
+ throw new EpdError(`${cfg.name} is not deployed on ${host2.name}`);
3269
+ if (cfg.mode === "process") {
3270
+ const dir = `${paths(cfg).current}`;
3271
+ const script = `cd ${shq(dir)} && ${pathPrelude(cfg)} && set -a && . ${shq(paths(cfg).envFile)} && set +a && ${command}`;
3272
+ log.info(`${host2.name}: ${command}`);
3273
+ const code2 = await host2.interactive(script, { sudo: false });
3274
+ if (code2 !== 0)
3275
+ process.exitCode = code2;
3276
+ return;
3277
+ }
3278
+ if (ctx.values.reuse) {
3279
+ const slot = state.services[svc.name]?.slot;
3280
+ const container = `epd-${cfg.name}-${svc.name}-${slot}-0`;
3281
+ log.info(`${host2.name}: ${container} $ ${command}`);
3282
+ const code2 = await host2.interactive(`docker exec ${ctx.values.interactive ? "-it" : ""} ${shq(container)} ${cmdArgs}`);
3283
+ if (code2 !== 0)
3284
+ process.exitCode = code2;
3285
+ return;
3286
+ }
3287
+ const envFile = `${paths(cfg).app}/env.${svc.name}`;
3288
+ const args = [
3289
+ "docker",
3290
+ "run",
3291
+ "--rm",
3292
+ ctx.values.interactive ? "-it" : "",
3293
+ "--network",
3294
+ cfg.proxy.network,
3295
+ "--env-file",
3296
+ envFile,
3297
+ "--env",
3298
+ `PORT=${svc.port}`,
3299
+ "--label",
3300
+ `epd.app=${cfg.name}`,
3301
+ imageRef(cfg, version)
3302
+ ].filter(Boolean);
3303
+ log.info(`${host2.name}: ${command} ${c.gray(`(new container from ${version})`)}`);
3304
+ const code = await host2.interactive(`${args.map((a) => shq(a)).join(" ")} ${cmdArgs}`);
3305
+ if (code !== 0)
3306
+ process.exitCode = code;
3307
+ }
3308
+ };
3309
+ var shell = {
3310
+ name: "shell",
3311
+ summary: "Open a shell inside a running replica",
3312
+ options: { host: { type: "string" }, service: { type: "string" }, accessory: { type: "string" } },
3313
+ help: () => `${c.bold("epd shell")} \u2014 a shell inside a container that is serving traffic
3314
+
3315
+ Options
3316
+ --host <ip> Which host (default: the first one)
3317
+ --service <s> Which server group (default: the first one)
3318
+ --accessory <a> Open a shell inside this accessory`,
3319
+ async run(ctx) {
3320
+ const { config: cfg } = ctx.load();
3321
+ if (ctx.values.accessory) {
3322
+ await exec.run({ ...ctx, values: { ...ctx.values, interactive: true }, positionals: ["sh", "-c", "bash || sh"] });
3323
+ } else if (cfg.mode === "process") {
3324
+ await exec.run({ ...ctx, values: { ...ctx.values, interactive: true }, positionals: ["bash || sh"] });
3325
+ } else {
3326
+ await exec.run({ ...ctx, values: { ...ctx.values, reuse: true, interactive: true }, positionals: ["sh", "-c", "bash || sh"] });
3327
+ }
3328
+ }
3329
+ };
3330
+
3331
+ // src/commands/proxy.ts
3332
+ var proxy = {
3333
+ name: "proxy",
3334
+ summary: "Inspect or restart the shared Traefik proxy",
3335
+ options: { host: { type: "string" }, lines: { type: "string", short: "n" }, follow: { type: "boolean", short: "f" } },
3336
+ help: () => `${c.bold("epd proxy <status|reboot|logs|routes|remove>")}
3337
+
3338
+ One Traefik container per server, shared by every epd app on it. Rebooting it
3339
+ briefly interrupts *all* sites on that server; the apps themselves keep running.
3340
+
3341
+ status Is it up, on which image, with which entrypoints
3342
+ reboot Recreate it from the current configuration
3343
+ logs Traefik's own logs (certificates, routing errors)
3344
+ routes The route file epd wrote for this app
3345
+ remove Remove this app's routes and stop the proxy if nothing else needs it
3346
+
3347
+ Options
3348
+ --host <ip> Only this host
3349
+ -n, --lines <n> Log lines (default 100)
3350
+ -f, --follow Stream logs`,
3351
+ async run(ctx) {
3352
+ const loaded = ctx.load();
3353
+ const cfg = loaded.config;
3354
+ const sub = ctx.positionals[0] ?? "status";
3355
+ let names = proxyHosts(cfg);
3356
+ if (ctx.values.host)
3357
+ names = names.filter((h) => h === ctx.values.host);
3358
+ if (!names.length)
3359
+ throw new EpdError("no host in this config serves routes through the proxy");
3360
+ const hosts = names.map((h) => host(h, cfg.ssh));
3361
+ switch (sub) {
3362
+ case "status": {
3363
+ for (const host2 of hosts) {
3364
+ const s = await proxyState(host2, cfg);
3365
+ const listening = cfg.mode === "docker" ? await host2.capture(`docker port ${PROXY_CONTAINER} 2>/dev/null | sort -u || true`, { allowFailure: true }) : Object.entries(cfg.proxy.entrypoints).map(([n, p]) => `${n} -> :${p}`).join(`
3366
+ `);
3367
+ const apps = await host2.capture(`ls -1 ${shq(paths(cfg).proxyDynamicDir)} 2>/dev/null | sed 's/\\.yml$//' || true`, { allowFailure: true });
3368
+ log.plain(`${c.magenta(host2.name)} ${s.running ? c.green("running") : c.red(s.status)} ${c.gray(s.image)}`);
3369
+ if (listening)
3370
+ log.plain(listening.split(`
3371
+ `).map((l) => ` ${l}`).join(`
3372
+ `));
3373
+ if (apps)
3374
+ log.plain(` apps: ${apps.split(`
3375
+ `).filter(Boolean).join(", ")}`);
3376
+ }
3377
+ return;
3378
+ }
3379
+ case "reboot": {
3380
+ await pool(hosts, 4, async (host2) => {
3381
+ await ensureProxy(host2, cfg, { force: true, secrets: loaded.env });
3382
+ });
3383
+ log.ok("proxy restarted");
3384
+ return;
3385
+ }
3386
+ case "logs": {
3387
+ const lines = String(ctx.values.lines ?? "100");
3388
+ const follow = Boolean(ctx.values.follow);
3389
+ for (const host2 of hosts) {
3390
+ const cmd = cfg.mode === "docker" ? `docker logs ${follow ? "-f " : ""}--tail ${lines} ${PROXY_CONTAINER} 2>&1` : `${pathPrelude(cfg)} pm2 logs ${PROXY_PROCESS} --lines ${lines}${follow ? "" : " --nostream"} 2>&1`;
3391
+ if (follow) {
3392
+ log.info(`following proxy on ${host2.name} \u2014 Ctrl-C to stop`);
3393
+ await host2.interactive(cmd, { sudo: cfg.mode === "docker" ? undefined : false });
3394
+ } else {
3395
+ const out = await host2.exec(`${cmd} || true`, { allowFailure: true, sudo: cfg.mode === "docker" ? undefined : false });
3396
+ log.plain(c.magenta(host2.name));
3397
+ log.plain(out.stdout || c.gray(" no output"));
3398
+ }
3399
+ }
3400
+ return;
3401
+ }
3402
+ case "routes": {
3403
+ for (const host2 of hosts) {
3404
+ const doc = await host2.readFile(paths(cfg).proxyDynamic);
3405
+ log.plain(c.magenta(host2.name));
3406
+ log.plain(doc ?? c.gray(" no routes written yet"));
3407
+ }
3408
+ return;
3409
+ }
3410
+ case "remove": {
3411
+ for (const host2 of hosts) {
3412
+ await removeRoutes(host2, cfg);
3413
+ const left = await host2.capture(`ls -1 ${shq(paths(cfg).proxyDynamicDir)}/*.yml 2>/dev/null | wc -l`, { allowFailure: true });
3414
+ if (Number(left.trim()) === 0) {
3415
+ if (cfg.mode === "docker") {
3416
+ await host2.exec(`docker rm -f ${PROXY_CONTAINER} >/dev/null 2>&1 || true`, { allowFailure: true });
3417
+ } else {
3418
+ await host2.exec(`${pathPrelude(cfg)} pm2 delete ${PROXY_PROCESS} >/dev/null 2>&1 || true`, { allowFailure: true, sudo: false });
3419
+ }
3420
+ log.host(host2.name, "routes removed, proxy stopped (no apps left)");
3421
+ } else {
3422
+ log.host(host2.name, `routes removed, proxy still serving ${left.trim()} other app(s)`);
3423
+ }
3424
+ }
3425
+ return;
3426
+ }
3427
+ default:
3428
+ throw new EpdError(`unknown subcommand "${sub}"`, "Try: status, reboot, logs, routes, remove");
3429
+ }
3430
+ }
3431
+ };
3432
+
3433
+ // src/commands/config.ts
3434
+ var config = {
3435
+ name: "config",
3436
+ summary: "Print the resolved configuration",
3437
+ options: { json: { type: "boolean" }, raw: { type: "boolean" }, traefik: { type: "boolean" } },
3438
+ help: () => `${c.bold("epd config")} \u2014 show what epd actually understood
3439
+
3440
+ Every default filled in, every \${VAR} interpolated. The quickest way to check
3441
+ an overlay or a route before deploying.
3442
+
3443
+ Options
3444
+ --json Print the resolved config as JSON
3445
+ --raw Print the merged YAML input instead
3446
+ --traefik Print the traefik configuration epd would write`,
3447
+ async run(ctx) {
3448
+ const loaded = ctx.load();
3449
+ const cfg = loaded.config;
3450
+ if (ctx.values.raw) {
3451
+ console.log(JSON.stringify(loaded.raw, null, 2));
3452
+ return;
3453
+ }
3454
+ if (ctx.values.json) {
3455
+ console.log(JSON.stringify(cfg, null, 2));
3456
+ return;
3457
+ }
3458
+ if (ctx.values.traefik) {
3459
+ const targetHost = allHosts(cfg)[0] ?? "host";
3460
+ const live = new Map([[targetHost, new Map(cfg.services.map((s) => [s.name, "blue"]))]]);
3461
+ log.plain(c.bold("# traefik.yml (static, shared by every app on the host)"));
3462
+ console.log(JSON.stringify(staticConfig({
3463
+ app: cfg.name,
3464
+ image: cfg.proxy.image,
3465
+ network: cfg.proxy.network,
3466
+ entrypoints: cfg.proxy.entrypoints,
3467
+ ssl: cfg.proxy.ssl,
3468
+ email: cfg.proxy.email,
3469
+ challenge: cfg.proxy.challenge,
3470
+ dnsProvider: cfg.proxy.dnsProvider,
3471
+ dnsEnv: cfg.proxy.dnsEnv,
3472
+ acmeServer: cfg.proxy.acmeServer,
3473
+ logLevel: cfg.proxy.logLevel,
3474
+ accessLog: cfg.proxy.accessLog,
3475
+ dashboard: cfg.proxy.dashboard,
3476
+ dashboardHost: cfg.proxy.dashboardHost,
3477
+ trustedIps: cfg.proxy.trustedIps,
3478
+ extra: cfg.proxy.extra
3479
+ }), null, 2));
3480
+ log.plain(c.bold(`
3481
+ # dynamic/${cfg.name}.yml (this app's routes)`));
3482
+ console.log(JSON.stringify(dynamicConfig(cfg, buildEndpoints(cfg, targetHost, live), buildAccessoryEndpoints(cfg, targetHost)), null, 2));
3483
+ return;
3484
+ }
3485
+ log.plain(`${c.bold(cfg.name)} ${c.gray(cfg.configPath)}`);
3486
+ log.plain(` mode ${cfg.mode}${cfg.destination ? c.gray(` (${cfg.destination})`) : ""}`);
3487
+ if (cfg.mode === "docker")
3488
+ log.plain(` image ${cfg.image}${cfg.registry ? c.gray(` via ${cfg.registry.server ?? "docker.io"}`) : c.gray(" shipped over ssh")}`);
3489
+ else
3490
+ log.plain(` start ${cfg.process.start}`);
3491
+ log.plain(` hosts ${allHosts(cfg).join(", ")}`);
3492
+ log.plain(` ssh ${cfg.ssh.user}@\u2026:${cfg.ssh.port}`);
3493
+ log.plain(` strategy ${cfg.strategy}`);
3494
+ if (usesHostPorts(cfg)) {
3495
+ const b = portBlock(cfg);
3496
+ log.plain(` ports ${b.from}-${b.to} ${c.gray("(reserved on every host)")}`);
3497
+ }
3498
+ log.plain(`
3499
+ ${c.bold("Servers")}`);
3500
+ for (const svc of cfg.services) {
3501
+ log.plain(` ${c.bold(svc.name)} ${svc.replicas} \xD7 ${svc.hosts.length} host(s) on port ${svc.port}`);
3502
+ if (svc.command)
3503
+ log.plain(` command ${svc.command}`);
3504
+ log.plain(` hosts ${svc.hosts.join(", ")}`);
3505
+ if (usesHostPorts(cfg)) {
3506
+ log.plain(` host ports ${hostPort(cfg, svc, "blue", 0)}\u2026 ${c.gray("(blue)")} / ${hostPort(cfg, svc, "green", 0)}\u2026 ${c.gray("(green)")}`);
3507
+ }
3508
+ log.plain(` health ${svc.health.path ?? "tcp connect"} ${c.gray(`${svc.health.status}, ${svc.health.timeout}s`)}`);
3509
+ for (const route of svc.routes) {
3510
+ const scheme = route.ssl ? "https" : "http";
3511
+ const hosts = route.hosts.length ? route.hosts.join(", ") : "(any host)";
3512
+ log.plain(` route ${c.cyan(`${scheme}://${hosts}${route.path ?? ""}`)} \u2192 :${route.port}${route.redirect ? c.gray(` (redirects to ${route.redirect.to})`) : ""}`);
3513
+ }
3514
+ if (!svc.routes.length)
3515
+ log.plain(` route ${c.gray("none \u2014 worker, not exposed")}`);
3516
+ }
3517
+ if (cfg.accessories.length) {
3518
+ log.plain(`
3519
+ ${c.bold("Accessories")}`);
3520
+ for (const acc of cfg.accessories)
3521
+ log.plain(` ${acc.name.padEnd(12)} ${acc.image} ${c.gray(`on ${acc.host}`)}`);
3522
+ }
3523
+ log.plain(`
3524
+ ${c.bold("Proxy")}`);
3525
+ if (!cfg.proxy.enabled)
3526
+ log.plain(` ${c.gray("disabled")}`);
3527
+ else {
3528
+ log.plain(` traefik ${cfg.proxy.image} on ${proxyHosts(cfg).join(", ") || c.gray("no host serves routes")}`);
3529
+ log.plain(` entrypoints ${Object.entries(cfg.proxy.entrypoints).map(([n, p]) => `${n}:${p}`).join(", ")}`);
3530
+ log.plain(` tls ${cfg.proxy.ssl ? `Let's Encrypt (${cfg.proxy.challenge} challenge)` : "off"}`);
3531
+ log.plain(` cross host ${cfg.proxy.crossHost ? "on \u2014 every proxy balances across every host" : "off \u2014 each proxy uses its own replicas"}`);
3532
+ }
3533
+ const secrets = [...new Set([...cfg.secrets, ...cfg.services.flatMap((s) => s.secrets)])];
3534
+ if (secrets.length) {
3535
+ log.plain(`
3536
+ ${c.bold("Secrets")} ${c.gray("(read from the environment at deploy time)")}`);
3537
+ for (const s of secrets)
3538
+ log.plain(` ${s.padEnd(24)} ${loaded.env[s] !== undefined ? c.green("set") : c.red("missing")}`);
3539
+ }
3540
+ }
3541
+ };
3542
+
3543
+ // src/commands/lock.ts
3544
+ var lock = {
3545
+ name: "lock",
3546
+ summary: "Inspect or clear the deploy lock",
3547
+ options: {},
3548
+ help: () => `${c.bold("epd lock <status|release>")}
3549
+
3550
+ epd takes a lock on every server while deploying so two deploys cannot overlap.
3551
+ If a deploy was killed halfway, release it by hand.`,
3552
+ async run(ctx) {
3553
+ const { config: cfg } = ctx.load();
3554
+ const hosts = allHosts(cfg).map((h) => host(h, cfg.ssh));
3555
+ const sub = ctx.positionals[0] ?? "status";
3556
+ if (sub === "release") {
3557
+ await DeployLock.forceRelease(cfg, hosts);
3558
+ log.ok(`lock released on ${hosts.length} host(s)`);
3559
+ return;
3560
+ }
3561
+ if (sub !== "status")
3562
+ throw new EpdError(`unknown subcommand "${sub}"`, "Try: status, release");
3563
+ for (const { host: host2, info } of await DeployLock.status(cfg, hosts)) {
3564
+ log.plain(info ? `${c.magenta(host2)} ${c.yellow("locked")} by ${info.by} ${c.gray(`${info.at} \u2014 ${info.what}`)}` : `${c.magenta(host2)} ${c.green("free")}`);
3565
+ }
3566
+ }
3567
+ };
3568
+
3569
+ // src/commands/remove.ts
3570
+ var remove = {
3571
+ name: "remove",
3572
+ summary: "Stop this app and delete its files from the servers",
3573
+ options: { yes: { type: "boolean", short: "y" }, "keep-data": { type: "boolean" }, host: { type: "string", multiple: true } },
3574
+ help: () => `${c.bold("epd remove")} \u2014 take this app off the servers
3575
+
3576
+ Stops every replica, removes this app's routes, and deletes its directory.
3577
+ Other epd apps on the same servers, and the shared proxy, are left alone.
3578
+
3579
+ Options
3580
+ -y, --yes Do not ask for confirmation
3581
+ --keep-data Leave ${"{remote_root}"}/apps/<app> in place (releases, env files)
3582
+ --host <ip> Only this host`,
3583
+ async run(ctx) {
3584
+ const { config: cfg } = ctx.load();
3585
+ const only = ctx.values.host;
3586
+ const names = allHosts(cfg).filter((h) => !only?.length || only.includes(h));
3587
+ if (!ctx.values.yes) {
3588
+ if (!interactive())
3589
+ throw new EpdError("refusing to remove without confirmation", "Pass --yes to remove non-interactively.");
3590
+ const ok = await confirm(`Remove ${c.bold(cfg.name)} from ${names.join(", ")}?`, false);
3591
+ if (!ok) {
3592
+ log.info("nothing removed");
3593
+ return;
3594
+ }
3595
+ }
3596
+ await pool(names.map((h) => host(h, cfg.ssh)), 4, async (host2) => {
3597
+ await removeRoutes(host2, cfg);
3598
+ if (cfg.mode === "docker")
3599
+ await removeApp(host2, cfg);
3600
+ else
3601
+ await removeApp2(host2, cfg);
3602
+ if (!ctx.values["keep-data"]) {
3603
+ await host2.exec(`rm -rf ${shq(paths(cfg).app)} ${shq(`${paths(cfg).portsDir}/${cfg.portBase}`)}`, { allowFailure: true });
3604
+ }
3605
+ log.host(host2.name, "removed");
3606
+ });
3607
+ log.ok(`${cfg.name} removed from ${names.length} host(s)`);
3608
+ log.info(`the shared proxy is still running \u2014 ${c.cyan("epd proxy remove")} stops it if nothing else uses it`);
3609
+ }
3610
+ };
3611
+
3612
+ // src/commands/dockerCommand.ts
3613
+ function pretty(args) {
3614
+ const out = [];
3615
+ let line = "";
3616
+ for (const arg of args) {
3617
+ const quoted = /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : shq(arg);
3618
+ if (arg.startsWith("-") && line) {
3619
+ out.push(line);
3620
+ line = ` ${quoted}`;
3621
+ } else {
3622
+ line = line ? `${line} ${quoted}` : quoted;
3623
+ }
3624
+ }
3625
+ if (line)
3626
+ out.push(line);
3627
+ return out.join(" \\\n");
3628
+ }
3629
+ var dockerCommand = {
3630
+ name: "docker-command",
3631
+ aliases: ["command", "print-command"],
3632
+ summary: "Print the docker (or pm2) commands epd would run",
3633
+ options: { host: { type: "string" }, service: { type: "string" }, slot: { type: "string" }, version: { type: "string" }, replica: { type: "string" } },
3634
+ help: () => `${c.bold("epd docker-command")} \u2014 show the exact command epd runs for you
3635
+
3636
+ Useful to check what epd is doing, to run a container by hand, or to lift the
3637
+ command into another tool.
3638
+
3639
+ Options
3640
+ --host <ip> Which host to generate for (default: the first one)
3641
+ --service <name> Which server group (default: all)
3642
+ --slot <blue|green>
3643
+ --replica <n> Only this replica
3644
+ --version <v> Tag to use (default: the current git sha)`,
3645
+ async run(ctx) {
3646
+ const { config: cfg } = ctx.load();
3647
+ const version = (await resolveVersion(cfg, ctx.values.version)).id;
3648
+ const slot = ctx.values.slot ?? "blue";
3649
+ const services = cfg.services.filter((s) => !ctx.values.service || s.name === ctx.values.service);
3650
+ for (const svc of services) {
3651
+ const serverHost = ctx.values.host ?? svc.hosts[0] ?? "HOST";
3652
+ const replicas = ctx.values.replica !== undefined ? [Number(ctx.values.replica)] : Array.from({ length: svc.replicas }, (_, i) => i);
3653
+ log.plain(`${c.bold(`# ${svc.name}`)} ${c.gray(`on ${serverHost}, slot ${slot}, version ${version}`)}`);
3654
+ if (cfg.mode === "docker") {
3655
+ for (const i of replicas) {
3656
+ const args = dockerRunArgs({
3657
+ cfg,
3658
+ svc,
3659
+ slot,
3660
+ version,
3661
+ replica: i,
3662
+ serverHost,
3663
+ envFile: `${paths(cfg).app}/env.${svc.name}`
3664
+ });
3665
+ log.plain(pretty(args));
3666
+ log.plain("");
3667
+ }
3668
+ } else {
3669
+ const command = svc.command ?? cfg.process.start;
3670
+ const argv = parseCommand(command);
3671
+ for (const i of replicas) {
3672
+ const name = processName(cfg.name, svc.name, slot, i);
3673
+ const port = hostPort(cfg, svc, slot, i);
3674
+ log.plain(`PORT=${port} pm2 start ${shq(argv[0] ?? "")} --name ${shq(name)} \\
3675
+ --cwd ${shq(`${paths(cfg).current}`)} --interpreter none${argv.length > 1 ? ` \\
3676
+ -- ${argv.slice(1).map((a) => shq(a)).join(" ")}` : ""}`);
3677
+ log.plain("");
3678
+ }
3679
+ }
3680
+ }
3681
+ if (cfg.accessories.length) {
3682
+ for (const acc of cfg.accessories) {
3683
+ log.plain(`${c.bold(`# accessory: ${acc.name}`)} ${c.gray(`on ${acc.host}`)}`);
3684
+ const accArgs = [
3685
+ "docker",
3686
+ "run",
3687
+ "-d",
3688
+ "--name",
3689
+ accessoryContainer(cfg.name, acc.name),
3690
+ "--restart",
3691
+ "unless-stopped",
3692
+ "--network",
3693
+ cfg.proxy.network,
3694
+ "--label",
3695
+ `epd.app=${cfg.name}`,
3696
+ "--label",
3697
+ `epd.accessory=${acc.name}`,
3698
+ "--label",
3699
+ "epd.managed=true",
3700
+ "--env-file",
3701
+ `${paths(cfg).app}/env.acc.${acc.name}`,
3702
+ "--log-opt",
3703
+ "max-size=10m",
3704
+ "--log-opt",
3705
+ "max-file=3",
3706
+ ...acc.volumes.flatMap((v) => ["--volume", v]),
3707
+ ...acc.ports.flatMap((p) => ["--publish", p]),
3708
+ ...acc.dockerOptions,
3709
+ acc.image,
3710
+ ...acc.command ? parseCommand(acc.command) : []
3711
+ ];
3712
+ log.plain(pretty(accArgs));
3713
+ log.plain("");
3714
+ }
3715
+ }
3716
+ log.plain(`${c.bold("# proxy")} ${c.gray("(one per server, shared by every epd app on it)")}`);
3717
+ log.plain(pretty([
3718
+ "docker",
3719
+ "run",
3720
+ "-d",
3721
+ "--name",
3722
+ "epd-proxy",
3723
+ "--restart",
3724
+ "unless-stopped",
3725
+ "--network",
3726
+ cfg.proxy.network,
3727
+ ...Object.values(cfg.proxy.entrypoints).flatMap((p) => ["-p", `${p}:${p}`]),
3728
+ "-v",
3729
+ `${paths(cfg).proxyStatic}:/etc/traefik/traefik.yml:ro`,
3730
+ "-v",
3731
+ `${paths(cfg).proxyDynamicDir}:/etc/traefik/dynamic:ro`,
3732
+ "-v",
3733
+ `${paths(cfg).acmeDir}:/acme`,
3734
+ cfg.proxy.image
3735
+ ]));
3736
+ }
3737
+ };
3738
+
3739
+ // src/commands/index.ts
3740
+ var commands = [
3741
+ init,
3742
+ setup,
3743
+ deploy2,
3744
+ redeploy,
3745
+ rollback,
3746
+ status,
3747
+ logs,
3748
+ exec,
3749
+ shell,
3750
+ proxy,
3751
+ config,
3752
+ lock,
3753
+ remove,
3754
+ dockerCommand
3755
+ ];
3756
+
3757
+ // src/cli.ts
3758
+ var VERSION = "0.1.0";
3759
+ var USAGE = `${c.bold("epd")} \u2014 Easy Project Deployer ${c.gray(`v${VERSION}`)}
3760
+
3761
+ ${c.bold("Usage")}
3762
+ epd <command> [options]
3763
+
3764
+ ${c.bold("Commands")}
3765
+ ${commands.filter((cmd) => !cmd.hidden).map((cmd) => ` ${cmd.name.padEnd(16)}${cmd.summary}`).join(`
3766
+ `)}
3767
+
3768
+ ${c.bold("Global options")}
3769
+ -c, --config <file> Path to epd.yml
3770
+ -d, --destination <d> Overlay epd.<d>.yml (staging, production, \u2026)
3771
+ -v, --verbose Show every command epd runs
3772
+ -h, --help Show help for a command
3773
+ --version Print the epd version
3774
+
3775
+ ${c.bold("Examples")}
3776
+ epd init Create an epd.yml for this project
3777
+ epd setup Prepare the servers, then deploy
3778
+ epd deploy Build and deploy the current commit
3779
+ epd deploy -d staging Deploy with the staging overlay
3780
+ epd rollback Go back to the previous version
3781
+ epd logs -f Follow logs from every server
3782
+ epd docker-command Print the docker run command epd would use
3783
+ `;
3784
+ async function main(argv) {
3785
+ if (argv.includes("--version") && !argv[0]?.startsWith("-")) {}
3786
+ const first = argv[0];
3787
+ if (!first || first === "-h" || first === "--help" || first === "help") {
3788
+ console.log(USAGE);
3789
+ return 0;
3790
+ }
3791
+ if (first === "--version" || first === "-V") {
3792
+ console.log(VERSION);
3793
+ return 0;
3794
+ }
3795
+ const command = commands.find((cmd) => cmd.name === first || cmd.aliases?.includes(first));
3796
+ if (!command) {
3797
+ log.error(`unknown command "${first}"`);
3798
+ console.log(`
3799
+ Run ${c.cyan("epd --help")} to see the available commands.`);
3800
+ return 1;
3801
+ }
3802
+ const rest = argv.slice(1);
3803
+ let parsed;
3804
+ try {
3805
+ parsed = parseArgs({
3806
+ args: rest,
3807
+ options: {
3808
+ config: { type: "string", short: "c" },
3809
+ destination: { type: "string", short: "d" },
3810
+ verbose: { type: "boolean", short: "v" },
3811
+ help: { type: "boolean", short: "h" },
3812
+ ...command.options ?? {}
3813
+ },
3814
+ allowPositionals: true,
3815
+ allowNegative: true
3816
+ });
3817
+ } catch (e) {
3818
+ log.error(e instanceof Error ? e.message : String(e));
3819
+ console.log(`
3820
+ ${command.help()}`);
3821
+ return 1;
3822
+ }
3823
+ if (parsed.values.help) {
3824
+ console.log(command.help());
3825
+ return 0;
3826
+ }
3827
+ setVerbose(Boolean(parsed.values.verbose));
3828
+ const loadOptions = {
3829
+ file: parsed.values.config,
3830
+ destination: parsed.values.destination
3831
+ };
3832
+ const ctx = {
3833
+ values: parsed.values,
3834
+ positionals: parsed.positionals,
3835
+ loadOptions,
3836
+ load: () => loadConfig(loadOptions)
3837
+ };
3838
+ await command.run(ctx);
3839
+ return 0;
3840
+ }
3841
+ try {
3842
+ process.exitCode = await main(process.argv.slice(2));
3843
+ } catch (error) {
3844
+ if (error instanceof EpdError) {
3845
+ log.error(error.message);
3846
+ if (error.hint)
3847
+ console.error(`
3848
+ ${c.gray(error.hint)}`);
3849
+ } else {
3850
+ log.error(error instanceof Error ? error.message : String(error));
3851
+ if (process.env.EPD_DEBUG && error instanceof Error)
3852
+ console.error(error.stack);
3853
+ }
3854
+ process.exitCode = 1;
3855
+ }