@envsync-cloud/deploy-cli 0.6.2 → 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 +16 -7
  2. package/dist/index.js +483 -53
  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
@@ -63,6 +63,8 @@ Write the desired self-hosted config:
63
63
  npx @envsync-cloud/deploy-cli setup
64
64
  ```
65
65
 
66
+ `setup` requires an exact release version such as `0.6.2`. Channel names like `stable` and `latest` are not accepted for self-hosted installs.
67
+
66
68
  Bootstrap infra, migrations, RustFS, and OpenFGA:
67
69
 
68
70
  ```bash
@@ -77,7 +79,7 @@ npx @envsync-cloud/deploy-cli deploy
77
79
 
78
80
  The staged flow is:
79
81
  - `setup` writes desired config
80
- - `bootstrap` starts infra and persists generated runtime env state
82
+ - `bootstrap` starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
81
83
  - `deploy` starts the pending API and frontend services
82
84
 
83
85
  Check service health:
@@ -98,6 +100,13 @@ Restore from an existing backup archive:
98
100
  npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
99
101
  ```
100
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
+
101
110
  ## Links
102
111
 
103
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";
@@ -17,6 +18,7 @@ var DEPLOY_ENV = "/etc/envsync/deploy.env";
17
18
  var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
18
19
  var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
19
20
  var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
21
+ var BOOTSTRAP_BASE_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.base.yaml";
20
22
  var BOOTSTRAP_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.yaml";
21
23
  var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
22
24
  var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
@@ -45,6 +47,34 @@ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
45
47
  "OPENFGA_STORE_ID",
46
48
  "OPENFGA_MODEL_ID"
47
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
+ }
48
78
  function run(cmd, args, opts = {}) {
49
79
  const result = spawnSync(cmd, args, {
50
80
  cwd: opts.cwd,
@@ -83,6 +113,13 @@ function writeFile(target, content, mode) {
83
113
  fs.writeFileSync(target, content, "utf8");
84
114
  if (mode != null) fs.chmodSync(target, mode);
85
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
+ }
86
123
  function exists(target) {
87
124
  return fs.existsSync(target);
88
125
  }
@@ -203,16 +240,120 @@ function getDeployCliVersion() {
203
240
  return process.env.npm_package_version ?? "0.0.0";
204
241
  }
205
242
  }
206
- function defaultSourceConfig() {
243
+ function assertSemverVersion(version, label = "release version") {
244
+ if (!SEMVER_VERSION_RE.test(version)) {
245
+ throw new Error(`Invalid ${label} '${version}'. Expected an exact semver like 0.6.2.`);
246
+ }
247
+ }
248
+ function versionedImages(version) {
249
+ assertSemverVersion(version);
250
+ return {
251
+ api: `ghcr.io/envsync-cloud/envsync-api:${version}`,
252
+ keycloak: `envsync-keycloak:${version}`,
253
+ web: `ghcr.io/envsync-cloud/envsync-web-static:${version}`,
254
+ landing: `ghcr.io/envsync-cloud/envsync-landing-static:${version}`
255
+ };
256
+ }
257
+ function defaultSourceConfig(version) {
207
258
  return {
208
259
  repo_url: "https://github.com/EnvSync-Cloud/envsync.git",
209
- ref: `v${getDeployCliVersion()}`
260
+ ref: `v${version}`
210
261
  };
211
262
  }
263
+ function resolveReleaseVersion(raw) {
264
+ const releaseVersion = raw.release?.version;
265
+ if (releaseVersion) {
266
+ assertSemverVersion(releaseVersion);
267
+ return releaseVersion;
268
+ }
269
+ if (typeof raw.release_channel === "string" && raw.release_channel.length > 0) {
270
+ if (SEMVER_VERSION_RE.test(raw.release_channel)) {
271
+ return raw.release_channel;
272
+ }
273
+ if (raw.release_channel === "stable" || raw.release_channel === "latest") {
274
+ throw new Error(
275
+ "Legacy release channel config is no longer supported for self-hosted installs. Set an exact release version in /etc/envsync/deploy.yaml."
276
+ );
277
+ }
278
+ throw new Error(`Invalid legacy release channel '${raw.release_channel}'. Set an exact release version in /etc/envsync/deploy.yaml.`);
279
+ }
280
+ return getDeployCliVersion();
281
+ }
282
+ function requireDefined(value, label) {
283
+ if (value === void 0) {
284
+ throw new Error(`Missing ${label} in ${DEPLOY_YAML}. Run setup again.`);
285
+ }
286
+ return value;
287
+ }
212
288
  function normalizeConfig(raw) {
289
+ const version = resolveReleaseVersion(raw);
290
+ const derivedImages = versionedImages(version);
291
+ const { release_channel: _legacyReleaseChannel, ...rest } = raw;
292
+ const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
293
+ const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
213
294
  return {
214
- ...raw,
215
- source: raw.source ?? defaultSourceConfig()
295
+ ...rest,
296
+ source: {
297
+ repo_url: raw.source?.repo_url ?? "https://github.com/EnvSync-Cloud/envsync.git",
298
+ ref: `v${version}`
299
+ },
300
+ release: {
301
+ version
302
+ },
303
+ domain: {
304
+ root_domain: rootDomain,
305
+ acme_email: acmeEmail
306
+ },
307
+ images: {
308
+ api: derivedImages.api,
309
+ keycloak: derivedImages.keycloak,
310
+ web: derivedImages.web,
311
+ landing: derivedImages.landing,
312
+ clickstack: raw.images?.clickstack ?? "clickhouse/clickstack-all-in-one:latest",
313
+ traefik: raw.images?.traefik ?? "traefik:v3.1",
314
+ otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
315
+ },
316
+ services: {
317
+ stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
318
+ api_port: requireDefined(raw.services?.api_port, "services.api_port"),
319
+ clickstack_ui_port: requireDefined(raw.services?.clickstack_ui_port, "services.clickstack_ui_port"),
320
+ clickstack_otlp_http_port: requireDefined(raw.services?.clickstack_otlp_http_port, "services.clickstack_otlp_http_port"),
321
+ clickstack_otlp_grpc_port: requireDefined(raw.services?.clickstack_otlp_grpc_port, "services.clickstack_otlp_grpc_port"),
322
+ keycloak_port: requireDefined(raw.services?.keycloak_port, "services.keycloak_port"),
323
+ rustfs_port: requireDefined(raw.services?.rustfs_port, "services.rustfs_port"),
324
+ rustfs_console_port: requireDefined(raw.services?.rustfs_console_port, "services.rustfs_console_port")
325
+ },
326
+ auth: {
327
+ keycloak_realm: requireDefined(raw.auth?.keycloak_realm, "auth.keycloak_realm"),
328
+ admin_user: requireDefined(raw.auth?.admin_user, "auth.admin_user"),
329
+ admin_password: requireDefined(raw.auth?.admin_password, "auth.admin_password"),
330
+ web_client_id: requireDefined(raw.auth?.web_client_id, "auth.web_client_id"),
331
+ api_client_id: requireDefined(raw.auth?.api_client_id, "auth.api_client_id"),
332
+ cli_client_id: requireDefined(raw.auth?.cli_client_id, "auth.cli_client_id")
333
+ },
334
+ observability: {
335
+ retention_days: requireDefined(raw.observability?.retention_days, "observability.retention_days"),
336
+ public_obs: requireDefined(raw.observability?.public_obs, "observability.public_obs")
337
+ },
338
+ backup: {
339
+ output_dir: requireDefined(raw.backup?.output_dir, "backup.output_dir"),
340
+ encrypted: requireDefined(raw.backup?.encrypted, "backup.encrypted")
341
+ },
342
+ smtp: {
343
+ host: requireDefined(raw.smtp?.host, "smtp.host"),
344
+ port: requireDefined(raw.smtp?.port, "smtp.port"),
345
+ secure: requireDefined(raw.smtp?.secure, "smtp.secure"),
346
+ user: requireDefined(raw.smtp?.user, "smtp.user"),
347
+ pass: requireDefined(raw.smtp?.pass, "smtp.pass"),
348
+ from: requireDefined(raw.smtp?.from, "smtp.from")
349
+ },
350
+ exposure: {
351
+ public_auth: requireDefined(raw.exposure?.public_auth, "exposure.public_auth"),
352
+ public_obs: requireDefined(raw.exposure?.public_obs, "exposure.public_obs"),
353
+ mailpit_enabled: requireDefined(raw.exposure?.mailpit_enabled, "exposure.mailpit_enabled"),
354
+ s3_public: requireDefined(raw.exposure?.s3_public, "exposure.s3_public"),
355
+ s3_console_public: requireDefined(raw.exposure?.s3_console_public, "exposure.s3_console_public")
356
+ }
216
357
  };
217
358
  }
218
359
  function emptyGeneratedState() {
@@ -550,8 +691,10 @@ function renderOtelAgentConfig(config) {
550
691
  " exporters: [otlphttp/clickstack]"
551
692
  ].join("\n") + "\n";
552
693
  }
553
- function renderStack(config, runtimeEnv, includeAppServices) {
694
+ function renderStack(config, runtimeEnv, mode) {
554
695
  const hosts = domainMap(config.domain.root_domain);
696
+ const includeRuntimeInfra = mode !== "base";
697
+ const includeAppServices = mode === "full";
555
698
  const apiEnvironment = {
556
699
  ...runtimeEnv,
557
700
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
@@ -644,7 +787,7 @@ ${renderEnvList({
644
787
  volumes:
645
788
  - keycloak_db_data:/var/lib/postgresql/data
646
789
  networks: [envsync]
647
-
790
+ ${includeRuntimeInfra ? `
648
791
  keycloak:
