@aifabrix/server-setup 1.5.2 → 1.5.5

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,9 @@ 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. Install the **AI Fabrix server-setup** CLI (af-server): `npm install -g @aifabrix/server-setup`
20
+ 2. Install the **AI Fabrix server-setup** CLI (af-server): `npm install -g @aifabrix/server-setup`
21
+ - If you get **EACCES** (permission denied), use either:
22
+ `sudo npm install -g @aifabrix/server-setup`
21
23
  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).
22
24
 
23
25
  Once you have the platform (and the builder-server image), use **af-server** to install that server on your own host.
@@ -46,7 +48,7 @@ Complete the [manual prerequisites](#what-you-must-do-before-running-af-server)
46
48
 
47
49
  | Step | Where | Action |
48
50
  | ---- | --------- | ------ |
49
- | 1 | From PC | `af-server install-init $SSH` — only command over SSH; installs on server: SSH (if needed), Node 18+, npm, and `af-server` CLI. |
51
+ | 1 | From PC | `af-server install-init $SSH` — only command over SSH; installs on server: SSH (if needed), Node 20+, npm, and `af-server` CLI. |
50
52
  | 2 | One-time | Log in to the server once (e.g. with password) to approve passwordless SSH; then from PC: `af-server ssh-cert install $SSH`. |
51
53
  | 3 | From PC | Copy SSL certificate and key to the server (see [SSL directory and certificates](#ssl-directory-and-certificates)); example commands below. |
52
54
  | 4 | From PC | Log in to the server via SSH (passwordless). |
@@ -61,11 +63,10 @@ Set your target and run the only command that uses SSH from your PC:
61
63
 
62
64
  ```bash
63
65
  export SSH=serveradmin@builder02.aifabrix.dev
64
- export DOMAIN=builder02.aifabrix.dev
65
66
  af-server install-init $SSH
66
67
  ```
67
68
 
68
- This installs on the server: openssh-server (if needed), Node 18+, npm, and `@aifabrix/builder` + `@aifabrix/server-setup` so `af-server` is available there. No Docker, nginx, or builder-server yet.
69
+ This installs on the server: openssh-server (if needed), Node 20+, npm, and `@aifabrix/builder` + `@aifabrix/server-setup` so `af-server` is available there. You’ll see progress messages and live output from the bootstrap (package lists, SSH, Node, npm install). No Docker, nginx, or builder-server yet.
69
70
 
70
71
  ### Step 2: Passwordless SSH (from PC)
71
72
 
@@ -107,8 +108,8 @@ The image is not on a public registry. Use your platform’s method (e.g. Azure
107
108
 
108
109
  ```bash
109
110
  az login
110
- az acr login --name aifabrixdevacr
111
- docker pull aifabrixdevacr.azurecr.io/aifabrix/builder-server:latest
111
+ az acr login --name youracr
112
+ docker pull youracr.azurecr.io/aifabrix/builder-server:latest
112
113
  ```
113
114
 
114
115
  Or with Docker login (username/password from your registry):
@@ -121,11 +122,14 @@ docker pull <registry>/aifabrix/builder-server:latest
121
122
  ### Step 7: Install server (nginx vhost + container) (on server)
122
123
 
123
124
  ```bash
125
+ export DOMAIN=builder02.aifabrix.dev
124
126
  sudo af-server install-server --dev-domain $DOMAIN
125
127
  ```
126
128
 
127
129
  Use the same domain as your DNS and SSL. Optional: `--ssl-dir /opt/aifabrix/ssl`, `--data-dir /opt/aifabrix/builder-server/data`, `--builder-port 3000`.
128
130
 
131
+ If the builder-server image is not on the host yet, the first run writes an nginx vhost without client-cert verification (so `nginx -t` passes). After you pull the image and re-run `sudo af-server install-server --dev-domain $DOMAIN`, the container will start, create `ca.crt`, and a subsequent run will enable client-cert verification and reload nginx.
132
+
129
133
  ### Step 8: Done
130
134
 
131
135
  Your builder-server is up. Use the **AI Fabrix Builder CLI** for users, secrets, certs, etc.—see [Builder documentation](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/developer-isolation.md).
@@ -136,7 +140,7 @@ Your builder-server is up. Use the **AI Fabrix Builder CLI** for users, secrets,
136
140
 
137
141
  | Command | Description |
138
142
  | -------- | ----------- |
139
- | `af-server install-init <user@host> [ -i SSH_KEY ]` | **From PC only.** One-time bootstrap over SSH: install on server SSH (if needed), Node 18+, npm, and af-server CLI. |
143
+ | `af-server install-init <user@host> [ -i SSH_KEY ]` | **From PC only.** One-time bootstrap over SSH: install on server SSH (if needed), Node 20+, npm, and af-server CLI. Shows progress and streams server output. |
140
144
  | `af-server install [ user@host ] [ -d DATA_DIR ] [ --dev-domain DOMAIN ] [ --ssl-dir PATH ] [ -i SSH_KEY ]` | **Run on server** (omit target): `sudo af-server install`. Infra only: Docker, nginx pkg, Mutagen, data dir, cron. No builder vhost or container. With target: same infra over SSH. |
141
145
  | `af-server install-server --dev-domain DOMAIN [ -d DATA_DIR ] [ --ssl-dir PATH ] [ --builder-port PORT ]` | **On server only.** Nginx vhost, builder-server container, Docker TLS. Run after `sudo af-server install`. |
142
146
  | `af-server backup [ user@host ] [ -d DATA_DIR ] [ -o output.zip ] [ -i SSH_KEY ]` | On-demand backup (config + DB + keys). |
@@ -203,7 +207,7 @@ If you SSH as a non-root user, that user must be able to sudo. The script will a
203
207
  - **Admin user** — Adds the admin user (default `serveradmin`) to the `docker` group and grants passwordless sudo.
204
208
  - **Nginx** — Installs the nginx **package** only; enables and starts it. Does **not** write the builder vhost yet.
205
209
  - **Data dir** — Creates the data directory (default `/opt/aifabrix/builder-server/data`), workspace and ssh-keys subdirs, ownership for the container.
206
- - **Apply-dev-users** — Installs the script and cron job (every 2 minutes) that sync per-developer OS users from builder-server state.
210
+ - **Apply-dev-users** — Installs the script and cron job (every 2 minutes) that sync per-developer OS users from builder-server state. The script configures a user-writable npm/pnpm prefix (`~/.local`) for dev users and the aifabrix user so they can run `npm install -g` and `pnpm add -g` without sudo. When run as root, it can also grant passwordless sudo to the aifabrix user (override with `SUDO_NOPASSWD_USER` in apply-dev-users-defaults). **Without sudo:** run the script as the current user (e.g. `SUDO_NOPASSWD_USER=aifabrix`); it will only set up that user's `~/.local` and `~/.npmrc`/`~/.pnpmrc` so npm and pnpm global installs work without sudo.
207
211
  - **Mutagen** — Downloads and installs Mutagen; systemd service and daemon.
208
212
  - **Optional** — If `INSTALL_PORTAINER=1`, installs the Portainer container.
209
213
 
@@ -2,7 +2,11 @@
2
2
  # Apply per-developer OS users from builder-server state (DATA_DIR/ssh-keys, pending-removals).
3
3
  # Run via cron every 2 minutes. Creates/updates dev<id> users, .ssh/authorized_keys, workspace symlink,
4
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).
5
+ # Also: npm/pnpm user prefix (~/.local) for dev users so they can install global packages without sudo;
6
+ # optional passwordless sudo for SUDO_NOPASSWD_USER (only when run as root; skipped without sudo).
7
+ # Run as root for full sync (useradd, dev users, etc.). When run without root as SUDO_NOPASSWD_USER,
8
+ # only the current user's npm/pnpm prefix is configured so they can install global packages without sudo.
9
+ # DATA_DIR must match builder-server (e.g. /opt/aifabrix/builder-server/data).
6
10
 
7
11
  set -e
8
12
 
@@ -12,10 +16,36 @@ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
12
16
  # Defaults matching builder-server env.template (override via env or apply-dev-users-defaults)
13
17
  AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"
14
18
  AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"
19
+ # User that gets npm/pnpm prefix when run as that user without root; passwordless sudo only when root.
20
+ SUDO_NOPASSWD_USER="${SUDO_NOPASSWD_USER:-aifabrix}"
15
21
 
22
+ # Only root can write sudoers and manage dev users; without root we only set up current user's npm/pnpm.
23
+ ROOT_OK=0
24
+ [ "$(id -u)" = 0 ] && ROOT_OK=1
25
+
26
+ # When not root, current user can run script to set up only their npm/pnpm prefix (DATA_DIR not required).
16
27
  if [ ! -d "$DATA_DIR" ]; then
17
- echo "DATA_DIR not found: $DATA_DIR"
18
- exit 0
28
+ if [ "$ROOT_OK" = 1 ] || [ "$(id -un)" != "$SUDO_NOPASSWD_USER" ]; then
29
+ echo "DATA_DIR not found: $DATA_DIR"
30
+ exit 0
31
+ fi
32
+ fi
33
+
34
+ # --- 0. Passwordless sudo for SUDO_NOPASSWD_USER (only when run as root; skip when no sudo access) ---
35
+ if [ "$ROOT_OK" = 1 ]; then
36
+ case "$SUDO_NOPASSWD_USER" in
37
+ *'..'*|*'/'*|*';'*|*'|'*|*'&'*|*'$'*|*'`'*|*' '*)
38
+ ;;
39
+ *)
40
+ if [ -n "$SUDO_NOPASSWD_USER" ] && getent passwd "$SUDO_NOPASSWD_USER" >/dev/null 2>&1; then
41
+ SUDOERS_FILE="/etc/sudoers.d/99-nopasswd-${SUDO_NOPASSWD_USER}"
42
+ if [ ! -f "$SUDOERS_FILE" ]; then
43
+ echo "${SUDO_NOPASSWD_USER} ALL=(ALL) NOPASSWD:ALL" > "$SUDOERS_FILE"
44
+ chmod 440 "$SUDOERS_FILE"
45
+ fi
46
+ fi
47
+ ;;
48
+ esac
19
49
  fi
20
50
 
21
51
  # Read secrets-encryption key from server data dir (same as builder-server ENCRYPTION_KEY_PATH); do not log.
@@ -24,8 +54,8 @@ if [ -f "${DATA_DIR}/secrets-encryption.key" ]; then
24
54
  secrets_encryption_value=$(cat "${DATA_DIR}/secrets-encryption.key" | tr -d '\n\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
25
55
  fi
26
56
 
27
- # --- 1. Process removals ---
28
- if [ -f "$PENDING_REMOVALS" ]; then
57
+ # --- 1. Process removals (root only) ---
58
+ if [ "$ROOT_OK" = 1 ] && [ -f "$PENDING_REMOVALS" ]; then
29
59
  while IFS= read -r dev_id || [ -n "$dev_id" ]; do
30
60
  dev_id=$(echo "$dev_id" | tr -d '\r\n ')
31
61
  [ -z "$dev_id" ] && continue
@@ -37,7 +67,8 @@ if [ -f "$PENDING_REMOVALS" ]; then
37
67
  : > "$PENDING_REMOVALS"
38
68
  fi
39
69
 
40
- # --- 2. Process each developer that has keys ---
70
+ # --- 2. Process each developer that has keys (root only) ---
71
+ if [ "$ROOT_OK" = 1 ]; then
41
72
  for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
42
73
  [ -f "$key_file" ] || continue
43
74
  dev_id=$(dirname "$key_file" | xargs basename)
@@ -120,4 +151,59 @@ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
120
151
  echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
121
152
  chown "${user_name}:${user_name}" "$bashrc"
122
153
  fi
154
+
155
+ # npm/pnpm: user install prefix so dev users can run npm install -g, pnpm install, pnpm setup without sudo
156
+ mkdir -p "${home_dir}/.local/bin"
157
+ chown -R "${user_name}:${user_name}" "${home_dir}/.local"
158
+ printf 'prefix=%s/.local\n' "$home_dir" > "${home_dir}/.npmrc"
159
+ chown "${user_name}:${user_name}" "${home_dir}/.npmrc"
160
+ if [ ! -f "${home_dir}/.pnpmrc" ] || ! grep -q '^global-bin-dir=' "${home_dir}/.pnpmrc" 2>/dev/null; then
161
+ printf 'global-bin-dir=%s/.local/bin\n' "$home_dir" >> "${home_dir}/.pnpmrc"
162
+ chown "${user_name}:${user_name}" "${home_dir}/.pnpmrc"
163
+ fi
164
+ for f in "$profile" "$bashrc"; do
165
+ if ! grep -q '\.local/bin' "$f" 2>/dev/null; then
166
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$f"
167
+ chown "${user_name}:${user_name}" "$f"
168
+ fi
169
+ done
123
170
  done
171
+ fi
172
+
173
+ # --- 3. npm/pnpm prefix so SUDO_NOPASSWD_USER can install global packages without sudo ---
174
+ # When root: set up that user's home. When not root: set up current user's home (no chown).
175
+ setup_npm_pnpm_prefix() {
176
+ _home="$1"
177
+ _user="$2"
178
+ _use_chown="$3"
179
+ mkdir -p "${_home}/.local/bin"
180
+ [ "$_use_chown" = 1 ] && chown -R "${_user}:${_user}" "${_home}/.local"
181
+ _npmrc="${_home}/.npmrc"
182
+ if [ ! -f "$_npmrc" ] || ! grep -q '^prefix=' "$_npmrc" 2>/dev/null; then
183
+ printf 'prefix=%s/.local\n' "$_home" >> "$_npmrc"
184
+ [ "$_use_chown" = 1 ] && chown "${_user}:${_user}" "$_npmrc"
185
+ fi
186
+ for _f in "${_home}/.profile" "${_home}/.bashrc"; do
187
+ if [ -f "$_f" ] && ! grep -q '\.local/bin' "$_f" 2>/dev/null; then
188
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_f"
189
+ [ "$_use_chown" = 1 ] && chown "${_user}:${_user}" "$_f"
190
+ fi
191
+ done
192
+ _pnpmrc="${_home}/.pnpmrc"
193
+ if [ ! -f "$_pnpmrc" ] || ! grep -q '^global-bin-dir=' "$_pnpmrc" 2>/dev/null; then
194
+ printf 'global-bin-dir=%s/.local/bin\n' "$_home" >> "$_pnpmrc"
195
+ [ "$_use_chown" = 1 ] && chown "${_user}:${_user}" "$_pnpmrc"
196
+ fi
197
+ }
198
+
199
+ case "$SUDO_NOPASSWD_USER" in
200
+ *'..'*|*'/'*|*';'*|*'|'*|*'&'*|*'$'*|*'`'*|*' '*) ;;
201
+ *)
202
+ if [ "$ROOT_OK" = 1 ] && [ -n "$SUDO_NOPASSWD_USER" ] && getent passwd "$SUDO_NOPASSWD_USER" >/dev/null 2>&1; then
203
+ admin_home=$(getent passwd "$SUDO_NOPASSWD_USER" | cut -d: -f6)
204
+ [ -n "$admin_home" ] && [ -d "$admin_home" ] && setup_npm_pnpm_prefix "$admin_home" "$SUDO_NOPASSWD_USER" 1
205
+ elif [ "$ROOT_OK" = 0 ] && [ -n "$SUDO_NOPASSWD_USER" ] && [ "$(id -un)" = "$SUDO_NOPASSWD_USER" ]; then
206
+ [ -d "$HOME" ] && setup_npm_pnpm_prefix "$HOME" "$(id -un)" 0
207
+ fi
208
+ ;;
209
+ esac
@@ -252,6 +252,7 @@ fi
252
252
  if [ "$INSTALL_PHASE" = "server" ] || [ "$INSTALL_PHASE" = "full" ]; then
253
253
 
254
254
  # --- Nginx builder vhost from template ---
255
+ # If ca.crt does not exist yet (builder-server not run), omit client cert so nginx -t passes. Re-run install-server after container has created ca.crt to enable client verification.
255
256
  NGINX_CONF="$NGINX_CONF_DIR/$DEV_DOMAIN.conf"
256
257
  NGINX_TEMPLATE="$REPO_ROOT/builder/builder-server/nginx-builder-server.conf.template"
257
258
  if [ -f "$NGINX_TEMPLATE" ]; then
@@ -260,6 +261,10 @@ if [ -f "$NGINX_TEMPLATE" ]; then
260
261
  -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
261
262
  -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
262
263
  "$NGINX_TEMPLATE" > "$NGINX_CONF"
264
+ if [ ! -f "$DATA_DIR/ca.crt" ]; then
265
+ sed -i '/ssl_client_certificate/d' "$NGINX_CONF"
266
+ sed -i 's/ssl_verify_client optional;/ssl_verify_client off;/' "$NGINX_CONF"
267
+ fi
263
268
  elif [ ! -f "$NGINX_CONF" ]; then
264
269
  echo "Warning: SSL prereqs required. Place $NGINX_CONF (see SETUP.md) and ensure $SSL_DIR/wildcard.crt and $SSL_DIR/wildcard.key exist."
