@envsync-cloud/deploy-cli 0.6.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/dist/index.js +902 -0
  4. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EnvSync Cloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # `@envsync-cloud/deploy-cli`
2
+
3
+ CLI for self-hosted EnvSync deployment on Docker Swarm.
4
+
5
+ This package provisions and manages the current EnvSync self-hosted stack for a single-host Docker Swarm installation. It is intended for operators deploying EnvSync on Ubuntu or Debian rather than for local app development.
6
+
7
+ ## Supported Target Environment
8
+
9
+ - Single-host Ubuntu or Debian machine
10
+ - Docker Swarm manager node
11
+ - Public DNS ready for the root domain and subdomains
12
+ - Root or sudo access on the target host
13
+
14
+ The current self-hosted direction is documented in the main self-hosting guide:
15
+ - https://github.com/EnvSync-Cloud/envsync/blob/main/SELFHOSTING.md
16
+
17
+ ## Installation
18
+
19
+ Run without a global install:
20
+
21
+ ```bash
22
+ npx @envsync-cloud/deploy-cli <command>
23
+ ```
24
+
25
+ Install globally:
26
+
27
+ ```bash
28
+ npm install -g @envsync-cloud/deploy-cli
29
+ envsync-deploy <command>
30
+ ```
31
+
32
+ Optional Bun invocation:
33
+
34
+ ```bash
35
+ bunx @envsync-cloud/deploy-cli <command>
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ ```text
41
+ envsync-deploy preinstall
42
+ envsync-deploy setup
43
+ envsync-deploy deploy
44
+ envsync-deploy health [--json]
45
+ envsync-deploy upgrade
46
+ envsync-deploy upgrade-deps
47
+ envsync-deploy backup
48
+ envsync-deploy restore <archive>
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ Prepare the host:
54
+
55
+ ```bash
56
+ npx @envsync-cloud/deploy-cli preinstall
57
+ ```
58
+
59
+ Generate deployment state and prompts:
60
+
61
+ ```bash
62
+ npx @envsync-cloud/deploy-cli setup
63
+ ```
64
+
65
+ Apply the Docker Swarm stack:
66
+
67
+ ```bash
68
+ npx @envsync-cloud/deploy-cli deploy
69
+ ```
70
+
71
+ Check service health:
72
+
73
+ ```bash
74
+ npx @envsync-cloud/deploy-cli health --json
75
+ ```
76
+
77
+ Create a backup archive:
78
+
79
+ ```bash
80
+ npx @envsync-cloud/deploy-cli backup
81
+ ```
82
+
83
+ Restore from an existing backup archive:
84
+
85
+ ```bash
86
+ npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
87
+ ```
88
+
89
+ ## Links
90
+
91
+ - Repository: https://github.com/EnvSync-Cloud/envsync
92
+ - Issues: https://github.com/EnvSync-Cloud/envsync/issues
93
+ - Self-hosting guide: https://github.com/EnvSync-Cloud/envsync/blob/main/SELFHOSTING.md
94
+
95
+ ## Versioning
96
+
97
+ This package releases from the shared monorepo tag flow. Published npm versions are tied to repo tags in the form `vX.Y.Z`.
package/dist/index.js ADDED
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createHash, randomBytes } from "crypto";
5
+ import { spawnSync } from "child_process";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ import readline from "readline";
9
+ var HOST_ROOT = "/opt/envsync";
10
+ var DEPLOY_ROOT = "/opt/envsync/deploy";
11
+ var RELEASES_ROOT = "/opt/envsync/releases";
12
+ var BACKUPS_ROOT = "/opt/envsync/backups";
13
+ var ETC_ROOT = "/etc/envsync";
14
+ var TRAEFIK_STATE_ROOT = "/var/lib/envsync/traefik";
15
+ var REPO_ROOT = "/opt/envsync/repo";
16
+ var DEPLOY_ENV = "/etc/envsync/deploy.env";
17
+ var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
18
+ var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
19
+ var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
20
+ var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
21
+ var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
22
+ var NGINX_WEB_CONF = "/opt/envsync/deploy/nginx-web.conf";
23
+ var NGINX_LANDING_CONF = "/opt/envsync/deploy/nginx-landing.conf";
24
+ var OTEL_AGENT_CONF = "/opt/envsync/deploy/otel-agent.yaml";
25
+ var INTERNAL_CONFIG_JSON = "/opt/envsync/deploy/config.json";
26
+ var STACK_VOLUMES = [
27
+ "postgres_data",
28
+ "redis_data",
29
+ "rustfs_data",
30
+ "keycloak_db_data",
31
+ "openfga_db_data",
32
+ "minikms_db_data",
33
+ "clickstack_data",
34
+ "clickstack_ch_data",
35
+ "clickstack_ch_logs"
36
+ ];
37
+ function run(cmd, args, opts = {}) {
38
+ const result = spawnSync(cmd, args, {
39
+ cwd: opts.cwd,
40
+ env: { ...process.env, ...opts.env },
41
+ stdio: opts.quiet ? "pipe" : "inherit",
42
+ encoding: "utf8"
43
+ });
44
+ if (result.status !== 0) {
45
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
46
+ throw new Error(`Command failed: ${cmd} ${args.join(" ")}${stderr ? `
47
+ ${stderr}` : ""}`);
48
+ }
49
+ return result.stdout?.toString() ?? "";
50
+ }
51
+ function ensureDir(dir) {
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+ function writeFile(target, content, mode) {
55
+ ensureDir(path.dirname(target));
56
+ fs.writeFileSync(target, content, "utf8");
57
+ if (mode != null) fs.chmodSync(target, mode);
58
+ }
59
+ function exists(target) {
60
+ return fs.existsSync(target);
61
+ }
62
+ function randomSecret(bytes = 24) {
63
+ return randomBytes(bytes).toString("hex");
64
+ }
65
+ function yamlScalar(value) {
66
+ if (typeof value === "boolean") return value ? "true" : "false";
67
+ if (typeof value === "number") return `${value}`;
68
+ if (/^[A-Za-z0-9._/@:-]+$/.test(value)) return value;
69
+ return JSON.stringify(value);
70
+ }
71
+ function toYaml(value, indent = 0) {
72
+ const pad = " ".repeat(indent);
73
+ if (Array.isArray(value)) {
74
+ return value.map((item) => {
75
+ if (typeof item === "object" && item !== null) {
76
+ const child = toYaml(item, indent + 2);
77
+ return `${pad}- ${child.trimStart()}`.includes("\n") ? `${pad}-
78
+ ${child}` : `${pad}- ${child.trimStart()}`;
79
+ }
80
+ return `${pad}- ${yamlScalar(item)}`;
81
+ }).join("\n");
82
+ }
83
+ if (typeof value === "object" && value !== null) {
84
+ return Object.entries(value).map(([key, item]) => {
85
+ if (Array.isArray(item) || typeof item === "object" && item !== null) {
86
+ return `${pad}${key}:
87
+ ${toYaml(item, indent + 2)}`;
88
+ }
89
+ return `${pad}${key}: ${yamlScalar(item)}`;
90
+ }).join("\n");
91
+ }
92
+ return `${pad}${yamlScalar(value)}`;
93
+ }
94
+ function parseYamlScalar(value) {
95
+ const trimmed = value.trim();
96
+ if (trimmed === "true") return true;
97
+ if (trimmed === "false") return false;
98
+ if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
99
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
100
+ return trimmed.slice(1, -1);
101
+ }
102
+ return trimmed;
103
+ }
104
+ function parseSimpleYamlObject(input) {
105
+ const root = {};
106
+ const stack = [{ indent: -1, value: root }];
107
+ for (const rawLine of input.split(/\r?\n/)) {
108
+ const trimmed = rawLine.trim();
109
+ if (!trimmed || trimmed.startsWith("#")) continue;
110
+ const indent = rawLine.length - rawLine.trimStart().length;
111
+ const separator = trimmed.indexOf(":");
112
+ if (separator === -1) continue;
113
+ const key = trimmed.slice(0, separator).trim();
114
+ const rest = trimmed.slice(separator + 1).trim();
115
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
116
+ stack.pop();
117
+ }
118
+ const parent = stack[stack.length - 1].value;
119
+ if (!rest) {
120
+ const child = {};
121
+ parent[key] = child;
122
+ stack.push({ indent, value: child });
123
+ continue;
124
+ }
125
+ parent[key] = parseYamlScalar(rest);
126
+ }
127
+ return root;
128
+ }
129
+ function indentBlock(content, spaces) {
130
+ const prefix = " ".repeat(spaces);
131
+ return content.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n");
132
+ }
133
+ async function ask(question, fallback = "") {
134
+ if (!process.stdin.isTTY) return fallback;
135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
136
+ return await new Promise((resolve) => {
137
+ rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `, (answer) => {
138
+ rl.close();
139
+ resolve(answer.trim() || fallback);
140
+ });
141
+ });
142
+ }
143
+ function domainMap(rootDomain) {
144
+ return {
145
+ landing: rootDomain,
146
+ app: `app.${rootDomain}`,
147
+ api: `api.${rootDomain}`,
148
+ auth: `auth.${rootDomain}`,
149
+ obs: `obs.${rootDomain}`,
150
+ mail: `mail.${rootDomain}`,
151
+ s3: `s3.${rootDomain}`,
152
+ s3Console: `console.s3.${rootDomain}`
153
+ };
154
+ }
155
+ function loadConfig() {
156
+ if (!exists(DEPLOY_YAML)) {
157
+ throw new Error(`Missing deploy config at ${DEPLOY_YAML}. Run setup first.`);
158
+ }
159
+ const raw = fs.readFileSync(DEPLOY_YAML, "utf8");
160
+ if (raw.trimStart().startsWith("{")) {
161
+ return JSON.parse(raw);
162
+ }
163
+ return parseSimpleYamlObject(raw);
164
+ }
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));
176
+ }
177
+ function buildEnvMap(config) {
178
+ const hosts = domainMap(config.domain.root_domain);
179
+ return {
180
+ NODE_ENV: "production",
181
+ PORT: `${config.services.api_port}`,
182
+ DATABASE_HOST: "postgres",
183
+ DATABASE_PORT: "5432",
184
+ DATABASE_USER: "postgres",
185
+ DATABASE_PASSWORD: "envsync-postgres",
186
+ DATABASE_NAME: "envsync",
187
+ POSTGRES_USER: "postgres",
188
+ POSTGRES_PASSWORD: "envsync-postgres",
189
+ POSTGRES_DB: "envsync",
190
+ S3_BUCKET: "envsync-bucket",
191
+ S3_REGION: "us-east-1",
192
+ S3_ACCESS_KEY: "envsync-rustfs",
193
+ S3_SECRET_KEY: randomSecret(16),
194
+ S3_BUCKET_URL: `https://${hosts.s3}`,
195
+ S3_ENDPOINT: "http://rustfs:9000",
196
+ REDIS_URL: "redis://redis:6379",
197
+ SMTP_HOST: config.smtp.host,
198
+ SMTP_PORT: `${config.smtp.port}`,
199
+ SMTP_SECURE: `${config.smtp.secure}`,
200
+ SMTP_USER: config.smtp.user,
201
+ SMTP_PASS: config.smtp.pass,
202
+ SMTP_FROM: config.smtp.from,
203
+ KEYCLOAK_URL: "http://keycloak:8080",
204
+ KEYCLOAK_REALM: config.auth.keycloak_realm,
205
+ KEYCLOAK_ADMIN_USER: config.auth.admin_user,
206
+ KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
207
+ KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
208
+ KEYCLOAK_WEB_CLIENT_SECRET: randomSecret(),
209
+ KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
210
+ KEYCLOAK_API_CLIENT_ID: config.auth.api_client_id,
211
+ KEYCLOAK_API_CLIENT_SECRET: randomSecret(),
212
+ KEYCLOAK_WEB_REDIRECT_URI: `https://${hosts.api}/api/access/web/callback`,
213
+ KEYCLOAK_WEB_CALLBACK_URL: `https://${hosts.app}/auth/callback`,
214
+ KEYCLOAK_API_REDIRECT_URI: `https://${hosts.api}/api/access/api/callback`,
215
+ LANDING_PAGE_URL: `https://${hosts.landing}`,
216
+ DASHBOARD_URL: `https://${hosts.app}`,
217
+ OPENFGA_API_URL: "http://openfga:8090",
218
+ OPENFGA_STORE_ID: "",
219
+ OPENFGA_MODEL_ID: "",
220
+ OPENFGA_DB_PASSWORD: randomSecret(),
221
+ MINIKMS_GRPC_ADDR: "minikms:50051",
222
+ MINIKMS_TLS_ENABLED: "false",
223
+ MINIKMS_ROOT_KEY: randomBytes(32).toString("hex"),
224
+ MINIKMS_DB_USER: "postgres",
225
+ MINIKMS_DB_PASSWORD: randomSecret(),
226
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
227
+ OTEL_SERVICE_NAME: "envsync-api",
228
+ OTEL_SDK_DISABLED: "false",
229
+ CLICKSTACK_URL: `https://${hosts.obs}`
230
+ };
231
+ }
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";
237
+ }
238
+ function renderServiceEnvironment(config, overrides = {}) {
239
+ return toYaml({ ...buildEnvMap(config), ...overrides }, 0);
240
+ }
241
+ function renderKeycloakRealm(config) {
242
+ const hosts = domainMap(config.domain.root_domain);
243
+ const webSecret = extractEnvValue("KEYCLOAK_WEB_CLIENT_SECRET");
244
+ const apiSecret = extractEnvValue("KEYCLOAK_API_CLIENT_SECRET");
245
+ return JSON.stringify(
246
+ {
247
+ realm: config.auth.keycloak_realm,
248
+ enabled: true,
249
+ loginTheme: "envsync",
250
+ emailTheme: "envsync",
251
+ clients: [
252
+ {
253
+ clientId: config.auth.web_client_id,
254
+ name: "EnvSync Web",
255
+ protocol: "openid-connect",
256
+ publicClient: false,
257
+ secret: webSecret,
258
+ standardFlowEnabled: true,
259
+ directAccessGrantsEnabled: false,
260
+ redirectUris: [`https://${hosts.api}/api/access/web/callback`],
261
+ webOrigins: [`https://${hosts.app}`],
262
+ defaultClientScopes: ["basic", "web-origins", "profile", "email", "roles"]
263
+ },
264
+ {
265
+ clientId: config.auth.api_client_id,
266
+ name: "EnvSync API",
267
+ protocol: "openid-connect",
268
+ publicClient: false,
269
+ secret: apiSecret,
270
+ standardFlowEnabled: true,
271
+ redirectUris: [`https://${hosts.api}/api/access/api/callback`],
272
+ webOrigins: [`https://${hosts.api}`],
273
+ defaultClientScopes: ["basic", "profile", "email", "roles"]
274
+ },
275
+ {
276
+ clientId: config.auth.cli_client_id,
277
+ name: "EnvSync CLI",
278
+ protocol: "openid-connect",
279
+ publicClient: true,
280
+ standardFlowEnabled: false,
281
+ directAccessGrantsEnabled: false,
282
+ attributes: {
283
+ "oauth2.device.authorization.grant.enabled": "true"
284
+ },
285
+ defaultClientScopes: ["basic", "profile", "email", "roles"]
286
+ }
287
+ ]
288
+ },
289
+ null,
290
+ 2
291
+ ) + "\n";
292
+ }
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
+ function renderTraefikDynamicConfig(config) {
299
+ const hosts = domainMap(config.domain.root_domain);
300
+ return [
301
+ "http:",
302
+ " middlewares:",
303
+ " secure-headers:",
304
+ " headers:",
305
+ " browserXssFilter: true",
306
+ " contentTypeNosniff: true",
307
+ " forceSTSHeader: true",
308
+ " stsSeconds: 31536000",
309
+ " gzip:",
310
+ " compress: {}",
311
+ " services:",
312
+ " envsync-api:",
313
+ " weighted:",
314
+ " services:",
315
+ " - name: envsync-api-blue",
316
+ " weight: 100",
317
+ " - name: envsync-api-green",
318
+ " weight: 0",
319
+ " envsync-api-blue:",
320
+ " loadBalancer:",
321
+ " servers:",
322
+ " - url: http://envsync_api_blue:4000",
323
+ " envsync-api-green:",
324
+ " loadBalancer:",
325
+ " servers:",
326
+ " - url: http://envsync_api_green:4000",
327
+ " landing:",
328
+ " loadBalancer:",
329
+ " servers:",
330
+ " - url: http://landing_nginx:8080",
331
+ " web:",
332
+ " loadBalancer:",
333
+ " servers:",
334
+ " - url: http://web_nginx:8080",
335
+ " routers:",
336
+ ` landing-router:`,
337
+ ` rule: Host(\`${hosts.landing}\`)`,
338
+ " service: landing",
339
+ " entryPoints: [websecure]",
340
+ " tls: {}",
341
+ ` web-router:`,
342
+ ` rule: Host(\`${hosts.app}\`)`,
343
+ " service: web",
344
+ " entryPoints: [websecure]",
345
+ " tls: {}",
346
+ ` api-router:`,
347
+ ` rule: Host(\`${hosts.api}\`)`,
348
+ " service: envsync-api",
349
+ " entryPoints: [websecure]",
350
+ " tls: {}"
351
+ ].join("\n") + "\n";
352
+ }
353
+ function renderNginxConf(kind) {
354
+ return [
355
+ "server {",
356
+ " listen 8080;",
357
+ " server_name _;",
358
+ ` root /srv/${kind};`,
359
+ " index index.html;",
360
+ " location / {",
361
+ " try_files $uri $uri/ /index.html;",
362
+ " }",
363
+ "}"
364
+ ].join("\n") + "\n";
365
+ }
366
+ function renderOtelAgentConfig(config) {
367
+ return [
368
+ "receivers:",
369
+ " otlp:",
370
+ " protocols:",
371
+ " grpc:",
372
+ " http:",
373
+ "processors:",
374
+ " batch: {}",
375
+ " resource:",
376
+ " attributes:",
377
+ " - key: deployment.environment",
378
+ " value: production",
379
+ " action: upsert",
380
+ "exporters:",
381
+ " otlphttp/clickstack:",
382
+ ` endpoint: http://clickstack:${config.services.clickstack_otlp_http_port}`,
383
+ "service:",
384
+ " pipelines:",
385
+ " traces:",
386
+ " receivers: [otlp]",
387
+ " processors: [resource, batch]",
388
+ " exporters: [otlphttp/clickstack]",
389
+ " logs:",
390
+ " receivers: [otlp]",
391
+ " processors: [resource, batch]",
392
+ " exporters: [otlphttp/clickstack]",
393
+ " metrics:",
394
+ " receivers: [otlp]",
395
+ " processors: [resource, batch]",
396
+ " exporters: [otlphttp/clickstack]"
397
+ ].join("\n") + "\n";
398
+ }
399
+ function renderStack(config) {
400
+ const hosts = domainMap(config.domain.root_domain);
401
+ const apiEnvironment = renderServiceEnvironment(config, {
402
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
403
+ KEYCLOAK_URL: "http://keycloak:8080",
404
+ OPENFGA_API_URL: "http://openfga:8090",
405
+ MINIKMS_GRPC_ADDR: "minikms:50051",
406
+ S3_ENDPOINT: "http://rustfs:9000",
407
+ S3_BUCKET_URL: `https://${hosts.s3}`
408
+ });
409
+ return `
410
+ version: "3.9"
411
+ services:
412
+ traefik:
413
+ image: ${config.images.traefik}
414
+ command:
415
+ - --providers.docker.swarmMode=true
416
+ - --providers.docker.exposedByDefault=false
417
+ - --providers.file.filename=/etc/traefik/dynamic/traefik-dynamic.yaml
418
+ - --entrypoints.web.address=:80
419
+ - --entrypoints.websecure.address=:443
420
+ - --certificatesresolvers.letsencrypt.acme.email=${config.domain.acme_email}
421
+ - --certificatesresolvers.letsencrypt.acme.storage=/var/lib/traefik/acme.json
422
+ - --certificatesresolvers.letsencrypt.acme.httpchallenge=true
423
+ - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
424
+ ports:
425
+ - target: 80
426
+ published: 80
427
+ protocol: tcp
428
+ mode: host
429
+ - target: 443
430
+ published: 443
431
+ protocol: tcp
432
+ mode: host
433
+ volumes:
434
+ - /var/run/docker.sock:/var/run/docker.sock:ro
435
+ - ${TRAEFIK_STATE_ROOT}:/var/lib/traefik
436
+ - ${DEPLOY_ROOT}:/etc/traefik/dynamic:ro
437
+ networks: [envsync]
438
+
439
+ postgres:
440
+ image: postgres:17
441
+ environment:
442
+ POSTGRES_USER: postgres
443
+ POSTGRES_PASSWORD: envsync-postgres
444
+ POSTGRES_DB: envsync
445
+ volumes:
446
+ - postgres_data:/var/lib/postgresql/data
447
+ networks: [envsync]
448
+
449
+ redis:
450
+ image: redis:7
451
+ volumes:
452
+ - redis_data:/data
453
+ networks: [envsync]
454
+
455
+ rustfs:
456
+ image: rustfs/rustfs:latest
457
+ 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"
462
+ volumes:
463
+ - rustfs_data:/data
464
+ networks: [envsync]
465
+ deploy:
466
+ labels:
467
+ - traefik.enable=true
468
+ - traefik.http.routers.s3.rule=Host(\`${hosts.s3}\`)
469
+ - traefik.http.routers.s3.entrypoints=websecure
470
+ - traefik.http.routers.s3.tls.certresolver=letsencrypt
471
+ - traefik.http.services.s3.loadbalancer.server.port=9000
472
+ - traefik.http.routers.s3-console.rule=Host(\`${hosts.s3Console}\`)
473
+ - traefik.http.routers.s3-console.entrypoints=websecure
474
+ - traefik.http.routers.s3-console.tls.certresolver=letsencrypt
475
+ - traefik.http.services.s3-console.loadbalancer.server.port=9001
476
+
477
+ keycloak_db:
478
+ image: postgres:17
479
+ environment:
480
+ POSTGRES_USER: keycloak
481
+ POSTGRES_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
482
+ POSTGRES_DB: keycloak
483
+ volumes:
484
+ - keycloak_db_data:/var/lib/postgresql/data
485
+ networks: [envsync]
486
+
487
+ keycloak:
488
+ image: ${config.images.keycloak}
489
+ entrypoint: ["/bin/sh", "-lc"]
490
+ command:
491
+ - /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start-dev
492
+ 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}
502
+ volumes:
503
+ - ${DEPLOY_ROOT}/keycloak-realm.envsync.json:/opt/keycloak/data/import/realm.json:ro
504
+ networks: [envsync]
505
+ deploy:
506
+ labels:
507
+ - traefik.enable=true
508
+ - traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
509
+ - traefik.http.routers.keycloak.entrypoints=websecure
510
+ - traefik.http.routers.keycloak.tls.certresolver=letsencrypt
511
+ - traefik.http.services.keycloak.loadbalancer.server.port=8080
512
+
513
+ openfga_db:
514
+ image: postgres:17
515
+ environment:
516
+ POSTGRES_USER: openfga
517
+ POSTGRES_PASSWORD: ${extractEnvValue("OPENFGA_DB_PASSWORD")}
518
+ POSTGRES_DB: openfga
519
+ volumes:
520
+ - openfga_db_data:/var/lib/postgresql/data
521
+ networks: [envsync]
522
+
523
+ openfga:
524
+ image: openfga/openfga:v1.12.0
525
+ command: run
526
+ 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
531
+ networks: [envsync]
532
+
533
+ minikms_db:
534
+ image: postgres:17
535
+ environment:
536
+ POSTGRES_USER: postgres
537
+ POSTGRES_PASSWORD: ${extractEnvValue("MINIKMS_DB_PASSWORD")}
538
+ POSTGRES_DB: minikms
539
+ volumes:
540
+ - minikms_db_data:/var/lib/postgresql/data
541
+ networks: [envsync]
542
+
543
+ minikms:
544
+ image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
545
+ 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"
550
+ networks: [envsync]
551
+
552
+ clickstack:
553
+ image: ${config.images.clickstack}
554
+ volumes:
555
+ - clickstack_data:/data/db
556
+ - clickstack_ch_data:/var/lib/clickhouse
557
+ - clickstack_ch_logs:/var/log/clickhouse-server
558
+ networks: [envsync]
559
+ deploy:
560
+ labels:
561
+ - traefik.enable=true
562
+ - traefik.http.routers.obs.rule=Host(\`${hosts.obs}\`)
563
+ - traefik.http.routers.obs.entrypoints=websecure
564
+ - traefik.http.routers.obs.tls.certresolver=letsencrypt
565
+ - traefik.http.services.obs.loadbalancer.server.port=8080
566
+
567
+ otel-agent:
568
+ image: ${config.images.otel_agent}
569
+ command: ["--config=/etc/otel-agent.yaml"]
570
+ volumes:
571
+ - ${OTEL_AGENT_CONF}:/etc/otel-agent.yaml:ro
572
+ networks: [envsync]
573
+
574
+ landing_nginx:
575
+ image: nginx:1.27-alpine
576
+ volumes:
577
+ - ${NGINX_LANDING_CONF}:/etc/nginx/conf.d/default.conf:ro
578
+ - ${RELEASES_ROOT}/landing/current:/srv/landing:ro
579
+ networks: [envsync]
580
+
581
+ web_nginx:
582
+ image: nginx:1.27-alpine
583
+ volumes:
584
+ - ${NGINX_WEB_CONF}:/etc/nginx/conf.d/default.conf:ro
585
+ - ${RELEASES_ROOT}/web/current:/srv/web:ro
586
+ networks: [envsync]
587
+
588
+ envsync_api_blue:
589
+ image: ${config.images.api}
590
+ environment:
591
+ ${indentBlock(apiEnvironment, 6)}
592
+ networks: [envsync]
593
+
594
+ envsync_api_green:
595
+ image: ${config.images.api}
596
+ environment:
597
+ ${indentBlock(apiEnvironment, 6)}
598
+ networks: [envsync]
599
+
600
+ networks:
601
+ envsync:
602
+ driver: overlay
603
+ attachable: true
604
+
605
+ volumes:
606
+ postgres_data:
607
+ redis_data:
608
+ rustfs_data:
609
+ keycloak_db_data:
610
+ openfga_db_data:
611
+ minikms_db_data:
612
+ clickstack_data:
613
+ clickstack_ch_data:
614
+ clickstack_ch_logs:
615
+ `.trimStart();
616
+ }
617
+ async function cmdPreinstall() {
618
+ ensureDir(HOST_ROOT);
619
+ ensureDir(DEPLOY_ROOT);
620
+ ensureDir(RELEASES_ROOT);
621
+ ensureDir(BACKUPS_ROOT);
622
+ ensureDir(ETC_ROOT);
623
+ ensureDir(TRAEFIK_STATE_ROOT);
624
+ run("bash", ["-lc", "command -v apt-get >/dev/null"]);
625
+ run("sudo", ["apt-get", "update"]);
626
+ run("sudo", ["apt-get", "install", "-y", "docker.io", "docker-compose-v2", "git", "curl", "jq", "openssl", "tar"]);
627
+ run("sudo", ["systemctl", "enable", "--now", "docker"]);
628
+ try {
629
+ run("docker", ["swarm", "init"]);
630
+ } catch {
631
+ }
632
+ run("docker", ["buildx", "version"]);
633
+ run("bash", ["-lc", "curl -fsSL https://ghcr.io >/dev/null"]);
634
+ run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
635
+ }
636
+ async function cmdSetup() {
637
+ const rootDomain = await ask("Root domain", "example.com");
638
+ const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
639
+ const channel = await ask("Release channel", "stable");
640
+ const adminUser = await ask("Keycloak admin user", "admin");
641
+ const adminPassword = await ask("Keycloak admin password", randomSecret(12));
642
+ const smtpHost = await ask("SMTP host", "smtp.example.com");
643
+ const smtpPort = Number(await ask("SMTP port", "587"));
644
+ const smtpSecure = await ask("SMTP secure (true/false)", "true") === "true";
645
+ const smtpUser = await ask("SMTP user", "");
646
+ const smtpPass = await ask("SMTP pass", "");
647
+ const smtpFrom = await ask("SMTP from", `noreply@${rootDomain}`);
648
+ const retentionDays = Number(await ask("ClickStack retention days", "30"));
649
+ const publicAuth = await ask("Expose auth.<domain> publicly (true/false)", "true") === "true";
650
+ const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
651
+ const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
652
+ const config = {
653
+ domain: { root_domain: rootDomain, acme_email: acmeEmail },
654
+ images: {
655
+ api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
656
+ keycloak: `envsync-keycloak:${channel}`,
657
+ web: `ghcr.io/envsync-cloud/envsync-web-static:${channel}`,
658
+ landing: `ghcr.io/envsync-cloud/envsync-landing-static:${channel}`,
659
+ clickstack: "clickhouse/clickstack-all-in-one:latest",
660
+ traefik: "traefik:v3.1",
661
+ otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
662
+ },
663
+ services: {
664
+ stack_name: "envsync",
665
+ api_port: 4e3,
666
+ clickstack_ui_port: 8080,
667
+ clickstack_otlp_http_port: 4318,
668
+ clickstack_otlp_grpc_port: 4317,
669
+ keycloak_port: 8080,
670
+ rustfs_port: 9e3,
671
+ rustfs_console_port: 9001
672
+ },
673
+ auth: {
674
+ keycloak_realm: "envsync",
675
+ admin_user: adminUser,
676
+ admin_password: adminPassword,
677
+ web_client_id: "envsync-web",
678
+ api_client_id: "envsync-api",
679
+ cli_client_id: "envsync-cli"
680
+ },
681
+ observability: {
682
+ retention_days: retentionDays,
683
+ public_obs: publicObs
684
+ },
685
+ backup: {
686
+ output_dir: BACKUPS_ROOT,
687
+ encrypted: true
688
+ },
689
+ smtp: {
690
+ host: smtpHost,
691
+ port: smtpPort,
692
+ secure: smtpSecure,
693
+ user: smtpUser,
694
+ pass: smtpPass,
695
+ from: smtpFrom
696
+ },
697
+ exposure: {
698
+ public_auth: publicAuth,
699
+ public_obs: publicObs,
700
+ mailpit_enabled: mailpitEnabled,
701
+ s3_public: true,
702
+ s3_console_public: true
703
+ },
704
+ release_channel: channel
705
+ };
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);
711
+ console.log(`Config written to ${DEPLOY_YAML}`);
712
+ console.log("Create these DNS records:");
713
+ console.log(JSON.stringify(domainMap(rootDomain), null, 2));
714
+ }
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]);
730
+ }
731
+ async function cmdDeploy() {
732
+ const config = loadConfig();
733
+ buildKeycloakImage(config.images.keycloak);
734
+ ensureDir(`${RELEASES_ROOT}/web/current`);
735
+ ensureDir(`${RELEASES_ROOT}/landing/current`);
736
+ extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
737
+ extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
738
+ run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
739
+ }
740
+ async function cmdHealth(asJson) {
741
+ const config = loadConfig();
742
+ const hosts = domainMap(config.domain.root_domain);
743
+ const services = run("docker", ["stack", "services", config.services.stack_name], { quiet: true });
744
+ const checks = {
745
+ keycloak_image: config.images.keycloak,
746
+ services,
747
+ public: {
748
+ landing: `https://${hosts.landing}`,
749
+ app: `https://${hosts.app}`,
750
+ api: `https://${hosts.api}/health`,
751
+ auth: `https://${hosts.auth}/realms/${config.auth.keycloak_realm}/.well-known/openid-configuration`,
752
+ obs: `https://${hosts.obs}`
753
+ }
754
+ };
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));
759
+ }
760
+ }
761
+ 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);
766
+ await cmdDeploy();
767
+ }
768
+ async function cmdUpgradeDeps() {
769
+ const config = loadConfig();
770
+ config.images.traefik = "traefik:v3.1";
771
+ config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
772
+ config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
773
+ saveConfig(config);
774
+ await cmdDeploy();
775
+ }
776
+ function sha256File(filePath) {
777
+ const hash = createHash("sha256");
778
+ hash.update(fs.readFileSync(filePath));
779
+ return hash.digest("hex");
780
+ }
781
+ function stackVolumeName(config, name) {
782
+ return `${config.services.stack_name}_${name}`;
783
+ }
784
+ function backupDockerVolume(volumeName, targetDir) {
785
+ ensureDir(targetDir);
786
+ run("docker", [
787
+ "run",
788
+ "--rm",
789
+ "-v",
790
+ `${volumeName}:/from:ro`,
791
+ "-v",
792
+ `${targetDir}:/to`,
793
+ "alpine:3.20",
794
+ "sh",
795
+ "-lc",
796
+ "cd /from && tar -czf /to/volume.tar.gz ."
797
+ ]);
798
+ }
799
+ function restoreDockerVolume(volumeName, sourceDir) {
800
+ run("docker", ["volume", "create", volumeName], { quiet: true });
801
+ run("docker", [
802
+ "run",
803
+ "--rm",
804
+ "-v",
805
+ `${volumeName}:/to`,
806
+ "-v",
807
+ `${sourceDir}:/from:ro`,
808
+ "alpine:3.20",
809
+ "sh",
810
+ "-lc",
811
+ "cd /to && tar -xzf /from/volume.tar.gz"
812
+ ]);
813
+ }
814
+ async function cmdBackup() {
815
+ const config = loadConfig();
816
+ ensureDir(config.backup.output_dir);
817
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
818
+ const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
819
+ const manifestPath = `${archiveBase}.manifest.json`;
820
+ const tarPath = `${archiveBase}.tar.gz`;
821
+ const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
822
+ ensureDir(staged);
823
+ writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
824
+ writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
825
+ writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
826
+ writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
827
+ writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
828
+ writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
829
+ writeFile(path.join(staged, "keycloak-realm.envsync.json"), fs.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
830
+ writeFile(path.join(staged, "otel-agent.yaml"), fs.readFileSync(OTEL_AGENT_CONF, "utf8"));
831
+ const volumesDir = path.join(staged, "volumes");
832
+ for (const volume of STACK_VOLUMES) {
833
+ const target = path.join(volumesDir, volume);
834
+ backupDockerVolume(stackVolumeName(config, volume), target);
835
+ }
836
+ run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
837
+ const manifest = {
838
+ archive: path.basename(tarPath),
839
+ sha256: sha256File(tarPath),
840
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
841
+ stack_name: config.services.stack_name,
842
+ volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
843
+ };
844
+ writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
845
+ console.log(tarPath);
846
+ }
847
+ async function cmdRestore(archivePath) {
848
+ if (!archivePath) throw new Error("restore requires a .tar.gz path");
849
+ const config = loadConfig();
850
+ const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
851
+ ensureDir(restoreRoot);
852
+ run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
853
+ writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
854
+ writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
855
+ writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
856
+ writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
857
+ writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
858
+ writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
859
+ writeFile(KEYCLOAK_REALM_FILE, fs.readFileSync(path.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
860
+ writeFile(OTEL_AGENT_CONF, fs.readFileSync(path.join(restoreRoot, "otel-agent.yaml"), "utf8"));
861
+ for (const volume of STACK_VOLUMES) {
862
+ restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
863
+ }
864
+ await cmdDeploy();
865
+ }
866
+ async function main() {
867
+ const command = process.argv[2];
868
+ const flag = process.argv[3];
869
+ switch (command) {
870
+ case "preinstall":
871
+ await cmdPreinstall();
872
+ break;
873
+ case "setup":
874
+ await cmdSetup();
875
+ break;
876
+ case "deploy":
877
+ await cmdDeploy();
878
+ break;
879
+ case "health":
880
+ await cmdHealth(flag === "--json");
881
+ break;
882
+ case "upgrade":
883
+ await cmdUpgrade();
884
+ break;
885
+ case "upgrade-deps":
886
+ await cmdUpgradeDeps();
887
+ break;
888
+ case "backup":
889
+ await cmdBackup();
890
+ break;
891
+ case "restore":
892
+ await cmdRestore(flag ?? "");
893
+ break;
894
+ default:
895
+ console.log("Usage: envsync-deploy <preinstall|setup|deploy|health|upgrade|upgrade-deps|backup|restore>");
896
+ process.exit(command ? 1 : 0);
897
+ }
898
+ }
899
+ main().catch((err) => {
900
+ console.error(err instanceof Error ? err.message : err);
901
+ process.exit(1);
902
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@envsync-cloud/deploy-cli",
3
+ "version": "0.6.0",
4
+ "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
+ "type": "module",
6
+ "bin": {
7
+ "envsync-deploy": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup --config tsup.config.ts",
19
+ "prepack": "bun run build",
20
+ "check:pack": "npm pack --dry-run",
21
+ "start": "bun run src/index.ts"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/EnvSync-Cloud/envsync.git",
26
+ "directory": "packages/deploy-cli"
27
+ },
28
+ "homepage": "https://github.com/EnvSync-Cloud/envsync/tree/main/packages/deploy-cli#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/EnvSync-Cloud/envsync/issues"
31
+ },
32
+ "author": "EnvSync Cloud",
33
+ "license": "MIT",
34
+ "keywords": [
35
+ "envsync",
36
+ "deploy",
37
+ "self-hosted",
38
+ "docker-swarm",
39
+ "cli",
40
+ "secrets"
41
+ ],
42
+ "devDependencies": {
43
+ "tsup": "^8.5.0",
44
+ "typescript": "^5.9.2"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }