@aifabrix/server-setup 1.1.0 → 1.3.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
@@ -1,68 +1,131 @@
1
- # @aifabrix/server-setup (af-server)
1
+ # AI Fabrix Builder Server Setup (af-server)
2
2
 
3
- CLI to install, backup, and restore AI Fabrix builder-server over SSH. Runs on your PC or CI; the server does **not** need Node.js or the aifabrix Builder.
3
+ This is the **one document** you need to get your builder-server **from zero to up and running** in your own Docker. How to use the server (users, secrets, onboarding, etc.) is in the **AI Fabrix Builder CLI** documentation—full open source: [github.com/esystemsdev/aifabrix-builder](https://github.com/esystemsdev/aifabrix-builder).
4
4
 
5
- - **Backup** = configuration + database only (no workspace or developer code).
6
- - **Restore** = push backup zip (builder.db + keys) back to server DATA_DIR.
5
+ ---
7
6
 
8
- ## Commands
7
+ ## Why AI Fabrix Builder server?
9
8
 
10
- Omit `user@host` to run **locally** on the current machine (Ubuntu only). With `user@host`, commands run over SSH.
9
+ - **Full AI Fabrix platform, one server, multi-developer:** You run the full AI Fabrix platform and a **separate builder-server** where multiple developers each get their **own dedicated, isolated environment** (users, certificates, secrets, workspaces). One installer (af-server) sets up Docker, nginx, SSL proxy, sync user, and backup on that server.
10
+ - **One tool for install and management:** The same **AI Fabrix Builder** CLI gets you the platform and manages users, secrets, and onboarding. All how-to in [Builder CLI docs](https://github.com/esystemsdev/aifabrix-builder).
11
+ - **Security and standards:** TLS, client certificates, no secrets in version control. Backups are explicit (config + DB + keys); store them encrypted.
11
12
 
12
- ```bash
13
- # Install server (Docker, nginx, SSL proxy, sync user, cron).
14
- af-server install [ user@host ] [ -d DATA_DIR ] [ --dev-domain DOMAIN ] [ --ssl-dir PATH ] [ -i SSH_KEY ]
13
+ ---
15
14
 
16
- # On-demand backup: fetch config + DB + keys from server (or local DATA_DIR), save zip.
17
- af-server backup [ user@host ] [ -d DATA_DIR ] [ -o output.zip ] [ -i SSH_KEY ]
15
+ ## Getting the builder-server Docker image
18
16
 
19
- # Install cron backup (daily 02:00, keep last 7).
20
- af-server backup [ user@host ] --schedule [ --backup-dir PATH ] [ --keep-days N ] [ -i SSH_KEY ]
17
+ **The builder-server Docker image is not on a public registry.** You get it with the **AI Fabrix platform**.
21
18
 
22
- # Restore backup zip to DATA_DIR.
23
- af-server restore backup.zip [ user@host ] [ -d DATA_DIR ] [ --force ] [ -i SSH_KEY ]
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).
24
21
 
25
- # ssh-cert: install = append your local SSH public key for passwordless auth; request = stub for future SSH CA.
26
- af-server ssh-cert request [ --user ID ] [ -i SSH_KEY ]
27
- af-server ssh-cert install [ user@host ] [ -i SSH_KEY ]
28
- ```
22
+ Once you have the platform (and the builder-server image), use **af-server** to install that server on your own host.
29
23
 
30
- **ssh-cert install:** Appends your **local SSH public key** (from `~/.ssh/id_ed25519.pub` or `id_rsa.pub`, or from `-i key` → `key.pub`) to the server user's `~/.ssh/authorized_keys`. After that, install/backup/restore work without password when using that key or ssh-agent. First run may use password or `-i`. **request** remains a stub for future SSH CA integration.
24
+ ---
31
25
 
32
26
  ## Prerequisites
33
27
 
34
- - Node.js >= 18
35
- - SSH access to the server (key-based auth recommended; use `-i path/to/key`)
28
+ - **Node.js 18** (where you run af-server)
29
+ - **SSH access** to the server (key-based auth). af-server uses `~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub` by default; use `-i path/to/key` if your key is elsewhere.
30
+ - **Server:** Ubuntu. For remote install the script can install Docker; you provide domain, SSL directory, and certificates (next section).
36
31
 
37
- The server should have **builder-server** data in either:
32
+ ---
38
33
 
39
- - **SQLite** (`DATA_DIR/builder.db`) — after JSON→SQLite migration (see repo plan 006); backup/restore and cron backup use this.
40
- - **JSON files** — legacy; on-demand backup still works (exports to SQLite inside the zip).
34
+ ## Manual prerequisites (before install)
41
35
 
42
- ## Backup contents
36
+ Do the steps in [What you must do before running af-server](#what-you-must-do-before-running-af-server) (below): Ubuntu version, root/sudo, SSH, DNS (CNAME or A), SSL directory, and certificate + key. The install script does **not** create or obtain certificates; it configures nginx to use the files you provide.
43
37
 
44
- - `config.json` — DATA_DIR, createdAt, source (builder.db or json)
45
- - `backup.db` — SQLite (either copy of builder.db or export from JSON)
46
- - `ca.crt`, `ca.key`, `secrets-encryption.key` — for restore
38
+ ---
47
39
 
48
- **Security:** Backups contain secrets. Store them encrypted at rest and restrict filesystem access. Never log key material.
40
+ ## Install: from zero to running
49
41
 
50
- ## Cron backup (--schedule)
42
+ **From your PC** (SSH to server):
51
43
 
52
- Installs a shell script on the server that runs daily at 02:00. Requires `builder.db` (SQLite) and `zip` on the server. Keeps the last N backups (default 7). Backup dir default: `/opt/aifabrix/backups`.
44
+ ```bash
45
+ af-server install user@host
46
+ ```
53
47
 
54
- ## Build and run locally
48
+ **On the server itself** (Ubuntu, no target):
55
49
 
56
50
  ```bash
57
- cd server-setup
58
- npm install
59
- npm run build
60
- node dist/cli.js --help
61
- # or: npm link && af-server --help
51
+ af-server install
62
52
  ```
63
53
 
64
- ## References
54
+ 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).
55
+
56
+ ---
57
+
58
+ ## Commands
59
+
60
+ | Command | Description |
61
+ | -------- | ----------- |
62
+ | `af-server install [ user@host ] [ -d DATA_DIR ] [ --dev-domain DOMAIN ] [ --ssl-dir PATH ] [ -i SSH_KEY ]` | Install or update: Docker, nginx (builder vhost always updated from template), SSL proxy, sync user, cron. Re-run to apply latest config. |
63
+ | `af-server backup [ user@host ] [ -d DATA_DIR ] [ -o output.zip ] [ -i SSH_KEY ]` | On-demand backup (config + DB + keys). |
64
+ | `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
+ | `af-server restore backup.zip [ user@host ] [ -d DATA_DIR ] [ --force ] [ -i SSH_KEY ]` | Restore backup to DATA_DIR. |
66
+ | `af-server ssh-cert install [ user@host ] [ -i SSH_KEY ]` | Add your SSH public key to server (passwordless auth). |
67
+
68
+ Backups contain secrets; store encrypted. Cron backup needs SQLite (`builder.db`) and `zip` on the server; default backup dir: `/opt/aifabrix/backups`.
69
+
70
+ ---
71
+
72
+ ## Documentation
73
+
74
+ All **how to use** the builder-server (users, secrets, onboarding, developer isolation, etc.) is in the open-source **AI Fabrix Builder** docs:
75
+
76
+ - [AI Fabrix Builder](https://github.com/esystemsdev/aifabrix-builder) — repo and README
77
+ - [docs/README.md](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/README.md) — full documentation index
78
+
79
+ This README is only: **zero to up and running your builder server**.
80
+
81
+ ---
82
+
83
+ ## What you must do before running af-server
84
+
85
+ Complete these **manually** before `af-server install`. The install script does not create DNS, certificates, or the admin account; it expects them to exist.
86
+
87
+ **Server and access**
88
+
89
+ - **Ubuntu** — 22.04 LTS or later (recommended). Other Debian-based systems may work but are not guaranteed.
90
+ - **Root or sudo** — The install script must run with root (or sudo). From your PC you SSH as a user that can `sudo` to root; on the server you run `sudo af-server install` (or as root).
91
+ - **SSH access** — You need key-based or password SSH to the server so you can run `af-server install user@host`, or you run install locally on the server.
92
+
93
+ **DNS (CNAME or A record)**
94
+
95
+ - Create a **DNS A record** or **CNAME** so your chosen domain points to the server’s IP.
96
+ - Default domain used by the script: `builder01.aifabrix.dev`. To use another domain, set `--dev-domain` or env `DEV_DOMAIN`.
97
+ - Ensure DNS has propagated before install (nginx will use this domain).
98
+
99
+ **SSL directory and certificates**
100
+
101
+ - Create the SSL directory (default `/opt/aifabrix/ssl`):
102
+ `sudo mkdir -p /opt/aifabrix/ssl`
103
+ To use another path, set `--ssl-dir` or env `SSL_DIR`.
104
+ - **Obtain** a TLS certificate and private key (e.g. Let’s Encrypt, internal CA, or purchased). Full chain if applicable.
105
+ - **Place** in the SSL directory:
106
+ - `wildcard.crt` — certificate (full chain if applicable)
107
+ - `wildcard.key` — private key
108
+ Set permissions: `chmod 600 /opt/aifabrix/ssl/wildcard.key`
109
+ If your files have other names (e.g. `fullchain.pem` / `privkey.pem`), symlink them to `wildcard.crt` and `wildcard.key`.
110
+ - The install script **does not** create or obtain certificates; it only configures nginx to use these files.
111
+
112
+ **Admin user (optional but typical)**
113
+ If you SSH as a non-root user, that user must be able to sudo. The script will add an admin user (default `serveradmin`) to the `docker` group and grant it passwordless sudo. Ensure that user exists on the server if you rely on it, or set `SETUP_ADMIN_USER` to your SSH user.
114
+
115
+ ---
116
+
117
+ ## High level: what the install does on your Ubuntu server
118
+
119
+ When you run `af-server install`, the script (as root) does the following on the server:
120
+
121
+ - **System** — `apt update` and `apt upgrade`; optional hostname change if `SETUP_HOSTNAME` is set.
122
+ - **Docker** — Installs Docker if missing; enables and starts the Docker service.
123
+ - **Admin user** — Adds the admin user (default `serveradmin`) to the `docker` group and grants passwordless sudo (`/etc/sudoers.d/<admin>`).
124
+ - **Nginx** — Installs nginx if missing; enables and starts it. Generates a site config for your domain that proxies HTTPS to the builder-server container (using `wildcard.crt` and `wildcard.key` from your SSL dir).
125
+ - **Builder-server data dir** — Creates the data directory (default `/opt/aifabrix/builder-server/data`), sets ownership for the container. Starts the `builder-server` container if the image already exists on the server; otherwise prints how to build and run it (you get the image from the AI Fabrix platform).
126
+ - **Sync user** — Creates a system user (default `aifabrix-sync`) for Mutagen SSH sync; home under the data dir; creates `.ssh` and `authorized_keys`.
127
+ - **Cron job** — Installs a cron job (every 2 minutes) that copies the managed `authorized_keys` file into the sync user’s `.ssh/authorized_keys`.
128
+ - **Mutagen** — Downloads and installs the Mutagen binary; creates a systemd service and starts the daemon.
129
+ - **Optional** — If `INSTALL_PORTAINER=1`, installs the Portainer container. If Docker TLS is not skipped, writes `/etc/docker/daemon.json`; you can use the **same certificate** from `/opt/aifabrix/ssl` (e.g. symlink or copy `wildcard.crt` and `wildcard.key` to the paths Docker expects: `/etc/docker/server-cert.pem`, `/etc/docker/server-key.pem`, and if needed `ca.pem` for the CA), or provide separate Docker TLS certs.
65
130
 
66
- - [SETUP.md](../SETUP.md) server installation and manual prerequisites
67
- - [builder/builder-server/README.md](../builder/builder-server/README.md) — builder-server data dir and mounts
68
- - [AI Fabrix Builder CLI](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/CLI-REFERENCE.md) — for onboarding and certs (talks to builder-server API)
131
+ Result: your Ubuntu server has Docker, nginx (HTTPS for your domain), the builder-server container (if image present), the sync user and key sync, and Mutagen—ready for the Builder CLI to use.
@@ -1,8 +1,8 @@
1
1
  # Nginx snippet for builder-server onboarding API (https://DEV_DOMAIN_PLACEHOLDER).
2
2
  # Generated from template by af-server install (substitutes DEV_DOMAIN, SSL_DIR, BUILDER_SERVER_PORT, DATA_DIR).
3
3
  # SSL cert and key from SSL_DIR_PLACEHOLDER (wildcard.crt, wildcard.key).
4
- # Client cert: ssl_client_certificate uses DATA_DIR_PLACEHOLDER/ca.crt (Builder CA; created by builder-server on first run).
5
- # Reload nginx after placing: sudo nginx -t && sudo systemctl reload nginx.
4
+ # Client cert: ssl_client_certificate uses DATA_DIR_PLACEHOLDER/ca.crt (Builder CA).
5
+ # Client sends X-Client-Cert as base64-encoded PEM; nginx forwards it to the backend.
6
6
 
7
7
  server {
8
8
  listen 443 ssl;
@@ -21,6 +21,6 @@ server {
21
21
  proxy_set_header X-Real-IP $remote_addr;
22
22
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23
23
  proxy_set_header X-Forwarded-Proto $scheme;
24
- proxy_set_header X-Client-Cert $ssl_client_cert;
24
+ proxy_set_header X-Client-Cert $http_x_client_cert;
25
25
  }
26
26
  }
@@ -1,6 +1,7 @@
1
1
  #!/bin/sh
2
2
  # Idempotent dev server setup script (no Node/Builder on server). Safe to run multiple times.
3
3
  # Run via af-server install user@host. REPO_ROOT must be set to the dir containing builder/builder-server/nginx-builder-server.conf.template.
4
+ # On empty server: installs Docker, nginx, admin user, optional Portainer/Mutagen; then updates nginx config and ensures builder-server container.
4
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.
5
6
 
6
7
  set -e
@@ -101,21 +102,24 @@ if ! command -v nginx >/dev/null 2>&1; then
101
102
  systemctl enable nginx
102
103
  systemctl start nginx
103
104
  fi
104
-
105
105
  NGINX_CONF="$NGINX_CONF_DIR/$DEV_DOMAIN.conf"
106
106
  NGINX_TEMPLATE="$REPO_ROOT/builder/builder-server/nginx-builder-server.conf.template"
107
- if [ ! -f "$NGINX_CONF" ] && [ -f "$NGINX_TEMPLATE" ]; then
107
+ # Always update builder vhost from template so re-running install applies latest config.
108
+ if [ -f "$NGINX_TEMPLATE" ]; then
108
109
  sed -e "s|DEV_DOMAIN_PLACEHOLDER|$DEV_DOMAIN|g" \
109
110
  -e "s|SSL_DIR_PLACEHOLDER|$SSL_DIR|g" \
110
111
  -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
111
112
  -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
112
113
  "$NGINX_TEMPLATE" > "$NGINX_CONF"
113
- if nginx -t 2>/dev/null; then
114
- systemctl reload nginx
115
- fi
116
114
  elif [ ! -f "$NGINX_CONF" ]; then
117
115
  echo "Warning: SSL prereqs required. Place $NGINX_CONF (see SETUP.md) and ensure $SSL_DIR/wildcard.crt and $SSL_DIR/wildcard.key exist."
118
116
  fi
117
+ if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
118
+ systemctl reload nginx
119
+ elif [ -f "$NGINX_TEMPLATE" ] && command -v nginx >/dev/null 2>&1; then
120
+ echo "Warning: nginx -t failed (config was still written to $NGINX_CONF). Nginx was NOT reloaded."
121
+ nginx -t 2>&1 || true
122
+ fi
119
123
 
120
124
  # --- Mutagen ---
121
125
  if ! command -v mutagen >/dev/null 2>&1; then
@@ -166,15 +170,60 @@ DOCKER_EOF
166
170
  fi
167
171
 
168
172
  # --- Builder-server data dir and container ---
173
+ # 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
+ # Nginx uses DATA_DIR/ca.crt for ssl_client_certificate; container must use the same host path so CA matches.
175
+ CONTAINER_DATA_PATH="/mnt/data"
176
+ BUILDER_IMAGE="aifabrix/builder-server:latest"
169
177
  mkdir -p "$DATA_DIR"
170
- chown 1001:65533 "$DATA_DIR"
178
+ chown -R 1001:65533 "$DATA_DIR"
179
+ chmod 755 "$DATA_DIR"
180
+ DATA_DIR_ABS=$(cd "$DATA_DIR" && pwd)
171
181
  if command -v docker >/dev/null 2>&1; then
172
- if ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^builder-server$'; then
173
- echo "Builder-server container not found. Build and run manually (see builder/builder-server/README.md):"
174
- echo " docker build -t builder-server:latest -f builder/builder-server/Dockerfile ."
175
- echo " docker run -d --name builder-server --restart unless-stopped -p ${BUILDER_SERVER_PORT}:3000 -v $DATA_DIR:/data -e PORT=3000 builder-server:latest"
182
+ CONTAINER_NAME=""
183
+ for n in builder-server aifabrix-builder-server; do
184
+ if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${n}$"; then
185
+ CONTAINER_NAME="$n"
186
+ break
187
+ fi
188
+ done
189
+ if [ -n "$CONTAINER_NAME" ]; then
190
+ # Ensure container uses DATA_DIR as bind mount so nginx and app share same ca.crt (check /mnt/data first, then /data)
191
+ DATA_MOUNT_SOURCE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/mnt/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER_NAME" 2>/dev/null)
192
+ if [ -z "$DATA_MOUNT_SOURCE" ]; then
193
+ DATA_MOUNT_SOURCE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER_NAME" 2>/dev/null)
194
+ fi
195
+ MOUNT_SOURCE_ABS=""
196
+ if [ -n "$DATA_MOUNT_SOURCE" ] && [ -d "$DATA_MOUNT_SOURCE" ]; then
197
+ MOUNT_SOURCE_ABS=$(cd "$DATA_MOUNT_SOURCE" && pwd)
198
+ fi
199
+ if [ "$MOUNT_SOURCE_ABS" != "$DATA_DIR_ABS" ]; then
200
+ echo "Recreating builder-server container to use DATA_DIR bind mount ($DATA_DIR_ABS -> $CONTAINER_DATA_PATH) so nginx and container share the same CA."
201
+ BUILDER_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null) || true
202
+ [ -z "$BUILDER_IMAGE" ] && BUILDER_IMAGE="aifabrix/builder-server:latest"
203
+ docker stop "$CONTAINER_NAME" 2>/dev/null || true
204
+ docker rm "$CONTAINER_NAME" 2>/dev/null || true
205
+ CONTAINER_NAME=""
206
+ fi
207
+ fi
208
+ if [ -z "$CONTAINER_NAME" ]; then
209
+ IMG_TO_USE="$BUILDER_IMAGE"
210
+ if ! docker images -q "$IMG_TO_USE" 2>/dev/null | grep -q .; then
211
+ IMG_TO_USE="builder-server:latest"
212
+ fi
213
+ if docker images -q "$IMG_TO_USE" 2>/dev/null | grep -q .; then
214
+ docker run -d --name aifabrix-builder-server --restart unless-stopped \
215
+ -p "${BUILDER_SERVER_PORT}:3000" \
216
+ -v "$DATA_DIR_ABS:$CONTAINER_DATA_PATH" \
217
+ -e PORT=3000 \
218
+ -e DATA_DIR="$CONTAINER_DATA_PATH" \
219
+ -e ENCRYPTION_KEY_PATH="${CONTAINER_DATA_PATH}/secrets-encryption.key" \
220
+ "$IMG_TO_USE"
221
+ else
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."
223
+ echo "After the image is on this host, re-run af-server install. Install will start the container with: -v $DATA_DIR_ABS:$CONTAINER_DATA_PATH and DATA_DIR=$CONTAINER_DATA_PATH."
224
+ fi
176
225
  else
177
- docker start builder-server 2>/dev/null || true
226
+ docker start "$CONTAINER_NAME" 2>/dev/null || true
178
227
  fi
179
228
  fi
180
229
 
@@ -77,6 +77,14 @@ describe('backup-db', () => {
77
77
  expect(row).toEqual({ id: '03', name: 'C' });
78
78
  db.close();
79
79
  });
80
+ it('imports no users when users.json is empty object (no .users key, not array)', () => {
81
+ const outPath = path.join(tmpDir, 'empty-users.db');
82
+ createBackupDbFromJson(outPath, { users: '{}' }, config);
83
+ const db = new Database(outPath, { readonly: true });
84
+ const count = db.prepare('SELECT COUNT(*) as n FROM users').get();
85
+ expect(count.n).toBe(0);
86
+ db.close();
87
+ });
80
88
  it('imports pins from tokens.json shape', () => {
81
89
  const outPath = path.join(tmpDir, 'out.db');
82
90
  const tokensJson = JSON.stringify({
@@ -129,6 +137,14 @@ describe('backup-db', () => {
129
137
  expect(row.user_id).toBe('03');
130
138
  db.close();
131
139
  });
140
+ it('imports no pins when tokens.json is empty object (no .pins key, not array)', () => {
141
+ const outPath = path.join(tmpDir, 'empty-pins.db');
142
+ createBackupDbFromJson(outPath, { tokens: '{}' }, config);
143
+ const db = new Database(outPath, { readonly: true });
144
+ const count = db.prepare('SELECT COUNT(*) as n FROM pin_tokens').get();
145
+ expect(count.n).toBe(0);
146
+ db.close();
147
+ });
132
148
  it('imports secrets from secrets.json shape (camelCase)', () => {
133
149
  const outPath = path.join(tmpDir, 'out.db');
134
150
  const secretsJson = JSON.stringify({
@@ -159,6 +175,21 @@ describe('backup-db', () => {
159
175
  expect(row.auth_tag).toBe('tag2');
160
176
  db.close();
161
177
  });
178
+ it('skips secret entries with null or invalid shape', () => {
179
+ const outPath = path.join(tmpDir, 'secrets-skip.db');
180
+ const secretsJson = JSON.stringify({
181
+ secrets: {
182
+ valid: { iv: 'i', auth_tag: 't', cipher: 'c' },
183
+ nullEntry: null,
184
+ invalid: { iv: 'i' },
185
+ },
186
+ });
187
+ createBackupDbFromJson(outPath, { secrets: secretsJson }, config);
188
+ const db = new Database(outPath, { readonly: true });
189
+ const rows = db.prepare('SELECT key FROM secrets').all();
190
+ expect(rows.map((r) => r.key)).toEqual(['valid']);
191
+ db.close();
192
+ });
162
193
  it('imports ssh_keys from byUser shape', () => {
163
194
  const outPath = path.join(tmpDir, 'out.db');
164
195
  const sshKeysJson = JSON.stringify({
@@ -213,6 +244,20 @@ describe('backup-db', () => {
213
244
  expect(count.n).toBe(0);
214
245
  db.close();
215
246
  });
247
+ it('imports ssh_keys with createdAt (camelCase) only', () => {
248
+ const outPath = path.join(tmpDir, 'ssh-createdAt.db');
249
+ const sshKeysJson = JSON.stringify({
250
+ byUser: {
251
+ '01': [{ publicKey: 'ssh-rsa AAAA', label: 'l', fingerprint: 'f', createdAt: '2025-06-01T00:00:00Z' }],
252
+ },
253
+ });
254
+ createBackupDbFromJson(outPath, { sshKeys: sshKeysJson }, config);
255
+ const db = new Database(outPath, { readonly: true });
256
+ const row = db.prepare('SELECT user_id, created_at FROM ssh_keys').get();
257
+ expect(row.user_id).toBe('01');
258
+ expect(row.created_at).toBe('2025-06-01T00:00:00Z');
259
+ db.close();
260
+ });
216
261
  });
217
262
  describe('copyBuilderDbAsBackup', () => {
218
263
  it('copies users and pin_tokens from source db to backup', () => {
@@ -183,6 +183,23 @@ describe('runBackupLocal', () => {
183
183
  const config = JSON.parse(fs.readFileSync(path.join(tmpExtract, CONFIG_JSON), 'utf8'));
184
184
  expect(config.source).toBe('json');
185
185
  });
186
+ it('uses fallback JSON when a local JSON file is missing (catch path)', async () => {
187
+ fs.writeFileSync(path.join(dataDir, 'users.json'), '{"users":[]}');
188
+ fs.writeFileSync(path.join(dataDir, 'tokens.json'), '{"pins":[]}');
189
+ // omit secrets.json and ssh-public-keys.json so readFileSync throws and fallback is used
190
+ const outPath = path.join(dataDir, 'fallback.zip');
191
+ const result = await runBackupLocal({ dataDir, outputPath: outPath });
192
+ expect(fs.existsSync(result)).toBe(true);
193
+ const tmpExtract = path.join(dataDir, 'extract-fallback');
194
+ fs.mkdirSync(tmpExtract, { recursive: true });
195
+ await extract(result, { dir: tmpExtract });
196
+ const db = new Database(path.join(tmpExtract, BACKUP_DB), { readonly: true });
197
+ const userCount = db.prepare('SELECT COUNT(*) as n FROM users').get();
198
+ const secretCount = db.prepare('SELECT COUNT(*) as n FROM secrets').get();
199
+ expect(userCount.n).toBe(0);
200
+ expect(secretCount.n).toBe(0);
201
+ db.close();
202
+ });
186
203
  it('includes key files when present in dataDir', async () => {
187
204
  const dbPath = path.join(dataDir, BUILDER_DB);
188
205
  const db = new Database(dbPath);
package/dist/install.js CHANGED
@@ -65,7 +65,7 @@ export function runInstallLocal(options = {}) {
65
65
  try {
66
66
  fs.writeFileSync(path.join(tmpDir, 'setup.sh'), getSetupScript(), { mode: 0o755 });
67
67
  fs.writeFileSync(path.join(builderSubdir, 'nginx-builder-server.conf.template'), getNginxTemplate());
68
- const env = `REPO_ROOT=${tmpDir} DATA_DIR=${dataDir} DEV_DOMAIN=${devDomain} SSL_DIR=${sslDir} BUILDER_SERVER_PORT=${builderServerPort}`;
68
+ const env = [`REPO_ROOT=${tmpDir}`, `DATA_DIR=${dataDir}`, `DEV_DOMAIN=${devDomain}`, `SSL_DIR=${sslDir}`, `BUILDER_SERVER_PORT=${builderServerPort}`].join(' ');
69
69
  execSync(`sudo ${env} ${tmpDir}/setup.sh`, { stdio: 'inherit' });
70
70
  }
71
71
  finally {
package/dist/restore.js CHANGED
@@ -45,7 +45,7 @@ export async function runRestore(options) {
45
45
  await exec(conn, `chown 1001:65533 ${dataDir}/${k}`);
46
46
  }
47
47
  }
48
- const restart = await exec(conn, 'docker restart builder-server 2>/dev/null || true');
48
+ const restart = await exec(conn, 'docker restart builder-server 2>/dev/null; docker restart aifabrix-builder-server 2>/dev/null || true');
49
49
  if (restart.stderr && !restart.stderr.includes('No such container')) {
50
50
  process.stderr.write(restart.stderr);
51
51
  }
@@ -89,7 +89,7 @@ export async function runRestoreLocal(options) {
89
89
  }
90
90
  }
91
91
  try {
92
- execSync('docker restart builder-server 2>/dev/null || true', { stdio: 'inherit' });
92
+ execSync('docker restart builder-server 2>/dev/null; docker restart aifabrix-builder-server 2>/dev/null || true', { stdio: 'inherit' });
93
93
  }
94
94
  catch {
95
95
  // container may not exist
@@ -212,4 +212,36 @@ describe('runRestoreLocal', () => {
212
212
  fs.writeFileSync(path.join(dataDir, BUILDER_DB), 'existing');
213
213
  await expect(runRestoreLocal({ zipPath, dataDir })).rejects.toThrow('DATA_DIR already has builder.db');
214
214
  });
215
+ it('uses config.dataDir from zip when options.dataDir not provided', async () => {
216
+ const zipContentDir = path.join(workDir, 'content');
217
+ fs.mkdirSync(zipContentDir, { recursive: true });
218
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
219
+ const db = new Database(dbPath);
220
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY); INSERT INTO users (id) VALUES (\'1\');');
221
+ db.close();
222
+ const configFromZip = { dataDir: path.join(workDir, 'restore-from-config'), createdAt: new Date().toISOString(), source: 'builder.db' };
223
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify(configFromZip));
224
+ for (const k of KEY_FILES) {
225
+ fs.writeFileSync(path.join(zipContentDir, k), `content-${k}`);
226
+ }
227
+ await new Promise((resolve, reject) => {
228
+ const archive = archiver('zip', { zlib: { level: 6 } });
229
+ const out = fs.createWriteStream(zipPath);
230
+ out.on('close', () => resolve());
231
+ archive.on('error', reject);
232
+ archive.pipe(out);
233
+ archive.file(dbPath, { name: BACKUP_DB });
234
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
235
+ for (const k of KEY_FILES) {
236
+ archive.file(path.join(zipContentDir, k), { name: k });
237
+ }
238
+ archive.finalize();
239
+ });
240
+ await runRestoreLocal({ zipPath, force: true });
241
+ expect(fs.existsSync(path.join(configFromZip.dataDir, BUILDER_DB))).toBe(true);
242
+ const restoredDb = new Database(path.join(configFromZip.dataDir, BUILDER_DB), { readonly: true });
243
+ const user = restoredDb.prepare('SELECT id FROM users').get();
244
+ expect(user.id).toBe('1');
245
+ restoredDb.close();
246
+ });
215
247
  });
@@ -64,6 +64,13 @@ describe('ssh-cert', () => {
64
64
  await expect(runSshCertInstall({ target: 'user@host' })).rejects.toThrow('Could not read authorized_keys');
65
65
  expect(mockClose).toHaveBeenCalledWith(mockConn);
66
66
  });
67
+ it('appends newline before new key when existing content does not end with newline', async () => {
68
+ mockReadFile.mockResolvedValueOnce(Buffer.from('existing key line', 'utf8'));
69
+ await runSshCertInstall({ target: 'user@host' });
70
+ const written = mockWriteFile.mock.calls[0][2].toString('utf8');
71
+ expect(written).toMatch(/existing key line\n.*# af-server/);
72
+ expect(written).toContain(KEY_LINE);
73
+ });
67
74
  });
68
75
  describe('runSshCertInstallLocal', () => {
69
76
  let fakeHome;
@@ -97,5 +104,15 @@ describe('ssh-cert', () => {
97
104
  expect(afterSecond).toBe(afterFirst);
98
105
  expect(console.log).toHaveBeenCalledWith('Key already installed.');
99
106
  });
107
+ it('appends newline before new key when authorized_keys exists and does not end with newline', () => {
108
+ const sshDir = path.join(fakeHome, '.ssh');
109
+ fs.mkdirSync(sshDir, { recursive: true });
110
+ const authKeysPath = path.join(sshDir, 'authorized_keys');
111
+ fs.writeFileSync(authKeysPath, 'existing key line', { mode: 0o600 });
112
+ runSshCertInstallLocal(undefined, { homedir: fakeHome });
113
+ const content = fs.readFileSync(authKeysPath, 'utf8');
114
+ expect(content).toMatch(/existing key line\n.*# af-server/);
115
+ expect(content).toContain(KEY_LINE);
116
+ });
100
117
  });
101
118
  });
@@ -53,4 +53,13 @@ describe('ubuntu', () => {
53
53
  Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
54
54
  expect(() => requireUbuntu({ osReleasePath: path.join(TEST_TMP, 'nonexistent') })).toThrow(/Local mode is supported only on Ubuntu/);
55
55
  });
56
+ it('uses default os-release path when options is empty object', () => {
57
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
58
+ try {
59
+ requireUbuntu({});
60
+ }
61
+ catch (e) {
62
+ expect(e.message).toMatch(/Local mode is supported only on Ubuntu/);
63
+ }
64
+ });
56
65
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/server-setup",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",