ui-syncup-cli 0.4.0-beta.6

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 +94 -0
  2. package/dist/index.js +779 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # ui-syncup CLI
2
+
3
+ Self-host [UI SyncUp](https://github.com/BYKHD/ui-syncup) with a single command. No infrastructure knowledge required.
4
+
5
+ ## Requirements
6
+
7
+ - [Docker](https://docs.docker.com/get-docker/) ≥ 24
8
+ - Node.js ≥ 20 (only needed to run the CLI — the app itself runs in Docker)
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ mkdir my-syncup && cd my-syncup
14
+ npx ui-syncup init
15
+ ```
16
+
17
+ The wizard downloads `compose.yml`, walks you through service configuration, and starts the stack.
18
+
19
+ ## Commands
20
+
21
+ ### Setup & Lifecycle
22
+
23
+ | Command | Description |
24
+ |---|---|
25
+ | `init` | Guided first-time setup wizard |
26
+ | `start` | Start the stack (reads `COMPOSE_PROFILES` from `.env`) |
27
+ | `stop` | Stop gracefully — data is preserved |
28
+ | `restart [service]` | Restart all services or a single one |
29
+ | `remove` | Remove containers (`--volumes` to also wipe all data) |
30
+
31
+ ### Observability
32
+
33
+ | Command | Description |
34
+ |---|---|
35
+ | `status` | Show container states, health, and app URL |
36
+ | `logs [service]` | Tail last 200 lines (`-F` to stream live) |
37
+ | `doctor` | Validate env vars, health endpoint, and disk space |
38
+ | `open` | Open the app in your default browser |
39
+
40
+ ### Maintenance
41
+
42
+ | Command | Description |
43
+ |---|---|
44
+ | `upgrade` | Pull latest image and restart (migrations apply automatically) |
45
+ | `backup` | Dump PostgreSQL + MinIO to a timestamped `.tar.gz` |
46
+ | `restore <archive>` | Restore from a backup archive |
47
+
48
+ ## Usage Examples
49
+
50
+ ```bash
51
+ # First-time setup
52
+ npx ui-syncup init
53
+
54
+ # Day-to-day
55
+ ui-syncup status
56
+ ui-syncup logs -F # stream all logs
57
+ ui-syncup logs app -F # stream app logs only
58
+ ui-syncup restart app # restart just the app container
59
+
60
+ # Upgrades
61
+ ui-syncup upgrade
62
+
63
+ # Backup & restore
64
+ ui-syncup backup -o ~/backups
65
+ ui-syncup restore ~/backups/ui-syncup-backup-2026-03-19T12-00.tar.gz
66
+
67
+ # Teardown
68
+ ui-syncup remove # keep data volumes
69
+ ui-syncup remove --volumes # wipe everything
70
+ ```
71
+
72
+ ## Bundled Services (Docker Compose profiles)
73
+
74
+ `init` lets you choose which services to bundle. Your selection is saved as `COMPOSE_PROFILES` in `.env` so subsequent `start`/`upgrade` commands pick it up automatically.
75
+
76
+ | Profile | Service | Use when |
77
+ |---|---|---|
78
+ | `db` | PostgreSQL 15 | No external database |
79
+ | `cache` | Redis 7 | No external Redis/Upstash |
80
+ | `storage` | MinIO | No external S3/R2/Backblaze |
81
+
82
+ ## Backup Details
83
+
84
+ `backup` only exports data for active profiles:
85
+
86
+ - **PostgreSQL** (`db` profile) — `pg_dumpall` → `postgres.sql`
87
+ - **MinIO** (`storage` profile) — volume tar → `minio_data.tar.gz`
88
+ - Redis is intentionally excluded (cache — not persistent state)
89
+
90
+ Output is a single `.tar.gz` archive you can store offsite.
91
+
92
+ ## License
93
+
94
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+ "use strict";
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __commonJS = (cb, mod) => function __require() {
11
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+
30
+ // package.json
31
+ var require_package = __commonJS({
32
+ "package.json"(exports2, module2) {
33
+ module2.exports = {
34
+ name: "ui-syncup-cli",
35
+ version: "0.4.0-beta.5",
36
+ description: "Self-host UI SyncUp with a single command",
37
+ bin: {
38
+ "ui-syncup": "./dist/index.js"
39
+ },
40
+ files: [
41
+ "dist"
42
+ ],
43
+ scripts: {
44
+ build: "tsup",
45
+ dev: "tsup --watch"
46
+ },
47
+ dependencies: {
48
+ commander: "^12.0.0",
49
+ "@inquirer/prompts": "^8.0.0",
50
+ chalk: "^5.3.0",
51
+ ora: "^8.0.0"
52
+ },
53
+ devDependencies: {
54
+ tsup: "^8.0.0",
55
+ typescript: "^5.0.0",
56
+ "@types/node": "^20.0.0"
57
+ },
58
+ engines: {
59
+ node: ">=20"
60
+ },
61
+ license: "MIT"
62
+ };
63
+ }
64
+ });
65
+
66
+ // index.ts
67
+ var import_commander = require("commander");
68
+
69
+ // src/commands/init.ts
70
+ var import_node_fs2 = require("fs");
71
+ var import_node_child_process2 = require("child_process");
72
+ var import_prompts = require("@inquirer/prompts");
73
+
74
+ // src/lib/ui.ts
75
+ var import_chalk = __toESM(require("chalk"));
76
+ var pipe = import_chalk.default.white("\u2502");
77
+ var ui = {
78
+ banner: (version3) => {
79
+ const title = ` \u2732 UI SyncUp v${version3} `;
80
+ const tagline = ` A visual feedback and issue tracking platform `;
81
+ const width = Math.max(title.length, tagline.length);
82
+ const pad = (s) => s + " ".repeat(width - s.length);
83
+ console.log(import_chalk.default.white("\u2554" + "\u2550".repeat(width) + "\u2557"));
84
+ console.log(
85
+ import_chalk.default.white("\u2551") + " " + import_chalk.default.white("\u2732") + " " + import_chalk.default.bold.white("UI SyncUp") + " " + import_chalk.default.dim(`v${version3}`) + " ".repeat(width - title.length + 2) + import_chalk.default.white("\u2551")
86
+ );
87
+ console.log(import_chalk.default.white("\u2560" + "\u2500".repeat(width) + "\u2563"));
88
+ console.log(
89
+ import_chalk.default.white("\u2551") + import_chalk.default.dim(pad(tagline)) + import_chalk.default.white("\u2551")
90
+ );
91
+ console.log(import_chalk.default.white("\u255A" + "\u2550".repeat(width) + "\u255D"));
92
+ },
93
+ header: (msg) => {
94
+ console.log(" ");
95
+ console.log(import_chalk.default.cyan("\u2699\uFE0E") + " " + import_chalk.default.bold.white(msg));
96
+ console.log(pipe);
97
+ },
98
+ step: (n, total, msg) => {
99
+ console.log(import_chalk.default.magenta("\u25C6") + " " + import_chalk.default.bold.magenta(`Step ${n} of ${total}`));
100
+ console.log(pipe + " " + import_chalk.default.white(msg));
101
+ },
102
+ info: (msg) => console.log(pipe + " \u{1F680} " + import_chalk.default.blue(msg)),
103
+ success: (msg) => console.log(pipe + " \u2728 " + import_chalk.default.green(msg)),
104
+ warn: (msg) => console.log(pipe + " \u26A0\uFE0F " + import_chalk.default.yellow(msg)),
105
+ error: (msg) => console.error(pipe + " \u{1F6A8} " + import_chalk.default.red(msg))
106
+ };
107
+
108
+ // src/lib/env.ts
109
+ var import_node_fs = require("fs");
110
+ var import_node_crypto = require("crypto");
111
+ var REQUIRED_VARS = [
112
+ "DATABASE_URL",
113
+ "DIRECT_URL",
114
+ "REDIS_URL",
115
+ "BETTER_AUTH_SECRET",
116
+ "BETTER_AUTH_URL",
117
+ "NEXT_PUBLIC_APP_URL",
118
+ "NEXT_PUBLIC_API_URL"
119
+ ];
120
+ var ENV_EXAMPLE_URL = "https://github.com/BYKHD/ui-syncup/blob/main/.env.example";
121
+ function parseEnv(filePath) {
122
+ if (!(0, import_node_fs.existsSync)(filePath)) return {};
123
+ const content = (0, import_node_fs.readFileSync)(filePath, "utf-8");
124
+ const vars = {};
125
+ for (const line of content.split("\n")) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed || trimmed.startsWith("#")) continue;
128
+ const idx = trimmed.indexOf("=");
129
+ if (idx === -1) continue;
130
+ vars[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
131
+ }
132
+ return vars;
133
+ }
134
+ function writeEnv(filePath, vars) {
135
+ const lines = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
136
+ (0, import_node_fs.writeFileSync)(filePath, lines.join("\n") + "\n", { mode: 384 });
137
+ }
138
+ function generateSecret() {
139
+ return (0, import_node_crypto.randomBytes)(32).toString("hex");
140
+ }
141
+ function getMissingVars(vars) {
142
+ return REQUIRED_VARS.filter((k) => !vars[k] || vars[k].trim() === "");
143
+ }
144
+
145
+ // src/lib/docker.ts
146
+ var import_node_child_process = require("child_process");
147
+ function isDockerRunning() {
148
+ try {
149
+ (0, import_node_child_process.execSync)("docker info", { stdio: "ignore" });
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+ function volumeExists(volumeName) {
156
+ try {
157
+ const result = (0, import_node_child_process.execSync)(`docker volume inspect ${volumeName}`, { stdio: "pipe" });
158
+ return !!result;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+ function removeVolume(volumeName) {
164
+ try {
165
+ (0, import_node_child_process.execSync)(`docker volume rm ${volumeName}`, { stdio: "pipe" });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ function runCompose(composeFile, args, profiles = [], quiet = false) {
172
+ const profileFlags = profiles.flatMap((p) => ["--profile", p]);
173
+ const cmd = ["docker", "compose", "-f", composeFile, ...profileFlags, ...args];
174
+ const result = (0, import_node_child_process.spawnSync)(cmd[0], cmd.slice(1), { stdio: quiet ? "pipe" : "inherit" });
175
+ return { success: result.status === 0 };
176
+ }
177
+
178
+ // src/commands/init.ts
179
+ var { version } = require_package();
180
+ var COMPOSE_URL = "https://raw.githubusercontent.com/BYKHD/ui-syncup/main/docker/compose.yml";
181
+ var ENV_EXAMPLE_URL2 = "https://raw.githubusercontent.com/BYKHD/ui-syncup/main/.env.example";
182
+ async function initCommand() {
183
+ ui.banner(version);
184
+ ui.header("Setup Wizard \u{1FA84}");
185
+ ui.step(1, 6, "Checking Docker...");
186
+ if (!isDockerRunning()) {
187
+ ui.error("Docker is not running. Please start Docker and try again.");
188
+ process.exit(1);
189
+ }
190
+ ui.success("Docker is running");
191
+ ui.step(2, 6, "Setting up compose.yml...");
192
+ if ((0, import_node_fs2.existsSync)("compose.yml")) {
193
+ ui.info("compose.yml already exists \u2014 skipping download");
194
+ } else {
195
+ (0, import_node_child_process2.execSync)(`curl -fsSL "${COMPOSE_URL}" -o compose.yml`, { stdio: "inherit" });
196
+ ui.success("Downloaded compose.yml");
197
+ }
198
+ ui.step(3, 6, "Setting up .env...");
199
+ let envVars = {};
200
+ const envExists = (0, import_node_fs2.existsSync)(".env");
201
+ if (envExists) {
202
+ ui.warn(".env already exists \u2014 checking for missing vars only");
203
+ envVars = parseEnv(".env");
204
+ } else {
205
+ (0, import_node_child_process2.execSync)(`curl -fsSL "${ENV_EXAMPLE_URL2}" -o .env`, { stdio: "inherit" });
206
+ envVars = parseEnv(".env");
207
+ ui.success("Downloaded .env template");
208
+ }
209
+ ui.step(4, 6, "Configuring services...");
210
+ const appUrl = await (0, import_prompts.input)({
211
+ message: "Public URL of your app (e.g. https://syncup.example.com):",
212
+ default: envVars["BETTER_AUTH_URL"] || "",
213
+ validate: (v) => v.startsWith("http") || "Must start with http:// or https://"
214
+ });
215
+ envVars["BETTER_AUTH_URL"] = appUrl;
216
+ envVars["NEXT_PUBLIC_APP_URL"] = appUrl;
217
+ envVars["NEXT_PUBLIC_API_URL"] = `${appUrl}/api`;
218
+ if (!envVars["BETTER_AUTH_SECRET"]) {
219
+ const autoSecret = await (0, import_prompts.confirm)({
220
+ message: "Auto-generate a secure BETTER_AUTH_SECRET?",
221
+ default: true
222
+ });
223
+ if (autoSecret) {
224
+ envVars["BETTER_AUTH_SECRET"] = generateSecret();
225
+ ui.success("Generated BETTER_AUTH_SECRET");
226
+ }
227
+ }
228
+ const profiles = [];
229
+ const dbChoice = await (0, import_prompts.select)({
230
+ message: "Database backend:",
231
+ choices: [
232
+ { name: "Bundled PostgreSQL (recommended)", value: "bundled" },
233
+ { name: "External (Supabase / Neon / other)", value: "external" }
234
+ ]
235
+ });
236
+ if (dbChoice === "bundled") {
237
+ profiles.push("db");
238
+ const projectName = process.env["COMPOSE_PROJECT_NAME"] || "ui-syncup";
239
+ const pgVolume = `${projectName}_postgres_data`;
240
+ const volumeAlreadyExists = volumeExists(pgVolume);
241
+ if (volumeAlreadyExists) {
242
+ ui.warn(`Existing PostgreSQL volume detected (${pgVolume}).`);
243
+ ui.warn("The password you enter MUST match the one used when the volume was first created.");
244
+ ui.warn('If you changed the password, choose "Reset" to wipe the volume and start fresh.');
245
+ const resetChoice = await (0, import_prompts.select)({
246
+ message: "How do you want to proceed?",
247
+ choices: [
248
+ { name: "Keep existing volume (enter the original password)", value: "keep" },
249
+ { name: "Reset volume \u2014 wipe all data and reinitialise (irreversible)", value: "reset" }
250
+ ]
251
+ });
252
+ if (resetChoice === "reset") {
253
+ const confirmed = await (0, import_prompts.confirm)({
254
+ message: `Delete volume "${pgVolume}" and all its data? This cannot be undone.`,
255
+ default: false
256
+ });
257
+ if (confirmed) {
258
+ if (!removeVolume(pgVolume)) {
259
+ ui.error(`Failed to remove volume ${pgVolume}. Is the container still running?`);
260
+ process.exit(1);
261
+ }
262
+ ui.success(`Volume ${pgVolume} removed \u2014 will be reinitialised with the new password.`);
263
+ } else {
264
+ ui.info("Reset cancelled \u2014 keeping existing volume. Enter the original password below.");
265
+ }
266
+ }
267
+ }
268
+ const pgPass = await (0, import_prompts.input)({
269
+ message: "PostgreSQL password (POSTGRES_PASSWORD, min 8 chars):",
270
+ validate: (v) => v.length >= 8 || "Minimum 8 characters"
271
+ });
272
+ envVars["POSTGRES_PASSWORD"] = pgPass;
273
+ envVars["DATABASE_URL"] = `postgresql://syncup:${pgPass}@postgres:5432/ui_syncup`;
274
+ envVars["DIRECT_URL"] = envVars["DATABASE_URL"];
275
+ } else {
276
+ envVars["DATABASE_URL"] = await (0, import_prompts.input)({
277
+ message: "DATABASE_URL (postgresql://...):",
278
+ validate: (v) => v.startsWith("postgres") || "Must be a PostgreSQL URL"
279
+ });
280
+ envVars["DIRECT_URL"] = await (0, import_prompts.input)({
281
+ message: "DIRECT_URL (non-pooled, for migrations):",
282
+ default: envVars["DATABASE_URL"]
283
+ });
284
+ }
285
+ const cacheChoice = await (0, import_prompts.select)({
286
+ message: "Cache backend:",
287
+ choices: [
288
+ { name: "Bundled Redis (recommended)", value: "bundled" },
289
+ { name: "External (Upstash / Redis Cloud)", value: "external" }
290
+ ]
291
+ });
292
+ if (cacheChoice === "bundled") {
293
+ profiles.push("cache");
294
+ envVars["REDIS_URL"] = "redis://redis:6379";
295
+ } else {
296
+ envVars["REDIS_URL"] = await (0, import_prompts.input)({
297
+ message: "REDIS_URL (redis://...):",
298
+ validate: (v) => v.startsWith("redis") || "Must be a Redis URL"
299
+ });
300
+ }
301
+ const storageChoice = await (0, import_prompts.select)({
302
+ message: "Storage backend:",
303
+ choices: [
304
+ { name: "Bundled MinIO (recommended)", value: "bundled" },
305
+ { name: "External S3 (AWS / R2 / Backblaze)", value: "external" }
306
+ ]
307
+ });
308
+ if (storageChoice === "bundled") {
309
+ profiles.push("storage");
310
+ const minioUser = await (0, import_prompts.input)({ message: "MinIO root username:", default: "minioadmin" });
311
+ const minioPass = await (0, import_prompts.input)({
312
+ message: "MinIO root password (min 8 chars):",
313
+ validate: (v) => v.length >= 8 || "Minimum 8 characters"
314
+ });
315
+ envVars["MINIO_ROOT_USER"] = minioUser;
316
+ envVars["MINIO_ROOT_PASSWORD"] = minioPass;
317
+ envVars["STORAGE_ENDPOINT"] = "http://minio:9000";
318
+ envVars["STORAGE_REGION"] = "us-east-1";
319
+ envVars["STORAGE_ACCESS_KEY_ID"] = minioUser;
320
+ envVars["STORAGE_SECRET_ACCESS_KEY"] = minioPass;
321
+ envVars["STORAGE_ATTACHMENTS_BUCKET"] = "ui-syncup-attachments";
322
+ envVars["STORAGE_MEDIA_BUCKET"] = "ui-syncup-media";
323
+ envVars["STORAGE_ATTACHMENTS_PUBLIC_URL"] = `${appUrl}/storage/attachments`;
324
+ envVars["STORAGE_MEDIA_PUBLIC_URL"] = `${appUrl}/storage/media`;
325
+ } else {
326
+ envVars["STORAGE_ENDPOINT"] = await (0, import_prompts.input)({ message: "Storage endpoint URL:" });
327
+ envVars["STORAGE_ACCESS_KEY_ID"] = await (0, import_prompts.input)({ message: "Storage access key ID:" });
328
+ envVars["STORAGE_SECRET_ACCESS_KEY"] = await (0, import_prompts.input)({ message: "Storage secret access key:" });
329
+ }
330
+ const emailChoice = await (0, import_prompts.select)({
331
+ message: "Email provider:",
332
+ choices: [
333
+ { name: "Resend (recommended for production)", value: "resend" },
334
+ { name: "SMTP \u2014 custom server (SendGrid, Postmark, etc.)", value: "smtp" },
335
+ { name: "Mailpit \u2014 bundled SMTP catcher (dev/test only)", value: "mailpit" },
336
+ { name: "Skip for now", value: "skip" }
337
+ ]
338
+ });
339
+ if (emailChoice === "resend") {
340
+ envVars["RESEND_API_KEY"] = await (0, import_prompts.input)({ message: "Resend API key:" });
341
+ envVars["RESEND_FROM_EMAIL"] = await (0, import_prompts.input)({ message: "From email address:" });
342
+ } else if (emailChoice === "smtp") {
343
+ envVars["SMTP_HOST"] = await (0, import_prompts.input)({ message: "SMTP host:" });
344
+ envVars["SMTP_PORT"] = await (0, import_prompts.input)({ message: "SMTP port:", default: "587" });
345
+ envVars["SMTP_USER"] = await (0, import_prompts.input)({ message: "SMTP username:" });
346
+ envVars["SMTP_PASSWORD"] = await (0, import_prompts.input)({ message: "SMTP password:" });
347
+ envVars["SMTP_FROM_EMAIL"] = await (0, import_prompts.input)({ message: "From email:" });
348
+ envVars["SMTP_SECURE"] = await (0, import_prompts.input)({ message: "TLS? (true/false):", default: "true" });
349
+ } else if (emailChoice === "mailpit") {
350
+ profiles.push("mail");
351
+ envVars["SMTP_HOST"] = "mailpit";
352
+ envVars["SMTP_PORT"] = "1025";
353
+ envVars["SMTP_USER"] = "user";
354
+ envVars["SMTP_PASSWORD"] = "password";
355
+ envVars["SMTP_FROM_EMAIL"] = "noreply@localhost.com";
356
+ envVars["SMTP_SECURE"] = "false";
357
+ }
358
+ envVars["COMPOSE_PROFILES"] = profiles.join(",");
359
+ ui.step(5, 6, "Writing .env...");
360
+ writeEnv(".env", envVars);
361
+ ui.success(".env written (permissions: 0600)");
362
+ ui.step(6, 6, "Starting UI SyncUp...");
363
+ const result = runCompose("compose.yml", ["up", "-d"], profiles);
364
+ if (!result.success) {
365
+ ui.error("docker compose up failed \u2014 check logs with: docker compose logs app");
366
+ process.exit(1);
367
+ }
368
+ ui.success("UI SyncUp is running!");
369
+ ui.info(`Open: ${appUrl}`);
370
+ if (profiles.includes("mail")) {
371
+ ui.info("Mailpit inbox: http://localhost:8025");
372
+ }
373
+ if (profiles.length > 0) {
374
+ ui.info(`Active profiles: ${profiles.join(", ")}`);
375
+ }
376
+ }
377
+
378
+ // src/commands/start.ts
379
+ var import_node_fs3 = require("fs");
380
+ async function startCommand(composeFile) {
381
+ ui.header("UI SyncUp \u2014 Start");
382
+ if (!isDockerRunning()) {
383
+ ui.error("Docker is not running.");
384
+ process.exit(1);
385
+ }
386
+ const profiles = (0, import_node_fs3.existsSync)(".env") ? (parseEnv(".env")["COMPOSE_PROFILES"] ?? "").split(",").filter(Boolean) : [];
387
+ ui.info("Starting stack...");
388
+ const result = runCompose(composeFile, ["up", "-d"], profiles);
389
+ if (!result.success) {
390
+ ui.error("Failed to start stack \u2014 check logs with: ui-syncup logs");
391
+ process.exit(1);
392
+ }
393
+ ui.success("Stack is running.");
394
+ }
395
+
396
+ // src/commands/stop.ts
397
+ async function stopCommand(composeFile) {
398
+ ui.header("UI SyncUp \u2014 Stop");
399
+ if (!isDockerRunning()) {
400
+ ui.error("Docker is not running.");
401
+ process.exit(1);
402
+ }
403
+ ui.info("Stopping stack...");
404
+ const result = runCompose(composeFile, ["stop"]);
405
+ if (!result.success) {
406
+ ui.error("Failed to stop stack");
407
+ process.exit(1);
408
+ }
409
+ ui.success("Stack stopped. Data preserved \u2014 run: ui-syncup start to resume.");
410
+ }
411
+
412
+ // src/commands/restart.ts
413
+ async function restartCommand(composeFile, service) {
414
+ ui.header("UI SyncUp \u2014 Restart");
415
+ if (!isDockerRunning()) {
416
+ ui.error("Docker is not running.");
417
+ process.exit(1);
418
+ }
419
+ const args = service ? ["restart", service] : ["restart"];
420
+ ui.info(service ? `Restarting ${service}...` : "Restarting all services...");
421
+ const result = runCompose(composeFile, args);
422
+ if (!result.success) {
423
+ ui.error("Restart failed");
424
+ process.exit(1);
425
+ }
426
+ ui.success("Restart complete.");
427
+ }
428
+
429
+ // src/commands/status.ts
430
+ var import_node_child_process3 = require("child_process");
431
+ var import_node_fs4 = require("fs");
432
+ async function statusCommand(composeFile) {
433
+ if (!isDockerRunning()) {
434
+ ui.error("Docker is not running.");
435
+ process.exit(1);
436
+ }
437
+ if (!(0, import_node_fs4.existsSync)(composeFile)) {
438
+ ui.error(`Compose file not found: ${composeFile}. Run: ui-syncup init`);
439
+ process.exit(1);
440
+ }
441
+ (0, import_node_child_process3.spawnSync)("docker", ["compose", "-f", composeFile, "ps"], { stdio: "inherit" });
442
+ const vars = (0, import_node_fs4.existsSync)(".env") ? parseEnv(".env") : {};
443
+ const port = vars["PORT"] || "3000";
444
+ const appUrl = vars["NEXT_PUBLIC_APP_URL"] || `http://localhost:${port}`;
445
+ console.log("");
446
+ ui.info(`App \u2192 ${appUrl}`);
447
+ }
448
+
449
+ // src/commands/logs.ts
450
+ var import_node_child_process4 = require("child_process");
451
+ var import_node_fs5 = require("fs");
452
+ async function logsCommand(composeFile, service, follow) {
453
+ if (!isDockerRunning()) {
454
+ ui.error("Docker is not running.");
455
+ process.exit(1);
456
+ }
457
+ if (!(0, import_node_fs5.existsSync)(composeFile)) {
458
+ ui.error(`Compose file not found: ${composeFile}. Run: ui-syncup init`);
459
+ process.exit(1);
460
+ }
461
+ const args = ["compose", "-f", composeFile, "logs", "--tail=200"];
462
+ if (follow) args.push("--follow");
463
+ if (service) args.push(service);
464
+ (0, import_node_child_process4.spawnSync)("docker", args, { stdio: "inherit" });
465
+ }
466
+
467
+ // src/commands/upgrade.ts
468
+ var import_ora = __toESM(require("ora"));
469
+ async function upgradeCommand(composeFile) {
470
+ ui.header("UI SyncUp \u2014 Upgrade");
471
+ if (!isDockerRunning()) {
472
+ ui.error("Docker is not running.");
473
+ process.exit(1);
474
+ }
475
+ const pull = (0, import_ora.default)("Pulling latest image...").start();
476
+ const pullResult = runCompose(composeFile, ["pull"], [], true);
477
+ if (!pullResult.success) {
478
+ pull.fail("docker compose pull failed");
479
+ process.exit(1);
480
+ }
481
+ pull.succeed("Latest image pulled");
482
+ const up = (0, import_ora.default)("Restarting stack (migrations run automatically)...").start();
483
+ const upResult = runCompose(composeFile, ["up", "-d", "--remove-orphans"], [], true);
484
+ if (!upResult.success) {
485
+ up.fail("Stack restart failed \u2014 check logs with: ui-syncup logs");
486
+ process.exit(1);
487
+ }
488
+ up.succeed("Upgrade complete. Migrations applied.");
489
+ }
490
+
491
+ // src/commands/backup.ts
492
+ var import_node_child_process5 = require("child_process");
493
+ var import_node_fs6 = require("fs");
494
+ var import_node_path = require("path");
495
+ var import_ora2 = __toESM(require("ora"));
496
+ async function backupCommand(composeFile, outputDir) {
497
+ ui.header("UI SyncUp \u2014 Backup");
498
+ if (!isDockerRunning()) {
499
+ ui.error("Docker is not running.");
500
+ process.exit(1);
501
+ }
502
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
503
+ const label = `ui-syncup-backup-${timestamp}`;
504
+ const backupDir = (0, import_node_path.resolve)(outputDir, label);
505
+ (0, import_node_fs6.mkdirSync)(backupDir, { recursive: true });
506
+ const vars = (0, import_node_fs6.existsSync)(".env") ? parseEnv(".env") : {};
507
+ const profiles = (vars["COMPOSE_PROFILES"] ?? "").split(",").filter(Boolean);
508
+ let backedUp = false;
509
+ if (profiles.includes("db")) {
510
+ const spinner = (0, import_ora2.default)("Dumping PostgreSQL...").start();
511
+ try {
512
+ const dump = (0, import_node_child_process5.execSync)(
513
+ `docker compose -f ${composeFile} exec -T postgres pg_dumpall -U postgres`,
514
+ { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }
515
+ );
516
+ (0, import_node_fs6.writeFileSync)(`${backupDir}/postgres.sql`, dump);
517
+ spinner.succeed("PostgreSQL dump saved");
518
+ backedUp = true;
519
+ } catch {
520
+ spinner.fail("PostgreSQL backup failed \u2014 is the db container running?");
521
+ }
522
+ } else {
523
+ ui.info("Skipping PostgreSQL (db profile not active)");
524
+ }
525
+ if (profiles.includes("storage")) {
526
+ const spinner = (0, import_ora2.default)("Exporting MinIO volume...").start();
527
+ const result = (0, import_node_child_process5.spawnSync)("docker", [
528
+ "run",
529
+ "--rm",
530
+ "-v",
531
+ "ui-syncup_minio_data:/volume",
532
+ "-v",
533
+ `${backupDir}:/backup`,
534
+ "alpine",
535
+ "tar",
536
+ "czf",
537
+ "/backup/minio_data.tar.gz",
538
+ "-C",
539
+ "/volume",
540
+ "."
541
+ ], { stdio: "pipe" });
542
+ if (result.status === 0) {
543
+ spinner.succeed("MinIO volume exported");
544
+ backedUp = true;
545
+ } else {
546
+ spinner.fail("MinIO backup failed \u2014 is the storage profile running?");
547
+ }
548
+ } else {
549
+ ui.info("Skipping MinIO (storage profile not active)");
550
+ }
551
+ if (!backedUp) {
552
+ ui.warn("No data services active. Set COMPOSE_PROFILES=db,storage in .env to enable backups.");
553
+ (0, import_node_fs6.rmSync)(backupDir, { recursive: true });
554
+ return;
555
+ }
556
+ const archiving = (0, import_ora2.default)("Archiving...").start();
557
+ const archivePath = (0, import_node_path.resolve)(outputDir, `${label}.tar.gz`);
558
+ (0, import_node_child_process5.spawnSync)("tar", ["-czf", archivePath, "-C", (0, import_node_path.resolve)(outputDir), label], { stdio: "pipe" });
559
+ (0, import_node_fs6.rmSync)(backupDir, { recursive: true });
560
+ archiving.succeed(`Backup saved \u2192 ${archivePath}`);
561
+ console.log("");
562
+ ui.info(`To restore: ui-syncup restore ${archivePath}`);
563
+ }
564
+
565
+ // src/commands/restore.ts
566
+ var import_node_child_process6 = require("child_process");
567
+ var import_node_fs7 = require("fs");
568
+ var import_node_path2 = require("path");
569
+ var import_prompts2 = require("@inquirer/prompts");
570
+ var import_ora3 = __toESM(require("ora"));
571
+ async function restoreCommand(composeFile, archivePath) {
572
+ ui.header("UI SyncUp \u2014 Restore");
573
+ if (!isDockerRunning()) {
574
+ ui.error("Docker is not running.");
575
+ process.exit(1);
576
+ }
577
+ const resolvedArchive = (0, import_node_path2.resolve)(archivePath);
578
+ if (!(0, import_node_fs7.existsSync)(resolvedArchive)) {
579
+ ui.error(`Archive not found: ${resolvedArchive}`);
580
+ process.exit(1);
581
+ }
582
+ ui.warn("Restore will overwrite current data. The app container will be stopped briefly.");
583
+ const confirmed = await (0, import_prompts2.confirm)({ message: "Continue with restore?" });
584
+ if (!confirmed) {
585
+ ui.info("Aborted.");
586
+ return;
587
+ }
588
+ const tmpDir = `/tmp/ui-syncup-restore-${Date.now()}`;
589
+ (0, import_node_fs7.mkdirSync)(tmpDir, { recursive: true });
590
+ const extractSpinner = (0, import_ora3.default)("Extracting archive...").start();
591
+ const extractResult = (0, import_node_child_process6.spawnSync)("tar", ["-xzf", resolvedArchive, "-C", tmpDir]);
592
+ if (extractResult.status !== 0) {
593
+ extractSpinner.fail("Failed to extract archive");
594
+ (0, import_node_fs7.rmSync)(tmpDir, { recursive: true });
595
+ process.exit(1);
596
+ }
597
+ extractSpinner.succeed("Archive extracted");
598
+ const entries = (0, import_node_fs7.readdirSync)(tmpDir);
599
+ const backupDir = (0, import_node_path2.join)(tmpDir, entries[0]);
600
+ const vars = (0, import_node_fs7.existsSync)(".env") ? parseEnv(".env") : {};
601
+ const profiles = (vars["COMPOSE_PROFILES"] ?? "").split(",").filter(Boolean);
602
+ const stopSpinner = (0, import_ora3.default)("Stopping app container...").start();
603
+ runCompose(composeFile, ["stop", "app"], [], true);
604
+ stopSpinner.succeed("App container stopped");
605
+ const sqlFile = (0, import_node_path2.join)(backupDir, "postgres.sql");
606
+ if ((0, import_node_fs7.existsSync)(sqlFile) && profiles.includes("db")) {
607
+ const spinner = (0, import_ora3.default)("Restoring PostgreSQL...").start();
608
+ const result = (0, import_node_child_process6.spawnSync)(
609
+ "bash",
610
+ ["-c", `docker compose -f ${composeFile} exec -T postgres psql -U postgres < "${sqlFile}"`],
611
+ { stdio: "pipe" }
612
+ );
613
+ if (result.status === 0) {
614
+ spinner.succeed("PostgreSQL restored");
615
+ } else {
616
+ spinner.fail("PostgreSQL restore failed \u2014 check: ui-syncup logs postgres");
617
+ }
618
+ }
619
+ const minioArchive = (0, import_node_path2.join)(backupDir, "minio_data.tar.gz");
620
+ if ((0, import_node_fs7.existsSync)(minioArchive) && profiles.includes("storage")) {
621
+ const spinner = (0, import_ora3.default)("Restoring MinIO volume...").start();
622
+ (0, import_node_child_process6.spawnSync)("docker", [
623
+ "run",
624
+ "--rm",
625
+ "-v",
626
+ "ui-syncup_minio_data:/volume",
627
+ "-v",
628
+ `${backupDir}:/backup`,
629
+ "alpine",
630
+ "sh",
631
+ "-c",
632
+ "tar xzf /backup/minio_data.tar.gz -C /volume"
633
+ ], { stdio: "pipe" });
634
+ spinner.succeed("MinIO volume restored");
635
+ }
636
+ (0, import_node_fs7.rmSync)(tmpDir, { recursive: true });
637
+ const restartSpinner = (0, import_ora3.default)("Restarting stack...").start();
638
+ runCompose(composeFile, ["up", "-d"], [], true);
639
+ restartSpinner.succeed("Stack restarted");
640
+ console.log("");
641
+ ui.success("Restore complete.");
642
+ }
643
+
644
+ // src/commands/open.ts
645
+ var import_node_child_process7 = require("child_process");
646
+ var import_node_fs8 = require("fs");
647
+ async function openCommand() {
648
+ const vars = (0, import_node_fs8.existsSync)(".env") ? parseEnv(".env") : {};
649
+ const port = vars["PORT"] || "3000";
650
+ const url = vars["NEXT_PUBLIC_APP_URL"] || `http://localhost:${port}`;
651
+ ui.info(`Opening ${url}...`);
652
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
653
+ try {
654
+ (0, import_node_child_process7.execSync)(`${cmd} "${url}"`, { stdio: "ignore" });
655
+ } catch {
656
+ ui.warn(`Could not open browser automatically. Visit: ${url}`);
657
+ }
658
+ }
659
+
660
+ // src/commands/remove.ts
661
+ var import_prompts3 = require("@inquirer/prompts");
662
+ async function removeCommand(composeFile, withVolumes) {
663
+ ui.header("UI SyncUp \u2014 Remove");
664
+ if (!isDockerRunning()) {
665
+ ui.error("Docker is not running.");
666
+ process.exit(1);
667
+ }
668
+ if (withVolumes) {
669
+ ui.warn("This permanently deletes ALL data: database, storage, and cache volumes.");
670
+ } else {
671
+ ui.warn("This stops and removes containers. Data volumes will be preserved.");
672
+ }
673
+ const confirmed = await (0, import_prompts3.confirm)({
674
+ message: withVolumes ? "Delete all data and remove the stack?" : "Remove the stack (keep data volumes)?"
675
+ });
676
+ if (!confirmed) {
677
+ ui.info("Aborted.");
678
+ return;
679
+ }
680
+ const args = ["down"];
681
+ if (withVolumes) args.push("--volumes");
682
+ const result = runCompose(composeFile, args);
683
+ if (!result.success) {
684
+ ui.error("Remove failed");
685
+ process.exit(1);
686
+ }
687
+ ui.success(
688
+ withVolumes ? "Stack and all data removed." : "Stack removed. Data volumes preserved. Run: ui-syncup start to restart."
689
+ );
690
+ }
691
+
692
+ // src/commands/doctor.ts
693
+ var import_node_child_process8 = require("child_process");
694
+ var import_node_fs9 = require("fs");
695
+ async function doctorCommand() {
696
+ ui.header("UI SyncUp \u2014 Doctor");
697
+ let allGood = true;
698
+ ui.info("Checking Docker daemon...");
699
+ if (isDockerRunning()) {
700
+ ui.success("Docker is running");
701
+ } else {
702
+ ui.error("Docker is not running");
703
+ allGood = false;
704
+ }
705
+ ui.info("Checking .env required variables...");
706
+ if (!(0, import_node_fs9.existsSync)(".env")) {
707
+ ui.error(".env not found. Run: npx ui-syncup init");
708
+ allGood = false;
709
+ } else {
710
+ const vars = parseEnv(".env");
711
+ const missing = getMissingVars(vars);
712
+ if (missing.length === 0) {
713
+ ui.success("All required env vars present");
714
+ } else {
715
+ for (const k of missing) {
716
+ ui.error(`Missing: ${k} \u2014 see ${ENV_EXAMPLE_URL}`);
717
+ }
718
+ allGood = false;
719
+ }
720
+ }
721
+ ui.info("Checking /api/health...");
722
+ try {
723
+ const vars = (0, import_node_fs9.existsSync)(".env") ? parseEnv(".env") : {};
724
+ const appUrl = vars["NEXT_PUBLIC_APP_URL"] || "http://localhost:3000";
725
+ const raw = (0, import_node_child_process8.execSync)(`curl -sf "${appUrl}/api/health"`, {
726
+ encoding: "utf-8",
727
+ timeout: 5e3
728
+ });
729
+ const json = JSON.parse(raw);
730
+ if (json.status === "ok") {
731
+ ui.success(`Health OK \u2014 version: ${json.version}`);
732
+ } else {
733
+ ui.warn(`Health returned status: ${json.status}`);
734
+ }
735
+ } catch {
736
+ ui.error("Health endpoint unreachable \u2014 is the stack running?");
737
+ allGood = false;
738
+ }
739
+ ui.info("Checking disk space...");
740
+ try {
741
+ const dfOut = (0, import_node_child_process8.execSync)("df -k . | awk 'NR==2{print $4}'", {
742
+ encoding: "utf-8"
743
+ }).trim();
744
+ const freeGB = parseInt(dfOut, 10) / 1024 / 1024;
745
+ if (freeGB >= 2) {
746
+ ui.success(`Disk space OK \u2014 ${freeGB.toFixed(1)} GB free`);
747
+ } else {
748
+ ui.warn(`Low disk space \u2014 ${freeGB.toFixed(1)} GB free (recommend >= 2 GB)`);
749
+ allGood = false;
750
+ }
751
+ } catch {
752
+ ui.warn("Could not check disk space");
753
+ }
754
+ console.log("");
755
+ if (allGood) {
756
+ ui.success("All checks passed.");
757
+ } else {
758
+ ui.error("Some checks failed \u2014 see above.");
759
+ process.exit(1);
760
+ }
761
+ }
762
+
763
+ // index.ts
764
+ var { version: version2 } = require_package();
765
+ var DEFAULT_COMPOSE = "compose.yml";
766
+ var program = new import_commander.Command().name("ui-syncup").description("Self-host UI SyncUp with a single command").version(version2);
767
+ program.command("init").description("Guided setup: download compose file, configure services, start the stack").action(initCommand);
768
+ program.command("start").description("Start the stack (reads COMPOSE_PROFILES from .env)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action(({ file }) => startCommand(file));
769
+ program.command("stop").description("Stop the stack gracefully (data is preserved)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action(({ file }) => stopCommand(file));
770
+ program.command("restart [service]").description("Restart all services or a single one (app|postgres|redis|minio)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action((service, { file }) => restartCommand(file, service));
771
+ program.command("status").description("Show container states, health, and app URL").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action(({ file }) => statusCommand(file));
772
+ program.command("logs [service]").description("Tail logs for all services or a single one (app|postgres|redis|minio)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).option("-F, --follow", "Stream logs (Ctrl+C to stop)", false).action((service, { file, follow }) => logsCommand(file, service, follow));
773
+ program.command("upgrade").description("Pull latest image and restart the stack (migrations run automatically)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action(({ file }) => upgradeCommand(file));
774
+ program.command("backup").description("Dump PostgreSQL and MinIO to a timestamped .tar.gz archive").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).option("-o, --output <dir>", "Directory to write the archive into", ".").action(({ file, output }) => backupCommand(file, output));
775
+ program.command("restore <archive>").description("Restore from a backup archive (stops app briefly during restore)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).action((archive, { file }) => restoreCommand(file, archive));
776
+ program.command("open").description("Open the app in your default browser").action(openCommand);
777
+ program.command("remove").description("Remove containers (add --volumes to also wipe all data)").option("-f, --file <path>", "Path to compose file", DEFAULT_COMPOSE).option("--volumes", "Also delete all data volumes (irreversible)", false).action(({ file, volumes }) => removeCommand(file, volumes));
778
+ program.command("doctor").description("Validate environment, service health, and disk space").action(doctorCommand);
779
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "ui-syncup-cli",
3
+ "version": "0.4.0-beta.6",
4
+ "description": "Self-host UI SyncUp with a single command",
5
+ "bin": {
6
+ "ui-syncup": "./dist/index.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsup",
13
+ "dev": "tsup --watch"
14
+ },
15
+ "dependencies": {
16
+ "commander": "^12.0.0",
17
+ "@inquirer/prompts": "^8.0.0",
18
+ "chalk": "^5.3.0",
19
+ "ora": "^8.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "tsup": "^8.0.0",
23
+ "typescript": "^5.0.0",
24
+ "@types/node": "^20.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "license": "MIT"
30
+ }