@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 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 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. |
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 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.
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 aifabrixdevacr
113
- 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
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 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. |
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 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.3",
3
+ "version": "1.5.6",
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",