@envsync-cloud/deploy-cli 0.6.3 → 0.6.5

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 +16 -7
  2. package/dist/index.js +387 -38
  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
@@ -71,6 +71,8 @@ Bootstrap infra, migrations, RustFS, and OpenFGA:
71
71
  npx @envsync-cloud/deploy-cli bootstrap
72
72
  ```
73
73
 
74
+ `bootstrap` is destructive. It removes the existing EnvSync stack, matching containers, network, and managed volumes before rebuilding, and requires typing `ARE YOU SURE?` to continue.
75
+
74
76
  Deploy the pending API and frontend services:
75
77
 
76
78
  ```bash
@@ -79,7 +81,7 @@ npx @envsync-cloud/deploy-cli deploy
79
81
 
80
82
  The staged flow is:
81
83
  - `setup` writes desired config
82
- - `bootstrap` starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
84
+ - `bootstrap` resets the existing EnvSync deployment, then starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
83
85
  - `deploy` starts the pending API and frontend services
84
86
 
85
87
  Check service health:
@@ -100,6 +102,13 @@ Restore from an existing backup archive:
100
102
  npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
101
103
  ```
102
104
 
105
+ Preview mutating commands without changing the host:
106
+
107
+ ```bash
108
+ npx @envsync-cloud/deploy-cli bootstrap --dry-run
109
+ npx @envsync-cloud/deploy-cli deploy --dry-run
110
+ ```
111
+
103
112
  ## Links
104
113
 
105
114
  - 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,36 @@ 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 logWarn(message) {
73
+ console.log(`${chalk.yellow("[warn]")} ${message}`);
74
+ }
75
+ function logDryRun(message) {
76
+ console.log(`${chalk.magenta("[dry-run]")} ${message}`);
77
+ }
78
+ function logCommand(cmd, args) {
79
+ console.log(chalk.dim(`$ ${formatCommand(cmd, args)}`));
80
+ }
50
81
  function run(cmd, args, opts = {}) {
51
82
  const result = spawnSync(cmd, args, {
52
83
  cwd: opts.cwd,
@@ -85,6 +116,13 @@ function writeFile(target, content, mode) {
85
116
  fs.writeFileSync(target, content, "utf8");
86
117
  if (mode != null) fs.chmodSync(target, mode);
87
118
  }
119
+ function writeFileMaybe(target, content, mode) {
120
+ if (currentOptions.dryRun) {
121
+ logDryRun(`Would write ${target}`);
122
+ return;
123
+ }
124
+ writeFile(target, content, mode);
125
+ }
88
126
  function exists(target) {
89
127
  return fs.existsSync(target);
90
128
  }
@@ -181,6 +219,18 @@ async function ask(question, fallback = "") {
181
219
  });
182
220
  });
183
221
  }
222
+ async function askRequired(question) {
223
+ if (!process.stdin.isTTY) {
224
+ throw new Error(`${question} confirmation requires an interactive terminal.`);
225
+ }
226
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
227
+ return await new Promise((resolve) => {
228
+ rl.question(`${question} `, (answer) => {
229
+ rl.close();
230
+ resolve(answer.trim());
231
+ });
232
+ });
233
+ }
184
234
  function sleepSeconds(seconds) {
185
235
  spawnSync("sleep", [`${seconds}`], { stdio: "ignore" });
186
236
  }
@@ -329,6 +379,7 @@ function emptyGeneratedState() {
329
379
  },
330
380
  secrets: {
331
381
  s3_secret_key: "",
382
+ keycloak_db_password: "",
332
383
  keycloak_web_client_secret: "",
333
384
  keycloak_api_client_secret: "",
334
385
  openfga_db_password: "",
@@ -349,6 +400,7 @@ function normalizeGeneratedState(raw) {
349
400
  },
350
401
  secrets: {
351
402
  s3_secret_key: raw?.secrets?.s3_secret_key ?? defaults.secrets.s3_secret_key,
403
+ keycloak_db_password: raw?.secrets?.keycloak_db_password ?? defaults.secrets.keycloak_db_password,
352
404
  keycloak_web_client_secret: raw?.secrets?.keycloak_web_client_secret ?? defaults.secrets.keycloak_web_client_secret,
353
405
  keycloak_api_client_secret: raw?.secrets?.keycloak_api_client_secret ?? defaults.secrets.keycloak_api_client_secret,
354
406
  openfga_db_password: raw?.secrets?.openfga_db_password ?? defaults.secrets.openfga_db_password,
@@ -391,6 +443,7 @@ function mergeGeneratedState(env, generated) {
391
443
  },
392
444
  secrets: {
393
445
  s3_secret_key: env.S3_SECRET_KEY ?? normalized.secrets.s3_secret_key,
446
+ keycloak_db_password: env.KEYCLOAK_DB_PASSWORD ?? normalized.secrets.keycloak_db_password,
394
447
  keycloak_web_client_secret: env.KEYCLOAK_WEB_CLIENT_SECRET ?? normalized.secrets.keycloak_web_client_secret,
395
448
  keycloak_api_client_secret: env.KEYCLOAK_API_CLIENT_SECRET ?? normalized.secrets.keycloak_api_client_secret,
396
449
  openfga_db_password: env.OPENFGA_DB_PASSWORD ?? normalized.secrets.openfga_db_password,
@@ -411,6 +464,7 @@ function ensureGeneratedRuntimeState(generated) {
411
464
  openfga: generated.openfga,
412
465
  secrets: {
413
466
  s3_secret_key: generated.secrets.s3_secret_key || randomSecret(16),
467
+ keycloak_db_password: generated.secrets.keycloak_db_password || "",
414
468
  keycloak_web_client_secret: generated.secrets.keycloak_web_client_secret || randomSecret(),
415
469
  keycloak_api_client_secret: generated.secrets.keycloak_api_client_secret || randomSecret(),
416
470
  openfga_db_password: generated.secrets.openfga_db_password || randomSecret(),
@@ -453,6 +507,7 @@ function buildRuntimeEnv(config, generated) {
453
507
  KEYCLOAK_REALM: config.auth.keycloak_realm,
454
508
  KEYCLOAK_ADMIN_USER: config.auth.admin_user,
455
509
  KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
510
+ KEYCLOAK_DB_PASSWORD: generated.secrets.keycloak_db_password || config.auth.admin_password,
456
511
  KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
457
512
  KEYCLOAK_WEB_CLIENT_SECRET: generated.secrets.keycloak_web_client_secret,
458
513
  KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
@@ -746,7 +801,7 @@ ${renderEnvList({
746
801
  environment:
747
802
  ${renderEnvList({
748
803
  POSTGRES_USER: "keycloak",
749
- POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
804
+ POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
750
805
  POSTGRES_DB: "keycloak"
751
806
  })}
752
807
  volumes:
@@ -763,12 +818,13 @@ ${renderEnvList({
763
818
  KC_DB: "postgres",
764
819
  KC_DB_URL: "jdbc:postgresql://keycloak_db:5432/keycloak",
765
820
  KC_DB_USERNAME: "keycloak",
766
- KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
821
+ KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
767
822
  KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
768
823
  KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
769
824
  KC_HTTP_ENABLED: "true",
825
+ KC_HEALTH_ENABLED: "true",
770
826
  KC_PROXY_HEADERS: "xforwarded",
771
- KC_HOSTNAME: hosts.auth
827
+ KC_HOSTNAME_STRICT: "false"
772
828
  })}
773
829
  volumes:
774
830
  - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
@@ -897,40 +953,59 @@ volumes:
897
953
  }
898
954
  function writeDeployArtifacts(config, generated) {
899
955
  const runtimeEnv = buildRuntimeEnv(config, generated);
900
- writeFile(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
901
- writeFile(
956
+ logStep("Rendering deploy artifacts");
957
+ writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
958
+ writeFileMaybe(
902
959
  INTERNAL_CONFIG_JSON,
903
960
  JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
904
961
  );
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));
962
+ writeFileMaybe(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
963
+ writeFileMaybe(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
964
+ writeFileMaybe(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
965
+ writeFileMaybe(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
966
+ writeFileMaybe(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
967
+ writeFileMaybe(STACK_FILE, renderStack(config, runtimeEnv, "full"));
968
+ writeFileMaybe(NGINX_WEB_CONF, renderNginxConf("web"));
969
+ writeFileMaybe(NGINX_LANDING_CONF, renderNginxConf("landing"));
970
+ writeFileMaybe(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
971
+ logSuccess(currentOptions.dryRun ? "Deploy artifacts previewed" : "Deploy artifacts written");
914
972
  }
915
973
  function saveDesiredConfig(config) {
916
974
  const internal = readInternalState();
917
975
  const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
918
- writeFile(DEPLOY_YAML, toYaml(config) + "\n");
919
- writeFile(
976
+ logStep(`Saving desired config to ${DEPLOY_YAML}`);
977
+ writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
978
+ writeFileMaybe(
920
979
  INTERNAL_CONFIG_JSON,
921
980
  JSON.stringify({ config, generated }, null, 2) + "\n"
922
981
  );
982
+ logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
923
983
  }
924
984
  function ensureRepoCheckout(config) {
985
+ logStep(`Ensuring pinned repo checkout at ${config.source.ref}`);
986
+ if (currentOptions.dryRun) {
987
+ logDryRun(`Would ensure repo checkout at ${REPO_ROOT}`);
988
+ return;
989
+ }
925
990
  ensureDir(REPO_ROOT);
926
991
  if (!exists(path.join(REPO_ROOT, ".git"))) {
992
+ logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
927
993
  run("git", ["clone", config.source.repo_url, REPO_ROOT]);
928
994
  }
995
+ logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
929
996
  run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
997
+ logCommand("git", ["fetch", "--tags", "--force", "origin"]);
930
998
  run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
999
+ logCommand("git", ["checkout", "--force", config.source.ref]);
931
1000
  run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
1001
+ logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
932
1002
  }
933
1003
  function extractStaticBundle(image, targetDir) {
1004
+ logStep(`Extracting static bundle from ${image}`);
1005
+ if (currentOptions.dryRun) {
1006
+ logDryRun(`Would extract ${image} into ${targetDir}`);
1007
+ return;
1008
+ }
934
1009
  ensureDir(targetDir);
935
1010
  const containerId = run("docker", ["create", image], { quiet: true }).trim();
936
1011
  try {
@@ -938,24 +1013,43 @@ function extractStaticBundle(image, targetDir) {
938
1013
  } finally {
939
1014
  run("docker", ["rm", "-f", containerId], { quiet: true });
940
1015
  }
1016
+ logSuccess(`Static bundle extracted to ${targetDir}`);
941
1017
  }
942
1018
  function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
943
1019
  const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
944
1020
  if (!exists(path.join(buildContext, "Dockerfile"))) {
945
1021
  throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
946
1022
  }
1023
+ logStep(`Building Keycloak image ${imageTag}`);
1024
+ if (currentOptions.dryRun) {
1025
+ logDryRun(`Would build ${imageTag} from ${buildContext}`);
1026
+ return;
1027
+ }
1028
+ logCommand("docker", ["build", "-t", imageTag, buildContext]);
947
1029
  run("docker", ["build", "-t", imageTag, buildContext]);
1030
+ logSuccess(`Built Keycloak image ${imageTag}`);
948
1031
  }
949
1032
  function stackNetworkName(config) {
950
1033
  return `${config.services.stack_name}_envsync`;
951
1034
  }
952
1035
  function assertSwarmManager() {
1036
+ if (currentOptions.dryRun) {
1037
+ logDryRun("Skipping Docker Swarm manager validation");
1038
+ return;
1039
+ }
1040
+ logStep("Validating Docker Swarm manager state");
953
1041
  const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
954
1042
  if (state !== "active|true") {
955
1043
  throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
956
1044
  }
1045
+ logSuccess("Docker Swarm manager is ready");
957
1046
  }
958
1047
  function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
1048
+ if (currentOptions.dryRun) {
1049
+ logDryRun(`Would wait for ${label}`);
1050
+ return;
1051
+ }
1052
+ logStep(`Waiting for ${label}`);
959
1053
  const deadline = Date.now() + timeoutSeconds * 1e3;
960
1054
  while (Date.now() < deadline) {
961
1055
  const args = ["run", "--rm", "--network", stackNetworkName(config)];
@@ -967,6 +1061,7 @@ function waitForCommand(config, label, image, command, timeoutSeconds = 120, env
967
1061
  }
968
1062
  args.push(image, "sh", "-lc", command);
969
1063
  if (commandSucceeds("docker", args)) {
1064
+ logSuccess(`${label} is ready`);
970
1065
  return;
971
1066
  }
972
1067
  sleepSeconds(2);
@@ -998,6 +1093,23 @@ function waitForHttpService(config, label, url, timeoutSeconds = 120) {
998
1093
  waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
999
1094
  }
1000
1095
  function runOpenFgaMigrate(config, runtimeEnv) {
1096
+ logStep("Running OpenFGA datastore migrations");
1097
+ if (currentOptions.dryRun) {
1098
+ logDryRun("Would run OpenFGA datastore migrations");
1099
+ logCommand("docker", [
1100
+ "run",
1101
+ "--rm",
1102
+ "--network",
1103
+ stackNetworkName(config),
1104
+ "-e",
1105
+ "OPENFGA_DATASTORE_ENGINE=postgres",
1106
+ "-e",
1107
+ `OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
1108
+ "openfga/openfga:v1.12.0",
1109
+ "migrate"
1110
+ ]);
1111
+ return;
1112
+ }
1001
1113
  run("docker", [
1002
1114
  "run",
1003
1115
  "--rm",
@@ -1010,8 +1122,28 @@ function runOpenFgaMigrate(config, runtimeEnv) {
1010
1122
  "openfga/openfga:v1.12.0",
1011
1123
  "migrate"
1012
1124
  ]);
1125
+ logSuccess("OpenFGA datastore migrations completed");
1013
1126
  }
1014
1127
  function runMiniKmsMigrate(config, runtimeEnv) {
1128
+ logStep("Running miniKMS datastore migrations");
1129
+ if (currentOptions.dryRun) {
1130
+ logDryRun("Would run miniKMS datastore migrations");
1131
+ logCommand("docker", [
1132
+ "run",
1133
+ "--rm",
1134
+ "--network",
1135
+ stackNetworkName(config),
1136
+ "-e",
1137
+ `PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
1138
+ "-v",
1139
+ `${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
1140
+ "postgres:17",
1141
+ "sh",
1142
+ "-lc",
1143
+ "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"
1144
+ ]);
1145
+ return;
1146
+ }
1015
1147
  run("docker", [
1016
1148
  "run",
1017
1149
  "--rm",
@@ -1026,8 +1158,35 @@ function runMiniKmsMigrate(config, runtimeEnv) {
1026
1158
  "-lc",
1027
1159
  "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
1160
  ]);
1161
+ logSuccess("miniKMS datastore migrations completed");
1029
1162
  }
1030
1163
  function runBootstrapInit(config) {
1164
+ logStep("Running API bootstrap init");
1165
+ if (currentOptions.dryRun) {
1166
+ logDryRun("Would run API bootstrap init and persist generated OpenFGA IDs");
1167
+ logCommand("docker", [
1168
+ "run",
1169
+ "--rm",
1170
+ "--network",
1171
+ stackNetworkName(config),
1172
+ "--env-file",
1173
+ DEPLOY_ENV,
1174
+ "-e",
1175
+ "SKIP_ROOT_ENV=1",
1176
+ "-e",
1177
+ "SKIP_ROOT_ENV_WRITE=1",
1178
+ config.images.api,
1179
+ "bun",
1180
+ "run",
1181
+ "scripts/prod-init.ts",
1182
+ "--json",
1183
+ "--no-write-root-env"
1184
+ ]);
1185
+ return {
1186
+ openfgaStoreId: "",
1187
+ openfgaModelId: ""
1188
+ };
1189
+ }
1031
1190
  const output = run(
1032
1191
  "docker",
1033
1192
  [
@@ -1054,6 +1213,7 @@ function runBootstrapInit(config) {
1054
1213
  if (!result.openfgaStoreId || !result.openfgaModelId) {
1055
1214
  throw new Error("Bootstrap init did not return OpenFGA IDs");
1056
1215
  }
1216
+ logSuccess("API bootstrap init completed");
1057
1217
  return {
1058
1218
  openfgaStoreId: result.openfgaStoreId,
1059
1219
  openfgaModelId: result.openfgaModelId
@@ -1111,6 +1271,95 @@ function listStackServices(config) {
1111
1271
  }
1112
1272
  return services;
1113
1273
  }
1274
+ function listManagedContainers(config) {
1275
+ const output = tryRun("docker", ["ps", "-aq", "--filter", `name=^/${config.services.stack_name}_`], { quiet: true });
1276
+ return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1277
+ }
1278
+ function stackExists(config) {
1279
+ const output = tryRun("docker", ["stack", "ls", "--format", "{{.Name}}"], { quiet: true });
1280
+ return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).includes(config.services.stack_name);
1281
+ }
1282
+ function waitForStackRemoval(config, timeoutSeconds = 60) {
1283
+ if (currentOptions.dryRun) {
1284
+ logDryRun(`Would wait for stack ${config.services.stack_name} to be removed`);
1285
+ return;
1286
+ }
1287
+ const deadline = Date.now() + timeoutSeconds * 1e3;
1288
+ while (Date.now() < deadline) {
1289
+ if (!stackExists(config)) {
1290
+ return;
1291
+ }
1292
+ sleepSeconds(2);
1293
+ }
1294
+ throw new Error(`Timed out waiting for stack ${config.services.stack_name} to be removed`);
1295
+ }
1296
+ async function confirmBootstrapReset(config) {
1297
+ const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
1298
+ const containerIds = listManagedContainers(config);
1299
+ const networkName = stackNetworkName(config);
1300
+ logWarn("Bootstrap will delete existing EnvSync Docker resources before rebuilding infra.");
1301
+ logWarn(`Stack: ${config.services.stack_name}`);
1302
+ logWarn(`Network: ${networkName}`);
1303
+ logWarn(`Volumes: ${volumeNames.join(", ")}`);
1304
+ if (containerIds.length > 0) {
1305
+ logWarn(`Containers: ${containerIds.join(", ")}`);
1306
+ } else {
1307
+ logWarn("Containers: none currently matched");
1308
+ }
1309
+ logWarn("This removes existing deployment data for the managed EnvSync services.");
1310
+ const response = await askRequired(chalk.bold.red('Type "ARE YOU SURE?" to continue:'));
1311
+ if (response !== "ARE YOU SURE?") {
1312
+ throw new Error("Bootstrap aborted. Confirmation did not match 'ARE YOU SURE?'.");
1313
+ }
1314
+ logSuccess("Destructive bootstrap reset confirmed");
1315
+ }
1316
+ function cleanupBootstrapState(config) {
1317
+ const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
1318
+ const containerIds = listManagedContainers(config);
1319
+ const networkName = stackNetworkName(config);
1320
+ logStep("Removing existing EnvSync deployment resources");
1321
+ if (currentOptions.dryRun) {
1322
+ if (stackExists(config)) {
1323
+ logDryRun(`Would remove stack ${config.services.stack_name}`);
1324
+ logCommand("docker", ["stack", "rm", config.services.stack_name]);
1325
+ } else {
1326
+ logDryRun(`No existing stack named ${config.services.stack_name} found`);
1327
+ }
1328
+ if (containerIds.length > 0) {
1329
+ logDryRun(`Would remove containers: ${containerIds.join(", ")}`);
1330
+ logCommand("docker", ["rm", "-f", ...containerIds]);
1331
+ }
1332
+ logDryRun(`Would remove network ${networkName} if present`);
1333
+ logCommand("docker", ["network", "rm", networkName]);
1334
+ for (const volumeName of volumeNames) {
1335
+ logDryRun(`Would remove volume ${volumeName}`);
1336
+ logCommand("docker", ["volume", "rm", "-f", volumeName]);
1337
+ }
1338
+ logSuccess("Bootstrap cleanup preview completed");
1339
+ return;
1340
+ }
1341
+ if (stackExists(config)) {
1342
+ logCommand("docker", ["stack", "rm", config.services.stack_name]);
1343
+ run("docker", ["stack", "rm", config.services.stack_name]);
1344
+ waitForStackRemoval(config);
1345
+ }
1346
+ const refreshedContainers = listManagedContainers(config);
1347
+ if (refreshedContainers.length > 0) {
1348
+ logCommand("docker", ["rm", "-f", ...refreshedContainers]);
1349
+ run("docker", ["rm", "-f", ...refreshedContainers]);
1350
+ }
1351
+ if (commandSucceeds("docker", ["network", "inspect", networkName])) {
1352
+ logCommand("docker", ["network", "rm", networkName]);
1353
+ run("docker", ["network", "rm", networkName]);
1354
+ }
1355
+ for (const volumeName of volumeNames) {
1356
+ if (commandSucceeds("docker", ["volume", "inspect", volumeName])) {
1357
+ logCommand("docker", ["volume", "rm", "-f", volumeName]);
1358
+ run("docker", ["volume", "rm", "-f", volumeName]);
1359
+ }
1360
+ }
1361
+ logSuccess("Existing EnvSync deployment resources removed");
1362
+ }
1114
1363
  function serviceHealth(services, name) {
1115
1364
  return services.get(`${name}`) ?? "missing";
1116
1365
  }
@@ -1141,6 +1390,7 @@ async function cmdPreinstall() {
1141
1390
  run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
1142
1391
  }
1143
1392
  async function cmdSetup() {
1393
+ logSection("Setup");
1144
1394
  const rootDomain = await ask("Root domain", "example.com");
1145
1395
  const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
1146
1396
  const releaseVersion = await ask("Release version", getDeployCliVersion());
@@ -1216,33 +1466,62 @@ async function cmdSetup() {
1216
1466
  }
1217
1467
  };
1218
1468
  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:");
1469
+ logSuccess(`Config written to ${DEPLOY_YAML}`);
1470
+ logInfo(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
1471
+ logInfo("Create these DNS records:");
1222
1472
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
1223
1473
  }
1224
1474
  async function cmdBootstrap() {
1475
+ logSection("Bootstrap");
1225
1476
  const { config, generated } = loadState();
1226
1477
  const nextGenerated = ensureGeneratedRuntimeState(generated);
1227
1478
  const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
1479
+ logInfo(`Release version: ${config.release.version}`);
1228
1480
  assertSwarmManager();
1481
+ if (currentOptions.dryRun) {
1482
+ logWarn("Dry-run mode: bootstrap reset will be previewed but not executed.");
1483
+ } else {
1484
+ await confirmBootstrapReset(config);
1485
+ }
1486
+ cleanupBootstrapState(config);
1229
1487
  ensureRepoCheckout(config);
1230
1488
  writeDeployArtifacts(config, nextGenerated);
1231
1489
  buildKeycloakImage(config.images.keycloak);
1232
- run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1490
+ if (currentOptions.dryRun) {
1491
+ logDryRun(`Would deploy base bootstrap stack for ${config.services.stack_name}`);
1492
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1493
+ } else {
1494
+ logStep("Deploying base bootstrap stack");
1495
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1496
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1497
+ logSuccess("Base bootstrap stack deployed");
1498
+ }
1233
1499
  waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
1234
1500
  waitForRedisService(config);
1235
1501
  waitForTcpService(config, "rustfs", "rustfs", 9e3);
1236
- waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_ADMIN_PASSWORD);
1502
+ waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_DB_PASSWORD);
1237
1503
  waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
1238
1504
  waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
1239
1505
  runOpenFgaMigrate(config, runtimeEnv);
1240
1506
  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");
1507
+ if (currentOptions.dryRun) {
1508
+ logDryRun(`Would deploy runtime bootstrap stack for ${config.services.stack_name}`);
1509
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1510
+ } else {
1511
+ logStep("Deploying runtime bootstrap stack");
1512
+ logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1513
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1514
+ logSuccess("Runtime bootstrap stack deployed");
1515
+ }
1516
+ waitForHttpService(config, "keycloak", "http://keycloak:8080/health/ready", 180);
1243
1517
  waitForHttpService(config, "openfga", "http://openfga:8090/stores");
1244
1518
  waitForTcpService(config, "minikms", "minikms", 50051);
1245
1519
  const initResult = runBootstrapInit(config);
1520
+ if (currentOptions.dryRun) {
1521
+ logDryRun("Skipping generated OpenFGA ID persistence in preview mode");
1522
+ logSuccess("Bootstrap dry-run completed");
1523
+ return;
1524
+ }
1246
1525
  const bootstrappedGenerated = normalizeGeneratedState({
1247
1526
  openfga: {
1248
1527
  store_id: initResult.openfgaStoreId,
@@ -1254,26 +1533,46 @@ async function cmdBootstrap() {
1254
1533
  }
1255
1534
  });
1256
1535
  writeDeployArtifacts(config, bootstrappedGenerated);
1257
- console.log("Bootstrap completed.");
1536
+ logSuccess("Bootstrap completed");
1258
1537
  }
1259
1538
  async function cmdDeploy() {
1539
+ logSection("Deploy");
1260
1540
  const { config, generated } = loadState();
1541
+ logInfo(`Release version: ${config.release.version}`);
1261
1542
  assertSwarmManager();
1262
1543
  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.");
1544
+ if (!currentOptions.dryRun) {
1545
+ const services = listStackServices(config);
1546
+ 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") {
1547
+ throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
1548
+ }
1549
+ } else {
1550
+ logDryRun("Skipping runtime bootstrap service validation");
1266
1551
  }
1267
1552
  ensureRepoCheckout(config);
1268
1553
  writeDeployArtifacts(config, generated);
1269
1554
  buildKeycloakImage(config.images.keycloak);
1270
- ensureDir(`${RELEASES_ROOT}/web/current`);
1271
- ensureDir(`${RELEASES_ROOT}/landing/current`);
1555
+ if (currentOptions.dryRun) {
1556
+ logDryRun(`Would ensure ${RELEASES_ROOT}/web/current exists`);
1557
+ logDryRun(`Would ensure ${RELEASES_ROOT}/landing/current exists`);
1558
+ } else {
1559
+ ensureDir(`${RELEASES_ROOT}/web/current`);
1560
+ ensureDir(`${RELEASES_ROOT}/landing/current`);
1561
+ }
1272
1562
  extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
1273
1563
  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));
1564
+ writeFileMaybe(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1565
+ writeFileMaybe(`${RELEASES_ROOT}/landing/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1566
+ if (currentOptions.dryRun) {
1567
+ logDryRun(`Would deploy full stack for ${config.services.stack_name}`);
1568
+ logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1569
+ logSuccess("Deploy dry-run completed");
1570
+ return;
1571
+ }
1572
+ logStep("Deploying full stack");
1573
+ logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1276
1574
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1575
+ logSuccess("Deploy completed");
1277
1576
  }
1278
1577
  async function cmdHealth(asJson) {
1279
1578
  const { config, generated } = loadState();
@@ -1314,20 +1613,28 @@ async function cmdHealth(asJson) {
1314
1613
  console.log(JSON.stringify(checks, null, 2));
1315
1614
  }
1316
1615
  async function cmdUpgrade() {
1616
+ logSection("Upgrade");
1317
1617
  const { config } = loadState();
1318
1618
  config.images = {
1319
1619
  ...config.images,
1320
1620
  ...versionedImages(config.release.version)
1321
1621
  };
1322
1622
  saveDesiredConfig(config);
1623
+ if (currentOptions.dryRun) {
1624
+ logDryRun(`Would upgrade stack to release ${config.release.version}`);
1625
+ }
1323
1626
  await cmdDeploy();
1324
1627
  }
1325
1628
  async function cmdUpgradeDeps() {
1629
+ logSection("Upgrade Dependencies");
1326
1630
  const { config } = loadState();
1327
1631
  config.images.traefik = "traefik:v3.1";
1328
1632
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
1329
1633
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
1330
1634
  saveDesiredConfig(config);
1635
+ if (currentOptions.dryRun) {
1636
+ logDryRun("Would refresh dependency image tags and redeploy");
1637
+ }
1331
1638
  await cmdDeploy();
1332
1639
  }
1333
1640
  function sha256File(filePath) {
@@ -1339,6 +1646,11 @@ function stackVolumeName(config, name) {
1339
1646
  return `${config.services.stack_name}_${name}`;
1340
1647
  }
1341
1648
  function backupDockerVolume(volumeName, targetDir) {
1649
+ logStep(`Backing up Docker volume ${volumeName}`);
1650
+ if (currentOptions.dryRun) {
1651
+ logDryRun(`Would back up ${volumeName} into ${targetDir}`);
1652
+ return;
1653
+ }
1342
1654
  ensureDir(targetDir);
1343
1655
  run("docker", [
1344
1656
  "run",
@@ -1352,8 +1664,14 @@ function backupDockerVolume(volumeName, targetDir) {
1352
1664
  "-lc",
1353
1665
  "cd /from && tar -czf /to/volume.tar.gz ."
1354
1666
  ]);
1667
+ logSuccess(`Backed up Docker volume ${volumeName}`);
1355
1668
  }
1356
1669
  function restoreDockerVolume(volumeName, sourceDir) {
1670
+ logStep(`Restoring Docker volume ${volumeName}`);
1671
+ if (currentOptions.dryRun) {
1672
+ logDryRun(`Would restore ${volumeName} from ${sourceDir}`);
1673
+ return;
1674
+ }
1357
1675
  run("docker", ["volume", "create", volumeName], { quiet: true });
1358
1676
  run("docker", [
1359
1677
  "run",
@@ -1367,15 +1685,29 @@ function restoreDockerVolume(volumeName, sourceDir) {
1367
1685
  "-lc",
1368
1686
  "cd /to && tar -xzf /from/volume.tar.gz"
1369
1687
  ]);
1688
+ logSuccess(`Restored Docker volume ${volumeName}`);
1370
1689
  }
1371
1690
  async function cmdBackup() {
1691
+ logSection("Backup");
1372
1692
  const { config } = loadState();
1373
- ensureDir(config.backup.output_dir);
1374
1693
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
1375
1694
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
1376
1695
  const manifestPath = `${archiveBase}.manifest.json`;
1377
1696
  const tarPath = `${archiveBase}.tar.gz`;
1378
1697
  const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
1698
+ logInfo(`Backup archive target: ${tarPath}`);
1699
+ if (currentOptions.dryRun) {
1700
+ logDryRun(`Would stage backup files in ${staged}`);
1701
+ for (const volume of STACK_VOLUMES) {
1702
+ backupDockerVolume(stackVolumeName(config, volume), path.join(staged, "volumes", volume));
1703
+ }
1704
+ logDryRun(`Would write manifest ${manifestPath}`);
1705
+ logDryRun(`Would create archive ${tarPath}`);
1706
+ logSuccess("Backup dry-run completed");
1707
+ console.log(tarPath);
1708
+ return;
1709
+ }
1710
+ ensureDir(config.backup.output_dir);
1379
1711
  ensureDir(staged);
1380
1712
  writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
1381
1713
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
@@ -1400,11 +1732,21 @@ async function cmdBackup() {
1400
1732
  volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
1401
1733
  };
1402
1734
  writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
1735
+ logSuccess("Backup completed");
1403
1736
  console.log(tarPath);
1404
1737
  }
1405
1738
  async function cmdRestore(archivePath) {
1406
1739
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
1740
+ logSection("Restore");
1407
1741
  const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
1742
+ logInfo(`Restore archive: ${archivePath}`);
1743
+ if (currentOptions.dryRun) {
1744
+ logDryRun(`Would extract ${archivePath} into ${restoreRoot}`);
1745
+ logDryRun(`Would restore deploy files into ${DEPLOY_ROOT} and ${ETC_ROOT}`);
1746
+ logDryRun("Would restore all managed Docker volumes from the archive");
1747
+ logSuccess("Restore dry-run completed");
1748
+ return;
1749
+ }
1408
1750
  ensureDir(restoreRoot);
1409
1751
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
1410
1752
  writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
@@ -1421,11 +1763,16 @@ async function cmdRestore(archivePath) {
1421
1763
  for (const volume of STACK_VOLUMES) {
1422
1764
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
1423
1765
  }
1424
- console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
1766
+ logSuccess("Restore completed. Run 'envsync-deploy deploy' to start services.");
1425
1767
  }
1426
1768
  async function main() {
1427
- const command = process.argv[2];
1428
- const flag = process.argv[3];
1769
+ const argv = process.argv.slice(2);
1770
+ const command = argv[0];
1771
+ const args = argv.slice(1);
1772
+ currentOptions = {
1773
+ dryRun: args.includes("--dry-run")
1774
+ };
1775
+ const positionals = args.filter((arg) => arg !== "--dry-run");
1429
1776
  switch (command) {
1430
1777
  case "preinstall":
1431
1778
  await cmdPreinstall();
@@ -1440,7 +1787,7 @@ async function main() {
1440
1787
  await cmdDeploy();
1441
1788
  break;
1442
1789
  case "health":
1443
- await cmdHealth(flag === "--json");
1790
+ await cmdHealth(positionals[0] === "--json");
1444
1791
  break;
1445
1792
  case "upgrade":
1446
1793
  await cmdUpgrade();
@@ -1452,10 +1799,12 @@ async function main() {
1452
1799
  await cmdBackup();
1453
1800
  break;
1454
1801
  case "restore":
1455
- await cmdRestore(flag ?? "");
1802
+ await cmdRestore(positionals[0] ?? "");
1456
1803
  break;
1457
1804
  default:
1458
- console.log("Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore>");
1805
+ console.log(
1806
+ "Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore> [--dry-run]"
1807
+ );
1459
1808
  process.exit(command ? 1 : 0);
1460
1809
  }
1461
1810
  }
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.5",
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"