@envsync-cloud/deploy-cli 0.6.1 → 0.6.2

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 +14 -2
  2. package/dist/index.js +473 -127
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,6 +40,7 @@ bunx @envsync-cloud/deploy-cli <command>
40
40
  ```text
41
41
  envsync-deploy preinstall
42
42
  envsync-deploy setup
43
+ envsync-deploy bootstrap
43
44
  envsync-deploy deploy
44
45
  envsync-deploy health [--json]
45
46
  envsync-deploy upgrade
@@ -56,18 +57,29 @@ Prepare the host:
56
57
  npx @envsync-cloud/deploy-cli preinstall
57
58
  ```
58
59
 
59
- Generate deployment state and prompts:
60
+ Write the desired self-hosted config:
60
61
 
61
62
  ```bash
62
63
  npx @envsync-cloud/deploy-cli setup
63
64
  ```
64
65
 
65
- Apply the Docker Swarm stack:
66
+ Bootstrap infra, migrations, RustFS, and OpenFGA:
67
+
68
+ ```bash
69
+ npx @envsync-cloud/deploy-cli bootstrap
70
+ ```
71
+
72
+ Deploy the pending API and frontend services:
66
73
 
67
74
  ```bash
68
75
  npx @envsync-cloud/deploy-cli deploy
69
76
  ```
70
77
 
78
+ The staged flow is:
79
+ - `setup` writes desired config
80
+ - `bootstrap` starts infra and persists generated runtime env state
81
+ - `deploy` starts the pending API and frontend services
82
+
71
83
  Check service health:
72
84
 
73
85
  ```bash
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ var DEPLOY_ENV = "/etc/envsync/deploy.env";
17
17
  var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
18
18
  var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
19
19
  var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
20
+ var BOOTSTRAP_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.yaml";
20
21
  var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
21
22
  var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
22
23
  var NGINX_WEB_CONF = "/opt/envsync/deploy/nginx-web.conf";
@@ -34,6 +35,16 @@ var STACK_VOLUMES = [
34
35
  "clickstack_ch_data",
35
36
  "clickstack_ch_logs"
36
37
  ];
38
+ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
39
+ "S3_SECRET_KEY",
40
+ "KEYCLOAK_WEB_CLIENT_SECRET",
41
+ "KEYCLOAK_API_CLIENT_SECRET",
42
+ "OPENFGA_DB_PASSWORD",
43
+ "MINIKMS_ROOT_KEY",
44
+ "MINIKMS_DB_PASSWORD",
45
+ "OPENFGA_STORE_ID",
46
+ "OPENFGA_MODEL_ID"
47
+ ];
37
48
  function run(cmd, args, opts = {}) {
38
49
  const result = spawnSync(cmd, args, {
39
50
  cwd: opts.cwd,
@@ -48,6 +59,22 @@ ${stderr}` : ""}`);
48
59
  }
49
60
  return result.stdout?.toString() ?? "";
50
61
  }
62
+ function tryRun(cmd, args, opts = {}) {
63
+ try {
64
+ return run(cmd, args, opts);
65
+ } catch {
66
+ return "";
67
+ }
68
+ }
69
+ function commandSucceeds(cmd, args, opts = {}) {
70
+ const result = spawnSync(cmd, args, {
71
+ cwd: opts.cwd,
72
+ env: { ...process.env, ...opts.env },
73
+ stdio: "ignore",
74
+ encoding: "utf8"
75
+ });
76
+ return result.status === 0;
77
+ }
51
78
  function ensureDir(dir) {
52
79
  fs.mkdirSync(dir, { recursive: true });
53
80
  }
@@ -126,9 +153,21 @@ function parseSimpleYamlObject(input) {
126
153
  }
127
154
  return root;
128
155
  }
129
- function indentBlock(content, spaces) {
130
- const prefix = " ".repeat(spaces);
131
- return content.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n");
156
+ function parseEnvFile(content) {
157
+ const out = {};
158
+ for (const line of content.split(/\r?\n/)) {
159
+ const trimmed = line.trim();
160
+ if (!trimmed || trimmed.startsWith("#")) continue;
161
+ const eq = trimmed.indexOf("=");
162
+ if (eq === -1) continue;
163
+ const key = trimmed.slice(0, eq).trim();
164
+ let value = trimmed.slice(eq + 1).trim();
165
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
166
+ value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"');
167
+ }
168
+ out[key] = value;
169
+ }
170
+ return out;
132
171
  }
133
172
  async function ask(question, fallback = "") {
134
173
  if (!process.stdin.isTTY) return fallback;
@@ -140,6 +179,9 @@ async function ask(question, fallback = "") {
140
179
  });
141
180
  });
142
181
  }
182
+ function sleepSeconds(seconds) {
183
+ spawnSync("sleep", [`${seconds}`], { stdio: "ignore" });
184
+ }
143
185
  function domainMap(rootDomain) {
144
186
  return {
145
187
  landing: rootDomain,
@@ -152,29 +194,130 @@ function domainMap(rootDomain) {
152
194
  s3Console: `console.s3.${rootDomain}`
153
195
  };
154
196
  }
197
+ function getDeployCliVersion() {
198
+ try {
199
+ const packageJsonPath = new URL("../package.json", import.meta.url);
200
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
201
+ return JSON.parse(raw).version ?? "0.0.0";
202
+ } catch {
203
+ return process.env.npm_package_version ?? "0.0.0";
204
+ }
205
+ }
206
+ function defaultSourceConfig() {
207
+ return {
208
+ repo_url: "https://github.com/EnvSync-Cloud/envsync.git",
209
+ ref: `v${getDeployCliVersion()}`
210
+ };
211
+ }
212
+ function normalizeConfig(raw) {
213
+ return {
214
+ ...raw,
215
+ source: raw.source ?? defaultSourceConfig()
216
+ };
217
+ }
218
+ function emptyGeneratedState() {
219
+ return {
220
+ openfga: {
221
+ store_id: "",
222
+ model_id: ""
223
+ },
224
+ secrets: {
225
+ s3_secret_key: "",
226
+ keycloak_web_client_secret: "",
227
+ keycloak_api_client_secret: "",
228
+ openfga_db_password: "",
229
+ minikms_root_key: "",
230
+ minikms_db_password: ""
231
+ },
232
+ bootstrap: {
233
+ completed_at: ""
234
+ }
235
+ };
236
+ }
237
+ function normalizeGeneratedState(raw) {
238
+ const defaults = emptyGeneratedState();
239
+ return {
240
+ openfga: {
241
+ store_id: raw?.openfga?.store_id ?? defaults.openfga.store_id,
242
+ model_id: raw?.openfga?.model_id ?? defaults.openfga.model_id
243
+ },
244
+ secrets: {
245
+ s3_secret_key: raw?.secrets?.s3_secret_key ?? defaults.secrets.s3_secret_key,
246
+ keycloak_web_client_secret: raw?.secrets?.keycloak_web_client_secret ?? defaults.secrets.keycloak_web_client_secret,
247
+ keycloak_api_client_secret: raw?.secrets?.keycloak_api_client_secret ?? defaults.secrets.keycloak_api_client_secret,
248
+ openfga_db_password: raw?.secrets?.openfga_db_password ?? defaults.secrets.openfga_db_password,
249
+ minikms_root_key: raw?.secrets?.minikms_root_key ?? defaults.secrets.minikms_root_key,
250
+ minikms_db_password: raw?.secrets?.minikms_db_password ?? defaults.secrets.minikms_db_password
251
+ },
252
+ bootstrap: {
253
+ completed_at: raw?.bootstrap?.completed_at ?? defaults.bootstrap.completed_at
254
+ }
255
+ };
256
+ }
257
+ function readInternalState() {
258
+ if (!exists(INTERNAL_CONFIG_JSON)) return null;
259
+ const raw = JSON.parse(fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
260
+ return {
261
+ config: raw.config ? normalizeConfig(raw.config) : void 0,
262
+ generated: normalizeGeneratedState(raw.generated)
263
+ };
264
+ }
155
265
  function loadConfig() {
156
266
  if (!exists(DEPLOY_YAML)) {
157
267
  throw new Error(`Missing deploy config at ${DEPLOY_YAML}. Run setup first.`);
158
268
  }
159
269
  const raw = fs.readFileSync(DEPLOY_YAML, "utf8");
160
270
  if (raw.trimStart().startsWith("{")) {
161
- return JSON.parse(raw);
271
+ return normalizeConfig(JSON.parse(raw));
162
272
  }
163
- return parseSimpleYamlObject(raw);
273
+ return normalizeConfig(parseSimpleYamlObject(raw));
164
274
  }
165
- function saveConfig(config) {
166
- writeFile(DEPLOY_YAML, toYaml(config) + "\n");
167
- writeFile(INTERNAL_CONFIG_JSON, JSON.stringify(config, null, 2) + "\n");
168
- writeFile(DEPLOY_ENV, renderEnv(config), 384);
169
- writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
170
- writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config));
171
- writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
172
- writeFile(STACK_FILE, renderStack(config));
173
- writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
174
- writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
175
- writeFile(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
275
+ function loadGeneratedEnv() {
276
+ if (!exists(DEPLOY_ENV)) return {};
277
+ return parseEnvFile(fs.readFileSync(DEPLOY_ENV, "utf8"));
278
+ }
279
+ function mergeGeneratedState(env, generated) {
280
+ const normalized = normalizeGeneratedState(generated);
281
+ return normalizeGeneratedState({
282
+ openfga: {
283
+ store_id: env.OPENFGA_STORE_ID ?? normalized.openfga.store_id,
284
+ model_id: env.OPENFGA_MODEL_ID ?? normalized.openfga.model_id
285
+ },
286
+ secrets: {
287
+ s3_secret_key: env.S3_SECRET_KEY ?? normalized.secrets.s3_secret_key,
288
+ keycloak_web_client_secret: env.KEYCLOAK_WEB_CLIENT_SECRET ?? normalized.secrets.keycloak_web_client_secret,
289
+ keycloak_api_client_secret: env.KEYCLOAK_API_CLIENT_SECRET ?? normalized.secrets.keycloak_api_client_secret,
290
+ openfga_db_password: env.OPENFGA_DB_PASSWORD ?? normalized.secrets.openfga_db_password,
291
+ minikms_root_key: env.MINIKMS_ROOT_KEY ?? normalized.secrets.minikms_root_key,
292
+ minikms_db_password: env.MINIKMS_DB_PASSWORD ?? normalized.secrets.minikms_db_password
293
+ },
294
+ bootstrap: normalized.bootstrap
295
+ });
296
+ }
297
+ function loadState() {
298
+ const config = loadConfig();
299
+ const internal = readInternalState();
300
+ const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
301
+ return { config, generated };
302
+ }
303
+ function ensureGeneratedRuntimeState(generated) {
304
+ return normalizeGeneratedState({
305
+ openfga: generated.openfga,
306
+ secrets: {
307
+ s3_secret_key: generated.secrets.s3_secret_key || randomSecret(16),
308
+ keycloak_web_client_secret: generated.secrets.keycloak_web_client_secret || randomSecret(),
309
+ keycloak_api_client_secret: generated.secrets.keycloak_api_client_secret || randomSecret(),
310
+ openfga_db_password: generated.secrets.openfga_db_password || randomSecret(),
311
+ minikms_root_key: generated.secrets.minikms_root_key || randomBytes(32).toString("hex"),
312
+ minikms_db_password: generated.secrets.minikms_db_password || randomSecret()
313
+ },
314
+ bootstrap: generated.bootstrap
315
+ });
316
+ }
317
+ function keycloakImageTag(image) {
318
+ return image.split(":").slice(1).join(":") || "local";
176
319
  }
177
- function buildEnvMap(config) {
320
+ function buildRuntimeEnv(config, generated) {
178
321
  const hosts = domainMap(config.domain.root_domain);
179
322
  return {
180
323
  NODE_ENV: "production",
@@ -190,7 +333,7 @@ function buildEnvMap(config) {
190
333
  S3_BUCKET: "envsync-bucket",
191
334
  S3_REGION: "us-east-1",
192
335
  S3_ACCESS_KEY: "envsync-rustfs",
193
- S3_SECRET_KEY: randomSecret(16),
336
+ S3_SECRET_KEY: generated.secrets.s3_secret_key,
194
337
  S3_BUCKET_URL: `https://${hosts.s3}`,
195
338
  S3_ENDPOINT: "http://rustfs:9000",
196
339
  REDIS_URL: "redis://redis:6379",
@@ -205,43 +348,40 @@ function buildEnvMap(config) {
205
348
  KEYCLOAK_ADMIN_USER: config.auth.admin_user,
206
349
  KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
207
350
  KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
208
- KEYCLOAK_WEB_CLIENT_SECRET: randomSecret(),
351
+ KEYCLOAK_WEB_CLIENT_SECRET: generated.secrets.keycloak_web_client_secret,
209
352
  KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
210
353
  KEYCLOAK_API_CLIENT_ID: config.auth.api_client_id,
211
- KEYCLOAK_API_CLIENT_SECRET: randomSecret(),
354
+ KEYCLOAK_API_CLIENT_SECRET: generated.secrets.keycloak_api_client_secret,
212
355
  KEYCLOAK_WEB_REDIRECT_URI: `https://${hosts.api}/api/access/web/callback`,
213
356
  KEYCLOAK_WEB_CALLBACK_URL: `https://${hosts.app}/auth/callback`,
214
357
  KEYCLOAK_API_REDIRECT_URI: `https://${hosts.api}/api/access/api/callback`,
215
358
  LANDING_PAGE_URL: `https://${hosts.landing}`,
216
359
  DASHBOARD_URL: `https://${hosts.app}`,
217
360
  OPENFGA_API_URL: "http://openfga:8090",
218
- OPENFGA_STORE_ID: "",
219
- OPENFGA_MODEL_ID: "",
220
- OPENFGA_DB_PASSWORD: randomSecret(),
361
+ OPENFGA_STORE_ID: generated.openfga.store_id,
362
+ OPENFGA_MODEL_ID: generated.openfga.model_id,
363
+ OPENFGA_DB_PASSWORD: generated.secrets.openfga_db_password,
221
364
  MINIKMS_GRPC_ADDR: "minikms:50051",
222
365
  MINIKMS_TLS_ENABLED: "false",
223
- MINIKMS_ROOT_KEY: randomBytes(32).toString("hex"),
366
+ MINIKMS_ROOT_KEY: generated.secrets.minikms_root_key,
224
367
  MINIKMS_DB_USER: "postgres",
225
- MINIKMS_DB_PASSWORD: randomSecret(),
368
+ MINIKMS_DB_PASSWORD: generated.secrets.minikms_db_password,
226
369
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
227
370
  OTEL_SERVICE_NAME: "envsync-api",
228
371
  OTEL_SDK_DISABLED: "false",
229
- CLICKSTACK_URL: `https://${hosts.obs}`
372
+ CLICKSTACK_URL: `https://${hosts.obs}`,
373
+ KEYCLOAK_IMAGE_TAG: keycloakImageTag(config.images.keycloak)
230
374
  };
231
375
  }
232
- function renderEnv(config) {
233
- return Object.entries({
234
- ...buildEnvMap(config),
235
- KEYCLOAK_IMAGE_TAG: config.images.keycloak.split(":").slice(1).join(":") || "local"
236
- }).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
376
+ function renderEnvFile(env) {
377
+ return Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
237
378
  }
238
- function renderServiceEnvironment(config, overrides = {}) {
239
- return toYaml({ ...buildEnvMap(config), ...overrides }, 0);
379
+ function renderEnvList(values, indent = 6) {
380
+ const prefix = " ".repeat(indent);
381
+ return Object.entries(values).map(([key, value]) => `${prefix}- ${JSON.stringify(`${key}=${String(value)}`)}`).join("\n");
240
382
  }
241
- function renderKeycloakRealm(config) {
383
+ function renderKeycloakRealm(config, runtimeEnv) {
242
384
  const hosts = domainMap(config.domain.root_domain);
243
- const webSecret = extractEnvValue("KEYCLOAK_WEB_CLIENT_SECRET");
244
- const apiSecret = extractEnvValue("KEYCLOAK_API_CLIENT_SECRET");
245
385
  return JSON.stringify(
246
386
  {
247
387
  realm: config.auth.keycloak_realm,
@@ -254,7 +394,7 @@ function renderKeycloakRealm(config) {
254
394
  name: "EnvSync Web",
255
395
  protocol: "openid-connect",
256
396
  publicClient: false,
257
- secret: webSecret,
397
+ secret: runtimeEnv.KEYCLOAK_WEB_CLIENT_SECRET,
258
398
  standardFlowEnabled: true,
259
399
  directAccessGrantsEnabled: false,
260
400
  redirectUris: [
@@ -273,7 +413,7 @@ function renderKeycloakRealm(config) {
273
413
  name: "EnvSync API",
274
414
  protocol: "openid-connect",
275
415
  publicClient: false,
276
- secret: apiSecret,
416
+ secret: runtimeEnv.KEYCLOAK_API_CLIENT_SECRET,
277
417
  standardFlowEnabled: true,
278
418
  redirectUris: [`https://${hosts.api}/api/access/api/callback`],
279
419
  webOrigins: [`https://${hosts.api}`],
@@ -297,11 +437,6 @@ function renderKeycloakRealm(config) {
297
437
  2
298
438
  ) + "\n";
299
439
  }
300
- function extractEnvValue(key) {
301
- const env = exists(DEPLOY_ENV) ? fs.readFileSync(DEPLOY_ENV, "utf8") : "";
302
- const line = env.split(/\r?\n/).find((entry) => entry.startsWith(`${key}=`));
303
- return line?.slice(key.length + 1) ?? "";
304
- }
305
440
  function renderTraefikDynamicConfig(config) {
306
441
  const hosts = domainMap(config.domain.root_domain);
307
442
  return [
@@ -340,17 +475,17 @@ function renderTraefikDynamicConfig(config) {
340
475
  " servers:",
341
476
  " - url: http://web_nginx:8080",
342
477
  " routers:",
343
- ` landing-router:`,
478
+ " landing-router:",
344
479
  ` rule: Host(\`${hosts.landing}\`)`,
345
480
  " service: landing",
346
481
  " entryPoints: [websecure]",
347
482
  " tls: {}",
348
- ` web-router:`,
483
+ " web-router:",
349
484
  ` rule: Host(\`${hosts.app}\`)`,
350
485
  " service: web",
351
486
  " entryPoints: [websecure]",
352
487
  " tls: {}",
353
- ` api-router:`,
488
+ " api-router:",
354
489
  ` rule: Host(\`${hosts.api}\`)`,
355
490
  " service: envsync-api",
356
491
  " entryPoints: [websecure]",
@@ -415,16 +550,17 @@ function renderOtelAgentConfig(config) {
415
550
  " exporters: [otlphttp/clickstack]"
416
551
  ].join("\n") + "\n";
417
552
  }
418
- function renderStack(config) {
553
+ function renderStack(config, runtimeEnv, includeAppServices) {
419
554
  const hosts = domainMap(config.domain.root_domain);
420
- const apiEnvironment = renderServiceEnvironment(config, {
555
+ const apiEnvironment = {
556
+ ...runtimeEnv,
421
557
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
422
558
  KEYCLOAK_URL: "http://keycloak:8080",
423
559
  OPENFGA_API_URL: "http://openfga:8090",
424
560
  MINIKMS_GRPC_ADDR: "minikms:50051",
425
561
  S3_ENDPOINT: "http://rustfs:9000",
426
562
  S3_BUCKET_URL: `https://${hosts.s3}`
427
- });
563
+ };
428
564
  return `
429
565
  version: "3.9"
430
566
  services:
@@ -458,9 +594,11 @@ services:
458
594
  postgres:
459
595
  image: postgres:17
460
596
  environment:
461
- POSTGRES_USER: postgres
462
- POSTGRES_PASSWORD: envsync-postgres
463
- POSTGRES_DB: envsync
597
+ ${renderEnvList({
598
+ POSTGRES_USER: "postgres",
599
+ POSTGRES_PASSWORD: "envsync-postgres",
600
+ POSTGRES_DB: "envsync"
601
+ })}
464
602
  volumes:
465
603
  - postgres_data:/var/lib/postgresql/data
466
604
  networks: [envsync]
@@ -474,10 +612,12 @@ services:
474
612
  rustfs:
475
613
  image: rustfs/rustfs:latest
476
614
  environment:
477
- RUSTFS_DATA_DIR: /data
478
- RUSTFS_ACCESS_KEY: envsync-rustfs
479
- RUSTFS_SECRET_KEY: ${extractEnvValue("S3_SECRET_KEY")}
480
- RUSTFS_CONSOLE_ENABLE: "true"
615
+ ${renderEnvList({
616
+ RUSTFS_DATA_DIR: "/data",
617
+ RUSTFS_ACCESS_KEY: "envsync-rustfs",
618
+ RUSTFS_SECRET_KEY: runtimeEnv.S3_SECRET_KEY,
619
+ RUSTFS_CONSOLE_ENABLE: "true"
620
+ })}
481
621
  volumes:
482
622
  - rustfs_data:/data
483
623
  networks: [envsync]
@@ -496,9 +636,11 @@ services:
496
636
  keycloak_db:
497
637
  image: postgres:17
498
638
  environment:
499
- POSTGRES_USER: keycloak
500
- POSTGRES_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
501
- POSTGRES_DB: keycloak
639
+ ${renderEnvList({
640
+ POSTGRES_USER: "keycloak",
641
+ POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
642
+ POSTGRES_DB: "keycloak"
643
+ })}
502
644
  volumes:
503
645
  - keycloak_db_data:/var/lib/postgresql/data
504
646
  networks: [envsync]
@@ -509,17 +651,19 @@ services:
509
651
  command:
510
652
  - /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start-dev
511
653
  environment:
512
- KC_DB: postgres
513
- KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak
514
- KC_DB_USERNAME: keycloak
515
- KC_DB_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
516
- KC_BOOTSTRAP_ADMIN_USERNAME: ${config.auth.admin_user}
517
- KC_BOOTSTRAP_ADMIN_PASSWORD: ${config.auth.admin_password}
518
- KC_HTTP_ENABLED: "true"
519
- KC_PROXY_HEADERS: xforwarded
520
- KC_HOSTNAME: ${hosts.auth}
654
+ ${renderEnvList({
655
+ KC_DB: "postgres",
656
+ KC_DB_URL: "jdbc:postgresql://keycloak_db:5432/keycloak",
657
+ KC_DB_USERNAME: "keycloak",
658
+ KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
659
+ KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
660
+ KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
661
+ KC_HTTP_ENABLED: "true",
662
+ KC_PROXY_HEADERS: "xforwarded",
663
+ KC_HOSTNAME: hosts.auth
664
+ })}
521
665
  volumes:
522
- - ${DEPLOY_ROOT}/keycloak-realm.envsync.json:/opt/keycloak/data/import/realm.json:ro
666
+ - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
523
667
  networks: [envsync]
524
668
  deploy:
525
669
  labels:
@@ -532,9 +676,11 @@ services:
532
676
  openfga_db:
533
677
  image: postgres:17
534
678
  environment:
535
- POSTGRES_USER: openfga
536
- POSTGRES_PASSWORD: ${extractEnvValue("OPENFGA_DB_PASSWORD")}
537
- POSTGRES_DB: openfga
679
+ ${renderEnvList({
680
+ POSTGRES_USER: "openfga",
681
+ POSTGRES_PASSWORD: runtimeEnv.OPENFGA_DB_PASSWORD,
682
+ POSTGRES_DB: "openfga"
683
+ })}
538
684
  volumes:
539
685
  - openfga_db_data:/var/lib/postgresql/data
540
686
  networks: [envsync]
@@ -543,18 +689,22 @@ services:
543
689
  image: openfga/openfga:v1.12.0
544
690
  command: run
545
691
  environment:
546
- OPENFGA_DATASTORE_ENGINE: postgres
547
- OPENFGA_DATASTORE_URI: postgres://openfga:${extractEnvValue("OPENFGA_DB_PASSWORD")}@openfga_db:5432/openfga?sslmode=disable
548
- OPENFGA_HTTP_ADDR: 0.0.0.0:8090
549
- OPENFGA_GRPC_ADDR: 0.0.0.0:8091
692
+ ${renderEnvList({
693
+ OPENFGA_DATASTORE_ENGINE: "postgres",
694
+ OPENFGA_DATASTORE_URI: `postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
695
+ OPENFGA_HTTP_ADDR: "0.0.0.0:8090",
696
+ OPENFGA_GRPC_ADDR: "0.0.0.0:8091"
697
+ })}
550
698
  networks: [envsync]
551
699
 
552
700
  minikms_db:
553
701
  image: postgres:17
554
702
  environment:
555
- POSTGRES_USER: postgres
556
- POSTGRES_PASSWORD: ${extractEnvValue("MINIKMS_DB_PASSWORD")}
557
- POSTGRES_DB: minikms
703
+ ${renderEnvList({
704
+ POSTGRES_USER: "postgres",
705
+ POSTGRES_PASSWORD: runtimeEnv.MINIKMS_DB_PASSWORD,
706
+ POSTGRES_DB: "minikms"
707
+ })}
558
708
  volumes:
559
709
  - minikms_db_data:/var/lib/postgresql/data
560
710
  networks: [envsync]
@@ -562,10 +712,13 @@ services:
562
712
  minikms:
563
713
  image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
564
714
  environment:
565
- MINIKMS_ROOT_KEY: ${extractEnvValue("MINIKMS_ROOT_KEY")}
566
- MINIKMS_DB_URL: postgres://postgres:${extractEnvValue("MINIKMS_DB_PASSWORD")}@minikms_db:5432/minikms?sslmode=disable
567
- MINIKMS_REDIS_URL: redis://redis:6379
568
- MINIKMS_TLS_ENABLED: "false"
715
+ ${renderEnvList({
716
+ MINIKMS_ROOT_KEY: runtimeEnv.MINIKMS_ROOT_KEY,
717
+ MINIKMS_DB_URL: `postgres://postgres:${runtimeEnv.MINIKMS_DB_PASSWORD}@minikms_db:5432/minikms?sslmode=disable`,
718
+ MINIKMS_REDIS_URL: "redis://redis:6379",
719
+ MINIKMS_GRPC_ADDR: "0.0.0.0:50051",
720
+ MINIKMS_TLS_ENABLED: "false"
721
+ })}
569
722
  networks: [envsync]
570
723
 
571
724
  clickstack:
@@ -589,6 +742,7 @@ services:
589
742
  volumes:
590
743
  - ${OTEL_AGENT_CONF}:/etc/otel-agent.yaml:ro
591
744
  networks: [envsync]
745
+ ${includeAppServices ? `
592
746
 
593
747
  landing_nginx:
594
748
  image: nginx:1.27-alpine
@@ -607,14 +761,14 @@ services:
607
761
  envsync_api_blue:
608
762
  image: ${config.images.api}
609
763
  environment:
610
- ${indentBlock(apiEnvironment, 6)}
764
+ ${renderEnvList(apiEnvironment)}
611
765
  networks: [envsync]
612
766
 
613
767
  envsync_api_green:
614
768
  image: ${config.images.api}
615
769
  environment:
616
- ${indentBlock(apiEnvironment, 6)}
617
- networks: [envsync]
770
+ ${renderEnvList(apiEnvironment)}
771
+ networks: [envsync]` : ""}
618
772
 
619
773
  networks:
620
774
  envsync:
@@ -633,6 +787,166 @@ volumes:
633
787
  clickstack_ch_logs:
634
788
  `.trimStart();
635
789
  }
790
+ function writeDeployArtifacts(config, generated) {
791
+ const runtimeEnv = buildRuntimeEnv(config, generated);
792
+ writeFile(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
793
+ writeFile(
794
+ INTERNAL_CONFIG_JSON,
795
+ JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
796
+ );
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));
805
+ }
806
+ function saveDesiredConfig(config) {
807
+ const internal = readInternalState();
808
+ const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
809
+ writeFile(DEPLOY_YAML, toYaml(config) + "\n");
810
+ writeFile(
811
+ INTERNAL_CONFIG_JSON,
812
+ JSON.stringify({ config, generated }, null, 2) + "\n"
813
+ );
814
+ }
815
+ function ensureRepoCheckout(config) {
816
+ ensureDir(REPO_ROOT);
817
+ if (!exists(path.join(REPO_ROOT, ".git"))) {
818
+ run("git", ["clone", config.source.repo_url, REPO_ROOT]);
819
+ }
820
+ run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
821
+ run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
822
+ run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
823
+ }
824
+ function extractStaticBundle(image, targetDir) {
825
+ ensureDir(targetDir);
826
+ const containerId = run("docker", ["create", image], { quiet: true }).trim();
827
+ try {
828
+ run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
829
+ } finally {
830
+ run("docker", ["rm", "-f", containerId], { quiet: true });
831
+ }
832
+ }
833
+ function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
834
+ const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
835
+ if (!exists(path.join(buildContext, "Dockerfile"))) {
836
+ throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
837
+ }
838
+ run("docker", ["build", "-t", imageTag, buildContext]);
839
+ }
840
+ function stackNetworkName(config) {
841
+ return `${config.services.stack_name}_envsync`;
842
+ }
843
+ function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
844
+ const deadline = Date.now() + timeoutSeconds * 1e3;
845
+ while (Date.now() < deadline) {
846
+ if (commandSucceeds(
847
+ "docker",
848
+ ["run", "--rm", "--network", stackNetworkName(config), "alpine:3.20", "sh", "-lc", `nc -z -w 2 ${host} ${port}`]
849
+ )) {
850
+ return;
851
+ }
852
+ sleepSeconds(2);
853
+ }
854
+ throw new Error(`Timed out waiting for ${label} at ${host}:${port}`);
855
+ }
856
+ function runBootstrapInit(config) {
857
+ const output = run(
858
+ "docker",
859
+ [
860
+ "run",
861
+ "--rm",
862
+ "--network",
863
+ stackNetworkName(config),
864
+ "--env-file",
865
+ DEPLOY_ENV,
866
+ "-e",
867
+ "SKIP_ROOT_ENV=1",
868
+ "-e",
869
+ "SKIP_ROOT_ENV_WRITE=1",
870
+ config.images.api,
871
+ "bun",
872
+ "run",
873
+ "scripts/prod-init.ts",
874
+ "--json",
875
+ "--no-write-root-env"
876
+ ],
877
+ { quiet: true }
878
+ ).trim();
879
+ const result = JSON.parse(output);
880
+ if (!result.openfgaStoreId || !result.openfgaModelId) {
881
+ throw new Error("Bootstrap init did not return OpenFGA IDs");
882
+ }
883
+ return {
884
+ openfgaStoreId: result.openfgaStoreId,
885
+ openfgaModelId: result.openfgaModelId
886
+ };
887
+ }
888
+ function hasCompleteBootstrapState(generated) {
889
+ return REQUIRED_BOOTSTRAP_ENV_KEYS.every((key) => {
890
+ switch (key) {
891
+ case "S3_SECRET_KEY":
892
+ return generated.secrets.s3_secret_key.length > 0;
893
+ case "KEYCLOAK_WEB_CLIENT_SECRET":
894
+ return generated.secrets.keycloak_web_client_secret.length > 0;
895
+ case "KEYCLOAK_API_CLIENT_SECRET":
896
+ return generated.secrets.keycloak_api_client_secret.length > 0;
897
+ case "OPENFGA_DB_PASSWORD":
898
+ return generated.secrets.openfga_db_password.length > 0;
899
+ case "MINIKMS_ROOT_KEY":
900
+ return generated.secrets.minikms_root_key.length > 0;
901
+ case "MINIKMS_DB_PASSWORD":
902
+ return generated.secrets.minikms_db_password.length > 0;
903
+ case "OPENFGA_STORE_ID":
904
+ return generated.openfga.store_id.length > 0;
905
+ case "OPENFGA_MODEL_ID":
906
+ return generated.openfga.model_id.length > 0;
907
+ default:
908
+ return false;
909
+ }
910
+ });
911
+ }
912
+ function assertBootstrapState(generated) {
913
+ if (!hasCompleteBootstrapState(generated)) {
914
+ throw new Error("Missing bootstrap state. Run 'envsync-deploy bootstrap' first.");
915
+ }
916
+ }
917
+ function parseReplicaHealth(raw) {
918
+ const match = raw.match(/^(\d+)\/(\d+)$/);
919
+ if (!match) return raw.trim() ? "degraded" : "missing";
920
+ const current = Number(match[1]);
921
+ const desired = Number(match[2]);
922
+ if (desired === 0) return "missing";
923
+ if (current === desired) return "healthy";
924
+ return "degraded";
925
+ }
926
+ function listStackServices(config) {
927
+ const output = tryRun(
928
+ "docker",
929
+ ["stack", "services", config.services.stack_name, "--format", "{{.Name}}|{{.Replicas}}"],
930
+ { quiet: true }
931
+ );
932
+ const services = /* @__PURE__ */ new Map();
933
+ for (const line of output.split(/\r?\n/)) {
934
+ if (!line.trim()) continue;
935
+ const [name, replicas] = line.split("|");
936
+ services.set(name, parseReplicaHealth(replicas ?? ""));
937
+ }
938
+ return services;
939
+ }
940
+ function serviceHealth(services, name) {
941
+ return services.get(`${name}`) ?? "missing";
942
+ }
943
+ function apiHealth(services, stackName) {
944
+ const blue = serviceHealth(services, `${stackName}_envsync_api_blue`);
945
+ const green = serviceHealth(services, `${stackName}_envsync_api_green`);
946
+ if (blue === "missing" && green === "missing") return "missing";
947
+ if (blue === "healthy" || green === "healthy") return "healthy";
948
+ return "degraded";
949
+ }
636
950
  async function cmdPreinstall() {
637
951
  ensureDir(HOST_ROOT);
638
952
  ensureDir(DEPLOY_ROOT);
@@ -669,6 +983,7 @@ async function cmdSetup() {
669
983
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
670
984
  const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
671
985
  const config = {
986
+ source: defaultSourceConfig(),
672
987
  domain: { root_domain: rootDomain, acme_email: acmeEmail },
673
988
  images: {
674
989
  api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
@@ -722,33 +1037,44 @@ async function cmdSetup() {
722
1037
  },
723
1038
  release_channel: channel
724
1039
  };
725
- ensureDir(REPO_ROOT);
726
- if (!exists(path.join(REPO_ROOT, ".git"))) {
727
- run("git", ["clone", "https://github.com/EnvSync-Cloud/envsync.git", REPO_ROOT]);
728
- }
729
- saveConfig(config);
1040
+ saveDesiredConfig(config);
730
1041
  console.log(`Config written to ${DEPLOY_YAML}`);
1042
+ console.log(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
731
1043
  console.log("Create these DNS records:");
732
1044
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
733
1045
  }
734
- function extractStaticBundle(image, targetDir) {
735
- ensureDir(targetDir);
736
- const containerId = run("docker", ["create", image], { quiet: true }).trim();
737
- try {
738
- run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
739
- } finally {
740
- run("docker", ["rm", "-f", containerId], { quiet: true });
741
- }
742
- }
743
- function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
744
- const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
745
- if (!exists(path.join(buildContext, "Dockerfile"))) {
746
- throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
747
- }
748
- run("docker", ["build", "-t", imageTag, buildContext]);
1046
+ async function cmdBootstrap() {
1047
+ const { config, generated } = loadState();
1048
+ const nextGenerated = ensureGeneratedRuntimeState(generated);
1049
+ ensureRepoCheckout(config);
1050
+ writeDeployArtifacts(config, nextGenerated);
1051
+ 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);
1055
+ waitForTcpService(config, "rustfs", "rustfs", 9e3);
1056
+ waitForTcpService(config, "keycloak", "keycloak", 8080);
1057
+ waitForTcpService(config, "openfga", "openfga", 8090);
1058
+ waitForTcpService(config, "minikms", "minikms", 50051);
1059
+ const initResult = runBootstrapInit(config);
1060
+ const bootstrappedGenerated = normalizeGeneratedState({
1061
+ openfga: {
1062
+ store_id: initResult.openfgaStoreId,
1063
+ model_id: initResult.openfgaModelId
1064
+ },
1065
+ secrets: nextGenerated.secrets,
1066
+ bootstrap: {
1067
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1068
+ }
1069
+ });
1070
+ writeDeployArtifacts(config, bootstrappedGenerated);
1071
+ console.log("Bootstrap completed.");
749
1072
  }
750
1073
  async function cmdDeploy() {
751
- const config = loadConfig();
1074
+ const { config, generated } = loadState();
1075
+ assertBootstrapState(generated);
1076
+ ensureRepoCheckout(config);
1077
+ writeDeployArtifacts(config, generated);
752
1078
  buildKeycloakImage(config.images.keycloak);
753
1079
  ensureDir(`${RELEASES_ROOT}/web/current`);
754
1080
  ensureDir(`${RELEASES_ROOT}/landing/current`);
@@ -759,12 +1085,29 @@ async function cmdDeploy() {
759
1085
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
760
1086
  }
761
1087
  async function cmdHealth(asJson) {
762
- const config = loadConfig();
1088
+ const { config, generated } = loadState();
763
1089
  const hosts = domainMap(config.domain.root_domain);
764
- const services = run("docker", ["stack", "services", config.services.stack_name], { quiet: true });
1090
+ const services = listStackServices(config);
1091
+ const stackName = config.services.stack_name;
1092
+ const bootstrapServices = {
1093
+ postgres: serviceHealth(services, `${stackName}_postgres`),
1094
+ redis: serviceHealth(services, `${stackName}_redis`),
1095
+ rustfs: serviceHealth(services, `${stackName}_rustfs`),
1096
+ keycloak: serviceHealth(services, `${stackName}_keycloak`),
1097
+ openfga: serviceHealth(services, `${stackName}_openfga`),
1098
+ minikms: serviceHealth(services, `${stackName}_minikms`)
1099
+ };
765
1100
  const checks = {
766
- keycloak_image: config.images.keycloak,
767
- services,
1101
+ bootstrap: {
1102
+ completed: hasCompleteBootstrapState(generated) && generated.bootstrap.completed_at.length > 0,
1103
+ completed_at: generated.bootstrap.completed_at || null,
1104
+ services: bootstrapServices
1105
+ },
1106
+ deploy: {
1107
+ api: apiHealth(services, stackName),
1108
+ web: serviceHealth(services, `${stackName}_web_nginx`),
1109
+ landing: serviceHealth(services, `${stackName}_landing_nginx`)
1110
+ },
768
1111
  public: {
769
1112
  landing: `https://${hosts.landing}`,
770
1113
  app: `https://${hosts.app}`,
@@ -773,25 +1116,24 @@ async function cmdHealth(asJson) {
773
1116
  obs: `https://${hosts.obs}`
774
1117
  }
775
1118
  };
776
- if (asJson) console.log(JSON.stringify(checks, null, 2));
777
- else {
778
- console.log(services);
779
- console.log(JSON.stringify(checks.public, null, 2));
1119
+ if (asJson) {
1120
+ console.log(JSON.stringify(checks, null, 2));
1121
+ return;
780
1122
  }
1123
+ console.log(JSON.stringify(checks, null, 2));
781
1124
  }
782
1125
  async function cmdUpgrade() {
783
- const config = loadConfig();
784
- const nextImage = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
785
- config.images.api = nextImage;
786
- saveConfig(config);
1126
+ const { config } = loadState();
1127
+ config.images.api = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
1128
+ saveDesiredConfig(config);
787
1129
  await cmdDeploy();
788
1130
  }
789
1131
  async function cmdUpgradeDeps() {
790
- const config = loadConfig();
1132
+ const { config } = loadState();
791
1133
  config.images.traefik = "traefik:v3.1";
792
1134
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
793
1135
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
794
- saveConfig(config);
1136
+ saveDesiredConfig(config);
795
1137
  await cmdDeploy();
796
1138
  }
797
1139
  function sha256File(filePath) {
@@ -833,7 +1175,7 @@ function restoreDockerVolume(volumeName, sourceDir) {
833
1175
  ]);
834
1176
  }
835
1177
  async function cmdBackup() {
836
- const config = loadConfig();
1178
+ const { config } = loadState();
837
1179
  ensureDir(config.backup.output_dir);
838
1180
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
839
1181
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
@@ -845,14 +1187,14 @@ async function cmdBackup() {
845
1187
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
846
1188
  writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
847
1189
  writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
1190
+ writeFile(path.join(staged, "docker-stack.bootstrap.yaml"), fs.readFileSync(BOOTSTRAP_STACK_FILE, "utf8"));
848
1191
  writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
849
1192
  writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
850
1193
  writeFile(path.join(staged, "keycloak-realm.envsync.json"), fs.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
851
1194
  writeFile(path.join(staged, "otel-agent.yaml"), fs.readFileSync(OTEL_AGENT_CONF, "utf8"));
852
1195
  const volumesDir = path.join(staged, "volumes");
853
1196
  for (const volume of STACK_VOLUMES) {
854
- const target = path.join(volumesDir, volume);
855
- backupDockerVolume(stackVolumeName(config, volume), target);
1197
+ backupDockerVolume(stackVolumeName(config, volume), path.join(volumesDir, volume));
856
1198
  }
857
1199
  run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
858
1200
  const manifest = {
@@ -867,7 +1209,6 @@ async function cmdBackup() {
867
1209
  }
868
1210
  async function cmdRestore(archivePath) {
869
1211
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
870
- const config = loadConfig();
871
1212
  const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
872
1213
  ensureDir(restoreRoot);
873
1214
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
@@ -875,14 +1216,16 @@ async function cmdRestore(archivePath) {
875
1216
  writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
876
1217
  writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
877
1218
  writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
1219
+ writeFile(BOOTSTRAP_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.yaml"), "utf8"));
878
1220
  writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
879
1221
  writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
880
1222
  writeFile(KEYCLOAK_REALM_FILE, fs.readFileSync(path.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
881
1223
  writeFile(OTEL_AGENT_CONF, fs.readFileSync(path.join(restoreRoot, "otel-agent.yaml"), "utf8"));
1224
+ const config = loadConfig();
882
1225
  for (const volume of STACK_VOLUMES) {
883
1226
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
884
1227
  }
885
- await cmdDeploy();
1228
+ console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
886
1229
  }
887
1230
  async function main() {
888
1231
  const command = process.argv[2];
@@ -894,6 +1237,9 @@ async function main() {
894
1237
  case "setup":
895
1238
  await cmdSetup();
896
1239
  break;
1240
+ case "bootstrap":
1241
+ await cmdBootstrap();
1242
+ break;
897
1243
  case "deploy":
898
1244
  await cmdDeploy();
899
1245
  break;
@@ -913,7 +1259,7 @@ async function main() {
913
1259
  await cmdRestore(flag ?? "");
914
1260
  break;
915
1261
  default:
916
- console.log("Usage: envsync-deploy <preinstall|setup|deploy|health|upgrade|upgrade-deps|backup|restore>");
1262
+ console.log("Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore>");
917
1263
  process.exit(command ? 1 : 0);
918
1264
  }
919
1265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {