dokku-compose 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +53 -6
  2. package/dist/index.js +773 -130
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -53,6 +53,12 @@ var AppSchema = z.object({
53
53
  logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
54
54
  registry: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
55
55
  scheduler: z.string().optional(),
56
+ scale: z.record(z.string(), z.preprocess((v) => typeof v === "string" && /^\s*-?\d+\s*$/.test(v) ? Number(v) : v, z.number().int().min(0))).optional(),
57
+ ps: z.object({
58
+ "procfile-path": z.union([z.string(), z.number()]).optional(),
59
+ "restart-policy": z.union([z.string(), z.number()]).optional(),
60
+ "stop-timeout-seconds": z.union([z.string(), z.number()]).optional()
61
+ }).strict().optional(),
56
62
  checks: ChecksSchema.optional(),
57
63
  build: z.object({
58
64
  dockerfile: z.string().optional(),
@@ -117,16 +123,29 @@ function parseConfig(raw) {
117
123
  }
118
124
 
119
125
  // src/core/config.ts
120
- function interpolateEnvVars(content) {
126
+ function interpolateValue(value, missing) {
127
+ if (typeof value === "string") {
128
+ return value.replace(/\$\$\{|\$\{([^}]+)\}/g, (match, name) => {
129
+ if (name === void 0) return "${";
130
+ const v = process.env[name];
131
+ if (v === void 0 || v === "") {
132
+ missing.push(name);
133
+ return "";
134
+ }
135
+ return v;
136
+ });
137
+ }
138
+ if (Array.isArray(value)) return value.map((v) => interpolateValue(v, missing));
139
+ if (value !== null && typeof value === "object") {
140
+ const out = {};
141
+ for (const [k, v] of Object.entries(value)) out[k] = interpolateValue(v, missing);
142
+ return out;
143
+ }
144
+ return value;
145
+ }
146
+ function interpolateEnvVars(raw) {
121
147
  const missing = [];
122
- const result = content.replace(/\$\{([^}]+)\}/g, (_, name) => {
123
- const value = process.env[name];
124
- if (value === void 0 || value === "") {
125
- missing.push(name);
126
- return "";
127
- }
128
- return value;
129
- });
148
+ const result = interpolateValue(raw, missing);
130
149
  if (missing.length > 0) {
131
150
  const unique = Array.from(new Set(missing));
132
151
  throw new Error(
@@ -140,7 +159,7 @@ function loadConfig(filePath) {
140
159
  throw new Error(`Config file not found: ${filePath}`);
141
160
  }
142
161
  const content = fs.readFileSync(filePath, "utf8");
143
- const raw = yaml.load(interpolateEnvVars(content));
162
+ const raw = interpolateEnvVars(yaml.load(content));
144
163
  return parseConfig(raw);
145
164
  }
146
165
 
@@ -149,6 +168,95 @@ import { execa } from "execa";
149
168
  import { createHash } from "crypto";
150
169
  import * as os from "os";
151
170
  import * as path from "path";
171
+
172
+ // src/core/mask.ts
173
+ var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
174
+ function maskValue(value) {
175
+ if (value.length <= 4) return "****";
176
+ return `****${value.slice(-4)}`;
177
+ }
178
+ function maskSensitiveArgs(cmd) {
179
+ let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
180
+ return `${key}=${maskValue(value)}`;
181
+ });
182
+ masked = masked.replace(
183
+ /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
184
+ (_, prefix, password) => `${prefix}${maskValue(password)}`
185
+ );
186
+ masked = masked.replace(
187
+ /^(\S+:backup-auth\s+\S+\s+)(\S+)(\s+)(\S+)/,
188
+ (_, prefix, accessKeyId, sep, secretAccessKey) => `${prefix}${maskValue(accessKeyId)}${sep}${maskValue(secretAccessKey)}`
189
+ );
190
+ return masked;
191
+ }
192
+ function maskSensitiveArgsList(args) {
193
+ const secrets = [];
194
+ const masked = [...args];
195
+ const mark = (index) => {
196
+ const value = masked[index];
197
+ if (value === void 0 || value === "") return;
198
+ secrets.push(value);
199
+ masked[index] = maskValue(value);
200
+ };
201
+ for (let i = 0; i < masked.length; i++) {
202
+ const m = masked[i].match(/^([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(.+)$/i);
203
+ if (m) {
204
+ secrets.push(m[2]);
205
+ masked[i] = `${m[1]}=${maskValue(m[2])}`;
206
+ }
207
+ }
208
+ if (masked[0] === "registry:login") {
209
+ mark(masked.length - 1);
210
+ }
211
+ if (/^\S+:backup-auth$/.test(masked[0] ?? "")) {
212
+ mark(2);
213
+ mark(3);
214
+ }
215
+ return { masked, secrets };
216
+ }
217
+ function maskSecretsInText(text, secrets) {
218
+ let masked = text;
219
+ for (const secret of secrets) {
220
+ if (!secret) continue;
221
+ masked = masked.split(secret).join(maskValue(secret));
222
+ }
223
+ return masked;
224
+ }
225
+ function maskSensitiveData(data) {
226
+ if (data === null || data === void 0) return data;
227
+ if (typeof data === "string") return data;
228
+ if (Array.isArray(data)) return data.map(maskSensitiveData);
229
+ if (typeof data === "object") {
230
+ const result = {};
231
+ for (const [key, value] of Object.entries(data)) {
232
+ if (typeof value === "string" && SENSITIVE_RE.test(key)) {
233
+ result[key] = maskValue(value);
234
+ } else if (typeof value === "object" && value !== null) {
235
+ result[key] = maskSensitiveData(value);
236
+ } else {
237
+ result[key] = value;
238
+ }
239
+ }
240
+ return result;
241
+ }
242
+ return data;
243
+ }
244
+
245
+ // src/core/dokku.ts
246
+ var DokkuCommandError = class extends Error {
247
+ constructor(args, exitCode, stderr) {
248
+ const { masked, secrets } = maskSensitiveArgsList(args);
249
+ const maskedCmd = masked.join(" ");
250
+ const stderrTail = maskSecretsInText(stderr.trim(), secrets);
251
+ super(
252
+ `dokku ${maskedCmd} failed (exit ${exitCode})` + (stderrTail ? `: ${stderrTail}` : "")
253
+ );
254
+ this.args = args;
255
+ this.exitCode = exitCode;
256
+ this.stderr = stderr;
257
+ this.name = "DokkuCommandError";
258
+ }
259
+ };
152
260
  function createRunner(opts = {}) {
153
261
  const log = [];
154
262
  const controlPath = opts.host ? path.join(os.tmpdir(), `dc-${createHash("sha1").update(opts.host).digest("hex").slice(0, 16)}.sock`) : null;
@@ -160,17 +268,17 @@ function createRunner(opts = {}) {
160
268
  async function execDokku(args) {
161
269
  if (opts.host) {
162
270
  try {
163
- const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)]);
164
- return { stdout: String(result.stdout ?? ""), ok: true };
271
+ const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)], { stdin: "ignore" });
272
+ return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
165
273
  } catch (e) {
166
- return { stdout: String(e.stdout ?? ""), ok: false };
274
+ return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
167
275
  }
168
276
  } else {
169
277
  try {
170
- const result = await execa("dokku", args);
171
- return { stdout: String(result.stdout ?? ""), ok: true };
278
+ const result = await execa("dokku", args, { stdin: "ignore" });
279
+ return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
172
280
  } catch (e) {
173
- return { stdout: String(e.stdout ?? ""), ok: false };
281
+ return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
174
282
  }
175
283
  }
176
284
  }
@@ -181,12 +289,20 @@ function createRunner(opts = {}) {
181
289
  log.push(args.map((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
182
290
  return;
183
291
  }
184
- await execDokku(args);
292
+ const { ok, exitCode, stderr } = await execDokku(args);
293
+ if (!ok) {
294
+ throw new DokkuCommandError(args, exitCode, stderr);
295
+ }
185
296
  },
186
297
  async query(...args) {
187
298
  const { stdout } = await execDokku(args);
188
299
  return stdout.trim();
189
300
  },
301
+ async queryStrict(...args) {
302
+ const { stdout, ok, exitCode, stderr } = await execDokku(args);
303
+ if (!ok) throw new DokkuCommandError(args, exitCode, stderr);
304
+ return stdout.trim();
305
+ },
190
306
  async check(...args) {
191
307
  const { ok } = await execDokku(args);
192
308
  return ok;
@@ -194,7 +310,7 @@ function createRunner(opts = {}) {
194
310
  async close() {
195
311
  if (!opts.host || !controlPath) return;
196
312
  try {
197
- await execa("ssh", ["-O", "exit", "-o", `ControlPath=${controlPath}`, `dokku@${opts.host}`]);
313
+ await execa("ssh", ["-O", "exit", "-o", `ControlPath=${controlPath}`, `dokku@${opts.host}`], { stdin: "ignore" });
198
314
  } catch {
199
315
  }
200
316
  }
@@ -215,6 +331,9 @@ function createContext(runner) {
215
331
  }
216
332
  return cache.get(key);
217
333
  },
334
+ queryStrict(...args) {
335
+ return runner.queryStrict(...args);
336
+ },
218
337
  check(...args) {
219
338
  return runner.check(...args);
220
339
  },
@@ -263,6 +382,11 @@ function computeChange(before, after) {
263
382
  }
264
383
  return { before, after, changed: before !== after };
265
384
  }
385
+ function computeAdditiveChange(before, after) {
386
+ const change = computeChange(before ?? {}, after);
387
+ change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
388
+ return change;
389
+ }
266
390
 
267
391
  // src/core/logger.ts
268
392
  import chalk from "chalk";
@@ -275,21 +399,49 @@ function logDone() {
275
399
  function logSkip() {
276
400
  console.log(`... ${chalk.yellow("already configured")}`);
277
401
  }
402
+ function logError(context, message) {
403
+ console.error(chalk.red(`[${context.padEnd(12)}] ERROR: ${message}`));
404
+ }
278
405
  function logWarn(context, message) {
279
406
  console.warn(chalk.yellow(`[${context.padEnd(12)}] WARN: ${message}`));
280
407
  }
281
408
 
282
409
  // src/core/reconcile.ts
283
- async function reconcile(resource, ctx, target, desired) {
284
- if (desired === void 0) return;
410
+ async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
411
+ const canPrune = prune && !!resource.prune && !!resource.removals;
412
+ const pruneFrom = async (current) => {
413
+ const removed = resource.removals(current, desired);
414
+ if (removed.length === 0) return;
415
+ logAction(target, `${resource.key} (prune)`);
416
+ await resource.prune(ctx, target, removed, current, desired);
417
+ logDone();
418
+ };
419
+ if (desired === void 0) {
420
+ if (canPrune) await pruneFrom(await resource.read(ctx, target));
421
+ return;
422
+ }
285
423
  logAction(target, `${resource.key}`);
286
424
  if (resource.forceApply) {
287
425
  await resource.onChange(ctx, target, { before: void 0, after: desired, changed: true });
288
426
  logDone();
427
+ if (canPrune) await pruneFrom(await resource.read(ctx, target));
289
428
  return;
290
429
  }
291
430
  const before = await resource.read(ctx, target);
292
- const change = computeChange(before, desired);
431
+ if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
432
+ if (canPrune) {
433
+ const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
434
+ const pending = resource.removals(before, desired).length > 0;
435
+ if (change2.changed) {
436
+ await resource.onChange(ctx, target, change2, pending);
437
+ logDone();
438
+ } else {
439
+ logSkip();
440
+ }
441
+ await pruneFrom(before);
442
+ return;
443
+ }
444
+ const change = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
293
445
  if (!change.changed) {
294
446
  logSkip();
295
447
  return;
@@ -421,9 +573,73 @@ var Storage = {
421
573
  };
422
574
 
423
575
  // src/resources/properties.ts
576
+ async function isAppDeployed(ctx, target) {
577
+ const raw = await ctx.query("ps:report", target, "--deployed");
578
+ return raw.trim() === "true";
579
+ }
580
+ var NGINX_KEYS = [
581
+ "access-log-format",
582
+ "access-log-path",
583
+ "bind-address-ipv4",
584
+ "bind-address-ipv6",
585
+ "client-body-timeout",
586
+ "client-header-timeout",
587
+ "client-max-body-size",
588
+ "disable-custom-config",
589
+ "error-log-path",
590
+ "hsts",
591
+ "hsts-include-subdomains",
592
+ "hsts-max-age",
593
+ "hsts-preload",
594
+ "keepalive-timeout",
595
+ "lingering-timeout",
596
+ "proxy-buffer-size",
597
+ "proxy-buffering",
598
+ "proxy-buffers",
599
+ "proxy-busy-buffers-size",
600
+ "proxy-connect-timeout",
601
+ "proxy-keepalive",
602
+ "proxy-read-timeout",
603
+ "proxy-send-timeout",
604
+ "send-timeout",
605
+ "underscore-in-headers",
606
+ "x-forwarded-for-value",
607
+ "x-forwarded-port-value",
608
+ "x-forwarded-proto-value",
609
+ "x-forwarded-ssl"
610
+ ];
611
+ var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
612
+ var REGISTRY_KEYS = [
613
+ "auth-servers",
614
+ "image-repo",
615
+ "image-repo-template",
616
+ "push-extra-tags",
617
+ "push-on-release",
618
+ "server"
619
+ ];
620
+ function propertyRemovals(allowed) {
621
+ return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
622
+ }
623
+ function propertyPrune(setCmd, afterPrune = []) {
624
+ return async (ctx, target, removed) => {
625
+ for (const key of removed) await ctx.run(setCmd, target, key);
626
+ for (const cmd of afterPrune) {
627
+ try {
628
+ await ctx.run(cmd, target);
629
+ } catch (err) {
630
+ if (await isAppDeployed(ctx, target)) throw err;
631
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
632
+ }
633
+ }
634
+ };
635
+ }
424
636
  function propertyResource(opts) {
425
637
  return {
426
638
  key: opts.key,
639
+ // Keys only on the server never make the block "changed"; diff shows them as removals.
640
+ additive: true,
641
+ removals: propertyRemovals(opts.settable),
642
+ prune: propertyPrune(opts.setCmd, opts.afterChange),
427
643
  async read(ctx, target) {
428
644
  const raw = await ctx.query(`${opts.namespace}:report`, target);
429
645
  return parseReport(raw, opts.namespace);
@@ -432,13 +648,18 @@ function propertyResource(opts) {
432
648
  const raw = await ctx.query(`${opts.namespace}:report`);
433
649
  return parseBulkReport(raw, opts.namespace);
434
650
  },
435
- async onChange(ctx, target, change) {
651
+ async onChange(ctx, target, change, deferFollowUp = false) {
436
652
  for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
437
653
  await ctx.run(opts.setCmd, target, key, String(value));
438
654
  }
439
- if (opts.afterChange) {
655
+ if (opts.afterChange && !deferFollowUp) {
440
656
  for (const cmd of opts.afterChange) {
441
- await ctx.run(cmd, target);
657
+ try {
658
+ await ctx.run(cmd, target);
659
+ } catch (err) {
660
+ if (await isAppDeployed(ctx, target)) throw err;
661
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
662
+ }
442
663
  }
443
664
  }
444
665
  }
@@ -448,20 +669,28 @@ var Nginx = propertyResource({
448
669
  key: "nginx",
449
670
  namespace: "nginx",
450
671
  setCmd: "nginx:set",
672
+ settable: NGINX_KEYS,
451
673
  afterChange: ["proxy:build-config"]
452
674
  });
453
675
  var Logs = propertyResource({
454
676
  key: "logs",
455
677
  namespace: "logs",
456
- setCmd: "logs:set"
678
+ setCmd: "logs:set",
679
+ settable: LOGS_KEYS
457
680
  });
458
681
  var Registry = propertyResource({
459
682
  key: "registry",
460
683
  namespace: "registry",
461
- setCmd: "registry:set"
684
+ setCmd: "registry:set",
685
+ settable: REGISTRY_KEYS
462
686
  });
463
687
  var Scheduler = {
464
688
  key: "scheduler",
689
+ // A scheduler selected on the app but not declared in the file.
690
+ removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
691
+ prune: async (ctx, target) => {
692
+ await ctx.run("scheduler:set", target, "selected");
693
+ },
465
694
  async read(ctx, target) {
466
695
  const raw = await ctx.query("scheduler:report", target);
467
696
  const report = parseReport(raw, "scheduler");
@@ -502,21 +731,38 @@ var Proxy = {
502
731
  }
503
732
  };
504
733
 
734
+ // src/core/config-json.ts
735
+ function parseConfigJson(raw, what) {
736
+ let parsed;
737
+ try {
738
+ parsed = JSON.parse(raw);
739
+ } catch {
740
+ throw new Error(`${what}: config:export did not return JSON`);
741
+ }
742
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
743
+ throw new Error(`${what}: config:export did not return a JSON object`);
744
+ }
745
+ const result = {};
746
+ for (const [k, v] of Object.entries(parsed)) result[k] = String(v);
747
+ return result;
748
+ }
749
+
505
750
  // src/resources/config.ts
506
751
  var MANAGED_KEYS_VAR = "DOKKU_COMPOSE_MANAGED_KEYS";
752
+ var MISSING = "\0missing";
507
753
  var Config = {
508
754
  key: "env",
755
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && typeof desired === "object" && k in desired)),
509
756
  read: async (ctx, target) => {
510
757
  const managedRaw = await ctx.query("config:get", target, MANAGED_KEYS_VAR);
511
758
  const managedKeys = managedRaw.trim() ? managedRaw.trim().split(",").filter(Boolean) : [];
512
759
  const result = {};
760
+ for (const k of managedKeys) result[k] = MISSING;
513
761
  if (managedKeys.length > 0) {
514
- const raw = await ctx.query("config:export", target, "--format", "shell");
515
- for (const line of raw.split("\n")) {
516
- const match = line.match(/^export\s+(\w+)=['"]?(.*?)['"]?$/);
517
- if (match && managedKeys.includes(match[1])) {
518
- result[match[1]] = match[2];
519
- }
762
+ const raw = await ctx.queryStrict("config:export", "--format", "json", target);
763
+ const current = parseConfigJson(raw, target);
764
+ for (const k of managedKeys) {
765
+ if (k in current) result[k] = current[k];
520
766
  }
521
767
  }
522
768
  return result;
@@ -526,9 +772,12 @@ var Config = {
526
772
  if (removed.length > 0) {
527
773
  await ctx.run("config:unset", "--no-restart", target, ...removed);
528
774
  }
529
- const toSet = { ...added, ...modified };
530
775
  const allDesiredKeys = Object.keys(change.after);
531
- const managedValue = allDesiredKeys.join(",");
776
+ if (allDesiredKeys.length === 0) {
777
+ await ctx.run("config:unset", "--no-restart", target, MANAGED_KEYS_VAR);
778
+ return;
779
+ }
780
+ const toSet = { ...added, ...modified };
532
781
  if (Object.keys(toSet).length > 0 || removed.length > 0) {
533
782
  const pairs = Object.entries(change.after).map(([k, v]) => `${k}=${v}`);
534
783
  await ctx.run(
@@ -536,7 +785,7 @@ var Config = {
536
785
  "--no-restart",
537
786
  target,
538
787
  ...pairs,
539
- `${MANAGED_KEYS_VAR}=${managedValue}`
788
+ `${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
540
789
  );
541
790
  }
542
791
  }
@@ -546,15 +795,17 @@ var Config = {
546
795
  var Certs = {
547
796
  key: "ssl",
548
797
  read: async (ctx, target) => {
549
- const raw = await ctx.query("certs:report", target, "--ssl-enabled");
550
- return raw.trim() === "true";
798
+ const raw = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
799
+ if (raw === "") return void 0;
800
+ return raw === "true";
551
801
  },
552
802
  readAll: async (ctx) => {
553
803
  const raw = await ctx.query("certs:report");
554
804
  const bulk = parseBulkReport(raw, "ssl");
555
805
  const result = /* @__PURE__ */ new Map();
556
806
  for (const [app, report] of bulk) {
557
- result.set(app, report["enabled"] === "true");
807
+ const enabled = report["enabled"];
808
+ result.set(app, enabled === void 0 ? void 0 : enabled === "true");
558
809
  }
559
810
  return result;
560
811
  },
@@ -569,10 +820,17 @@ var Certs = {
569
820
  };
570
821
 
571
822
  // src/resources/builder.ts
823
+ function parseBuildOptions(dockerOptsBuild) {
824
+ const out = [];
825
+ for (const raw of dockerOptsBuild.trim().split(/\s+(?=--\S)/)) {
826
+ const m = raw.match(/^--build-arg\s+([^=\s]+)=?(.*)$/s);
827
+ if (m) out.push({ key: m[1], value: m[2], raw });
828
+ }
829
+ return out;
830
+ }
572
831
  function parseBuildArgs(dockerOptsBuild) {
573
832
  const args = {};
574
- const matches = dockerOptsBuild.matchAll(/--build-arg\s+(\S+?)=(\S+)/g);
575
- for (const m of matches) args[m[1]] = m[2];
833
+ for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
576
834
  return args;
577
835
  }
578
836
  function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, dockerOptsBuild) {
@@ -589,6 +847,19 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
589
847
  }
590
848
  var Builder = {
591
849
  key: "build",
850
+ additive: true,
851
+ // Build args (`--build-arg KEY=...` build docker options) whose KEY is not in `build.args`.
852
+ removals: (current, desired) => Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`),
853
+ // Remove exactly the stale `--build-arg` options; other build options stay.
854
+ prune: async (ctx, target, removed) => {
855
+ const stale = new Set(removed.map((l) => l.slice("args.".length)));
856
+ const report = parseReport(await ctx.query("docker-options:report", target), "docker-options");
857
+ const options = parseBuildOptions(report["build"] ?? "").filter((o) => stale.has(o.key));
858
+ for (const o of options) await ctx.run("docker-options:remove", target, "build", o.raw);
859
+ for (const k of stale) {
860
+ if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
861
+ }
862
+ },
592
863
  async read(ctx, target) {
593
864
  const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
594
865
  ctx.query("builder:report", target),
@@ -660,6 +931,11 @@ function dockerOptsFromReport(report) {
660
931
  }
661
932
  var DockerOptions = {
662
933
  key: "docker_options",
934
+ // Pruned by `up` within a declared block only, so a missing block is not shown.
935
+ removals: (current, desired) => !desired ? [] : PHASES.flatMap((phase) => {
936
+ const wanted = new Set(desired?.[phase] ?? []);
937
+ return (current?.[phase] ?? []).filter((o) => !wanted.has(o)).map((o) => `${phase}: ${o}`);
938
+ }),
663
939
  async read(ctx, target) {
664
940
  const raw = await ctx.query("docker-options:report", target);
665
941
  return dockerOptsFromReport(parseReport(raw, "docker-options"));
@@ -687,6 +963,68 @@ var DockerOptions = {
687
963
  }
688
964
  };
689
965
 
966
+ // src/resources/ps.ts
967
+ var PS_KEYS = ["procfile-path", "restart-policy", "stop-timeout-seconds"];
968
+ function parseScale(raw) {
969
+ const result = {};
970
+ let parsed;
971
+ try {
972
+ parsed = JSON.parse(raw);
973
+ } catch {
974
+ return result;
975
+ }
976
+ if (!Array.isArray(parsed)) return result;
977
+ for (const e of parsed) {
978
+ if (e && typeof e.process_type === "string" && e.quantity !== void 0) {
979
+ result[e.process_type] = String(e.quantity);
980
+ }
981
+ }
982
+ return result;
983
+ }
984
+ function parsePsReport(raw) {
985
+ const result = {};
986
+ let parsed;
987
+ try {
988
+ parsed = JSON.parse(raw);
989
+ } catch {
990
+ return result;
991
+ }
992
+ if (!parsed || typeof parsed !== "object") return result;
993
+ for (const key of PS_KEYS) {
994
+ const v = parsed[key];
995
+ if (v !== void 0 && v !== null && String(v) !== "") result[key] = String(v);
996
+ }
997
+ return result;
998
+ }
999
+ var Scale = {
1000
+ key: "scale",
1001
+ additive: true,
1002
+ async read(ctx, target) {
1003
+ return parseScale(await ctx.query("ps:scale", "--format", "json", target));
1004
+ },
1005
+ async onChange(ctx, target, change) {
1006
+ const pairs = Object.entries({ ...change.added, ...change.modified }).map(([proc, count]) => `${proc}=${count}`);
1007
+ if (pairs.length === 0) return;
1008
+ await ctx.run("ps:scale", "--skip-deploy", target, ...pairs);
1009
+ }
1010
+ };
1011
+ var Ps = {
1012
+ key: "ps",
1013
+ additive: true,
1014
+ removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
1015
+ prune: async (ctx, target, removed) => {
1016
+ for (const key of removed) await ctx.run("ps:set", target, key);
1017
+ },
1018
+ async read(ctx, target) {
1019
+ return parsePsReport(await ctx.queryStrict("ps:report", target, "--format", "json"));
1020
+ },
1021
+ async onChange(ctx, target, change) {
1022
+ for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
1023
+ await ctx.run("ps:set", target, key, String(value));
1024
+ }
1025
+ }
1026
+ };
1027
+
690
1028
  // src/resources/git.ts
691
1029
  var Git = {
692
1030
  key: "git",
@@ -714,7 +1052,33 @@ var Git = {
714
1052
  var Checks = {
715
1053
  key: "checks",
716
1054
  forceApply: true,
717
- read: async () => ({}),
1055
+ // Applying never reads (forceApply); the read only finds settings to report as removed.
1056
+ // Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty.
1057
+ read: async (ctx, target) => {
1058
+ const report = parseReport(await ctx.query("checks:report", target), "checks");
1059
+ const current = {};
1060
+ for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
1061
+ const v = report[reportKey];
1062
+ if (v && v !== "none" && v !== "_all_") current[key] = v;
1063
+ }
1064
+ if (report["wait-to-retire"]) current["wait-to-retire"] = report["wait-to-retire"];
1065
+ return current;
1066
+ },
1067
+ removals: (current, desired) => desired === false ? [] : Object.keys(current || {}).filter((k) => !(desired && k in desired)),
1068
+ prune: async (ctx, target, removed, current, desired) => {
1069
+ const cur = current;
1070
+ const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
1071
+ const declared = new Set(desired && desired !== false ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1072
+ const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
1073
+ const enable = [...stale].filter((t) => !declared.has(t));
1074
+ for (const t of stale) {
1075
+ if (declared.has(t)) {
1076
+ logWarn(target, `checks: '${t}' is declared in the file but also set in a list the file does not declare; left as is. Run 'dokku checks:enable ${target} ${t}' and then 'up' to clear it`);
1077
+ }
1078
+ }
1079
+ if (enable.length > 0) await ctx.run("checks:enable", target, ...enable);
1080
+ if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
1081
+ },
718
1082
  onChange: async (ctx, target, { after }) => {
719
1083
  if (after === false) {
720
1084
  await ctx.run("checks:disable", target);
@@ -755,6 +1119,12 @@ var Networks = {
755
1119
  };
756
1120
  var NetworkProps = {
757
1121
  key: "network",
1122
+ additive: true,
1123
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
1124
+ // Dokku's property names are hyphenated; the schema's are underscored.
1125
+ prune: async (ctx, target, removed) => {
1126
+ for (const key of removed) await ctx.run("network:set", target, key.replaceAll("_", "-"));
1127
+ },
758
1128
  read: async (ctx, target) => {
759
1129
  const raw = await ctx.query("network:report", target);
760
1130
  const report = parseReport(raw, "network");
@@ -801,19 +1171,90 @@ var NetworkProps = {
801
1171
  };
802
1172
 
803
1173
  // src/modules/plugins.ts
1174
+ function parsePluginList(raw) {
1175
+ const result = /* @__PURE__ */ new Map();
1176
+ let parsed;
1177
+ try {
1178
+ parsed = JSON.parse(raw);
1179
+ } catch {
1180
+ throw new Error(`plugin:list --format json did not return JSON (does this Dokku support --format json?): ${raw.slice(0, 200)}`);
1181
+ }
1182
+ if (!Array.isArray(parsed)) {
1183
+ throw new Error(`plugin:list --format json did not return a JSON array: ${raw.slice(0, 200)}`);
1184
+ }
1185
+ for (const p of parsed) {
1186
+ if (p && typeof p.name === "string") {
1187
+ result.set(p.name, {
1188
+ version: typeof p.version === "string" ? p.version : "",
1189
+ committish: typeof p.committish === "string" ? p.committish : ""
1190
+ });
1191
+ }
1192
+ }
1193
+ return result;
1194
+ }
1195
+ var SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
1196
+ function compareVersions(a, b) {
1197
+ const na = a.replace(/^v/, "");
1198
+ const nb = b.replace(/^v/, "");
1199
+ const ma = na.match(SEMVER);
1200
+ const mb = nb.match(SEMVER);
1201
+ if (!ma || !mb) return na === nb ? 0 : void 0;
1202
+ for (let i = 1; i <= 3; i++) {
1203
+ const d = Number(ma[i]) - Number(mb[i]);
1204
+ if (d !== 0) return d < 0 ? -1 : 1;
1205
+ }
1206
+ return 0;
1207
+ }
1208
+ var SHA = /^[0-9a-f]{7,40}$/i;
1209
+ function planPlugins(listOutput, plugins) {
1210
+ const installed = parsePluginList(listOutput);
1211
+ return Object.entries(plugins).map(([name, config]) => {
1212
+ const current = installed.get(name);
1213
+ const desired = config.version;
1214
+ if (current === void 0) return { name, action: "install", desired };
1215
+ const installedVersion = current.version || current.committish.slice(0, 7) || void 0;
1216
+ if (desired === void 0) return { name, action: "ok", installed: installedVersion };
1217
+ if (SHA.test(desired) && current.committish) {
1218
+ if (current.committish.toLowerCase().startsWith(desired.toLowerCase())) {
1219
+ return { name, action: "ok", installed: installedVersion, desired };
1220
+ }
1221
+ }
1222
+ if (!current.version) return { name, action: "unknown", installed: installedVersion, desired };
1223
+ const cmp = compareVersions(current.version, desired);
1224
+ const action = cmp === void 0 ? "unknown" : cmp < 0 ? "update" : cmp > 0 ? "newer" : "ok";
1225
+ return { name, action, installed: installedVersion, desired };
1226
+ });
1227
+ }
804
1228
  async function ensurePlugins(ctx, plugins) {
805
- const listOutput = await ctx.query("plugin:list");
806
- const installedNames = new Set(
807
- listOutput.split("\n").map((line) => line.trim().split(/\s+/)[0]).filter(Boolean)
808
- );
809
- for (const [name, config] of Object.entries(plugins)) {
810
- logAction("plugins", `Installing ${name}`);
811
- if (installedNames.has(name)) {
812
- logSkip();
813
- continue;
1229
+ const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), plugins);
1230
+ for (const plan of plans) {
1231
+ const config = plugins[plan.name];
1232
+ switch (plan.action) {
1233
+ case "install":
1234
+ logAction("plugins", `Installing ${plan.name}`);
1235
+ await ctx.run(
1236
+ "plugin:install",
1237
+ config.url,
1238
+ ...config.version ? ["--committish", config.version] : [],
1239
+ "--name",
1240
+ plan.name
1241
+ );
1242
+ logDone();
1243
+ break;
1244
+ case "update":
1245
+ logAction("plugins", `Updating ${plan.name}`);
1246
+ await ctx.run("plugin:update", plan.name, config.version);
1247
+ logDone();
1248
+ break;
1249
+ case "newer":
1250
+ logWarn("plugins", `${plan.name} is ${plan.installed}, newer than the declared ${plan.desired}; leaving it (never downgrades)`);
1251
+ break;
1252
+ case "unknown":
1253
+ logWarn("plugins", `${plan.name} is ${plan.installed} and the declared ${plan.desired} cannot be compared; leaving it`);
1254
+ break;
1255
+ default:
1256
+ break;
814
1257
  }
815
- await ctx.run("plugin:install", config.url, "--name", name);
816
- logDone();
817
1258
  }
818
1259
  }
819
1260
 
@@ -1133,7 +1574,64 @@ async function ensureGlobalNginx(ctx, nginx) {
1133
1574
  }
1134
1575
 
1135
1576
  // src/commands/up.ts
1136
- async function runUp(ctx, config, appFilter) {
1577
+ var UpFailedError = class extends Error {
1578
+ constructor(failures) {
1579
+ super(
1580
+ `up failed for ${failures.length} app(s): ${failures.map((f) => f.app).join(", ")}`
1581
+ );
1582
+ this.failures = failures;
1583
+ this.name = "UpFailedError";
1584
+ }
1585
+ };
1586
+ var AppStepsError = class extends AggregateError {
1587
+ constructor(errors) {
1588
+ super(errors, errors.map((e) => e instanceof Error ? e.message : String(e)).join("; "));
1589
+ this.name = "AppStepsError";
1590
+ }
1591
+ };
1592
+ async function processApp(ctx, config, app, appConfig, failFast = false, prune = false) {
1593
+ const errors = [];
1594
+ let leftOnServer = 0;
1595
+ const count = prune ? void 0 : (removed) => {
1596
+ leftOnServer += removed.length;
1597
+ };
1598
+ const step = async (fn) => {
1599
+ try {
1600
+ await fn();
1601
+ } catch (error) {
1602
+ if (failFast) throw error;
1603
+ logError(app, error instanceof Error ? error.message : String(error));
1604
+ errors.push(error);
1605
+ }
1606
+ };
1607
+ await reconcile(Apps, ctx, app, true);
1608
+ await step(() => reconcile(Domains, ctx, app, appConfig.domains));
1609
+ await step(() => reconcile(Networks, ctx, app, appConfig.networks));
1610
+ await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
1611
+ await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
1612
+ await step(() => reconcile(Ports, ctx, app, appConfig.ports));
1613
+ if (config.postgres || config.redis) {
1614
+ await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
1615
+ }
1616
+ await step(() => reconcile(Certs, ctx, app, appConfig.ssl));
1617
+ await step(() => reconcile(Storage, ctx, app, appConfig.storage));
1618
+ await step(() => reconcile(Nginx, ctx, app, appConfig.nginx, count, prune));
1619
+ await step(() => reconcile(Checks, ctx, app, appConfig.checks, void 0, prune));
1620
+ await step(() => reconcile(Logs, ctx, app, appConfig.logs, count, prune));
1621
+ await step(() => reconcile(Registry, ctx, app, appConfig.registry, count, prune));
1622
+ await step(() => reconcile(Scheduler, ctx, app, appConfig.scheduler, count, prune));
1623
+ await step(() => reconcile(Ps, ctx, app, appConfig.ps, count, prune));
1624
+ await step(() => reconcile(Scale, ctx, app, appConfig.scale));
1625
+ await step(() => reconcile(Config, ctx, app, appConfig.env || {}));
1626
+ await step(() => reconcile(Builder, ctx, app, appConfig.build, count, prune));
1627
+ await step(() => reconcile(Git, ctx, app, appConfig.git ?? config.git));
1628
+ await step(() => reconcile(DockerOptions, ctx, app, appConfig.docker_options));
1629
+ if (leftOnServer > 0) {
1630
+ logWarn(app, `${leftOnServer} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
1631
+ }
1632
+ if (errors.length > 0) throw new AppStepsError(errors);
1633
+ }
1634
+ async function runUp(ctx, config, appFilter, opts = {}) {
1137
1635
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1138
1636
  await ensureDokkuVersion(ctx, config.dokku?.version);
1139
1637
  if (config.plugins) await ensurePlugins(ctx, config.plugins);
@@ -1146,29 +1644,22 @@ async function runUp(ctx, config, appFilter) {
1146
1644
  if (config.postgres) await ensurePostgres(ctx, config.postgres);
1147
1645
  if (config.redis) await ensureRedis(ctx, config.redis);
1148
1646
  if (config.postgres) await ensurePostgresBackups(ctx, config.postgres);
1647
+ const failures = [];
1149
1648
  for (const app of apps) {
1150
1649
  const appConfig = config.apps[app];
1151
1650
  if (!appConfig) continue;
1152
- await reconcile(Apps, ctx, app, true);
1153
- await reconcile(Domains, ctx, app, appConfig.domains);
1154
- await reconcile(Networks, ctx, app, appConfig.networks);
1155
- await reconcile(NetworkProps, ctx, app, appConfig.network);
1156
- await reconcile(Proxy, ctx, app, appConfig.proxy?.enabled);
1157
- await reconcile(Ports, ctx, app, appConfig.ports);
1158
- if (config.postgres || config.redis) {
1159
- await ensureAppLinks(ctx, app, appConfig.links ?? [], config);
1651
+ try {
1652
+ await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
1653
+ } catch (error) {
1654
+ if (opts.failFast) throw error;
1655
+ if (!(error instanceof AppStepsError)) {
1656
+ logError(app, error instanceof Error ? error.message : String(error));
1657
+ }
1658
+ failures.push({ app, error });
1160
1659
  }
1161
- await reconcile(Certs, ctx, app, appConfig.ssl);
1162
- await reconcile(Storage, ctx, app, appConfig.storage);
1163
- await reconcile(Nginx, ctx, app, appConfig.nginx);
1164
- await reconcile(Checks, ctx, app, appConfig.checks);
1165
- await reconcile(Logs, ctx, app, appConfig.logs);
1166
- await reconcile(Registry, ctx, app, appConfig.registry);
1167
- await reconcile(Scheduler, ctx, app, appConfig.scheduler);
1168
- await reconcile(Config, ctx, app, appConfig.env);
1169
- await reconcile(Builder, ctx, app, appConfig.build);
1170
- await reconcile(Git, ctx, app, appConfig.git ?? config.git);
1171
- await reconcile(DockerOptions, ctx, app, appConfig.docker_options);
1660
+ }
1661
+ if (failures.length > 0) {
1662
+ throw new UpFailedError(failures);
1172
1663
  }
1173
1664
  }
1174
1665
 
@@ -1191,15 +1682,35 @@ async function exportApps(ctx) {
1191
1682
  }
1192
1683
 
1193
1684
  // src/commands/down.ts
1685
+ var DownFailedError = class extends Error {
1686
+ constructor(failures) {
1687
+ super(
1688
+ `down failed for ${failures.length} app(s): ${failures.map((f) => f.app).join(", ")} \u2014 service and network teardown was skipped because app teardown failed`
1689
+ );
1690
+ this.failures = failures;
1691
+ this.name = "DownFailedError";
1692
+ }
1693
+ };
1194
1694
  async function runDown(ctx, config, appFilter, opts) {
1195
1695
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1696
+ const failures = [];
1196
1697
  for (const app of apps) {
1197
1698
  const appConfig = config.apps[app];
1198
1699
  if (!appConfig) continue;
1199
- if (appConfig.links && (config.postgres || config.redis)) {
1200
- await destroyAppLinks(ctx, app, appConfig.links, config);
1700
+ try {
1701
+ if (appConfig.links && (config.postgres || config.redis)) {
1702
+ await destroyAppLinks(ctx, app, appConfig.links, config);
1703
+ }
1704
+ await destroyApp(ctx, app);
1705
+ } catch (error) {
1706
+ if (opts.failFast) throw error;
1707
+ const message = error instanceof Error ? error.message : String(error);
1708
+ logError(app, message);
1709
+ failures.push({ app, error });
1201
1710
  }
1202
- await destroyApp(ctx, app);
1711
+ }
1712
+ if (failures.length > 0) {
1713
+ throw new DownFailedError(failures);
1203
1714
  }
1204
1715
  if (config.postgres) await destroyPostgres(ctx, config.postgres);
1205
1716
  if (config.redis) await destroyRedis(ctx, config.redis);
@@ -1233,6 +1744,8 @@ var CONFIG_RESOURCES = [
1233
1744
  Logs,
1234
1745
  Registry,
1235
1746
  Scheduler,
1747
+ Ps,
1748
+ Scale,
1236
1749
  Config
1237
1750
  ];
1238
1751
  var BUILD_RESOURCES = [
@@ -1289,80 +1802,158 @@ async function runExport(ctx, opts) {
1289
1802
  return config;
1290
1803
  }
1291
1804
 
1292
- // src/core/mask.ts
1293
- var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
1294
- function maskValue(value) {
1295
- if (value.length <= 4) return "****";
1296
- return `****${value.slice(-4)}`;
1805
+ // src/commands/diff.ts
1806
+ import chalk2 from "chalk";
1807
+ function diffSslStatus(desired, current) {
1808
+ if (typeof desired !== "object" || desired === null) return void 0;
1809
+ if (current === void 0) return void 0;
1810
+ if (current === true) return "in-sync";
1811
+ if (current === false || current === null) return "missing";
1812
+ return void 0;
1297
1813
  }
1298
- function maskSensitiveArgs(cmd) {
1299
- let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
1300
- return `${key}=${maskValue(value)}`;
1301
- });
1302
- masked = masked.replace(
1303
- /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
1304
- (_, prefix, password) => `${prefix}${maskValue(password)}`
1305
- );
1306
- return masked;
1814
+ function hasAdditionsOrChanges(change) {
1815
+ const c = change;
1816
+ if (c.added === void 0) return c.changed;
1817
+ const added = Array.isArray(c.added) ? c.added.length : Object.keys(c.added).length;
1818
+ return added > 0 || Object.keys(c.modified ?? {}).length > 0;
1307
1819
  }
1308
- function maskSensitiveData(data) {
1309
- if (data === null || data === void 0) return data;
1310
- if (typeof data === "string") return data;
1311
- if (Array.isArray(data)) return data.map(maskSensitiveData);
1312
- if (typeof data === "object") {
1313
- const result = {};
1314
- for (const [key, value] of Object.entries(data)) {
1315
- if (typeof value === "string" && SENSITIVE_RE.test(key)) {
1316
- result[key] = maskValue(value);
1317
- } else if (typeof value === "object" && value !== null) {
1318
- result[key] = maskSensitiveData(value);
1319
- } else {
1320
- result[key] = value;
1321
- }
1322
- }
1323
- return result;
1324
- }
1325
- return data;
1820
+ function isEmptyValue(value) {
1821
+ return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1326
1822
  }
1327
-
1328
- // src/commands/diff.ts
1329
- import chalk2 from "chalk";
1330
1823
  async function computeDiff(ctx, config) {
1331
- const result = { apps: {}, services: {}, inSync: true };
1824
+ const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
1825
+ const appNames = Object.keys(config.apps);
1332
1826
  await ensureDokkuVersion(ctx, config.dokku?.version);
1827
+ const appExists = /* @__PURE__ */ new Map();
1828
+ await Promise.all(
1829
+ appNames.map(async (app) => {
1830
+ appExists.set(app, await ctx.check("apps:exists", app));
1831
+ })
1832
+ );
1833
+ const existingAppNames = appNames.filter((app) => appExists.get(app));
1333
1834
  const prefetched = /* @__PURE__ */ new Map();
1334
1835
  await Promise.all(
1335
1836
  ALL_APP_RESOURCES.filter((r) => !r.forceApply && !r.key.startsWith("_") && r.readAll).map(async (r) => {
1336
- prefetched.set(r.key, await r.readAll(ctx));
1837
+ try {
1838
+ const bulk = await r.readAll(ctx);
1839
+ const unreadable = existingAppNames.length > 0 && bulk.size === 0;
1840
+ prefetched.set(r.key, unreadable ? null : bulk);
1841
+ } catch {
1842
+ prefetched.set(r.key, null);
1843
+ }
1337
1844
  })
1338
1845
  );
1339
1846
  for (const [app, appConfig] of Object.entries(config.apps)) {
1340
1847
  const appDiff = {};
1341
1848
  for (const resource of ALL_APP_RESOURCES) {
1342
1849
  if (resource.key.startsWith("_")) continue;
1343
- if (resource.forceApply) continue;
1344
1850
  let desired;
1345
1851
  if (resource.key === "proxy") {
1346
1852
  desired = appConfig.proxy?.enabled;
1347
1853
  } else {
1348
1854
  desired = appConfig[resource.key];
1349
1855
  }
1350
- if (desired === void 0) continue;
1856
+ const checkRemovalsOnly = desired === void 0;
1857
+ if (checkRemovalsOnly && (!resource.removals || !appExists.get(app))) continue;
1858
+ if (resource.forceApply && !resource.removals) continue;
1859
+ if (!appExists.get(app)) {
1860
+ appDiff[resource.key] = { status: "missing", desired, current: void 0 };
1861
+ result.inSync = false;
1862
+ continue;
1863
+ }
1351
1864
  const bulk = prefetched.get(resource.key);
1352
- const current = bulk ? bulk.get(app) : await resource.read(ctx, app);
1353
- const change = computeChange(current, desired);
1354
- if (!change.changed) {
1865
+ let current;
1866
+ let bulkUnreadable = false;
1867
+ if (checkRemovalsOnly) {
1868
+ try {
1869
+ current = bulk ? bulk.get(app) : await resource.read(ctx, app);
1870
+ if (bulk === null || current === void 0) current = await resource.read(ctx, app);
1871
+ } catch (err) {
1872
+ result.readWarnings.push(`could not read '${resource.key}' for app '${app}' to look for removed settings: ${err instanceof Error ? err.message : String(err)}`);
1873
+ continue;
1874
+ }
1875
+ const removed2 = resource.removals(current, void 0);
1876
+ if (removed2.length > 0) {
1877
+ appDiff[resource.key] = { status: "extra", current, removed: removed2 };
1878
+ result.inSync = false;
1879
+ }
1880
+ continue;
1881
+ }
1882
+ if (bulk === null) {
1883
+ bulkUnreadable = true;
1884
+ current = await resource.read(ctx, app);
1885
+ } else if (bulk) {
1886
+ current = bulk.get(app);
1887
+ if (resource.key === "ssl" && current === void 0) {
1888
+ current = await resource.read(ctx, app);
1889
+ if (current === void 0) bulkUnreadable = true;
1890
+ }
1891
+ } else {
1892
+ current = await resource.read(ctx, app);
1893
+ }
1894
+ const sslStatus = resource.key === "ssl" ? diffSslStatus(desired, current) : void 0;
1895
+ if (sslStatus) {
1896
+ appDiff[resource.key] = { status: sslStatus, desired, current };
1897
+ if (sslStatus !== "in-sync") result.inSync = false;
1898
+ continue;
1899
+ }
1900
+ let removed = resource.removals ? resource.removals(current, desired) : [];
1901
+ if (resource.forceApply) {
1902
+ if (removed.length > 0) {
1903
+ appDiff[resource.key] = { status: "extra", desired, current, removed };
1904
+ result.inSync = false;
1905
+ }
1906
+ continue;
1907
+ }
1908
+ const change = resource.additive ? computeAdditiveChange(current, desired) : computeChange(current, desired);
1909
+ if (!resource.removals && Array.isArray(current) && "removed" in change) {
1910
+ removed = change.removed;
1911
+ }
1912
+ if (!change.changed && removed.length === 0) {
1355
1913
  appDiff[resource.key] = { status: "in-sync", desired, current };
1356
- } else if (current === null || current === void 0 || Array.isArray(current) && current.length === 0 || typeof current === "object" && Object.keys(current).length === 0) {
1914
+ } else if (removed.length > 0 && !hasAdditionsOrChanges(change)) {
1915
+ appDiff[resource.key] = { status: "extra", desired, current, removed };
1916
+ result.inSync = false;
1917
+ } else if (bulkUnreadable && isEmptyValue(current)) {
1918
+ appDiff[resource.key] = { status: "unknown", desired, current };
1919
+ result.inSync = false;
1920
+ const msg = `could not read '${resource.key}' for app '${app}' (bulk report was empty and the per-app read returned nothing)`;
1921
+ result.readWarnings.push(msg);
1922
+ } else if (isEmptyValue(current)) {
1357
1923
  appDiff[resource.key] = { status: "missing", desired, current };
1358
1924
  result.inSync = false;
1359
1925
  } else {
1360
1926
  appDiff[resource.key] = { status: "changed", desired, current };
1361
1927
  result.inSync = false;
1362
1928
  }
1929
+ if (removed.length > 0 && appDiff[resource.key].status !== "extra") {
1930
+ appDiff[resource.key].removed = removed;
1931
+ }
1932
+ }
1933
+ if (appConfig.links === void 0 && appExists.get(app)) {
1934
+ const linked = [];
1935
+ for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
1936
+ for (const svc of Object.keys(services ?? {})) {
1937
+ if (await ctx.check(`${plugin}:linked`, svc, app)) linked.push(svc);
1938
+ }
1939
+ }
1940
+ if (linked.length > 0) {
1941
+ appDiff["links"] = { status: "extra", current: linked, removed: linked, note: "no links: in the file; up will unlink these" };
1942
+ result.inSync = false;
1943
+ }
1363
1944
  }
1364
1945
  result.apps[app] = appDiff;
1365
1946
  }
1947
+ if (config.plugins) {
1948
+ const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), config.plugins);
1949
+ for (const plan of plans) {
1950
+ if (plan.action === "ok") continue;
1951
+ const status = plan.action === "install" ? "missing" : plan.action === "unknown" ? "unknown" : "changed";
1952
+ const note = plan.action === "newer" ? "installed is newer; up warns and leaves it" : plan.action === "unknown" ? "versions cannot be compared; up leaves it" : void 0;
1953
+ result.plugins[plan.name] = { status, desired: plan.desired, current: plan.installed, note };
1954
+ if (plan.action !== "unknown") result.inSync = false;
1955
+ }
1956
+ }
1366
1957
  for (const svc of Object.keys(config.postgres ?? {})) {
1367
1958
  const exists = await ctx.check("postgres:exists", svc);
1368
1959
  result.services[svc] = { status: exists ? "in-sync" : "missing" };
@@ -1376,14 +1967,16 @@ async function computeDiff(ctx, config) {
1376
1967
  return result;
1377
1968
  }
1378
1969
  function maskDiffResult(diff) {
1379
- const masked = { apps: {}, services: diff.services, inSync: diff.inSync };
1970
+ const masked = { apps: {}, services: diff.services, plugins: diff.plugins, inSync: diff.inSync, readWarnings: diff.readWarnings };
1380
1971
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1381
1972
  const maskedAppDiff = {};
1382
1973
  for (const [feature, d] of Object.entries(appDiff)) {
1383
1974
  maskedAppDiff[feature] = {
1384
1975
  status: d.status,
1385
1976
  desired: maskSensitiveData(d.desired),
1386
- current: maskSensitiveData(d.current)
1977
+ current: maskSensitiveData(d.current),
1978
+ ...d.removed ? { removed: d.removed } : {},
1979
+ ...d.note ? { note: d.note } : {}
1387
1980
  };
1388
1981
  }
1389
1982
  masked.apps[app] = maskedAppDiff;
@@ -1400,18 +1993,29 @@ function formatSummary(diff) {
1400
1993
  } else {
1401
1994
  lines.push(` app: ${chalk2.bold(app)}`);
1402
1995
  for (const [feature, d] of changes) {
1403
- const sym = d.status === "missing" ? chalk2.green("+") : chalk2.yellow("~");
1996
+ const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : d.status === "extra" ? chalk2.red("-") : chalk2.yellow("~");
1404
1997
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1405
1998
  }
1406
1999
  }
1407
2000
  }
2001
+ for (const [name, d] of Object.entries(diff.plugins)) {
2002
+ const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
2003
+ const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
2004
+ lines.push(` plugin: ${name}`);
2005
+ lines.push(` ${sym} version: ${detail}${d.note ? ` (${d.note})` : ""}`);
2006
+ }
1408
2007
  for (const [svc, d] of Object.entries(diff.services)) {
1409
2008
  if (d.status === "missing") {
1410
2009
  lines.push(` services:`);
1411
2010
  lines.push(` ${chalk2.green("+")} ${svc}: not provisioned`);
1412
2011
  }
1413
2012
  }
1414
- 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;
2013
+ const total = Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length;
2014
+ if (diff.readWarnings.length > 0) {
2015
+ lines.push("");
2016
+ lines.push(chalk2.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
2017
+ for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
2018
+ }
1415
2019
  lines.push("");
1416
2020
  if (total === 0) {
1417
2021
  lines.push(chalk2.green(" Everything in sync."));
@@ -1422,11 +2026,21 @@ function formatSummary(diff) {
1422
2026
  return lines.join("\n");
1423
2027
  }
1424
2028
  function formatFeatureSummary(d) {
2029
+ const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
2030
+ if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
2031
+ const base = formatChange(d);
2032
+ return gone ? `${base}; ${gone}` : base;
2033
+ }
2034
+ function formatChange(d) {
2035
+ if (d.status === "unknown") return "(could not read server state \u2014 not reported as missing)";
1425
2036
  if (d.status === "missing") return "(not set on server)";
1426
2037
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
1427
2038
  return `${d.current.length} \u2192 ${d.desired.length} items`;
1428
2039
  }
1429
- return `${JSON.stringify(d.current)} \u2192 ${JSON.stringify(d.desired)}`;
2040
+ return `${JSON.stringify(d.current, showUnset)} \u2192 ${JSON.stringify(d.desired, showUnset)}`;
2041
+ }
2042
+ function showUnset(_key, value) {
2043
+ return value === "\0missing" ? "(unset)" : value;
1430
2044
  }
1431
2045
  function formatVerbose(diff) {
1432
2046
  const lines = [""];
@@ -1435,18 +2049,38 @@ function formatVerbose(diff) {
1435
2049
  if (changes.length === 0) continue;
1436
2050
  for (const [feature, d] of changes) {
1437
2051
  lines.push(`@@ app: ${app} / ${feature} @@`);
1438
- const currentLines = d.current !== void 0 ? JSON.stringify(d.current, null, 2).split("\n") : [];
1439
- const desiredLines = JSON.stringify(d.desired, null, 2).split("\n");
2052
+ if (d.status === "unknown") {
2053
+ lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
2054
+ continue;
2055
+ }
2056
+ if (d.status === "extra") {
2057
+ for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r}`));
2058
+ if (d.note) lines.push(` (${d.note})`);
2059
+ continue;
2060
+ }
2061
+ const currentLines = d.current !== void 0 ? JSON.stringify(d.current, showUnset, 2).split("\n") : [];
2062
+ const desiredLines = JSON.stringify(d.desired, showUnset, 2).split("\n");
1440
2063
  for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
1441
2064
  for (const line of desiredLines) lines.push(chalk2.green(`+ ${line}`));
2065
+ for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r} (not in the file)`));
1442
2066
  }
1443
2067
  }
2068
+ for (const [name, d] of Object.entries(diff.plugins)) {
2069
+ lines.push(`@@ plugin: ${name} @@`);
2070
+ if (d.current !== void 0) lines.push(chalk2.red(`- ${d.current}`));
2071
+ lines.push(chalk2.green(`+ ${d.desired ?? "(any version)"}`) + (d.note ? ` (${d.note})` : ""));
2072
+ }
1444
2073
  for (const [svc, d] of Object.entries(diff.services)) {
1445
2074
  if (d.status === "missing") {
1446
2075
  lines.push(`@@ services @@`);
1447
2076
  lines.push(chalk2.green(`+ ${svc}`));
1448
2077
  }
1449
2078
  }
2079
+ if (diff.readWarnings.length > 0) {
2080
+ lines.push("");
2081
+ lines.push(chalk2.red("Could not read some server state \u2014 treated as unknown, not missing:"));
2082
+ for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
2083
+ }
1450
2084
  lines.push("");
1451
2085
  return lines.join("\n");
1452
2086
  }
@@ -1502,6 +2136,7 @@ function validate(filePath) {
1502
2136
  }
1503
2137
 
1504
2138
  // src/index.ts
2139
+ import chalk3 from "chalk";
1505
2140
  var require2 = createRequire(import.meta.url);
1506
2141
  var { version } = require2("../package.json");
1507
2142
  var program = new Command().name("dokku-compose").version(version);
@@ -1511,12 +2146,12 @@ function makeRunner(opts) {
1511
2146
  dryRun: opts.dryRun ?? false
1512
2147
  });
1513
2148
  }
1514
- program.command("up [apps...]").description("Create/update apps and services to match config").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--dry-run", "Print commands without executing").option("--sensitive", "Show sensitive values in dry-run output (masked by default)").option("--fail-fast", "Stop on first error").action(async (apps, opts) => {
2149
+ program.command("up [apps...]").description("Create/update apps and services to match config").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--dry-run", "Print commands without executing").option("--sensitive", "Show sensitive values in dry-run output (masked by default)").option("--fail-fast", "Stop on first error").option("--prune", "Also reset settings the file no longer declares (read `diff` first)").action(async (apps, opts) => {
1515
2150
  const config = loadConfig(opts.file);
1516
2151
  const runner = makeRunner(opts);
1517
2152
  const ctx = createContext(runner);
1518
2153
  try {
1519
- await runUp(ctx, config, apps);
2154
+ await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
1520
2155
  if (opts.dryRun) {
1521
2156
  console.log("\n# Commands that would run:");
1522
2157
  for (const cmd of runner.dryRunLog) {
@@ -1524,11 +2159,15 @@ program.command("up [apps...]").description("Create/update apps and services to
1524
2159
  console.log(`dokku ${line}`);
1525
2160
  }
1526
2161
  }
2162
+ } catch (error) {
2163
+ console.error(chalk3.red(`
2164
+ ${error.message}`));
2165
+ process.exitCode = 1;
1527
2166
  } finally {
1528
2167
  await ctx.close();
1529
2168
  }
1530
2169
  });
1531
- program.command("down [apps...]").description("Destroy apps and services (requires --force)").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--force", "Required to destroy apps").action(async (apps, opts) => {
2170
+ program.command("down [apps...]").description("Destroy apps and services (requires --force)").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--force", "Required to destroy apps").option("--fail-fast", "Stop on first error").action(async (apps, opts) => {
1532
2171
  if (!opts.force) {
1533
2172
  console.error("--force required");
1534
2173
  process.exit(1);
@@ -1537,7 +2176,11 @@ program.command("down [apps...]").description("Destroy apps and services (requir
1537
2176
  const runner = makeRunner({});
1538
2177
  const ctx = createContext(runner);
1539
2178
  try {
1540
- await runDown(ctx, config, apps, { force: true });
2179
+ await runDown(ctx, config, apps, { force: true, failFast: opts.failFast ?? false });
2180
+ } catch (error) {
2181
+ console.error(chalk3.red(`
2182
+ ${error.message}`));
2183
+ process.exitCode = 1;
1541
2184
  } finally {
1542
2185
  await ctx.close();
1543
2186
  }
@@ -1586,7 +2229,7 @@ program.command("diff").description("Show what is out of sync between config and
1586
2229
  const diff = opts.sensitive ? rawDiff : maskDiffResult(rawDiff);
1587
2230
  const output = opts.verbose ? formatVerbose(diff) : formatSummary(diff);
1588
2231
  process.stdout.write(output);
1589
- process.exit(rawDiff.inSync ? 0 : 1);
2232
+ process.exitCode = rawDiff.inSync ? 0 : 1;
1590
2233
  } finally {
1591
2234
  await ctx.close();
1592
2235
  }