@envsync-cloud/deploy-cli 0.6.0 → 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 +495 -128
  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,11 +394,18 @@ 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
- redirectUris: [`https://${hosts.api}/api/access/web/callback`],
400
+ redirectUris: [
401
+ `https://${hosts.api}/api/access/web/callback`,
402
+ `https://${hosts.app}/auth/callback`,
403
+ `https://${hosts.app}`
404
+ ],
261
405
  webOrigins: [`https://${hosts.app}`],
406
+ attributes: {
407
+ "post.logout.redirect.uris": "+"
408
+ },
262
409
  defaultClientScopes: ["basic", "web-origins", "profile", "email", "roles"]
263
410
  },
264
411
  {
@@ -266,7 +413,7 @@ function renderKeycloakRealm(config) {
266
413
  name: "EnvSync API",
267
414
  protocol: "openid-connect",
268
415
  publicClient: false,
269
- secret: apiSecret,
416
+ secret: runtimeEnv.KEYCLOAK_API_CLIENT_SECRET,
270
417
  standardFlowEnabled: true,
271
418
  redirectUris: [`https://${hosts.api}/api/access/api/callback`],
272
419
  webOrigins: [`https://${hosts.api}`],
@@ -290,11 +437,6 @@ function renderKeycloakRealm(config) {
290
437
  2
291
438
  ) + "\n";
292
439
  }
293
- function extractEnvValue(key) {
294
- const env = exists(DEPLOY_ENV) ? fs.readFileSync(DEPLOY_ENV, "utf8") : "";
295
- const line = env.split(/\r?\n/).find((entry) => entry.startsWith(`${key}=`));
296
- return line?.slice(key.length + 1) ?? "";
297
- }
298
440
  function renderTraefikDynamicConfig(config) {
299
441
  const hosts = domainMap(config.domain.root_domain);
300
442
  return [
@@ -333,17 +475,17 @@ function renderTraefikDynamicConfig(config) {
333
475
  " servers:",
334
476
  " - url: http://web_nginx:8080",
335
477
  " routers:",
336
- ` landing-router:`,
478
+ " landing-router:",
337
479
  ` rule: Host(\`${hosts.landing}\`)`,
338
480
  " service: landing",
339
481
  " entryPoints: [websecure]",
340
482
  " tls: {}",
341
- ` web-router:`,
483
+ " web-router:",
342
484
  ` rule: Host(\`${hosts.app}\`)`,
343
485
  " service: web",
344
486
  " entryPoints: [websecure]",
345
487
  " tls: {}",
346
- ` api-router:`,
488
+ " api-router:",
347
489
  ` rule: Host(\`${hosts.api}\`)`,
348
490
  " service: envsync-api",
349
491
  " entryPoints: [websecure]",
@@ -363,6 +505,18 @@ function renderNginxConf(kind) {
363
505
  "}"
364
506
  ].join("\n") + "\n";
365
507
  }
508
+ function renderFrontendRuntimeConfig(config) {
509
+ const hosts = domainMap(config.domain.root_domain);
510
+ return `window.__ENVSYNC_RUNTIME_CONFIG__ = ${JSON.stringify({
511
+ apiBaseUrl: `https://${hosts.api}`,
512
+ appBaseUrl: `https://${hosts.app}`,
513
+ authBaseUrl: `https://${hosts.auth}`,
514
+ keycloakRealm: config.auth.keycloak_realm,
515
+ webClientId: config.auth.web_client_id,
516
+ apiDocsUrl: `https://${hosts.api}/docs`
517
+ }, null, 2)};
518
+ `;
519
+ }
366
520
  function renderOtelAgentConfig(config) {
367
521
  return [
368
522
  "receivers:",
@@ -396,16 +550,17 @@ function renderOtelAgentConfig(config) {
396
550
  " exporters: [otlphttp/clickstack]"
397
551
  ].join("\n") + "\n";
398
552
  }
399
- function renderStack(config) {
553
+ function renderStack(config, runtimeEnv, includeAppServices) {
400
554
  const hosts = domainMap(config.domain.root_domain);
401
- const apiEnvironment = renderServiceEnvironment(config, {
555
+ const apiEnvironment = {
556
+ ...runtimeEnv,
402
557
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
403
558
  KEYCLOAK_URL: "http://keycloak:8080",
404
559
  OPENFGA_API_URL: "http://openfga:8090",
405
560
  MINIKMS_GRPC_ADDR: "minikms:50051",
406
561
  S3_ENDPOINT: "http://rustfs:9000",
407
562
  S3_BUCKET_URL: `https://${hosts.s3}`
408
- });
563
+ };
409
564
  return `
410
565
  version: "3.9"
411
566
  services:
@@ -439,9 +594,11 @@ services:
439
594
  postgres:
440
595
  image: postgres:17
441
596
  environment:
442
- POSTGRES_USER: postgres
443
- POSTGRES_PASSWORD: envsync-postgres
444
- POSTGRES_DB: envsync
597
+ ${renderEnvList({
598
+ POSTGRES_USER: "postgres",
599
+ POSTGRES_PASSWORD: "envsync-postgres",
600
+ POSTGRES_DB: "envsync"
601
+ })}
445
602
  volumes:
446
603
  - postgres_data:/var/lib/postgresql/data
447
604
  networks: [envsync]
@@ -455,10 +612,12 @@ services:
455
612
  rustfs:
456
613
  image: rustfs/rustfs:latest
457
614
  environment:
458
- RUSTFS_DATA_DIR: /data
459
- RUSTFS_ACCESS_KEY: envsync-rustfs
460
- RUSTFS_SECRET_KEY: ${extractEnvValue("S3_SECRET_KEY")}
461
- 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
+ })}
462
621
  volumes:
463
622
  - rustfs_data:/data
464
623
  networks: [envsync]
@@ -477,9 +636,11 @@ services:
477
636
  keycloak_db:
478
637
  image: postgres:17
479
638
  environment:
480
- POSTGRES_USER: keycloak
481
- POSTGRES_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
482
- POSTGRES_DB: keycloak
639
+ ${renderEnvList({
640
+ POSTGRES_USER: "keycloak",
641
+ POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
642
+ POSTGRES_DB: "keycloak"
643
+ })}
483
644
  volumes:
484
645
  - keycloak_db_data:/var/lib/postgresql/data
485
646
  networks: [envsync]
@@ -490,17 +651,19 @@ services:
490
651
  command:
491
652
  - /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start-dev
492
653
  environment:
493
- KC_DB: postgres
494
- KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak
495
- KC_DB_USERNAME: keycloak
496
- KC_DB_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
497
- KC_BOOTSTRAP_ADMIN_USERNAME: ${config.auth.admin_user}
498
- KC_BOOTSTRAP_ADMIN_PASSWORD: ${config.auth.admin_password}
499
- KC_HTTP_ENABLED: "true"
500
- KC_PROXY_HEADERS: xforwarded
501
- 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
+ })}
502
665
  volumes:
503
- - ${DEPLOY_ROOT}/keycloak-realm.envsync.json:/opt/keycloak/data/import/realm.json:ro
666
+ - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
504
667
  networks: [envsync]
505
668
  deploy:
506
669
  labels:
@@ -513,9 +676,11 @@ services:
513
676
  openfga_db:
514
677
  image: postgres:17
515
678
  environment:
516
- POSTGRES_USER: openfga
517
- POSTGRES_PASSWORD: ${extractEnvValue("OPENFGA_DB_PASSWORD")}
518
- POSTGRES_DB: openfga
679
+ ${renderEnvList({
680
+ POSTGRES_USER: "openfga",
681
+ POSTGRES_PASSWORD: runtimeEnv.OPENFGA_DB_PASSWORD,
682
+ POSTGRES_DB: "openfga"
683
+ })}
519
684
  volumes:
520
685
  - openfga_db_data:/var/lib/postgresql/data
521
686
  networks: [envsync]
@@ -524,18 +689,22 @@ services:
524
689
  image: openfga/openfga:v1.12.0
525
690
  command: run
526
691
  environment:
527
- OPENFGA_DATASTORE_ENGINE: postgres
528
- OPENFGA_DATASTORE_URI: postgres://openfga:${extractEnvValue("OPENFGA_DB_PASSWORD")}@openfga_db:5432/openfga?sslmode=disable
529
- OPENFGA_HTTP_ADDR: 0.0.0.0:8090
530
- 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
+ })}
531
698
  networks: [envsync]
532
699
 
533
700
  minikms_db:
534
701
  image: postgres:17
535
702
  environment:
536
- POSTGRES_USER: postgres
537
- POSTGRES_PASSWORD: ${extractEnvValue("MINIKMS_DB_PASSWORD")}
538
- POSTGRES_DB: minikms
703
+ ${renderEnvList({
704
+ POSTGRES_USER: "postgres",
705
+ POSTGRES_PASSWORD: runtimeEnv.MINIKMS_DB_PASSWORD,
706
+ POSTGRES_DB: "minikms"
707
+ })}
539
708
  volumes:
540
709
  - minikms_db_data:/var/lib/postgresql/data
541
710
  networks: [envsync]
@@ -543,10 +712,13 @@ services:
543
712
  minikms:
544
713
  image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
545
714
  environment:
546
- MINIKMS_ROOT_KEY: ${extractEnvValue("MINIKMS_ROOT_KEY")}
547
- MINIKMS_DB_URL: postgres://postgres:${extractEnvValue("MINIKMS_DB_PASSWORD")}@minikms_db:5432/minikms?sslmode=disable
548
- MINIKMS_REDIS_URL: redis://redis:6379
549
- 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
+ })}
550
722
  networks: [envsync]
551
723
 
552
724
  clickstack:
@@ -570,6 +742,7 @@ services:
570
742
  volumes:
571
743
  - ${OTEL_AGENT_CONF}:/etc/otel-agent.yaml:ro
572
744
  networks: [envsync]
745
+ ${includeAppServices ? `
573
746
 
