@aifabrix/server-setup 1.5.3 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/assets/setup-dev-server-no-node.sh +17 -0
- package/assets/setup-install-init.sh +29 -8
- package/dist/cli.js +2 -1
- package/dist/install-init-core.d.ts +11 -0
- package/dist/install-init-core.js +29 -0
- package/dist/install-init-paths.d.ts +4 -0
- package/dist/install-init-paths.js +9 -0
- package/dist/install-init.d.ts +2 -2
- package/dist/install-init.js +11 -30
- package/dist/install-init.spec.d.ts +3 -0
- package/dist/install-init.spec.js +113 -6
- package/dist/ssh.d.ts +7 -0
- package/dist/ssh.js +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Complete the [manual prerequisites](#what-you-must-do-before-running-af-server)
|
|
|
48
48
|
|
|
49
49
|
| Step | Where | Action |
|
|
50
50
|
| ---- | --------- | ------ |
|
|
51
|
-
| 1 | From PC | `af-server install-init $SSH` — only command over SSH; installs on server: SSH (if needed), Node
|
|
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. |
|
|
52
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`. |
|
|
53
53
|
| 3 | From PC | Copy SSL certificate and key to the server (see [SSL directory and certificates](#ssl-directory-and-certificates)); example commands below. |
|
|
54
54
|
| 4 | From PC | Log in to the server via SSH (passwordless). |
|
|
@@ -63,11 +63,10 @@ Set your target and run the only command that uses SSH from your PC:
|
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
65
|
export SSH=serveradmin@builder02.aifabrix.dev
|
|
66
|
-
export DOMAIN=builder02.aifabrix.dev
|
|
67
66
|
af-server install-init $SSH
|
|
68
67
|
```
|
|
69
68
|
|
|
70
|
-
This installs on the server: openssh-server (if needed), Node
|
|
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.
|
|
71
70
|
|
|
72
71
|
### Step 2: Passwordless SSH (from PC)
|
|
73
72
|
|
|
@@ -109,8 +108,8 @@ The image is not on a public registry. Use your platform’s method (e.g. Azure
|
|
|
109
108
|
|
|
110
109
|
```bash
|
|
111
110
|
az login
|
|
112
|
-
az acr login --name
|
|
113
|
-
docker pull
|
|
111
|
+
az acr login --name youracr
|
|
112
|
+
docker pull youracr.azurecr.io/aifabrix/builder-server:latest
|
|
114
113
|
```
|
|
115
114
|
|
|
116
115
|
Or with Docker login (username/password from your registry):
|
|
@@ -123,11 +122,14 @@ docker pull <registry>/aifabrix/builder-server:latest
|
|
|
123
122
|
### Step 7: Install server (nginx vhost + container) (on server)
|
|
124
123
|
|
|
125
124
|
```bash
|
|
125
|
+
export DOMAIN=builder02.aifabrix.dev
|
|
126
126
|
sudo af-server install-server --dev-domain $DOMAIN
|
|
127
127
|
```
|
|
128
128
|
|
|
129
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`.
|
|
130
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
|
+
|
|
131
133
|
### Step 8: Done
|
|
132
134
|
|
|
133
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).
|
|
@@ -138,7 +140,7 @@ Your builder-server is up. Use the **AI Fabrix Builder CLI** for users, secrets,
|
|
|
138
140
|
|
|
139
141
|
| Command | Description |
|
|
140
142
|
| -------- | ----------- |
|
|
141
|
-
| `af-server install-init <user@host> [ -i SSH_KEY ]` | **From PC only.** One-time bootstrap over SSH: install on server SSH (if needed), Node
|
|
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. |
|
|
142
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. |
|
|
143
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`. |
|
|
144
146
|
| `af-server backup [ user@host ] [ -d DATA_DIR ] [ -o output.zip ] [ -i SSH_KEY ]` | On-demand backup (config + DB + keys). |
|
|
@@ -216,3 +218,24 @@ If you SSH as a non-root user, that user must be able to sudo. The script will a
|
|
|
216
218
|
- **Docker TLS** — Copies certs and configures `/etc/docker/daemon.json` for TLS (using website cert and builder-server CA).
|
|
217
219
|
|
|
218
220
|
Result: after both steps, the server has Docker, nginx (HTTPS for your domain), the builder-server container (if image present), and Mutagen—ready for the Builder CLI to use.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Troubleshooting
|
|
225
|
+
|
|
226
|
+
### `af-server --version` shows an old version after installing a newer one
|
|
227
|
+
|
|
228
|
+
If you ran `sudo npm install -g @aifabrix/server-setup@1.5.6` but `af-server --version` still reports an older version (e.g. 1.5.3):
|
|
229
|
+
|
|
230
|
+
1. **Check which binary is used**
|
|
231
|
+
Run `which af-server` and `sudo which af-server`. If they differ, your shell may be using a user-level install while the new one was installed for root.
|
|
232
|
+
|
|
233
|
+
2. **Uninstall, clear cache, then reinstall**
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
sudo npm uninstall -g @aifabrix/server-setup
|
|
237
|
+
npm cache clean --force
|
|
238
|
+
sudo npm install -g @aifabrix/server-setup@1.5.6
|
|
239
|
+
hash -r # clear shell’s command cache
|
|
240
|
+
af-server --version
|
|
241
|
+
```
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,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
|
+
}
|
package/dist/install-init.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node
|
|
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>;
|
package/dist/install-init.js
CHANGED
|
@@ -1,39 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node
|
|
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
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,10 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for install-init: bootstrap
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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');
|