dokku-compose 0.13.0 → 0.14.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/index.js CHANGED
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ assertDokkuSupported,
4
+ compareToFile,
5
+ enforceFileVersion,
6
+ logAction,
7
+ logDone,
8
+ logError,
9
+ logSkip,
10
+ logWarn
11
+ } from "./chunk-WM244P42.js";
2
12
 
3
13
  // src/index.ts
4
14
  import { Command } from "commander";
5
- import * as fs3 from "fs";
15
+ import * as fs4 from "fs";
6
16
  import * as yaml3 from "js-yaml";
7
17
  import { createRequire } from "module";
8
18
 
@@ -12,6 +22,241 @@ import * as yaml from "js-yaml";
12
22
 
13
23
  // src/core/schema.ts
14
24
  import { z } from "zod";
25
+
26
+ // src/resources/parsers.ts
27
+ function parseReport(raw, namespace) {
28
+ const result = {};
29
+ const prefixPattern = namespace.replace(/-/g, "[\\s-]");
30
+ const prefix = new RegExp(`^${prefixPattern}\\s+`, "i");
31
+ for (const line of raw.split("\n")) {
32
+ if (line.trimStart().startsWith("=====>")) continue;
33
+ const colonIdx = line.indexOf(":");
34
+ if (colonIdx === -1) continue;
35
+ const rawKey = line.slice(0, colonIdx).trim();
36
+ if (!rawKey) continue;
37
+ const value = line.slice(colonIdx + 1).trim();
38
+ const stripped = rawKey.replace(prefix, "");
39
+ const key = stripped.toLowerCase().replace(/\s+/g, "-");
40
+ if (key.startsWith("computed-") || key.startsWith("global-") || key === "last-visited-at") continue;
41
+ if (!value) continue;
42
+ result[key] = value;
43
+ }
44
+ return result;
45
+ }
46
+ function parseBulkReport(raw, namespace, headerNamespace) {
47
+ const result = /* @__PURE__ */ new Map();
48
+ const sections = raw.split(/(?=^=====> )/m).filter((s) => s.trim());
49
+ const nsPattern = (headerNamespace ?? namespace).replace(/-/g, "[\\s-]");
50
+ const headerRe = new RegExp(`^=====> (.+?)\\s+${nsPattern}\\s+information`, "i");
51
+ for (const section of sections) {
52
+ const headerEnd = section.indexOf("\n");
53
+ if (headerEnd === -1) continue;
54
+ const header = section.slice(0, headerEnd);
55
+ const match = header.match(headerRe);
56
+ if (!match) continue;
57
+ const app = match[1];
58
+ result.set(app, parseReport(section, namespace));
59
+ }
60
+ return result;
61
+ }
62
+ function parseJsonReport(raw, scope) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ } catch {
67
+ throw new Error(`report --format json did not return JSON: ${raw.slice(0, 200)}`);
68
+ }
69
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
70
+ throw new Error(`report --format json did not return a JSON object: ${raw.slice(0, 200)}`);
71
+ }
72
+ const result = {};
73
+ for (const [key, value] of Object.entries(parsed)) {
74
+ if (value === null || value === void 0 || String(value) === "") continue;
75
+ if (scope === "global") {
76
+ if (key.startsWith("global-")) result[key.slice("global-".length)] = String(value);
77
+ } else if (scope.includes(key)) {
78
+ result[key] = String(value);
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+
84
+ // src/resources/properties.ts
85
+ async function isAppDeployed(ctx, target) {
86
+ const raw = await ctx.query("ps:report", target, "--deployed");
87
+ return raw.trim() === "true";
88
+ }
89
+ var NGINX_KEYS = [
90
+ "access-log-format",
91
+ "access-log-path",
92
+ "bind-address-ipv4",
93
+ "bind-address-ipv6",
94
+ "client-body-timeout",
95
+ "client-header-timeout",
96
+ "client-max-body-size",
97
+ "disable-custom-config",
98
+ "error-log-path",
99
+ "hsts",
100
+ "hsts-include-subdomains",
101
+ "hsts-max-age",
102
+ "hsts-preload",
103
+ "keepalive-timeout",
104
+ "lingering-timeout",
105
+ "nginx-conf-sigil-path",
106
+ "nginx-service-command",
107
+ "proxy-buffer-size",
108
+ "proxy-buffering",
109
+ "proxy-buffers",
110
+ "proxy-busy-buffers-size",
111
+ "proxy-connect-timeout",
112
+ "proxy-keepalive",
113
+ "proxy-read-timeout",
114
+ "proxy-send-timeout",
115
+ "send-timeout",
116
+ "underscore-in-headers",
117
+ "x-forwarded-for-value",
118
+ "x-forwarded-port-value",
119
+ "x-forwarded-proto-value",
120
+ "x-forwarded-ssl"
121
+ ];
122
+ var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
123
+ var LOGS_GLOBAL_KEYS = [...LOGS_KEYS, "vector-image", "vector-networks"];
124
+ var REGISTRY_KEYS = [
125
+ "image-repo",
126
+ "image-repo-template",
127
+ "push-extra-tags",
128
+ "push-on-release",
129
+ "server"
130
+ ];
131
+ function propertyRemovals(allowed) {
132
+ return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
133
+ }
134
+ function propertyPrune(setCmd, afterPrune = []) {
135
+ return async (ctx, target, removed) => {
136
+ for (const key of removed) await ctx.run(setCmd, target, key);
137
+ for (const cmd of afterPrune) {
138
+ try {
139
+ await ctx.run(cmd, target);
140
+ } catch (err) {
141
+ if (await isAppDeployed(ctx, target)) throw err;
142
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
143
+ }
144
+ }
145
+ };
146
+ }
147
+ function propertyResource(opts) {
148
+ return {
149
+ key: opts.key,
150
+ // Keys only on the server never make the block "changed"; diff shows them as removals.
151
+ additive: true,
152
+ removals: propertyRemovals(opts.settable),
153
+ prune: propertyPrune(opts.setCmd, opts.afterChange),
154
+ async read(ctx, target) {
155
+ if (opts.json) return parseJsonReport(await ctx.queryStrict(`${opts.namespace}:report`, target, "--format", "json"), opts.settable);
156
+ const raw = await ctx.query(`${opts.namespace}:report`, target);
157
+ return parseReport(raw, opts.namespace);
158
+ },
159
+ ...opts.json ? {} : {
160
+ async readAll(ctx) {
161
+ const raw = await ctx.query(`${opts.namespace}:report`);
162
+ return parseBulkReport(raw, opts.namespace);
163
+ }
164
+ },
165
+ async onChange(ctx, target, change, deferFollowUp = false) {
166
+ for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
167
+ await ctx.run(opts.setCmd, target, key, String(value));
168
+ }
169
+ if (opts.afterChange && !deferFollowUp) {
170
+ for (const cmd of opts.afterChange) {
171
+ try {
172
+ await ctx.run(cmd, target);
173
+ } catch (err) {
174
+ if (await isAppDeployed(ctx, target)) throw err;
175
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ };
181
+ }
182
+ var Nginx = propertyResource({
183
+ key: "nginx",
184
+ namespace: "nginx",
185
+ setCmd: "nginx:set",
186
+ settable: NGINX_KEYS,
187
+ afterChange: ["proxy:build-config"]
188
+ });
189
+ var Logs = propertyResource({
190
+ key: "logs",
191
+ namespace: "logs",
192
+ setCmd: "logs:set",
193
+ settable: LOGS_KEYS,
194
+ json: true
195
+ });
196
+ var Registry = propertyResource({
197
+ key: "registry",
198
+ namespace: "registry",
199
+ setCmd: "registry:set",
200
+ settable: REGISTRY_KEYS
201
+ });
202
+ function schedulerParts(cfg) {
203
+ if (typeof cfg === "string") return { selected: cfg, shell: "" };
204
+ return { selected: cfg?.selected ?? "", shell: cfg?.shell ?? "" };
205
+ }
206
+ function schedulerFromReport(report) {
207
+ const selected = report["selected"] ?? "";
208
+ const shell = report["shell"];
209
+ return shell ? { ...selected ? { selected } : {}, shell } : selected;
210
+ }
211
+ var Scheduler = {
212
+ key: "scheduler",
213
+ // A scheduler setting on the app that the file does not declare.
214
+ removals: (current, desired) => {
215
+ const have = schedulerParts(current);
216
+ const want = schedulerParts(desired);
217
+ const removed = [];
218
+ if (have.selected && !want.selected) removed.push("scheduler");
219
+ if (have.shell && !want.shell) removed.push("shell");
220
+ return removed;
221
+ },
222
+ prune: async (ctx, target, removed) => {
223
+ for (const label of removed) await ctx.run("scheduler:set", target, label === "shell" ? "shell" : "selected");
224
+ },
225
+ inSync: (current, desired) => {
226
+ const have = schedulerParts(current);
227
+ const want = schedulerParts(desired);
228
+ return (!want.selected || want.selected === have.selected) && (!want.shell || want.shell === have.shell);
229
+ },
230
+ async read(ctx, target) {
231
+ const raw = await ctx.query("scheduler:report", target);
232
+ return schedulerFromReport(parseReport(raw, "scheduler"));
233
+ },
234
+ async readAll(ctx) {
235
+ const raw = await ctx.query("scheduler:report");
236
+ const bulk = parseBulkReport(raw, "scheduler");
237
+ const result = /* @__PURE__ */ new Map();
238
+ for (const [app, report] of bulk) result.set(app, schedulerFromReport(report));
239
+ return result;
240
+ },
241
+ async onChange(ctx, target, change) {
242
+ const have = schedulerParts(change.before);
243
+ const want = schedulerParts(change.after);
244
+ if (want.selected && want.selected !== have.selected) await ctx.run("scheduler:set", target, "selected", want.selected);
245
+ if (want.shell && want.shell !== have.shell) await ctx.run("scheduler:set", target, "shell", want.shell);
246
+ }
247
+ };
248
+
249
+ // src/core/schema.ts
250
+ function propertyMap(namespace, keys, value) {
251
+ return z.record(z.string(), value).superRefine((map, ctx) => {
252
+ for (const key of Object.keys(map)) {
253
+ if (!keys.includes(key)) {
254
+ ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set property (valid: ${keys.join(", ")})` });
255
+ }
256
+ }
257
+ });
258
+ }
259
+ var StringOrNumber = z.union([z.string(), z.number()]);
15
260
  var PortSchema = z.string().regex(
16
261
  /^(http|https|tcp|udp):\d+:\d+$/,
17
262
  "Port must be scheme:host:container (e.g. http:80:3000)"
@@ -24,46 +269,79 @@ var ChecksSchema = z.union([
24
269
  z.literal(false),
25
270
  z.object({
26
271
  disabled: z.array(z.string()).optional(),
27
- skipped: z.array(z.string()).optional()
28
- }).catchall(z.union([z.string(), z.number(), z.boolean()]))
272
+ skipped: z.array(z.string()).optional(),
273
+ // The only `checks:set` key (Dokku 0.38.31); anything else is refused.
274
+ "wait-to-retire": z.union([z.string(), z.number(), z.boolean()]).optional()
275
+ }).strict()
29
276
  ]);
30
277
  var GitSchema = z.object({
31
- deploy_branch: z.string().optional()
32
- });
278
+ deploy_branch: z.string().optional(),
279
+ keep_git_dir: z.union([z.string(), z.boolean()]).optional(),
280
+ rev_env_var: z.string().optional(),
281
+ source_image: z.string().optional(),
282
+ // Dokku reads `archive-max-files` and `archive-max-size` only from the global setting
283
+ // (`git:set --global`), which dokku-compose does not manage yet; a per-app value never applies.
284
+ archive_max_files: z.any().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-files <n>`" }),
285
+ archive_max_size: z.any().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-size <n>`" })
286
+ }).strict();
287
+ var LinkSchema = z.union([
288
+ z.string(),
289
+ z.object({
290
+ service: z.string(),
291
+ alias: z.string().min(1).optional(),
292
+ env_var: z.string().min(1).optional(),
293
+ querystring: z.string().min(1).optional()
294
+ }).strict().refine((l) => !(l.alias && l.env_var), { message: "`alias` and `env_var` cannot be combined" })
295
+ ]);
33
296
  var AppSchema = z.object({
34
297
  domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
35
- links: z.array(z.string()).optional(),
298
+ links: z.array(LinkSchema).optional(),
36
299
  ports: z.array(PortSchema).optional(),
37
300
  env: EnvMapSchema.optional(),
301
+ /** App env keys something else sets: names only; `diff` stops noting them. */
302
+ env_external: z.array(z.string().min(1)).optional(),
38
303
  ssl: z.union([
39
304
  z.literal(false),
40
305
  z.literal(true),
41
306
  z.object({ certfile: z.string(), keyfile: z.string() })
42
307
  ]).optional(),
43
308
  storage: z.array(z.string()).optional(),
44
- proxy: z.object({ enabled: z.boolean() }).optional(),
309
+ proxy: z.object({
310
+ enabled: z.boolean().optional(),
311
+ type: z.string().optional(),
312
+ proxy_port: StringOrNumber.optional(),
313
+ proxy_ssl_port: StringOrNumber.optional()
314
+ }).strict().optional(),
45
315
  networks: z.array(z.string()).optional(),
46
316
  network: z.object({
47
317
  attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
48
318
  initial_network: z.union([z.string(), z.literal(false)]).optional(),
49
319
  bind_all_interfaces: z.boolean().optional(),
320
+ static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
50
321
  tld: z.union([z.string(), z.literal(false)]).optional()
51
322
  }).optional(),
52
- nginx: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
53
- logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
54
- registry: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
55
- scheduler: z.string().optional(),
323
+ nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
324
+ logs: propertyMap("logs", LOGS_KEYS, StringOrNumber).optional(),
325
+ // `auth-servers` is report-only: it is a registry login, not a `registry:set` key.
326
+ registry: propertyMap("registry", REGISTRY_KEYS, z.union([z.string(), z.boolean()])).optional(),
327
+ scheduler: z.union([z.string(), z.object({ selected: z.string().optional(), shell: z.string().optional() }).strict()]).optional(),
56
328
  scale: z.record(z.string(), z.preprocess((v) => typeof v === "string" && /^\s*-?\d+\s*$/.test(v) ? Number(v) : v, z.number().int().min(0))).optional(),
57
329
  ps: z.object({
58
330
  "procfile-path": z.union([z.string(), z.number()]).optional(),
59
331
  "restart-policy": z.union([z.string(), z.number()]).optional(),
60
- "stop-timeout-seconds": z.union([z.string(), z.number()]).optional()
332
+ "stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
333
+ "dockerfile-start-cmd": z.union([z.string(), z.number()]).optional(),
334
+ restore: z.union([z.string(), z.boolean()]).optional(),
335
+ "skip-deploy": z.union([z.string(), z.boolean()]).optional(),
336
+ "start-cmd": z.union([z.string(), z.number()]).optional()
61
337
  }).strict().optional(),
62
338
  checks: ChecksSchema.optional(),
63
339
  build: z.object({
64
340
  dockerfile: z.string().optional(),
65
341
  app_json: z.string().optional(),
66
342
  context: z.string().optional(),
343
+ selected: z.string().optional(),
344
+ skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
67
345
  args: z.record(z.string(), z.string()).optional()
68
346
  }).optional(),
69
347
  docker_options: z.object({
@@ -88,7 +366,8 @@ var ServiceBackupSchema = z.object({
88
366
  var PostgresSchema = z.object({
89
367
  version: z.string().optional(),
90
368
  image: z.string().optional(),
91
- backup: ServiceBackupSchema.optional()
369
+ // false removes the service's backup schedule and credentials; absent leaves them alone
370
+ backup: z.union([ServiceBackupSchema, z.literal(false)]).optional()
92
371
  });
93
372
  var RedisSchema = z.object({
94
373
  version: z.string().optional(),
@@ -114,8 +393,9 @@ var ConfigSchema = z.object({
114
393
  apps: z.record(z.string(), AppSchema),
115
394
  domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
116
395
  env: EnvMapSchema.optional(),
117
- nginx: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
118
- logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
396
+ nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
397
+ // The top-level block is also applied with `--global`, which takes two more keys.
398
+ logs: propertyMap("logs", LOGS_GLOBAL_KEYS, StringOrNumber).optional(),
119
399
  git: GitSchema.optional()
120
400
  });
121
401
  function parseConfig(raw) {
@@ -180,7 +460,7 @@ function maskSensitiveArgs(cmd) {
180
460
  return `${key}=${maskValue(value)}`;
181
461
  });
182
462
  masked = masked.replace(
183
- /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
463
+ /^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
184
464
  (_, prefix, password) => `${prefix}${maskValue(password)}`
185
465
  );
186
466
  masked = masked.replace(
@@ -205,7 +485,7 @@ function maskSensitiveArgsList(args) {
205
485
  masked[i] = `${m[1]}=${maskValue(m[2])}`;
206
486
  }
207
487
  }
208
- if (masked[0] === "registry:login") {
488
+ if (masked[0] === "registry:login" && !masked.includes("--password-stdin")) {
209
489
  mark(masked.length - 1);
210
490
  }
211
491
  if (/^\S+:backup-auth$/.test(masked[0] ?? "")) {
@@ -229,7 +509,7 @@ function maskSensitiveData(data) {
229
509
  if (typeof data === "object") {
230
510
  const result = {};
231
511
  for (const [key, value] of Object.entries(data)) {
232
- if (typeof value === "string" && SENSITIVE_RE.test(key)) {
512
+ if (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
233
513
  result[key] = maskValue(value);
234
514
  } else if (typeof value === "object" && value !== null) {
235
515
  result[key] = maskSensitiveData(value);
@@ -241,6 +521,13 @@ function maskSensitiveData(data) {
241
521
  }
242
522
  return data;
243
523
  }
524
+ function maskQuerystring(qs) {
525
+ return qs.split("&").map((pair) => {
526
+ const i = pair.indexOf("=");
527
+ if (i === -1 || !SENSITIVE_RE.test(pair.slice(0, i))) return pair;
528
+ return `${pair.slice(0, i)}=${maskValue(pair.slice(i + 1))}`;
529
+ }).join("&");
530
+ }
244
531
 
245
532
  // src/core/dokku.ts
246
533
  var DokkuCommandError = class extends Error {
@@ -265,17 +552,18 @@ function createRunner(opts = {}) {
265
552
  if (/^[A-Za-z0-9_\-:.,/=@]+$/.test(arg)) return arg;
266
553
  return `'${arg.replace(/'/g, "'\\''")}'`;
267
554
  }
268
- async function execDokku(args) {
555
+ async function execDokku(args, input) {
556
+ const stdio = input === void 0 ? { stdin: "ignore" } : { input };
269
557
  if (opts.host) {
270
558
  try {
271
- const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)], { stdin: "ignore" });
559
+ const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)], stdio);
272
560
  return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
273
561
  } catch (e) {
274
562
  return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
275
563
  }
276
564
  } else {
277
565
  try {
278
- const result = await execa("dokku", args, { stdin: "ignore" });
566
+ const result = await execa("dokku", args, stdio);
279
567
  return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
280
568
  } catch (e) {
281
569
  return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
@@ -307,6 +595,18 @@ function createRunner(opts = {}) {
307
595
  const { ok } = await execDokku(args);
308
596
  return ok;
309
597
  },
598
+ async status(args, input) {
599
+ const { exitCode, ok } = await execDokku(args, input);
600
+ return ok ? 0 : exitCode;
601
+ },
602
+ async runWithInput(input, ...args) {
603
+ if (opts.dryRun) {
604
+ log.push(args.map((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
605
+ return;
606
+ }
607
+ const { ok, exitCode, stderr } = await execDokku(args, input);
608
+ if (!ok) throw new DokkuCommandError(args, exitCode, input ? stderr.split(input).join("****") : stderr);
609
+ },
310
610
  async close() {
311
611
  if (!opts.host || !controlPath) return;
312
612
  try {
@@ -321,9 +621,23 @@ function createRunner(opts = {}) {
321
621
  function createContext(runner) {
322
622
  const cache = /* @__PURE__ */ new Map();
323
623
  const commands = [];
624
+ const restarts = /* @__PURE__ */ new Map();
625
+ const rebuilds = /* @__PURE__ */ new Map();
324
626
  return {
325
627
  commands,
326
628
  runner,
629
+ restarts,
630
+ rebuilds,
631
+ needsRebuild(app, reason) {
632
+ const reasons = rebuilds.get(app) ?? [];
633
+ if (!reasons.includes(reason)) reasons.push(reason);
634
+ rebuilds.set(app, reasons);
635
+ },
636
+ needsRestart(app, reason) {
637
+ const reasons = restarts.get(app) ?? [];
638
+ if (!reasons.includes(reason)) reasons.push(reason);
639
+ restarts.set(app, reasons);
640
+ },
327
641
  query(...args) {
328
642
  const key = args.join("\0");
329
643
  if (!cache.has(key)) {
@@ -337,6 +651,13 @@ function createContext(runner) {
337
651
  check(...args) {
338
652
  return runner.check(...args);
339
653
  },
654
+ status(args, input) {
655
+ return runner.status(args, input);
656
+ },
657
+ async runWithInput(input, ...args) {
658
+ commands.push(args);
659
+ await runner.runWithInput(input, ...args);
660
+ },
340
661
  async run(...args) {
341
662
  commands.push(args);
342
663
  await runner.run(...args);
@@ -348,6 +669,17 @@ function createContext(runner) {
348
669
  }
349
670
 
350
671
  // src/core/change.ts
672
+ function sameValue(x, y, subset = false) {
673
+ if (x !== null && y !== null && typeof x === "object" && typeof y === "object") {
674
+ if (Array.isArray(x) || Array.isArray(y)) {
675
+ if (!Array.isArray(x) || !Array.isArray(y)) return false;
676
+ return x.length === y.length && x.every((v) => y.some((w) => sameValue(v, w))) && y.every((w) => x.some((v) => sameValue(v, w)));
677
+ }
678
+ const kx = Object.keys(x), ky = Object.keys(y);
679
+ return (subset ? ky : kx).every((k) => k in x && k in y && sameValue(x[k], y[k], subset)) && (subset || kx.length === ky.length);
680
+ }
681
+ return String(x) === String(y);
682
+ }
351
683
  function computeChange(before, after) {
352
684
  if (before === null || before === void 0 || after === null || after === void 0) {
353
685
  return { before, after, changed: before !== after };
@@ -375,7 +707,7 @@ function computeChange(before, after) {
375
707
  for (const key of allKeys) {
376
708
  if (!(key in b)) added[key] = a[key];
377
709
  else if (!(key in a)) removed.push(key);
378
- else if (String(b[key]) !== String(a[key])) modified[key] = a[key];
710
+ else if (!sameValue(b[key], a[key])) modified[key] = a[key];
379
711
  }
380
712
  const changed = Object.keys(added).length > 0 || removed.length > 0 || Object.keys(modified).length > 0;
381
713
  return { before, after, changed, added, removed, modified };
@@ -384,28 +716,13 @@ function computeChange(before, after) {
384
716
  }
385
717
  function computeAdditiveChange(before, after) {
386
718
  const change = computeChange(before ?? {}, after);
719
+ const b = before ?? {};
720
+ const a = after;
721
+ for (const k of Object.keys(change.modified)) if (sameValue(b[k], a[k], true)) delete change.modified[k];
387
722
  change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
388
723
  return change;
389
724
  }
390
725
 
391
- // src/core/logger.ts
392
- import chalk from "chalk";
393
- function logAction(context, message) {
394
- process.stdout.write(chalk.blue(`[${context.padEnd(12)}]`) + ` ${message}`);
395
- }
396
- function logDone() {
397
- console.log(`... ${chalk.green("done")}`);
398
- }
399
- function logSkip() {
400
- console.log(`... ${chalk.yellow("already configured")}`);
401
- }
402
- function logError(context, message) {
403
- console.error(chalk.red(`[${context.padEnd(12)}] ERROR: ${message}`));
404
- }
405
- function logWarn(context, message) {
406
- console.warn(chalk.yellow(`[${context.padEnd(12)}] WARN: ${message}`));
407
- }
408
-
409
726
  // src/core/reconcile.ts
410
727
  async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
411
728
  const canPrune = prune && !!resource.prune && !!resource.removals;
@@ -428,6 +745,17 @@ async function reconcile(resource, ctx, target, desired, onRemovals, prune = fal
428
745
  return;
429
746
  }
430
747
  const before = await resource.read(ctx, target);
748
+ if (resource.inSync) {
749
+ if (before !== void 0 && resource.inSync(before, desired)) {
750
+ logSkip();
751
+ } else {
752
+ await resource.onChange(ctx, target, { before, after: desired, changed: true });
753
+ logDone();
754
+ }
755
+ if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
756
+ if (canPrune) await pruneFrom(before);
757
+ return;
758
+ }
431
759
  if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
432
760
  if (canPrune) {
433
761
  const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
@@ -465,50 +793,10 @@ var Apps = {
465
793
  }
466
794
  };
467
795
 
468
- // src/resources/parsers.ts
469
- function parseReport(raw, namespace) {
470
- const result = {};
471
- const prefixPattern = namespace.replace(/-/g, "[\\s-]");
472
- const prefix = new RegExp(`^${prefixPattern}\\s+`, "i");
473
- for (const line of raw.split("\n")) {
474
- if (line.trimStart().startsWith("=====>")) continue;
475
- const colonIdx = line.indexOf(":");
476
- if (colonIdx === -1) continue;
477
- const rawKey = line.slice(0, colonIdx).trim();
478
- if (!rawKey) continue;
479
- const value = line.slice(colonIdx + 1).trim();
480
- const stripped = rawKey.replace(prefix, "");
481
- const key = stripped.toLowerCase().replace(/\s+/g, "-");
482
- if (key.startsWith("computed-") || key.startsWith("global-") || key === "last-visited-at") continue;
483
- if (!value) continue;
484
- result[key] = value;
485
- }
486
- return result;
487
- }
488
- function parseBulkReport(raw, namespace, headerNamespace) {
489
- const result = /* @__PURE__ */ new Map();
490
- const sections = raw.split(/(?=^=====> )/m).filter((s) => s.trim());
491
- const nsPattern = (headerNamespace ?? namespace).replace(/-/g, "[\\s-]");
492
- const headerRe = new RegExp(`^=====> (.+?)\\s+${nsPattern}\\s+information`, "i");
493
- for (const section of sections) {
494
- const headerEnd = section.indexOf("\n");
495
- if (headerEnd === -1) continue;
496
- const header = section.slice(0, headerEnd);
497
- const match = header.match(headerRe);
498
- if (!match) continue;
499
- const app = match[1];
500
- result.set(app, parseReport(section, namespace));
501
- }
502
- return result;
503
- }
504
-
505
796
  // src/resources/lists.ts
506
797
  function splitWords(raw) {
507
798
  return raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
508
799
  }
509
- function splitLines(raw) {
510
- return raw.split("\n").map((s) => s.trim()).filter(Boolean);
511
- }
512
800
  var Ports = {
513
801
  key: "ports",
514
802
  read: async (ctx, target) => {
@@ -528,24 +816,53 @@ var Ports = {
528
816
  await ctx.run("ports:set", target, ...change.after);
529
817
  }
530
818
  };
819
+ function parseEnabled(raw) {
820
+ const v = raw.trim();
821
+ if (v === "true") return true;
822
+ if (v === "false") return false;
823
+ throw new Error(`unexpected domains:report --domains-app-enabled output: '${v}'`);
824
+ }
531
825
  var Domains = {
532
826
  key: "domains",
533
827
  read: async (ctx, target) => {
828
+ let enabledRaw;
829
+ try {
830
+ enabledRaw = await ctx.queryStrict("domains:report", target, "--domains-app-enabled");
831
+ } catch (err) {
832
+ if (!await ctx.check("apps:exists", target)) return [];
833
+ throw err;
834
+ }
835
+ const enabled = parseEnabled(enabledRaw);
836
+ if (!enabled) return false;
534
837
  const raw = await ctx.query("domains:report", target, "--domains-app-vhosts");
535
- return splitLines(raw);
838
+ return splitWords(raw);
536
839
  },
537
840
  readAll: async (ctx) => {
538
841
  const raw = await ctx.query("domains:report");
539
842
  const bulk = parseBulkReport(raw, "domains");
540
843
  const result = /* @__PURE__ */ new Map();
541
844
  for (const [app, report] of bulk) {
542
- result.set(app, report["app-vhosts"] ? splitWords(report["app-vhosts"]) : []);
845
+ if (report["app-enabled"] === void 0) {
846
+ result.set(app, splitWords(report["app-vhosts"] ?? ""));
847
+ continue;
848
+ }
849
+ result.set(app, parseEnabled(report["app-enabled"]) ? splitWords(report["app-vhosts"] ?? "") : false);
543
850
  }
544
851
  return result;
545
852
  },
546
- onChange: async (ctx, target, { added, removed }) => {
547
- for (const d of removed) await ctx.run("domains:remove", target, d);
548
- for (const d of added) await ctx.run("domains:add", target, d);
853
+ onChange: async (ctx, target, { before, after }) => {
854
+ if (after === false) {
855
+ await ctx.run("domains:disable", target);
856
+ return;
857
+ }
858
+ if (Array.isArray(before)) {
859
+ const { added, removed } = computeChange(before, after);
860
+ for (const d of removed) await ctx.run("domains:remove", target, d);
861
+ for (const d of added) await ctx.run("domains:add", target, d);
862
+ return;
863
+ }
864
+ await ctx.run("domains:enable", target);
865
+ await ctx.run("domains:set", target, ...after);
549
866
  }
550
867
  };
551
868
  function parseDeployMounts(raw) {
@@ -567,146 +884,14 @@ var Storage = {
567
884
  return result;
568
885
  },
569
886
  onChange: async (ctx, target, { added, removed }) => {
570
- for (const m of removed) await ctx.run("storage:unmount", target, m);
571
- for (const m of added) await ctx.run("storage:mount", target, m);
572
- }
573
- };
574
-
575
- // src/resources/properties.ts
576
- async function isAppDeployed(ctx, target) {
577
- const raw = await ctx.query("ps:report", target, "--deployed");
578
- return raw.trim() === "true";
579
- }
580
- var NGINX_KEYS = [
581
- "access-log-format",
582
- "access-log-path",
583
- "bind-address-ipv4",
584
- "bind-address-ipv6",
585
- "client-body-timeout",
586
- "client-header-timeout",
587
- "client-max-body-size",
588
- "disable-custom-config",
589
- "error-log-path",
590
- "hsts",
591
- "hsts-include-subdomains",
592
- "hsts-max-age",
593
- "hsts-preload",
594
- "keepalive-timeout",
595
- "lingering-timeout",
596
- "proxy-buffer-size",
597
- "proxy-buffering",
598
- "proxy-buffers",
599
- "proxy-busy-buffers-size",
600
- "proxy-connect-timeout",
601
- "proxy-keepalive",
602
- "proxy-read-timeout",
603
- "proxy-send-timeout",
604
- "send-timeout",
605
- "underscore-in-headers",
606
- "x-forwarded-for-value",
607
- "x-forwarded-port-value",
608
- "x-forwarded-proto-value",
609
- "x-forwarded-ssl"
610
- ];
611
- var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
612
- var REGISTRY_KEYS = [
613
- "auth-servers",
614
- "image-repo",
615
- "image-repo-template",
616
- "push-extra-tags",
617
- "push-on-release",
618
- "server"
619
- ];
620
- function propertyRemovals(allowed) {
621
- return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
622
- }
623
- function propertyPrune(setCmd, afterPrune = []) {
624
- return async (ctx, target, removed) => {
625
- for (const key of removed) await ctx.run(setCmd, target, key);
626
- for (const cmd of afterPrune) {
627
- try {
628
- await ctx.run(cmd, target);
629
- } catch (err) {
630
- if (await isAppDeployed(ctx, target)) throw err;
631
- logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
632
- }
887
+ for (const m of removed) {
888
+ await ctx.run("storage:unmount", target, m);
889
+ ctx.needsRestart(target, "storage");
633
890
  }
634
- };
635
- }
636
- function propertyResource(opts) {
637
- return {
638
- key: opts.key,
639
- // Keys only on the server never make the block "changed"; diff shows them as removals.
640
- additive: true,
641
- removals: propertyRemovals(opts.settable),
642
- prune: propertyPrune(opts.setCmd, opts.afterChange),
643
- async read(ctx, target) {
644
- const raw = await ctx.query(`${opts.namespace}:report`, target);
645
- return parseReport(raw, opts.namespace);
646
- },
647
- async readAll(ctx) {
648
- const raw = await ctx.query(`${opts.namespace}:report`);
649
- return parseBulkReport(raw, opts.namespace);
650
- },
651
- async onChange(ctx, target, change, deferFollowUp = false) {
652
- for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
653
- await ctx.run(opts.setCmd, target, key, String(value));
654
- }
655
- if (opts.afterChange && !deferFollowUp) {
656
- for (const cmd of opts.afterChange) {
657
- try {
658
- await ctx.run(cmd, target);
659
- } catch (err) {
660
- if (await isAppDeployed(ctx, target)) throw err;
661
- logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
662
- }
663
- }
664
- }
891
+ for (const m of added) {
892
+ await ctx.run("storage:mount", target, m);
893
+ ctx.needsRestart(target, "storage");
665
894
  }
666
- };
667
- }
668
- var Nginx = propertyResource({
669
- key: "nginx",
670
- namespace: "nginx",
671
- setCmd: "nginx:set",
672
- settable: NGINX_KEYS,
673
- afterChange: ["proxy:build-config"]
674
- });
675
- var Logs = propertyResource({
676
- key: "logs",
677
- namespace: "logs",
678
- setCmd: "logs:set",
679
- settable: LOGS_KEYS
680
- });
681
- var Registry = propertyResource({
682
- key: "registry",
683
- namespace: "registry",
684
- setCmd: "registry:set",
685
- settable: REGISTRY_KEYS
686
- });
687
- var Scheduler = {
688
- key: "scheduler",
689
- // A scheduler selected on the app but not declared in the file.
690
- removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
691
- prune: async (ctx, target) => {
692
- await ctx.run("scheduler:set", target, "selected");
693
- },
694
- async read(ctx, target) {
695
- const raw = await ctx.query("scheduler:report", target);
696
- const report = parseReport(raw, "scheduler");
697
- return report["selected"] ?? "";
698
- },
699
- async readAll(ctx) {
700
- const raw = await ctx.query("scheduler:report");
701
- const bulk = parseBulkReport(raw, "scheduler");
702
- const result = /* @__PURE__ */ new Map();
703
- for (const [app, report] of bulk) {
704
- result.set(app, report["selected"] ?? "");
705
- }
706
- return result;
707
- },
708
- async onChange(ctx, target, change) {
709
- await ctx.run("scheduler:set", target, "selected", change.after);
710
895
  }
711
896
  };
712
897
 
@@ -730,6 +915,40 @@ var Proxy = {
730
915
  await ctx.run(after ? "proxy:enable" : "proxy:disable", target);
731
916
  }
732
917
  };
918
+ var PROXY_PROP_KEYS = ["type", "proxy_port", "proxy_ssl_port"];
919
+ function proxyPropsFromReport(report) {
920
+ const config = {};
921
+ if (report["type"]) config.type = report["type"];
922
+ if (report["proxy-port"]) config.proxy_port = report["proxy-port"];
923
+ if (report["proxy-ssl-port"]) config.proxy_ssl_port = report["proxy-ssl-port"];
924
+ return config;
925
+ }
926
+ var ProxyProps = {
927
+ key: "proxy_props",
928
+ additive: true,
929
+ // Dokku writes `proxy-port` and `proxy-ssl-port` itself on every nginx build, so they are
930
+ // compared only when declared and are never removals; only `type` is the user's to remove.
931
+ removals: (current, desired) => ["type"].filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
932
+ prune: async (ctx, target, removed) => {
933
+ for (const key of removed) await ctx.run("proxy:set", target, key.replaceAll("_", "-"));
934
+ },
935
+ read: async (ctx, target) => proxyPropsFromReport(parseReport(await ctx.query("proxy:report", target), "proxy")),
936
+ readAll: async (ctx) => {
937
+ const bulk = parseBulkReport(await ctx.query("proxy:report"), "proxy");
938
+ return new Map([...bulk].map(([app, report]) => [app, proxyPropsFromReport(report)]));
939
+ },
940
+ onChange: async (ctx, target, change) => {
941
+ for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
942
+ await ctx.run("proxy:set", target, key.replaceAll("_", "-"), String(value));
943
+ }
944
+ }
945
+ };
946
+ function proxyProps(proxy) {
947
+ if (!proxy) return void 0;
948
+ const props = {};
949
+ for (const k of PROXY_PROP_KEYS) if (proxy[k] !== void 0) props[k] = proxy[k];
950
+ return Object.keys(props).length > 0 ? props : void 0;
951
+ }
733
952
 
734
953
  // src/core/config-json.ts
735
954
  function parseConfigJson(raw, what) {
@@ -771,6 +990,7 @@ var Config = {
771
990
  const { added, removed, modified } = change;
772
991
  if (removed.length > 0) {
773
992
  await ctx.run("config:unset", "--no-restart", target, ...removed);
993
+ ctx.needsRestart(target, "env");
774
994
  }
775
995
  const allDesiredKeys = Object.keys(change.after);
776
996
  if (allDesiredKeys.length === 0) {
@@ -787,53 +1007,127 @@ var Config = {
787
1007
  ...pairs,
788
1008
  `${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
789
1009
  );
1010
+ ctx.needsRestart(target, "env");
790
1011
  }
791
1012
  }
792
1013
  };
793
1014
 
794
1015
  // src/resources/certs.ts
1016
+ import * as fs2 from "fs";
1017
+ var NO_ENDPOINT = "doesn't have an SSL endpoint";
1018
+ function readCertFile(path2) {
1019
+ try {
1020
+ return fs2.readFileSync(path2, "utf8");
1021
+ } catch (err) {
1022
+ throw new Error(`ssl: cannot read certfile '${path2}': ${err instanceof Error ? err.message : String(err)}`);
1023
+ }
1024
+ }
795
1025
  var Certs = {
796
1026
  key: "ssl",
797
1027
  read: async (ctx, target) => {
798
- const raw = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
799
- if (raw === "") return void 0;
800
- return raw === "true";
801
- },
802
- readAll: async (ctx) => {
803
- const raw = await ctx.query("certs:report");
804
- const bulk = parseBulkReport(raw, "ssl");
805
- const result = /* @__PURE__ */ new Map();
806
- for (const [app, report] of bulk) {
807
- const enabled = report["enabled"];
808
- result.set(app, enabled === void 0 ? void 0 : enabled === "true");
1028
+ const enabled = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
1029
+ if (enabled === "false") return false;
1030
+ if (enabled !== "true") return void 0;
1031
+ try {
1032
+ return { crt: await ctx.queryStrict("certs:show", target, "crt") };
1033
+ } catch (err) {
1034
+ if (err instanceof DokkuCommandError && err.message.includes(NO_ENDPOINT)) return false;
1035
+ return void 0;
809
1036
  }
810
- return result;
1037
+ },
1038
+ inSync: (current, desired) => {
1039
+ const installed = current;
1040
+ if (installed === void 0) return false;
1041
+ if (desired === false) return installed === false;
1042
+ if (desired === true) return installed !== false;
1043
+ if (installed === false || typeof desired !== "object" || !("certfile" in desired)) return false;
1044
+ return installed.crt.trimEnd() === readCertFile(desired.certfile).trimEnd();
811
1045
  },
812
1046
  onChange: async (ctx, target, { before, after }) => {
1047
+ if (before === void 0) {
1048
+ throw new Error("ssl: could not read the installed certificate; left as is");
1049
+ }
813
1050
  if (after === false && before) {
814
1051
  await ctx.run("certs:remove", target);
815
1052
  }
816
- if (after && typeof after === "object") {
817
- await ctx.run("certs:add", target, after.certfile, after.keyfile);
1053
+ if (after && typeof after === "object" && "certfile" in after) {
1054
+ await ctx.run(before ? "certs:update" : "certs:add", target, after.certfile, after.keyfile);
818
1055
  }
819
1056
  }
820
1057
  };
821
1058
 
1059
+ // src/core/shellwords.ts
1060
+ function shellSplit(input) {
1061
+ const words = [];
1062
+ let cur = "";
1063
+ let inWord = false;
1064
+ let quote = null;
1065
+ for (let i = 0; i < input.length; i++) {
1066
+ const c = input[i];
1067
+ if (quote === "'") {
1068
+ if (c === "'") quote = null;
1069
+ else cur += c;
1070
+ } else if (c === "\\") {
1071
+ if (i + 1 >= input.length) return void 0;
1072
+ cur += input[++i];
1073
+ inWord = true;
1074
+ } else if (quote === '"') {
1075
+ if (c === '"') quote = null;
1076
+ else cur += c;
1077
+ } else if (c === '"' || c === "'") {
1078
+ quote = c;
1079
+ inWord = true;
1080
+ } else if (/\s/.test(c)) {
1081
+ if (inWord) {
1082
+ words.push(cur);
1083
+ cur = "";
1084
+ inWord = false;
1085
+ }
1086
+ } else {
1087
+ cur += c;
1088
+ inWord = true;
1089
+ }
1090
+ }
1091
+ if (quote) return void 0;
1092
+ if (inWord) words.push(cur);
1093
+ return words;
1094
+ }
1095
+ function quoteShellArg(s) {
1096
+ if (s === "") return "''";
1097
+ if (!/[ \t\n"'$`\\*?\[\]<>|&;(){}!#~]/.test(s)) return s;
1098
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1099
+ }
1100
+
822
1101
  // src/resources/builder.ts
823
- function parseBuildOptions(dockerOptsBuild) {
1102
+ function parseBuildOptions(buildList) {
824
1103
  const out = [];
825
- for (const raw of dockerOptsBuild.trim().split(/\s+(?=--\S)/)) {
826
- const m = raw.match(/^--build-arg\s+([^=\s]+)=?(.*)$/s);
1104
+ for (const raw of buildList) {
1105
+ const words = shellSplit(raw) ?? raw.trim().split(/\s+/);
1106
+ if (words[0] !== "--build-arg" || words.length < 2) continue;
1107
+ const m = words.slice(1).join(" ").match(/^([^=\s]+)=?(.*)$/s);
827
1108
  if (m) out.push({ key: m[1], value: m[2], raw });
828
1109
  }
829
1110
  return out;
830
1111
  }
831
- function parseBuildArgs(dockerOptsBuild) {
1112
+ function parseBuildArgs(buildList) {
832
1113
  const args = {};
833
- for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
1114
+ for (const o of parseBuildOptions(buildList)) args[o.key] = o.value;
834
1115
  return args;
835
1116
  }
836
- function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, dockerOptsBuild) {
1117
+ async function readBuildList(ctx, target) {
1118
+ const raw = await ctx.query("docker-options:report", target, "--format", "json");
1119
+ if (!raw.trim()) return [];
1120
+ let report;
1121
+ try {
1122
+ report = JSON.parse(raw);
1123
+ } catch {
1124
+ report = void 0;
1125
+ }
1126
+ const list = report?.["build-list"];
1127
+ if (!Array.isArray(list)) throw new Error(`docker-options:report ${target} --format json did not return a build-list (needs Dokku >= 0.38.22)`);
1128
+ return list.map(String);
1129
+ }
1130
+ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, buildList) {
837
1131
  const config = {};
838
1132
  if (dockerfileReport["dockerfile-path"])
839
1133
  config.dockerfile = dockerfileReport["dockerfile-path"];
@@ -841,71 +1135,93 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
841
1135
  config.app_json = appJsonReport["selected"];
842
1136
  if (builderReport["build-dir"])
843
1137
  config.context = builderReport["build-dir"];
844
- const args = parseBuildArgs(dockerOptsBuild);
1138
+ if (builderReport["selected"])
1139
+ config.selected = builderReport["selected"];
1140
+ if (builderReport["skip-cleanup"])
1141
+ config.skip_cleanup = builderReport["skip-cleanup"];
1142
+ const args = parseBuildArgs(buildList);
845
1143
  if (Object.keys(args).length > 0) config.args = args;
1144
+ const seen = /* @__PURE__ */ new Set();
1145
+ const duplicated = /* @__PURE__ */ new Set();
1146
+ for (const o of parseBuildOptions(buildList)) (seen.has(o.key) ? duplicated : seen).add(o.key);
1147
+ Object.defineProperty(config, "duplicateArgs", { value: duplicated, enumerable: false });
846
1148
  return config;
847
1149
  }
848
1150
  var Builder = {
849
1151
  key: "build",
850
1152
  additive: true,
851
- // Build args (`--build-arg KEY=...` build docker options) whose KEY is not in `build.args`.
852
- removals: (current, desired) => Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`),
853
- // Remove exactly the stale `--build-arg` options; other build options stay.
1153
+ // Additive compare, plus: a declared arg stored more than once is drift until one option is left.
1154
+ inSync: (current, desired) => !computeAdditiveChange(current, desired).changed && !Object.keys(desired.args ?? {}).some((k) => current?.duplicateArgs?.has(k)),
1155
+ // `builder:set` keys and build args (`--build-arg KEY=...` build docker options) not in the file.
1156
+ removals: (current, desired) => [
1157
+ ...["selected", "skip_cleanup"].filter((k) => current?.[k] !== void 0 && desired?.[k] === void 0),
1158
+ ...Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`)
1159
+ ],
1160
+ // Reset the stale `builder:set` keys; remove exactly the stale `--build-arg` options, other build options stay.
854
1161
  prune: async (ctx, target, removed) => {
855
- const stale = new Set(removed.map((l) => l.slice("args.".length)));
856
- const report = parseReport(await ctx.query("docker-options:report", target), "docker-options");
857
- const options = parseBuildOptions(report["build"] ?? "").filter((o) => stale.has(o.key));
858
- for (const o of options) await ctx.run("docker-options:remove", target, "build", o.raw);
1162
+ for (const key of removed.filter((l) => !l.startsWith("args."))) {
1163
+ await ctx.run("builder:set", target, key.replaceAll("_", "-"));
1164
+ ctx.needsRebuild(target, "build");
1165
+ }
1166
+ const stale = new Set(removed.filter((l) => l.startsWith("args.")).map((l) => l.slice("args.".length)));
1167
+ if (stale.size === 0) return;
1168
+ const options = parseBuildOptions(await readBuildList(ctx, target)).filter((o) => stale.has(o.key));
1169
+ for (const o of options) {
1170
+ await ctx.run("docker-options:remove", target, "build", o.raw);
1171
+ ctx.needsRebuild(target, "build args");
1172
+ }
859
1173
  for (const k of stale) {
860
1174
  if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
861
1175
  }
862
1176
  },
863
1177
  async read(ctx, target) {
864
- const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
1178
+ const [builderRaw, dockerfileRaw, appJsonRaw, buildList] = await Promise.all([
865
1179
  ctx.query("builder:report", target),
866
1180
  ctx.query("builder-dockerfile:report", target),
867
1181
  ctx.query("app-json:report", target),
868
- ctx.query("docker-options:report", target)
1182
+ readBuildList(ctx, target)
869
1183
  ]);
870
1184
  return buildConfigFromReports(
871
1185
  parseReport(builderRaw, "builder"),
872
1186
  parseReport(dockerfileRaw, "builder-dockerfile"),
873
1187
  parseReport(appJsonRaw, "app-json"),
874
- parseReport(dockerOptsRaw, "docker-options")["build"] ?? ""
1188
+ buildList
875
1189
  );
876
1190
  },
877
- async readAll(ctx) {
878
- const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
879
- ctx.query("builder:report"),
880
- ctx.query("builder-dockerfile:report"),
881
- ctx.query("app-json:report"),
882
- ctx.query("docker-options:report")
883
- ]);
884
- const builderBulk = parseBulkReport(builderRaw, "builder");
885
- const dockerfileBulk = parseBulkReport(dockerfileRaw, "builder-dockerfile");
886
- const appJsonBulk = parseBulkReport(appJsonRaw, "app-json");
887
- const dockerOptsBulk = parseBulkReport(dockerOptsRaw, "docker-options");
888
- const result = /* @__PURE__ */ new Map();
889
- for (const app of builderBulk.keys()) {
890
- result.set(app, buildConfigFromReports(
891
- builderBulk.get(app) ?? {},
892
- dockerfileBulk.get(app) ?? {},
893
- appJsonBulk.get(app) ?? {},
894
- dockerOptsBulk.get(app)?.["build"] ?? ""
895
- ));
896
- }
897
- return result;
898
- },
899
- async onChange(ctx, target, { after }) {
900
- if (after.dockerfile)
1191
+ async onChange(ctx, target, { before, after }) {
1192
+ if (after.dockerfile && after.dockerfile !== before?.dockerfile) {
901
1193
  await ctx.run("builder-dockerfile:set", target, "dockerfile-path", after.dockerfile);
902
- if (after.app_json)
1194
+ ctx.needsRebuild(target, "build");
1195
+ }
1196
+ if (after.app_json && after.app_json !== before?.app_json) {
903
1197
  await ctx.run("app-json:set", target, "appjson-path", after.app_json);
904
- if (after.context)
1198
+ ctx.needsRebuild(target, "build");
1199
+ }
1200
+ if (after.context && after.context !== before?.context) {
905
1201
  await ctx.run("builder:set", target, "build-dir", after.context);
1202
+ ctx.needsRebuild(target, "build");
1203
+ }
1204
+ if (after.selected && after.selected !== before?.selected) {
1205
+ await ctx.run("builder:set", target, "selected", after.selected);
1206
+ ctx.needsRebuild(target, "build");
1207
+ }
1208
+ if (after.skip_cleanup !== void 0 && String(after.skip_cleanup) !== String(before?.skip_cleanup))
1209
+ await ctx.run("builder:set", target, "skip-cleanup", String(after.skip_cleanup));
906
1210
  if (after.args) {
1211
+ const stored = parseBuildOptions(await readBuildList(ctx, target));
1212
+ const current = parseBuildArgs(stored.map((o) => o.raw));
1213
+ const dup = before?.duplicateArgs;
1214
+ const changedKeys = new Set(Object.keys(after.args).filter((k) => k in current && (current[k] !== after.args[k] || dup?.has(k))));
1215
+ if (changedKeys.size > 0) {
1216
+ for (const o of stored.filter((o2) => changedKeys.has(o2.key))) {
1217
+ await ctx.run("docker-options:remove", target, "build", o.raw);
1218
+ ctx.needsRebuild(target, "build args");
1219
+ }
1220
+ }
907
1221
  for (const [key, value] of Object.entries(after.args)) {
908
- await ctx.run("docker-options:add", target, "build", `--build-arg ${key}=${value}`);
1222
+ if (key in current && !changedKeys.has(key)) continue;
1223
+ await ctx.run("docker-options:add", target, "build", `--build-arg ${quoteShellArg(`${key}=${value}`)}`);
1224
+ ctx.needsRebuild(target, "build args");
909
1225
  }
910
1226
  }
911
1227
  }
@@ -949,22 +1265,43 @@ var DockerOptions = {
949
1265
  }
950
1266
  return result;
951
1267
  },
1268
+ // Dokku: options "only apply to containers created after the options have been
1269
+ // modified" (docs/advanced-usage/docker-options.md, v0.38.31), so deploy and
1270
+ // run options need a restart. Build options are read only by a build, which
1271
+ // a restart does not run, so they need `ps:rebuild`.
952
1272
  async onChange(ctx, target, { before, after }) {
953
1273
  for (const phase of PHASES) {
954
1274
  const prev = new Set(before[phase] ?? []);
955
1275
  const desired = new Set(after[phase] ?? []);
956
1276
  for (const opt of desired) {
957
- if (!prev.has(opt)) await ctx.run("docker-options:add", target, phase, opt);
1277
+ if (!prev.has(opt)) {
1278
+ await ctx.run("docker-options:add", target, phase, opt);
1279
+ if (phase === "build") ctx.needsRebuild(target, "build options");
1280
+ else ctx.needsRestart(target, "docker_options");
1281
+ }
958
1282
  }
959
1283
  for (const opt of prev) {
960
- if (!desired.has(opt)) await ctx.run("docker-options:remove", target, phase, opt);
1284
+ if (!desired.has(opt)) {
1285
+ await ctx.run("docker-options:remove", target, phase, opt);
1286
+ if (phase === "build") ctx.needsRebuild(target, "build options");
1287
+ else ctx.needsRestart(target, "docker_options");
1288
+ }
961
1289
  }
962
1290
  }
963
1291
  }
964
1292
  };
965
1293
 
966
1294
  // src/resources/ps.ts
967
- var PS_KEYS = ["procfile-path", "restart-policy", "stop-timeout-seconds"];
1295
+ var PS_KEYS = [
1296
+ "dockerfile-start-cmd",
1297
+ "procfile-path",
1298
+ "restart-policy",
1299
+ "restore",
1300
+ "skip-deploy",
1301
+ "start-cmd",
1302
+ "stop-timeout-seconds"
1303
+ ];
1304
+ var DOKKU_MANAGED = /* @__PURE__ */ new Set(["restore"]);
968
1305
  function parseScale(raw) {
969
1306
  const result = {};
970
1307
  let parsed;
@@ -1011,7 +1348,7 @@ var Scale = {
1011
1348
  var Ps = {
1012
1349
  key: "ps",
1013
1350
  additive: true,
1014
- removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
1351
+ removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !DOKKU_MANAGED.has(k) && !(desired && k in desired)),
1015
1352
  prune: async (ctx, target, removed) => {
1016
1353
  for (const key of removed) await ctx.run("ps:set", target, key);
1017
1354
  },
@@ -1026,24 +1363,35 @@ var Ps = {
1026
1363
  };
1027
1364
 
1028
1365
  // src/resources/git.ts
1366
+ var toDokku = (key) => key.replaceAll("_", "-");
1367
+ var NOT_REMOVABLE = /* @__PURE__ */ new Set(["deploy_branch", "source_image", "rev_env_var"]);
1368
+ function fromReport(report) {
1369
+ const config = {};
1370
+ if (report["deploy-branch"]) config.deploy_branch = report["deploy-branch"];
1371
+ if (report["keep-git-dir"]) config.keep_git_dir = report["keep-git-dir"];
1372
+ if (report["rev-env-var"]) config.rev_env_var = report["rev-env-var"];
1373
+ if (report["source-image"]) config.source_image = report["source-image"];
1374
+ return config;
1375
+ }
1029
1376
  var Git = {
1030
1377
  key: "git",
1031
- read: async (ctx, target) => {
1032
- const report = await ctx.query("git:report", target, "--git-deploy-branch");
1033
- return { deploy_branch: report.trim() || void 0 };
1378
+ additive: true,
1379
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !NOT_REMOVABLE.has(k) && !(desired && k in desired)),
1380
+ prune: async (ctx, target, removed) => {
1381
+ for (const key of removed) await ctx.run("git:set", target, toDokku(key));
1034
1382
  },
1383
+ read: async (ctx, target) => fromReport(parseReport(await ctx.query("git:report", target), "git")),
1035
1384
  readAll: async (ctx) => {
1036
- const raw = await ctx.query("git:report");
1037
- const bulk = parseBulkReport(raw, "git");
1038
1385
  const result = /* @__PURE__ */ new Map();
1039
- for (const [app, report] of bulk) {
1040
- result.set(app, { deploy_branch: report["deploy-branch"] || void 0 });
1386
+ for (const section of (await ctx.query("git:report")).split(/(?=^=====> )/m)) {
1387
+ const app = section.match(/^=====> (.+?)\s+git\s+information/i)?.[1];
1388
+ if (app) result.set(app, fromReport(parseReport(section, "git")));
1041
1389
  }
1042
1390
  return result;
1043
1391
  },
1044
- onChange: async (ctx, target, { after }) => {
1045
- if (after.deploy_branch) {
1046
- await ctx.run("git:set", target, "deploy-branch", after.deploy_branch);
1392
+ onChange: async (ctx, target, change) => {
1393
+ for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
1394
+ await ctx.run("git:set", target, toDokku(key), String(value));
1047
1395
  }
1048
1396
  }
1049
1397
  };
@@ -1051,11 +1399,12 @@ var Git = {
1051
1399
  // src/resources/checks.ts
1052
1400
  var Checks = {
1053
1401
  key: "checks",
1054
- forceApply: true,
1055
- // Applying never reads (forceApply); the read only finds settings to report as removed.
1056
- // Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty.
1402
+ // Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty;
1403
+ // a disabled list of `_all_` (`checks:disable <app>` with no types) reads as `false`.
1057
1404
  read: async (ctx, target) => {
1058
1405
  const report = parseReport(await ctx.query("checks:report", target), "checks");
1406
+ if (report["disabled-list"] === void 0) return void 0;
1407
+ if (report["disabled-list"] === "_all_") return false;
1059
1408
  const current = {};
1060
1409
  for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
1061
1410
  const v = report[reportKey];
@@ -1068,7 +1417,7 @@ var Checks = {
1068
1417
  prune: async (ctx, target, removed, current, desired) => {
1069
1418
  const cur = current;
1070
1419
  const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
1071
- const declared = new Set(desired && desired !== false ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1420
+ const declared = new Set(desired ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1072
1421
  const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
1073
1422
  const enable = [...stale].filter((t) => !declared.has(t));
1074
1423
  for (const t of stale) {
@@ -1076,26 +1425,32 @@ var Checks = {
1076
1425
  logWarn(target, `checks: '${t}' is declared in the file but also set in a list the file does not declare; left as is. Run 'dokku checks:enable ${target} ${t}' and then 'up' to clear it`);
1077
1426
  }
1078
1427
  }
1079
- if (enable.length > 0) await ctx.run("checks:enable", target, ...enable);
1428
+ if (enable.length > 0) await ctx.run("checks:enable", target, enable.join(","));
1080
1429
  if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
1081
1430
  },
1082
- onChange: async (ctx, target, { after }) => {
1083
- if (after === false) {
1084
- await ctx.run("checks:disable", target);
1085
- return;
1086
- }
1087
- if (after.disabled && after.disabled.length > 0) {
1088
- await ctx.run("checks:disable", target, ...after.disabled);
1089
- }
1090
- if (after.skipped && after.skipped.length > 0) {
1091
- await ctx.run("checks:skip", target, ...after.skipped);
1092
- }
1093
- for (const [key, value] of Object.entries(after)) {
1094
- if (key === "disabled" || key === "skipped") continue;
1095
- await ctx.run("checks:set", target, key, String(value));
1096
- }
1431
+ inSync: (current, desired) => checksCalls(current, desired).length === 0,
1432
+ onChange: async (ctx, target, { before, after }) => {
1433
+ if (before === void 0) throw new Error("checks: could not read checks:report; left as is");
1434
+ for (const call of checksCalls(before, after)) await ctx.run(call[0], target, ...call.slice(1));
1097
1435
  }
1098
1436
  };
1437
+ function checksCalls(current, desired) {
1438
+ if (desired === false) return current === false ? [] : [["checks:disable"]];
1439
+ const calls = [];
1440
+ const cur = current || {};
1441
+ if (current === false) calls.push(["checks:enable"]);
1442
+ const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
1443
+ for (const [key, cmd] of [["disabled", "checks:disable"], ["skipped", "checks:skip"]]) {
1444
+ const have = new Set(split(cur[key]));
1445
+ const missing = (desired[key] ?? []).filter((t) => !have.has(t));
1446
+ if (missing.length > 0) calls.push([cmd, missing.join(",")]);
1447
+ }
1448
+ for (const [key, value] of Object.entries(desired)) {
1449
+ if (key === "disabled" || key === "skipped") continue;
1450
+ if (cur[key] !== String(value)) calls.push(["checks:set", key, String(value)]);
1451
+ }
1452
+ return calls;
1453
+ }
1099
1454
 
1100
1455
  // src/resources/network.ts
1101
1456
  var Networks = {
@@ -1115,6 +1470,7 @@ var Networks = {
1115
1470
  },
1116
1471
  onChange: async (ctx, target, { after }) => {
1117
1472
  await ctx.run("network:set", target, "attach-post-deploy", ...after);
1473
+ ctx.needsRebuild(target, "networks");
1118
1474
  }
1119
1475
  };
1120
1476
  var NetworkProps = {
@@ -1123,7 +1479,11 @@ var NetworkProps = {
1123
1479
  removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
1124
1480
  // Dokku's property names are hyphenated; the schema's are underscored.
1125
1481
  prune: async (ctx, target, removed) => {
1126
- for (const key of removed) await ctx.run("network:set", target, key.replaceAll("_", "-"));
1482
+ for (const key of removed) {
1483
+ await ctx.run("network:set", target, key.replaceAll("_", "-"));
1484
+ if (key === "attach_post_create" || key === "initial_network") ctx.needsRebuild(target, "network");
1485
+ else ctx.needsRestart(target, "network");
1486
+ }
1127
1487
  },
1128
1488
  read: async (ctx, target) => {
1129
1489
  const raw = await ctx.query("network:report", target);
@@ -1134,6 +1494,7 @@ var NetworkProps = {
1134
1494
  }
1135
1495
  if (report["initial-network"]) config.initial_network = report["initial-network"];
1136
1496
  if (report["bind-all-interfaces"] === "true") config.bind_all_interfaces = true;
1497
+ if (report["static-web-listener"]) config.static_web_listener = report["static-web-listener"];
1137
1498
  if (report["tld"]) config.tld = report["tld"];
1138
1499
  return config;
1139
1500
  },
@@ -1148,6 +1509,7 @@ var NetworkProps = {
1148
1509
  }
1149
1510
  if (report["initial-network"]) config.initial_network = report["initial-network"];
1150
1511
  if (report["bind-all-interfaces"] === "true") config.bind_all_interfaces = true;
1512
+ if (report["static-web-listener"]) config.static_web_listener = report["static-web-listener"];
1151
1513
  if (report["tld"]) config.tld = report["tld"];
1152
1514
  result.set(app, config);
1153
1515
  }
@@ -1157,15 +1519,22 @@ var NetworkProps = {
1157
1519
  if (after.attach_post_create !== void 0 && after.attach_post_create !== false) {
1158
1520
  const nets = Array.isArray(after.attach_post_create) ? after.attach_post_create : [after.attach_post_create];
1159
1521
  await ctx.run("network:set", target, "attach-post-create", ...nets);
1522
+ ctx.needsRebuild(target, "network");
1160
1523
  }
1161
1524
  if (after.initial_network !== void 0 && after.initial_network !== false) {
1162
1525
  await ctx.run("network:set", target, "initial-network", after.initial_network);
1526
+ ctx.needsRebuild(target, "network");
1163
1527
  }
1164
1528
  if (after.bind_all_interfaces !== void 0) {
1165
1529
  await ctx.run("network:set", target, "bind-all-interfaces", String(after.bind_all_interfaces));
1530
+ ctx.needsRestart(target, "network");
1531
+ }
1532
+ if (after.static_web_listener !== void 0 && after.static_web_listener !== false) {
1533
+ await ctx.run("network:set", target, "static-web-listener", after.static_web_listener);
1166
1534
  }
1167
1535
  if (after.tld !== void 0 && after.tld !== false) {
1168
1536
  await ctx.run("network:set", target, "tld", after.tld);
1537
+ ctx.needsRestart(target, "network");
1169
1538
  }
1170
1539
  }
1171
1540
  };
@@ -1259,13 +1628,61 @@ async function ensurePlugins(ctx, plugins) {
1259
1628
  }
1260
1629
 
1261
1630
  // src/modules/docker-auth.ts
1631
+ var AUTH_MATCH = 0;
1632
+ var AUTH_MISSING = 1;
1633
+ var AUTH_DIFFERS = 2;
1634
+ var AUTH_INVALID_ARGUMENTS = 3;
1635
+ var AUTH_UNKNOWN = 4;
1636
+ function registryHostname(server) {
1637
+ const normalized = server === "hub.docker.com" || server === "docker.com" ? "docker.io" : server;
1638
+ const host = normalized.replace(/^https?:\/\//, "").split("/")[0];
1639
+ return ["", "docker.io", "index.docker.io"].includes(host) ? "docker.io" : host;
1640
+ }
1641
+ async function loginMatches(ctx, server, creds) {
1642
+ const code = await ctx.status(
1643
+ ["registry:auth-status", "--global", "--password-stdin", server, creds.username],
1644
+ creds.password
1645
+ );
1646
+ if (code === AUTH_MATCH) return true;
1647
+ if (code === AUTH_MISSING || code === AUTH_DIFFERS || code === AUTH_UNKNOWN) return false;
1648
+ if (code === AUTH_INVALID_ARGUMENTS) {
1649
+ throw new Error(`registry:auth-status rejected the arguments for ${server} (exit 3)`);
1650
+ }
1651
+ throw new Error(`registry:auth-status failed for ${server} (exit ${code})`);
1652
+ }
1653
+ async function globalAuthServers(ctx) {
1654
+ const raw = await ctx.query("registry:report", "--global", "--registry-global-auth-servers");
1655
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
1656
+ }
1262
1657
  async function ensureDockerAuth(ctx, config) {
1263
1658
  for (const [server, creds] of Object.entries(config)) {
1264
1659
  logAction("docker-auth", `Logging in to ${server} as ${creds.username}`);
1265
- await ctx.run("registry:login", "--global", server, creds.username, creds.password);
1660
+ if (await loginMatches(ctx, server, creds)) {
1661
+ logSkip();
1662
+ continue;
1663
+ }
1664
+ await ctx.runWithInput(creds.password, "registry:login", "--global", "--password-stdin", server, creds.username);
1665
+ logDone();
1666
+ }
1667
+ }
1668
+ async function pruneDockerAuth(ctx, config) {
1669
+ const declared = new Set(Object.keys(config).map(registryHostname));
1670
+ for (const server of await globalAuthServers(ctx)) {
1671
+ if (declared.has(server)) continue;
1672
+ logAction("docker-auth", `Logging out of ${server}`);
1673
+ await ctx.run("registry:logout", "--global", server);
1266
1674
  logDone();
1267
1675
  }
1268
1676
  }
1677
+ async function readDockerAuthDrift(ctx, config) {
1678
+ const stale = [];
1679
+ for (const [server, creds] of Object.entries(config)) {
1680
+ if (!await loginMatches(ctx, server, creds)) stale.push(server);
1681
+ }
1682
+ const declared = new Set(Object.keys(config).map(registryHostname));
1683
+ const extra = (await globalAuthServers(ctx)).filter((s) => !declared.has(s));
1684
+ return { stale, extra };
1685
+ }
1269
1686
 
1270
1687
  // src/modules/network.ts
1271
1688
  async function ensureNetworks(ctx, networks) {
@@ -1286,61 +1703,255 @@ async function exportNetworks(ctx) {
1286
1703
  return output.split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("=====>") && !DOCKER_BUILTIN_NETWORKS.has(s));
1287
1704
  }
1288
1705
 
1289
- // src/modules/postgres.ts
1706
+ // src/modules/service-drift.ts
1290
1707
  import { createHash as createHash2 } from "crypto";
1291
- function backupHashKey(serviceName) {
1292
- return "DOKKU_COMPOSE_BACKUP_HASH_" + serviceName.toUpperCase().replace(/-/g, "_");
1708
+ var infoReads = /* @__PURE__ */ new WeakMap();
1709
+ function readServiceInfo(ctx, plugin, name) {
1710
+ let reads = infoReads.get(ctx);
1711
+ if (!reads) {
1712
+ reads = /* @__PURE__ */ new Map();
1713
+ infoReads.set(ctx, reads);
1714
+ }
1715
+ const key = `${plugin}\0${name}`;
1716
+ let read = reads.get(key);
1717
+ if (!read) {
1718
+ read = fetchServiceInfo(ctx, plugin, name);
1719
+ read.catch(() => reads.delete(key));
1720
+ reads.set(key, read);
1721
+ }
1722
+ return read;
1723
+ }
1724
+ async function fetchServiceInfo(ctx, plugin, name) {
1725
+ const raw = await ctx.queryStrict(`${plugin}:info`, name, "--format", "json");
1726
+ try {
1727
+ const info = JSON.parse(raw);
1728
+ if (info && typeof info === "object" && !Array.isArray(info)) return info;
1729
+ } catch {
1730
+ }
1731
+ throw new Error(`${plugin}:info ${name} --format json did not return JSON`);
1732
+ }
1733
+ var field = (info, key) => String(info[key] ?? "");
1734
+ function versionMismatches(info, declared) {
1735
+ const out = [];
1736
+ if (declared.version !== void 0 && declared.version !== field(info, "image-version")) {
1737
+ out.push({ field: "version", desired: declared.version, current: field(info, "image-version") });
1738
+ }
1739
+ if (declared.image !== void 0 && declared.image !== field(info, "image")) {
1740
+ out.push({ field: "image", desired: declared.image, current: field(info, "image") });
1741
+ }
1742
+ return out;
1743
+ }
1744
+ async function readServiceMismatches(ctx, plugin, name, declared) {
1745
+ if (declared.version === void 0 && declared.image === void 0) return [];
1746
+ return versionMismatches(await readServiceInfo(ctx, plugin, name), declared);
1747
+ }
1748
+ function backupAuthFingerprint(accessKeyId, secretAccessKey) {
1749
+ const firstLine = (v) => v.trim().split("\n")[0].trim();
1750
+ return createHash2("sha256").update(`${firstLine(accessKeyId)}
1751
+ ${firstLine(secretAccessKey)}`).digest("hex");
1752
+ }
1753
+ function hasBackup(info) {
1754
+ return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true";
1755
+ }
1756
+ function backupMismatches(info, backup) {
1757
+ for (const key of ["backup-authenticated", "backup-auth-fingerprint", "backup-schedule"]) {
1758
+ if (!(key in info)) throw new Error("dokku-postgres too old to report backup state (needs 2.3.1+)");
1759
+ }
1760
+ if (backup === false) {
1761
+ return hasBackup(info) ? [{ field: "backup", desired: "none", current: describeServerBackup(info) }] : [];
1762
+ }
1763
+ const out = [];
1764
+ const { auth } = backup;
1765
+ const authDiffers = [];
1766
+ if (field(info, "backup-auth-fingerprint") !== backupAuthFingerprint(auth.access_key_id, auth.secret_access_key)) {
1767
+ authDiffers.push("credentials");
1768
+ }
1769
+ if (field(info, "backup-default-region") !== auth.region) authDiffers.push("region");
1770
+ if (field(info, "backup-signature-version") !== auth.signature_version) authDiffers.push("signature version");
1771
+ if (field(info, "backup-endpoint-url") !== auth.endpoint) authDiffers.push("endpoint");
1772
+ if (authDiffers.length > 0) {
1773
+ out.push({
1774
+ field: "backup.auth",
1775
+ desired: `${authDiffers.join(", ")} from the file`,
1776
+ current: field(info, "backup-authenticated") === "true" ? "other values" : "not authenticated"
1777
+ });
1778
+ }
1779
+ if (field(info, "backup-schedule") !== backup.schedule) {
1780
+ out.push({ field: "backup.schedule", desired: backup.schedule, current: field(info, "backup-schedule") });
1781
+ }
1782
+ if (field(info, "backup-bucket") !== backup.bucket) {
1783
+ out.push({ field: "backup.bucket", desired: backup.bucket, current: field(info, "backup-bucket") });
1784
+ }
1785
+ return out;
1293
1786
  }
1294
- function computeBackupHash(backup) {
1295
- return createHash2("sha256").update(JSON.stringify(backup)).digest("hex");
1787
+ function describeServerBackup(info) {
1788
+ const schedule = field(info, "backup-schedule");
1789
+ const parts = [];
1790
+ if (schedule) parts.push(`scheduled ${schedule} to ${field(info, "backup-bucket")}`);
1791
+ if (field(info, "backup-authenticated") === "true") parts.push("credentials stored");
1792
+ return parts.join(", ") || "none";
1296
1793
  }
1794
+
1795
+ // src/modules/config.ts
1796
+ var MANAGED_KEYS_VAR2 = "DOKKU_COMPOSE_MANAGED_KEYS";
1797
+ var GLOBAL_MANAGED_KEYS_VAR = "DOKKU_COMPOSE_GLOBAL_MANAGED_KEYS";
1798
+ function isReserved(key) {
1799
+ return key === GLOBAL_MANAGED_KEYS_VAR || key === MANAGED_KEYS_VAR2 || key.startsWith(BACKUP_HASH_PREFIX);
1800
+ }
1801
+ var globalEnvReads = /* @__PURE__ */ new WeakMap();
1802
+ function readGlobalEnv(ctx) {
1803
+ let read = globalEnvReads.get(ctx);
1804
+ if (!read) {
1805
+ read = ctx.queryStrict("config:export", "--global", "--format", "json").then((raw) => parseConfigJson(raw, "--global"));
1806
+ read.catch(() => globalEnvReads.delete(ctx));
1807
+ globalEnvReads.set(ctx, read);
1808
+ }
1809
+ return read;
1810
+ }
1811
+ var BACKUP_HASH_PREFIX = "DOKKU_COMPOSE_BACKUP_HASH_";
1812
+ async function planGlobalEnv(ctx, env, read) {
1813
+ const current = read ?? await readGlobalEnv(ctx);
1814
+ const desired = Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)]));
1815
+ const managed = (current[GLOBAL_MANAGED_KEYS_VAR] ?? "").split(",").filter(Boolean);
1816
+ const removed = managed.filter((k) => !(k in desired) && !isReserved(k) && k in current);
1817
+ const toSet = Object.fromEntries(Object.entries(desired).filter(([k, v]) => current[k] !== v));
1818
+ const marker = Object.keys(desired).join(",");
1819
+ const markerOutdated = [...managed].sort().join(",") !== Object.keys(desired).sort().join(",");
1820
+ return { toSet, removed, marker, markerOutdated };
1821
+ }
1822
+ async function ensureGlobalConfig(ctx, env) {
1823
+ if (env === false) return false;
1824
+ const { toSet, removed, marker, markerOutdated } = await planGlobalEnv(ctx, env ?? {});
1825
+ const unset = [...removed];
1826
+ if (markerOutdated && marker === "") unset.push(GLOBAL_MANAGED_KEYS_VAR);
1827
+ if (unset.length > 0) await ctx.run("config:unset", "--global", ...unset);
1828
+ const pairs = Object.entries(toSet).map(([k, v]) => `${k}=${v}`);
1829
+ if (markerOutdated && marker !== "") pairs.push(`${GLOBAL_MANAGED_KEYS_VAR}=${marker}`);
1830
+ if (pairs.length > 0) await ctx.run("config:set", "--global", ...pairs);
1831
+ return Object.keys(toSet).length > 0 || removed.length > 0;
1832
+ }
1833
+ async function readGlobalEnvDrift(ctx, env, current) {
1834
+ const { toSet, removed, markerOutdated } = await planGlobalEnv(ctx, env ?? {}, current);
1835
+ return { changed: Object.keys(toSet), removed, markerOutdated };
1836
+ }
1837
+
1838
+ // src/modules/postgres.ts
1297
1839
  async function ensurePostgres(ctx, services) {
1840
+ const created = /* @__PURE__ */ new Set();
1298
1841
  for (const [name, config] of Object.entries(services)) {
1299
1842
  logAction("services", `Ensuring ${name}`);
1300
1843
  const exists = await ctx.check("postgres:exists", name);
1301
1844
  if (exists) {
1302
1845
  logSkip();
1846
+ try {
1847
+ const mismatches = await readServiceMismatches(ctx, "postgres", name, config);
1848
+ if (mismatches.length > 0) {
1849
+ const parts = mismatches.map((m) => `${m.field} is ${m.current || "(unknown)"} on the server but ${m.desired} in the file`);
1850
+ logWarn("services", `${name}: ${parts.join("; ")}; up does not change it`);
1851
+ }
1852
+ } catch (err) {
1853
+ logWarn("services", `${name}: could not compare version and image: ${err instanceof Error ? err.message : String(err)}`);
1854
+ }
1303
1855
  continue;
1304
1856
  }
1305
1857
  const args = ["postgres:create", name];
1306
1858
  if (config.image) args.push("--image", config.image);
1307
1859
  if (config.version) args.push("--image-version", config.version);
1308
1860
  await ctx.run(...args);
1861
+ created.add(name);
1309
1862
  logDone();
1310
1863
  }
1864
+ return created;
1311
1865
  }
1312
- async function ensurePostgresBackups(ctx, services) {
1866
+ async function ensurePostgresBackups(ctx, services, opts = {}) {
1313
1867
  for (const [name, config] of Object.entries(services)) {
1314
- if (!config.backup) continue;
1868
+ const backup = config.backup;
1869
+ if (backup === void 0 && !opts.prune) continue;
1870
+ if (backup === void 0) {
1871
+ let info2;
1872
+ try {
1873
+ info2 = await readServiceInfo(ctx, "postgres", name);
1874
+ } catch (err) {
1875
+ logWarn("services", `could not read backup state for ${name}: ${err instanceof Error ? err.message : String(err)}`);
1876
+ continue;
1877
+ }
1878
+ if (hasBackup(info2)) {
1879
+ logWarn("services", `${name}: has a backup the file does not declare (${describeServerBackup(info2)}); --prune leaves it, set backup: false to remove it`);
1880
+ }
1881
+ continue;
1882
+ }
1883
+ if (opts.dryRunCreated?.has(name)) {
1884
+ logAction("services", `Configuring backup for ${name}`);
1885
+ if (backup !== false) {
1886
+ const { schedule: schedule2, bucket: bucket2, auth: auth2 } = backup;
1887
+ await ctx.run(
1888
+ "postgres:backup-auth",
1889
+ name,
1890
+ auth2.access_key_id,
1891
+ auth2.secret_access_key,
1892
+ auth2.region,
1893
+ auth2.signature_version,
1894
+ auth2.endpoint
1895
+ );
1896
+ await ctx.run("postgres:backup-schedule", name, schedule2, bucket2);
1897
+ }
1898
+ logDone();
1899
+ continue;
1900
+ }
1901
+ const info = await readServiceInfo(ctx, "postgres", name);
1315
1902
  logAction("services", `Configuring backup for ${name}`);
1316
- const hashKey = backupHashKey(name);
1317
- const desiredHash = computeBackupHash(config.backup);
1318
- const storedHash = await ctx.query("config:get", "--global", hashKey);
1319
- if (storedHash === desiredHash) {
1903
+ const fields = new Set(backupMismatches(info, backup).map((m) => m.field));
1904
+ if (fields.size === 0) {
1320
1905
  logSkip();
1321
1906
  continue;
1322
1907
  }
1323
- const { schedule, bucket, auth } = config.backup;
1324
- await ctx.run(
1325
- "postgres:backup-auth",
1326
- name,
1327
- auth.access_key_id,
1328
- auth.secret_access_key,
1329
- auth.region,
1330
- auth.signature_version,
1331
- auth.endpoint
1332
- );
1333
- await ctx.run("postgres:backup-schedule", name, schedule, bucket);
1334
- const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
1335
- if (!parseBackupSchedule(cronOutput)) {
1336
- throw new Error(
1337
- `Backup schedule for ${name} was not installed after backup-schedule ran`
1908
+ if (backup === false) {
1909
+ if (String(info["backup-schedule"] ?? "") !== "") await ctx.run("postgres:backup-unschedule", name);
1910
+ await ctx.run("postgres:backup-deauth", name);
1911
+ logDone();
1912
+ continue;
1913
+ }
1914
+ const { schedule, bucket, auth } = backup;
1915
+ if (fields.has("backup.auth")) {
1916
+ await ctx.run(
1917
+ "postgres:backup-auth",
1918
+ name,
1919
+ auth.access_key_id,
1920
+ auth.secret_access_key,
1921
+ auth.region,
1922
+ auth.signature_version,
1923
+ auth.endpoint
1338
1924
  );
1339
1925
  }
1340
- await ctx.run("config:set", "--global", `${hashKey}=${desiredHash}`);
1926
+ if (fields.has("backup.schedule")) {
1927
+ await ctx.run("postgres:backup-schedule", name, schedule, bucket);
1928
+ const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
1929
+ if (!parseBackupSchedule(cronOutput)) {
1930
+ throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
1931
+ }
1932
+ } else if (fields.has("backup.bucket")) {
1933
+ await ctx.run("postgres:set", name, "backup-bucket", bucket);
1934
+ }
1341
1935
  logDone();
1342
1936
  }
1343
1937
  }
1938
+ async function removeBackupHashKeys(ctx) {
1939
+ let env;
1940
+ try {
1941
+ env = await readGlobalEnv(ctx);
1942
+ } catch (err) {
1943
+ logWarn("global", `could not read global env to remove old backup hash keys: ${err instanceof Error ? err.message : String(err)}`);
1944
+ return;
1945
+ }
1946
+ const stale = staleBackupHashKeys(env);
1947
+ if (stale.length === 0) return;
1948
+ logAction("global", `Removing ${stale.length} old backup hash key(s)`);
1949
+ await ctx.run("config:unset", "--global", "--no-restart", ...stale);
1950
+ logDone();
1951
+ }
1952
+ function staleBackupHashKeys(env) {
1953
+ return Object.keys(env).filter((k) => k.startsWith(BACKUP_HASH_PREFIX)).sort();
1954
+ }
1344
1955
  async function destroyPostgres(ctx, services) {
1345
1956
  for (const [name] of Object.entries(services)) {
1346
1957
  logAction("services", `Destroying ${name}`);
@@ -1413,6 +2024,15 @@ async function ensureRedis(ctx, services) {
1413
2024
  const exists = await ctx.check("redis:exists", name);
1414
2025
  if (exists) {
1415
2026
  logSkip();
2027
+ try {
2028
+ const mismatches = await readServiceMismatches(ctx, "redis", name, config);
2029
+ if (mismatches.length > 0) {
2030
+ const parts = mismatches.map((m) => `${m.field} is ${m.current || "(unknown)"} on the server but ${m.desired} in the file`);
2031
+ logWarn("services", `${name}: ${parts.join("; ")}; up does not change it`);
2032
+ }
2033
+ } catch (err) {
2034
+ logWarn("services", `${name}: could not compare version and image: ${err instanceof Error ? err.message : String(err)}`);
2035
+ }
1416
2036
  continue;
1417
2037
  }
1418
2038
  const args = ["redis:create", name];
@@ -1460,41 +2080,10 @@ async function exportRedis(ctx) {
1460
2080
  return services;
1461
2081
  }
1462
2082
 
1463
- // src/modules/version.ts
1464
- var SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/;
1465
- function parseSemver(input) {
1466
- const m = input.match(SEMVER_RE);
1467
- if (!m) throw new Error(`Cannot parse version: ${input}`);
1468
- return [Number(m[1]), Number(m[2]), Number(m[3])];
1469
- }
1470
- function extractServerVersion(output) {
1471
- const match = output.match(/(\d+\.\d+\.\d+)/);
1472
- return match ? match[1] : null;
1473
- }
1474
- function compareSemver(a, b) {
1475
- const [aMaj, aMin, aPatch] = parseSemver(a);
1476
- const [bMaj, bMin, bPatch] = parseSemver(b);
1477
- if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
1478
- if (aMin !== bMin) return aMin < bMin ? -1 : 1;
1479
- if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
1480
- return 0;
1481
- }
1482
- async function ensureDokkuVersion(ctx, pinned) {
1483
- if (!pinned) return;
1484
- const output = await ctx.query("version");
1485
- const server = extractServerVersion(output);
1486
- if (!server) {
1487
- throw new Error(`Cannot parse Dokku server version from output: ${output}`);
1488
- }
1489
- if (compareSemver(server, pinned) === -1) {
1490
- logWarn(
1491
- "dokku",
1492
- `server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.`
1493
- );
1494
- }
1495
- }
1496
-
1497
2083
  // src/modules/links.ts
2084
+ function normalizeLink(entry) {
2085
+ return typeof entry === "string" ? { service: entry } : entry;
2086
+ }
1498
2087
  function resolveServicePlugin(name, config) {
1499
2088
  if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
1500
2089
  if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
@@ -1506,24 +2095,112 @@ function allServices(config) {
1506
2095
  for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
1507
2096
  return entries;
1508
2097
  }
2098
+ var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
2099
+ function linkEnvVar(plugin, link) {
2100
+ if (link.env_var) return link.env_var;
2101
+ if (link.alias) return `${link.alias}_URL`;
2102
+ return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
2103
+ }
2104
+ function linkFlags(link) {
2105
+ const flags = [];
2106
+ if (link.alias) flags.push("--alias", link.alias);
2107
+ if (link.env_var) flags.push("--env-var", link.env_var);
2108
+ if (link.querystring) flags.push("--querystring", link.querystring);
2109
+ return flags;
2110
+ }
2111
+ function readEnv(ctx, app, cache) {
2112
+ let read = cache?.get(app);
2113
+ if (!read) {
2114
+ read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
2115
+ cache?.set(app, read);
2116
+ }
2117
+ return read;
2118
+ }
2119
+ function findLinkVariables(env, plugin, service) {
2120
+ const host = `dokku-${plugin}-${service}`;
2121
+ const found = [];
2122
+ for (const [name, value] of Object.entries(env)) {
2123
+ if (typeof value !== "string") continue;
2124
+ const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
2125
+ if (m && m[1] === host) found.push({ name, value });
2126
+ }
2127
+ return found;
2128
+ }
2129
+ function isFallbackName(plugin, name) {
2130
+ return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
2131
+ }
2132
+ function querystringOf(url) {
2133
+ const q = url.indexOf("?");
2134
+ return q === -1 ? "" : url.slice(q + 1);
2135
+ }
2136
+ function show(name, qs) {
2137
+ return qs ? `${name}?${maskQuerystring(qs)}` : name;
2138
+ }
2139
+ async function readLinkOptions(ctx, app, plugin, link, cache) {
2140
+ let env;
2141
+ try {
2142
+ env = await readEnv(ctx, app, cache);
2143
+ } catch (err) {
2144
+ return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
2145
+ }
2146
+ const found = findLinkVariables(env, plugin, link.service);
2147
+ if (found.length === 0) {
2148
+ return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
2149
+ }
2150
+ const name = linkEnvVar(plugin, link);
2151
+ const desired = link.querystring ?? "";
2152
+ const plain = !link.alias && !link.env_var;
2153
+ const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
2154
+ if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
2155
+ const current = querystringOf(hit.value);
2156
+ if (current === desired) return { state: "same" };
2157
+ return { state: "differs", current: show(name, current), desired: show(name, desired) };
2158
+ }
2159
+ async function readServiceLinks(ctx, app, desiredLinks, config) {
2160
+ const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
2161
+ const states = [];
2162
+ for (const [service, plugin] of allServices(config)) {
2163
+ states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
2164
+ }
2165
+ return states;
2166
+ }
2167
+ function servicesToUnlink(states) {
2168
+ return states.filter((s) => !s.link && s.isLinked);
2169
+ }
1509
2170
  async function ensureAppLinks(ctx, app, desiredLinks, config) {
1510
- const desiredSet = new Set(desiredLinks);
1511
- for (const [serviceName, plugin] of allServices(config)) {
1512
- const isLinked = await ctx.check(`${plugin}:linked`, serviceName, app);
1513
- const isDesired = desiredSet.has(serviceName);
1514
- if (isDesired && !isLinked) {
2171
+ const states = await readServiceLinks(ctx, app, desiredLinks, config);
2172
+ const unlink = new Set(servicesToUnlink(states));
2173
+ for (const state of states) {
2174
+ const { service: serviceName, plugin, link, isLinked } = state;
2175
+ if (link && !isLinked) {
1515
2176
  logAction(app, `Linking ${serviceName}`);
1516
- await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart");
2177
+ await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
2178
+ ctx.needsRestart(app, "links");
1517
2179
  logDone();
1518
- } else if (!isDesired && isLinked) {
2180
+ } else if (link && isLinked) {
2181
+ const opts = await readLinkOptions(ctx, app, plugin, link);
2182
+ if (opts.state === "differs") {
2183
+ logAction(app, `Relinking ${serviceName}`);
2184
+ await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
2185
+ try {
2186
+ await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
2187
+ } catch (err) {
2188
+ logWarn(app, `${app} is now unlinked from ${serviceName}`);
2189
+ throw err;
2190
+ }
2191
+ ctx.needsRestart(app, "links");
2192
+ logDone();
2193
+ }
2194
+ } else if (unlink.has(state)) {
1519
2195
  logAction(app, `Unlinking ${serviceName}`);
1520
2196
  await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
2197
+ ctx.needsRestart(app, "links");
1521
2198
  logDone();
1522
2199
  }
1523
2200
  }
1524
2201
  }
1525
2202
  async function destroyAppLinks(ctx, app, links, config) {
1526
- for (const serviceName of links) {
2203
+ for (const serviceName of links.map((l) => normalizeLink(l).service)) {
1527
2204
  const resolved = resolveServicePlugin(serviceName, config);
1528
2205
  if (!resolved) continue;
1529
2206
  const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
@@ -1535,45 +2212,103 @@ async function destroyAppLinks(ctx, app, links, config) {
1535
2212
  async function exportAppLinks(ctx, app, config) {
1536
2213
  const linked = [];
1537
2214
  for (const [serviceName, plugin] of allServices(config)) {
1538
- const isLinked = await ctx.check(`${plugin}:linked`, serviceName, app);
1539
- if (isLinked) linked.push(serviceName);
2215
+ if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
2216
+ let found = [];
2217
+ try {
2218
+ found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
2219
+ } catch {
2220
+ }
2221
+ const fallback = linkEnvVar(plugin, { service: serviceName });
2222
+ const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
2223
+ if (!v) {
2224
+ linked.push(serviceName);
2225
+ continue;
2226
+ }
2227
+ const querystring = querystringOf(v.value);
2228
+ if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
2229
+ linked.push(serviceName);
2230
+ continue;
2231
+ }
2232
+ const entry = { service: serviceName };
2233
+ if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
2234
+ if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
2235
+ else entry.env_var = v.name;
2236
+ }
2237
+ if (querystring) entry.querystring = querystring;
2238
+ linked.push(entry);
1540
2239
  }
1541
2240
  return linked;
1542
2241
  }
1543
2242
 
1544
2243
  // src/modules/domains.ts
2244
+ async function readGlobalDomains(ctx) {
2245
+ const raw = await ctx.queryStrict("domains:report", "--global", "--domains-global-vhosts");
2246
+ return raw.split(/\s+/).filter(Boolean);
2247
+ }
2248
+ function globalDomainsInSync(current, desired) {
2249
+ if (desired === false) return current.length === 0;
2250
+ const sa = new Set(current);
2251
+ const sb = new Set(desired);
2252
+ return sa.size === sb.size && [...sa].every((x) => sb.has(x));
2253
+ }
1545
2254
  async function ensureGlobalDomains(ctx, domains) {
1546
2255
  logAction("global", "Configuring domains");
1547
- if (domains === false) {
1548
- await ctx.run("domains:clear-global");
1549
- } else {
1550
- await ctx.run("domains:set-global", ...domains);
2256
+ const current = await readGlobalDomains(ctx);
2257
+ if (globalDomainsInSync(current, domains)) {
2258
+ logSkip();
2259
+ return;
1551
2260
  }
2261
+ if (domains === false) await ctx.run("domains:clear-global");
2262
+ else await ctx.run("domains:set-global", ...domains);
1552
2263
  logDone();
1553
2264
  }
1554
2265
 
1555
- // src/modules/config.ts
1556
- async function ensureGlobalConfig(ctx, env) {
1557
- if (env === false) return;
1558
- const pairs = Object.entries(env).map(([k, v]) => `${k}=${v}`);
1559
- await ctx.run("config:set", "--global", ...pairs);
1560
- }
1561
-
1562
2266
  // src/modules/logs.ts
2267
+ async function readGlobalLogs(ctx) {
2268
+ return parseJsonReport(await ctx.queryStrict("logs:report", "--global", "--format", "json"), "global");
2269
+ }
1563
2270
  async function ensureGlobalLogs(ctx, logs) {
2271
+ logAction("global", "Configuring logs");
2272
+ const current = await readGlobalLogs(ctx);
2273
+ let changed = false;
1564
2274
  for (const [key, value] of Object.entries(logs)) {
2275
+ if (current[key] === String(value)) continue;
1565
2276
  await ctx.run("logs:set", "--global", key, String(value));
2277
+ changed = true;
1566
2278
  }
2279
+ if (changed) logDone();
2280
+ else logSkip();
1567
2281
  }
1568
2282
 
1569
2283
  // src/modules/nginx.ts
2284
+ async function readGlobalNginx(ctx) {
2285
+ return parseJsonReport(await ctx.queryStrict("nginx:report", "--global", "--format", "json"), "global");
2286
+ }
1570
2287
  async function ensureGlobalNginx(ctx, nginx) {
2288
+ logAction("global", "Configuring nginx");
2289
+ const current = await readGlobalNginx(ctx);
2290
+ let changed = false;
1571
2291
  for (const [key, value] of Object.entries(nginx)) {
2292
+ if (current[key] === String(value)) continue;
1572
2293
  await ctx.run("nginx:set", "--global", key, String(value));
2294
+ changed = true;
1573
2295
  }
2296
+ if (changed) logDone();
2297
+ else logSkip();
1574
2298
  }
1575
2299
 
1576
2300
  // src/commands/up.ts
2301
+ function markGlobalEnvRestart(ctx, apps, reason = "global env") {
2302
+ for (const app of apps) ctx.needsRestart(app, reason);
2303
+ }
2304
+ function printRestartSummary(ctx, dryRun = false) {
2305
+ const restarts = [...ctx.restarts].filter(([app]) => !ctx.rebuilds.has(app));
2306
+ if (restarts.length === 0 && ctx.rebuilds.size === 0) return;
2307
+ console.log(`
2308
+ # Apps that ${dryRun ? "would need" : "need"} a restart or rebuild:`);
2309
+ for (const [app, reasons] of restarts) console.log(`dokku ps:restart ${app} # ${reasons.join(", ")}`);
2310
+ for (const [app, reasons] of ctx.rebuilds) console.log(`dokku ps:rebuild ${app} # ${reasons.join(", ")}`);
2311
+ }
1577
2312
  var UpFailedError = class extends Error {
1578
2313
  constructor(failures) {
1579
2314
  super(
@@ -1609,6 +2344,7 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
1609
2344
  await step(() => reconcile(Networks, ctx, app, appConfig.networks));
1610
2345
  await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
1611
2346
  await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
2347
+ await step(() => reconcile(ProxyProps, ctx, app, proxyProps(appConfig.proxy), count, prune));
1612
2348
  await step(() => reconcile(Ports, ctx, app, appConfig.ports));
1613
2349
  if (config.postgres || config.redis) {
1614
2350
  await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
@@ -1632,18 +2368,36 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
1632
2368
  if (errors.length > 0) throw new AppStepsError(errors);
1633
2369
  }
1634
2370
  async function runUp(ctx, config, appFilter, opts = {}) {
2371
+ try {
2372
+ await runUpPhases(ctx, config, appFilter, opts);
2373
+ } finally {
2374
+ printRestartSummary(ctx, opts.dryRun);
2375
+ }
2376
+ }
2377
+ async function runUpPhases(ctx, config, appFilter, opts) {
1635
2378
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1636
- await ensureDokkuVersion(ctx, config.dokku?.version);
2379
+ enforceFileVersion(await assertDokkuSupported(ctx), config.dokku?.version);
1637
2380
  if (config.plugins) await ensurePlugins(ctx, config.plugins);
1638
- if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth);
2381
+ if (config.docker_auth) {
2382
+ await ensureDockerAuth(ctx, config.docker_auth);
2383
+ if (opts.prune) await pruneDockerAuth(ctx, config.docker_auth);
2384
+ }
1639
2385
  if (config.domains !== void 0) await ensureGlobalDomains(ctx, config.domains);
1640
- if (config.env !== void 0) await ensureGlobalConfig(ctx, config.env);
2386
+ if (await ensureGlobalConfig(ctx, config.env)) {
2387
+ markGlobalEnvRestart(ctx, apps.filter((app) => config.apps[app]));
2388
+ }
1641
2389
  if (config.logs !== void 0) await ensureGlobalLogs(ctx, config.logs);
1642
2390
  if (config.nginx !== void 0) await ensureGlobalNginx(ctx, config.nginx);
1643
2391
  if (config.networks) await ensureNetworks(ctx, config.networks);
1644
- if (config.postgres) await ensurePostgres(ctx, config.postgres);
2392
+ const createdPostgres = config.postgres ? await ensurePostgres(ctx, config.postgres) : /* @__PURE__ */ new Set();
1645
2393
  if (config.redis) await ensureRedis(ctx, config.redis);
1646
- if (config.postgres) await ensurePostgresBackups(ctx, config.postgres);
2394
+ if (config.postgres) {
2395
+ await ensurePostgresBackups(ctx, config.postgres, {
2396
+ prune: opts.prune,
2397
+ dryRunCreated: opts.dryRun ? createdPostgres : void 0
2398
+ });
2399
+ }
2400
+ await removeBackupHashKeys(ctx);
1647
2401
  const failures = [];
1648
2402
  for (const app of apps) {
1649
2403
  const appConfig = config.apps[app];
@@ -1693,6 +2447,7 @@ var DownFailedError = class extends Error {
1693
2447
  };
1694
2448
  async function runDown(ctx, config, appFilter, opts) {
1695
2449
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
2450
+ await assertDokkuSupported(ctx);
1696
2451
  const failures = [];
1697
2452
  for (const app of apps) {
1698
2453
  const appConfig = config.apps[app];
@@ -1734,6 +2489,7 @@ var NETWORKING_RESOURCES = [
1734
2489
  Networks,
1735
2490
  NetworkProps,
1736
2491
  Proxy,
2492
+ ProxyProps,
1737
2493
  Ports
1738
2494
  ];
1739
2495
  var CONFIG_RESOURCES = [
@@ -1762,8 +2518,7 @@ var ALL_APP_RESOURCES = [
1762
2518
  // src/commands/export.ts
1763
2519
  async function runExport(ctx, opts) {
1764
2520
  const config = { apps: {} };
1765
- const version2 = extractServerVersion(await ctx.query("version"));
1766
- if (version2) config.dokku = { version: version2 };
2521
+ config.dokku = { version: await assertDokkuSupported(ctx) };
1767
2522
  const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx);
1768
2523
  const networks = await exportNetworks(ctx);
1769
2524
  if (networks.length > 0) config.networks = networks;
@@ -1774,7 +2529,10 @@ async function runExport(ctx, opts) {
1774
2529
  const prefetched = /* @__PURE__ */ new Map();
1775
2530
  await Promise.all(
1776
2531
  ALL_APP_RESOURCES.filter((r) => !r.forceApply && !r.key.startsWith("_") && r.readAll).map(async (r) => {
1777
- prefetched.set(r.key, await r.readAll(ctx));
2532
+ try {
2533
+ prefetched.set(r.key, await r.readAll(ctx));
2534
+ } catch {
2535
+ }
1778
2536
  })
1779
2537
  );
1780
2538
  for (const app of apps) {
@@ -1782,13 +2540,24 @@ async function runExport(ctx, opts) {
1782
2540
  for (const resource of ALL_APP_RESOURCES) {
1783
2541
  if (resource.key.startsWith("_")) continue;
1784
2542
  if (resource.forceApply) continue;
2543
+ if (resource.key === "checks") continue;
1785
2544
  const bulk = prefetched.get(resource.key);
1786
- const value = bulk ? bulk.get(app) : await resource.read(ctx, app);
2545
+ let value;
2546
+ try {
2547
+ value = bulk ? bulk.get(app) : await resource.read(ctx, app);
2548
+ } catch (err) {
2549
+ logWarn(app, `could not read '${resource.key}', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
2550
+ continue;
2551
+ }
1787
2552
  if (value === void 0 || value === null || value === "") continue;
1788
2553
  if (Array.isArray(value) && value.length === 0) continue;
1789
2554
  if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) continue;
1790
- if (resource.key === "proxy") {
1791
- appConfig.proxy = { enabled: value };
2555
+ if (resource.key === "ssl") {
2556
+ appConfig.ssl = value !== false;
2557
+ } else if (resource.key === "proxy") {
2558
+ appConfig.proxy = { ...appConfig.proxy, enabled: value };
2559
+ } else if (resource.key === "proxy_props") {
2560
+ appConfig.proxy = { ...appConfig.proxy, ...value };
1792
2561
  } else {
1793
2562
  appConfig[resource.key] = value;
1794
2563
  }
@@ -1802,14 +2571,41 @@ async function runExport(ctx, opts) {
1802
2571
  return config;
1803
2572
  }
1804
2573
 
2574
+ // src/modules/env-notes.ts
2575
+ var MANAGED_KEYS_VAR3 = "DOKKU_COMPOSE_MANAGED_KEYS";
2576
+ var DOKKU_WRITTEN = /* @__PURE__ */ new Set(["GIT_REV", "NO_VHOST"]);
2577
+ var SERVICE_URL_HOST = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?dokku-[a-z]+-[^:/?]+/i;
2578
+ function undeclaredEnvKeys(input) {
2579
+ const { env } = input;
2580
+ const skip = /* @__PURE__ */ new Set([MANAGED_KEYS_VAR3, ...input.declared, ...input.external]);
2581
+ for (const k of String(env[MANAGED_KEYS_VAR3] ?? "").split(",")) if (k) skip.add(k);
2582
+ for (const link of input.links) {
2583
+ for (const v of findLinkVariables(env, link.plugin, link.service)) skip.add(v.name);
2584
+ }
2585
+ const notes = [];
2586
+ for (const [key, value] of Object.entries(env)) {
2587
+ if (skip.has(key) || DOKKU_WRITTEN.has(key)) continue;
2588
+ if (key.startsWith("DOKKU_") && key !== "DOKKU_START_CMD") continue;
2589
+ if (typeof value === "string" && SERVICE_URL_HOST.test(value)) continue;
2590
+ const duplicate = key in input.globalFile && key in input.globalServer && input.globalServer[key] === String(value);
2591
+ notes.push(duplicate ? { key, label: "duplicate of global" } : { key });
2592
+ }
2593
+ return notes;
2594
+ }
2595
+
1805
2596
  // src/commands/diff.ts
1806
- import chalk2 from "chalk";
1807
- function diffSslStatus(desired, current) {
1808
- if (typeof desired !== "object" || desired === null) return void 0;
1809
- if (current === void 0) return void 0;
1810
- if (current === true) return "in-sync";
1811
- if (current === false || current === null) return "missing";
1812
- return void 0;
2597
+ import chalk from "chalk";
2598
+ function displayCurrent(resourceKey, current) {
2599
+ if (resourceKey === "ssl" && current && typeof current === "object") return "(certificate installed)";
2600
+ return current;
2601
+ }
2602
+ function safeInSync(resource, current, desired, app, warnings) {
2603
+ try {
2604
+ return resource.inSync(current, desired);
2605
+ } catch (err) {
2606
+ warnings.push(`could not compare '${resource.key}' for app '${app}': ${err instanceof Error ? err.message : String(err)}`);
2607
+ return void 0;
2608
+ }
1813
2609
  }
1814
2610
  function hasAdditionsOrChanges(change) {
1815
2611
  const c = change;
@@ -1821,9 +2617,14 @@ function isEmptyValue(value) {
1821
2617
  return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1822
2618
  }
1823
2619
  async function computeDiff(ctx, config) {
1824
- const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
2620
+ const result = { apps: {}, services: {}, plugins: {}, global: {}, networks: {}, inSync: true, envNotes: {}, readWarnings: [] };
1825
2621
  const appNames = Object.keys(config.apps);
1826
- await ensureDokkuVersion(ctx, config.dokku?.version);
2622
+ const serverVersion = await assertDokkuSupported(ctx);
2623
+ const fileVersion = config.dokku?.version;
2624
+ if (fileVersion && compareToFile(serverVersion, fileVersion) !== "equal") {
2625
+ result.dokku = { file: fileVersion, server: serverVersion };
2626
+ result.inSync = false;
2627
+ }
1827
2628
  const appExists = /* @__PURE__ */ new Map();
1828
2629
  await Promise.all(
1829
2630
  appNames.map(async (app) => {
@@ -1843,13 +2644,29 @@ async function computeDiff(ctx, config) {
1843
2644
  }
1844
2645
  })
1845
2646
  );
2647
+ let globalEnv;
2648
+ let globalEnvValues = {};
2649
+ let globalEnvError;
2650
+ if (config.env !== false) {
2651
+ try {
2652
+ globalEnvValues = await readGlobalEnv(ctx);
2653
+ globalEnv = await readGlobalEnvDrift(ctx, config.env, globalEnvValues);
2654
+ } catch (err) {
2655
+ globalEnvError = err;
2656
+ }
2657
+ }
1846
2658
  for (const [app, appConfig] of Object.entries(config.apps)) {
1847
2659
  const appDiff = {};
2660
+ const envCache = /* @__PURE__ */ new Map();
1848
2661
  for (const resource of ALL_APP_RESOURCES) {
1849
2662
  if (resource.key.startsWith("_")) continue;
1850
2663
  let desired;
1851
2664
  if (resource.key === "proxy") {
1852
2665
  desired = appConfig.proxy?.enabled;
2666
+ } else if (resource.key === "git") {
2667
+ desired = appConfig.git ?? config.git;
2668
+ } else if (resource.key === "proxy_props") {
2669
+ desired = proxyProps(appConfig.proxy);
1853
2670
  } else {
1854
2671
  desired = appConfig[resource.key];
1855
2672
  }
@@ -1884,17 +2701,28 @@ async function computeDiff(ctx, config) {
1884
2701
  current = await resource.read(ctx, app);
1885
2702
  } else if (bulk) {
1886
2703
  current = bulk.get(app);
1887
- if (resource.key === "ssl" && current === void 0) {
1888
- current = await resource.read(ctx, app);
1889
- if (current === void 0) bulkUnreadable = true;
1890
- }
1891
2704
  } else {
1892
2705
  current = await resource.read(ctx, app);
1893
2706
  }
1894
- const sslStatus = resource.key === "ssl" ? diffSslStatus(desired, current) : void 0;
1895
- if (sslStatus) {
1896
- appDiff[resource.key] = { status: sslStatus, desired, current };
1897
- if (sslStatus !== "in-sync") result.inSync = false;
2707
+ if (resource.inSync) {
2708
+ const removedHere = resource.removals ? resource.removals(current, desired) : [];
2709
+ let status;
2710
+ if (current === void 0) {
2711
+ status = "unknown";
2712
+ result.readWarnings.push(`could not read '${resource.key}' for app '${app}'`);
2713
+ } else {
2714
+ const same = safeInSync(resource, current, desired, app, result.readWarnings);
2715
+ if (same === void 0) status = "unknown";
2716
+ else if (same) status = removedHere.length > 0 ? "extra" : "in-sync";
2717
+ else status = current === false || isEmptyValue(current) ? "missing" : "changed";
2718
+ }
2719
+ appDiff[resource.key] = {
2720
+ status,
2721
+ desired,
2722
+ current: displayCurrent(resource.key, current),
2723
+ ...removedHere.length > 0 ? { removed: removedHere } : {}
2724
+ };
2725
+ if (status !== "in-sync") result.inSync = false;
1898
2726
  continue;
1899
2727
  }
1900
2728
  let removed = resource.removals ? resource.removals(current, desired) : [];
@@ -1930,16 +2758,57 @@ async function computeDiff(ctx, config) {
1930
2758
  appDiff[resource.key].removed = removed;
1931
2759
  }
1932
2760
  }
1933
- if (appConfig.links === void 0 && appExists.get(app)) {
1934
- const linked = [];
1935
- for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
1936
- for (const svc of Object.keys(services ?? {})) {
1937
- if (await ctx.check(`${plugin}:linked`, svc, app)) linked.push(svc);
2761
+ if (appExists.get(app) && (config.postgres || config.redis)) {
2762
+ const unlink = servicesToUnlink(await readServiceLinks(ctx, app, appConfig.links ?? [], config)).map((s) => s.service);
2763
+ if (appConfig.links === void 0) {
2764
+ if (unlink.length > 0) {
2765
+ appDiff["links"] = { status: "extra", current: unlink, removed: unlink, note: "no links: in the file; up will unlink these" };
2766
+ result.inSync = false;
2767
+ }
2768
+ } else {
2769
+ for (const service of unlink) {
2770
+ appDiff[`links:${service}`] = { status: "extra", current: service, removed: [service], note: "up unlinks" };
2771
+ result.inSync = false;
2772
+ }
2773
+ }
2774
+ }
2775
+ if (appConfig.links && appExists.get(app)) {
2776
+ for (const entry of appConfig.links) {
2777
+ const link = normalizeLink(entry);
2778
+ const resolved = resolveServicePlugin(link.service, config);
2779
+ if (!resolved) continue;
2780
+ if (!await ctx.check(`${resolved.plugin}:linked`, link.service, app)) {
2781
+ appDiff[`links:${link.service}`] = { status: "missing", desired: linkEnvVar(resolved.plugin, link), current: void 0 };
2782
+ result.inSync = false;
2783
+ continue;
2784
+ }
2785
+ const opts = await readLinkOptions(ctx, app, resolved.plugin, link, envCache);
2786
+ if (opts.state === "differs") {
2787
+ appDiff[`links:${link.service}`] = { status: "changed", desired: opts.desired, current: opts.current, note: "up unlinks and relinks it" };
2788
+ result.inSync = false;
2789
+ } else if (opts.state === "unreadable") {
2790
+ result.readWarnings.push(`could not read the link options of '${link.service}' for app '${app}' (not counted as drift): ${opts.reason}`);
1938
2791
  }
1939
2792
  }
1940
- if (linked.length > 0) {
1941
- appDiff["links"] = { status: "extra", current: linked, removed: linked, note: "no links: in the file; up will unlink these" };
1942
- result.inSync = false;
2793
+ }
2794
+ if (appExists.get(app)) {
2795
+ try {
2796
+ const links = (appConfig.links ?? []).flatMap((entry) => {
2797
+ const link = normalizeLink(entry);
2798
+ const resolved = resolveServicePlugin(link.service, config);
2799
+ return resolved ? [{ plugin: resolved.plugin, service: link.service }] : [];
2800
+ });
2801
+ const notes = undeclaredEnvKeys({
2802
+ env: await readEnv(ctx, app, envCache),
2803
+ declared: appConfig.env ? Object.keys(appConfig.env) : [],
2804
+ links,
2805
+ external: appConfig.env_external ?? [],
2806
+ globalFile: config.env || {},
2807
+ globalServer: globalEnvValues
2808
+ });
2809
+ if (notes.length > 0) result.envNotes[app] = notes;
2810
+ } catch (err) {
2811
+ result.readWarnings.push(`could not read the env of app '${app}' to note keys the file does not declare: ${err instanceof Error ? err.message : String(err)}`);
1943
2812
  }
1944
2813
  }
1945
2814
  result.apps[app] = appDiff;
@@ -1954,20 +2823,137 @@ async function computeDiff(ctx, config) {
1954
2823
  if (plan.action !== "unknown") result.inSync = false;
1955
2824
  }
1956
2825
  }
1957
- for (const svc of Object.keys(config.postgres ?? {})) {
1958
- const exists = await ctx.check("postgres:exists", svc);
1959
- result.services[svc] = { status: exists ? "in-sync" : "missing" };
1960
- if (!exists) result.inSync = false;
2826
+ if (config.domains !== void 0) {
2827
+ try {
2828
+ const current = await readGlobalDomains(ctx);
2829
+ if (!globalDomainsInSync(current, config.domains)) {
2830
+ result.global.domains = { status: current.length === 0 ? "missing" : "changed", desired: config.domains, current };
2831
+ result.inSync = false;
2832
+ }
2833
+ } catch (err) {
2834
+ result.global.domains = { status: "unknown", desired: config.domains };
2835
+ result.readWarnings.push(`could not read global 'domains': ${err instanceof Error ? err.message : String(err)}`);
2836
+ result.inSync = false;
2837
+ }
2838
+ }
2839
+ if (globalEnv) {
2840
+ const { changed, removed, markerOutdated } = globalEnv;
2841
+ if (changed.length > 0 || removed.length > 0 || markerOutdated) {
2842
+ result.global.env = {
2843
+ status: changed.length === 0 && removed.length > 0 ? "extra" : "changed",
2844
+ desired: Object.fromEntries(changed.map((k) => [k, "****"])),
2845
+ removed,
2846
+ note: changed.length === 0 && removed.length === 0 ? `${GLOBAL_MANAGED_KEYS_VAR} not written yet` : void 0
2847
+ };
2848
+ result.inSync = false;
2849
+ }
2850
+ } else if (config.env !== false) {
2851
+ const err = globalEnvError;
2852
+ result.global.env = { status: "unknown", desired: Object.keys(config.env ?? {}) };
2853
+ result.readWarnings.push(`could not read global 'env': ${err instanceof Error ? err.message : String(err)}`);
2854
+ result.inSync = false;
2855
+ }
2856
+ try {
2857
+ const stale = staleBackupHashKeys(await readGlobalEnv(ctx));
2858
+ if (stale.length > 0) {
2859
+ result.global.backup_hash = { status: "extra", removed: stale, note: "old backup hash keys; up unsets them" };
2860
+ result.inSync = false;
2861
+ }
2862
+ } catch (err) {
2863
+ if (config.env === false) result.readWarnings.push(`could not read global env: ${err instanceof Error ? err.message : String(err)}`);
2864
+ }
2865
+ if (config.docker_auth) {
2866
+ try {
2867
+ const { stale, extra } = await readDockerAuthDrift(ctx, config.docker_auth);
2868
+ if (stale.length > 0 || extra.length > 0) {
2869
+ result.global.docker_auth = {
2870
+ status: stale.length > 0 ? "changed" : "extra",
2871
+ desired: Object.fromEntries(stale.map((server) => [server, "login missing or differs"])),
2872
+ removed: extra,
2873
+ note: extra.length > 0 ? "up --prune logs out of these" : void 0
2874
+ };
2875
+ result.inSync = false;
2876
+ }
2877
+ } catch (err) {
2878
+ result.global.docker_auth = { status: "unknown", desired: Object.keys(config.docker_auth) };
2879
+ result.readWarnings.push(`could not read global 'docker_auth': ${err instanceof Error ? err.message : String(err)}`);
2880
+ result.inSync = false;
2881
+ }
2882
+ }
2883
+ for (const [name, declared, read] of [
2884
+ ["logs", config.logs, readGlobalLogs],
2885
+ ["nginx", config.nginx, readGlobalNginx]
2886
+ ]) {
2887
+ if (declared === void 0) continue;
2888
+ let current;
2889
+ try {
2890
+ current = await read(ctx);
2891
+ } catch (err) {
2892
+ result.global[name] = { status: "unknown", desired: declared };
2893
+ result.readWarnings.push(`could not read global '${name}': ${err instanceof Error ? err.message : String(err)}`);
2894
+ result.inSync = false;
2895
+ continue;
2896
+ }
2897
+ const differs = Object.entries(declared).filter(([k, v]) => current[k] !== String(v));
2898
+ if (differs.length === 0) continue;
2899
+ const shown = Object.fromEntries(Object.keys(declared).filter((k) => k in current).map((k) => [k, current[k]]));
2900
+ result.global[name] = { status: differs.every(([k]) => !(k in current)) ? "missing" : "changed", desired: declared, current: shown };
2901
+ result.inSync = false;
2902
+ }
2903
+ for (const net of config.networks ?? []) {
2904
+ if (await ctx.check("network:exists", net)) continue;
2905
+ result.networks[net] = { status: "missing" };
2906
+ result.inSync = false;
1961
2907
  }
1962
- for (const svc of Object.keys(config.redis ?? {})) {
1963
- const exists = await ctx.check("redis:exists", svc);
1964
- result.services[svc] = { status: exists ? "in-sync" : "missing" };
1965
- if (!exists) result.inSync = false;
2908
+ for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
2909
+ for (const [svc, declared] of Object.entries(services ?? {})) {
2910
+ const exists = await ctx.check(`${plugin}:exists`, svc);
2911
+ if (!exists) {
2912
+ result.services[svc] = { status: "missing" };
2913
+ result.inSync = false;
2914
+ continue;
2915
+ }
2916
+ const backup = plugin === "postgres" ? declared.backup : void 0;
2917
+ const compares = declared?.version !== void 0 || declared?.image !== void 0 || backup !== void 0;
2918
+ if (!compares && plugin === "redis") {
2919
+ result.services[svc] = { status: "in-sync", mismatches: [] };
2920
+ continue;
2921
+ }
2922
+ try {
2923
+ const info = await readServiceInfo(ctx, plugin, svc);
2924
+ const mismatches = [...versionMismatches(info, declared), ...backup !== void 0 ? backupMismatches(info, backup) : []];
2925
+ const note = plugin === "postgres" && backup === void 0 && hasBackup(info) ? `backup on the server is not in the file (${describeServerBackup(info)}); up leaves it, backup: false removes it` : void 0;
2926
+ result.services[svc] = { status: mismatches.length ? "changed" : "in-sync", mismatches, note };
2927
+ if (mismatches.length) result.inSync = false;
2928
+ } catch (err) {
2929
+ result.readWarnings.push(`could not read ${plugin} service '${svc}': ${err instanceof Error ? err.message : String(err)}`);
2930
+ if (!compares) {
2931
+ result.services[svc] = { status: "in-sync", mismatches: [] };
2932
+ continue;
2933
+ }
2934
+ result.services[svc] = { status: "unknown" };
2935
+ result.inSync = false;
2936
+ }
2937
+ }
1966
2938
  }
1967
2939
  return result;
1968
2940
  }
1969
2941
  function maskDiffResult(diff) {
1970
- const masked = { apps: {}, services: diff.services, plugins: diff.plugins, inSync: diff.inSync, readWarnings: diff.readWarnings };
2942
+ const masked = {
2943
+ apps: {},
2944
+ services: diff.services,
2945
+ plugins: diff.plugins,
2946
+ dokku: diff.dokku,
2947
+ global: Object.fromEntries(Object.entries(diff.global).map(([k, d]) => [k, {
2948
+ ...d,
2949
+ desired: maskSensitiveData(d.desired),
2950
+ current: maskSensitiveData(d.current)
2951
+ }])),
2952
+ networks: diff.networks,
2953
+ inSync: diff.inSync,
2954
+ envNotes: diff.envNotes,
2955
+ readWarnings: diff.readWarnings
2956
+ };
1971
2957
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1972
2958
  const maskedAppDiff = {};
1973
2959
  for (const [feature, d] of Object.entries(appDiff)) {
@@ -1983,6 +2969,9 @@ function maskDiffResult(diff) {
1983
2969
  }
1984
2970
  return masked;
1985
2971
  }
2972
+ function driftCount(diff) {
2973
+ return Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length + Object.keys(diff.global).length + Object.keys(diff.networks).length + (diff.dokku ? 1 : 0);
2974
+ }
1986
2975
  function formatSummary(diff) {
1987
2976
  const lines = [""];
1988
2977
  for (const [app, appDiff] of Object.entries(diff.apps)) {
@@ -1991,15 +2980,30 @@ function formatSummary(diff) {
1991
2980
  lines.push(` app: ${app}`);
1992
2981
  lines.push(` (in sync)`);
1993
2982
  } else {
1994
- lines.push(` app: ${chalk2.bold(app)}`);
2983
+ lines.push(` app: ${chalk.bold(app)}`);
1995
2984
  for (const [feature, d] of changes) {
1996
- const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : d.status === "extra" ? chalk2.red("-") : chalk2.yellow("~");
2985
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : d.status === "extra" ? chalk.red("-") : chalk.yellow("~");
1997
2986
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1998
2987
  }
1999
2988
  }
2989
+ const notes = diff.envNotes[app];
2990
+ if (notes) lines.push(` note: env not in the file (up leaves these): ${notes.map(showNote).join(", ")}`);
2991
+ }
2992
+ if (diff.dokku) {
2993
+ lines.push(` dokku`);
2994
+ lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
2995
+ }
2996
+ for (const [name, d] of Object.entries(diff.global)) {
2997
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
2998
+ lines.push(` global`);
2999
+ lines.push(` ${sym} ${name}: ${formatFeatureSummary(d)}`);
3000
+ }
3001
+ for (const net of Object.keys(diff.networks)) {
3002
+ lines.push(` networks:`);
3003
+ lines.push(` ${chalk.green("+")} ${net}: not created`);
2000
3004
  }
2001
3005
  for (const [name, d] of Object.entries(diff.plugins)) {
2002
- const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
3006
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
2003
3007
  const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
2004
3008
  lines.push(` plugin: ${name}`);
2005
3009
  lines.push(` ${sym} version: ${detail}${d.note ? ` (${d.note})` : ""}`);
@@ -2007,24 +3011,32 @@ function formatSummary(diff) {
2007
3011
  for (const [svc, d] of Object.entries(diff.services)) {
2008
3012
  if (d.status === "missing") {
2009
3013
  lines.push(` services:`);
2010
- lines.push(` ${chalk2.green("+")} ${svc}: not provisioned`);
3014
+ lines.push(` ${chalk.green("+")} ${svc}: not provisioned`);
3015
+ }
3016
+ if (d.mismatches?.length || d.note) lines.push(` services: ${svc}`);
3017
+ for (const m of d.mismatches ?? []) {
3018
+ lines.push(` ${chalk.yellow("~")} ${m.field}: ${m.current || "(unknown)"} \u2192 ${m.desired}`);
2011
3019
  }
3020
+ if (d.note) lines.push(` note: ${d.note}`);
2012
3021
  }
2013
- const total = Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length;
3022
+ const total = driftCount(diff);
2014
3023
  if (diff.readWarnings.length > 0) {
2015
3024
  lines.push("");
2016
- lines.push(chalk2.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
2017
- for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
3025
+ lines.push(chalk.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
3026
+ for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
2018
3027
  }
2019
3028
  lines.push("");
2020
3029
  if (total === 0) {
2021
- lines.push(chalk2.green(" Everything in sync."));
3030
+ lines.push(chalk.green(" Everything in sync."));
2022
3031
  } else {
2023
- lines.push(chalk2.yellow(` ${total} resource(s) out of sync.`));
3032
+ lines.push(chalk.yellow(` ${total} resource(s) out of sync.`));
2024
3033
  }
2025
3034
  lines.push("");
2026
3035
  return lines.join("\n");
2027
3036
  }
3037
+ function showNote(n) {
3038
+ return n.label ? `${n.key} (${n.label})` : n.key;
3039
+ }
2028
3040
  function formatFeatureSummary(d) {
2029
3041
  const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
2030
3042
  if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
@@ -2037,7 +3049,8 @@ function formatChange(d) {
2037
3049
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
2038
3050
  return `${d.current.length} \u2192 ${d.desired.length} items`;
2039
3051
  }
2040
- return `${JSON.stringify(d.current, showUnset)} \u2192 ${JSON.stringify(d.desired, showUnset)}`;
3052
+ const desired = JSON.stringify(d.desired, showUnset);
3053
+ return d.current === void 0 ? desired : `${JSON.stringify(d.current, showUnset)} \u2192 ${desired}`;
2041
3054
  }
2042
3055
  function showUnset(_key, value) {
2043
3056
  return value === "\0missing" ? "(unset)" : value;
@@ -2050,53 +3063,87 @@ function formatVerbose(diff) {
2050
3063
  for (const [feature, d] of changes) {
2051
3064
  lines.push(`@@ app: ${app} / ${feature} @@`);
2052
3065
  if (d.status === "unknown") {
2053
- lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
3066
+ lines.push(chalk.red(` ? could not read server state \u2014 not reported as missing`));
2054
3067
  continue;
2055
3068
  }
2056
3069
  if (d.status === "extra") {
2057
- for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r}`));
3070
+ for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r}`));
2058
3071
  if (d.note) lines.push(` (${d.note})`);
2059
3072
  continue;
2060
3073
  }
2061
3074
  const currentLines = d.current !== void 0 ? JSON.stringify(d.current, showUnset, 2).split("\n") : [];
2062
3075
  const desiredLines = JSON.stringify(d.desired, showUnset, 2).split("\n");
2063
- for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
2064
- for (const line of desiredLines) lines.push(chalk2.green(`+ ${line}`));
2065
- for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r} (not in the file)`));
3076
+ for (const line of currentLines) lines.push(chalk.red(`- ${line}`));
3077
+ for (const line of desiredLines) lines.push(chalk.green(`+ ${line}`));
3078
+ for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
3079
+ }
3080
+ }
3081
+ for (const [app, notes] of Object.entries(diff.envNotes)) {
3082
+ lines.push(`@@ app: ${app} / env not in the file (note; up leaves these) @@`);
3083
+ for (const n of notes) lines.push(` note: ${showNote(n)}`);
3084
+ }
3085
+ if (diff.dokku) {
3086
+ lines.push(`@@ dokku version @@`);
3087
+ lines.push(chalk.red(`- ${diff.dokku.server} (server)`));
3088
+ lines.push(chalk.green(`+ ${diff.dokku.file} (file)`));
3089
+ }
3090
+ for (const [name, d] of Object.entries(diff.global)) {
3091
+ lines.push(`@@ global / ${name} @@`);
3092
+ if (d.status === "unknown") {
3093
+ lines.push(chalk.red(` ? could not read server state \u2014 not reported as missing`));
3094
+ continue;
2066
3095
  }
3096
+ if (d.current !== void 0) for (const line of JSON.stringify(d.current, null, 2).split("\n")) lines.push(chalk.red(`- ${line}`));
3097
+ if (d.status !== "extra") for (const line of JSON.stringify(d.desired, null, 2).split("\n")) lines.push(chalk.green(`+ ${line}`));
3098
+ for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
3099
+ if (d.note) lines.push(` (${d.note})`);
3100
+ }
3101
+ for (const net of Object.keys(diff.networks)) {
3102
+ lines.push(`@@ networks @@`);
3103
+ lines.push(chalk.green(`+ ${net}`));
2067
3104
  }
2068
3105
  for (const [name, d] of Object.entries(diff.plugins)) {
2069
3106
  lines.push(`@@ plugin: ${name} @@`);
2070
- if (d.current !== void 0) lines.push(chalk2.red(`- ${d.current}`));
2071
- lines.push(chalk2.green(`+ ${d.desired ?? "(any version)"}`) + (d.note ? ` (${d.note})` : ""));
3107
+ if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
3108
+ lines.push(chalk.green(`+ ${d.desired ?? "(any version)"}`) + (d.note ? ` (${d.note})` : ""));
2072
3109
  }
2073
3110
  for (const [svc, d] of Object.entries(diff.services)) {
2074
3111
  if (d.status === "missing") {
2075
3112
  lines.push(`@@ services @@`);
2076
- lines.push(chalk2.green(`+ ${svc}`));
3113
+ lines.push(chalk.green(`+ ${svc}`));
3114
+ }
3115
+ for (const m of d.mismatches ?? []) {
3116
+ lines.push(`@@ services: ${svc} ${m.field} @@`);
3117
+ lines.push(chalk.red(`- ${m.current}`));
3118
+ lines.push(chalk.green(`+ ${m.desired}`));
2077
3119
  }
3120
+ if (d.note) lines.push(`@@ services: ${svc} @@
3121
+ (${d.note})`);
2078
3122
  }
2079
3123
  if (diff.readWarnings.length > 0) {
2080
3124
  lines.push("");
2081
- lines.push(chalk2.red("Could not read some server state \u2014 treated as unknown, not missing:"));
2082
- for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
3125
+ lines.push(chalk.red("Could not read some server state \u2014 treated as unknown, not missing:"));
3126
+ for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
3127
+ }
3128
+ if (driftCount(diff) === 0) {
3129
+ lines.push(chalk.green("Everything in sync."));
2083
3130
  }
2084
3131
  lines.push("");
2085
3132
  return lines.join("\n");
2086
3133
  }
2087
3134
 
2088
3135
  // src/commands/validate.ts
2089
- import * as fs2 from "fs";
3136
+ import * as fs3 from "fs";
2090
3137
  import * as yaml2 from "js-yaml";
2091
3138
  function validate(filePath) {
2092
3139
  const errors = [];
2093
3140
  const warnings = [];
2094
- if (!fs2.existsSync(filePath)) {
3141
+ if (!fs3.existsSync(filePath)) {
2095
3142
  return { errors: [`File not found: ${filePath}`], warnings };
2096
3143
  }
2097
3144
  let raw;
2098
3145
  try {
2099
- raw = yaml2.load(fs2.readFileSync(filePath, "utf8"));
3146
+ raw = yaml2.load(fs3.readFileSync(filePath, "utf8"));
2100
3147
  } catch (e) {
2101
3148
  return { errors: [`YAML parse error: ${e.message}`], warnings };
2102
3149
  }
@@ -2104,7 +3151,13 @@ function validate(filePath) {
2104
3151
  if (!result.success) {
2105
3152
  for (const issue of result.error.issues) {
2106
3153
  const issuePath = issue.path.join(".");
2107
- errors.push(`${issuePath}: ${issue.message}`);
3154
+ const branches = issue.code === "invalid_union" ? issue.errors : void 0;
3155
+ const reasons = branches?.filter((b) => !b.every((i) => i.code === "invalid_type" && i.path.length === 0)).flat();
3156
+ if (reasons && reasons.length > 0) {
3157
+ for (const r of reasons) errors.push(`${[issuePath, ...r.path].filter((x) => x !== "").join(".")}: ${r.message}`);
3158
+ } else {
3159
+ errors.push(`${issuePath}: ${issue.message}`);
3160
+ }
2108
3161
  }
2109
3162
  }
2110
3163
  const data = raw;
@@ -2117,9 +3170,11 @@ function validate(filePath) {
2117
3170
  for (const name of Object.keys(data.redis)) serviceNames.add(name);
2118
3171
  }
2119
3172
  for (const [appName, appCfg] of Object.entries(data.apps)) {
3173
+ errors.push(...envExternalErrors(appName, appCfg));
2120
3174
  if (!appCfg?.links) continue;
2121
- for (const link of appCfg.links) {
2122
- if (!serviceNames.has(link)) {
3175
+ for (const entry of appCfg.links) {
3176
+ const link = typeof entry === "string" ? entry : entry?.service;
3177
+ if (typeof link === "string" && !serviceNames.has(link)) {
2123
3178
  errors.push(`apps.${appName}.links: service "${link}" not defined in postgres or redis`);
2124
3179
  }
2125
3180
  }
@@ -2134,9 +3189,25 @@ function validate(filePath) {
2134
3189
  }
2135
3190
  return { errors, warnings };
2136
3191
  }
3192
+ function envExternalErrors(appName, appCfg) {
3193
+ const list = appCfg?.env_external;
3194
+ if (!Array.isArray(list)) return [];
3195
+ const declared = appCfg.env && typeof appCfg.env === "object" ? appCfg.env : {};
3196
+ const errors = [];
3197
+ const seen = /* @__PURE__ */ new Set();
3198
+ for (const key of list) {
3199
+ if (typeof key !== "string") continue;
3200
+ const where = `apps.${appName}.env_external`;
3201
+ if (key === "DOKKU_COMPOSE_MANAGED_KEYS") errors.push(`${where}: "${key}" is dokku-compose's own marker`);
3202
+ else if (seen.has(key)) errors.push(`${where}: "${key}" is listed twice`);
3203
+ else if (key in declared) errors.push(`${where}: "${key}" is also in env:`);
3204
+ seen.add(key);
3205
+ }
3206
+ return errors;
3207
+ }
2137
3208
 
2138
3209
  // src/index.ts
2139
- import chalk3 from "chalk";
3210
+ import chalk2 from "chalk";
2140
3211
  var require2 = createRequire(import.meta.url);
2141
3212
  var { version } = require2("../package.json");
2142
3213
  var program = new Command().name("dokku-compose").version(version);
@@ -2151,7 +3222,7 @@ program.command("up [apps...]").description("Create/update apps and services to
2151
3222
  const runner = makeRunner(opts);
2152
3223
  const ctx = createContext(runner);
2153
3224
  try {
2154
- await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
3225
+ await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false, dryRun: opts.dryRun ?? false });
2155
3226
  if (opts.dryRun) {
2156
3227
  console.log("\n# Commands that would run:");
2157
3228
  for (const cmd of runner.dryRunLog) {
@@ -2160,7 +3231,7 @@ program.command("up [apps...]").description("Create/update apps and services to
2160
3231
  }
2161
3232
  }
2162
3233
  } catch (error) {
2163
- console.error(chalk3.red(`
3234
+ console.error(chalk2.red(`
2164
3235
  ${error.message}`));
2165
3236
  process.exitCode = 1;
2166
3237
  } finally {
@@ -2178,7 +3249,7 @@ program.command("down [apps...]").description("Destroy apps and services (requir
2178
3249
  try {
2179
3250
  await runDown(ctx, config, apps, { force: true, failFast: opts.failFast ?? false });
2180
3251
  } catch (error) {
2181
- console.error(chalk3.red(`
3252
+ console.error(chalk2.red(`
2182
3253
  ${error.message}`));
2183
3254
  process.exitCode = 1;
2184
3255
  } finally {
@@ -2211,11 +3282,15 @@ program.command("export").description("Export server state to dokku-compose.yml
2211
3282
  const result = opts.sensitive ? rawResult : maskSensitiveData(rawResult);
2212
3283
  const out = yaml3.dump(result, { lineWidth: 120 });
2213
3284
  if (opts.output) {
2214
- fs3.writeFileSync(opts.output, out);
3285
+ fs4.writeFileSync(opts.output, out);
2215
3286
  console.error(`Written to ${opts.output}`);
2216
3287
  } else {
2217
3288
  process.stdout.write(out);
2218
3289
  }
3290
+ } catch (error) {
3291
+ console.error(chalk2.red(`
3292
+ ${error.message}`));
3293
+ process.exitCode = 1;
2219
3294
  } finally {
2220
3295
  await ctx.close();
2221
3296
  }
@@ -2230,6 +3305,10 @@ program.command("diff").description("Show what is out of sync between config and
2230
3305
  const output = opts.verbose ? formatVerbose(diff) : formatSummary(diff);
2231
3306
  process.stdout.write(output);
2232
3307
  process.exitCode = rawDiff.inSync ? 0 : 1;
3308
+ } catch (error) {
3309
+ console.error(chalk2.red(`
3310
+ ${error.message}`));
3311
+ process.exitCode = 1;
2233
3312
  } finally {
2234
3313
  await ctx.close();
2235
3314
  }
@@ -2238,8 +3317,12 @@ program.command("ps [apps...]").description("Show status of configured apps").op
2238
3317
  const config = loadConfig(opts.file);
2239
3318
  const runner = makeRunner({});
2240
3319
  try {
2241
- const { runPs } = await import("./ps-33II4UU3.js");
3320
+ const { runPs } = await import("./ps-QQAVQTEV.js");
2242
3321
  await runPs(runner, config, apps);
3322
+ } catch (error) {
3323
+ console.error(chalk2.red(`
3324
+ ${error.message}`));
3325
+ process.exitCode = 1;
2243
3326
  } finally {
2244
3327
  await runner.close();
2245
3328
  }