574
747
  landing_nginx:
575
748
  image: nginx:1.27-alpine
@@ -588,14 +761,14 @@ services:
588
761
  envsync_api_blue:
589
762
  image: ${config.images.api}
590
763
  environment:
591
- ${indentBlock(apiEnvironment, 6)}
764
+ ${renderEnvList(apiEnvironment)}
592
765
  networks: [envsync]
593
766
 
594
767
  envsync_api_green:
595
768
  image: ${config.images.api}
596
769
  environment:
597
- ${indentBlock(apiEnvironment, 6)}
598
- networks: [envsync]
770
+ ${renderEnvList(apiEnvironment)}
771
+ networks: [envsync]` : ""}
599
772
 
600
773
  networks:
601
774
  envsync:
@@ -614,6 +787,166 @@ volumes:
614
787
  clickstack_ch_logs:
615
788
  `.trimStart();
616
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
+ }
617
950
  async function cmdPreinstall() {
618
951
  ensureDir(HOST_ROOT);
619
952
  ensureDir(DEPLOY_ROOT);
@@ -650,6 +983,7 @@ async function cmdSetup() {
650
983
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
651
984
  const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
652
985
  const config = {
986
+ source: defaultSourceConfig(),
653
987
  domain: { root_domain: rootDomain, acme_email: acmeEmail },
654
988
  images: {
655
989
  api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
@@ -703,47 +1037,77 @@ async function cmdSetup() {
703
1037
  },
704
1038
  release_channel: channel
705
1039
  };
706
- ensureDir(REPO_ROOT);
707
- if (!exists(path.join(REPO_ROOT, ".git"))) {
708
- run("git", ["clone", "https://github.com/EnvSync-Cloud/envsync.git", REPO_ROOT]);
709
- }
710
- saveConfig(config);
1040
+ saveDesiredConfig(config);
711
1041
  console.log(`Config written to ${DEPLOY_YAML}`);
1042
+ console.log(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
712
1043
  console.log("Create these DNS records:");
713
1044
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
714
1045
  }
715
- function extractStaticBundle(image, targetDir) {
716
- ensureDir(targetDir);
717
- const containerId = run("docker", ["create", image], { quiet: true }).trim();
718
- try {
719
- run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
720
- } finally {
721
- run("docker", ["rm", "-f", containerId], { quiet: true });
722
- }
723
- }
724
- function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
725
- const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
726
- if (!exists(path.join(buildContext, "Dockerfile"))) {
727
- throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
728
- }
729
- 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.");
730
1072
  }
731
1073
  async function cmdDeploy() {
732
- const config = loadConfig();
1074
+ const { config, generated } = loadState();
1075
+ assertBootstrapState(generated);
1076
+ ensureRepoCheckout(config);
1077
+ writeDeployArtifacts(config, generated);
733
1078
  buildKeycloakImage(config.images.keycloak);
734
1079
  ensureDir(`${RELEASES_ROOT}/web/current`);
735
1080
  ensureDir(`${RELEASES_ROOT}/landing/current`);
736
1081
  extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
737
1082
  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));
738
1085
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
739
1086
  }
740
1087
  async function cmdHealth(asJson) {
741
- const config = loadConfig();
1088
+ const { config, generated } = loadState();
742
1089
  const hosts = domainMap(config.domain.root_domain);
743
- 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
+ };
744
1100
  const checks = {
745
- keycloak_image: config.images.keycloak,
746
- 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
+ },
747
1111
  public: {
748
1112
  landing: `https://${hosts.landing}`,
749
1113
  app: `https://${hosts.app}`,
@@ -752,25 +1116,24 @@ async function cmdHealth(asJson) {
752
1116
  obs: `https://${hosts.obs}`
753
1117
  }
754
1118
  };
755
- if (asJson) console.log(JSON.stringify(checks, null, 2));
756
- else {
757
- console.log(services);
758
- console.log(JSON.stringify(checks.public, null, 2));
1119
+ if (asJson) {
1120
+ console.log(JSON.stringify(checks, null, 2));
1121
+ return;
759
1122
  }
1123
+ console.log(JSON.stringify(checks, null, 2));
760
1124
  }
761
1125
  async function cmdUpgrade() {
762
- const config = loadConfig();
763
- const nextImage = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
764
- config.images.api = nextImage;
765
- saveConfig(config);
1126
+ const { config } = loadState();
1127
+ config.images.api = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
1128
+ saveDesiredConfig(config);
766
1129
  await cmdDeploy();
767
1130
  }