649
792
  image: ${config.images.keycloak}
650
793
  entrypoint: ["/bin/sh", "-lc"]
@@ -659,8 +802,9 @@ ${renderEnvList({
659
802
  KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
660
803
  KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
661
804
  KC_HTTP_ENABLED: "true",
805
+ KC_HEALTH_ENABLED: "true",
662
806
  KC_PROXY_HEADERS: "xforwarded",
663
- KC_HOSTNAME: hosts.auth
807
+ KC_HOSTNAME_STRICT: "false"
664
808
  })}
665
809
  volumes:
666
810
  - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
@@ -671,7 +815,7 @@ ${renderEnvList({
671
815
  - traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
672
816
  - traefik.http.routers.keycloak.entrypoints=websecure
673
817
  - traefik.http.routers.keycloak.tls.certresolver=letsencrypt
674
- - traefik.http.services.keycloak.loadbalancer.server.port=8080
818
+ - traefik.http.services.keycloak.loadbalancer.server.port=8080` : ""}
675
819
 
676
820
  openfga_db:
677
821
  image: postgres:17
@@ -684,7 +828,7 @@ ${renderEnvList({
684
828
  volumes:
685
829
  - openfga_db_data:/var/lib/postgresql/data
686
830
  networks: [envsync]
687
-
831
+ ${includeRuntimeInfra ? `
688
832
  openfga:
689
833
  image: openfga/openfga:v1.12.0
690
834
  command: run
@@ -695,7 +839,7 @@ ${renderEnvList({
695
839
  OPENFGA_HTTP_ADDR: "0.0.0.0:8090",
696
840
  OPENFGA_GRPC_ADDR: "0.0.0.0:8091"
697
841
  })}
698
- networks: [envsync]
842
+ networks: [envsync]` : ""}
699
843
 
700
844
  minikms_db:
701
845
  image: postgres:17
@@ -708,7 +852,7 @@ ${renderEnvList({
708
852
  volumes:
709
853
  - minikms_db_data:/var/lib/postgresql/data
710
854
  networks: [envsync]
711
-
855
+ ${includeRuntimeInfra ? `
712
856
  minikms:
713
857
  image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
714
858
  environment:
@@ -719,7 +863,7 @@ ${renderEnvList({
719
863
  MINIKMS_GRPC_ADDR: "0.0.0.0:50051",
720
864
  MINIKMS_TLS_ENABLED: "false"
721
865
  })}
722
- networks: [envsync]
866
+ networks: [envsync]` : ""}
723
867
 
724
868
  clickstack:
725
869
  image: ${config.images.clickstack}
@@ -789,39 +933,59 @@ volumes:
789
933
  }
790
934
  function writeDeployArtifacts(config, generated) {
791
935
  const runtimeEnv = buildRuntimeEnv(config, generated);
792
- writeFile(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
793
- writeFile(
936
+ logStep("Rendering deploy artifacts");
937
+ writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
938
+ writeFileMaybe(
794
939
  INTERNAL_CONFIG_JSON,
795
940
  JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
796
941
  );
797
- writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
798
- writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
799
- writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
800
- writeFile(STACK_FILE, renderStack(config, runtimeEnv, true));
801
- writeFile(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, false));
802
- writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
803
- writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
804
- 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");
805
952
  }
806
953
  function saveDesiredConfig(config) {
807
954
  const internal = readInternalState();
808
955
  const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
809
- writeFile(DEPLOY_YAML, toYaml(config) + "\n");
810
- writeFile(
956
+ logStep(`Saving desired config to ${DEPLOY_YAML}`);
957
+ writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
958
+ writeFileMaybe(
811
959
  INTERNAL_CONFIG_JSON,
812
960
  JSON.stringify({ config, generated }, null, 2) + "\n"
813
961
  );
962
+ logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
814
963
  }
815
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
+ }
816
970
  ensureDir(REPO_ROOT);
817
971
  if (!exists(path.join(REPO_ROOT, ".git"))) {
972
+ logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
818
973
  run("git", ["clone", config.source.repo_url, REPO_ROOT]);
819
974
  }
975
+ logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
820
976
  run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
977
+ logCommand("git", ["fetch", "--tags", "--force", "origin"]);
821
978
  run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
979
+ logCommand("git", ["checkout", "--force", config.source.ref]);
822
980
  run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
981
+ logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
823
982
  }
824
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
+ }
825
989
  ensureDir(targetDir);
826
990
  const containerId = run("docker", ["create", image], { quiet: true }).trim();
827
991
  try {
@@ -829,17 +993,69 @@ function extractStaticBundle(image, targetDir) {
829
993
  } finally {
830
994
  run("docker", ["rm", "-f", containerId], { quiet: true });
831
995
  }
996
+ logSuccess(`Static bundle extracted to ${targetDir}`);
832
997
  }
833
998
  function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
834
999
  const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
835
1000
  if (!exists(path.join(buildContext, "Dockerfile"))) {
836
1001
  throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
837
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]);
838
1009
  run("docker", ["build", "-t", imageTag, buildContext]);
1010
+ logSuccess(`Built Keycloak image ${imageTag}`);
839
1011
  }
840
1012
  function stackNetworkName(config) {
841
1013
  return `${config.services.stack_name}_envsync`;
842
1014
  }
1015
+ function assertSwarmManager() {
1016
+ if (currentOptions.dryRun) {
1017
+ logDryRun("Skipping Docker Swarm manager validation");
1018
+ return;
1019
+ }
1020
+ logStep("Validating Docker Swarm manager state");
1021
+ const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
1022
+ if (state !== "active|true") {
1023
+ throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
1024
+ }
1025
+ logSuccess("Docker Swarm manager is ready");
1026
+ }
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}`);
1033
+ const deadline = Date.now() + timeoutSeconds * 1e3;
1034
+ while (Date.now() < deadline) {
1035
+ const args = ["run", "--rm", "--network", stackNetworkName(config)];
1036
+ for (const volume of volumes) {
1037
+ args.push("-v", volume);
1038
+ }
1039
+ for (const [key, value] of Object.entries(env)) {
1040
+ args.push("-e", `${key}=${value}`);
1041
+ }
1042
+ args.push(image, "sh", "-lc", command);
1043
+ if (commandSucceeds("docker", args)) {
1044
+ logSuccess(`${label} is ready`);
1045
+ return;
1046
+ }
1047
+ sleepSeconds(2);
1048
+ }
1049
+ throw new Error(`Timed out waiting for ${label}`);
1050
+ }
1051
+ function waitForPostgresService(config, label, host, user, password) {
1052
+ waitForCommand(config, `${label} database readiness`, "postgres:17", `pg_isready -h ${host} -U ${user}`, 120, {
1053
+ PGPASSWORD: password
1054
+ });
1055
+ }
1056
+ function waitForRedisService(config) {
1057
+ waitForCommand(config, "redis readiness", "redis:7", "redis-cli -h redis ping | grep PONG");
1058
+ }
843
1059
  function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
844
1060
  const deadline = Date.now() + timeoutSeconds * 1e3;
845
1061
  while (Date.now() < deadline) {
@@ -853,7 +1069,104 @@ function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
853
1069
  }
854
1070
  throw new Error(`Timed out waiting for ${label} at ${host}:${port}`);
855
1071
  }
1072
+ function waitForHttpService(config, label, url, timeoutSeconds = 120) {
1073
+ waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
1074
+ }
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
+ }
1093
+ run("docker", [
1094
+ "run",
1095
+ "--rm",
1096
+ "--network",
1097
+ stackNetworkName(config),
1098
+ "-e",
1099
+ "OPENFGA_DATASTORE_ENGINE=postgres",
1100
+ "-e",
1101
+ `OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
1102
+ "openfga/openfga:v1.12.0",
1103
+ "migrate"
1104
+ ]);
1105
+ logSuccess("OpenFGA datastore migrations completed");
1106
+ }
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
+ }
1127
+ run("docker", [
1128
+ "run",
1129
+ "--rm",
1130
+ "--network",
1131
+ stackNetworkName(config),
1132
+ "-e",
1133
+ `PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
1134
+ "-v",
1135
+ `${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
1136
+ "postgres:17",
1137
+ "sh",
1138
+ "-lc",
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"
1140
+ ]);
1141
+ logSuccess("miniKMS datastore migrations completed");
1142
+ }
856
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
+ }
857
1170
  const output = run(
858
1171
  "docker",
859
1172
  [
@@ -880,6 +1193,7 @@ function runBootstrapInit(config) {
880
1193
  if (!result.openfgaStoreId || !result.openfgaModelId) {
881
1194
  throw new Error("Bootstrap init did not return OpenFGA IDs");
882
1195
  }
1196
+ logSuccess("API bootstrap init completed");
883
1197
  return {
884
1198
  openfgaStoreId: result.openfgaStoreId,
885
1199
  openfgaModelId: result.openfgaModelId
@@ -967,9 +1281,12 @@ async function cmdPreinstall() {
967
1281
  run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
968
1282
  }
969
1283
  async function cmdSetup() {
1284
+ logSection("Setup");
970
1285
  const rootDomain = await ask("Root domain", "example.com");
971
1286
  const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
972
- const channel = await ask("Release channel", "stable");
1287
+ const releaseVersion = await ask("Release version", getDeployCliVersion());
1288
+ assertSemverVersion(releaseVersion, "release version");
1289
+ const releaseImages = versionedImages(releaseVersion);
973
1290
  const adminUser = await ask("Keycloak admin user", "admin");
974
1291
  const adminPassword = await ask("Keycloak admin password", randomSecret(12));
975
1292
  const smtpHost = await ask("SMTP host", "smtp.example.com");
@@ -983,13 +1300,16 @@ async function cmdSetup() {
983
1300
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
984
1301
  const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
985
1302
  const config = {
986
- source: defaultSourceConfig(),
1303
+ source: defaultSourceConfig(releaseVersion),
1304
+ release: {
1305
+ version: releaseVersion
1306
+ },
987
1307
  domain: { root_domain: rootDomain, acme_email: acmeEmail },
988
1308
  images: {
989
- api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
990
- keycloak: `envsync-keycloak:${channel}`,
991
- web: `ghcr.io/envsync-cloud/envsync-web-static:${channel}`,
992
- landing: `ghcr.io/envsync-cloud/envsync-landing-static:${channel}`,
1309
+ api: releaseImages.api,
1310
+ keycloak: releaseImages.keycloak,
1311
+ web: releaseImages.web,
1312
+ landing: releaseImages.landing,
993
1313
  clickstack: "clickhouse/clickstack-all-in-one:latest",
994
1314
  traefik: "traefik:v3.1",
995
1315
  otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
@@ -1034,29 +1354,59 @@ async function cmdSetup() {
1034
1354
  mailpit_enabled: mailpitEnabled,
1035
1355
  s3_public: true,
1036
1356
  s3_console_public: true
1037
- },
1038
- release_channel: channel
1357
+ }
1039
1358
  };
1040
1359
  saveDesiredConfig(config);
1041
- console.log(`Config written to ${DEPLOY_YAML}`);
1042
- console.log(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
1043
- 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:");
1044
1363
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
1045
1364
  }
1046
1365
  async function cmdBootstrap() {
1366
+ logSection("Bootstrap");
1047
1367
  const { config, generated } = loadState();
1048
1368
  const nextGenerated = ensureGeneratedRuntimeState(generated);
1369
+ const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
1370
+ logInfo(`Release version: ${config.release.version}`);
1371
+ assertSwarmManager();
1049
1372
  ensureRepoCheckout(config);
1050
1373
  writeDeployArtifacts(config, nextGenerated);
1051
1374
  buildKeycloakImage(config.images.keycloak);
1052
- run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1053
- waitForTcpService(config, "postgres", "postgres", 5432);
1054
- waitForTcpService(config, "redis", "redis", 6379);
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
+ }
1384
+ waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
1385
+ waitForRedisService(config);
1055
1386
  waitForTcpService(config, "rustfs", "rustfs", 9e3);
1056
- waitForTcpService(config, "keycloak", "keycloak", 8080);
1057
- waitForTcpService(config, "openfga", "openfga", 8090);
1387
+ waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_ADMIN_PASSWORD);
1388
+ waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
1389
+ waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
1390
+ runOpenFgaMigrate(config, runtimeEnv);
1391
+ runMiniKmsMigrate(config, runtimeEnv);
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);
1402
+ waitForHttpService(config, "openfga", "http://openfga:8090/stores");
1058
1403
  waitForTcpService(config, "minikms", "minikms", 50051);
1059
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
+ }
1060
1410
  const bootstrappedGenerated = normalizeGeneratedState({
1061
1411
  openfga: {
1062
1412
  store_id: initResult.openfgaStoreId,
@@ -1068,21 +1418,46 @@ async function cmdBootstrap() {
1068
1418
  }
1069
1419
  });
1070
1420
  writeDeployArtifacts(config, bootstrappedGenerated);
1071
- console.log("Bootstrap completed.");
1421
+ logSuccess("Bootstrap completed");
1072
1422
  }
1073
1423
  async function cmdDeploy() {
1424
+ logSection("Deploy");
1074
1425
  const { config, generated } = loadState();
1426
+ logInfo(`Release version: ${config.release.version}`);
1427
+ assertSwarmManager();
1075
1428
  assertBootstrapState(generated);
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");
1436
+ }
1076
1437
  ensureRepoCheckout(config);
1077
1438
  writeDeployArtifacts(config, generated);
1078
1439
  buildKeycloakImage(config.images.keycloak);
1079
- ensureDir(`${RELEASES_ROOT}/web/current`);
1080
- 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
+ }
1081
1447
  extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
1082
1448
  extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
1083
- writeFile(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
1084
- 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]);
1085
1459
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
1460
+ logSuccess("Deploy completed");
1086
1461
  }
1087
1462
  async function cmdHealth(asJson) {
1088
1463
  const { config, generated } = loadState();
@@ -1123,17 +1498,28 @@ async function cmdHealth(asJson) {
1123
1498
  console.log(JSON.stringify(checks, null, 2));
1124
1499
  }
1125
1500
  async function cmdUpgrade() {
1501
+ logSection("Upgrade");
1126
1502
  const { config } = loadState();
1127
- config.images.api = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
1503
+ config.images = {
1504
+ ...config.images,
1505
+ ...versionedImages(config.release.version)
1506
+ };
1128
1507
  saveDesiredConfig(config);
1508
+ if (currentOptions.dryRun) {
1509
+ logDryRun(`Would upgrade stack to release ${config.release.version}`);
1510
+ }
1129
1511
  await cmdDeploy();
1130
1512
  }
1131
1513
  async function cmdUpgradeDeps() {
1514
+ logSection("Upgrade Dependencies");
1132
1515
  const { config } = loadState();
1133
1516
  config.images.traefik = "traefik:v3.1";
1134
1517
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
1135
1518
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
1136
1519
  saveDesiredConfig(config);
1520
+ if (currentOptions.dryRun) {
1521
+ logDryRun("Would refresh dependency image tags and redeploy");
1522
+ }
1137
1523
  await cmdDeploy();
1138
1524
  }
1139
1525
  function sha256File(filePath) {
@@ -1145,6 +1531,11 @@ function stackVolumeName(config, name) {
1145
1531
  return `${config.services.stack_name}_${name}`;
1146
1532
  }
1147
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
+ }
1148
1539
  ensureDir(targetDir);
1149
1540
  run("docker", [
1150
1541
  "run",
@@ -1158,8 +1549,14 @@ function backupDockerVolume(volumeName, targetDir) {
1158
1549
  "-lc",
1159
1550
  "cd /from && tar -czf /to/volume.tar.gz ."
1160
1551
  ]);
1552
+ logSuccess(`Backed up Docker volume ${volumeName}`);
1161
1553
  }
1162
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
+ }
1163
1560
  run("docker", ["volume", "create", volumeName], { quiet: true });
1164
1561
  run("docker", [
1165
1562
  "run",
@@ -1173,20 +1570,35 @@ function restoreDockerVolume(volumeName, sourceDir) {
1173
1570
  "-lc",
1174
1571
  "cd /to && tar -xzf /from/volume.tar.gz"
1175
1572
  ]);
1573
+ logSuccess(`Restored Docker volume ${volumeName}`);
1176
1574
  }
1177
1575
  async function cmdBackup() {
1576
+ logSection("Backup");
1178
1577
  const { config } = loadState();
1179
- ensureDir(config.backup.output_dir);
1180
1578
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
1181
1579
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
1182
1580
  const manifestPath = `${archiveBase}.manifest.json`;
1183
1581
  const tarPath = `${archiveBase}.tar.gz`;
1184
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);
1185
1596
  ensureDir(staged);
1186
1597
  writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
1187
1598
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
1188
1599
  writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
1189
1600
  writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
1601
+ writeFile(path.join(staged, "docker-stack.bootstrap.base.yaml"), fs.readFileSync(BOOTSTRAP_BASE_STACK_FILE, "utf8"));
1190
1602
  writeFile(path.join(staged, "docker-stack.bootstrap.yaml"), fs.readFileSync(BOOTSTRAP_STACK_FILE, "utf8"));
1191
1603
  writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
1192
1604
  writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
@@ -1205,17 +1617,28 @@ async function cmdBackup() {
1205
1617
  volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
1206
1618
  };
1207
1619
  writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
1620
+ logSuccess("Backup completed");
1208
1621
  console.log(tarPath);
1209
1622
  }
1210
1623
  async function cmdRestore(archivePath) {
1211
1624
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
1625
+ logSection("Restore");
1212
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
+ }
1213
1635
  ensureDir(restoreRoot);
1214
1636
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
1215
1637
  writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
1216
1638
  writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
1217
1639
  writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
1218
1640
  writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
1641
+ writeFile(BOOTSTRAP_BASE_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.base.yaml"), "utf8"));
1219
1642
  writeFile(BOOTSTRAP_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.yaml"), "utf8"));
1220
1643
  writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
1221
1644
  writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
@@ -1225,11 +1648,16 @@ async function cmdRestore(archivePath) {
1225
1648
  for (const volume of STACK_VOLUMES) {
1226
1649
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
1227
1650
  }
1228
- console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
1651
+ logSuccess("Restore completed. Run 'envsync-deploy deploy' to start services.");
1229
1652
  }
1230
1653
  async function main() {
1231
- const command = process.argv[2];
1232
- 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");
1233
1661
  switch (command) {
1234
1662
  case "preinstall":
1235
1663
  await cmdPreinstall();
@@ -1244,7 +1672,7 @@ async function main() {
1244
1672
  await cmdDeploy();
1245
1673
  break;
1246
1674
  case "health":
1247
- await cmdHealth(flag === "--json");
1675
+ await cmdHealth(positionals[0] === "--json");
1248
1676
  break;
1249
1677
  case "upgrade":
1250
1678
  await cmdUpgrade();
@@ -1256,10 +1684,12 @@ async function main() {
1256
1684
  await cmdBackup();
1257
1685
  break;
1258
1686
  case "restore":
1259
- await cmdRestore(flag ?? "");
1687
+ await cmdRestore(positionals[0] ?? "");
1260
1688
  break;
1261
1689
  default:
1262
- 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
+ );
1263
1693
  process.exit(command ? 1 : 0);
1264
1694
  }
1265
1695
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.6.2",
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"