dokku-compose 0.11.1 → 0.12.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 +11 -0
  2. package/dist/index.js +271 -81
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -104,6 +104,17 @@ Running `up` again produces no changes — every step checks current state befor
104
104
  | `--remove-orphans` | Destroy services and networks not in config |
105
105
  | `--verbose` | Show git-style +/- diff (diff command only) |
106
106
 
107
+ ### Errors
108
+
109
+ A failing Dokku command (mutation) is never reported as a success. On `up` and `down`, a
110
+ global-phase failure (plugins, docker auth, global config, networks, services) aborts the whole
111
+ run immediately. A per-app failure is printed with the command and Dokku's error output, and by
112
+ default the run continues to the next app, exiting non-zero at the end with a summary of which
113
+ apps failed; pass `--fail-fast` to stop at the first failure instead. `diff` never reports a
114
+ resource as missing when it could not actually read server state — it falls back to a per-app
115
+ read and, if that's unreadable too, marks the resource `unknown` and exits non-zero with a
116
+ warning naming what it couldn't read.
117
+
107
118
  ## Features
108
119
 
109
120
  All features are idempotent — running `up` twice produces no changes.
package/dist/index.js CHANGED
@@ -149,6 +149,95 @@ import { execa } from "execa";
149
149
  import { createHash } from "crypto";
150
150
  import * as os from "os";
151
151
  import * as path from "path";
152
+
153
+ // src/core/mask.ts
154
+ var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
155
+ function maskValue(value) {
156
+ if (value.length <= 4) return "****";
157
+ return `****${value.slice(-4)}`;
158
+ }
159
+ function maskSensitiveArgs(cmd) {
160
+ let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
161
+ return `${key}=${maskValue(value)}`;
162
+ });
163
+ masked = masked.replace(
164
+ /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
165
+ (_, prefix, password) => `${prefix}${maskValue(password)}`
166
+ );
167
+ masked = masked.replace(
168
+ /^(\S+:backup-auth\s+\S+\s+)(\S+)(\s+)(\S+)/,
169
+ (_, prefix, accessKeyId, sep, secretAccessKey) => `${prefix}${maskValue(accessKeyId)}${sep}${maskValue(secretAccessKey)}`
170
+ );
171
+ return masked;
172
+ }
173
+ function maskSensitiveArgsList(args) {
174
+ const secrets = [];
175
+ const masked = [...args];
176
+ const mark = (index) => {
177
+ const value = masked[index];
178
+ if (value === void 0 || value === "") return;
179
+ secrets.push(value);
180
+ masked[index] = maskValue(value);
181
+ };
182
+ for (let i = 0; i < masked.length; i++) {
183
+ const m = masked[i].match(/^([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(.+)$/i);
184
+ if (m) {
185
+ secrets.push(m[2]);
186
+ masked[i] = `${m[1]}=${maskValue(m[2])}`;
187
+ }
188
+ }
189
+ if (masked[0] === "registry:login") {
190
+ mark(masked.length - 1);
191
+ }
192
+ if (/^\S+:backup-auth$/.test(masked[0] ?? "")) {
193
+ mark(2);
194
+ mark(3);
195
+ }
196
+ return { masked, secrets };
197
+ }
198
+ function maskSecretsInText(text, secrets) {
199
+ let masked = text;
200
+ for (const secret of secrets) {
201
+ if (!secret) continue;
202
+ masked = masked.split(secret).join(maskValue(secret));
203
+ }
204
+ return masked;
205
+ }
206
+ function maskSensitiveData(data) {
207
+ if (data === null || data === void 0) return data;
208
+ if (typeof data === "string") return data;
209
+ if (Array.isArray(data)) return data.map(maskSensitiveData);
210
+ if (typeof data === "object") {
211
+ const result = {};
212
+ for (const [key, value] of Object.entries(data)) {
213
+ if (typeof value === "string" && SENSITIVE_RE.test(key)) {
214
+ result[key] = maskValue(value);
215
+ } else if (typeof value === "object" && value !== null) {
216
+ result[key] = maskSensitiveData(value);
217
+ } else {
218
+ result[key] = value;
219
+ }
220
+ }
221
+ return result;
222
+ }
223
+ return data;
224
+ }
225
+
226
+ // src/core/dokku.ts
227
+ var DokkuCommandError = class extends Error {
228
+ constructor(args, exitCode, stderr) {
229
+ const { masked, secrets } = maskSensitiveArgsList(args);
230
+ const maskedCmd = masked.join(" ");
231
+ const stderrTail = maskSecretsInText(stderr.trim(), secrets);
232
+ super(
233
+ `dokku ${maskedCmd} failed (exit ${exitCode})` + (stderrTail ? `: ${stderrTail}` : "")
234
+ );
235
+ this.args = args;
236
+ this.exitCode = exitCode;
237
+ this.stderr = stderr;
238
+ this.name = "DokkuCommandError";
239
+ }
240
+ };
152
241
  function createRunner(opts = {}) {
153
242
  const log = [];
154
243
  const controlPath = opts.host ? path.join(os.tmpdir(), `dc-${createHash("sha1").update(opts.host).digest("hex").slice(0, 16)}.sock`) : null;
@@ -160,17 +249,17 @@ function createRunner(opts = {}) {
160
249
  async function execDokku(args) {
161
250
  if (opts.host) {
162
251
  try {
163
- const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)]);
164
- return { stdout: String(result.stdout ?? ""), ok: true };
252
+ const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)], { stdin: "ignore" });
253
+ return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
165
254
  } catch (e) {
166
- return { stdout: String(e.stdout ?? ""), ok: false };
255
+ return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
167
256
  }
168
257
  } else {
169
258
  try {
170
- const result = await execa("dokku", args);
171
- return { stdout: String(result.stdout ?? ""), ok: true };
259
+ const result = await execa("dokku", args, { stdin: "ignore" });
260
+ return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
172
261
  } catch (e) {
173
- return { stdout: String(e.stdout ?? ""), ok: false };
262
+ return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
174
263
  }
175
264
  }
176
265
  }
@@ -181,7 +270,10 @@ function createRunner(opts = {}) {
181
270
  log.push(args.map((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
182
271
  return;
183
272
  }
184
- await execDokku(args);
273
+ const { ok, exitCode, stderr } = await execDokku(args);
274
+ if (!ok) {
275
+ throw new DokkuCommandError(args, exitCode, stderr);
276
+ }
185
277
  },
186
278
  async query(...args) {
187
279
  const { stdout } = await execDokku(args);
@@ -194,7 +286,7 @@ function createRunner(opts = {}) {
194
286
  async close() {
195
287
  if (!opts.host || !controlPath) return;
196
288
  try {
197
- await execa("ssh", ["-O", "exit", "-o", `ControlPath=${controlPath}`, `dokku@${opts.host}`]);
289
+ await execa("ssh", ["-O", "exit", "-o", `ControlPath=${controlPath}`, `dokku@${opts.host}`], { stdin: "ignore" });
198
290
  } catch {
199
291
  }
200
292
  }
@@ -275,6 +367,9 @@ function logDone() {
275
367
  function logSkip() {
276
368
  console.log(`... ${chalk.yellow("already configured")}`);
277
369
  }
370
+ function logError(context, message) {
371
+ console.error(chalk.red(`[${context.padEnd(12)}] ERROR: ${message}`));
372
+ }
278
373
  function logWarn(context, message) {
279
374
  console.warn(chalk.yellow(`[${context.padEnd(12)}] WARN: ${message}`));
280
375
  }
@@ -421,6 +516,10 @@ var Storage = {
421
516
  };
422
517
 
423
518
  // src/resources/properties.ts
519
+ async function isAppDeployed(ctx, target) {
520
+ const raw = await ctx.query("ps:report", target, "--deployed");
521
+ return raw.trim() === "true";
522
+ }
424
523
  function propertyResource(opts) {
425
524
  return {
426
525
  key: opts.key,
@@ -438,7 +537,12 @@ function propertyResource(opts) {
438
537
  }
439
538
  if (opts.afterChange) {
440
539
  for (const cmd of opts.afterChange) {
441
- await ctx.run(cmd, target);
540
+ try {
541
+ await ctx.run(cmd, target);
542
+ } catch (err) {
543
+ if (await isAppDeployed(ctx, target)) throw err;
544
+ logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
545
+ }
442
546
  }
443
547
  }
444
548
  }
@@ -546,15 +650,17 @@ var Config = {
546
650
  var Certs = {
547
651
  key: "ssl",
548
652
  read: async (ctx, target) => {
549
- const raw = await ctx.query("certs:report", target, "--ssl-enabled");
550
- return raw.trim() === "true";
653
+ const raw = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
654
+ if (raw === "") return void 0;
655
+ return raw === "true";
551
656
  },
552
657
  readAll: async (ctx) => {
553
658
  const raw = await ctx.query("certs:report");
554
659
  const bulk = parseBulkReport(raw, "ssl");
555
660
  const result = /* @__PURE__ */ new Map();
556
661
  for (const [app, report] of bulk) {
557
- result.set(app, report["enabled"] === "true");
662
+ const enabled = report["enabled"];
663
+ result.set(app, enabled === void 0 ? void 0 : enabled === "true");
558
664
  }
559
665
  return result;
560
666
  },
@@ -1133,7 +1239,38 @@ async function ensureGlobalNginx(ctx, nginx) {
1133
1239
  }
1134
1240
 
1135
1241
  // src/commands/up.ts
1136
- async function runUp(ctx, config, appFilter) {
1242
+ var UpFailedError = class extends Error {
1243
+ constructor(failures) {
1244
+ super(
1245
+ `up failed for ${failures.length} app(s): ${failures.map((f) => f.app).join(", ")}`
1246
+ );
1247
+ this.failures = failures;
1248
+ this.name = "UpFailedError";
1249
+ }
1250
+ };
1251
+ async function processApp(ctx, config, app, appConfig) {
1252
+ 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);
1258
+ 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);
1272
+ }
1273
+ async function runUp(ctx, config, appFilter, opts = {}) {
1137
1274
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1138
1275
  await ensureDokkuVersion(ctx, config.dokku?.version);
1139
1276
  if (config.plugins) await ensurePlugins(ctx, config.plugins);
@@ -1146,29 +1283,21 @@ async function runUp(ctx, config, appFilter) {
1146
1283
  if (config.postgres) await ensurePostgres(ctx, config.postgres);
1147
1284
  if (config.redis) await ensureRedis(ctx, config.redis);
1148
1285
  if (config.postgres) await ensurePostgresBackups(ctx, config.postgres);
1286
+ const failures = [];
1149
1287
  for (const app of apps) {
1150
1288
  const appConfig = config.apps[app];
1151
1289
  if (!appConfig) continue;
1152
- await reconcile(Apps, ctx, app, true);
1153
- await reconcile(Domains, ctx, app, appConfig.domains);
1154
- await reconcile(Networks, ctx, app, appConfig.networks);
1155
- await reconcile(NetworkProps, ctx, app, appConfig.network);
1156
- await reconcile(Proxy, ctx, app, appConfig.proxy?.enabled);
1157
- await reconcile(Ports, ctx, app, appConfig.ports);
1158
- if (config.postgres || config.redis) {
1159
- await ensureAppLinks(ctx, app, appConfig.links ?? [], config);
1290
+ try {
1291
+ await processApp(ctx, config, app, appConfig);
1292
+ } catch (error) {
1293
+ if (opts.failFast) throw error;
1294
+ const message = error instanceof Error ? error.message : String(error);
1295
+ logError(app, message);
1296
+ failures.push({ app, error });
1160
1297
  }
1161
- await reconcile(Certs, ctx, app, appConfig.ssl);
1162
- await reconcile(Storage, ctx, app, appConfig.storage);
1163
- await reconcile(Nginx, ctx, app, appConfig.nginx);
1164
- await reconcile(Checks, ctx, app, appConfig.checks);
1165
- await reconcile(Logs, ctx, app, appConfig.logs);
1166
- await reconcile(Registry, ctx, app, appConfig.registry);
1167
- await reconcile(Scheduler, ctx, app, appConfig.scheduler);
1168
- await reconcile(Config, ctx, app, appConfig.env);
1169
- await reconcile(Builder, ctx, app, appConfig.build);
1170
- await reconcile(Git, ctx, app, appConfig.git ?? config.git);
1171
- await reconcile(DockerOptions, ctx, app, appConfig.docker_options);
1298
+ }
1299
+ if (failures.length > 0) {
1300
+ throw new UpFailedError(failures);
1172
1301
  }
1173
1302
  }
1174
1303
 
@@ -1191,15 +1320,35 @@ async function exportApps(ctx) {
1191
1320
  }
1192
1321
 
1193
1322
  // src/commands/down.ts
1323
+ var DownFailedError = class extends Error {
1324
+ constructor(failures) {
1325
+ super(
1326
+ `down failed for ${failures.length} app(s): ${failures.map((f) => f.app).join(", ")} \u2014 service and network teardown was skipped because app teardown failed`
1327
+ );
1328
+ this.failures = failures;
1329
+ this.name = "DownFailedError";
1330
+ }
1331
+ };
1194
1332
  async function runDown(ctx, config, appFilter, opts) {
1195
1333
  const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
1334
+ const failures = [];
1196
1335
  for (const app of apps) {
1197
1336
  const appConfig = config.apps[app];
1198
1337
  if (!appConfig) continue;
1199
- if (appConfig.links && (config.postgres || config.redis)) {
1200
- await destroyAppLinks(ctx, app, appConfig.links, config);
1338
+ try {
1339
+ if (appConfig.links && (config.postgres || config.redis)) {
1340
+ await destroyAppLinks(ctx, app, appConfig.links, config);
1341
+ }
1342
+ await destroyApp(ctx, app);
1343
+ } catch (error) {
1344
+ if (opts.failFast) throw error;
1345
+ const message = error instanceof Error ? error.message : String(error);
1346
+ logError(app, message);
1347
+ failures.push({ app, error });
1201
1348
  }
1202
- await destroyApp(ctx, app);
1349
+ }
1350
+ if (failures.length > 0) {
1351
+ throw new DownFailedError(failures);
1203
1352
  }
1204
1353
  if (config.postgres) await destroyPostgres(ctx, config.postgres);
1205
1354
  if (config.redis) await destroyRedis(ctx, config.redis);
@@ -1289,51 +1438,39 @@ async function runExport(ctx, opts) {
1289
1438
  return config;
1290
1439
  }
1291
1440
 
1292
- // src/core/mask.ts
1293
- var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
1294
- function maskValue(value) {
1295
- if (value.length <= 4) return "****";
1296
- return `****${value.slice(-4)}`;
1297
- }
1298
- function maskSensitiveArgs(cmd) {
1299
- let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
1300
- return `${key}=${maskValue(value)}`;
1301
- });
1302
- masked = masked.replace(
1303
- /^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
1304
- (_, prefix, password) => `${prefix}${maskValue(password)}`
1305
- );
1306
- return masked;
1307
- }
1308
- function maskSensitiveData(data) {
1309
- if (data === null || data === void 0) return data;
1310
- if (typeof data === "string") return data;
1311
- if (Array.isArray(data)) return data.map(maskSensitiveData);
1312
- if (typeof data === "object") {
1313
- const result = {};
1314
- for (const [key, value] of Object.entries(data)) {
1315
- if (typeof value === "string" && SENSITIVE_RE.test(key)) {
1316
- result[key] = maskValue(value);
1317
- } else if (typeof value === "object" && value !== null) {
1318
- result[key] = maskSensitiveData(value);
1319
- } else {
1320
- result[key] = value;
1321
- }
1322
- }
1323
- return result;
1324
- }
1325
- return data;
1326
- }
1327
-
1328
1441
  // src/commands/diff.ts
1329
1442
  import chalk2 from "chalk";
1443
+ function diffSslStatus(desired, current) {
1444
+ if (typeof desired !== "object" || desired === null) return void 0;
1445
+ if (current === void 0) return void 0;
1446
+ if (current === true) return "in-sync";
1447
+ if (current === false || current === null) return "missing";
1448
+ return void 0;
1449
+ }
1450
+ function isEmptyValue(value) {
1451
+ return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
1452
+ }
1330
1453
  async function computeDiff(ctx, config) {
1331
- const result = { apps: {}, services: {}, inSync: true };
1454
+ const result = { apps: {}, services: {}, inSync: true, readWarnings: [] };
1455
+ const appNames = Object.keys(config.apps);
1332
1456
  await ensureDokkuVersion(ctx, config.dokku?.version);
1457
+ const appExists = /* @__PURE__ */ new Map();
1458
+ await Promise.all(
1459
+ appNames.map(async (app) => {
1460
+ appExists.set(app, await ctx.check("apps:exists", app));
1461
+ })
1462
+ );
1463
+ const existingAppNames = appNames.filter((app) => appExists.get(app));
1333
1464
  const prefetched = /* @__PURE__ */ new Map();
1334
1465
  await Promise.all(
1335
1466
  ALL_APP_RESOURCES.filter((r) => !r.forceApply && !r.key.startsWith("_") && r.readAll).map(async (r) => {
1336
- prefetched.set(r.key, await r.readAll(ctx));
1467
+ try {
1468
+ const bulk = await r.readAll(ctx);
1469
+ const unreadable = existingAppNames.length > 0 && bulk.size === 0;
1470
+ prefetched.set(r.key, unreadable ? null : bulk);
1471
+ } catch {
1472
+ prefetched.set(r.key, null);
1473
+ }
1337
1474
  })
1338
1475
  );
1339
1476
  for (const [app, appConfig] of Object.entries(config.apps)) {
@@ -1348,12 +1485,41 @@ async function computeDiff(ctx, config) {
1348
1485
  desired = appConfig[resource.key];
1349
1486
  }
1350
1487
  if (desired === void 0) continue;
1488
+ if (!appExists.get(app)) {
1489
+ appDiff[resource.key] = { status: "missing", desired, current: void 0 };
1490
+ result.inSync = false;
1491
+ continue;
1492
+ }
1351
1493
  const bulk = prefetched.get(resource.key);
1352
- const current = bulk ? bulk.get(app) : await resource.read(ctx, app);
1494
+ let current;
1495
+ let bulkUnreadable = false;
1496
+ if (bulk === null) {
1497
+ bulkUnreadable = true;
1498
+ current = await resource.read(ctx, app);
1499
+ } else if (bulk) {
1500
+ current = bulk.get(app);
1501
+ if (resource.key === "ssl" && current === void 0) {
1502
+ current = await resource.read(ctx, app);
1503
+ if (current === void 0) bulkUnreadable = true;
1504
+ }
1505
+ } else {
1506
+ current = await resource.read(ctx, app);
1507
+ }
1508
+ const sslStatus = resource.key === "ssl" ? diffSslStatus(desired, current) : void 0;
1509
+ if (sslStatus) {
1510
+ appDiff[resource.key] = { status: sslStatus, desired, current };
1511
+ if (sslStatus !== "in-sync") result.inSync = false;
1512
+ continue;
1513
+ }
1353
1514
  const change = computeChange(current, desired);
1354
1515
  if (!change.changed) {
1355
1516
  appDiff[resource.key] = { status: "in-sync", desired, current };
1356
- } else if (current === null || current === void 0 || Array.isArray(current) && current.length === 0 || typeof current === "object" && Object.keys(current).length === 0) {
1517
+ } else if (bulkUnreadable && isEmptyValue(current)) {
1518
+ appDiff[resource.key] = { status: "unknown", desired, current };
1519
+ result.inSync = false;
1520
+ const msg = `could not read '${resource.key}' for app '${app}' (bulk report was empty and the per-app read returned nothing)`;
1521
+ result.readWarnings.push(msg);
1522
+ } else if (isEmptyValue(current)) {
1357
1523
  appDiff[resource.key] = { status: "missing", desired, current };
1358
1524
  result.inSync = false;
1359
1525
  } else {
@@ -1376,7 +1542,7 @@ async function computeDiff(ctx, config) {
1376
1542
  return result;
1377
1543
  }
1378
1544
  function maskDiffResult(diff) {
1379
- const masked = { apps: {}, services: diff.services, inSync: diff.inSync };
1545
+ const masked = { apps: {}, services: diff.services, inSync: diff.inSync, readWarnings: diff.readWarnings };
1380
1546
  for (const [app, appDiff] of Object.entries(diff.apps)) {
1381
1547
  const maskedAppDiff = {};
1382
1548
  for (const [feature, d] of Object.entries(appDiff)) {
@@ -1400,7 +1566,7 @@ function formatSummary(diff) {
1400
1566
  } else {
1401
1567
  lines.push(` app: ${chalk2.bold(app)}`);
1402
1568
  for (const [feature, d] of changes) {
1403
- const sym = d.status === "missing" ? chalk2.green("+") : chalk2.yellow("~");
1569
+ const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
1404
1570
  lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
1405
1571
  }
1406
1572
  }
@@ -1412,6 +1578,11 @@ function formatSummary(diff) {
1412
1578
  }
1413
1579
  }
1414
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;
1581
+ if (diff.readWarnings.length > 0) {
1582
+ 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}`));
1585
+ }
1415
1586
  lines.push("");
1416
1587
  if (total === 0) {
1417
1588
  lines.push(chalk2.green(" Everything in sync."));
@@ -1422,6 +1593,7 @@ function formatSummary(diff) {
1422
1593
  return lines.join("\n");
1423
1594
  }
1424
1595
  function formatFeatureSummary(d) {
1596
+ if (d.status === "unknown") return "(could not read server state \u2014 not reported as missing)";
1425
1597
  if (d.status === "missing") return "(not set on server)";
1426
1598
  if (Array.isArray(d.desired) && Array.isArray(d.current)) {
1427
1599
  return `${d.current.length} \u2192 ${d.desired.length} items`;
@@ -1435,6 +1607,10 @@ function formatVerbose(diff) {
1435
1607
  if (changes.length === 0) continue;
1436
1608
  for (const [feature, d] of changes) {
1437
1609
  lines.push(`@@ app: ${app} / ${feature} @@`);
1610
+ if (d.status === "unknown") {
1611
+ lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
1612
+ continue;
1613
+ }
1438
1614
  const currentLines = d.current !== void 0 ? JSON.stringify(d.current, null, 2).split("\n") : [];
1439
1615
  const desiredLines = JSON.stringify(d.desired, null, 2).split("\n");
1440
1616
  for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
@@ -1447,6 +1623,11 @@ function formatVerbose(diff) {
1447
1623
  lines.push(chalk2.green(`+ ${svc}`));
1448
1624
  }
1449
1625
  }
1626
+ if (diff.readWarnings.length > 0) {
1627
+ 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}`));
1630
+ }
1450
1631
  lines.push("");
1451
1632
  return lines.join("\n");
1452
1633
  }
@@ -1502,6 +1683,7 @@ function validate(filePath) {
1502
1683
  }
1503
1684
 
1504
1685
  // src/index.ts
1686
+ import chalk3 from "chalk";
1505
1687
  var require2 = createRequire(import.meta.url);
1506
1688
  var { version } = require2("../package.json");
1507
1689
  var program = new Command().name("dokku-compose").version(version);
@@ -1516,7 +1698,7 @@ program.command("up [apps...]").description("Create/update apps and services to
1516
1698
  const runner = makeRunner(opts);
1517
1699
  const ctx = createContext(runner);
1518
1700
  try {
1519
- await runUp(ctx, config, apps);
1701
+ await runUp(ctx, config, apps, { failFast: opts.failFast ?? false });
1520
1702
  if (opts.dryRun) {
1521
1703
  console.log("\n# Commands that would run:");
1522
1704
  for (const cmd of runner.dryRunLog) {
@@ -1524,11 +1706,15 @@ program.command("up [apps...]").description("Create/update apps and services to
1524
1706
  console.log(`dokku ${line}`);
1525
1707
  }
1526
1708
  }
1709
+ } catch (error) {
1710
+ console.error(chalk3.red(`
1711
+ ${error.message}`));
1712
+ process.exitCode = 1;
1527
1713
  } finally {
1528
1714
  await ctx.close();
1529
1715
  }
1530
1716
  });
1531
- program.command("down [apps...]").description("Destroy apps and services (requires --force)").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--force", "Required to destroy apps").action(async (apps, opts) => {
1717
+ program.command("down [apps...]").description("Destroy apps and services (requires --force)").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--force", "Required to destroy apps").option("--fail-fast", "Stop on first error").action(async (apps, opts) => {
1532
1718
  if (!opts.force) {
1533
1719
  console.error("--force required");
1534
1720
  process.exit(1);
@@ -1537,7 +1723,11 @@ program.command("down [apps...]").description("Destroy apps and services (requir
1537
1723
  const runner = makeRunner({});
1538
1724
  const ctx = createContext(runner);
1539
1725
  try {
1540
- await runDown(ctx, config, apps, { force: true });
1726
+ await runDown(ctx, config, apps, { force: true, failFast: opts.failFast ?? false });
1727
+ } catch (error) {
1728
+ console.error(chalk3.red(`
1729
+ ${error.message}`));
1730
+ process.exitCode = 1;
1541
1731
  } finally {
1542
1732
  await ctx.close();
1543
1733
  }
@@ -1586,7 +1776,7 @@ program.command("diff").description("Show what is out of sync between config and
1586
1776
  const diff = opts.sensitive ? rawDiff : maskDiffResult(rawDiff);
1587
1777
  const output = opts.verbose ? formatVerbose(diff) : formatSummary(diff);
1588
1778
  process.stdout.write(output);
1589
- process.exit(rawDiff.inSync ? 0 : 1);
1779
+ process.exitCode = rawDiff.inSync ? 0 : 1;
1590
1780
  } finally {
1591
1781
  await ctx.close();
1592
1782
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dokku-compose",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",