265
270
  fi
@@ -410,6 +415,18 @@ DOCKER_EOF
410
415
  fi
411
416
  fi
412
417
 
418
+ # If ca.crt now exists (e.g. container just created it this run), regenerate nginx config with client cert and reload.
419
+ if [ -f "$NGINX_TEMPLATE" ] && [ -n "$DATA_DIR_ABS" ] && [ -f "$DATA_DIR_ABS/ca.crt" ]; then
420
+ sed -e "s|DEV_DOMAIN_PLACEHOLDER|$DEV_DOMAIN|g" \
421
+ -e "s|SSL_DIR_PLACEHOLDER|$SSL_DIR|g" \
422
+ -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
423
+ -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
424
+ "$NGINX_TEMPLATE" > "$NGINX_CONF"
425
+ if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
426
+ systemctl reload nginx
427
+ fi
428
+ fi
429
+
413
430
  fi
414
431
  # --- End server phase ---
415
432
 
@@ -1,42 +1,63 @@
1
1
  #!/bin/sh
2
- # One-time bootstrap: ensure SSH, install Node 18+ and npm, then af-server CLI on the server.
2
+ # One-time bootstrap: ensure SSH, install Node 20+ and npm, then af-server CLI on the server.
3
3
  # Run via: af-server install-init user@host (from PC over SSH). No Docker, nginx, or builder-server.
4
4
  # After this, user logs in to the server and runs: sudo af-server install, then sudo af-server install-server --dev-domain DOMAIN.
5
5
 
6
6
  set -e
7
7
  export DEBIAN_FRONTEND=noninteractive
8
8
 
9
- # --- SSH server (so install-init and later ssh-cert install can connect) ---
9
+ echo "==> AI Fabrix server bootstrap starting..."
10
+ echo ""
11
+
12
+ # --- Update package lists (so installs use up-to-date indexes) ---
13
+ echo "==> Updating package lists..."
10
14
  wait_for_apt() {
11
15
  i=0
12
16
  while [ $i -lt 20 ]; do
13
17
  apt-get update -qq 2>/dev/null && return 0
14
- echo "Waiting for apt lock..."; sleep 6
18
+ echo " Waiting for apt lock..."; sleep 6
15
19
  i=$((i + 1))
16
20
  done
17
21
  return 1
18
22
  }
19
23
  wait_for_apt
24
+ echo " Package lists up to date."
25
+ echo ""
26
+
27
+ # --- SSH server (so install-init and later ssh-cert install can connect) ---
28
+ echo "==> Ensuring SSH server is installed and running..."
20
29
  apt-get install -y openssh-server
21
30
  systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
22
31
  systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
32
+ echo " SSH server ready."
33
+ echo ""
23
34
 
