dokku-compose 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,11 +8,11 @@ import {
8
8
  logError,
9
9
  logSkip,
10
10
  logWarn
11
- } from "./chunk-3W53KPE4.js";
11
+ } from "./chunk-WM244P42.js";
12
12
 
13
13
  // src/index.ts
14
14
  import { Command } from "commander";
15
- import * as fs3 from "fs";
15
+ import * as fs4 from "fs";
16
16
  import * as yaml3 from "js-yaml";
17
17
  import { createRequire } from "module";
18
18
 
@@ -22,6 +22,241 @@ import * as yaml from "js-yaml";
22
22
 
23
23
  // src/core/schema.ts
24
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()]);
25
260
  var PortSchema = z.string().regex(
26
261
  /^(http|https|tcp|udp):\d+:\d+$/,
27
262
  "Port must be scheme:host:container (e.g. http:80:3000)"
@@ -34,46 +269,79 @@ var ChecksSchema = z.union([
34
269
  z.literal(false),
35
270
  z.object({
36
271
  disabled: z.array(z.string()).optional(),
37
- skipped: z.array(z.string()).optional()
38
- }).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()
39
276
  ]);
40
277
  var GitSchema = z.object({
41
- deploy_branch: z.string().optional()
42
- });
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().optional().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().optional().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
+ ]);
43
296
  var AppSchema = z.object({
44
297
  domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
45
- links: z.array(z.string()).optional(),
298
+ links: z.array(LinkSchema).optional(),
46
299
  ports: z.array(PortSchema).optional(),
47
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(),
48
303
  ssl: z.union([
49
304
  z.literal(false),
50
305
  z.literal(true),
51
306
  z.object({ certfile: z.string(), keyfile: z.string() })
52
307
  ]).optional(),
53
308
  storage: z.array(z.string()).optional(),
54
- 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(),
55
315
  networks: z.array(z.string()).optional(),
56
316
  network: z.object({
57
317
  attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
58
318
  initial_network: z.union([z.string(), z.literal(false)]).optional(),
59
319
  bind_all_interfaces: z.boolean().optional(),
320
+ static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
60
321
  tld: z.union([z.string(), z.literal(false)]).optional()
61
322
  }).optional(),
62
- nginx: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
63
- logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
64
- registry: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
65
- 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(),
66
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(),
67
329
  ps: z.object({
68
330
  "procfile-path": z.union([z.string(), z.number()]).optional(),
69
331
  "restart-policy": z.union([z.string(), z.number()]).optional(),
70
- "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()
71
337
  }).strict().optional(),
72
338
  checks: ChecksSchema.optional(),
73
339
  build: z.object({
74
340
  dockerfile: z.string().optional(),
75
341
  app_json: z.string().optional(),
76
342
  context: z.string().optional(),
343
+ selected: z.string().optional(),
344
+ skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
77
345
  args: z.record(z.string(), z.string()).optional()
78
346
  }).optional(),
79
347
  docker_options: z.object({
@@ -98,7 +366,8 @@ var ServiceBackupSchema = z.object({
98
366
  var PostgresSchema = z.object({
99
367
  version: z.string().optional(),
100
368
  image: z.string().optional(),
101
- 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()
102
371
  });
103
372
  var RedisSchema = z.object({
104
373
  version: z.string().optional(),
@@ -124,8 +393,9 @@ var ConfigSchema = z.object({
124
393
  apps: z.record(z.string(), AppSchema),
125
394
  domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
126
395
  env: EnvMapSchema.optional(),
127
- nginx: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
128
- 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(),
129
399
  git: GitSchema.optional()
130
400
  });
131
401
  function parseConfig(raw) {
@@ -190,7 +460,7 @@ function maskSensitiveArgs(cmd) {
190
460
  return `${key}=${maskValue(value)}`;
191
461
  });
192
462
  masked = masked.replace(
193
- /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
463
+ /^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
194
464
  (_, prefix, password) => `${prefix}${maskValue(password)}`
195
465
  );
196
466
  masked = masked.replace(
@@ -215,7 +485,7 @@ function maskSensitiveArgsList(args) {
215
485
  masked[i] = `${m[1]}=${maskValue(m[2])}`;
216
486
  }
217
487
  }
218
- if (masked[0] === "registry:login") {
488
+ if (masked[0] === "registry:login" && !masked.includes("--password-stdin")) {
219
489
  mark(masked.length - 1);
220
490
  }
221
491
  if (/^\S+:backup-auth$/.test(masked[0] ?? "")) {
@@ -239,7 +509,7 @@ function maskSensitiveData(data) {
239
509
  if (typeof data === "object") {
240
510
  const result = {};
241
511
  for (const [key, value] of Object.entries(data)) {
242
- if (typeof value === "string" && SENSITIVE_RE.test(key)) {
512
+ if (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
243
513
  result[key] = maskValue(value);
244
514
  } else if (typeof value === "object" && value !== null) {
245
515
  result[key] = maskSensitiveData(value);
@@ -251,6 +521,13 @@ function maskSensitiveData(data) {
251
521
  }
252
522
  return data;
253
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
+ }
254
531
 
255
532
  // src/core/dokku.ts
256
533
  var DokkuCommandError = class extends Error {
@@ -275,17 +552,18 @@ function createRunner(opts = {}) {
275
552
  if (/^[A-Za-z0-9_\-:.,/=@]+$/.test(arg)) return arg;
276
553
  return `'${arg.replace(/'/g, "'\\''")}'`;
277
554
  }
278
- async function execDokku(args) {
555
+ async function execDokku(args, input) {
556
+ const stdio = input === void 0 ? { stdin: "ignore" } : { input };
279
557
  if (opts.host) {
280
558
  try {
281
- 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);
282
560
  return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
283
561
  } catch (e) {
284
562
  return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
285
563
  }
286
564
  } else {
287
565
  try {
288
- const result = await execa("dokku", args, { stdin: "ignore" });
566
+ const result = await execa("dokku", args, stdio);
289
567
  return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
290
568
  } catch (e) {
291
569
  return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
@@ -317,6 +595,18 @@ function createRunner(opts = {}) {
317
595
  const { ok } = await execDokku(args);
318
596
  return ok;
319
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
+ },
320
610
  async close() {
321
611
  if (!opts.host || !controlPath) return;
322
612
  try {
@@ -331,9 +621,23 @@ function createRunner(opts = {}) {
331
621
  function createContext(runner) {
332
622
  const cache = /* @__PURE__ */ new Map();
333
623
  const commands = [];
624
+ const restarts = /* @__PURE__ */ new Map();
625
+ const rebuilds = /* @__PURE__ */ new Map();
334
626
  return {
335
627
  commands,
336
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
+ },
337
641
  query(...args) {
338
642
  const key = args.join("\0");
339
643
  if (!cache.has(key)) {
@@ -347,6 +651,13 @@ function createContext(runner) {
347
651
  check(...args) {
348
652
  return runner.check(...args);
349
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
+ },
350
661
  async run(...args) {
351
662
  commands.push(args);
352
663
  await runner.run(...args);
@@ -358,6 +669,17 @@ function createContext(runner) {
358
669
  }
359
670
 
360
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
+ }
361
683
  function computeChange(before, after) {
362
684
  if (before === null || before === void 0 || after === null || after === void 0) {
363
685
  return { before, after, changed: before !== after };
@@ -385,7 +707,7 @@ function computeChange(before, after) {
385
707
  for (const key of allKeys) {
386
708
  if (!(key in b)) added[key] = a[key];
387
709
  else if (!(key in a)) removed.push(key);
388
- else if (String(b[key]) !== String(a[key])) modified[key] = a[key];
710
+ else if (!sameValue(b[key], a[key])) modified[key] = a[key];
389
711
  }
390
712
  const changed = Object.keys(added).length > 0 || removed.length > 0 || Object.keys(modified).length > 0;
391
713
  return { before, after, changed, added, removed, modified };
@@ -394,6 +716,9 @@ function computeChange(before, after) {
394
716
  }
395
717
  function computeAdditiveChange(before, after) {
396
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];
397
722
  change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
398
723
  return change;
399
724
  }
@@ -420,6 +745,17 @@ async function reconcile(resource, ctx, target, desired, onRemovals, prune = fal
420
745
  return;
421
746
  }
422
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
+ }
423
759
  if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
424
760
  if (canPrune) {
425
761
  const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
@@ -457,50 +793,10 @@ var Apps = {
457
793
  }
458
794
  };
459
795
 
460
- // src/resources/parsers.ts
461
- function parseReport(raw, namespace) {
462
- const result = {};
463
- const prefixPattern = namespace.replace(/-/g, "[\\s-]");
464
- const prefix = new RegExp(`^${prefixPattern}\\s+`, "i");
465
- for (const line of raw.split("\n")) {
466
- if (line.trimStart().startsWith("=====>")) continue;
467
- const colonIdx = line.indexOf(":");
468
- if (colonIdx === -1) continue;
469
- const rawKey = line.slice(0, colonIdx).trim();
470
- if (!rawKey) continue;
471
- const value = line.slice(colonIdx + 1).trim();
472
- const stripped = rawKey.replace(prefix, "");
473
- const key = stripped.toLowerCase().replace(/\s+/g, "-");
474
- if (key.startsWith("computed-") || key.startsWith("global-") || key === "last-visited-at") continue;
475
- if (!value) continue;
476
- result[key] = value;
477
- }
478
- return result;
479
- }
480
- function parseBulkReport(raw, namespace, headerNamespace) {
481
- const result = /* @__PURE__ */ new Map();
482
- const sections = raw.split(/(?=^=====> )/m).filter((s) => s.trim());
483
- const nsPattern = (headerNamespace ?? namespace).replace(/-/g, "[\\s-]");
484
- const headerRe = new RegExp(`^=====> (.+?)\\s+${nsPattern}\\s+information`, "i");
485
- for (const section of sections) {
486
- const headerEnd = section.indexOf("\n");
487
- if (headerEnd === -1) continue;
488
- const header = section.slice(0, headerEnd);
489
- const match = header.match(headerRe);
490
- if (!match) continue;
491
- const app = match[1];
492
- result.set(app, parseReport(section, namespace));
493
- }
494
- return result;
495
- }
496
-
497
796
  // src/resources/lists.ts
498
797
  function splitWords(raw) {
499
798
  return raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
500
799
  }
501
- function splitLines(raw) {
502
- return raw.split("\n").map((s) => s.trim()).filter(Boolean);
503
- }
504
800
  var Ports = {
505
801
  key: "ports",
506
802
  read: async (ctx, target) => {
@@ -520,24 +816,53 @@ var Ports = {
520
816
  await ctx.run("ports:set", target, ...change.after);
521
817
  }
522
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
+ }
523
825
  var Domains = {
524
826
  key: "domains",
525
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;
526
837
  const raw = await ctx.query("domains:report", target, "--domains-app-vhosts");
527
- return splitLines(raw);
838
+ return splitWords(raw);
528
839
  },
529
840
  readAll: async (ctx) => {
530
841
  const raw = await ctx.query("domains:report");
531
842
  const bulk = parseBulkReport(raw, "domains");
532
843
  const result = /* @__PURE__ */ new Map();
533
844
  for (const [app, report] of bulk) {
534
- 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);
535
850
  }
536
851
  return result;
537
852
  },
538
- onChange: async (ctx, target, { added, removed }) => {
539
- for (const d of removed) await ctx.run("domains:remove", target, d);
540
- 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);
541
866
  }
542
867
  };
543
868
  function parseDeployMounts(raw) {
@@ -559,146 +884,14 @@ var Storage = {
559
884
  return result;
560
885
  },
561
886
  onChange: async (ctx, target, { added, removed }) => {
562
- for (const m of removed) await ctx.run("storage:unmount", target, m);
563
- for (const m of added) await ctx.run("storage:mount", target, m);
564
- }
565
- };
566
-
567
- // src/resources/properties.ts
568
- async function isAppDeployed(ctx, target) {
569
- const raw = await ctx.query("ps:report", target, "--deployed");
570
- return raw.trim() === "true";
571
- }
572
- var NGINX_KEYS = [
573
- "access-log-format",
574
- "access-log-path",
575
- "bind-address-ipv4",
576
- "bind-address-ipv6",
577
- "client-body-timeout",
578
- "client-header-timeout",
579
- "client-max-body-size",
580
- "disable-custom-config",
581
- "error-log-path",
582
- "hsts",
583
- "hsts-include-subdomains",
584
- "hsts-max-age",
585
- "hsts-preload",
586
- "keepalive-timeout",
587
- "lingering-timeout",
588
- "proxy-buffer-size",
589
- "proxy-buffering",
590
- "proxy-buffers",
591
- "proxy-busy-buffers-size",
592
- "proxy-connect-timeout",
593
- "proxy-keepalive",
594
- "proxy-read-timeout",
595
- "proxy-send-timeout",
596
- "send-timeout",
597
- "underscore-in-headers",
598
- "x-forwarded-for-value",
599
- "x-forwarded-port-value",
600
- "x-forwarded-proto-value",
601
- "x-forwarded-ssl"
602
- ];
603
- var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
604
- var REGISTRY_KEYS = [
605
- "auth-servers",
606
- "image-repo",
607
- "image-repo-template",
608
- "push-extra-tags",
609
- "push-on-release",
610
- "server"
611
- ];
612
- function propertyRemovals(allowed) {
613
- return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
614
- }
615
- function propertyPrune(setCmd, afterPrune = []) {
616
- return async (ctx, target, removed) => {
617
- for (const key of removed) await ctx.run(setCmd, target, key);
618
- for (const cmd of afterPrune) {
619
- try {
620
- await ctx.run(cmd, target);
621
- } catch (err) {
622
- if (await isAppDeployed(ctx, target)) throw err;
623
- logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
624
- }
887
+ for (const m of removed) {
888
+ await ctx.run("storage:unmount", target, m);
889
+ ctx.needsRestart(target, "storage");
625
890
  }
626
- };
627
- }
628
- function propertyResource(opts) {
629
- return {
630
- key: opts.key,
631
- // Keys only on the server never make the block "changed"; diff shows them as removals.
632
- additive: true,
633
- removals: propertyRemovals(opts.settable),
634
- prune: propertyPrune(opts.setCmd, opts.afterChange),
635
- async read(ctx, target) {
636
- const raw = await ctx.query(`${opts.namespace}:report`, target);
637
- return parseReport(raw, opts.namespace);
638
- },
639
- async readAll(ctx) {
640
- const raw = await ctx.query(`${opts.namespace}:report`);
641
- return parseBulkReport(raw, opts.namespace);
642
- },
643
- async onChange(ctx, target, change, deferFollowUp = false) {
644
- for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
645
- await ctx.run(opts.setCmd, target, key, String(value));
646
- }
647
- if (opts.afterChange && !deferFollowUp) {
648
- for (const cmd of opts.afterChange) {
649
- try {
650
- await ctx.run(cmd, target);
651
- } catch (err) {
652
- if (await isAppDeployed(ctx, target)) throw err;
653
- logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
654
- }
655
- }
656
- }
657
- }
658
- };
659
- }
660
- var Nginx = propertyResource({
661
- key: "nginx",
662
- namespace: "nginx",
663
- setCmd: "nginx:set",
664
- settable: NGINX_KEYS,
665
- afterChange: ["proxy:build-config"]
666
- });
667
- var Logs = propertyResource({
668
- key: "logs",
669
- namespace: "logs",
670
- setCmd: "logs:set",
671
- settable: LOGS_KEYS
672
- });
673
- var Registry = propertyResource({
674
- key: "registry",
675
- namespace: "registry",
676
- setCmd: "registry:set",
677
- settable: REGISTRY_KEYS
678
- });
679
- var Scheduler = {
680
- key: "scheduler",
681
- // A scheduler selected on the app but not declared in the file.
682
- removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
683
- prune: async (ctx, target) => {
684
- await ctx.run("scheduler:set", target, "selected");
685
- },
686
- async read(ctx, target) {
687
- const raw = await ctx.query("scheduler:report", target);
688
- const report = parseReport(raw, "scheduler");
689
- return report["selected"] ?? "";
690
- },
691
- async readAll(ctx) {
692
- const raw = await ctx.query("scheduler:report");
693
- const bulk = parseBulkReport(raw, "scheduler");
694
- const result = /* @__PURE__ */ new Map();
695
- for (const [app, report] of bulk) {
696
- result.set(app, report["selected"] ?? "");
891
+ for (const m of added) {
892
+ await ctx.run("storage:mount", target, m);
893
+ ctx.needsRestart(target, "storage");
697
894
  }
698
- return result;
699
- },
700
- async onChange(ctx, target, change) {
701
- await ctx.run("scheduler:set", target, "selected", change.after);
702
895
  }
703
896
  };
704
897
 
@@ -722,6 +915,40 @@ var Proxy = {
722
915
  await ctx.run(after ? "proxy:enable" : "proxy:disable", target);
723
916
  }
724
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
+ }
725
952
 
726
953
  // src/core/config-json.ts
727
954
  function parseConfigJson(raw, what) {
@@ -763,6 +990,7 @@ var Config = {
763
990
  const { added, removed, modified } = change;
764
991
  if (removed.length > 0) {
765
992
  await ctx.run("config:unset", "--no-restart", target, ...removed);
993
+ ctx.needsRestart(target, "env");
766
994
  }
767
995
  const allDesiredKeys = Object.keys(change.after);
768
996
  if (allDesiredKeys.length === 0) {
@@ -779,53 +1007,127 @@ var Config = {
779
1007
  ...pairs,
780
1008
  `${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
781
1009
  );
1010
+ ctx.needsRestart(target, "env");
782
1011
  }
783
1012
  }
784
1013
  };
785
1014
 
786
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
+ }
787
1025
  var Certs = {
788
1026
  key: "ssl",
789
- read: async (ctx, target) => {
790
- const raw = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
791
- if (raw === "") return void 0;
792
- return raw === "true";
793
- },
794
- readAll: async (ctx) => {
795
- const raw = await ctx.query("certs:report");
796
- const bulk = parseBulkReport(raw, "ssl");
797
- const result = /* @__PURE__ */ new Map();
798
- for (const [app, report] of bulk) {
799
- const enabled = report["enabled"];
800
- result.set(app, enabled === void 0 ? void 0 : enabled === "true");
1027
+ read: async (ctx, target) => {
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;
801
1036
  }
802
- 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();
803
1045
  },
804
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
+ }
805
1050
  if (after === false && before) {
806
1051
  await ctx.run("certs:remove", target);
807
1052
  }
808
- if (after && typeof after === "object") {
809
- 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);
810
1055
  }
811
1056
  }
812
1057
  };
813
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
+
814
1101
  // src/resources/builder.ts
815
- function parseBuildOptions(dockerOptsBuild) {
1102
+ function parseBuildOptions(buildList) {
816
1103
  const out = [];
817
- for (const raw of dockerOptsBuild.trim().split(/\s+(?=--\S)/)) {
818
- 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);
819
1108
  if (m) out.push({ key: m[1], value: m[2], raw });
820
1109
  }
821
1110
  return out;
822
1111
  }
823
- function parseBuildArgs(dockerOptsBuild) {
1112
+ function parseBuildArgs(buildList) {
824
1113
  const args = {};
825
- for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
1114
+ for (const o of parseBuildOptions(buildList)) args[o.key] = o.value;
826
1115
  return args;
827
1116
  }
828
- 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) {
829
1131
  const config = {};
830
1132
  if (dockerfileReport["dockerfile-path"])
831
1133
  config.dockerfile = dockerfileReport["dockerfile-path"];
@@ -833,71 +1135,93 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
833
1135
  config.app_json = appJsonReport["selected"];
834
1136
  if (builderReport["build-dir"])
835
1137
  config.context = builderReport["build-dir"];
836
- 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);
837
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 });
838
1148
  return config;
839
1149
  }
840
1150
  var Builder = {
841
1151
  key: "build",
842
1152
  additive: true,
843
- // Build args (`--build-arg KEY=...` build docker options) whose KEY is not in `build.args`.
844
- removals: (current, desired) => Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`),
845
- // 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.
846
1161
  prune: async (ctx, target, removed) => {
847
- const stale = new Set(removed.map((l) => l.slice("args.".length)));
848
- const report = parseReport(await ctx.query("docker-options:report", target), "docker-options");
849
- const options = parseBuildOptions(report["build"] ?? "").filter((o) => stale.has(o.key));
850
- 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
+ }
851
1173
  for (const k of stale) {
852
1174
  if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
853
1175
  }
854
1176
  },
855
1177
  async read(ctx, target) {
856
- const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
1178
+ const [builderRaw, dockerfileRaw, appJsonRaw, buildList] = await Promise.all([
857
1179
  ctx.query("builder:report", target),
858
1180
  ctx.query("builder-dockerfile:report", target),
859
1181
  ctx.query("app-json:report", target),
860
- ctx.query("docker-options:report", target)
1182
+ readBuildList(ctx, target)
861
1183
  ]);
862
1184
  return buildConfigFromReports(
863
1185
  parseReport(builderRaw, "builder"),
864
1186
  parseReport(dockerfileRaw, "builder-dockerfile"),
865
1187
  parseReport(appJsonRaw, "app-json"),
866
- parseReport(dockerOptsRaw, "docker-options")["build"] ?? ""
1188
+ buildList
867
1189
  );
868
1190
  },
869
- async readAll(ctx) {
870
- const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
871
- ctx.query("builder:report"),
872
- ctx.query("builder-dockerfile:report"),
873
- ctx.query("app-json:report"),
874
- ctx.query("docker-options:report")
875
- ]);
876
- const builderBulk = parseBulkReport(builderRaw, "builder");
877
- const dockerfileBulk = parseBulkReport(dockerfileRaw, "builder-dockerfile");
878
- const appJsonBulk = parseBulkReport(appJsonRaw, "app-json");
879
- const dockerOptsBulk = parseBulkReport(dockerOptsRaw, "docker-options");
880
- const result = /* @__PURE__ */ new Map();
881
- for (const app of builderBulk.keys()) {
882
- result.set(app, buildConfigFromReports(
883
- builderBulk.get(app) ?? {},
884
- dockerfileBulk.get(app) ?? {},
885
- appJsonBulk.get(app) ?? {},
886
- dockerOptsBulk.get(app)?.["build"] ?? ""
887
- ));
888
- }
889
- return result;
890
- },
891
- async onChange(ctx, target, { after }) {
892
- if (after.dockerfile)
1191
+ async onChange(ctx, target, { before, after }) {
1192
+ if (after.dockerfile && after.dockerfile !== before?.dockerfile) {
893
1193
  await ctx.run("builder-dockerfile:set", target, "dockerfile-path", after.dockerfile);
894
- if (after.app_json)
1194
+ ctx.needsRebuild(target, "build");
1195
+ }
1196
+ if (after.app_json && after.app_json !== before?.app_json) {
895
1197
  await ctx.run("app-json:set", target, "appjson-path", after.app_json);
896
- if (after.context)
1198
+ ctx.needsRebuild(target, "build");
1199
+ }
1200
+ if (after.context && after.context !== before?.context) {
897
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));
898
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
+ }
899
1221
  for (const [key, value] of Object.entries(after.args)) {
900
- 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");
901
1225
  }
902
1226
  }
903
1227
  }
@@ -941,22 +1265,43 @@ var DockerOptions = {
941
1265
  }
942
1266
  return result;
943
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`.
944
1272
  async onChange(ctx, target, { before, after }) {
945
1273
  for (const phase of PHASES) {
946
1274
  const prev = new Set(before[phase] ?? []);
947
1275
  const desired = new Set(after[phase] ?? []);
948
1276
  for (const opt of desired) {
949
- 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
+ }
950
1282
  }
951
1283
  for (const opt of prev) {
952
- 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
+ }
953
1289
  }
954
1290
  }
955
1291
  }
956
1292
  };
957
1293
 
958
1294
  // src/resources/ps.ts
959
- 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"]);
960
1305
  function parseScale(raw) {
961
1306
  const result = {};
962
1307
  let parsed;
@@ -1003,7 +1348,7 @@ var Scale = {
1003
1348
  var Ps = {
1004
1349
  key: "ps",
1005
1350
  additive: true,
1006
- 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)),
1007
1352
  prune: async (ctx, target, removed) => {
1008
1353
  for (const key of removed) await ctx.run("ps:set", target, key);
1009
1354
  },
@@ -1018,24 +1363,35 @@ var Ps = {
1018
1363
  };
1019
1364
 
1020
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
+ }
1021
1376
  var Git = {
1022
1377
  key: "git",
1023
- read: async (ctx, target) => {
1024
- const report = await ctx.query("git:report", target, "--git-deploy-branch");
1025
- 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));
1026
1382
  },
1383
+ read: async (ctx, target) => fromReport(parseReport(await ctx.query("git:report", target), "git")),
1027
1384
  readAll: async (ctx) => {
1028
- const raw = await ctx.query("git:report");
1029
- const bulk = parseBulkReport(raw, "git");
1030
1385
  const result = /* @__PURE__ */ new Map();
1031
- for (const [app, report] of bulk) {
1032
- 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")));
1033
1389
  }
1034
1390
  return result;
1035
1391
  },
1036
- onChange: async (ctx, target, { after }) => {
1037
- if (after.deploy_branch) {
1038
- 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));
1039
1395
  }
1040
1396
  }
1041
1397
  };
@@ -1043,11 +1399,12 @@ var Git = {
1043
1399
  // src/resources/checks.ts
1044
1400
  var Checks = {
1045
1401
  key: "checks",
1046
- forceApply: true,
1047
- // Applying never reads (forceApply); the read only finds settings to report as removed.
1048
- // 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`.
1049
1404
  read: async (ctx, target) => {
1050
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;
1051
1408
  const current = {};
1052
1409
  for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
1053
1410
  const v = report[reportKey];
@@ -1060,7 +1417,7 @@ var Checks = {
1060
1417
  prune: async (ctx, target, removed, current, desired) => {
1061
1418
  const cur = current;
1062
1419
  const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
1063
- const declared = new Set(desired && desired !== false ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1420
+ const declared = new Set(desired ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1064
1421
  const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
1065
1422
  const enable = [...stale].filter((t) => !declared.has(t));
1066
1423
  for (const t of stale) {
@@ -1068,26 +1425,32 @@ var Checks = {
1068
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`);
1069
1426
  }
1070
1427
  }
1071
- if (enable.length > 0) await ctx.run("checks:enable", target, ...enable);
1428
+ if (enable.length > 0) await ctx.run("checks:enable", target, enable.join(","));
1072
1429
  if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
1073
1430
  },
1074
- onChange: async (ctx, target, { after }) => {
1075
- if (after === false) {
1076
- await ctx.run("checks:disable", target);
1077
- return;
1078
- }
1079
- if (after.disabled && after.disabled.length > 0) {
1080
- await ctx.run("checks:disable", target, ...after.disabled);
1081
- }
1082
- if (after.skipped && after.skipped.length > 0) {
1083
- await ctx.run("checks:skip", target, ...after.skipped);
1084
- }
1085
- for (const [key, value] of Object.entries(after)) {
1086
- if (key === "disabled" || key === "skipped") continue;
1087
- await ctx.run("checks:set", target, key, String(value));
1088
- }
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));
1089
1435
  }
1090
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
+ }
1091
1454
 
1092
1455
  // src/resources/network.ts
1093
1456
  var Networks = {
@@ -1107,6 +1470,7 @@ var Networks = {
1107
1470
  },
1108
1471
  onChange: async (ctx, target, { after }) => {
1109
1472
  await ctx.run("network:set", target, "attach-post-deploy", ...after);
1473
+ ctx.needsRebuild(target, "networks");
1110
1474
  }
1111
1475
  };
1112
1476
  var NetworkProps = {
@@ -1115,7 +1479,11 @@ var NetworkProps = {
1115
1479
  removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
1116
1480
  // Dokku's property names are hyphenated; the schema's are underscored.
1117
1481
  prune: async (ctx, target, removed) => {
1118
- 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
+ }
1119
1487
  },
1120
1488
  read: async (ctx, target) => {
1121
1489
  const raw = await ctx.query("network:report", target);
@@ -1126,6 +1494,7 @@ var NetworkProps = {
1126
1494
  }
1127
1495
  if (report["initial-network"]) config.initial_network = report["initial-network"];
1128
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"];
1129
1498
  if (report["tld"]) config.tld = report["tld"];
1130
1499
  return config;
1131
1500
  },
@@ -1140,6 +1509,7 @@ var NetworkProps = {
1140
1509
  }
1141
1510
  if (report["initial-network"]) config.initial_network = report["initial-network"];
1142
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"];
1143
1513
  if (report["tld"]) config.tld = report["tld"];
1144
1514
  result.set(app, config);
1145
1515
  }
@@ -1149,15 +1519,22 @@ var NetworkProps = {
1149
1519
  if (after.attach_post_create !== void 0 && after.attach_post_create !== false) {
1150
1520
  const nets = Array.isArray(after.attach_post_create) ? after.attach_post_create : [after.attach_post_create];
1151
1521
  await ctx.run("network:set", target, "attach-post-create", ...nets);
1522
+ ctx.needsRebuild(target, "network");
1152
1523
  }
1153
1524
  if (after.initial_network !== void 0 && after.initial_network !== false) {
1154
1525
  await ctx.run("network:set", target, "initial-network", after.initial_network);
1526
+ ctx.needsRebuild(target, "network");
1155
1527
  }
1156
1528
  if (after.bind_all_interfaces !== void 0) {
1157
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);
1158
1534
  }
1159
1535
  if (after.tld !== void 0 && after.tld !== false) {
1160
1536
  await ctx.run("network:set", target, "tld", after.tld);
1537
+ ctx.needsRestart(target, "network");
1161
1538
  }
1162
1539
  }
1163
1540
  };
@@ -1251,13 +1628,61 @@ async function ensurePlugins(ctx, plugins) {
1251
1628
  }
1252
1629
 
1253
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
+ }
1254
1657
  async function ensureDockerAuth(ctx, config) {
1255
1658
  for (const [server, creds] of Object.entries(config)) {
1256
1659
  logAction("docker-auth", `Logging in to ${server} as ${creds.username}`);
1257
- 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);
1258
1674
  logDone();
1259
1675
  }
1260
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
+ }
1261
1686
 
1262
1687
  // src/modules/network.ts
1263
1688
  async function ensureNetworks(ctx, networks) {
@@ -1278,61 +1703,255 @@ async function exportNetworks(ctx) {
1278
1703
  return output.split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("=====>") && !DOCKER_BUILTIN_NETWORKS.has(s));
1279
1704
  }
1280
1705
 
1281
- // src/modules/postgres.ts
1706
+ // src/modules/service-drift.ts
1282
1707
  import { createHash as createHash2 } from "crypto";
1283
- function backupHashKey(serviceName) {
1284
- 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";
1285
1755
  }
1286
- function computeBackupHash(backup) {
1287
- return createHash2("sha256").update(JSON.stringify(backup)).digest("hex");
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;
1288
1786
  }
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";
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
1289
1839
  async function ensurePostgres(ctx, services) {
1840
+ const created = /* @__PURE__ */ new Set();
1290
1841
  for (const [name, config] of Object.entries(services)) {
1291
1842
  logAction("services", `Ensuring ${name}`);
1292
1843
  const exists = await ctx.check("postgres:exists", name);
1293
1844
  if (exists) {
1294
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
+ }
1295
1855
  continue;
1296
1856
  }
1297
1857
  const args = ["postgres:create", name];
1298
1858
  if (config.image) args.push("--image", config.image);
1299
1859
  if (config.version) args.push("--image-version", config.version);
1300
1860
  await ctx.run(...args);
1861
+ created.add(name);
1301
1862
  logDone();
1302
1863
  }
1864
+ return created;
1303
1865
  }
1304
- async function ensurePostgresBackups(ctx, services) {
1866
+ async function ensurePostgresBackups(ctx, services, opts = {}) {
1305
1867
  for (const [name, config] of Object.entries(services)) {
1306
- 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);
1307
1902
  logAction("services", `Configuring backup for ${name}`);
1308
- const hashKey = backupHashKey(name);
1309
- const desiredHash = computeBackupHash(config.backup);
1310
- const storedHash = await ctx.query("config:get", "--global", hashKey);
1311
- if (storedHash === desiredHash) {
1903
+ const fields = new Set(backupMismatches(info, backup).map((m) => m.field));
1904
+ if (fields.size === 0) {
1312
1905
  logSkip();
1313
1906
  continue;
1314
1907
  }
1315
- const { schedule, bucket, auth } = config.backup;
1316
- await ctx.run(
1317
- "postgres:backup-auth",
1318
- name,
1319
- auth.access_key_id,
1320
- auth.secret_access_key,
1321
- auth.region,
1322
- auth.signature_version,
1323
- auth.endpoint
1324
- );
1325
- await ctx.run("postgres:backup-schedule", name, schedule, bucket);
1326
- const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
1327
- if (!parseBackupSchedule(cronOutput)) {
1328
- throw new Error(
1329
- `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
1330
1924
  );
1331
1925
  }
1332
- 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
+ }
1333
1935
  logDone();
1334
1936
  }
1335
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
+ }
1336
1955
  async function destroyPostgres(ctx, services) {
1337
1956
  for (const [name] of Object.entries(services)) {
1338
1957
  logAction("services", `Destroying ${name}`);
@@ -1405,6 +2024,15 @@ async function ensureRedis(ctx, services) {
1405
2024
  const exists = await ctx.check("redis:exists", name);
1406
2025
  if (exists) {
1407
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
+ }
1408
2036
  continue;
1409
2037
  }
1410
2038
  const args = ["redis:create", name];
@@ -1453,6 +2081,9 @@ async function exportRedis(ctx) {
1453
2081
  }
1454
2082
 
1455
2083
  // src/modules/links.ts
2084
+ function normalizeLink(entry) {
2085
+ return typeof entry === "string" ? { service: entry } : entry;
2086
+ }
1456
2087
  function resolveServicePlugin(name, config) {
1457
2088
  if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
1458
2089
  if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
@@ -1464,24 +2095,112 @@ function allServices(config) {
1464
2095
  for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
1465
2096
  return entries;
1466
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
+ }
1467
2170
  async function ensureAppLinks(ctx, app, desiredLinks, config) {
1468
- const desiredSet = new Set(desiredLinks);
1469
- for (const [serviceName, plugin] of allServices(config)) {
1470
- const isLinked = await ctx.check(`${plugin}:linked`, serviceName, app);
1471
- const isDesired = desiredSet.has(serviceName);
1472
- 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) {
1473
2176
  logAction(app, `Linking ${serviceName}`);
1474
- 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");
1475
2179
  logDone();
1476
- } 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)) {
1477
2195
  logAction(app, `Unlinking ${serviceName}`);
1478
2196
  await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
2197
+ ctx.needsRestart(app, "links");
1479
2198
  logDone();
1480
2199
  }
1481
2200
  }
1482
2201
  }
1483
2202
  async function destroyAppLinks(ctx, app, links, config) {
1484
- for (const serviceName of links) {
2203
+ for (const serviceName of links.map((l) => normalizeLink(l).service)) {
1485
2204
  const resolved = resolveServicePlugin(serviceName, config);
1486
2205
  if (!resolved) continue;
1487
2206
  const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
@@ -1493,45 +2212,103 @@ async function destroyAppLinks(ctx, app, links, config) {
1493
2212
  async function exportAppLinks(ctx, app, config) {
1494
2213
  const linked = [];
1495
2214
  for (const [serviceName, plugin] of allServices(config)) {
1496
- const isLinked = await ctx.check(`${plugin}:linked`, serviceName, app);
1497
- 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);
1498
2239
  }
1499
2240
  return linked;
1500
2241
  }
1501
2242
 
1502
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
+ }
1503
2254
  async function ensureGlobalDomains(ctx, domains) {
1504
2255
  logAction("global", "Configuring domains");
1505
- if (domains === false) {
1506
- await ctx.run("domains:clear-global");
1507
- } else {
1508
- await ctx.run("domains:set-global", ...domains);
2256
+ const current = await readGlobalDomains(ctx);
2257
+ if (globalDomainsInSync(current, domains)) {
2258
+ logSkip();
2259
+ return;
1509
2260
  }
2261
+ if (domains === false) await ctx.run("domains:clear-global");
2262
+ else await ctx.run("domains:set-global", ...domains);
1510
2263
  logDone();
1511
2264
  }
1512
2265
 
1513
- // src/modules/config.ts
1514
- async function ensureGlobalConfig(ctx, env) {
1515
- if (env === false) return;
1516
- const pairs = Object.entries(env).map(([k, v]) => `${k}=${v}`);
1517
- await ctx.run("config:set", "--global", ...pairs);
1518
- }
1519
-
1520
2266
  // src/modules/logs.ts
2267
+ async function readGlobalLogs(ctx) {
2268
+ return parseJsonReport(await ctx.queryStrict("logs:report", "--global", "--format", "json"), "global");
2269
+ }
1521
2270
  async function ensureGlobalLogs(ctx, logs) {
2271
+ logAction("global", "Configuring logs");
2272
+ const current = await readGlobalLogs(ctx);
2273
+ let changed = false;
1522
2274
  for (const [key, value] of Object.entries(logs)) {
2275
+ if (current[key] === String(value)) continue;
1523
2276
  await ctx.run("logs:set", "--global", key, String(value));
2277
+ changed = true;
1524
2278
  }
2279
+ if (changed) logDone();
2280
+ else logSkip();
1525
2281
  }
1526
2282
 
1527
2283
  // src/modules/nginx.ts
2284
+ async function readGlobalNginx(ctx) {
2285
+ return parseJsonReport(await ctx.queryStrict("nginx:report", "--global", "--format", "json"), "global");
2286
+ }
1528
2287
  async function ensureGlobalNginx(ctx, nginx) {
2288
+ logAction("global", "Configuring nginx");
2289
+ const current = await readGlobalNginx(ctx);
2290
+ let changed = false;
1529
2291
  for (const [key, value] of Object.entries(nginx)) {
2292
+ if (current[key] === String(value)) continue;
1530
2293
  await ctx.run("nginx:set", "--global", key, String(value));
2294
+ changed = true;
1531
2295
  }
2296
+ if (changed) logDone();
2297
+ else logSkip();
1532
2298
  }
1533
2299
 
1534
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
+ }
1535
2312
  var UpFailedError = class extends Error {
1536
2313
  constructor(failures) {
1537
2314
  super(
@@ -1567,6 +2344,7 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
1567
2344
  await step(() => reconcile(Networks, ctx, app, appConfig.networks));
1568
2345
  await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
1569
2346
  await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
2347
+ await step(() => reconcile(ProxyProps, ctx, app, proxyProps(appConfig.proxy), count, prune));
1570
2348
  await step(() => reconcile(Ports, ctx, app, appConfig.ports));
1571
2349
  if (config.postgres || config.redis) {
1572
2350
  await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
@@ -1590,18 +2368,36 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
1590
2368
  if (errors.length > 0) throw new AppStepsError(errors);
1591
2369
  }
1592
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) {
1593
2378
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1594
2379
  enforceFileVersion(await assertDokkuSupported(ctx), config.dokku?.version);
1595
2380
  if (config.plugins) await ensurePlugins(ctx, config.plugins);
1596
- 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
+ }
1597
2385
  if (config.domains !== void 0) await ensureGlobalDomains(ctx, config.domains);
1598
- 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
+ }
1599
2389
  if (config.logs !== void 0) await ensureGlobalLogs(ctx, config.logs);
1600
2390
  if (config.nginx !== void 0) await ensureGlobalNginx(ctx, config.nginx);
1601
2391
  if (config.networks) await ensureNetworks(ctx, config.networks);
1602
- if (config.postgres) await ensurePostgres(ctx, config.postgres);
2392
+ const createdPostgres = config.postgres ? await ensurePostgres(ctx, config.postgres) : /* @__PURE__ */ new Set();
1603
2393
  if (config.redis) await ensureRedis(ctx, config.redis);
1604
- 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);
1605
2401
  const failures = [];
1606
2402
  for (const app of apps) {
1607
2403
  const appConfig = config.apps[app];
@@ -1693,6 +2489,7 @@ var NETWORKING_RESOURCES = [
1693
2489
  Networks,
1694
2490
  NetworkProps,
1695
2491
  Proxy,
2492
+ ProxyProps,
1696
2493
  Ports
1697
2494
  ];
1698
2495
  var CONFIG_RESOURCES = [
@@ -1732,7 +2529,10 @@ async function runExport(ctx, opts) {
1732
2529
  const prefetched = /* @__PURE__ */ new Map();
1733
2530
  await Promise.all(
1734
2531
  ALL_APP_RESOURCES.filter((r) => !r.forceApply && !r.key.startsWith("_") && r.readAll).map(async (r) => {
1735
- prefetched.set(r.key, await r.readAll(ctx));
2532
+ try {
2533
+ prefetched.set(r.key, await r.readAll(ctx));
2534
+ } catch {
2535
+ }
1736
2536
  })
1737
2537
  );
1738
2538
  for (const app of apps) {
@@ -1740,13 +2540,24 @@ async function runExport(ctx, opts) {
1740
2540
  for (const resource of ALL_APP_RESOURCES) {
1741
2541
  if (resource.key.startsWith("_")) continue;
1742
2542
  if (resource.forceApply) continue;
2543
+ if (resource.key === "checks") continue;
1743
2544
  const bulk = prefetched.get(resource.key);
1744
- 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
+ }
1745
2552
  if (value === void 0 || value === null || value === "") continue;
1746
2553
  if (Array.isArray(value) && value.length === 0) continue;
1747
2554
  if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) continue;
1748
- if (resource.key === "proxy") {
1749
- 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 };
1750
2561
  } else {
1751
2562
  appConfig[resource.key] = value;
1752
2563
  }
@@ -1760,14 +2571,41 @@ async function runExport(ctx, opts) {
1760
2571
  return config;
1761
2572
  }
1762
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
+
1763
2596
  // src/commands/diff.ts
1764
2597
  import chalk from "chalk";
1765
- function diffSslStatus(desired, current) {
1766
- if (typeof desired !== "object" || desired === null) return void 0;
1767
- if (current === void 0) return void 0;
1768
- if (current === true) return "in-sync";
1769
- if (current === false || current === null) return "missing";
1770
- return void 0;
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
+ }
1771
2609
  }
1772
2610
  function hasAdditionsOrChanges(change) {
1773
2611
  const c = change;
@@ -1779,7 +2617,7 @@ function isEmptyValue(value) {
1779
2617
  return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1780
2618
  }
1781
2619
  async function computeDiff(ctx, config) {
1782
- const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
2620
+ const result = { apps: {}, services: {}, plugins: {}, global: {}, networks: {}, inSync: true, envNotes: {}, readWarnings: [] };
1783
2621
  const appNames = Object.keys(config.apps);
1784
2622
  const serverVersion = await assertDokkuSupported(ctx);
1785
2623
  const fileVersion = config.dokku?.version;
@@ -1806,13 +2644,29 @@ async function computeDiff(ctx, config) {
1806
2644
  }
1807
2645
  })
1808
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
+ }
1809
2658
  for (const [app, appConfig] of Object.entries(config.apps)) {
1810
2659
  const appDiff = {};
2660
+ const envCache = /* @__PURE__ */ new Map();
1811
2661
  for (const resource of ALL_APP_RESOURCES) {
1812
2662
  if (resource.key.startsWith("_")) continue;
1813
2663
  let desired;
1814
2664
  if (resource.key === "proxy") {
1815
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);
1816
2670
  } else {
1817
2671
  desired = appConfig[resource.key];
1818
2672
  }
@@ -1847,17 +2701,28 @@ async function computeDiff(ctx, config) {
1847
2701
  current = await resource.read(ctx, app);
1848
2702
  } else if (bulk) {
1849
2703
  current = bulk.get(app);
1850
- if (resource.key === "ssl" && current === void 0) {
1851
- current = await resource.read(ctx, app);
1852
- if (current === void 0) bulkUnreadable = true;
1853
- }
1854
2704
  } else {
1855
2705
  current = await resource.read(ctx, app);
1856
2706
  }
1857
- const sslStatus = resource.key === "ssl" ? diffSslStatus(desired, current) : void 0;
1858
- if (sslStatus) {
1859
- appDiff[resource.key] = { status: sslStatus, desired, current };
1860
- 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;
1861
2726
  continue;
1862
2727
  }
1863
2728
  let removed = resource.removals ? resource.removals(current, desired) : [];
@@ -1893,16 +2758,57 @@ async function computeDiff(ctx, config) {
1893
2758
  appDiff[resource.key].removed = removed;
1894
2759
  }
1895
2760
  }
1896
- if (appConfig.links === void 0 && appExists.get(app)) {
1897
- const linked = [];
1898
- for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
1899
- for (const svc of Object.keys(services ?? {})) {
1900
- 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}`);
1901
2791
  }
1902
2792
  }
1903
- if (linked.length > 0) {
1904
- appDiff["links"] = { status: "extra", current: linked, removed: linked, note: "no links: in the file; up will unlink these" };
1905
- 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)}`);
1906
2812
  }
1907
2813
  }
1908
2814
  result.apps[app] = appDiff;
@@ -1917,20 +2823,137 @@ async function computeDiff(ctx, config) {
1917
2823
  if (plan.action !== "unknown") result.inSync = false;
1918
2824
  }
1919
2825
  }
1920
- for (const svc of Object.keys(config.postgres ?? {})) {
1921
- const exists = await ctx.check("postgres:exists", svc);
1922
- result.services[svc] = { status: exists ? "in-sync" : "missing" };
1923
- 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;
1924
2907
  }
1925
- for (const svc of Object.keys(config.redis ?? {})) {
1926
- const exists = await ctx.check("redis:exists", svc);
1927
- result.services[svc] = { status: exists ? "in-sync" : "missing" };
1928
- 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
+ }
1929
2938
  }
1930
2939
  return result;
1931
2940
  }
1932
2941
  function maskDiffResult(diff) {
1933
- const masked = { apps: {}, services: diff.services, plugins: diff.plugins, dokku: diff.dokku, 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
+ };
1934
2957
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1935
2958
  const maskedAppDiff = {};
1936
2959
  for (const [feature, d] of Object.entries(appDiff)) {
@@ -1946,6 +2969,9 @@ function maskDiffResult(diff) {
1946
2969
  }
1947
2970
  return masked;
1948
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
+ }
1949
2975
  function formatSummary(diff) {
1950
2976
  const lines = [""];
1951
2977
  for (const [app, appDiff] of Object.entries(diff.apps)) {
@@ -1960,11 +2986,22 @@ function formatSummary(diff) {
1960
2986
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1961
2987
  }
1962
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(", ")}`);
1963
2991
  }
1964
2992
  if (diff.dokku) {
1965
2993
  lines.push(` dokku`);
1966
2994
  lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
1967
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`);
3004
+ }
1968
3005
  for (const [name, d] of Object.entries(diff.plugins)) {
1969
3006
  const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
1970
3007
  const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
@@ -1976,8 +3013,13 @@ function formatSummary(diff) {
1976
3013
  lines.push(` services:`);
1977
3014
  lines.push(` ${chalk.green("+")} ${svc}: not provisioned`);
1978
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}`);
3019
+ }
3020
+ if (d.note) lines.push(` note: ${d.note}`);
1979
3021
  }
1980
- 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 + (diff.dokku ? 1 : 0);
3022
+ const total = driftCount(diff);
1981
3023
  if (diff.readWarnings.length > 0) {
1982
3024
  lines.push("");
1983
3025
  lines.push(chalk.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
@@ -1992,6 +3034,9 @@ function formatSummary(diff) {
1992
3034
  lines.push("");
1993
3035
  return lines.join("\n");
1994
3036
  }
3037
+ function showNote(n) {
3038
+ return n.label ? `${n.key} (${n.label})` : n.key;
3039
+ }
1995
3040
  function formatFeatureSummary(d) {
1996
3041
  const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
1997
3042
  if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
@@ -2004,7 +3049,8 @@ function formatChange(d) {
2004
3049
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
2005
3050
  return `${d.current.length} \u2192 ${d.desired.length} items`;
2006
3051
  }
2007
- 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}`;
2008
3054
  }
2009
3055
  function showUnset(_key, value) {
2010
3056
  return value === "\0missing" ? "(unset)" : value;
@@ -2032,11 +3078,30 @@ function formatVerbose(diff) {
2032
3078
  for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
2033
3079
  }
2034
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
+ }
2035
3085
  if (diff.dokku) {
2036
3086
  lines.push(`@@ dokku version @@`);
2037
3087
  lines.push(chalk.red(`- ${diff.dokku.server} (server)`));
2038
3088
  lines.push(chalk.green(`+ ${diff.dokku.file} (file)`));
2039
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;
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}`));
3104
+ }
2040
3105
  for (const [name, d] of Object.entries(diff.plugins)) {
2041
3106
  lines.push(`@@ plugin: ${name} @@`);
2042
3107
  if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
@@ -2047,28 +3112,38 @@ function formatVerbose(diff) {
2047
3112
  lines.push(`@@ services @@`);
2048
3113
  lines.push(chalk.green(`+ ${svc}`));
2049
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}`));
3119
+ }
3120
+ if (d.note) lines.push(`@@ services: ${svc} @@
3121
+ (${d.note})`);
2050
3122
  }
2051
3123
  if (diff.readWarnings.length > 0) {
2052
3124
  lines.push("");
2053
3125
  lines.push(chalk.red("Could not read some server state \u2014 treated as unknown, not missing:"));
2054
3126
  for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
2055
3127
  }
3128
+ if (driftCount(diff) === 0) {
3129
+ lines.push(chalk.green("Everything in sync."));
3130
+ }
2056
3131
  lines.push("");
2057
3132
  return lines.join("\n");
2058
3133
  }
2059
3134
 
2060
3135
  // src/commands/validate.ts
2061
- import * as fs2 from "fs";
3136
+ import * as fs3 from "fs";
2062
3137
  import * as yaml2 from "js-yaml";
2063
3138
  function validate(filePath) {
2064
3139
  const errors = [];
2065
3140
  const warnings = [];
2066
- if (!fs2.existsSync(filePath)) {
3141
+ if (!fs3.existsSync(filePath)) {
2067
3142
  return { errors: [`File not found: ${filePath}`], warnings };
2068
3143
  }
2069
3144
  let raw;
2070
3145
  try {
2071
- raw = yaml2.load(fs2.readFileSync(filePath, "utf8"));
3146
+ raw = yaml2.load(fs3.readFileSync(filePath, "utf8"));
2072
3147
  } catch (e) {
2073
3148
  return { errors: [`YAML parse error: ${e.message}`], warnings };
2074
3149
  }
@@ -2076,7 +3151,13 @@ function validate(filePath) {
2076
3151
  if (!result.success) {
2077
3152
  for (const issue of result.error.issues) {
2078
3153
  const issuePath = issue.path.join(".");
2079
- 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
+ }
2080
3161
  }
2081
3162
  }
2082
3163
  const data = raw;
@@ -2089,9 +3170,11 @@ function validate(filePath) {
2089
3170
  for (const name of Object.keys(data.redis)) serviceNames.add(name);
2090
3171
  }
2091
3172
  for (const [appName, appCfg] of Object.entries(data.apps)) {
3173
+ errors.push(...envExternalErrors(appName, appCfg));
2092
3174
  if (!appCfg?.links) continue;
2093
- for (const link of appCfg.links) {
2094
- 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)) {
2095
3178
  errors.push(`apps.${appName}.links: service "${link}" not defined in postgres or redis`);
2096
3179
  }
2097
3180
  }
@@ -2106,6 +3189,22 @@ function validate(filePath) {
2106
3189
  }
2107
3190
  return { errors, warnings };
2108
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
+ }
2109
3208
 
2110
3209
  // src/index.ts
2111
3210
  import chalk2 from "chalk";
@@ -2123,7 +3222,7 @@ program.command("up [apps...]").description("Create/update apps and services to
2123
3222
  const runner = makeRunner(opts);
2124
3223
  const ctx = createContext(runner);
2125
3224
  try {
2126
- 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 });
2127
3226
  if (opts.dryRun) {
2128
3227
  console.log("\n# Commands that would run:");
2129
3228
  for (const cmd of runner.dryRunLog) {
@@ -2183,7 +3282,7 @@ program.command("export").description("Export server state to dokku-compose.yml
2183
3282
  const result = opts.sensitive ? rawResult : maskSensitiveData(rawResult);
2184
3283
  const out = yaml3.dump(result, { lineWidth: 120 });
2185
3284
  if (opts.output) {
2186
- fs3.writeFileSync(opts.output, out);
3285
+ fs4.writeFileSync(opts.output, out);
2187
3286
  console.error(`Written to ${opts.output}`);
2188
3287
  } else {
2189
3288
  process.stdout.write(out);
@@ -2218,7 +3317,7 @@ program.command("ps [apps...]").description("Show status of configured apps").op
2218
3317
  const config = loadConfig(opts.file);
2219
3318
  const runner = makeRunner({});
2220
3319
  try {
2221
- const { runPs } = await import("./ps-IWBYZRDO.js");
3320
+ const { runPs } = await import("./ps-QQAVQTEV.js");
2222
3321
  await runPs(runner, config, apps);
2223
3322
  } catch (error) {
2224
3323
  console.error(chalk2.red(`