@envsync-cloud/deploy-cli 0.6.3 → 0.6.4

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 +13 -6
  2. package/dist/index.js +269 -35
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -40,13 +40,13 @@ bunx @envsync-cloud/deploy-cli <command>
40
40
  ```text
41
41
  envsync-deploy preinstall
42
42
  envsync-deploy setup
43
- envsync-deploy bootstrap
44
- envsync-deploy deploy
43
+ envsync-deploy bootstrap [--dry-run]
44
+ envsync-deploy deploy [--dry-run]
45
45
  envsync-deploy health [--json]
46
- envsync-deploy upgrade
47
- envsync-deploy upgrade-deps
48
- envsync-deploy backup
49
- envsync-deploy restore <archive>
46
+ envsync-deploy upgrade [--dry-run]
47
+ envsync-deploy upgrade-deps [--dry-run]
48
+ envsync-deploy backup [--dry-run]
49
+ envsync-deploy restore <archive> [--dry-run]
50
50
  ```
51
51
 
52
52
  ## Quick Start
@@ -100,6 +100,13 @@ Restore from an existing backup archive:
100
100
  npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
101
101
  ```
102
102
 
103
+ Preview mutating commands without changing the host:
104
+
105
+ ```bash
106
+ npx @envsync-cloud/deploy-cli bootstrap --dry-run
107
+ npx @envsync-cloud/deploy-cli deploy --dry-run
108
+ ```
109
+
103
110
  ## Links
104
111
 
105
112
  - Repository: https://github.com/EnvSync-Cloud/envsync
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { spawnSync } from "child_process";
6
6
  import fs from "fs";
7
7
  import path from "path";
8
8
  import readline from "readline";
9
+ import chalk from "chalk";
9
10
  var HOST_ROOT = "/opt/envsync";
10
11
  var DEPLOY_ROOT = "/opt/envsync/deploy";
11
12
  var RELEASES_ROOT = "/opt/envsync/releases";
@@ -47,6 +48,33 @@ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
47
48
  "OPENFGA_MODEL_ID"
48
49
  ];
49
50
  var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
51
+ var currentOptions = { dryRun: false };
52
+ function formatShellArg(arg) {
53
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg;
54
+ return JSON.stringify(arg);
55
+ }
56
+ function formatCommand(cmd, args) {
57
+ return [cmd, ...args].map(formatShellArg).join(" ");
58
+ }
59
+ function logSection(title) {
60
+ console.log(`
61
+ ${chalk.bold.blue(title)}`);
62
+ }
63
+ function logStep(message) {
64
+ console.log(`${chalk.cyan("[step]")} ${message}`);
65
+ }
66
+ function logInfo(message) {
67
+ console.log(`${chalk.blue("[info]")} ${message}`);
68
+ }
69
+ function logSuccess(message) {
70
+ console.log(`${chalk.green("[ok]")} ${message}`);
71
+ }
72
+ function logDryRun(message) {
73
+ console.log(`${chalk.magenta("[dry-run]")} ${message}`);
74
+ }
75
+ function logCommand(cmd, args) {
76
+ console.log(chalk.dim(`$ ${formatCommand(cmd, args)}`));
77
+ }
50
78
  function run(cmd, args, opts = {}) {
51
79
  const result = spawnSync(cmd, args, {
52
80
  cwd: opts.cwd,
@@ -85,6 +113,13 @@ function writeFile(target, content, mode) {
85
113
  fs.writeFileSync(target, content, "utf8");
86
114
  if (mode != null) fs.chmodSync(target, mode);
87
115
  }
116
+ function writeFileMaybe(target, content, mode) {
117
+ if (currentOptions.dryRun) {
118
+ logDryRun(`Would write ${target}`);
119
+ return;
120
+ }
121
+ writeFile(target, content, mode);
122
+ }
88
123
  function exists(target) {
89
124
  return fs.existsSync(target);
90
125
  }
@@ -767,8 +802,9 @@ ${renderEnvList({
767
802
  KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
768
803
  KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
769
804
  KC_HTTP_ENABLED: "true",
805
+ KC_HEALTH_ENABLED: "true",
770
806
  KC_PROXY_HEADERS: "xforwarded",
771
- KC_HOSTNAME: hosts.auth
807
+ KC_HOSTNAME_STRICT: "false"
772
808
  })}
773
809
  volumes:
774
810
  - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
@@ -897,40 +933,59 @@ volumes:
897
933
  }
898
934
  function writeDeployArtifacts(config, generated) {
899
935
  const runtimeEnv = buildRuntimeEnv(config, generated);
900
- writeFile(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
901
- writeFile(
936
+ logStep("Rendering deploy artifacts");
937
+ writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
938
+ writeFileMaybe(
902
939
  INTERNAL_CONFIG_JSON,
903
940
  JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
904
941
  );
905
- writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
906
- writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
907
- writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
908
- writeFile(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
909
- writeFile(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
910
- writeFile(STACK_FILE, renderStack(config, runtimeEnv, "full"));
911
- writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
912
- writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
913
- writeFile(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
942
+ writeFileMaybe(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
943
+ writeFileMaybe(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
944
+ writeFileMaybe(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
945
+ writeFileMaybe(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
946
+ writeFileMaybe(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
947
+ writeFileMaybe(STACK_FILE, renderStack(config, runtimeEnv, "full"));
948
+ writeFileMaybe(NGINX_WEB_CONF, renderNginxConf("web"));
949
+ writeFileMaybe(NGINX_LANDING_CONF, renderNginxConf("landing"));
950
+ writeFileMaybe(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
951
+ logSuccess(currentOptions.dryRun ? "Deploy artifacts previewed" : "Deploy artifacts written");
914
952
  }
915
953
  function saveDesiredConfig(config) {
916
954
  const internal = readInternalState();
917
955
  const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
918
- writeFile(DEPLOY_YAML, toYaml(config) + "\n");
919
- writeFile(
956
+ logStep(`Saving desired config to ${DEPLOY_YAML}`);
957
+ writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
958
+ writeFileMaybe(
920
959
  INTERNAL_CONFIG_JSON,
921
960
  JSON.stringify({ config, generated }, null, 2) + "\n"
922
961
  );
962
+ logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
923
963
  }
924
964
  function ensureRepoCheckout(config) {
965
+ logStep(`Ensuring pinned repo checkout at ${config.source.ref}`);
966
+ if (currentOptions.dryRun) {
967
+ logDryRun(`Would ensure repo checkout at ${REPO_ROOT}`);
968
+ return;
969
+ }
925
970
  ensureDir(REPO_ROOT);
926
971
  if (!exists(path.join(REPO_ROOT, ".git"))) {
972
+ logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
927
973
  run("git", ["clone", config.source.repo_url, REPO_ROOT]);
928
974
  }
975
+ logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
929
976
  run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
977
+ logCommand("git", ["fetch", "--tags", "--force", "origin"]);
930
978
  run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
979
+ logCommand("git", ["checkout", "--force", config.source.ref]);
931
980
  run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
981
+ logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
932
982
  }
933
983
  function extractStaticBundle(image, targetDir) {
984
+ logStep(`Extracting static bundle from ${image}`);
985
+ if (currentOptions.dryRun) {
986
+ logDryRun(`Would extract ${image} into ${targetDir}`);
987
+ return;
988
+ }
934
989
  ensureDir(targetDir);
935
990
  const containerId = run("docker", ["create", image], { quiet: true }).trim();
936
991
  try {
@@ -938,24 +993,43 @@ function extractStaticBundle(image, targetDir) {
938
993
  } finally {
939
994
  run("docker", ["rm", "-f", containerId], { quiet: true });
940
995
  }
996
+ logSuccess(`Static bundle extracted to ${targetDir}`);
941
997
  }
942
998
  function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
943
999
  const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
944
1000
  if (!exists(path.join(buildContext, "Dockerfile"))) {
945
1001
  throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
946
1002
  }
1003
+ logStep(`Building Keycloak image ${imageTag}`);
1004
+ if (currentOptions.dryRun) {
1005
+ logDryRun(`Would build ${imageTag} from ${buildContext}`);
1006
+ return;
1007
+ }
1008
+ logCommand("docker", ["build", "-t", imageTag, buildContext]);
947
1009
  run("docker", ["build", "-t", imageTag, buildContext]);
1010
+ logSuccess(`Built Keycloak image ${imageTag}`);
948
1011
  }
949
1012
  function stackNetworkName(config) {
950
1013
  return `${config.services.stack_name}_envsync`;
951
1014
  }
952
1015
  function assertSwarmManager() {
1016
+ if (currentOptions.dryRun) {
1017
+ logDryRun("Skipping Docker Swarm manager validation");
1018
+ return;
1019
+ }
1020
+ logStep("Validating Docker Swarm manager state");
953
1021
  const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
954
1022
  if (state !== "active|true") {
955
1023
  throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
956
1024
  }
1025
+ logSuccess("Docker Swarm manager is ready");
957
1026
  }
958
1027
  function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
1028
+ if (currentOptions.dryRun) {
1029
+ logDryRun(`Would wait for ${label}`);
1030
+ return;
1031
+ }
1032
+ logStep(`Waiting for ${label}`);
959
1033
  const deadline = Date.now() + timeoutSeconds * 1e3;
960
1034
  while (Date.now() < deadline) {
961
1035
  const args = ["run", "--rm", "--network", stackNetworkName(config)];
@@ -967,6 +1041,7 @@ function waitForCommand(config, label, image, command, timeoutSeconds = 120, env
967
1041
  }
968
1042
  args.push(image, "sh", "-lc", command);
969
1043
  if (commandSucceeds("docker", args)) {
1044
+ logSuccess(`${label} is ready`);
970
1045
  return;
971
1046
  }
972
1047
  sleepSeconds(2);
@@ -998,6 +1073,23 @@ function waitForHttpService(config, label, url, timeoutSeconds = 120) {
998
1073
  waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
999
1074
  }
1000
1075
  function runOpenFgaMigrate(config, runtimeEnv) {
1076
+ logStep("Running OpenFGA datastore migrations");
1077
+ if (currentOptions.dryRun) {
1078
+ logDryRun("Would run OpenFGA datastore migrations");
1079
+ logCommand("docker", [
1080
+ "run",
1081
+ "--rm",
1082
+ "--network",
1083
+ stackNetworkName(config),
1084
+ "-e",
1085
+ "OPENFGA_DATASTORE_ENGINE=postgres",
1086
+ "-e",
1087
+ `OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
1088
+ "openfga/openfga:v1.12.0",
1089
+ "migrate"
1090
+ ]);
1091
+ return;
1092
+ }
1001
1093
  run("docker", [
1002
1094
  "run",
1003
1095
  "--rm",
@@ -1010,8 +1102,28 @@ function runOpenFgaMigrate(config, runtimeEnv) {
1010
1102
  "openfga/openfga:v1.12.0",
1011
1103
  "migrate"
1012
1104
  ]);
1105
+ logSuccess("OpenFGA datastore migrations completed");
1013
1106
  }
1014
1107
  function runMiniKmsMigrate(config, runtimeEnv) {
1108
+ logStep("Running miniKMS datastore migrations");
1109
+ if (currentOptions.dryRun) {
1110
+ logDryRun("Would run miniKMS datastore migrations");
1111
+ logCommand("docker", [
1112
+ "run",
1113
+ "--rm",
1114
+ "--network",
1115
+ stackNetworkName(config),
1116
+ "-e",
1117
+ `PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
1118
+ "-v",
1119
+ `${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
1120
+ "postgres:17",
1121
+ "sh",
1122
+ "-lc",
1123
+ "psql -h minikms_db -U postgres -d minikms -f /migrations/001_initial_schema.sql && psql -h minikms_db -U postgres -d minikms -f /migrations/002_vault_storage.sql"
1124
+ ]);
1125
+ return;
1126
+ }
1015
1127
  run("docker", [
1016
1128
  "run",
1017
1129
  "--rm",
@@ -1026,8 +1138,35 @@ function runMiniKmsMigrate(config, runtimeEnv) {
1026
1138
  "-lc",
1027
1139
  "psql -h minikms_db -U postgres -d minikms -f /migrations/001_initial_schema.sql && psql -h minikms_db -U postgres -d minikms -f /migrations/002_vault_storage.sql"
1028
1140
  ]);
1141
+ logSuccess("miniKMS datastore migrations completed");
1029
1142
  }
1030
1143
  function runBootstrapInit(config) {
1144
+ logStep("Running API bootstrap init");
1145
+ if (currentOptions.dryRun) {
1146
+ logDryRun("Would run API bootstrap init and persist generated OpenFGA IDs");
1147
+ logCommand("docker", [
1148
+ "run",
1149
+ "--rm",
1150
+ "--network",
1151
+ stackNetworkName(config),
1152
+ "--env-file",
1153
+ DEPLOY_ENV,
1154
+ "-e",
1155
+ "SKIP_ROOT_ENV=1",
1156
+ "-e",
1157
+ "SKIP_ROOT_ENV_WRITE=1",
1158
+ config.images.api,
1159
+ "bun",
1160
+ "run",
1161
+ "scripts/prod-init.ts",
1162
+ "--json",
1163
+ "--no-write-root-env"
1164
+ ]);
1165
+ return {
1166
+ openfgaStoreId: "",
1167
+ openfgaModelId: ""
1168
+ };
1169
+ }
1031
1170
  const output = run(
1032
1171
  "docker",
1033
1172
  [
@@ -1054,6 +1193,7 @@ function runBootstrapInit(config) {
1054
1193
  if (!result.openfgaStoreId || !result.openfgaModelId) {
1055
1194
  throw new Error("Bootstrap init did not return OpenFGA IDs");
1056
1195
  }
1196
+ logSuccess("API bootstrap init completed");
1057
1197
  return {
1058
1198
  openfgaStoreId: result.openfgaStoreId,
1059
1199
  openfgaModelId: result.openfgaModelId
@@ -1141,6 +1281,7 @@ async function cmdPreinstall() {
1141
1281
  run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
1142
1282
  }
1143
1283
  async function cmdSetup() {
1284
+ logSection("Setup");
1144
1285
  const rootDomain = await ask("Root domain", "example.com");
1145
1286
  const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
1146
1287
  const releaseVersion = await ask("Release version", getDeployCliVersion());
@@ -1216,20 +1357,30 @@ async function cmdSetup() {
1216
1357
  }
1217
1358
  };
1218
1359
  saveDesiredConfig(config);
1219
- console.log(`Config written to ${DEPLOY_YAML}`);
1220
- console.log(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
1221
- console.log("Create these DNS records:");
1360
+ logSuccess(`Config written to ${DEPLOY_YAML}`);
1361
+ logInfo(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
1362
+ logInfo("Create these DNS records:");
1222
1363
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
1223
1364
  }
1224
1365
  async function cmdBootstrap() {
1366
+ logSection("Bootstrap");
1225
1367
  const { config, generated } = loadState();
1226
1368
  const nextGenerated = ensureGeneratedRuntimeState(generated);
1227
1369
  const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
1370
+ logInfo(`Release version: ${config.release.version}`);
1228
1371
  assertSwarmManager();
1229
1372
  ensureRepoCheckout(config);
1230
1373
  writeDeployArtifacts(config, nextGenerated);
1231
1374
  buildKeycloakImage(config.images.keycloak);
1232
- run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1375
+ if (currentOptions.dryRun) {
1376
+ logDryRun(`Would deploy base bootstrap stack for ${config.services.stack_name}`);
1377
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1378
+ } else {
1379
+ logStep("Deploying base bootstrap stack");
1380
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1381
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1382
+ logSuccess("Base bootstrap stack deployed");
1383
+ }
1233
1384
  waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
1234
1385
  waitForRedisService(config);
1235
1386
  waitForTcpService(config, "rustfs", "rustfs", 9e3);
@@ -1238,11 +1389,24 @@ async function cmdBootstrap() {
1238
1389
  waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
1239
1390
  runOpenFgaMigrate(config, runtimeEnv);
1240
1391
  runMiniKmsMigrate(config, runtimeEnv);
1241
- run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1242
- waitForHttpService(config, "keycloak", "http://keycloak:8080/realms/master");
1392
+ if (currentOptions.dryRun) {
1393
+ logDryRun(`Would deploy runtime bootstrap stack for ${config.services.stack_name}`);
1394
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1395
+ } else {
1396
+ logStep("Deploying runtime bootstrap stack");
1397
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1398
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1399
+ logSuccess("Runtime bootstrap stack deployed");
1400
+ }
1401
+ waitForHttpService(config, "keycloak", "http://keycloak:8080/health/ready", 180);
1243
1402
  waitForHttpService(config, "openfga", "http://openfga:8090/stores");
1244
1403
  waitForTcpService(config, "minikms", "minikms", 50051);
1245
1404
  const initResult = runBootstrapInit(config);
1405
+ if (currentOptions.dryRun) {
1406
+ logDryRun("Skipping generated OpenFGA ID persistence in preview mode");
1407
+ logSuccess("Bootstrap dry-run completed");
1408
+ return;
1409
+ }
1246
1410
  const bootstrappedGenerated = normalizeGeneratedState({
1247
1411
  openfga: {
1248
1412
  store_id: initResult.openfgaStoreId,
@@ -1254,26 +1418,46 @@ async function cmdBootstrap() {
1254
1418
  }
1255
1419
  });
1256
1420
  writeDeployArtifacts(config, bootstrappedGenerated);
1257
- console.log("Bootstrap completed.");
1421
+ logSuccess("Bootstrap completed");
1258
1422
  }
1259
1423
  async function cmdDeploy() {
1424
+ logSection("Deploy");
1260
1425
  const { config, generated } = loadState();
1426
+ logInfo(`Release version: ${config.release.version}`);
1261
1427
  assertSwarmManager();
1262
1428
  assertBootstrapState(generated);
1263
- const services = listStackServices(config);
1264
- if (serviceHealth(services, `${config.services.stack_name}_keycloak`) === "missing" || serviceHealth(services, `${config.services.stack_name}_openfga`) === "missing" || serviceHealth(services, `${config.services.stack_name}_minikms`) === "missing") {
1265
- throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
1429
+ if (!currentOptions.dryRun) {
1430
+ const services = listStackServices(config);
1431
+ if (serviceHealth(services, `${config.services.stack_name}_keycloak`) === "missing" || serviceHealth(services, `${config.services.stack_name}_openfga`) === "missing" || serviceHealth(services, `${config.services.stack_name}_minikms`) === "missing") {
1432
+ throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
1433
+ }
1434
+ } else {
1435
+ logDryRun("Skipping runtime bootstrap service validation");
1266
1436
  }
1267
1437
  ensureRepoCheckout(config);
1268
1438
  writeDeployArtifacts(config, generated);
1269
1439
  buildKeycloakImage(config.images.keycloak);
1270
- ensureDir(`${RELEASES_ROOT}/web/current`);
1271
- ensureDir(`${RELEASES_ROOT}/landing/current`);
1440
+ if (currentOptions.dryRun) {
1441
+ logDryRun(`Would ensure ${RELEASES_ROOT}/web/current exists`);
1442
+ logDryRun(`Would ensure ${RELEASES_ROOT}/landing/current exists`);
1443
+ } else {
1444
+ ensureDir(`${RELEASES_ROOT}/web/current`);
1445
+ ensureDir(`${RELEASES_ROOT}/landing/current`);
1446
+ }
1272
1447
  extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
1273
1448
  extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
1274
- writeFile(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1275
- writeFile(`${RELEASES_ROOT}/landing/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1449
+ writeFileMaybe(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1450
+ writeFileMaybe(`${RELEASES_ROOT}/landing/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1451
+ if (currentOptions.dryRun) {
1452
+ logDryRun(`Would deploy full stack for ${config.services.stack_name}`);
1453
+ logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1454
+ logSuccess("Deploy dry-run completed");
1455
+ return;
1456
+ }
1457
+ logStep("Deploying full stack");
1458
+ logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1276
1459
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1460
+ logSuccess("Deploy completed");
1277
1461
  }
1278
1462
  async function cmdHealth(asJson) {
1279
1463
  const { config, generated } = loadState();
@@ -1314,20 +1498,28 @@ async function cmdHealth(asJson) {
1314
1498
  console.log(JSON.stringify(checks, null, 2));
1315
1499
  }
1316
1500
  async function cmdUpgrade() {
1501
+ logSection("Upgrade");
1317
1502
  const { config } = loadState();
1318
1503
  config.images = {
1319
1504
  ...config.images,
1320
1505
  ...versionedImages(config.release.version)
1321
1506
  };
1322
1507
  saveDesiredConfig(config);
1508
+ if (currentOptions.dryRun) {
1509
+ logDryRun(`Would upgrade stack to release ${config.release.version}`);
1510
+ }
1323
1511
  await cmdDeploy();
1324
1512
  }
1325
1513
  async function cmdUpgradeDeps() {
1514
+ logSection("Upgrade Dependencies");
1326
1515
  const { config } = loadState();
1327
1516
  config.images.traefik = "traefik:v3.1";
1328
1517
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
1329
1518
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
1330
1519
  saveDesiredConfig(config);
1520
+ if (currentOptions.dryRun) {
1521
+ logDryRun("Would refresh dependency image tags and redeploy");
1522
+ }
1331
1523
  await cmdDeploy();
1332
1524
  }
1333
1525
  function sha256File(filePath) {
@@ -1339,6 +1531,11 @@ function stackVolumeName(config, name) {
1339
1531
  return `${config.services.stack_name}_${name}`;
1340
1532
  }
1341
1533
  function backupDockerVolume(volumeName, targetDir) {
1534
+ logStep(`Backing up Docker volume ${volumeName}`);
1535
+ if (currentOptions.dryRun) {
1536
+ logDryRun(`Would back up ${volumeName} into ${targetDir}`);
1537
+ return;
1538
+ }
1342
1539
  ensureDir(targetDir);
1343
1540
  run("docker", [
1344
1541
  "run",
@@ -1352,8 +1549,14 @@ function backupDockerVolume(volumeName, targetDir) {
1352
1549
  "-lc",
1353
1550
  "cd /from && tar -czf /to/volume.tar.gz ."
1354
1551
  ]);
1552
+ logSuccess(`Backed up Docker volume ${volumeName}`);
1355
1553
  }
1356
1554
  function restoreDockerVolume(volumeName, sourceDir) {
1555
+ logStep(`Restoring Docker volume ${volumeName}`);
1556
+ if (currentOptions.dryRun) {
1557
+ logDryRun(`Would restore ${volumeName} from ${sourceDir}`);
1558
+ return;
1559
+ }
1357
1560
  run("docker", ["volume", "create", volumeName], { quiet: true });
1358
1561
  run("docker", [
1359
1562
  "run",
@@ -1367,15 +1570,29 @@ function restoreDockerVolume(volumeName, sourceDir) {
1367
1570
  "-lc",
1368
1571
  "cd /to && tar -xzf /from/volume.tar.gz"
1369
1572
  ]);
1573
+ logSuccess(`Restored Docker volume ${volumeName}`);
1370
1574
  }
1371
1575
  async function cmdBackup() {
1576
+ logSection("Backup");
1372
1577
  const { config } = loadState();
1373
- ensureDir(config.backup.output_dir);
1374
1578
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
1375
1579
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
1376
1580
  const manifestPath = `${archiveBase}.manifest.json`;
1377
1581
  const tarPath = `${archiveBase}.tar.gz`;
1378
1582
  const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
1583
+ logInfo(`Backup archive target: ${tarPath}`);
1584
+ if (currentOptions.dryRun) {
1585
+ logDryRun(`Would stage backup files in ${staged}`);
1586
+ for (const volume of STACK_VOLUMES) {
1587
+ backupDockerVolume(stackVolumeName(config, volume), path.join(staged, "volumes", volume));
1588
+ }
1589
+ logDryRun(`Would write manifest ${manifestPath}`);
1590
+ logDryRun(`Would create archive ${tarPath}`);
1591
+ logSuccess("Backup dry-run completed");
1592
+ console.log(tarPath);
1593
+ return;
1594
+ }
1595
+ ensureDir(config.backup.output_dir);
1379
1596
  ensureDir(staged);
1380
1597
  writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
1381
1598
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
@@ -1400,11 +1617,21 @@ async function cmdBackup() {
1400
1617
  volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
1401
1618
  };
1402
1619
  writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
1620
+ logSuccess("Backup completed");
1403
1621
  console.log(tarPath);
1404
1622
  }
1405
1623
  async function cmdRestore(archivePath) {
1406
1624
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
1625
+ logSection("Restore");
1407
1626
  const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
1627
+ logInfo(`Restore archive: ${archivePath}`);
1628
+ if (currentOptions.dryRun) {
1629
+ logDryRun(`Would extract ${archivePath} into ${restoreRoot}`);
1630
+ logDryRun(`Would restore deploy files into ${DEPLOY_ROOT} and ${ETC_ROOT}`);
1631
+ logDryRun("Would restore all managed Docker volumes from the archive");
1632
+ logSuccess("Restore dry-run completed");
1633
+ return;
1634
+ }
1408
1635
  ensureDir(restoreRoot);
1409
1636
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
1410
1637
  writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
@@ -1421,11 +1648,16 @@ async function cmdRestore(archivePath) {
1421
1648
  for (const volume of STACK_VOLUMES) {
1422
1649
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
1423
1650
  }
1424
- console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
1651
+ logSuccess("Restore completed. Run 'envsync-deploy deploy' to start services.");
1425
1652
  }
1426
1653
  async function main() {
1427
- const command = process.argv[2];
1428
- const flag = process.argv[3];
1654
+ const argv = process.argv.slice(2);
1655
+ const command = argv[0];
1656
+ const args = argv.slice(1);
1657
+ currentOptions = {
1658
+ dryRun: args.includes("--dry-run")
1659
+ };
1660
+ const positionals = args.filter((arg) => arg !== "--dry-run");
1429
1661
  switch (command) {
1430
1662
  case "preinstall":
1431
1663
  await cmdPreinstall();
@@ -1440,7 +1672,7 @@ async function main() {
1440
1672
  await cmdDeploy();
1441
1673
  break;
1442
1674
  case "health":
1443
- await cmdHealth(flag === "--json");
1675
+ await cmdHealth(positionals[0] === "--json");
1444
1676
  break;
1445
1677
  case "upgrade":
1446
1678
  await cmdUpgrade();
@@ -1452,10 +1684,12 @@ async function main() {
1452
1684
  await cmdBackup();
1453
1685
  break;
1454
1686
  case "restore":
1455
- await cmdRestore(flag ?? "");
1687
+ await cmdRestore(positionals[0] ?? "");
1456
1688
  break;
1457
1689
  default:
1458
- console.log("Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore>");
1690
+ console.log(
1691
+ "Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore> [--dry-run]"
1692
+ );
1459
1693
  process.exit(command ? 1 : 0);
1460
1694
  }
1461
1695
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,9 @@
39
39
  "cli",
40
40
  "secrets"
41
41
  ],
42
+ "dependencies": {
43
+ "chalk": "^5.6.2"
44
+ },
42
45
  "devDependencies": {
43
46
  "tsup": "^8.5.0",
44
47
  "typescript": "^5.9.2"