24
- # --- Node.js 18+ and npm (NodeSource) ---
35
+ # --- Node.js 20+ and npm (NodeSource; af-server and builder need Node 20+) ---
36
+ echo "==> Ensuring Node.js 20+ and npm..."
25
37
  need_node=0
26
38
  if ! command -v node >/dev/null 2>&1; then
27
39
  need_node=1
28
40
  else
29
- case "$(node -v 2>/dev/null)" in v1[89].*|v[2-9]*) ;; *) need_node=1 ;; esac
41
+ case "$(node -v 2>/dev/null)" in
42
+ v2[0-9].*|v3*) ;;
43
+ *) need_node=1 ;;
44
+ esac
30
45
  fi
31
46
  if [ "$need_node" = "1" ]; then
47
+ echo " Installing Node.js 20.x from NodeSource..."
32
48
  curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
33
49
  apt-get install -y nodejs
50
+ echo " Node.js installed."
51
+ else
52
+ echo " Node $(node -v) already present."
34
53
  fi
35
- node -v
36
- npm -v
54
+ echo " node: $(node -v) npm: $(npm -v)"
55
+ echo ""
37
56
 
38
57
  # --- af-server CLI (same as on PC) ---
58
+ echo "==> Installing af-server CLI (npm install -g @aifabrix/builder @aifabrix/server-setup)..."
39
59
  npm install -g @aifabrix/builder @aifabrix/server-setup
40
- command -v af-server >/dev/null 2>&1 && af-server --version || true
60
+ command -v af-server >/dev/null 2>&1 && echo " $(af-server --version)" || true
61
+ echo ""
41
62
 
42
63
  echo "Bootstrap complete. Log in to the server and run: sudo af-server install, then sudo af-server install-server --dev-domain YOUR_DOMAIN"
package/dist/cli.js CHANGED
@@ -24,10 +24,11 @@ program
24
24
  .version(pkg.version);
25
25
  program
26
26
  .command('install-init <user@host>')
27
- .description('One-time bootstrap over SSH: install on server SSH (if needed), Node 18+, npm, and af-server CLI. Run from PC only.')
27
+ .description('One-time bootstrap over SSH: install on server SSH (if needed), Node 20+, npm, and af-server CLI. Run from PC only.')
28
28
  .option('-i, --identity <path>', 'SSH private key path')
29
29
  .action(async (target, opts) => {
30
30
  try {
31
+ console.log('Bootstrap starting. This will connect via SSH and install prerequisites on the server (SSH, Node 20+, npm, af-server CLI).');
31
32
  await runInstallInit({ target: target.trim(), privateKeyPath: opts.identity });
32
33
  console.log('Install-init complete. Log in to the server and run: sudo af-server install, then sudo af-server install-server --dev-domain YOUR_DOMAIN');
33
34
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * install-init core: script loading and bootstrap flow. No import.meta.url so Jest can load and test.
3
+ */
4
+ import type { Client } from 'ssh2';
5
+ export declare function toUnixLf(s: string): string;
6
+ export declare function getInitScript(assetsDir: string): string;
7
+ /**
8
+ * Run bootstrap on the remote host: upload script, chmod, run via sudo, then remove tmp dir.
9
+ * Caller must open and close the connection.
10
+ */
11
+ export declare function runBootstrap(conn: Client, scriptContent: string, tmpDir: string): Promise<void>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * install-init core: script loading and bootstrap flow. No import.meta.url so Jest can load and test.
3
+ */
4
+ import * as path from 'path';
5
+ import * as fs from 'fs';
6
+ import { exec, execStream, writeFile } from './ssh.js';
7
+ export function toUnixLf(s) {
8
+ return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
9
+ }
10
+ export function getInitScript(assetsDir) {
11
+ const p = path.join(assetsDir, 'setup-install-init.sh');
12
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
13
+ }
14
+ /**
15
+ * Run bootstrap on the remote host: upload script, chmod, run via sudo, then remove tmp dir.
16
+ * Caller must open and close the connection.
17
+ */
18
+ export async function runBootstrap(conn, scriptContent, tmpDir) {
19
+ await exec(conn, `mkdir -p ${tmpDir}`);
20
+ await exec(conn, `chmod 755 ${tmpDir}`);
21
+ await writeFile(conn, `${tmpDir}/setup-install-init.sh`, scriptContent);
22
+ await exec(conn, `chmod +x ${tmpDir}/setup-install-init.sh`);
23
+ const cmd = `sudo ${tmpDir}/setup-install-init.sh`;
24
+ const result = await execStream(conn, cmd);
25
+ if (result.code !== 0) {
26
+ throw new Error(`install-init script exited with code ${result.code}`);
27
+ }
28
+ await exec(conn, `rm -rf ${tmpDir}`);
29
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Paths for install-init (uses import.meta.url). Kept in a separate module so tests can mock it and never load this file in Jest.
3
+ */
4
+ export declare function getAssetsDir(): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Paths for install-init (uses import.meta.url). Kept in a separate module so tests can mock it and never load this file in Jest.
3
+ */
4
+ import * as path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ export function getAssetsDir() {
7
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8
+ return path.resolve(scriptDir, '..', 'assets');
9
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 18+, npm, and af-server CLI.
2
+ * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 20+, npm, and af-server CLI.
3
3
  * No Docker, nginx, or builder-server. After this, the user logs in to the server and runs install + install-server locally.
4
4
  */
5
5
  import { type SSHConnectionOptions } from './ssh.js';
6
- export declare function runInstallInit(options: SSHConnectionOptions): Promise<void>;
6
+ export declare function runInstallInit(options: SSHConnectionOptions, assetsDir?: string): Promise<void>;
@@ -1,39 +1,20 @@
1
1
  /**
2
- * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 18+, npm, and af-server CLI.
2
+ * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 20+, npm, and af-server CLI.
3
3
  * No Docker, nginx, or builder-server. After this, the user logs in to the server and runs install + install-server locally.
4
4
  */
5
- import * as path from 'path';
6
- import * as fs from 'fs';
7
- import { fileURLToPath } from 'url';
8
- import { createSSHClient, exec, writeFile, close } from './ssh.js';
9
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
- const ASSETS_DIR = path.resolve(scriptDir, '..', 'assets');
11
- function toUnixLf(s) {
12
- return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
13
- }
14
- function getInitScript() {
15
- const p = path.join(ASSETS_DIR, 'setup-install-init.sh');
16
- return toUnixLf(fs.readFileSync(p, 'utf8'));
17
- }
18
- export async function runInstallInit(options) {
5
+ import { createSSHClient, close } from './ssh.js';
6
+ import { getInitScript, runBootstrap } from './install-init-core.js';
7
+ import { getAssetsDir } from './install-init-paths.js';
8
+ export async function runInstallInit(options, assetsDir) {
9
+ const dir = assetsDir ?? getAssetsDir();
10
+ console.error(`Connecting to ${options.target}...`);
19
11
  const conn = await createSSHClient(options);
20
12
  try {
13
+ console.error('Connected. Uploading bootstrap script...');
21
14
  const tmpDir = `/tmp/aifabrix-init-${Date.now()}`;
22
- await exec(conn, `mkdir -p ${tmpDir}`);
23
- await exec(conn, `chmod 755 ${tmpDir}`);
24
- const script = getInitScript();
25
- await writeFile(conn, `${tmpDir}/setup-install-init.sh`, script);
26
- await exec(conn, `chmod +x ${tmpDir}/setup-install-init.sh`);
27
- const cmd = `sudo ${tmpDir}/setup-install-init.sh`;
28
- const result = await exec(conn, cmd);
29
- if (result.stderr)
30
- process.stderr.write(result.stderr);
31
- if (result.stdout)
32
- process.stdout.write(result.stdout);
33
- if (result.code !== 0) {
34
- throw new Error(`install-init script exited with code ${result.code}`);
35
- }
36
- await exec(conn, `rm -rf ${tmpDir}`);
15
+ const script = getInitScript(dir);
16
+ console.error('Running bootstrap on server (this may take 1–2 minutes)...\n');
17
+ await runBootstrap(conn, script, tmpDir);
37
18
  }
38
19
  finally {
39
20
  close(conn);
@@ -1 +1,4 @@
1
+ /**
2
+ * Tests for install-init: bootstrap script content, install-init-core helpers, and runInstallInit (with assetsDir passed so import.meta.url is never evaluated in Jest).
3
+ */
1
4
  export {};
@@ -1,10 +1,117 @@
1
1
  /**
2
- * Tests for install-init: bootstrap over SSH (mocked).
3
- * install-init.ts uses import.meta.url; Jest (CJS) fails to load it. Skipped until ESM/import.meta is supported in tests.
2
+ * Tests for install-init: bootstrap script content, install-init-core helpers, and runInstallInit (with assetsDir passed so import.meta.url is never evaluated in Jest).
4
3
  */
5
- describe.skip('install-init', () => {
6
- it('placeholder until Jest supports import.meta in transformed modules', () => {
7
- expect(true).toBe(true);
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ const ASSETS_DIR = path.resolve(__dirname, '..', 'assets');
7
+ const INIT_SCRIPT = path.join(ASSETS_DIR, 'setup-install-init.sh');
8
+ describe('install-init bootstrap script (setup-install-init.sh)', () => {
9
+ it('exists and defines expected bootstrap steps', () => {
10
+ expect(fs.existsSync(INIT_SCRIPT)).toBe(true);
11
+ const content = fs.readFileSync(INIT_SCRIPT, 'utf8');
12
+ expect(content).toContain('AI Fabrix server bootstrap');
13
+ expect(content).toContain('apt-get update');
14
+ expect(content).toContain('openssh-server');
15
+ expect(content).toContain('Node.js 20');
16
+ expect(content).toContain('NodeSource');
17
+ expect(content).toContain('setup_20.x');
18
+ expect(content).toContain('@aifabrix/builder');
19
+ expect(content).toContain('@aifabrix/server-setup');
20
+ expect(content).toContain('af-server');
21
+ expect(content).toContain('Bootstrap complete');
22
+ });
23
+ it('has shebang and set -e', () => {
24
+ const content = fs.readFileSync(INIT_SCRIPT, 'utf8');
25
+ expect(content).toMatch(/^#!\/bin\/sh/);
26
+ expect(content).toContain('set -e');
27
+ });
28
+ });
29
+ const mockConn = {};
30
+ const mockExec = jest.fn();
31
+ const mockWriteFile = jest.fn();
32
+ const mockExecStream = jest.fn();
33
+ const mockCreateSSHClient = jest.fn();
34
+ const mockClose = jest.fn();
35
+ jest.mock('./install-init-paths.js', () => ({
36
+ getAssetsDir: () => require('path').resolve(__dirname, '..', 'assets'),
37
+ }));
38
+ jest.mock('./ssh.js', () => ({
39
+ createSSHClient: (...args) => mockCreateSSHClient(...args),
40
+ close: (...args) => mockClose(...args),
41
+ exec: (...args) => mockExec(...args),
42
+ writeFile: (...args) => mockWriteFile(...args),
43
+ execStream: (...args) => mockExecStream(...args),
44
+ }));
45
+ import { toUnixLf, getInitScript, runBootstrap } from './install-init-core.js';
46
+ import { runInstallInit } from './install-init.js';
47
+ describe('install-init-core', () => {
48
+ beforeEach(() => {
49
+ jest.clearAllMocks();
50
+ mockExec.mockResolvedValue({ stdout: '', stderr: '', code: 0 });
51
+ mockWriteFile.mockResolvedValue(undefined);
52
+ mockExecStream.mockResolvedValue({ code: 0 });
53
+ });
54
+ describe('toUnixLf', () => {
55
+ it('converts CRLF to LF', () => {
56
+ expect(toUnixLf('a\r\nb\r\n')).toBe('a\nb\n');
57
+ });
58
+ it('converts CR to LF', () => {
59
+ expect(toUnixLf('a\rb\rc')).toBe('a\nb\nc');
60
+ });
61
+ it('leaves LF-only unchanged', () => {
62
+ expect(toUnixLf('a\nb\n')).toBe('a\nb\n');
63
+ });
64
+ });
65
+ describe('getInitScript', () => {
66
+ it('returns script content with Unix line endings', () => {
67
+ const script = getInitScript(ASSETS_DIR);
68
+ expect(script).toContain('#!/bin/sh');
69
+ expect(script).toContain('AI Fabrix server bootstrap');
70
+ expect(script).not.toMatch(/\r\n/);
71
+ });
72
+ it('throws when assets dir has no setup-install-init.sh', () => {
73
+ expect(() => getInitScript('/nonexistent')).toThrow();
74
+ });
75
+ });
76
+ describe('runBootstrap', () => {
77
+ it('runs mkdir, chmod, writeFile, chmod +x, execStream (sudo script), rm -rf', async () => {
78
+ const tmpDir = '/tmp/aifabrix-init-123';
79
+ const scriptContent = '#!/bin/sh\necho ok\n';
80
+ await runBootstrap(mockConn, scriptContent, tmpDir);
81
+ expect(mockExec).toHaveBeenCalledTimes(4);
82
+ expect(mockExec).toHaveBeenNthCalledWith(1, mockConn, `mkdir -p ${tmpDir}`);
83
+ expect(mockExec).toHaveBeenNthCalledWith(2, mockConn, `chmod 755 ${tmpDir}`);
84
+ expect(mockExec).toHaveBeenNthCalledWith(3, mockConn, `chmod +x ${tmpDir}/setup-install-init.sh`);
85
+ expect(mockExec).toHaveBeenNthCalledWith(4, mockConn, `rm -rf ${tmpDir}`);
86
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
87
+ expect(mockWriteFile).toHaveBeenCalledWith(mockConn, `${tmpDir}/setup-install-init.sh`, scriptContent);
88
+ expect(mockExecStream).toHaveBeenCalledTimes(1);
89
+ expect(mockExecStream).toHaveBeenCalledWith(mockConn, `sudo ${tmpDir}/setup-install-init.sh`);
90
+ });
91
+ it('throws when execStream returns non-zero code', async () => {
92
+ mockExecStream.mockResolvedValueOnce({ code: 1 });
93
+ await expect(runBootstrap(mockConn, '#!/bin/sh\nexit 1\n', '/tmp/test')).rejects.toThrow('install-init script exited with code 1');
94
+ });
95
+ });
96
+ describe('runInstallInit (install-init.ts)', () => {
97
+ beforeEach(() => {
98
+ mockCreateSSHClient.mockResolvedValue(mockConn);
99
+ jest.spyOn(console, 'error').mockImplementation(() => { });
100
+ });
101
+ afterEach(() => {
102
+ console.error.mockRestore();
103
+ });
104
+ it('connects, uploads script, runs bootstrap via sudo, then closes', async () => {
105
+ await runInstallInit({ target: 'user@host' }, ASSETS_DIR);
106
+ expect(mockCreateSSHClient).toHaveBeenCalledWith({ target: 'user@host' });
107
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
108
+ expect(mockWriteFile).toHaveBeenCalledWith(mockConn, expect.stringMatching(/\/setup-install-init\.sh$/), expect.any(String));
109
+ expect(mockExecStream).toHaveBeenCalledWith(mockConn, expect.stringContaining('sudo'));
110
+ });
111
+ it('throws when bootstrap script exits non-zero', async () => {
112
+ mockExecStream.mockResolvedValueOnce({ code: 1 });
113
+ await expect(runInstallInit({ target: 'user@host' }, ASSETS_DIR)).rejects.toThrow('install-init script exited with code 1');
114
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
115
+ });
8
116
  });
9
117
  });
10
- export {};
package/dist/ssh.d.ts CHANGED
@@ -20,6 +20,13 @@ export declare function exec(conn: Client, command: string): Promise<{
20
20
  stderr: string;
21
21
  code: number | null;
22
22
  }>;
23
+ /**
24
+ * Run a remote command and stream stdout/stderr to the current process so the user sees progress in real time.
25
+ * Resolves with the exit code when the command finishes.
26
+ */
27
+ export declare function execStream(conn: Client, command: string): Promise<{
28
+ code: number | null;
29
+ }>;
23
30
  /** Get remote user home directory (e.g. for ~/.ssh). Uses exec so paths work with SFTP. */
24
31
  export declare function getRemoteHome(conn: Client): Promise<string>;
25
32
  export declare function readFile(conn: Client, remotePath: string): Promise<Buffer>;
package/dist/ssh.js CHANGED
@@ -149,6 +149,23 @@ export function exec(conn, command) {
149
149
  });
150
150
  });
151
151
  }
152
+ /**
153
+ * Run a remote command and stream stdout/stderr to the current process so the user sees progress in real time.
154
+ * Resolves with the exit code when the command finishes.
155
+ */
156
+ export function execStream(conn, command) {
157
+ return new Promise((resolve, reject) => {
158
+ conn.exec(command, (err, stream) => {
159
+ if (err) {
160
+ reject(err);
161
+ return;
162
+ }
163
+ stream.on('data', (data) => process.stdout.write(data));
164
+ stream.stderr.on('data', (data) => process.stderr.write(data));
165
+ stream.on('close', (code) => resolve({ code: code ?? null }));
166
+ });
167
+ });
168
+ }
152
169
  /** Get remote user home directory (e.g. for ~/.ssh). Uses exec so paths work with SFTP. */
153
170
  export async function getRemoteHome(conn) {
154
171
  const result = await exec(conn, 'echo $HOME');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/server-setup",
3
- "version": "1.5.2",
3
+ "version": "1.5.5",
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",
@@ -21,12 +21,15 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "archiver": "^7.0.1",
24
- "better-sqlite3": "^11.6.0",
24
+ "better-sqlite3": "^12.0.0",
25
25
  "commander": "^12.1.0",
26
26
  "extract-zip": "^2.0.1",
27
27
  "read": "^1.0.7",
28
28
  "ssh2": "^1.15.0"
29
29
  },
30
+ "overrides": {
31
+ "glob": "^13.0.0"
32
+ },
30
33
  "devDependencies": {
31
34
  "@eslint/js": "^9.17.0",
32
35
  "@types/archiver": "^6.0.2",