dokku-compose 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ Like Docker Compose, but for Dokku.
30
30
  npm install -g dokku-compose
31
31
  ```
32
32
 
33
- Requires Node.js >= 20. See the [Installation Reference](docs/reference/install.md) for details.
33
+ Requires Node.js >= 20 and a server running Dokku >= 0.38.22 (every server command stops on an older one; dokku-compose never installs or upgrades Dokku). See the [Installation Reference](docs/reference/install.md) for details.
34
34
 
35
35
  ### 1. Export your existing server
36
36
 
@@ -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.
@@ -0,0 +1,89 @@
1
+ // src/modules/version.ts
2
+ import { createRequire } from "module";
3
+
4
+ // src/core/logger.ts
5
+ import chalk from "chalk";
6
+ function logAction(context, message) {
7
+ process.stdout.write(chalk.blue(`[${context.padEnd(12)}]`) + ` ${message}`);
8
+ }
9
+ function logDone() {
10
+ console.log(`... ${chalk.green("done")}`);
11
+ }
12
+ function logSkip() {
13
+ console.log(`... ${chalk.yellow("already configured")}`);
14
+ }
15
+ function logError(context, message) {
16
+ console.error(chalk.red(`[${context.padEnd(12)}] ERROR: ${message}`));
17
+ }
18
+ function logWarn(context, message) {
19
+ console.warn(chalk.yellow(`[${context.padEnd(12)}] WARN: ${message}`));
20
+ }
21
+
22
+ // src/modules/version.ts
23
+ var MIN_DOKKU_VERSION = "0.38.22";
24
+ var SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/;
25
+ function parseSemver(input) {
26
+ const m = input.match(SEMVER_RE);
27
+ if (!m) throw new Error(`Cannot parse version: ${input}`);
28
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
29
+ }
30
+ function extractServerVersion(output) {
31
+ const match = output.match(/(\d+\.\d+\.\d+)/);
32
+ return match ? match[1] : null;
33
+ }
34
+ function compareSemver(a, b) {
35
+ const [aMaj, aMin, aPatch] = parseSemver(a);
36
+ const [bMaj, bMin, bPatch] = parseSemver(b);
37
+ if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
38
+ if (aMin !== bMin) return aMin < bMin ? -1 : 1;
39
+ if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
40
+ return 0;
41
+ }
42
+ function toolVersion() {
43
+ const require2 = createRequire(import.meta.url);
44
+ for (const p of ["../package.json", "../../package.json"]) {
45
+ try {
46
+ return require2(p).version;
47
+ } catch {
48
+ }
49
+ }
50
+ return "unknown";
51
+ }
52
+ async function assertDokkuSupported(ctx) {
53
+ const output = await ctx.query("version");
54
+ const server = extractServerVersion(output);
55
+ if (!server) {
56
+ throw new Error(`Cannot parse Dokku server version from output: ${output}`);
57
+ }
58
+ if (compareSemver(server, MIN_DOKKU_VERSION) === -1) {
59
+ throw new Error(
60
+ `dokku-compose ${toolVersion()} needs Dokku >= ${MIN_DOKKU_VERSION}; this server runs ${server}. Upgrade Dokku first.`
61
+ );
62
+ }
63
+ return server;
64
+ }
65
+ function compareToFile(server, file) {
66
+ if (!file) return "none";
67
+ const c = compareSemver(server, file);
68
+ return c === 0 ? "equal" : c < 0 ? "older" : "newer";
69
+ }
70
+ function enforceFileVersion(server, file) {
71
+ const rel = compareToFile(server, file);
72
+ if (rel === "older") {
73
+ throw new Error(`Dokku server is v${server} but the file requires >= v${file}. Upgrade Dokku or lower dokku.version.`);
74
+ }
75
+ if (rel === "newer") {
76
+ logWarn("dokku", `server is v${server}, newer than dokku.version ${file} in the file; update the file when ready`);
77
+ }
78
+ }
79
+
80
+ export {
81
+ logAction,
82
+ logDone,
83
+ logSkip,
84
+ logError,
85
+ logWarn,
86
+ assertDokkuSupported,
87
+ compareToFile,
88
+ enforceFileVersion
89
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ assertDokkuSupported,
4
+ compareToFile,
5
+ enforceFileVersion,
6
+ logAction,
7
+ logDone,
8
+ logError,
9
+ logSkip,
10
+ logWarn
11
+ } from "./chunk-3W53KPE4.js";
2
12
 
3
13
  // src/index.ts
4
14
  import { Command } from "commander";
@@ -53,6 +63,12 @@ var AppSchema = z.object({
53
63
  logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
54
64
  registry: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
55
65
  scheduler: z.string().optional(),
66
+ scale: z.record(z.string(), z.preprocess((v) => typeof v === "string" && /^\s*-?\d+\s*$/.test(v) ? Number(v) : v, z.number().int().min(0))).optional(),
67
+ ps: z.object({
68
+ "procfile-path": z.union([z.string(), z.number()]).optional(),
69
+ "restart-policy": z.union([z.string(), z.number()]).optional(),
70
+ "stop-timeout-seconds": z.union([z.string(), z.number()]).optional()
71
+ }).strict().optional(),
56
72
  checks: ChecksSchema.optional(),
57
73
  build: z.object({
58
74
  dockerfile: z.string().optional(),
@@ -117,16 +133,29 @@ function parseConfig(raw) {
117
133
  }
118
134
 
119
135
  // src/core/config.ts
120
- function interpolateEnvVars(content) {
136
+ function interpolateValue(value, missing) {
137
+ if (typeof value === "string") {
138
+ return value.replace(/\$\$\{|\$\{([^}]+)\}/g, (match, name) => {
139
+ if (name === void 0) return "${";
140
+ const v = process.env[name];
141
+ if (v === void 0 || v === "") {
142
+ missing.push(name);
143
+ return "";
144
+ }
145
+ return v;
146
+ });
147
+ }
148
+ if (Array.isArray(value)) return value.map((v) => interpolateValue(v, missing));
149
+ if (value !== null && typeof value === "object") {
150
+ const out = {};
151
+ for (const [k, v] of Object.entries(value)) out[k] = interpolateValue(v, missing);
152
+ return out;
153
+ }
154
+ return value;
155
+ }
156
+ function interpolateEnvVars(raw) {
121
157
  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
- });
158
+ const result = interpolateValue(raw, missing);
130
159
  if (missing.length > 0) {
131
160
  const unique = Array.from(new Set(missing));
132
161
  throw new Error(
@@ -140,7 +169,7 @@ function loadConfig(filePath) {
140
169
  throw new Error(`Config file not found: ${filePath}`);
141
170
  }
142
171
  const content = fs.readFileSync(filePath, "utf8");
143
- const raw = yaml.load(interpolateEnvVars(content));
172
+ const raw = interpolateEnvVars(yaml.load(content));
144
173
  return parseConfig(raw);
145
174
  }
146
175
 
@@ -279,6 +308,11 @@ function createRunner(opts = {}) {
279
308
  const { stdout } = await execDokku(args);
280
309
  return stdout.trim();
281
310
  },
311
+ async queryStrict(...args) {
312
+ const { stdout, ok, exitCode, stderr } = await execDokku(args);
313
+ if (!ok) throw new DokkuCommandError(args, exitCode, stderr);
314
+ return stdout.trim();
315
+ },
282
316
  async check(...args) {
283
317
  const { ok } = await execDokku(args);
284
318
  return ok;
@@ -307,6 +341,9 @@ function createContext(runner) {
307
341
  }
308
342
  return cache.get(key);
309
343
  },
344
+ queryStrict(...args) {
345
+ return runner.queryStrict(...args);
346
+ },
310
347
  check(...args) {
311
348
  return runner.check(...args);
312
349
  },
@@ -355,36 +392,48 @@ function computeChange(before, after) {
355
392
  }
356
393
  return { before, after, changed: before !== after };
357
394
  }
358
-
359
- // src/core/logger.ts
360
- import chalk from "chalk";
361
- function logAction(context, message) {
362
- process.stdout.write(chalk.blue(`[${context.padEnd(12)}]`) + ` ${message}`);
363
- }
364
- function logDone() {
365
- console.log(`... ${chalk.green("done")}`);
366
- }
367
- function logSkip() {
368
- console.log(`... ${chalk.yellow("already configured")}`);
369
- }
370
- function logError(context, message) {
371
- console.error(chalk.red(`[${context.padEnd(12)}] ERROR: ${message}`));
372
- }
373
- function logWarn(context, message) {
374
- console.warn(chalk.yellow(`[${context.padEnd(12)}] WARN: ${message}`));
395
+ function computeAdditiveChange(before, after) {
396
+ const change = computeChange(before ?? {}, after);
397
+ change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
398
+ return change;
375
399
  }
376
400
 
377
401
  // src/core/reconcile.ts
378
- async function reconcile(resource, ctx, target, desired) {
379
- if (desired === void 0) return;
402
+ async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
403
+ const canPrune = prune && !!resource.prune && !!resource.removals;
404
+ const pruneFrom = async (current) => {
405
+ const removed = resource.removals(current, desired);
406
+ if (removed.length === 0) return;
407
+ logAction(target, `${resource.key} (prune)`);
408
+ await resource.prune(ctx, target, removed, current, desired);
409
+ logDone();
410
+ };
411
+ if (desired === void 0) {
412
+ if (canPrune) await pruneFrom(await resource.read(ctx, target));
413
+ return;
414
+ }
380
415
  logAction(target, `${resource.key}`);
381
416
  if (resource.forceApply) {
382
417
  await resource.onChange(ctx, target, { before: void 0, after: desired, changed: true });
383
418
  logDone();
419
+ if (canPrune) await pruneFrom(await resource.read(ctx, target));
384
420
  return;
385
421
  }
386
422
  const before = await resource.read(ctx, target);
387
- const change = computeChange(before, desired);
423
+ if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
424
+ if (canPrune) {
425
+ const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
426
+ const pending = resource.removals(before, desired).length > 0;
427
+ if (change2.changed) {
428
+ await resource.onChange(ctx, target, change2, pending);
429
+ logDone();
430
+ } else {
431
+ logSkip();
432
+ }
433
+ await pruneFrom(before);
434
+ return;
435
+ }
436
+ const change = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
388
437
  if (!change.changed) {
389
438
  logSkip();
390
439
  return;
@@ -520,9 +569,69 @@ async function isAppDeployed(ctx, target) {
520
569
  const raw = await ctx.query("ps:report", target, "--deployed");
521
570
  return raw.trim() === "true";
522
571
  }
572
+ var NGINX_KEYS = [
573
+ "access-log-format",
574
+ "access-log-path",
575
+ "bind-address-ipv4",
576
+ "bind-address-ipv6",
577
+ "client-body-timeout",
578
+ "client-header-timeout",
579
+ "client-max-body-size",
580
+ "disable-custom-config",
581
+ "error-log-path",
582
+ "hsts",
583
+ "hsts-include-subdomains",
584
+ "hsts-max-age",
585
+ "hsts-preload",
586
+ "keepalive-timeout",
587
+ "lingering-timeout",
588
+ "proxy-buffer-size",
589
+ "proxy-buffering",
590
+ "proxy-buffers",
591
+ "proxy-busy-buffers-size",
592
+ "proxy-connect-timeout",
593
+ "proxy-keepalive",
594
+ "proxy-read-timeout",
595
+ "proxy-send-timeout",
596
+ "send-timeout",
597
+ "underscore-in-headers",
598
+ "x-forwarded-for-value",
599
+ "x-forwarded-port-value",
600
+ "x-forwarded-proto-value",
601
+ "x-forwarded-ssl"
602
+ ];
603
+ var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
604
+ var REGISTRY_KEYS = [
605
+ "auth-servers",
606
+ "image-repo",
607
+ "image-repo-template",
608
+ "push-extra-tags",
609
+ "push-on-release",
610
+ "server"
611
+ ];
612
+ function propertyRemovals(allowed) {
613
+ return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
614
+ }
615
+ function propertyPrune(setCmd, afterPrune = []) {
616
+ return async (ctx, target, removed) => {
617
+ for (const key of removed) await ctx.run(setCmd, target, key);
618
+ for (const cmd of afterPrune) {
619
+ try {
620
+ await ctx.run(cmd, target);
621
+ } catch (err) {
622
+ if (await isAppDeployed(ctx, target)) throw err;
623
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
624
+ }
625
+ }
626
+ };
627
+ }
523
628
  function propertyResource(opts) {
524
629
  return {
525
630
  key: opts.key,
631
+ // Keys only on the server never make the block "changed"; diff shows them as removals.
632
+ additive: true,
633
+ removals: propertyRemovals(opts.settable),
634
+ prune: propertyPrune(opts.setCmd, opts.afterChange),
526
635
  async read(ctx, target) {
527
636
  const raw = await ctx.query(`${opts.namespace}:report`, target);
528
637
  return parseReport(raw, opts.namespace);
@@ -531,11 +640,11 @@ function propertyResource(opts) {
531
640
  const raw = await ctx.query(`${opts.namespace}:report`);
532
641
  return parseBulkReport(raw, opts.namespace);
533
642
  },
534
- async onChange(ctx, target, change) {
643
+ async onChange(ctx, target, change, deferFollowUp = false) {
535
644
  for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
536
645
  await ctx.run(opts.setCmd, target, key, String(value));
537
646
  }
538
- if (opts.afterChange) {
647
+ if (opts.afterChange && !deferFollowUp) {
539
648
  for (const cmd of opts.afterChange) {
540
649
  try {
541
650
  await ctx.run(cmd, target);
@@ -552,20 +661,28 @@ var Nginx = propertyResource({
552
661
  key: "nginx",
553
662
  namespace: "nginx",
554
663
  setCmd: "nginx:set",
664
+ settable: NGINX_KEYS,
555
665
  afterChange: ["proxy:build-config"]
556
666
  });
557
667
  var Logs = propertyResource({
558
668
  key: "logs",
559
669
  namespace: "logs",
560
- setCmd: "logs:set"
670
+ setCmd: "logs:set",
671
+ settable: LOGS_KEYS
561
672
  });
562
673
  var Registry = propertyResource({
563
674
  key: "registry",
564
675
  namespace: "registry",
565
- setCmd: "registry:set"
676
+ setCmd: "registry:set",
677
+ settable: REGISTRY_KEYS
566
678
  });
567
679
  var Scheduler = {
568
680
  key: "scheduler",
681
+ // A scheduler selected on the app but not declared in the file.
682
+ removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
683
+ prune: async (ctx, target) => {
684
+ await ctx.run("scheduler:set", target, "selected");
685
+ },
569
686
  async read(ctx, target) {
570
687
  const raw = await ctx.query("scheduler:report", target);
571
688
  const report = parseReport(raw, "scheduler");
@@ -606,21 +723,38 @@ var Proxy = {
606
723
  }
607
724
  };
608
725
 
726
+ // src/core/config-json.ts
727
+ function parseConfigJson(raw, what) {
728
+ let parsed;
729
+ try {
730
+ parsed = JSON.parse(raw);
731
+ } catch {
732
+ throw new Error(`${what}: config:export did not return JSON`);
733
+ }
734
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
735
+ throw new Error(`${what}: config:export did not return a JSON object`);
736
+ }
737
+ const result = {};
738
+ for (const [k, v] of Object.entries(parsed)) result[k] = String(v);
739
+ return result;
740
+ }
741
+
609
742
  // src/resources/config.ts
610
743
  var MANAGED_KEYS_VAR = "DOKKU_COMPOSE_MANAGED_KEYS";
744
+ var MISSING = "\0missing";
611
745
  var Config = {
612
746
  key: "env",
747
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && typeof desired === "object" && k in desired)),
613
748
  read: async (ctx, target) => {
614
749
  const managedRaw = await ctx.query("config:get", target, MANAGED_KEYS_VAR);
615
750
  const managedKeys = managedRaw.trim() ? managedRaw.trim().split(",").filter(Boolean) : [];
616
751
  const result = {};
752
+ for (const k of managedKeys) result[k] = MISSING;
617
753
  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
- }
754
+ const raw = await ctx.queryStrict("config:export", "--format", "json", target);
755
+ const current = parseConfigJson(raw, target);
756
+ for (const k of managedKeys) {
757
+ if (k in current) result[k] = current[k];
624
758
  }
625
759
  }
626
760
  return result;
@@ -630,9 +764,12 @@ var Config = {
630
764
  if (removed.length > 0) {
631
765
  await ctx.run("config:unset", "--no-restart", target, ...removed);
632
766
  }
633
- const toSet = { ...added, ...modified };
634
767
  const allDesiredKeys = Object.keys(change.after);
635
- const managedValue = allDesiredKeys.join(",");
768
+ if (allDesiredKeys.length === 0) {
769
+ await ctx.run("config:unset", "--no-restart", target, MANAGED_KEYS_VAR);
770
+ return;
771
+ }
772
+ const toSet = { ...added, ...modified };
636
773
  if (Object.keys(toSet).length > 0 || removed.length > 0) {
637
774
  const pairs = Object.entries(change.after).map(([k, v]) => `${k}=${v}`);
638
775
  await ctx.run(
@@ -640,7 +777,7 @@ var Config = {
640
777
  "--no-restart",
641
778
  target,
642
779
  ...pairs,
643
- `${MANAGED_KEYS_VAR}=${managedValue}`
780
+ `${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
644
781
  );
645
782
  }
646
783
  }
@@ -675,10 +812,17 @@ var Certs = {
675
812
  };
676
813
 
677
814
  // src/resources/builder.ts
815
+ function parseBuildOptions(dockerOptsBuild) {
816
+ const out = [];
817
+ for (const raw of dockerOptsBuild.trim().split(/\s+(?=--\S)/)) {
818
+ const m = raw.match(/^--build-arg\s+([^=\s]+)=?(.*)$/s);
819
+ if (m) out.push({ key: m[1], value: m[2], raw });
820
+ }
821
+ return out;
822
+ }
678
823
  function parseBuildArgs(dockerOptsBuild) {
679
824
  const args = {};
680
- const matches = dockerOptsBuild.matchAll(/--build-arg\s+(\S+?)=(\S+)/g);
681
- for (const m of matches) args[m[1]] = m[2];
825
+ for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
682
826
  return args;
683
827
  }
684
828
  function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, dockerOptsBuild) {
@@ -695,6 +839,19 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
695
839
  }
696
840
  var Builder = {
697
841
  key: "build",
842
+ additive: true,
843
+ // Build args (`--build-arg KEY=...` build docker options) whose KEY is not in `build.args`.
844
+ removals: (current, desired) => Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`),
845
+ // Remove exactly the stale `--build-arg` options; other build options stay.
846
+ prune: async (ctx, target, removed) => {
847
+ const stale = new Set(removed.map((l) => l.slice("args.".length)));
848
+ const report = parseReport(await ctx.query("docker-options:report", target), "docker-options");
849
+ const options = parseBuildOptions(report["build"] ?? "").filter((o) => stale.has(o.key));
850
+ for (const o of options) await ctx.run("docker-options:remove", target, "build", o.raw);
851
+ for (const k of stale) {
852
+ if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
853
+ }
854
+ },
698
855
  async read(ctx, target) {
699
856
  const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
700
857
  ctx.query("builder:report", target),
@@ -766,6 +923,11 @@ function dockerOptsFromReport(report) {
766
923
  }
767
924
  var DockerOptions = {
768
925
  key: "docker_options",
926
+ // Pruned by `up` within a declared block only, so a missing block is not shown.
927
+ removals: (current, desired) => !desired ? [] : PHASES.flatMap((phase) => {
928
+ const wanted = new Set(desired?.[phase] ?? []);
929
+ return (current?.[phase] ?? []).filter((o) => !wanted.has(o)).map((o) => `${phase}: ${o}`);
930
+ }),
769
931
  async read(ctx, target) {
770
932
  const raw = await ctx.query("docker-options:report", target);
771
933
  return dockerOptsFromReport(parseReport(raw, "docker-options"));
@@ -793,6 +955,68 @@ var DockerOptions = {
793
955
  }
794
956
  };
795
957
 
958
+ // src/resources/ps.ts
959
+ var PS_KEYS = ["procfile-path", "restart-policy", "stop-timeout-seconds"];
960
+ function parseScale(raw) {
961
+ const result = {};
962
+ let parsed;
963
+ try {
964
+ parsed = JSON.parse(raw);
965
+ } catch {
966
+ return result;
967
+ }
968
+ if (!Array.isArray(parsed)) return result;
969
+ for (const e of parsed) {
970
+ if (e && typeof e.process_type === "string" && e.quantity !== void 0) {
971
+ result[e.process_type] = String(e.quantity);
972
+ }
973
+ }
974
+ return result;
975
+ }
976
+ function parsePsReport(raw) {
977
+ const result = {};
978
+ let parsed;
979
+ try {
980
+ parsed = JSON.parse(raw);
981
+ } catch {
982
+ return result;
983
+ }
984
+ if (!parsed || typeof parsed !== "object") return result;
985
+ for (const key of PS_KEYS) {
986
+ const v = parsed[key];
987
+ if (v !== void 0 && v !== null && String(v) !== "") result[key] = String(v);
988
+ }
989
+ return result;
990
+ }
991
+ var Scale = {
992
+ key: "scale",
993
+ additive: true,
994
+ async read(ctx, target) {
995
+ return parseScale(await ctx.query("ps:scale", "--format", "json", target));
996
+ },
997
+ async onChange(ctx, target, change) {
998
+ const pairs = Object.entries({ ...change.added, ...change.modified }).map(([proc, count]) => `${proc}=${count}`);
999
+ if (pairs.length === 0) return;
1000
+ await ctx.run("ps:scale", "--skip-deploy", target, ...pairs);
1001
+ }
1002
+ };
1003
+ var Ps = {
1004
+ key: "ps",
1005
+ additive: true,
1006
+ removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
1007
+ prune: async (ctx, target, removed) => {
1008
+ for (const key of removed) await ctx.run("ps:set", target, key);
1009
+ },
1010
+ async read(ctx, target) {
1011
+ return parsePsReport(await ctx.queryStrict("ps:report", target, "--format", "json"));
1012
+ },
1013
+ async onChange(ctx, target, change) {
1014
+ for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
1015
+ await ctx.run("ps:set", target, key, String(value));
1016
+ }
1017
+ }
1018
+ };
1019
+
796
1020
  // src/resources/git.ts
797
1021
  var Git = {
798
1022
  key: "git",
@@ -820,7 +1044,33 @@ var Git = {
820
1044
  var Checks = {
821
1045
  key: "checks",
822
1046
  forceApply: true,
823
- read: async () => ({}),
1047
+ // Applying never reads (forceApply); the read only finds settings to report as removed.
1048
+ // Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty.
1049
+ read: async (ctx, target) => {
1050
+ const report = parseReport(await ctx.query("checks:report", target), "checks");
1051
+ const current = {};
1052
+ for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
1053
+ const v = report[reportKey];
1054
+ if (v && v !== "none" && v !== "_all_") current[key] = v;
1055
+ }
1056
+ if (report["wait-to-retire"]) current["wait-to-retire"] = report["wait-to-retire"];
1057
+ return current;
1058
+ },
1059
+ removals: (current, desired) => desired === false ? [] : Object.keys(current || {}).filter((k) => !(desired && k in desired)),
1060
+ prune: async (ctx, target, removed, current, desired) => {
1061
+ const cur = current;
1062
+ const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
1063
+ const declared = new Set(desired && desired !== false ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
1064
+ const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
1065
+ const enable = [...stale].filter((t) => !declared.has(t));
1066
+ for (const t of stale) {
1067
+ if (declared.has(t)) {
1068
+ logWarn(target, `checks: '${t}' is declared in the file but also set in a list the file does not declare; left as is. Run 'dokku checks:enable ${target} ${t}' and then 'up' to clear it`);
1069
+ }
1070
+ }
1071
+ if (enable.length > 0) await ctx.run("checks:enable", target, ...enable);
1072
+ if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
1073
+ },
824
1074
  onChange: async (ctx, target, { after }) => {
825
1075
  if (after === false) {
826
1076
  await ctx.run("checks:disable", target);
@@ -861,6 +1111,12 @@ var Networks = {
861
1111
  };
862
1112
  var NetworkProps = {
863
1113
  key: "network",
1114
+ additive: true,
1115
+ removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
1116
+ // Dokku's property names are hyphenated; the schema's are underscored.
1117
+ prune: async (ctx, target, removed) => {
1118
+ for (const key of removed) await ctx.run("network:set", target, key.replaceAll("_", "-"));
1119
+ },
864
1120
  read: async (ctx, target) => {
865
1121
  const raw = await ctx.query("network:report", target);
866
1122
  const report = parseReport(raw, "network");
@@ -907,19 +1163,90 @@ var NetworkProps = {
907
1163
  };
908
1164
 
909
1165
  // src/modules/plugins.ts
1166
+ function parsePluginList(raw) {
1167
+ const result = /* @__PURE__ */ new Map();
1168
+ let parsed;
1169
+ try {
1170
+ parsed = JSON.parse(raw);
1171
+ } catch {
1172
+ throw new Error(`plugin:list --format json did not return JSON (does this Dokku support --format json?): ${raw.slice(0, 200)}`);
1173
+ }
1174
+ if (!Array.isArray(parsed)) {
1175
+ throw new Error(`plugin:list --format json did not return a JSON array: ${raw.slice(0, 200)}`);
1176
+ }
1177
+ for (const p of parsed) {
1178
+ if (p && typeof p.name === "string") {
1179
+ result.set(p.name, {
1180
+ version: typeof p.version === "string" ? p.version : "",
1181
+ committish: typeof p.committish === "string" ? p.committish : ""
1182
+ });
1183
+ }
1184
+ }
1185
+ return result;
1186
+ }
1187
+ var SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
1188
+ function compareVersions(a, b) {
1189
+ const na = a.replace(/^v/, "");
1190
+ const nb = b.replace(/^v/, "");
1191
+ const ma = na.match(SEMVER);
1192
+ const mb = nb.match(SEMVER);
1193
+ if (!ma || !mb) return na === nb ? 0 : void 0;
1194
+ for (let i = 1; i <= 3; i++) {
1195
+ const d = Number(ma[i]) - Number(mb[i]);
1196
+ if (d !== 0) return d < 0 ? -1 : 1;
1197
+ }
1198
+ return 0;
1199
+ }
1200
+ var SHA = /^[0-9a-f]{7,40}$/i;
1201
+ function planPlugins(listOutput, plugins) {
1202
+ const installed = parsePluginList(listOutput);
1203
+ return Object.entries(plugins).map(([name, config]) => {
1204
+ const current = installed.get(name);
1205
+ const desired = config.version;
1206
+ if (current === void 0) return { name, action: "install", desired };
1207
+ const installedVersion = current.version || current.committish.slice(0, 7) || void 0;
1208
+ if (desired === void 0) return { name, action: "ok", installed: installedVersion };
1209
+ if (SHA.test(desired) && current.committish) {
1210
+ if (current.committish.toLowerCase().startsWith(desired.toLowerCase())) {
1211
+ return { name, action: "ok", installed: installedVersion, desired };
1212
+ }
1213
+ }
1214
+ if (!current.version) return { name, action: "unknown", installed: installedVersion, desired };
1215
+ const cmp = compareVersions(current.version, desired);
1216
+ const action = cmp === void 0 ? "unknown" : cmp < 0 ? "update" : cmp > 0 ? "newer" : "ok";
1217
+ return { name, action, installed: installedVersion, desired };
1218
+ });
1219
+ }
910
1220
  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;
1221
+ const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), plugins);
1222
+ for (const plan of plans) {
1223
+ const config = plugins[plan.name];
1224
+ switch (plan.action) {
1225
+ case "install":
1226
+ logAction("plugins", `Installing ${plan.name}`);
1227
+ await ctx.run(
1228
+ "plugin:install",
1229
+ config.url,
1230
+ ...config.version ? ["--committish", config.version] : [],
1231
+ "--name",
1232
+ plan.name
1233
+ );
1234
+ logDone();
1235
+ break;
1236
+ case "update":
1237
+ logAction("plugins", `Updating ${plan.name}`);
1238
+ await ctx.run("plugin:update", plan.name, config.version);
1239
+ logDone();
1240
+ break;
1241
+ case "newer":
1242
+ logWarn("plugins", `${plan.name} is ${plan.installed}, newer than the declared ${plan.desired}; leaving it (never downgrades)`);
1243
+ break;
1244
+ case "unknown":
1245
+ logWarn("plugins", `${plan.name} is ${plan.installed} and the declared ${plan.desired} cannot be compared; leaving it`);
1246
+ break;
1247
+ default:
1248
+ break;
920
1249
  }
921
- await ctx.run("plugin:install", config.url, "--name", name);
922
- logDone();
923
1250
  }
924
1251
  }
925
1252
 
@@ -1125,40 +1452,6 @@ async function exportRedis(ctx) {
1125
1452
  return services;
1126
1453
  }
1127
1454
 
1128
- // src/modules/version.ts
1129
- var SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/;
1130
- function parseSemver(input) {
1131
- const m = input.match(SEMVER_RE);
1132
- if (!m) throw new Error(`Cannot parse version: ${input}`);
1133
- return [Number(m[1]), Number(m[2]), Number(m[3])];
1134
- }
1135
- function extractServerVersion(output) {
1136
- const match = output.match(/(\d+\.\d+\.\d+)/);
1137
- return match ? match[1] : null;
1138
- }
1139
- function compareSemver(a, b) {
1140
- const [aMaj, aMin, aPatch] = parseSemver(a);
1141
- const [bMaj, bMin, bPatch] = parseSemver(b);
1142
- if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
1143
- if (aMin !== bMin) return aMin < bMin ? -1 : 1;
1144
- if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
1145
- return 0;
1146
- }
1147
- async function ensureDokkuVersion(ctx, pinned) {
1148
- if (!pinned) return;
1149
- const output = await ctx.query("version");
1150
- const server = extractServerVersion(output);
1151
- if (!server) {
1152
- throw new Error(`Cannot parse Dokku server version from output: ${output}`);
1153
- }
1154
- if (compareSemver(server, pinned) === -1) {
1155
- logWarn(
1156
- "dokku",
1157
- `server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.`
1158
- );
1159
- }
1160
- }
1161
-
1162
1455
  // src/modules/links.ts
1163
1456
  function resolveServicePlugin(name, config) {
1164
1457
  if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
@@ -1248,31 +1541,57 @@ var UpFailedError = class extends Error {
1248
1541
  this.name = "UpFailedError";
1249
1542
  }
1250
1543
  };
1251
- async function processApp(ctx, config, app, appConfig) {
1544
+ var AppStepsError = class extends AggregateError {
1545
+ constructor(errors) {
1546
+ super(errors, errors.map((e) => e instanceof Error ? e.message : String(e)).join("; "));
1547
+ this.name = "AppStepsError";
1548
+ }
1549
+ };
1550
+ async function processApp(ctx, config, app, appConfig, failFast = false, prune = false) {
1551
+ const errors = [];
1552
+ let leftOnServer = 0;
1553
+ const count = prune ? void 0 : (removed) => {
1554
+ leftOnServer += removed.length;
1555
+ };
1556
+ const step = async (fn) => {
1557
+ try {
1558
+ await fn();
1559
+ } catch (error) {
1560
+ if (failFast) throw error;
1561
+ logError(app, error instanceof Error ? error.message : String(error));
1562
+ errors.push(error);
1563
+ }
1564
+ };
1252
1565
  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);
1566
+ await step(() => reconcile(Domains, ctx, app, appConfig.domains));
1567
+ await step(() => reconcile(Networks, ctx, app, appConfig.networks));
1568
+ await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
1569
+ await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
1570
+ await step(() => reconcile(Ports, ctx, app, appConfig.ports));
1258
1571
  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);
1572
+ await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
1573
+ }
1574
+ await step(() => reconcile(Certs, ctx, app, appConfig.ssl));
1575
+ await step(() => reconcile(Storage, ctx, app, appConfig.storage));
1576
+ await step(() => reconcile(Nginx, ctx, app, appConfig.nginx, count, prune));
1577
+ await step(() => reconcile(Checks, ctx, app, appConfig.checks, void 0, prune));
1578
+ await step(() => reconcile(Logs, ctx, app, appConfig.logs, count, prune));
1579
+ await step(() => reconcile(Registry, ctx, app, appConfig.registry, count, prune));
1580
+ await step(() => reconcile(Scheduler, ctx, app, appConfig.scheduler, count, prune));
1581
+ await step(() => reconcile(Ps, ctx, app, appConfig.ps, count, prune));
1582
+ await step(() => reconcile(Scale, ctx, app, appConfig.scale));
1583
+ await step(() => reconcile(Config, ctx, app, appConfig.env || {}));
1584
+ await step(() => reconcile(Builder, ctx, app, appConfig.build, count, prune));
1585
+ await step(() => reconcile(Git, ctx, app, appConfig.git ?? config.git));
1586
+ await step(() => reconcile(DockerOptions, ctx, app, appConfig.docker_options));
1587
+ if (leftOnServer > 0) {
1588
+ logWarn(app, `${leftOnServer} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
1589
+ }
1590
+ if (errors.length > 0) throw new AppStepsError(errors);
1272
1591
  }
1273
1592
  async function runUp(ctx, config, appFilter, opts = {}) {
1274
1593
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1275
- await ensureDokkuVersion(ctx, config.dokku?.version);
1594
+ enforceFileVersion(await assertDokkuSupported(ctx), config.dokku?.version);
1276
1595
  if (config.plugins) await ensurePlugins(ctx, config.plugins);
1277
1596
  if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth);
1278
1597
  if (config.domains !== void 0) await ensureGlobalDomains(ctx, config.domains);
@@ -1288,11 +1607,12 @@ async function runUp(ctx, config, appFilter, opts = {}) {
1288
1607
  const appConfig = config.apps[app];
1289
1608
  if (!appConfig) continue;
1290
1609
  try {
1291
- await processApp(ctx, config, app, appConfig);
1610
+ await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
1292
1611
  } catch (error) {
1293
1612
  if (opts.failFast) throw error;
1294
- const message = error instanceof Error ? error.message : String(error);
1295
- logError(app, message);
1613
+ if (!(error instanceof AppStepsError)) {
1614
+ logError(app, error instanceof Error ? error.message : String(error));
1615
+ }
1296
1616
  failures.push({ app, error });
1297
1617
  }
1298
1618
  }
@@ -1331,6 +1651,7 @@ var DownFailedError = class extends Error {
1331
1651
  };
1332
1652
  async function runDown(ctx, config, appFilter, opts) {
1333
1653
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1654
+ await assertDokkuSupported(ctx);
1334
1655
  const failures = [];
1335
1656
  for (const app of apps) {
1336
1657
  const appConfig = config.apps[app];
@@ -1382,6 +1703,8 @@ var CONFIG_RESOURCES = [
1382
1703
  Logs,
1383
1704
  Registry,
1384
1705
  Scheduler,
1706
+ Ps,
1707
+ Scale,
1385
1708
  Config
1386
1709
  ];
1387
1710
  var BUILD_RESOURCES = [
@@ -1398,8 +1721,7 @@ var ALL_APP_RESOURCES = [
1398
1721
  // src/commands/export.ts
1399
1722
  async function runExport(ctx, opts) {
1400
1723
  const config = { apps: {} };
1401
- const version2 = extractServerVersion(await ctx.query("version"));
1402
- if (version2) config.dokku = { version: version2 };
1724
+ config.dokku = { version: await assertDokkuSupported(ctx) };
1403
1725
  const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx);
1404
1726
  const networks = await exportNetworks(ctx);
1405
1727
  if (networks.length > 0) config.networks = networks;
@@ -1439,7 +1761,7 @@ async function runExport(ctx, opts) {
1439
1761
  }
1440
1762
 
1441
1763
  // src/commands/diff.ts
1442
- import chalk2 from "chalk";
1764
+ import chalk from "chalk";
1443
1765
  function diffSslStatus(desired, current) {
1444
1766
  if (typeof desired !== "object" || desired === null) return void 0;
1445
1767
  if (current === void 0) return void 0;
@@ -1447,13 +1769,24 @@ function diffSslStatus(desired, current) {
1447
1769
  if (current === false || current === null) return "missing";
1448
1770
  return void 0;
1449
1771
  }
1772
+ function hasAdditionsOrChanges(change) {
1773
+ const c = change;
1774
+ if (c.added === void 0) return c.changed;
1775
+ const added = Array.isArray(c.added) ? c.added.length : Object.keys(c.added).length;
1776
+ return added > 0 || Object.keys(c.modified ?? {}).length > 0;
1777
+ }
1450
1778
  function isEmptyValue(value) {
1451
1779
  return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1452
1780
  }
1453
1781
  async function computeDiff(ctx, config) {
1454
- const result = { apps: {}, services: {}, inSync: true, readWarnings: [] };
1782
+ const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
1455
1783
  const appNames = Object.keys(config.apps);
1456
- await ensureDokkuVersion(ctx, config.dokku?.version);
1784
+ const serverVersion = await assertDokkuSupported(ctx);
1785
+ const fileVersion = config.dokku?.version;
1786
+ if (fileVersion && compareToFile(serverVersion, fileVersion) !== "equal") {
1787
+ result.dokku = { file: fileVersion, server: serverVersion };
1788
+ result.inSync = false;
1789
+ }
1457
1790
  const appExists = /* @__PURE__ */ new Map();
1458
1791
  await Promise.all(
1459
1792
  appNames.map(async (app) => {
@@ -1477,14 +1810,15 @@ async function computeDiff(ctx, config) {
1477
1810
  const appDiff = {};
1478
1811
  for (const resource of ALL_APP_RESOURCES) {
1479
1812
  if (resource.key.startsWith("_")) continue;
1480
- if (resource.forceApply) continue;
1481
1813
  let desired;
1482
1814
  if (resource.key === "proxy") {
1483
1815
  desired = appConfig.proxy?.enabled;
1484
1816
  } else {
1485
1817
  desired = appConfig[resource.key];
1486
1818
  }
1487
- if (desired === void 0) continue;
1819
+ const checkRemovalsOnly = desired === void 0;
1820
+ if (checkRemovalsOnly && (!resource.removals || !appExists.get(app))) continue;
1821
+ if (resource.forceApply && !resource.removals) continue;
1488
1822
  if (!appExists.get(app)) {
1489
1823
  appDiff[resource.key] = { status: "missing", desired, current: void 0 };
1490
1824
  result.inSync = false;
@@ -1493,6 +1827,21 @@ async function computeDiff(ctx, config) {
1493
1827
  const bulk = prefetched.get(resource.key);
1494
1828
  let current;
1495
1829
  let bulkUnreadable = false;
1830
+ if (checkRemovalsOnly) {
1831
+ try {
1832
+ current = bulk ? bulk.get(app) : await resource.read(ctx, app);
1833
+ if (bulk === null || current === void 0) current = await resource.read(ctx, app);
1834
+ } catch (err) {
1835
+ result.readWarnings.push(`could not read '${resource.key}' for app '${app}' to look for removed settings: ${err instanceof Error ? err.message : String(err)}`);
1836
+ continue;
1837
+ }
1838
+ const removed2 = resource.removals(current, void 0);
1839
+ if (removed2.length > 0) {
1840
+ appDiff[resource.key] = { status: "extra", current, removed: removed2 };
1841
+ result.inSync = false;
1842
+ }
1843
+ continue;
1844
+ }
1496
1845
  if (bulk === null) {
1497
1846
  bulkUnreadable = true;
1498
1847
  current = await resource.read(ctx, app);
@@ -1511,9 +1860,23 @@ async function computeDiff(ctx, config) {
1511
1860
  if (sslStatus !== "in-sync") result.inSync = false;
1512
1861
  continue;
1513
1862
  }
1514
- const change = computeChange(current, desired);
1515
- if (!change.changed) {
1863
+ let removed = resource.removals ? resource.removals(current, desired) : [];
1864
+ if (resource.forceApply) {
1865
+ if (removed.length > 0) {
1866
+ appDiff[resource.key] = { status: "extra", desired, current, removed };
1867
+ result.inSync = false;
1868
+ }
1869
+ continue;
1870
+ }
1871
+ const change = resource.additive ? computeAdditiveChange(current, desired) : computeChange(current, desired);
1872
+ if (!resource.removals && Array.isArray(current) && "removed" in change) {
1873
+ removed = change.removed;
1874
+ }
1875
+ if (!change.changed && removed.length === 0) {
1516
1876
  appDiff[resource.key] = { status: "in-sync", desired, current };
1877
+ } else if (removed.length > 0 && !hasAdditionsOrChanges(change)) {
1878
+ appDiff[resource.key] = { status: "extra", desired, current, removed };
1879
+ result.inSync = false;
1517
1880
  } else if (bulkUnreadable && isEmptyValue(current)) {
1518
1881
  appDiff[resource.key] = { status: "unknown", desired, current };
1519
1882
  result.inSync = false;
@@ -1526,9 +1889,34 @@ async function computeDiff(ctx, config) {
1526
1889
  appDiff[resource.key] = { status: "changed", desired, current };
1527
1890
  result.inSync = false;
1528
1891
  }
1892
+ if (removed.length > 0 && appDiff[resource.key].status !== "extra") {
1893
+ appDiff[resource.key].removed = removed;
1894
+ }
1895
+ }
1896
+ if (appConfig.links === void 0 && appExists.get(app)) {
1897
+ const linked = [];
1898
+ for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
1899
+ for (const svc of Object.keys(services ?? {})) {
1900
+ if (await ctx.check(`${plugin}:linked`, svc, app)) linked.push(svc);
1901
+ }
1902
+ }
1903
+ if (linked.length > 0) {
1904
+ appDiff["links"] = { status: "extra", current: linked, removed: linked, note: "no links: in the file; up will unlink these" };
1905
+ result.inSync = false;
1906
+ }
1529
1907
  }
1530
1908
  result.apps[app] = appDiff;
1531
1909
  }
1910
+ if (config.plugins) {
1911
+ const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), config.plugins);
1912
+ for (const plan of plans) {
1913
+ if (plan.action === "ok") continue;
1914
+ const status = plan.action === "install" ? "missing" : plan.action === "unknown" ? "unknown" : "changed";
1915
+ 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;
1916
+ result.plugins[plan.name] = { status, desired: plan.desired, current: plan.installed, note };
1917
+ if (plan.action !== "unknown") result.inSync = false;
1918
+ }
1919
+ }
1532
1920
  for (const svc of Object.keys(config.postgres ?? {})) {
1533
1921
  const exists = await ctx.check("postgres:exists", svc);
1534
1922
  result.services[svc] = { status: exists ? "in-sync" : "missing" };
@@ -1542,14 +1930,16 @@ async function computeDiff(ctx, config) {
1542
1930
  return result;
1543
1931
  }
1544
1932
  function maskDiffResult(diff) {
1545
- const masked = { apps: {}, services: diff.services, inSync: diff.inSync, readWarnings: diff.readWarnings };
1933
+ const masked = { apps: {}, services: diff.services, plugins: diff.plugins, dokku: diff.dokku, inSync: diff.inSync, readWarnings: diff.readWarnings };
1546
1934
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1547
1935
  const maskedAppDiff = {};
1548
1936
  for (const [feature, d] of Object.entries(appDiff)) {
1549
1937
  maskedAppDiff[feature] = {
1550
1938
  status: d.status,
1551
1939
  desired: maskSensitiveData(d.desired),
1552
- current: maskSensitiveData(d.current)
1940
+ current: maskSensitiveData(d.current),
1941
+ ...d.removed ? { removed: d.removed } : {},
1942
+ ...d.note ? { note: d.note } : {}
1553
1943
  };
1554
1944
  }
1555
1945
  masked.apps[app] = maskedAppDiff;
@@ -1564,41 +1954,60 @@ function formatSummary(diff) {
1564
1954
  lines.push(` app: ${app}`);
1565
1955
  lines.push(` (in sync)`);
1566
1956
  } else {
1567
- lines.push(` app: ${chalk2.bold(app)}`);
1957
+ lines.push(` app: ${chalk.bold(app)}`);
1568
1958
  for (const [feature, d] of changes) {
1569
- const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
1959
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : d.status === "extra" ? chalk.red("-") : chalk.yellow("~");
1570
1960
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1571
1961
  }
1572
1962
  }
1573
1963
  }
1964
+ if (diff.dokku) {
1965
+ lines.push(` dokku`);
1966
+ lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
1967
+ }
1968
+ for (const [name, d] of Object.entries(diff.plugins)) {
1969
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
1970
+ const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
1971
+ lines.push(` plugin: ${name}`);
1972
+ lines.push(` ${sym} version: ${detail}${d.note ? ` (${d.note})` : ""}`);
1973
+ }
1574
1974
  for (const [svc, d] of Object.entries(diff.services)) {
1575
1975
  if (d.status === "missing") {
1576
1976
  lines.push(` services:`);
1577
- lines.push(` ${chalk2.green("+")} ${svc}: not provisioned`);
1977
+ lines.push(` ${chalk.green("+")} ${svc}: not provisioned`);
1578
1978
  }
1579
1979
  }
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;
1980
+ const total = Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length + (diff.dokku ? 1 : 0);
1581
1981
  if (diff.readWarnings.length > 0) {
1582
1982
  lines.push("");
1583
- lines.push(chalk2.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
1584
- for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
1983
+ lines.push(chalk.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
1984
+ for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
1585
1985
  }
1586
1986
  lines.push("");
1587
1987
  if (total === 0) {
1588
- lines.push(chalk2.green(" Everything in sync."));
1988
+ lines.push(chalk.green(" Everything in sync."));
1589
1989
  } else {
1590
- lines.push(chalk2.yellow(` ${total} resource(s) out of sync.`));
1990
+ lines.push(chalk.yellow(` ${total} resource(s) out of sync.`));
1591
1991
  }
1592
1992
  lines.push("");
1593
1993
  return lines.join("\n");
1594
1994
  }
1595
1995
  function formatFeatureSummary(d) {
1996
+ const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
1997
+ if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
1998
+ const base = formatChange(d);
1999
+ return gone ? `${base}; ${gone}` : base;
2000
+ }
2001
+ function formatChange(d) {
1596
2002
  if (d.status === "unknown") return "(could not read server state \u2014 not reported as missing)";
1597
2003
  if (d.status === "missing") return "(not set on server)";
1598
2004
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
1599
2005
  return `${d.current.length} \u2192 ${d.desired.length} items`;
1600
2006
  }
1601
- return `${JSON.stringify(d.current)} \u2192 ${JSON.stringify(d.desired)}`;
2007
+ return `${JSON.stringify(d.current, showUnset)} \u2192 ${JSON.stringify(d.desired, showUnset)}`;
2008
+ }
2009
+ function showUnset(_key, value) {
2010
+ return value === "\0missing" ? "(unset)" : value;
1602
2011
  }
1603
2012
  function formatVerbose(diff) {
1604
2013
  const lines = [""];
@@ -1608,25 +2017,41 @@ function formatVerbose(diff) {
1608
2017
  for (const [feature, d] of changes) {
1609
2018
  lines.push(`@@ app: ${app} / ${feature} @@`);
1610
2019
  if (d.status === "unknown") {
1611
- lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
2020
+ lines.push(chalk.red(` ? could not read server state \u2014 not reported as missing`));
2021
+ continue;
2022
+ }
2023
+ if (d.status === "extra") {
2024
+ for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r}`));
2025
+ if (d.note) lines.push(` (${d.note})`);
1612
2026
  continue;
1613
2027
  }
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");
1616
- for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
1617
- for (const line of desiredLines) lines.push(chalk2.green(`+ ${line}`));
2028
+ const currentLines = d.current !== void 0 ? JSON.stringify(d.current, showUnset, 2).split("\n") : [];
2029
+ const desiredLines = JSON.stringify(d.desired, showUnset, 2).split("\n");
2030
+ for (const line of currentLines) lines.push(chalk.red(`- ${line}`));
2031
+ for (const line of desiredLines) lines.push(chalk.green(`+ ${line}`));
2032
+ for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
1618
2033
  }
1619
2034
  }
2035
+ if (diff.dokku) {
2036
+ lines.push(`@@ dokku version @@`);
2037
+ lines.push(chalk.red(`- ${diff.dokku.server} (server)`));
2038
+ lines.push(chalk.green(`+ ${diff.dokku.file} (file)`));
2039
+ }
2040
+ for (const [name, d] of Object.entries(diff.plugins)) {
2041
+ lines.push(`@@ plugin: ${name} @@`);
2042
+ if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
2043
+ lines.push(chalk.green(`+ ${d.desired ?? "(any version)"}`) + (d.note ? ` (${d.note})` : ""));
2044
+ }
1620
2045
  for (const [svc, d] of Object.entries(diff.services)) {
1621
2046
  if (d.status === "missing") {
1622
2047
  lines.push(`@@ services @@`);
1623
- lines.push(chalk2.green(`+ ${svc}`));
2048
+ lines.push(chalk.green(`+ ${svc}`));
1624
2049
  }
1625
2050
  }
1626
2051
  if (diff.readWarnings.length > 0) {
1627
2052
  lines.push("");
1628
- lines.push(chalk2.red("Could not read some server state \u2014 treated as unknown, not missing:"));
1629
- for (const w of diff.readWarnings) lines.push(chalk2.red(` - ${w}`));
2053
+ lines.push(chalk.red("Could not read some server state \u2014 treated as unknown, not missing:"));
2054
+ for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
1630
2055
  }
1631
2056
  lines.push("");
1632
2057
  return lines.join("\n");
@@ -1683,7 +2108,7 @@ function validate(filePath) {
1683
2108
  }
1684
2109
 
1685
2110
  // src/index.ts
1686
- import chalk3 from "chalk";
2111
+ import chalk2 from "chalk";
1687
2112
  var require2 = createRequire(import.meta.url);
1688
2113
  var { version } = require2("../package.json");
1689
2114
  var program = new Command().name("dokku-compose").version(version);
@@ -1693,12 +2118,12 @@ function makeRunner(opts) {
1693
2118
  dryRun: opts.dryRun ?? false
1694
2119
  });
1695
2120
  }
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) => {
2121
+ 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
2122
  const config = loadConfig(opts.file);
1698
2123
  const runner = makeRunner(opts);
1699
2124
  const ctx = createContext(runner);
1700
2125
  try {
1701
- await runUp(ctx, config, apps, { failFast: opts.failFast ?? false });
2126
+ await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
1702
2127
  if (opts.dryRun) {
1703
2128
  console.log("\n# Commands that would run:");
1704
2129
  for (const cmd of runner.dryRunLog) {
@@ -1707,7 +2132,7 @@ program.command("up [apps...]").description("Create/update apps and services to
1707
2132
  }
1708
2133
  }
1709
2134
  } catch (error) {
1710
- console.error(chalk3.red(`
2135
+ console.error(chalk2.red(`
1711
2136
  ${error.message}`));
1712
2137
  process.exitCode = 1;
1713
2138
  } finally {
@@ -1725,7 +2150,7 @@ program.command("down [apps...]").description("Destroy apps and services (requir
1725
2150
  try {
1726
2151
  await runDown(ctx, config, apps, { force: true, failFast: opts.failFast ?? false });
1727
2152
  } catch (error) {
1728
- console.error(chalk3.red(`
2153
+ console.error(chalk2.red(`
1729
2154
  ${error.message}`));
1730
2155
  process.exitCode = 1;
1731
2156
  } finally {
@@ -1763,6 +2188,10 @@ program.command("export").description("Export server state to dokku-compose.yml
1763
2188
  } else {
1764
2189
  process.stdout.write(out);
1765
2190
  }
2191
+ } catch (error) {
2192
+ console.error(chalk2.red(`
2193
+ ${error.message}`));
2194
+ process.exitCode = 1;
1766
2195
  } finally {
1767
2196
  await ctx.close();
1768
2197
  }
@@ -1777,6 +2206,10 @@ program.command("diff").description("Show what is out of sync between config and
1777
2206
  const output = opts.verbose ? formatVerbose(diff) : formatSummary(diff);
1778
2207
  process.stdout.write(output);
1779
2208
  process.exitCode = rawDiff.inSync ? 0 : 1;
2209
+ } catch (error) {
2210
+ console.error(chalk2.red(`
2211
+ ${error.message}`));
2212
+ process.exitCode = 1;
1780
2213
  } finally {
1781
2214
  await ctx.close();
1782
2215
  }
@@ -1785,8 +2218,12 @@ program.command("ps [apps...]").description("Show status of configured apps").op
1785
2218
  const config = loadConfig(opts.file);
1786
2219
  const runner = makeRunner({});
1787
2220
  try {
1788
- const { runPs } = await import("./ps-33II4UU3.js");
2221
+ const { runPs } = await import("./ps-IWBYZRDO.js");
1789
2222
  await runPs(runner, config, apps);
2223
+ } catch (error) {
2224
+ console.error(chalk2.red(`
2225
+ ${error.message}`));
2226
+ process.exitCode = 1;
1790
2227
  } finally {
1791
2228
  await runner.close();
1792
2229
  }
@@ -1,6 +1,11 @@
1
+ import {
2
+ assertDokkuSupported
3
+ } from "./chunk-3W53KPE4.js";
4
+
1
5
  // src/commands/ps.ts
2
6
  async function runPs(runner, config, appFilter) {
3
7
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
8
+ await assertDokkuSupported(runner);
4
9
  for (const app of apps) {
5
10
  const exists = await runner.check("apps:exists", app);
6
11
  if (!exists) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dokku-compose",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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",