@aifabrix/server-setup 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -17,7 +17,8 @@ This is the **one document** you need to get your builder-server **from zero to
17
17
  **The builder-server Docker image is not on a public registry.** You get it with the **AI Fabrix platform**.
18
18
 
19
19
  1. Install the **AI Fabrix Builder** CLI: `npm install -g @aifabrix/builder`
20
- 2. Use the CLI to run or deploy the platform (including builder-server). See [AI Fabrix Builder](https://github.com/esystemsdev/aifabrix-builder) and [docs/README.md](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/README.md).
20
+ 2. Install the **AI Fabrix server-setup** CLI (af-server): `npm install -g @aifabrix/server-setup`
21
+ 3. Use the Builder CLI to run or deploy the platform (including builder-server). See [AI Fabrix Builder](https://github.com/esystemsdev/aifabrix-builder) and [docs/README.md](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/README.md).
21
22
 
22
23
  Once you have the platform (and the builder-server image), use **af-server** to install that server on your own host.
23
24
 
@@ -39,16 +40,71 @@ Do the steps in [What you must do before running af-server](#what-you-must-do-be
39
40
 
40
41
  ## Install: from zero to running
41
42
 
42
- **From your PC** (SSH to server):
43
+ Complete the [manual prerequisites](#what-you-must-do-before-running-af-server) first (DNS, SSL directory, certificate and key on the server).
43
44
 
44
- ```bash
45
- af-server install user@host
46
- ```
45
+ ### Option A: From your PC (remote install over SSH)
46
+
47
+ 1. **Install CLIs** (one-time):
48
+
49
+ ```bash
50
+ npm install -g @aifabrix/builder
51
+ npm install -g @aifabrix/server-setup
52
+ ```
53
+
54
+ 2. **Set your server target and local path** (replace with your host, user, and the folder on your PC where your cert and key files are):
55
+
56
+ ```bash
57
+ export SSH=serveradmin@builder02.aifabrix.dev
58
+ export HDD=/c/git/esystemsdev/aifabrix-setup/certificates
59
+ export DOMAIN=builder02.aifabrix.dev
60
+ ```
61
+
62
+ 3. **If the server has no SSH yet** (e.g. fresh VM with console only), enable SSH first (you need another way to run one command, e.g. cloud console):
63
+
64
+ ```bash
65
+ af-server install-ssh $SSH
66
+ ```
67
+
68
+ 4. **Add your SSH key** for passwordless auth (so install can run over SSH):
69
+
70
+ ```bash
71
+ af-server ssh-cert install $SSH
72
+ ```
73
+
74
+ 5. **Put SSL cert and key on the server** in `/opt/aifabrix/ssl` as `wildcard.crt` and `wildcard.key`. See [SSL directory and certificates](#ssl-directory-and-certificates).
75
+
76
+ Example: copy from your PC to the server (create the directory on the server first, then copy and fix key permissions):
77
+
78
+ ```bash
79
+ ssh $SSH "sudo mkdir -p /opt/aifabrix/ssl"
80
+ scp $HDD/wildcard.crt $SSH:/tmp/wildcard.crt
81
+ scp $HDD/wildcard.key $SSH:/tmp/wildcard.key
82
+ ssh $SSH "sudo mv /tmp/wildcard.crt /tmp/wildcard.key /opt/aifabrix/ssl/ && sudo chmod 600 /opt/aifabrix/ssl/wildcard.key"
83
+ ```
84
+
85
+ 6. **Run install** (uses sudo on the server):
86
+
87
+ ```bash
88
+ af-server install $SSH
89
+ ```
90
+
91
+ To use a different domain or SSL path:
92
+
93
+ ```bash
94
+ af-server install $SSH --dev-domain $DOMAIN --ssl-dir /opt/aifabrix/ssl
95
+ ```
96
+
97
+ - Space between `$DOMAIN` and `--ssl-dir` is required.
98
+ - **Git Bash (Windows):** quote server paths so the shell doesn't expand `/opt` (e.g. `--ssl-dir '/opt/aifabrix/ssl'`, `--data-dir '/opt/aifabrix/builder-server/data'`).
99
+
100
+ ### Option B: On the server itself (Ubuntu, no target)
47
101
 
48
- **On the server itself** (Ubuntu, no target):
102
+ If you are already on the server (e.g. over console or SSH), run install locally. Prerequisites (DNS, SSL dir with `wildcard.crt` and `wildcard.key`) must still be done first.
49
103
 
50
104
  ```bash
51
- af-server install
105
+ npm install -g @aifabrix/builder
106
+ npm install -g @aifabrix/server-setup
107
+ sudo af-server install
52
108
  ```
53
109
 
54
110
  After install, your builder-server is up. Use the **AI Fabrix Builder CLI** for all usage (users, secrets, certs, etc.)—see [Builder documentation](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/developer-isolation.md).
@@ -64,6 +120,7 @@ After install, your builder-server is up. Use the **AI Fabrix Builder CLI** for
64
120
  | `af-server backup [ user@host ] --schedule [ --backup-dir PATH ] [ --keep-days N ] [ -i SSH_KEY ]` | Cron backup (daily 02:00, keep last N, default 7). |
65
121
  | `af-server restore backup.zip [ user@host ] [ -d DATA_DIR ] [ --force ] [ -i SSH_KEY ]` | Restore backup to DATA_DIR. |
66
122
  | `af-server ssh-cert install [ user@host ] [ -i SSH_KEY ]` | Add your SSH public key to server (passwordless auth). |
123
+ | `af-server install-ssh [ user@host ] [ -i SSH_KEY ]` | Activate SSH server (install openssh-server, enable and start ssh) without login. Omit target for local. |
67
124
 
68
125
  Backups contain secrets; store encrypted. Cron backup needs SQLite (`builder.db`) and `zip` on the server; default backup dir: `/opt/aifabrix/backups`.
69
126
 
@@ -0,0 +1,123 @@
1
+ #!/bin/sh
2
+ # Apply per-developer OS users from builder-server state (DATA_DIR/ssh-keys, pending-removals).
3
+ # Run via cron every 2 minutes. Creates/updates dev<id> users, .ssh/authorized_keys, workspace symlink,
4
+ # and optional ~/.aifabrix/config.yaml when missing. Processes pending-removals then applies keys.
5
+ # Requires root. DATA_DIR must match builder-server (e.g. /opt/aifabrix/builder-server/data).
6
+
7
+ set -e
8
+
9
+ DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
10
+ SSH_KEYS_DIR="${DATA_DIR}/ssh-keys"
11
+ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
12
+ # Defaults matching builder-server env.template (override via env or apply-dev-users-defaults)
13
+ AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"
14
+ AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"
15
+
16
+ if [ ! -d "$DATA_DIR" ]; then
17
+ echo "DATA_DIR not found: $DATA_DIR"
18
+ exit 0
19
+ fi
20
+
21
+ # Read secrets-encryption key from server data dir (same as builder-server ENCRYPTION_KEY_PATH); do not log.
22
+ secrets_encryption_value=""
23
+ if [ -f "${DATA_DIR}/secrets-encryption.key" ]; then
24
+ secrets_encryption_value=$(cat "${DATA_DIR}/secrets-encryption.key" | tr -d '\n\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
25
+ fi
26
+
27
+ # --- 1. Process removals ---
28
+ if [ -f "$PENDING_REMOVALS" ]; then
29
+ while IFS= read -r dev_id || [ -n "$dev_id" ]; do
30
+ dev_id=$(echo "$dev_id" | tr -d '\r\n ')
31
+ [ -z "$dev_id" ] && continue
32
+ user_name="dev${dev_id}"
33
+ if getent passwd "$user_name" >/dev/null 2>&1; then
34
+ userdel -r "$user_name" 2>/dev/null || userdel "$user_name" 2>/dev/null || true
35
+ fi
36
+ done < "$PENDING_REMOVALS"
37
+ : > "$PENDING_REMOVALS"
38
+ fi
39
+
40
+ # --- 2. Process each developer that has keys ---
41
+ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
42
+ [ -f "$key_file" ] || continue
43
+ dev_id=$(dirname "$key_file" | xargs basename)
44
+ [ -z "$dev_id" ] && continue
45
+ user_name="dev${dev_id}"
46
+ home_dir="/home/${user_name}"
47
+ workspace_target="${DATA_DIR}/workspace/${user_name}"
48
+
49
+ if ! getent passwd "$user_name" >/dev/null 2>&1; then
50
+ useradd -m -s /bin/bash "$user_name"
51
+ else
52
+ # Ensure shell is bash (e.g. after manual changes)
53
+ current_shell=$(getent passwd "$user_name" | cut -d: -f7)
54
+ if [ "$current_shell" != "/bin/bash" ]; then
55
+ usermod -s /bin/bash "$user_name" 2>/dev/null || true
56
+ fi
57
+ fi
58
+
59
+ mkdir -p "${home_dir}/.ssh"
60
+ cp "$key_file" "${home_dir}/.ssh/authorized_keys"
61
+ chown -R "${user_name}:${user_name}" "${home_dir}/.ssh"
62
+ chmod 700 "${home_dir}/.ssh"
63
+ chmod 600 "${home_dir}/.ssh/authorized_keys"
64
+
65
+ # .aifabrix/config.yaml only if missing (do not overwrite)
66
+ config_dir="${home_dir}/.aifabrix"
67
+ config_file="${config_dir}/config.yaml"
68
+ if [ ! -f "$config_file" ]; then
69
+ mkdir -p "$config_dir"
70
+ hostname_val=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
71
+ # Values from builder-server: secrets-encryption from DATA_DIR, paths from env (apply-dev-users-defaults or env.template)
72
+ yaml_escape() { echo "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; }
73
+ {
74
+ echo "user-mutagen-folder: ${workspace_target}"
75
+ printf "secrets-encryption: \"%s\"\n" "$(yaml_escape "$secrets_encryption_value")"
76
+ printf "aifabrix-secrets: \"%s\"\n" "$(yaml_escape "$AIFABRIX_SECRETS")"
77
+ printf "aifabrix-env-config: \"%s\"\n" "$(yaml_escape "$AIFABRIX_ENV_CONFIG")"
78
+ echo "remote-server: \"http://localhost:3000\""
79
+ echo "docker-endpoint: \"tcp://${hostname_val}:2376\""
80
+ echo "sync-ssh-user: \"${user_name}\""
81
+ echo "sync-ssh-host: \"${hostname_val}\""
82
+ } > "$config_file"
83
+ chown -R "${user_name}:${user_name}" "$config_dir"
84
+ chmod 700 "$config_dir"
85
+ chmod 600 "$config_file"
86
+ fi
87
+
88
+ # Workspace: ensure dir exists, owned by user; symlink from home
89
+ mkdir -p "$workspace_target"
90
+ chown -R "${user_name}:${user_name}" "$workspace_target"
91
+ if [ -L "${home_dir}/workspace" ]; then
92
+ current_target=$(readlink -f "${home_dir}/workspace" 2>/dev/null || true)
93
+ want_target=$(readlink -f "$workspace_target" 2>/dev/null || echo "$workspace_target")
94
+ if [ -n "$current_target" ] && [ -n "$want_target" ] && [ "$current_target" != "$want_target" ]; then
95
+ rm -f "${home_dir}/workspace"
96
+ ln -s "$workspace_target" "${home_dir}/workspace"
97
+ chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
98
+ fi
99
+ elif [ ! -e "${home_dir}/workspace" ]; then
100
+ ln -s "$workspace_target" "${home_dir}/workspace"
101
+ chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
102
+ fi
103
+
104
+ # Default directory on SSH login: cd to workspace
105
+ profile="${home_dir}/.profile"
106
+ if [ ! -f "$profile" ]; then
107
+ touch "$profile"
108
+ chown "${user_name}:${user_name}" "$profile"
109
+ fi
110
+ if ! grep -q 'cd.*workspace' "$profile" 2>/dev/null; then
111
+ echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$profile"
112
+ chown "${user_name}:${user_name}" "$profile"
113
+ fi
114
+ bashrc="${home_dir}/.bashrc"
115
+ if [ ! -f "$bashrc" ]; then
116
+ touch "$bashrc"
117
+ chown "${user_name}:${user_name}" "$bashrc"
118
+ fi
119
+ if ! grep -q 'cd.*workspace' "$bashrc" 2>/dev/null; then
120
+ echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
121
+ chown "${user_name}:${user_name}" "$bashrc"
122
+ fi
123
+ done
@@ -5,6 +5,7 @@
5
5
  # Optional env: DEV_DOMAIN, SSL_DIR, DATA_DIR, SETUP_ADMIN_USER, SYNC_USER, BUILDER_SERVER_PORT, NGINX_CONF_DIR, SETUP_HOSTNAME, INSTALL_PORTAINER=1, SKIP_DOCKER_TLS=1.
6
6
 
7
7
  set -e
8
+ export DEBIAN_FRONTEND=noninteractive
8
9
 
9
10
  REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
10
11
  DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
@@ -25,6 +26,9 @@ sanitize_path() {
25
26
  *) return 0 ;;
26
27
  esac
27
28
  }
29
+ case "$DEV_DOMAIN" in
30
+ *'--'*) echo "Invalid DEV_DOMAIN: '$DEV_DOMAIN' looks like a typo (e.g. missing space before --ssl-dir). Use: af-server install user@host --dev-domain DOMAIN --ssl-dir PATH"; exit 1 ;;
31
+ esac
28
32
  if ! sanitize_domain "$DEV_DOMAIN"; then
29
33
  echo "Invalid DEV_DOMAIN (use only letters, digits, dots, hyphens)."
30
34
  exit 1
@@ -58,7 +62,19 @@ fi
58
62
 
59
63
  # --- System updates ---
60
64
  apt-get update -qq
61
- DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
65
+ apt-get upgrade -y -qq
66
+
67
+ # --- Packages for SSH users (same base as builder-server/wsl/install-wsl-ubuntu-dev.sh; no Node/Ruby) ---
68
+ apt-get install -y \
69
+ openssh-server sudo curl git ca-certificates gnupg software-properties-common apt-transport-https \
70
+ build-essential unzip zip jq wget vim nano less locales \
71
+ python3 python3-venv python3-dev python3-pip \
72
+ openjdk-17-jdk \
73
+ autoconf bison libssl-dev libyaml-dev libreadline-dev zlib1g-dev libffi-dev libgdbm-dev libncurses-dev \
74
+ redis-tools dbus-x11 imagemagick \
75
+ openssh-client rsync sshfs
76
+ systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
77
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
62
78
 
63
79
  # --- Docker ---
64
80
  if ! command -v docker >/dev/null 2>&1; then
@@ -152,23 +168,6 @@ MUTAGEN_EOF
152
168
  fi
153
169
  fi
154
170
 
155
- # --- Docker TLS (daemon.json) ---
156
- if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
157
- if [ ! -f /etc/docker/daemon.json ]; then
158
- cat > /etc/docker/daemon.json << 'DOCKER_EOF'
159
- {
160
- "tls": true,
161
- "tlsverify": true,
162
- "tlscacert": "/etc/docker/ca.pem",
163
- "tlscert": "/etc/docker/server-cert.pem",
164
- "tlskey": "/etc/docker/server-key.pem",
165
- "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
166
- }
167
- DOCKER_EOF
168
- echo "Docker TLS daemon.json created. Ensure /etc/docker/ca.pem, server-cert.pem, server-key.pem exist (see SETUP.md Docker API TLS)."
169
- fi
170
- fi
171
-
172
171
  # --- Builder-server data dir and container ---
173
172
  # Paths match AI Fabrix Builder: aifabrix build + resolve use DATA_DIR=/mnt/data in container; host DATA_DIR (e.g. /opt/aifabrix/builder-server/data) is the HDD/mount. See builder/builder-server/README.md and env.template.
174
173
  # Nginx uses DATA_DIR/ca.crt for ssl_client_certificate; container must use the same host path so CA matches.
@@ -217,6 +216,7 @@ if command -v docker >/dev/null 2>&1; then
217
216
  -e PORT=3000 \
218
217
  -e DATA_DIR="$CONTAINER_DATA_PATH" \
219
218
  -e ENCRYPTION_KEY_PATH="${CONTAINER_DATA_PATH}/secrets-encryption.key" \
219
+ -e DOCKER_ENDPOINT="tcp://${DEV_DOMAIN}:2376" \
220
220
  "$IMG_TO_USE"
221
221
  else
222
222
  echo "Builder-server image not found. Get the image from the AI Fabrix Builder (aifabrix build builder-server; then push/deploy to this host). No source or docker build on server. See builder/builder-server/README.md."
@@ -227,50 +227,164 @@ if command -v docker >/dev/null 2>&1; then
227
227
  fi
228
228
  fi
229
229
 
230
- # --- Sync user for Mutagen SSH ---
231
- SYNC_USER_HOME="${DATA_DIR}/.sync-home"
232
- MANAGED_KEYS_FILE="${DATA_DIR}/sync-authorized-keys"
233
- if ! getent passwd "$SYNC_USER" >/dev/null 2>&1; then
234
- useradd --system --home-dir "$SYNC_USER_HOME" --create-home --shell /bin/bash "$SYNC_USER"
235
- fi
236
- mkdir -p "$SYNC_USER_HOME/.ssh"
237
- chown -R "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME"
238
- chmod 700 "$SYNC_USER_HOME/.ssh"
239
- if [ ! -f "$SYNC_USER_HOME/.ssh/authorized_keys" ]; then
240
- touch "$SYNC_USER_HOME/.ssh/authorized_keys"
241
- chown "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME/.ssh/authorized_keys"
242
- chmod 600 "$SYNC_USER_HOME/.ssh/authorized_keys"
230
+ # --- Docker TLS (daemon.json + certs): use website cert for server, builder-server CA for client verification ---
231
+ if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
232
+ DATA_DIR_ABS=$(cd "$DATA_DIR" 2>/dev/null && pwd) || true
233
+ if [ -n "$DATA_DIR_ABS" ]; then
234
+ # Wait for builder-server to create ca.crt (up to 30s)
235
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
236
+ [ -f "$DATA_DIR_ABS/ca.crt" ] && break
237
+ sleep 2
238
+ done
239
+ # Server cert/key: use existing website cert (same as nginx) so no extra cert to manage
240
+ if [ -f "$SSL_DIR/wildcard.crt" ] && [ -f "$SSL_DIR/wildcard.key" ]; then
241
+ cp "$SSL_DIR/wildcard.crt" /etc/docker/server-cert.pem
242
+ cp "$SSL_DIR/wildcard.key" /etc/docker/server-key.pem
243
+ chmod 600 /etc/docker/server-key.pem
244
+ fi
245
+ # Client verification: CA that signed developer certs (builder-server creates ca.crt on first run)
246
+ if [ -f "$DATA_DIR_ABS/ca.crt" ]; then
247
+ cp "$DATA_DIR_ABS/ca.crt" /etc/docker/ca.pem
248
+ fi
249
+ if [ -f /etc/docker/ca.pem ] && [ -f /etc/docker/server-cert.pem ] && [ -f /etc/docker/server-key.pem ]; then
250
+ # Allow daemon.json to set "hosts" (avoid conflict with systemd -H fd://)
251
+ DOCKER_DROPIN="/etc/systemd/system/docker.service.d"
252
+ if [ -d /etc/systemd/system ]; then
253
+ mkdir -p "$DOCKER_DROPIN"
254
+ if [ ! -f "$DOCKER_DROPIN/override.conf" ]; then
255
+ cat > "$DOCKER_DROPIN/override.conf" << 'DROPIN_EOF'
256
+ [Service]
257
+ ExecStart=
258
+ ExecStart=/usr/bin/dockerd
259
+ DROPIN_EOF
260
+ systemctl daemon-reload
261
+ fi
262
+ fi
263
+ [ -f /etc/docker/daemon.json ] && cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak
264
+ DATA_ROOT=''
265
+ if [ -f /etc/docker/daemon.json.bak ]; then
266
+ DATA_ROOT=$(grep -o '"data-root"[[:space:]]*:[[:space:]]*"[^"]*"' /etc/docker/daemon.json.bak 2>/dev/null | sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1)
267
+ fi
268
+ if [ -n "$DATA_ROOT" ] && [ -d "$DATA_ROOT" ]; then
269
+ cat > /etc/docker/daemon.json << DOCKER_EOF
270
+ {
271
+ "data-root": "$DATA_ROOT",
272
+ "tls": true,
273
+ "tlsverify": true,
274
+ "tlscacert": "/etc/docker/ca.pem",
275
+ "tlscert": "/etc/docker/server-cert.pem",
276
+ "tlskey": "/etc/docker/server-key.pem",
277
+ "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
278
+ }
279
+ DOCKER_EOF
280
+ else
281
+ cat > /etc/docker/daemon.json << 'DOCKER_EOF'
282
+ {
283
+ "tls": true,
284
+ "tlsverify": true,
285
+ "tlscacert": "/etc/docker/ca.pem",
286
+ "tlscert": "/etc/docker/server-cert.pem",
287
+ "tlskey": "/etc/docker/server-key.pem",
288
+ "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
289
+ }
290
+ DOCKER_EOF
291
+ fi
292
+ systemctl restart docker 2>/dev/null || true
293
+ if ! docker info >/dev/null 2>&1; then
294
+ echo "Docker failed to start with TLS config. Restoring previous daemon.json so Docker can start."
295
+ if [ -f /etc/docker/daemon.json.bak ]; then
296
+ mv /etc/docker/daemon.json.bak /etc/docker/daemon.json
297
+ else
298
+ rm -f /etc/docker/daemon.json
299
+ fi
300
+ systemctl start docker 2>/dev/null || true
301
+ echo "Run scripts/fix-docker-daemon.sh if Docker still does not start. See SETUP.md Docker API TLS."
302
+ else
303
+ docker start aifabrix-builder-server 2>/dev/null || docker start builder-server 2>/dev/null || true
304
+ fi
305
+ else
306
+ echo "Docker TLS skipped: need $SSL_DIR/wildcard.crt, wildcard.key and $DATA_DIR/ca.crt (ca.crt appears after builder-server first run). Re-run af-server install after container has started. See SETUP.md Docker API TLS."
307
+ fi
308
+ fi
243
309
  fi
244
310
 
245
- # --- Workspace dir ---
311
+ # --- Workspace and ssh-keys dirs (per-developer users; apply script manages OS users) ---
246
312
  mkdir -p "${DATA_DIR}/workspace"
247
- chown -R "$SYNC_USER:$SYNC_USER" "${DATA_DIR}/workspace" 2>/dev/null || true
313
+ mkdir -p "${DATA_DIR}/ssh-keys"
248
314
 
249
- # --- Host job: apply managed SSH keys to sync user ---
250
- APPLY_KEYS_SCRIPT="/usr/local/bin/aifabrix-apply-sync-keys.sh"
251
- if [ ! -f "$APPLY_KEYS_SCRIPT" ]; then
252
- cat > "$APPLY_KEYS_SCRIPT" << APPLY_EOF
315
+ # --- Host job: apply per-developer OS users from builder-server state ---
316
+ APPLY_DEV_USERS_SCRIPT="/usr/local/bin/aifabrix-apply-dev-users.sh"
317
+ SETUP_ASSETS_DIR="$(cd "$(dirname "$0")" && pwd)"
318
+ if [ ! -f "$APPLY_DEV_USERS_SCRIPT" ]; then
319
+ if [ -f "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" ]; then
320
+ cp "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" "$APPLY_DEV_USERS_SCRIPT"
321
+ else
322
+ # Inline fallback when run from builder/builder-server context
323
+ cat > "$APPLY_DEV_USERS_SCRIPT" << 'APPLY_DEV_EOF'
253
324
  #!/bin/sh
254
- SYNC_USER="$SYNC_USER"
255
- DATA_DIR="$DATA_DIR"
256
- SYNC_HOME="\${DATA_DIR}/.sync-home"
257
- MANAGED="\${DATA_DIR}/sync-authorized-keys"
258
- if [ -f "\$MANAGED" ]; then
259
- cp "\$MANAGED" "\$SYNC_HOME/.ssh/authorized_keys"
260
- else
261
- touch "\$SYNC_HOME/.ssh/authorized_keys"
325
+ DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
326
+ SSH_KEYS_DIR="${DATA_DIR}/ssh-keys"
327
+ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
328
+ [ ! -d "$DATA_DIR" ] && exit 0
329
+ if [ -f "$PENDING_REMOVALS" ]; then
330
+ while IFS= read -r dev_id || [ -n "$dev_id" ]; do
331
+ dev_id=$(echo "$dev_id" | tr -d '\r\n ')
332
+ [ -z "$dev_id" ] && continue
333
+ user_name="dev${dev_id}"
334
+ getent passwd "$user_name" >/dev/null 2>&1 && ( userdel -r "$user_name" 2>/dev/null || userdel "$user_name" 2>/dev/null || true )
335
+ done < "$PENDING_REMOVALS"
336
+ : > "$PENDING_REMOVALS"
262
337
  fi
263
- chown "\$SYNC_USER:\$SYNC_USER" "\$SYNC_HOME/.ssh/authorized_keys"
264
- chmod 600 "\$SYNC_HOME/.ssh/authorized_keys"
265
- if [ -d "\${DATA_DIR}/workspace" ]; then
266
- chown -R "\$SYNC_USER:\$SYNC_USER" "\${DATA_DIR}/workspace"
338
+ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
339
+ [ -f "$key_file" ] || continue
340
+ dev_id=$(dirname "$key_file" | xargs basename)
341
+ [ -z "$dev_id" ] && continue
342
+ user_name="dev${dev_id}"
343
+ home_dir="/home/${user_name}"
344
+ workspace_target="${DATA_DIR}/workspace/${user_name}"
345
+ getent passwd "$user_name" >/dev/null 2>&1 || useradd -m -s /bin/bash "$user_name"
346
+ mkdir -p "${home_dir}/.ssh"
347
+ cp "$key_file" "${home_dir}/.ssh/authorized_keys"
348
+ chown -R "${user_name}:${user_name}" "${home_dir}/.ssh"
349
+ chmod 700 "${home_dir}/.ssh"
350
+ chmod 600 "${home_dir}/.ssh/authorized_keys"
351
+ config_file="${home_dir}/.aifabrix/config.yaml"
352
+ if [ ! -f "$config_file" ]; then
353
+ mkdir -p "${home_dir}/.aifabrix"
354
+ hostname_val=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
355
+ printf 'user-mutagen-folder: %s\nsecrets-encryption: ""\naifabrix-secrets: ""\naifabrix-env-config: ""\nremote-server: "http://localhost:3000"\ndocker-endpoint: "tcp://%s:2376"\nsync-ssh-user: "%s"\nsync-ssh-host: "%s"\n' "$workspace_target" "$hostname_val" "$user_name" "$hostname_val" > "$config_file"
356
+ chown -R "${user_name}:${user_name}" "${home_dir}/.aifabrix"
357
+ chmod 700 "${home_dir}/.aifabrix"
358
+ chmod 600 "$config_file"
359
+ fi
360
+ mkdir -p "$workspace_target"
361
+ chown -R "${user_name}:${user_name}" "$workspace_target"
362
+ [ -L "${home_dir}/workspace" ] && [ "$(readlink -f "${home_dir}/workspace" 2>/dev/null)" != "$(readlink -f "$workspace_target" 2>/dev/null)" ] && rm -f "${home_dir}/workspace"
363
+ [ ! -e "${home_dir}/workspace" ] && ln -s "$workspace_target" "${home_dir}/workspace" && chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
364
+ profile="${home_dir}/.profile"
365
+ [ ! -f "$profile" ] && touch "$profile" && chown "${user_name}:${user_name}" "$profile"
366
+ grep -q 'cd.*workspace' "$profile" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$profile"
367
+ bashrc="${home_dir}/.bashrc"
368
+ [ ! -f "$bashrc" ] && touch "$bashrc" && chown "${user_name}:${user_name}" "$bashrc"
369
+ grep -q 'cd.*workspace' "$bashrc" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
370
+ done
371
+ APPLY_DEV_EOF
372
+ fi
373
+ chmod 755 "$APPLY_DEV_USERS_SCRIPT"
267
374
  fi
268
- APPLY_EOF
269
- chmod 755 "$APPLY_KEYS_SCRIPT"
375
+ # Defaults for apply script (aifabrix-secrets, aifabrix-env-config in generated config); match builder-server env.template
376
+ APPLY_DEFAULTS="$DATA_DIR_ABS/apply-dev-users-defaults"
377
+ if [ ! -f "$APPLY_DEFAULTS" ]; then
378
+ {
379
+ echo "# Sourced by cron before aifabrix-apply-dev-users.sh; override to match builder-server .env"
380
+ echo 'export AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"'
381
+ echo 'export AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"'
382
+ } > "$APPLY_DEFAULTS"
383
+ chmod 644 "$APPLY_DEFAULTS"
270
384
  fi
271
- if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-sync-keys ]; then
272
- echo "*/2 * * * * root $APPLY_KEYS_SCRIPT" > /etc/cron.d/aifabrix-sync-keys
273
- chmod 644 /etc/cron.d/aifabrix-sync-keys
385
+ if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-apply-dev-users ]; then
386
+ echo "*/2 * * * * root /bin/sh -c '. $APPLY_DEFAULTS 2>/dev/null; export DATA_DIR=$DATA_DIR_ABS; exec $APPLY_DEV_USERS_SCRIPT'" > /etc/cron.d/aifabrix-apply-dev-users
387
+ chmod 644 /etc/cron.d/aifabrix-apply-dev-users
274
388
  fi
275
389
 
276
390
  echo "Setup complete. Ensure manual prerequisites (SSL at $SSL_DIR: wildcard.crt, wildcard.key; DNS $DEV_DOMAIN) are done; see SETUP.md."
@@ -100,7 +100,7 @@ describe('runBackup', () => {
100
100
  mockExec.mockResolvedValue({ stdout: '1\n', stderr: '', code: 0 });
101
101
  mockReadFile.mockResolvedValue(Buffer.from(''));
102
102
  const result = await runBackup({ target: 'user@host', dataDir: '/data' });
103
- expect(result).toMatch(/\/aifabrix-backup-\d{8}-\d{4}\.zip$/);
103
+ expect(result).toMatch(/aifabrix-backup-\d{8}-\d{4}\.zip$/);
104
104
  expect(fs.existsSync(result)).toBe(true);
105
105
  try {
106
106
  fs.unlinkSync(result);
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { runBackup, runBackupLocal } from './backup.js';
9
9
  import { runBackupScheduleInstall, runBackupScheduleInstallLocal } from './backup-schedule.js';
10
10
  import { runRestore, runRestoreLocal } from './restore.js';
11
11
  import { runSshCertRequest, runSshCertInstall, runSshCertInstallLocal } from './ssh-cert.js';
12
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
12
13
  import { requireUbuntu } from './ubuntu.js';
13
14
  const program = new Command();
14
15
  program
@@ -133,6 +134,25 @@ program
133
134
  process.exit(1);
134
135
  }
135
136
  });
137
+ program
138
+ .command('install-ssh [user@host]')
139
+ .description('Activate SSH server (install openssh-server, enable and start ssh) without login. Omit target for local.')
140
+ .option('-i, --identity <path>', 'SSH private key path')
141
+ .action(async (target, opts) => {
142
+ try {
143
+ if (!target || !target.trim()) {
144
+ requireUbuntu();
145
+ runInstallSshLocal();
146
+ }
147
+ else {
148
+ await runInstallSsh({ target: target.trim(), privateKeyPath: opts.identity });
149
+ }
150
+ }
151
+ catch (err) {
152
+ console.error(err instanceof Error ? err.message : err);
153
+ process.exit(1);
154
+ }
155
+ });
136
156
  const sshCert = program.command('ssh-cert').description('Passwordless auth: install = append your SSH public key; request = stub for future SSH CA.');
137
157
  sshCert
138
158
  .command('request')
@@ -0,0 +1,10 @@
1
+ /**
2
+ * install-ssh: Activate SSH server (openssh-server) without login.
3
+ * Installs openssh-server if needed, enables and starts the ssh service so the server accepts SSH connections.
4
+ */
5
+ import { type SSHConnectionOptions } from './ssh.js';
6
+ export declare function runInstallSsh(options: SSHConnectionOptions): Promise<void>;
7
+ /**
8
+ * Enable and start SSH server locally (no SSH). Call requireUbuntu() before this.
9
+ */
10
+ export declare function runInstallSshLocal(): void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * install-ssh: Activate SSH server (openssh-server) without login.
3
+ * Installs openssh-server if needed, enables and starts the ssh service so the server accepts SSH connections.
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import { createSSHClient, exec, close } from './ssh.js';
7
+ const INSTALL_SSH_SCRIPT = [
8
+ 'wait_for_apt() { i=0; while [ $i -lt 20 ]; do',
9
+ ' apt-get update -qq 2>/dev/null && return 0;',
10
+ ' echo "Waiting for apt lock (another process is using apt)..."; sleep 6; i=$((i+1));',
11
+ ' done; return 1;',
12
+ '};',
13
+ 'wait_for_apt && DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server && systemctl enable ssh && systemctl start ssh',
14
+ ].join(' ');
15
+ export async function runInstallSsh(options) {
16
+ const conn = await createSSHClient(options);
17
+ try {
18
+ const cmd = `sudo bash -c '${INSTALL_SSH_SCRIPT.replace(/'/g, "'\"'\"'")}'`;
19
+ const result = await exec(conn, cmd);
20
+ if (result.stderr)
21
+ process.stderr.write(result.stderr);
22
+ if (result.stdout)
23
+ process.stdout.write(result.stdout);
24
+ if (result.code !== 0) {
25
+ throw new Error(`install-ssh exited with code ${result.code}`);
26
+ }
27
+ console.log('SSH server is enabled and running. You can connect with key-based or password auth.');
28
+ }
29
+ finally {
30
+ close(conn);
31
+ }
32
+ }
33
+ /**
34
+ * Enable and start SSH server locally (no SSH). Call requireUbuntu() before this.
35
+ */
36
+ export function runInstallSshLocal() {
37
+ execSync(`sudo bash -c '${INSTALL_SSH_SCRIPT.replace(/'/g, "'\"'\"'")}'`, { stdio: 'inherit' });
38
+ console.log('SSH server is enabled and running. You can connect with key-based or password auth.');
39
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for install-ssh: remote (mocked SSH) and local (mocked execSync).
3
+ */
4
+ export {};
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for install-ssh: remote (mocked SSH) and local (mocked execSync).
3
+ */
4
+ const mockConn = {};
5
+ const mockExec = jest.fn();
6
+ const mockClose = jest.fn();
7
+ jest.mock('child_process', () => ({ execSync: jest.fn(() => Buffer.from('')) }));
8
+ jest.mock('./ssh.js', () => ({
9
+ createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
10
+ exec: jest.fn((...args) => mockExec(...args)),
11
+ close: jest.fn((...args) => mockClose(...args)),
12
+ }));
13
+ import { execSync } from 'child_process';
14
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
15
+ describe('install-ssh', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ mockExec.mockResolvedValue({ stdout: '', stderr: '', code: 0 });
19
+ jest.spyOn(console, 'log').mockImplementation(() => { });
20
+ });
21
+ afterEach(() => {
22
+ console.log.mockRestore();
23
+ });
24
+ describe('runInstallSsh', () => {
25
+ it('connects, runs sudo apt install openssh-server and systemctl enable/start ssh, then closes', async () => {
26
+ await runInstallSsh({ target: 'user@host' });
27
+ expect(mockExec).toHaveBeenCalledTimes(1);
28
+ const cmd = mockExec.mock.calls[0][1];
29
+ expect(cmd).toContain('sudo');
30
+ expect(cmd).toContain('openssh-server');
31
+ expect(cmd).toContain('systemctl enable ssh');
32
+ expect(cmd).toContain('systemctl start ssh');
33
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
34
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
35
+ });
36
+ it('throws when script exits non-zero', async () => {
37
+ mockExec.mockResolvedValueOnce({ stdout: '', stderr: 'E: Unable to locate package', code: 100 });
38
+ await expect(runInstallSsh({ target: 'user@host' })).rejects.toThrow(/install-ssh exited with code 100/);
39
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
40
+ });
41
+ });
42
+ describe('runInstallSshLocal', () => {
43
+ it('calls execSync with sudo and install script', () => {
44
+ execSync.mockClear();
45
+ runInstallSshLocal();
46
+ expect(execSync).toHaveBeenCalledTimes(1);
47
+ const cmd = execSync.mock.calls[0][0];
48
+ expect(cmd).toContain('sudo');
49
+ expect(cmd).toContain('openssh-server');
50
+ expect(cmd).toContain('systemctl enable ssh');
51
+ expect(cmd).toContain('systemctl start ssh');
52
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
53
+ });
54
+ });
55
+ });
package/dist/install.js CHANGED
@@ -8,13 +8,17 @@ import { fileURLToPath } from 'url';
8
8
  import { createSSHClient, exec, writeFile, close, } from './ssh.js';
9
9
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const ASSETS_DIR = path.resolve(scriptDir, '..', 'assets');
11
+ /** Normalize to Unix LF so shebang and scripts run on Linux (avoids "No such file or directory" when CRLF). */
12
+ function toUnixLf(s) {
13
+ return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
14
+ }
11
15
  function getSetupScript() {
12
16
  const p = path.join(ASSETS_DIR, 'setup-dev-server-no-node.sh');
13
- return fs.readFileSync(p, 'utf8');
17
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
14
18
  }
15
19
  function getNginxTemplate() {
16
20
  const p = path.join(ASSETS_DIR, 'builder', 'builder-server', 'nginx-builder-server.conf.template');
17
- return fs.readFileSync(p, 'utf8');
21
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
18
22
  }
19
23
  export async function runInstall(options) {
20
24
  const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
@@ -31,12 +35,14 @@ export async function runInstall(options) {
31
35
  await writeFile(conn, `${tmpDir}/setup.sh`, setupScript);
32
36
  await writeFile(conn, `${tmpDir}/builder/builder-server/nginx-builder-server.conf.template`, nginxTemplate);
33
37
  await exec(conn, `chmod +x ${tmpDir}/setup.sh`);
38
+ /** Quote for remote shell so paths with spaces (e.g. Git Bash expanding /opt on Windows) don't break sudo. */
39
+ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
34
40
  const env = [
35
- `REPO_ROOT=${tmpDir}`,
36
- `DATA_DIR=${dataDir}`,
37
- `DEV_DOMAIN=${devDomain}`,
38
- `SSL_DIR=${sslDir}`,
39
- `BUILDER_SERVER_PORT=${builderServerPort}`,
41
+ `REPO_ROOT=${q(tmpDir)}`,
42
+ `DATA_DIR=${q(dataDir)}`,
43
+ `DEV_DOMAIN=${q(devDomain)}`,
44
+ `SSL_DIR=${q(sslDir)}`,
45
+ `BUILDER_SERVER_PORT=${q(String(builderServerPort))}`,
40
46
  ].join(' ');
41
47
  const cmd = `sudo ${env} ${tmpDir}/setup.sh`;
42
48
  const result = await exec(conn, cmd);
package/dist/ssh.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  export interface SSHConnectionOptions {
package/dist/ssh.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  import * as fs from 'fs';
8
+ import * as os from 'os';
7
9
  import * as path from 'path';
10
+ import read from 'read';
11
+ const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa'];
8
12
  export function parseTarget(target) {
9
13
  const at = target.lastIndexOf('@');
10
14
  if (at <= 0 || at === target.length - 1) {
@@ -26,18 +30,107 @@ export function createSSHClient(options) {
26
30
  }
27
31
  privateKey = fs.readFileSync(resolved, 'utf8');
28
32
  }
33
+ else {
34
+ const sshDir = path.join(os.homedir(), '.ssh');
35
+ for (const name of DEFAULT_KEY_NAMES) {
36
+ const keyPath = path.join(sshDir, name);
37
+ if (fs.existsSync(keyPath)) {
38
+ try {
39
+ privateKey = fs.readFileSync(keyPath, 'utf8');
40
+ break;
41
+ }
42
+ catch {
43
+ // skip unreadable key, try next
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const usedDefaultKey = !options.privateKeyPath && !options.privateKey && !!privateKey;
49
+ function isAuthError(err) {
50
+ const msg = err.message || '';
51
+ return (msg.includes('All configured authentication methods failed') ||
52
+ msg.includes('authentication') ||
53
+ err.level === 'client-authentication');
54
+ }
29
55
  return new Promise((resolve, reject) => {
30
- const conn = new Client();
31
- conn
32
- .on('ready', () => resolve(conn))
33
- .on('error', (err) => reject(err))
34
- .connect({
35
- host,
36
- port,
37
- username: user,
38
- privateKey: privateKey || undefined,
39
- tryKeyboard: !privateKey,
40
- });
56
+ const doConnect = (password, useKey = true) => {
57
+ const keyForThisConnection = useKey ? privateKey : undefined;
58
+ const conn = new Client();
59
+ if (!keyForThisConnection && password !== undefined) {
60
+ conn.on('keyboard-interactive', (_name, _instructions, _lang, prompts, finish) => {
61
+ if (prompts.length === 0) {
62
+ finish([]);
63
+ return;
64
+ }
65
+ finish(prompts.map(() => password));
66
+ });
67
+ }
68
+ else if (!keyForThisConnection) {
69
+ const onKeyboardInteractive = (_name, _instructions, _lang, prompts, finish) => {
70
+ if (prompts.length === 0) {
71
+ finish([]);
72
+ return;
73
+ }
74
+ const next = (index, answers) => {
75
+ if (index >= prompts.length) {
76
+ finish(answers);
77
+ return;
78
+ }
79
+ const p = prompts[index];
80
+ read({
81
+ prompt: p.prompt || (p.echo ? 'Response: ' : 'Password: '),
82
+ silent: !p.echo,
83
+ }, (err, value) => {
84
+ if (err) {
85
+ finish(answers);
86
+ return;
87
+ }
88
+ next(index + 1, answers.concat(value || ''));
89
+ });
90
+ };
91
+ next(0, []);
92
+ };
93
+ conn.on('keyboard-interactive', onKeyboardInteractive);
94
+ }
95
+ conn
96
+ .on('ready', () => resolve(conn))
97
+ .on('error', (err) => {
98
+ if (usedDefaultKey &&
99
+ useKey &&
100
+ isAuthError(err)) {
101
+ read({ prompt: 'Password: ', silent: true }, (errRead, value) => {
102
+ if (errRead) {
103
+ reject(errRead);
104
+ return;
105
+ }
106
+ doConnect(value || '', false);
107
+ });
108
+ }
109
+ else {
110
+ reject(err);
111
+ }
112
+ })
113
+ .connect({
114
+ host,
115
+ port,
116
+ username: user,
117
+ privateKey: keyForThisConnection || undefined,
118
+ password: !keyForThisConnection ? password : undefined,
119
+ tryKeyboard: !keyForThisConnection,
120
+ });
121
+ };
122
+ if (!privateKey) {
123
+ read({ prompt: 'Password: ', silent: true }, (err, value) => {
124
+ if (err) {
125
+ reject(err);
126
+ return;
127
+ }
128
+ doConnect(value || '');
129
+ });
130
+ }
131
+ else {
132
+ doConnect(undefined);
133
+ }
41
134
  });
42
135
  }
43
136
  export function exec(conn, command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/server-setup",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI to install, backup, and restore AI Fabrix builder-server (config + DB) over SSH",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -24,6 +24,7 @@
24
24
  "better-sqlite3": "^11.6.0",
25
25
  "commander": "^12.1.0",
26
26
  "extract-zip": "^2.0.1",
27
+ "read": "^1.0.7",
27
28
  "ssh2": "^1.15.0"
28
29
  },
29
30
  "devDependencies": {
@@ -32,6 +33,7 @@
32
33
  "@types/better-sqlite3": "^7.6.11",
33
34
  "@types/jest": "^29.5.14",
34
35
  "@types/node": "^22.10.2",
36
+ "@types/read": "^0.0.32",
35
37
  "@types/ssh2": "^1.15.5",
36
38
  "@typescript-eslint/eslint-plugin": "^8.17.0",
37
39
  "@typescript-eslint/parser": "^8.17.0",