wattetheria 0.1.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.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { run } = require("../lib/cli");
4
+
5
+ run(process.argv.slice(2)).catch((error) => {
6
+ const message = error && error.message ? error.message : String(error);
7
+ console.error(message);
8
+ process.exit(1);
9
+ });
@@ -0,0 +1,165 @@
1
+ # Release deployment stack: wattetheria + wattswarm.
2
+ #
3
+ # This file is intended for published image deployment.
4
+ # It should not depend on sibling local repositories or local Docker builds.
5
+ #
6
+ # Usage:
7
+ # export WATTSWARM_PG_PASSWORD='<strong-password>'
8
+ # docker compose -f docker-compose.release.yml up -d
9
+ #
10
+ # Notes:
11
+ # - Use coordinated image tags for wattetheria and wattswarm.
12
+ # - Prefer pinning image digests for production-grade reproducibility.
13
+ # - Avoid `container_name` so multiple environments and rolling upgrades remain possible.
14
+
15
+ services:
16
+ kernel:
17
+ image: ${WATTETHERIA_KERNEL_IMAGE:-wattetheria/wattetheria-kernel:latest}
18
+ restart: unless-stopped
19
+ depends_on:
20
+ wattswarm-kernel:
21
+ condition: service_started
22
+ environment:
23
+ WATTETHERIA_DATA_DIR: /var/lib/wattetheria
24
+ WATTETHERIA_CONTROL_PLANE_BIND: 0.0.0.0:7777
25
+ WATTETHERIA_WATTSWARM_UI_BASE_URL: http://wattswarm-kernel:7788
26
+ WATTETHERIA_WATTSWARM_SYNC_GRPC_ENDPOINT: http://wattswarm-kernel:7791
27
+ WATTETHERIA_BRAIN_PROVIDER_KIND: ${WATTETHERIA_BRAIN_PROVIDER_KIND:-rules}
28
+ WATTETHERIA_BRAIN_BASE_URL: ${WATTETHERIA_BRAIN_BASE_URL:-}
29
+ WATTETHERIA_BRAIN_MODEL: ${WATTETHERIA_BRAIN_MODEL:-}
30
+ WATTETHERIA_BRAIN_API_KEY_ENV: ${WATTETHERIA_BRAIN_API_KEY_ENV:-}
31
+ WATTETHERIA_SERVICENET_BASE_URL: ${WATTETHERIA_SERVICENET_BASE_URL:-}
32
+ WATTETHERIA_AUTONOMY_ENABLED: ${WATTETHERIA_AUTONOMY_ENABLED:-false}
33
+ WATTETHERIA_AUTONOMY_INTERVAL_SEC: ${WATTETHERIA_AUTONOMY_INTERVAL_SEC:-30}
34
+ OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
35
+ volumes:
36
+ - wattetheria_state:/var/lib/wattetheria
37
+ ports:
38
+ - "${WATTETHERIA_CONTROL_PLANE_BIND_HOST:-127.0.0.1}:${WATTETHERIA_CONTROL_PLANE_PORT:-7777}:7777"
39
+ networks:
40
+ - watt-internal
41
+ entrypoint: ["/app/scripts/docker-kernel-entrypoint.sh"]
42
+
43
+ observatory:
44
+ image: ${WATTETHERIA_OBSERVATORY_IMAGE:-wattetheria/wattetheria-observatory:latest}
45
+ restart: unless-stopped
46
+ depends_on:
47
+ - kernel
48
+ environment:
49
+ PORT: 8787
50
+ ports:
51
+ - "${WATTETHERIA_OBSERVATORY_BIND_HOST:-127.0.0.1}:${WATTETHERIA_OBSERVATORY_PORT:-8780}:8787"
52
+ networks:
53
+ - watt-internal
54
+ entrypoint: ["/app/scripts/docker-observatory-entrypoint.sh"]
55
+
56
+ wattswarm-postgres:
57
+ image: postgres:16
58
+ restart: unless-stopped
59
+ environment:
60
+ POSTGRES_DB: ${WATTSWARM_PG_DB:-wattswarm}
61
+ POSTGRES_USER: ${WATTSWARM_PG_USER:-postgres}
62
+ POSTGRES_PASSWORD: ${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}
63
+ volumes:
64
+ - wattswarm_pg_data:/var/lib/postgresql/data
65
+ healthcheck:
66
+ test: ["CMD-SHELL", "pg_isready -U ${WATTSWARM_PG_USER:-postgres} -d ${WATTSWARM_PG_DB:-wattswarm}"]
67
+ interval: 5s
68
+ timeout: 3s
69
+ retries: 20
70
+ networks:
71
+ - watt-internal
72
+
73
+ wattswarm-runtime:
74
+ image: ${WATTSWARM_RUNTIME_IMAGE:-wattetheria/wattswarm-runtime:latest}
75
+ restart: unless-stopped
76
+ entrypoint: ["/app/target/release/wattswarm-runtime"]
77
+ command: ["--listen", "0.0.0.0:8787"]
78
+ healthcheck:
79
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8787/health > /dev/null"]
80
+ interval: 5s
81
+ timeout: 3s
82
+ retries: 20
83
+ start_period: 5s
84
+ networks:
85
+ - watt-internal
86
+
87
+ wattswarm-kernel:
88
+ image: ${WATTSWARM_KERNEL_IMAGE:-wattetheria/wattswarm-kernel:latest}
89
+ restart: unless-stopped
90
+ depends_on:
91
+ wattswarm-postgres:
92
+ condition: service_healthy
93
+ wattswarm-runtime:
94
+ condition: service_healthy
95
+ environment:
96
+ WATTSWARM_PG_URL: postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
97
+ WATTSWARM_STATE_DIR: /var/lib/wattswarm
98
+ WATTSWARM_STORE_NAME: wattswarm.state
99
+ WATTSWARM_UI_LISTEN: 0.0.0.0:7788
100
+ WATTSWARM_WATTETHERIA_SYNC_GRPC_LISTEN: "0.0.0.0:7791"
101
+ WATTSWARM_P2P_ENABLED: ${WATTSWARM_P2P_ENABLED:-true}
102
+ WATTSWARM_P2P_MDNS: ${WATTSWARM_P2P_MDNS:-true}
103
+ WATTSWARM_P2P_PORT: ${WATTSWARM_P2P_PORT:-4001}
104
+ WATTSWARM_BOOTSTRAP_EXECUTOR_NAME: rt
105
+ WATTSWARM_BOOTSTRAP_EXECUTOR_URL: http://wattswarm-runtime:8787
106
+ WATTSWARM_UDP_ANNOUNCE_ENABLED: ${WATTSWARM_UDP_ANNOUNCE_ENABLED:-false}
107
+ WATTSWARM_UDP_ANNOUNCE_MODE: ${WATTSWARM_UDP_ANNOUNCE_MODE:-multicast}
108
+ WATTSWARM_UDP_ANNOUNCE_ADDR: ${WATTSWARM_UDP_ANNOUNCE_ADDR:-239.255.42.99}
109
+ WATTSWARM_UDP_ANNOUNCE_PORT: ${WATTSWARM_UDP_ANNOUNCE_PORT:-37931}
110
+ volumes:
111
+ - wattswarm_state_data:/var/lib/wattswarm
112
+ ports:
113
+ - "${WATTSWARM_UI_BIND_HOST:-127.0.0.1}:${WATTSWARM_UI_PORT:-7788}:7788"
114
+ - "${WATTSWARM_P2P_HOST_PORT:-4001}:${WATTSWARM_P2P_PORT:-4001}"
115
+ - "${WATTSWARM_UDP_ANNOUNCE_HOST_PORT:-37931}:${WATTSWARM_UDP_ANNOUNCE_PORT:-37931}/udp"
116
+ networks:
117
+ - watt-internal
118
+ entrypoint: ["/app/scripts/docker-kernel-entrypoint.sh"]
119
+
120
+ wattswarm-worker:
121
+ image: ${WATTSWARM_WORKER_IMAGE:-wattetheria/wattswarm-worker:latest}
122
+ restart: unless-stopped
123
+ depends_on:
124
+ wattswarm-postgres:
125
+ condition: service_healthy
126
+ wattswarm-runtime:
127
+ condition: service_healthy
128
+ wattswarm-kernel:
129
+ condition: service_started
130
+ environment:
131
+ WATTSWARM_PG_URL: postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
132
+ WATTSWARM_STATE_DIR: /var/lib/wattswarm
133
+ WATTSWARM_STORE_NAME: wattswarm.state
134
+ WATTSWARM_WORKER_CONCURRENCY: ${WATTSWARM_WORKER_CONCURRENCY:-16}
135
+ WATTSWARM_WORKER_POLL_MS: ${WATTSWARM_WORKER_POLL_MS:-250}
136
+ WATTSWARM_WORKER_LEASE_MS: ${WATTSWARM_WORKER_LEASE_MS:-30000}
137
+ volumes:
138
+ - wattswarm_state_data:/var/lib/wattswarm
139
+ networks:
140
+ - watt-internal
141
+ entrypoint: ["/app/target/release/wattswarm"]
142
+ command:
143
+ - --state-dir
144
+ - /var/lib/wattswarm
145
+ - --store
146
+ - wattswarm.state
147
+ - run
148
+ - --pg-url
149
+ - postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
150
+ - worker
151
+ - --concurrency
152
+ - "${WATTSWARM_WORKER_CONCURRENCY:-16}"
153
+ - --poll-ms
154
+ - "${WATTSWARM_WORKER_POLL_MS:-250}"
155
+ - --lease-ms
156
+ - "${WATTSWARM_WORKER_LEASE_MS:-30000}"
157
+
158
+ volumes:
159
+ wattetheria_state:
160
+ wattswarm_pg_data:
161
+ wattswarm_state_data:
162
+
163
+ networks:
164
+ watt-internal:
165
+ driver: bridge
package/lib/cli.js ADDED
@@ -0,0 +1,423 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const { spawnSync } = require("node:child_process");
6
+
7
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
8
+ const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
9
+ const DEFAULT_DEPLOY_DIR = path.join(os.homedir(), ".wattetheria", "deploy");
10
+ const DEFAULT_PROJECT_NAME = "wattetheria";
11
+ const DEFAULT_COMMAND = "install";
12
+ const IMAGE_KEYS = [
13
+ "WATTETHERIA_KERNEL_IMAGE",
14
+ "WATTETHERIA_OBSERVATORY_IMAGE",
15
+ "WATTSWARM_KERNEL_IMAGE",
16
+ "WATTSWARM_RUNTIME_IMAGE",
17
+ "WATTSWARM_WORKER_IMAGE"
18
+ ];
19
+
20
+ function printHelp() {
21
+ console.log(`Wattetheria CLI ${PACKAGE_JSON.version}
22
+
23
+ Usage:
24
+ npx wattetheria [command] [options]
25
+ npx wattetheria install
26
+
27
+ Commands:
28
+ install Prepare deployment, pull images, and start the stack
29
+ start Start an existing deployment
30
+ status Show docker compose status
31
+ update Update image tags, pull, and restart
32
+ stop Stop the deployment
33
+ uninstall Stop the deployment and optionally remove volumes
34
+ logs Show docker compose logs
35
+ doctor Check local prerequisites
36
+ help Show this help
37
+
38
+ Options:
39
+ --dir <path> Deployment directory (default: ${DEFAULT_DEPLOY_DIR})
40
+ --project-name <name> Docker compose project name (default: ${DEFAULT_PROJECT_NAME})
41
+ --tag <tag> Override all release image tags
42
+ --force Refresh deployment templates from package assets
43
+ --no-health-checks Skip HTTP health checks
44
+ --volumes With uninstall, remove named docker volumes
45
+ --purge With uninstall, remove the deployment directory
46
+ `);
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ let command = DEFAULT_COMMAND;
51
+ let index = 0;
52
+ if (argv[0] && !argv[0].startsWith("-")) {
53
+ command = argv[0];
54
+ index = 1;
55
+ }
56
+
57
+ const options = {
58
+ dir: DEFAULT_DEPLOY_DIR,
59
+ projectName: DEFAULT_PROJECT_NAME,
60
+ tag: PACKAGE_JSON.version,
61
+ force: false,
62
+ healthChecks: true,
63
+ volumes: false,
64
+ purge: false,
65
+ composeArgs: []
66
+ };
67
+
68
+ while (index < argv.length) {
69
+ const arg = argv[index];
70
+ if (arg === "--dir") {
71
+ options.dir = requireValue(arg, argv[++index]);
72
+ } else if (arg === "--project-name") {
73
+ options.projectName = requireValue(arg, argv[++index]);
74
+ } else if (arg === "--tag") {
75
+ options.tag = requireValue(arg, argv[++index]);
76
+ } else if (arg === "--force") {
77
+ options.force = true;
78
+ } else if (arg === "--no-health-checks") {
79
+ options.healthChecks = false;
80
+ } else if (arg === "--volumes") {
81
+ options.volumes = true;
82
+ } else if (arg === "--purge") {
83
+ options.purge = true;
84
+ } else if (arg === "--") {
85
+ options.composeArgs = argv.slice(index + 1);
86
+ break;
87
+ } else if (command === "logs") {
88
+ options.composeArgs.push(arg);
89
+ } else if (command === "help") {
90
+ break;
91
+ } else {
92
+ throw new Error(`Unknown option: ${arg}`);
93
+ }
94
+ index += 1;
95
+ }
96
+
97
+ return { command, options };
98
+ }
99
+
100
+ function requireValue(flag, value) {
101
+ if (!value || value.startsWith("-")) {
102
+ throw new Error(`Missing value for ${flag}`);
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function ensureCommandAvailable(command, helpText) {
108
+ const result = spawnSync(command, ["--version"], { stdio: "ignore" });
109
+ if (result.error || result.status !== 0) {
110
+ throw new Error(helpText);
111
+ }
112
+ }
113
+
114
+ function ensureDockerAvailable() {
115
+ ensureCommandAvailable(
116
+ "docker",
117
+ "Docker is required. Install Docker Desktop or another Docker-compatible runtime first."
118
+ );
119
+
120
+ const compose = spawnSync("docker", ["compose", "version"], {
121
+ stdio: "ignore"
122
+ });
123
+ if (compose.error || compose.status !== 0) {
124
+ throw new Error("Docker Compose v2 is required.");
125
+ }
126
+
127
+ const info = spawnSync("docker", ["info"], { stdio: "ignore" });
128
+ if (info.error || info.status !== 0) {
129
+ throw new Error("Docker daemon is not reachable. Start Docker Desktop or the Docker service first.");
130
+ }
131
+ }
132
+
133
+ function ensureDeploymentAssets(options) {
134
+ fs.mkdirSync(options.dir, { recursive: true });
135
+
136
+ const templateEnvPath = path.join(PACKAGE_ROOT, ".env.release.example");
137
+ const templateComposePath = path.join(PACKAGE_ROOT, "docker-compose.release.yml");
138
+ const targetEnvPath = envFilePath(options);
139
+ const targetComposePath = composeFilePath(options);
140
+
141
+ if (options.force || !fs.existsSync(targetEnvPath)) {
142
+ fs.copyFileSync(templateEnvPath, targetEnvPath);
143
+ }
144
+ if (options.force || !fs.existsSync(targetComposePath)) {
145
+ fs.copyFileSync(templateComposePath, targetComposePath);
146
+ }
147
+
148
+ const envMap = readEnvFile(targetEnvPath);
149
+ ensureDatabasePassword(envMap);
150
+ if (options.tag) {
151
+ pinImageTags(envMap, options.tag);
152
+ }
153
+ writeEnvFile(targetEnvPath, envMap);
154
+ }
155
+
156
+ function envFilePath(options) {
157
+ return path.join(options.dir, ".env");
158
+ }
159
+
160
+ function composeFilePath(options) {
161
+ return path.join(options.dir, "docker-compose.yml");
162
+ }
163
+
164
+ function readEnvFile(filePath) {
165
+ const envMap = new Map();
166
+ const content = fs.readFileSync(filePath, "utf8");
167
+ for (const rawLine of content.split(/\r?\n/)) {
168
+ const line = rawLine.trim();
169
+ if (!line || line.startsWith("#")) {
170
+ continue;
171
+ }
172
+ const separator = rawLine.indexOf("=");
173
+ if (separator < 0) {
174
+ continue;
175
+ }
176
+ const key = rawLine.slice(0, separator).trim();
177
+ const value = rawLine.slice(separator + 1);
178
+ envMap.set(key, value);
179
+ }
180
+ return envMap;
181
+ }
182
+
183
+ function writeEnvFile(filePath, envMap) {
184
+ const lines = [];
185
+ for (const [key, value] of envMap.entries()) {
186
+ lines.push(`${key}=${value}`);
187
+ }
188
+ fs.writeFileSync(filePath, `${lines.join("\n")}\n`);
189
+ }
190
+
191
+ function ensureDatabasePassword(envMap) {
192
+ const placeholder = "replace-with-strong-password";
193
+ const current = envMap.get("WATTSWARM_PG_PASSWORD");
194
+ if (!current || current === placeholder) {
195
+ envMap.set("WATTSWARM_PG_PASSWORD", randomPassword());
196
+ }
197
+ }
198
+
199
+ function pinImageTags(envMap, tag) {
200
+ for (const key of IMAGE_KEYS) {
201
+ if (!envMap.has(key)) {
202
+ continue;
203
+ }
204
+ const current = envMap.get(key);
205
+ const index = current.lastIndexOf(":");
206
+ if (index <= current.lastIndexOf("/")) {
207
+ envMap.set(key, `${current}:${tag}`);
208
+ } else {
209
+ envMap.set(key, `${current.slice(0, index + 1)}${tag}`);
210
+ }
211
+ }
212
+ }
213
+
214
+ function randomPassword() {
215
+ return crypto
216
+ .randomBytes(24)
217
+ .toString("base64")
218
+ .replace(/\+/g, "A")
219
+ .replace(/\//g, "B")
220
+ .replace(/=/g, "");
221
+ }
222
+
223
+ function runCompose(options, args, capture = false) {
224
+ const result = spawnSync(
225
+ "docker",
226
+ [
227
+ "compose",
228
+ "--project-name",
229
+ options.projectName,
230
+ "--env-file",
231
+ envFilePath(options),
232
+ "-f",
233
+ composeFilePath(options),
234
+ ...args
235
+ ],
236
+ {
237
+ stdio: capture ? "pipe" : "inherit",
238
+ encoding: capture ? "utf8" : undefined
239
+ }
240
+ );
241
+ if (result.error) {
242
+ throw result.error;
243
+ }
244
+ if (result.status !== 0) {
245
+ if (capture && result.stderr) {
246
+ throw new Error(result.stderr.trim() || `docker compose ${args.join(" ")} failed`);
247
+ }
248
+ throw new Error(`docker compose ${args.join(" ")} failed`);
249
+ }
250
+ return result;
251
+ }
252
+
253
+ async function waitForHttp(name, url, maxAttempts = 60, delayMs = 2000) {
254
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
255
+ try {
256
+ const response = await fetch(url, { method: "GET" });
257
+ if (response.ok) {
258
+ console.log(`[ok] ${name}: ${url}`);
259
+ return;
260
+ }
261
+ } catch (error) {
262
+ // retry
263
+ }
264
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
265
+ }
266
+ throw new Error(`Timed out waiting for ${name}: ${url}`);
267
+ }
268
+
269
+ function getEnvValue(envMap, key, defaultValue) {
270
+ const value = envMap.get(key);
271
+ return value && value.trim() ? value : defaultValue;
272
+ }
273
+
274
+ async function runHealthChecks(options) {
275
+ const envMap = readEnvFile(envFilePath(options));
276
+ const kernelHost = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
277
+ const kernelPort = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_PORT", "7777");
278
+ const uiHost = getEnvValue(envMap, "WATTSWARM_UI_BIND_HOST", "127.0.0.1");
279
+ const uiPort = getEnvValue(envMap, "WATTSWARM_UI_PORT", "7788");
280
+ const observatoryHost = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_BIND_HOST", "127.0.0.1");
281
+ const observatoryPort = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_PORT", "8780");
282
+
283
+ await waitForHttp("kernel health", `http://${kernelHost}:${kernelPort}/v1/health`);
284
+ await waitForHttp("wattswarm ui", `http://${uiHost}:${uiPort}/`);
285
+ await waitForHttp("observatory health", `http://${observatoryHost}:${observatoryPort}/healthz`);
286
+ }
287
+
288
+ function printSummary(options) {
289
+ const envMap = readEnvFile(envFilePath(options));
290
+ const kernelHost = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
291
+ const kernelPort = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_PORT", "7777");
292
+ const uiHost = getEnvValue(envMap, "WATTSWARM_UI_BIND_HOST", "127.0.0.1");
293
+ const uiPort = getEnvValue(envMap, "WATTSWARM_UI_PORT", "7788");
294
+ const observatoryHost = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_BIND_HOST", "127.0.0.1");
295
+ const observatoryPort = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_PORT", "8780");
296
+
297
+ console.log("");
298
+ console.log("Deployment complete.");
299
+ console.log(`Kernel: http://${kernelHost}:${kernelPort}`);
300
+ console.log(`Wattswarm UI: http://${uiHost}:${uiPort}`);
301
+ console.log(`Observatory: http://${observatoryHost}:${observatoryPort}`);
302
+ console.log(`Deploy dir: ${options.dir}`);
303
+ }
304
+
305
+ async function install(options) {
306
+ ensureDockerAvailable();
307
+ ensureDeploymentAssets(options);
308
+ runCompose(options, ["config"], true);
309
+ console.log("Pulling release images...");
310
+ runCompose(options, ["pull"]);
311
+ console.log("Starting release stack...");
312
+ runCompose(options, ["up", "-d"]);
313
+ if (options.healthChecks) {
314
+ await runHealthChecks(options);
315
+ }
316
+ printSummary(options);
317
+ }
318
+
319
+ async function start(options) {
320
+ ensureDockerAvailable();
321
+ if (!fs.existsSync(composeFilePath(options)) || !fs.existsSync(envFilePath(options))) {
322
+ throw new Error("Deployment is not initialized. Run install first.");
323
+ }
324
+ runCompose(options, ["up", "-d"]);
325
+ if (options.healthChecks) {
326
+ await runHealthChecks(options);
327
+ }
328
+ printSummary(options);
329
+ }
330
+
331
+ function status(options) {
332
+ ensureDockerAvailable();
333
+ runCompose(options, ["ps"]);
334
+ }
335
+
336
+ async function update(options) {
337
+ ensureDockerAvailable();
338
+ if (!fs.existsSync(composeFilePath(options)) || !fs.existsSync(envFilePath(options))) {
339
+ throw new Error("Deployment is not initialized. Run install first.");
340
+ }
341
+ ensureDeploymentAssets(options);
342
+ console.log("Pulling updated images...");
343
+ runCompose(options, ["pull"]);
344
+ console.log("Restarting release stack...");
345
+ runCompose(options, ["up", "-d"]);
346
+ if (options.healthChecks) {
347
+ await runHealthChecks(options);
348
+ }
349
+ printSummary(options);
350
+ }
351
+
352
+ function stop(options) {
353
+ ensureDockerAvailable();
354
+ runCompose(options, ["down"]);
355
+ }
356
+
357
+ function uninstall(options) {
358
+ ensureDockerAvailable();
359
+ const args = ["down"];
360
+ if (options.volumes) {
361
+ args.push("-v");
362
+ }
363
+ runCompose(options, args);
364
+ if (options.purge && fs.existsSync(options.dir)) {
365
+ fs.rmSync(options.dir, { recursive: true, force: true });
366
+ console.log(`Removed deployment directory: ${options.dir}`);
367
+ }
368
+ }
369
+
370
+ function logs(options) {
371
+ ensureDockerAvailable();
372
+ runCompose(options, ["logs", ...options.composeArgs]);
373
+ }
374
+
375
+ function doctor() {
376
+ ensureDockerAvailable();
377
+ console.log("Docker runtime is available.");
378
+ console.log(`Node.js ${process.version} is available.`);
379
+ }
380
+
381
+ async function run(argv) {
382
+ const { command, options } = parseArgs(argv);
383
+
384
+ switch (command) {
385
+ case "install":
386
+ await install(options);
387
+ return;
388
+ case "start":
389
+ case "up":
390
+ await start(options);
391
+ return;
392
+ case "status":
393
+ status(options);
394
+ return;
395
+ case "update":
396
+ await update(options);
397
+ return;
398
+ case "stop":
399
+ case "down":
400
+ stop(options);
401
+ return;
402
+ case "uninstall":
403
+ uninstall(options);
404
+ return;
405
+ case "logs":
406
+ logs(options);
407
+ return;
408
+ case "doctor":
409
+ doctor();
410
+ return;
411
+ case "help":
412
+ case "--help":
413
+ case "-h":
414
+ printHelp();
415
+ return;
416
+ default:
417
+ throw new Error(`Unknown command: ${command}`);
418
+ }
419
+ }
420
+
421
+ module.exports = {
422
+ run
423
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "wattetheria",
3
+ "version": "0.1.0",
4
+ "description": "Wattetheria deployment CLI",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "wattetheria": "./bin/wattetheria.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "lib/",
13
+ "docker-compose.release.yml",
14
+ ".env.release.example",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "check": "node --check bin/wattetheria.js && node --check lib/cli.js"
26
+ },
27
+ "keywords": [
28
+ "wattetheria",
29
+ "wattswarm",
30
+ "docker",
31
+ "cli",
32
+ "deploy"
33
+ ]
34
+ }