768
1131
  async function cmdUpgradeDeps() {
769
- const config = loadConfig();
1132
+ const { config } = loadState();
770
1133
  config.images.traefik = "traefik:v3.1";
771
1134
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
772
1135
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
773
- saveConfig(config);
1136
+ saveDesiredConfig(config);
774
1137
  await cmdDeploy();
775
1138
  }
776
1139
  function sha256File(filePath) {
@@ -812,7 +1175,7 @@ function restoreDockerVolume(volumeName, sourceDir) {
812
1175
  ]);
813
1176
  }
814
1177
  async function cmdBackup() {
815
- const config = loadConfig();
1178
+ const { config } = loadState();
816
1179
  ensureDir(config.backup.output_dir);
817
1180
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
818
1181
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
@@ -824,14 +1187,14 @@ async function cmdBackup() {
824
1187
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
825
1188
  writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
826
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"));
827
1191
  writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
828
1192
  writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
829
1193
  writeFile(path.join(staged, "keycloak-realm.envsync.json"), fs.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
830
1194
  writeFile(path.join(staged, "otel-agent.yaml"), fs.readFileSync(OTEL_AGENT_CONF, "utf8"));
831
1195
  const volumesDir = path.join(staged, "volumes");
832
1196
  for (const volume of STACK_VOLUMES) {
833
- const target = path.join(volumesDir, volume);
834
- backupDockerVolume(stackVolumeName(config, volume), target);
1197
+ backupDockerVolume(stackVolumeName(config, volume), path.join(volumesDir, volume));
835
1198
  }
836
1199
  run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
837
1200
  const manifest = {
@@ -846,7 +1209,6 @@ async function cmdBackup() {
846
1209
  }
847
1210
  async function cmdRestore(archivePath) {
848
1211
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
849
- const config = loadConfig();
850
1212
  const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
851
1213
  ensureDir(restoreRoot);
852
1214
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
@@ -854,14 +1216,16 @@ async function cmdRestore(archivePath) {
854
1216
  writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
855
1217
  writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
856
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"));
857
1220
  writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
858
1221
  writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
859
1222
  writeFile(KEYCLOAK_REALM_FILE, fs.readFileSync(path.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
860
1223
  writeFile(OTEL_AGENT_CONF, fs.readFileSync(path.join(restoreRoot, "otel-agent.yaml"), "utf8"));
1224
+ const config = loadConfig();
861
1225
  for (const volume of STACK_VOLUMES) {
862
1226
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
863
1227
  }
864
- await cmdDeploy();
1228
+ console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
865
1229
  }
866
1230
  async function main() {
867
1231
  const command = process.argv[2];
@@ -873,6 +1237,9 @@ async function main() {
873
1237
  case "setup":
874
1238
  await cmdSetup();
875
1239
  break;
1240
+ case "bootstrap":
1241
+ await cmdBootstrap();
1242
+ break;
876
1243
  case "deploy":
877
1244
  await cmdDeploy();
878
1245
  break;
@@ -892,7 +1259,7 @@ async function main() {
892
1259
  await cmdRestore(flag ?? "");
893
1260
  break;
894
1261
  default:
895
- 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>");
896
1263
  process.exit(command ? 1 : 0);
897
1264
  }
898
1265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {