dokku-compose 0.12.0 → 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 +42 -6
  2. package/dist/index.js +529 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -101,9 +101,32 @@ Running `up` again produces no changes — every step checks current state befor
101
101
  | `--dry-run` | Print commands without executing |
102
102
  | `--sensitive` | Show sensitive values in output (masked by default) |
103
103
  | `--fail-fast` | Stop on first error (default: continue to next app) |
104
+ | `--prune` | `up` only: also reset settings the file no longer declares (read `diff` first) |
104
105
  | `--remove-orphans` | Destroy services and networks not in config |
105
106
  | `--verbose` | Show git-style +/- diff (diff command only) |
106
107
 
108
+ ### Settings removed from the file
109
+
110
+ `diff` marks with a red `-` every setting the server still has that the file no longer declares
111
+ (managed env keys, nginx, logs, registry, scheduler, checks, `ps` and network properties, build
112
+ args, and entries missing from a declared list), including when the whole block is gone. Only
113
+ settable keys count, never read-only report lines, and a `-` counts as drift. It also warns when
114
+ an app has no `links:` but has services linked on the server, because `up` unlinks them.
115
+
116
+ `up` always unsets env keys removed from the file. For every other removal it leaves the setting in place and prints a one-line count per app of the leftovers it noticed in the blocks you declare; it does not look at removed whole blocks or checks. Run `dokku-compose diff` for the full list.
117
+
118
+ **`up --prune`** resets those leftovers. Read `diff` first: every `-` it shows for the resources below is what `--prune` will reset, and there is no prompt (`diff` is the preview, the flag is the consent). Add `--dry-run` to print the reset commands without running them.
119
+
120
+ | Resource | Reset command |
121
+ |----------|---------------|
122
+ | `nginx`, `logs`, `registry`, `ps` | `<plugin>:set <app> <key>` with no value (Dokku's default); nginx then rebuilds the proxy config |
123
+ | `scheduler` | `scheduler:set <app> selected` |
124
+ | `network` properties | `network:set <app> <key>` with no value (`attach_post_create` becomes `attach-post-create`, and so on) |
125
+ | `checks` | `checks:enable <app> <types>` for the process types of a removed `disabled`/`skipped` list that the file declares in no list (a type the file does declare is left, with a warning naming the command to run by hand), `checks:set <app> wait-to-retire` |
126
+ | `build.args` | `docker-options:remove <app> build "--build-arg KEY=VALUE"` for each stale key, by the exact option text (every occurrence); other build options stay |
127
+
128
+ It covers a block that is gone from the file as well as keys removed from a declared block, and it reads the app once more for a block the file no longer has. `--prune` never destroys an app, service or network, never uninstalls a plugin, never changes `scale`, and does not clear whole `domains`, `ports`, `storage` or `networks` lists because their key is missing (those already prune inside a block the file declares). `proxy` is not a removal. `networks:` owns `attach-post-deploy`: `--prune` does not touch it, and a missing `networks:` key does not clear it (that could detach a running app from its network on the next deploy); `networks: []` does. After `up --prune`, `diff` shows no `-` for the resources above, except for checks that were skipped with a warning (their `-` lines stay).
129
+
107
130
  ### Errors
108
131
 
109
132
  A failing Dokku command (mutation) is never reported as a success. On `up` and `down`, a
@@ -123,7 +146,7 @@ All features are idempotent — running `up` twice produces no changes.
123
146
  |---------|-------------|-----------|
124
147
  | Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) |
125
148
  | Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) |
126
- | Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) |
149
+ | Environment Variables | Set config vars per app or globally, with full convergence (keys removed from the YAML are unset) | [config](docs/reference/config.md) |
127
150
  | Build | Dockerfile path, build context, app.json, build args | [builder](docs/reference/builder.md) |
128
151
  | Docker Options | Custom Docker options per phase (build/deploy/run) | [docker_options](docs/reference/docker_options.md) |
129
152
  | Networks | Create shared Docker networks, attach to apps | [network](docs/reference/network.md) |
@@ -131,16 +154,29 @@ All features are idempotent — running `up` twice produces no changes.
131
154
  | Port Mappings | Map external ports to container ports | [ports](docs/reference/ports.md) |
132
155
  | SSL Certificates | Add or remove SSL certs | [certs](docs/reference/certs.md) |
133
156
  | Proxy | Enable/disable proxy, select implementation | [proxy](docs/reference/proxy.md) |
134
- | Storage | Persistent bind mounts with full convergence | [storage](docs/reference/storage.md) |
157
+ | Storage | Persistent bind mounts with full convergence (keys removed from the YAML are unset) | [storage](docs/reference/storage.md) |
135
158
  | Nginx | Set any nginx property per app or globally | [nginx](docs/reference/nginx.md) |
136
159
  | Zero-Downtime Checks | Configure deploy checks, disable per process type | [checks](docs/reference/checks.md) |
137
160
  | Log Management | Log retention and vector sink configuration | [logs](docs/reference/logs.md) |
138
- | Plugins | Install Dokku plugins declaratively | [plugins](docs/reference/plugins.md) |
161
+ | Plugins | Install Dokku plugins at pinned versions, updating forward only | [plugins](docs/reference/plugins.md) |
162
+ | Process Scale & ps | Declare process counts (`ps:scale`) and ps properties (`ps:set`) | [scale](docs/reference/scale.md) |
139
163
  | Docker Auth | Authenticate the host docker daemon to private registries (e.g. ghcr.io) for `git:from-image` flows | [docker_auth](docs/reference/docker_auth.md) |
140
164
  | Postgres | Postgres services with optional S3 backups | [postgres](docs/reference/postgres.md) |
141
165
  | Redis | Redis service instances | [redis](docs/reference/redis.md) |
142
166
  | Service Links | Link postgres/redis services to apps | [plugins](docs/reference/plugins.md#linking-services-to-apps-appsapplinks) |
143
167
 
168
+ ## Variable interpolation
169
+
170
+ `${VAR}` in a string value is replaced with the environment variable `VAR`. The file is parsed as YAML first and substituted afterwards, value by value, so a secret can contain any character (`"`, `'`, `#`, `\`, `:`, newlines) and arrives in the value unchanged; it can never change the file's structure. Keys and comments are never substituted. Every unset or empty variable is collected and reported together:
171
+
172
+ ```
173
+ Unset environment variable(s) referenced in config: R2_HOST, GITHUB_TOKEN
174
+ ```
175
+
176
+ To write a literal `${`, use `$${`: `"$${NOT_A_VAR}"` loads as `${NOT_A_VAR}`.
177
+
178
+ Because substitution happens after parsing, a value that is a bare `${VAR}` is always a string, and a variable cannot inject YAML (extra keys, lists, quoting). Earlier versions substituted into the raw text, so anything that relied on that must now be written in the file.
179
+
144
180
  ## Execution Modes
145
181
 
146
182
  ```bash
@@ -197,7 +233,7 @@ dokku-compose/
197
233
  │ │ └── validate.ts # validate command (offline)
198
234
  │ └── tests/
199
235
  │ ├── fixtures/ # Test YAML configs
200
- │ └── *.test.ts # Unit tests per module
236
+ │ └── *.test.ts # Unit tests, beside src/modules and src/resources
201
237
  └── dokku-compose.yml.example
202
238
  ```
203
239
 
@@ -215,8 +251,8 @@ bun install
215
251
  # Run all tests
216
252
  bun test
217
253
 
218
- # Run a specific module's tests
219
- bun test src/modules/postgres.test.ts
254
+ # Run a specific file's tests
255
+ bun test src/resources/config.test.ts
220
256
  ```
221
257
 
222
258
  Tests use [Bun's test runner](https://bun.sh/docs/cli/test) with a mocked `Runner` — no real Dokku server needed.
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
 
@@ -279,6 +298,11 @@ function createRunner(opts = {}) {
279
298
  const { stdout } = await execDokku(args);
280
299
  return stdout.trim();
281
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
+ },
282
306
  async check(...args) {
283
307
  const { ok } = await execDokku(args);
284
308
  return ok;
@@ -307,6 +331,9 @@ function createContext(runner) {
307
331
  }
308
332
  return cache.get(key);
309
333
  },
334
+ queryStrict(...args) {
335
+ return runner.queryStrict(...args);
336
+ },
310
337
  check(...args) {
311
338
  return runner.check(...args);
312
339
  },
@@ -355,6 +382,11 @@ function computeChange(before, after) {
355
382
  }
356
383
  return { before, after, changed: before !== after };
357
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
+ }
358
390
 
359
391
  // src/core/logger.ts
360
392
  import chalk from "chalk";
@@ -375,16 +407,41 @@ function logWarn(context, message) {
375
407
  }
376
408
 
377
409
  // src/core/reconcile.ts
378
- async function reconcile(resource, ctx, target, desired) {
379
- 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
+ }
380
423
  logAction(target, `${resource.key}`);
381
424
  if (resource.forceApply) {
382
425
  await resource.onChange(ctx, target, { before: void 0, after: desired, changed: true });
383
426
  logDone();
427
+ if (canPrune) await pruneFrom(await resource.read(ctx, target));
384
428
  return;
385
429
  }
386
430
  const before = await resource.read(ctx, target);
387
- 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);
388
445
  if (!change.changed) {
389
446
  logSkip();
390
447
  return;
@@ -520,9 +577,69 @@ async function isAppDeployed(ctx, target) {
520
577
  const raw = await ctx.query("ps:report", target, "--deployed");
521
578
  return raw.trim() === "true";
522
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
+ }
523
636
  function propertyResource(opts) {
524
637
  return {
525
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),
526
643
  async read(ctx, target) {
527
644
  const raw = await ctx.query(`${opts.namespace}:report`, target);
528
645
  return parseReport(raw, opts.namespace);
@@ -531,11 +648,11 @@ function propertyResource(opts) {
531
648
  const raw = await ctx.query(`${opts.namespace}:report`);
532
649
  return parseBulkReport(raw, opts.namespace);
533
650
  },
534
- async onChange(ctx, target, change) {
651
+ async onChange(ctx, target, change, deferFollowUp = false) {
535
652
  for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
536
653
  await ctx.run(opts.setCmd, target, key, String(value));
537
654
  }
538
- if (opts.afterChange) {
655
+ if (opts.afterChange && !deferFollowUp) {
539
656
  for (const cmd of opts.afterChange) {
540
657
  try {
541
658
  await ctx.run(cmd, target);
@@ -552,20 +669,28 @@ var Nginx = propertyResource({
552
669
  key: "nginx",
553
670
  namespace: "nginx",
554
671
  setCmd: "nginx:set",
672
+ settable: NGINX_KEYS,
555
673
  afterChange: ["proxy:build-config"]
556
674
  });
557
675
  var Logs = propertyResource({
558
676
  key: "logs",
559
677
  namespace: "logs",
560
- setCmd: "logs:set"
678
+ setCmd: "logs:set",
679
+ settable: LOGS_KEYS
561
680
  });
562
681
  var Registry = propertyResource({
563
682
  key: "registry",
564
683
  namespace: "registry",
565
- setCmd: "registry:set"
684
+ setCmd: "registry:set",
685
+ settable: REGISTRY_KEYS
566
686
  });
567
687
  var Scheduler = {
568
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
+ },
569
694
  async read(ctx, target) {
570
695
  const raw = await ctx.query("scheduler:report", target);
571
696
  const report = parseReport(raw, "scheduler");
@@ -606,21 +731,38 @@ var Proxy = {
606
731
  }
607
732
  };
608
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
+
609
750
  // src/resources/config.ts
610
751
  var MANAGED_KEYS_VAR = "DOKKU_COMPOSE_MANAGED_KEYS";
752
+ var MISSING = "\0missing";
611
753
  var Config = {
612
754
  key: "env",
755
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && typeof desired === "object" && k in desired)),
613
756
  read: async (ctx, target) => {
614
757
  const managedRaw = await ctx.query("config:get", target, MANAGED_KEYS_VAR);
615
758
  const managedKeys = managedRaw.trim() ? managedRaw.trim().split(",").filter(Boolean) : [];
616
759
  const result = {};
760
+ for (const k of managedKeys) result[k] = MISSING;
617
761
  if (managedKeys.length > 0) {
618
- const raw = await ctx.query("config:export", target, "--format", "shell");
619
- for (const line of raw.split("\n")) {
620
- const match = line.match(/^export\s+(\w+)=['"]?(.*?)['"]?$/);
621
- if (match && managedKeys.includes(match[1])) {
622
- result[match[1]] = match[2];
623
- }
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];
624
766
  }
625
767
  }
626
768
  return result;
@@ -630,9 +772,12 @@ var Config = {
630
772
  if (removed.length > 0) {
631
773
  await ctx.run("config:unset", "--no-restart", target, ...removed);
632
774
  }
633
- const toSet = { ...added, ...modified };
634
775
  const allDesiredKeys = Object.keys(change.after);
635
- 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 };
636
781
  if (Object.keys(toSet).length > 0 || removed.length > 0) {
637
782
  const pairs = Object.entries(change.after).map(([k, v]) => `${k}=${v}`);
638
783
  await ctx.run(
@@ -640,7 +785,7 @@ var Config = {
640
785
  "--no-restart",
641
786
  target,
642
787
  ...pairs,
643
- `${MANAGED_KEYS_VAR}=${managedValue}`
788
+ `${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
644
789
  );
645
790
  }
646
791
  }
@@ -675,10 +820,17 @@ var Certs = {
675
820
  };
676
821
 
677
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
+ }
678
831
  function parseBuildArgs(dockerOptsBuild) {
679
832
  const args = {};
680
- const matches = dockerOptsBuild.matchAll(/--build-arg\s+(\S+?)=(\S+)/g);
681
- for (const m of matches) args[m[1]] = m[2];
833
+ for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
682
834
  return args;
683
835
  }
684
836
  function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, dockerOptsBuild) {
@@ -695,6 +847,19 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
695
847
  }
696
848
  var Builder = {
697
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
+ },
698
863
  async read(ctx, target) {
699
864
  const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
700
865
  ctx.query("builder:report", target),
@@ -766,6 +931,11 @@ function dockerOptsFromReport(report) {
766
931
  }
767
932
  var DockerOptions = {
768
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
+ }),
769
939
  async read(ctx, target) {
770
940
  const raw = await ctx.query("docker-options:report", target);
771
941
  return dockerOptsFromReport(parseReport(raw, "docker-options"));
@@ -793,6 +963,68 @@ var DockerOptions = {
793
963
  }
794
964
  };
795
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
+
796
1028
  // src/resources/git.ts
797
1029
  var Git = {
798
1030
  key: "git",
@@ -820,7 +1052,33 @@ var Git = {
820
1052
  var Checks = {
821
1053
  key: "checks",
822
1054
  forceApply: true,
823
- 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
+ },
824
1082
  onChange: async (ctx, target, { after }) => {
825
1083
  if (after === false) {
826
1084
  await ctx.run("checks:disable", target);
@@ -861,6 +1119,12 @@ var Networks = {
861
1119
  };
862
1120
  var NetworkProps = {
863
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
+ },
864
1128
  read: async (ctx, target) => {
865
1129
  const raw = await ctx.query("network:report", target);
866
1130
  const report = parseReport(raw, "network");
@@ -907,19 +1171,90 @@ var NetworkProps = {
907
1171
  };
908
1172
 
909
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
+ }
910
1228
  async function ensurePlugins(ctx, plugins) {
911
- const listOutput = await ctx.query("plugin:list");
912
- const installedNames = new Set(
913
- listOutput.split("\n").map((line) => line.trim().split(/\s+/)[0]).filter(Boolean)
914
- );
915
- for (const [name, config] of Object.entries(plugins)) {
916
- logAction("plugins", `Installing ${name}`);
917
- if (installedNames.has(name)) {
918
- logSkip();
919
- 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;
920
1257
  }
921
- await ctx.run("plugin:install", config.url, "--name", name);
922
- logDone();
923
1258
  }
924
1259
  }
925
1260
 
@@ -1248,27 +1583,53 @@ var UpFailedError = class extends Error {
1248
1583
  this.name = "UpFailedError";
1249
1584
  }
1250
1585
  };
1251
- async function processApp(ctx, config, app, appConfig) {
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
+ };
1252
1607
  await reconcile(Apps, ctx, app, true);
1253
- await reconcile(Domains, ctx, app, appConfig.domains);
1254
- await reconcile(Networks, ctx, app, appConfig.networks);
1255
- await reconcile(NetworkProps, ctx, app, appConfig.network);
1256
- await reconcile(Proxy, ctx, app, appConfig.proxy?.enabled);
1257
- await reconcile(Ports, ctx, app, appConfig.ports);
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));
1258
1613
  if (config.postgres || config.redis) {
1259
- await ensureAppLinks(ctx, app, appConfig.links ?? [], config);
1260
- }
1261
- await reconcile(Certs, ctx, app, appConfig.ssl);
1262
- await reconcile(Storage, ctx, app, appConfig.storage);
1263
- await reconcile(Nginx, ctx, app, appConfig.nginx);
1264
- await reconcile(Checks, ctx, app, appConfig.checks);
1265
- await reconcile(Logs, ctx, app, appConfig.logs);
1266
- await reconcile(Registry, ctx, app, appConfig.registry);
1267
- await reconcile(Scheduler, ctx, app, appConfig.scheduler);
1268
- await reconcile(Config, ctx, app, appConfig.env);
1269
- await reconcile(Builder, ctx, app, appConfig.build);
1270
- await reconcile(Git, ctx, app, appConfig.git ?? config.git);
1271
- await reconcile(DockerOptions, ctx, app, appConfig.docker_options);
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);
1272
1633
  }
1273
1634
  async function runUp(ctx, config, appFilter, opts = {}) {
1274
1635
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
@@ -1288,11 +1649,12 @@ async function runUp(ctx, config, appFilter, opts = {}) {
1288
1649
  const appConfig = config.apps[app];
1289
1650
  if (!appConfig) continue;
1290
1651
  try {
1291
- await processApp(ctx, config, app, appConfig);
1652
+ await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
1292
1653
  } catch (error) {
1293
1654
  if (opts.failFast) throw error;
1294
- const message = error instanceof Error ? error.message : String(error);
1295
- logError(app, message);
1655
+ if (!(error instanceof AppStepsError)) {
1656
+ logError(app, error instanceof Error ? error.message : String(error));
1657
+ }
1296
1658
  failures.push({ app, error });
1297
1659
  }
1298
1660
  }
@@ -1382,6 +1744,8 @@ var CONFIG_RESOURCES = [
1382
1744
  Logs,
1383
1745
  Registry,
1384
1746
  Scheduler,
1747
+ Ps,
1748
+ Scale,
1385
1749
  Config
1386
1750
  ];
1387
1751
  var BUILD_RESOURCES = [
@@ -1447,11 +1811,17 @@ function diffSslStatus(desired, current) {
1447
1811
  if (current === false || current === null) return "missing";
1448
1812
  return void 0;
1449
1813
  }
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;
1819
+ }
1450
1820
  function isEmptyValue(value) {
1451
1821
  return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1452
1822
  }
1453
1823
  async function computeDiff(ctx, config) {
1454
- const result = { apps: {}, services: {}, inSync: true, readWarnings: [] };
1824
+ const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
1455
1825
  const appNames = Object.keys(config.apps);
1456
1826
  await ensureDokkuVersion(ctx, config.dokku?.version);
1457
1827
  const appExists = /* @__PURE__ */ new Map();
@@ -1477,14 +1847,15 @@ async function computeDiff(ctx, config) {
1477
1847
  const appDiff = {};
1478
1848
  for (const resource of ALL_APP_RESOURCES) {
1479
1849
  if (resource.key.startsWith("_")) continue;
1480
- if (resource.forceApply) continue;
1481
1850
  let desired;
1482
1851
  if (resource.key === "proxy") {
1483
1852
  desired = appConfig.proxy?.enabled;
1484
1853
  } else {
1485
1854
  desired = appConfig[resource.key];
1486
1855
  }
1487
- 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;
1488
1859
  if (!appExists.get(app)) {
1489
1860
  appDiff[resource.key] = { status: "missing", desired, current: void 0 };
1490
1861
  result.inSync = false;
@@ -1493,6 +1864,21 @@ async function computeDiff(ctx, config) {
1493
1864
  const bulk = prefetched.get(resource.key);
1494
1865
  let current;
1495
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
+ }
1496
1882
  if (bulk === null) {
1497
1883
  bulkUnreadable = true;
1498
1884
  current = await resource.read(ctx, app);
@@ -1511,9 +1897,23 @@ async function computeDiff(ctx, config) {
1511
1897
  if (sslStatus !== "in-sync") result.inSync = false;
1512
1898
  continue;
1513
1899
  }
1514
- const change = computeChange(current, desired);
1515
- if (!change.changed) {
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) {
1516
1913
  appDiff[resource.key] = { status: "in-sync", desired, current };
1914
+ } else if (removed.length > 0 && !hasAdditionsOrChanges(change)) {
1915
+ appDiff[resource.key] = { status: "extra", desired, current, removed };
1916
+ result.inSync = false;
1517
1917
  } else if (bulkUnreadable && isEmptyValue(current)) {
1518
1918
  appDiff[resource.key] = { status: "unknown", desired, current };
1519
1919
  result.inSync = false;
@@ -1526,9 +1926,34 @@ async function computeDiff(ctx, config) {
1526
1926
  appDiff[resource.key] = { status: "changed", desired, current };
1527
1927
  result.inSync = false;
1528
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
+ }
1529
1944
  }
1530
1945
  result.apps[app] = appDiff;
1531
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
+ }
1532
1957
  for (const svc of Object.keys(config.postgres ?? {})) {
1533
1958
  const exists = await ctx.check("postgres:exists", svc);
1534
1959
  result.services[svc] = { status: exists ? "in-sync" : "missing" };
@@ -1542,14 +1967,16 @@ async function computeDiff(ctx, config) {
1542
1967
  return result;
1543
1968
  }
1544
1969
  function maskDiffResult(diff) {
1545
- const masked = { apps: {}, services: diff.services, inSync: diff.inSync, readWarnings: diff.readWarnings };
1970
+ const masked = { apps: {}, services: diff.services, plugins: diff.plugins, inSync: diff.inSync, readWarnings: diff.readWarnings };
1546
1971
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1547
1972
  const maskedAppDiff = {};
1548
1973
  for (const [feature, d] of Object.entries(appDiff)) {
1549
1974
  maskedAppDiff[feature] = {
1550
1975
  status: d.status,
1551
1976
  desired: maskSensitiveData(d.desired),
1552
- current: maskSensitiveData(d.current)
1977
+ current: maskSensitiveData(d.current),
1978
+ ...d.removed ? { removed: d.removed } : {},
1979
+ ...d.note ? { note: d.note } : {}
1553
1980
  };
1554
1981
  }
1555
1982
  masked.apps[app] = maskedAppDiff;
@@ -1566,18 +1993,24 @@ function formatSummary(diff) {
1566
1993
  } else {
1567
1994
  lines.push(` app: ${chalk2.bold(app)}`);
1568
1995
  for (const [feature, d] of changes) {
1569
- const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
1996
+ const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : d.status === "extra" ? chalk2.red("-") : chalk2.yellow("~");
1570
1997
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1571
1998
  }
1572
1999
  }
1573
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
+ }
1574
2007
  for (const [svc, d] of Object.entries(diff.services)) {
1575
2008
  if (d.status === "missing") {
1576
2009
  lines.push(` services:`);
1577
2010
  lines.push(` ${chalk2.green("+")} ${svc}: not provisioned`);
1578
2011
  }
1579
2012
  }
1580
- 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;
1581
2014
  if (diff.readWarnings.length > 0) {
1582
2015
  lines.push("");
1583
2016
  lines.push(chalk2.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
@@ -1593,12 +2026,21 @@ function formatSummary(diff) {
1593
2026
  return lines.join("\n");
1594
2027
  }
1595
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) {
1596
2035
  if (d.status === "unknown") return "(could not read server state \u2014 not reported as missing)";
1597
2036
  if (d.status === "missing") return "(not set on server)";
1598
2037
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
1599
2038
  return `${d.current.length} \u2192 ${d.desired.length} items`;
1600
2039
  }
1601
- 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;
1602
2044
  }
1603
2045
  function formatVerbose(diff) {
1604
2046
  const lines = [""];
@@ -1611,12 +2053,23 @@ function formatVerbose(diff) {
1611
2053
  lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
1612
2054
  continue;
1613
2055
  }
1614
- const currentLines = d.current !== void 0 ? JSON.stringify(d.current, null, 2).split("\n") : [];
1615
- const desiredLines = JSON.stringify(d.desired, null, 2).split("\n");
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");
1616
2063
  for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
1617
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)`));
1618
2066
  }
1619
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
+ }
1620
2073
  for (const [svc, d] of Object.entries(diff.services)) {
1621
2074
  if (d.status === "missing") {
1622
2075
  lines.push(`@@ services @@`);
@@ -1693,12 +2146,12 @@ function makeRunner(opts) {
1693
2146
  dryRun: opts.dryRun ?? false
1694
2147
  });
1695
2148
  }
1696
- 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) => {
1697
2150
  const config = loadConfig(opts.file);
1698
2151
  const runner = makeRunner(opts);
1699
2152
  const ctx = createContext(runner);
1700
2153
  try {
1701
- await runUp(ctx, config, apps, { failFast: opts.failFast ?? false });
2154
+ await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
1702
2155
  if (opts.dryRun) {
1703
2156
  console.log("\n# Commands that would run:");
1704
2157
  for (const cmd of runner.dryRunLog) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dokku-compose",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Docker Compose for Dokku — declare your entire server in a single YAML file.",
5
5
  "main": "dist/index.js",
6
6
  "exports": "./dist/index.js",