@aifabrix/server-setup 1.3.0 → 1.5.2

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for install-ssh: remote (mocked SSH) and local (mocked execSync).
3
+ */
4
+ const mockConn = {};
5
+ const mockExec = jest.fn();
6
+ const mockClose = jest.fn();
7
+ jest.mock('child_process', () => ({ execSync: jest.fn(() => Buffer.from('')) }));
8
+ jest.mock('./ssh.js', () => ({
9
+ createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
10
+ exec: jest.fn((...args) => mockExec(...args)),
11
+ close: jest.fn((...args) => mockClose(...args)),
12
+ }));
13
+ import { execSync } from 'child_process';
14
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
15
+ describe('install-ssh', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ mockExec.mockResolvedValue({ stdout: '', stderr: '', code: 0 });
19
+ jest.spyOn(console, 'log').mockImplementation(() => { });
20
+ });
21
+ afterEach(() => {
22
+ console.log.mockRestore();
23
+ });
24
+ describe('runInstallSsh', () => {
25
+ it('connects, runs sudo apt install openssh-server and systemctl enable/start ssh, then closes', async () => {
26
+ await runInstallSsh({ target: 'user@host' });
27
+ expect(mockExec).toHaveBeenCalledTimes(1);
28
+ const cmd = mockExec.mock.calls[0][1];
29
+ expect(cmd).toContain('sudo');
30
+ expect(cmd).toContain('openssh-server');
31
+ expect(cmd).toContain('systemctl enable ssh');
32
+ expect(cmd).toContain('systemctl start ssh');
33
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
34
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
35
+ });
36
+ it('throws when script exits non-zero', async () => {
37
+ mockExec.mockResolvedValueOnce({ stdout: '', stderr: 'E: Unable to locate package', code: 100 });
38
+ await expect(runInstallSsh({ target: 'user@host' })).rejects.toThrow(/install-ssh exited with code 100/);
39
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
40
+ });
41
+ });
42
+ describe('runInstallSshLocal', () => {
43
+ it('calls execSync with sudo and install script', () => {
44
+ execSync.mockClear();
45
+ runInstallSshLocal();
46
+ expect(execSync).toHaveBeenCalledTimes(1);
47
+ const cmd = execSync.mock.calls[0][0];
48
+ expect(cmd).toContain('sudo');
49
+ expect(cmd).toContain('openssh-server');
50
+ expect(cmd).toContain('systemctl enable ssh');
51
+ expect(cmd).toContain('systemctl start ssh');
52
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
53
+ });
54
+ });
55
+ });
package/dist/install.d.ts CHANGED
@@ -17,3 +17,6 @@ export interface InstallLocalOptions {
17
17
  export declare function runInstall(options: InstallOptions): Promise<void>;
18
18
  /** Run setup script locally (no SSH). Call requireUbuntu() before this. */
19
19
  export declare function runInstallLocal(options?: InstallLocalOptions): void;
20
+ export declare function runInstallServer(options: InstallOptions): Promise<void>;
21
+ /** Run server phase locally (nginx vhost, builder-server container, Docker TLS). Call requireUbuntu() before this. */
22
+ export declare function runInstallServerLocal(options?: InstallLocalOptions): void;
package/dist/install.js CHANGED
@@ -8,13 +8,17 @@ import { fileURLToPath } from 'url';
8
8
  import { createSSHClient, exec, writeFile, close, } from './ssh.js';
9
9
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const ASSETS_DIR = path.resolve(scriptDir, '..', 'assets');
11
+ /** Normalize to Unix LF so shebang and scripts run on Linux (avoids "No such file or directory" when CRLF). */
12
+ function toUnixLf(s) {
13
+ return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
14
+ }
11
15
  function getSetupScript() {
12
16
  const p = path.join(ASSETS_DIR, 'setup-dev-server-no-node.sh');
13
- return fs.readFileSync(p, 'utf8');
17
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
14
18
  }
15
19
  function getNginxTemplate() {
16
20
  const p = path.join(ASSETS_DIR, 'builder', 'builder-server', 'nginx-builder-server.conf.template');
17
- return fs.readFileSync(p, 'utf8');
21
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
18
22
  }
19
23
  export async function runInstall(options) {
20
24
  const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
@@ -31,12 +35,15 @@ export async function runInstall(options) {
31
35
  await writeFile(conn, `${tmpDir}/setup.sh`, setupScript);
32
36
  await writeFile(conn, `${tmpDir}/builder/builder-server/nginx-builder-server.conf.template`, nginxTemplate);
33
37
  await exec(conn, `chmod +x ${tmpDir}/setup.sh`);
38
+ /** Quote for remote shell so paths with spaces (e.g. Git Bash expanding /opt on Windows) don't break sudo. */
39
+ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
34
40
  const env = [
35
- `REPO_ROOT=${tmpDir}`,
36
- `DATA_DIR=${dataDir}`,
37
- `DEV_DOMAIN=${devDomain}`,
38
- `SSL_DIR=${sslDir}`,
39
- `BUILDER_SERVER_PORT=${builderServerPort}`,
41
+ `REPO_ROOT=${q(tmpDir)}`,
42
+ `INSTALL_PHASE=infra`,
43
+ `DATA_DIR=${q(dataDir)}`,
44
+ `DEV_DOMAIN=${q(devDomain)}`,
45
+ `SSL_DIR=${q(sslDir)}`,
46
+ `BUILDER_SERVER_PORT=${q(String(builderServerPort))}`,
40
47
  ].join(' ');
41
48
  const cmd = `sudo ${env} ${tmpDir}/setup.sh`;
42
49
  const result = await exec(conn, cmd);
@@ -65,7 +72,65 @@ export function runInstallLocal(options = {}) {
65
72
  try {
66
73
  fs.writeFileSync(path.join(tmpDir, 'setup.sh'), getSetupScript(), { mode: 0o755 });
67
74
  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}`].join(' ');
75
+ const env = [`REPO_ROOT=${tmpDir}`, `INSTALL_PHASE=infra`, `DATA_DIR=${dataDir}`, `DEV_DOMAIN=${devDomain}`, `SSL_DIR=${sslDir}`, `BUILDER_SERVER_PORT=${builderServerPort}`].join(' ');
76
+ execSync(`sudo ${env} ${tmpDir}/setup.sh`, { stdio: 'inherit' });
77
+ }
78
+ finally {
79
+ fs.rmSync(tmpDir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ export async function runInstallServer(options) {
83
+ const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
84
+ const devDomain = options.devDomain ?? 'builder01.aifabrix.dev';
85
+ const sslDir = options.sslDir ?? '/opt/aifabrix/ssl';
86
+ const builderServerPort = options.builderServerPort ?? 3000;
87
+ const conn = await createSSHClient(options);
88
+ try {
89
+ const tmpDir = `/tmp/aifabrix-setup-${Date.now()}`;
90
+ await exec(conn, `mkdir -p ${tmpDir}/builder/builder-server`);
91
+ await exec(conn, `chmod 755 ${tmpDir}`);
92
+ const setupScript = getSetupScript();
93
+ const nginxTemplate = getNginxTemplate();
94
+ await writeFile(conn, `${tmpDir}/setup.sh`, setupScript);
95
+ await writeFile(conn, `${tmpDir}/builder/builder-server/nginx-builder-server.conf.template`, nginxTemplate);
96
+ await exec(conn, `chmod +x ${tmpDir}/setup.sh`);
97
+ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
98
+ const env = [
99
+ `REPO_ROOT=${q(tmpDir)}`,
100
+ `INSTALL_PHASE=server`,
101
+ `DATA_DIR=${q(dataDir)}`,
102
+ `DEV_DOMAIN=${q(devDomain)}`,
103
+ `SSL_DIR=${q(sslDir)}`,
104
+ `BUILDER_SERVER_PORT=${q(String(builderServerPort))}`,
105
+ ].join(' ');
106
+ const cmd = `sudo ${env} ${tmpDir}/setup.sh`;
107
+ const result = await exec(conn, cmd);
108
+ if (result.stderr)
109
+ process.stderr.write(result.stderr);
110
+ if (result.stdout)
111
+ process.stdout.write(result.stdout);
112
+ if (result.code !== 0) {
113
+ throw new Error(`Install-server script exited with code ${result.code}`);
114
+ }
115
+ await exec(conn, `rm -rf ${tmpDir}`);
116
+ }
117
+ finally {
118
+ close(conn);
119
+ }
120
+ }
121
+ /** Run server phase locally (nginx vhost, builder-server container, Docker TLS). Call requireUbuntu() before this. */
122
+ export function runInstallServerLocal(options = {}) {
123
+ const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
124
+ const devDomain = options.devDomain ?? 'builder01.aifabrix.dev';
125
+ const sslDir = options.sslDir ?? '/opt/aifabrix/ssl';
126
+ const builderServerPort = options.builderServerPort ?? 3000;
127
+ const tmpDir = `/tmp/aifabrix-setup-${Date.now()}`;
128
+ const builderSubdir = path.join(tmpDir, 'builder', 'builder-server');
129
+ fs.mkdirSync(builderSubdir, { recursive: true });
130
+ try {
131
+ fs.writeFileSync(path.join(tmpDir, 'setup.sh'), getSetupScript(), { mode: 0o755 });
132
+ fs.writeFileSync(path.join(builderSubdir, 'nginx-builder-server.conf.template'), getNginxTemplate());
133
+ const env = [`REPO_ROOT=${tmpDir}`, `INSTALL_PHASE=server`, `DATA_DIR=${dataDir}`, `DEV_DOMAIN=${devDomain}`, `SSL_DIR=${sslDir}`, `BUILDER_SERVER_PORT=${builderServerPort}`].join(' ');
69
134
  execSync(`sudo ${env} ${tmpDir}/setup.sh`, { stdio: 'inherit' });
70
135
  }
71
136
  finally {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tests for install: runInstall (infra phase) and runInstallServer / runInstallServerLocal (server phase).
3
+ * install.ts uses import.meta.url; Jest (CJS) fails to load it. Unit tests for runInstall* are skipped; we test script content.
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ const ASSETS_DIR = path.resolve(__dirname, '..', 'assets');
8
+ const SETUP_SCRIPT = path.join(ASSETS_DIR, 'setup-dev-server-no-node.sh');
9
+ describe('install script (setup-dev-server-no-node.sh)', () => {
10
+ it('defines INSTALL_PHASE and infra/server phase blocks', () => {
11
+ const content = fs.readFileSync(SETUP_SCRIPT, 'utf8');
12
+ expect(content).toContain('INSTALL_PHASE="${INSTALL_PHASE:-full}"');
13
+ expect(content).toContain('"$INSTALL_PHASE" = "infra"');
14
+ expect(content).toContain('"$INSTALL_PHASE" = "server"');
15
+ expect(content).toContain('End infra phase');
16
+ expect(content).toContain('End server phase');
17
+ });
18
+ });
19
+ describe.skip('install (runInstall* unit tests)', () => {
20
+ it('placeholder until Jest supports import.meta in transformed modules', () => {
21
+ expect(true).toBe(true);
22
+ });
23
+ });
package/dist/ssh.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  export interface SSHConnectionOptions {
package/dist/ssh.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  import * as fs from 'fs';
8
+ import * as os from 'os';
7
9
  import * as path from 'path';
10
+ import read from 'read';
11
+ const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa'];
8
12
  export function parseTarget(target) {
9
13
  const at = target.lastIndexOf('@');
10
14
  if (at <= 0 || at === target.length - 1) {
@@ -26,18 +30,107 @@ export function createSSHClient(options) {
26
30
  }
27
31
  privateKey = fs.readFileSync(resolved, 'utf8');
28
32
  }
33
+ else {
34
+ const sshDir = path.join(os.homedir(), '.ssh');
35
+ for (const name of DEFAULT_KEY_NAMES) {
36
+ const keyPath = path.join(sshDir, name);
37
+ if (fs.existsSync(keyPath)) {
38
+ try {
39
+ privateKey = fs.readFileSync(keyPath, 'utf8');
40
+ break;
41
+ }
42
+ catch {
43
+ // skip unreadable key, try next
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const usedDefaultKey = !options.privateKeyPath && !options.privateKey && !!privateKey;
49
+ function isAuthError(err) {
50
+ const msg = err.message || '';
51
+ return (msg.includes('All configured authentication methods failed') ||
52
+ msg.includes('authentication') ||
53
+ err.level === 'client-authentication');
54
+ }
29
55
  return new Promise((resolve, reject) => {
30
- const conn = new Client();
31
- conn
32
- .on('ready', () => resolve(conn))
33
- .on('error', (err) => reject(err))
34
- .connect({
35
- host,
36
- port,
37
- username: user,
38
- privateKey: privateKey || undefined,
39
- tryKeyboard: !privateKey,
40
- });
56
+ const doConnect = (password, useKey = true) => {
57
+ const keyForThisConnection = useKey ? privateKey : undefined;
58
+ const conn = new Client();
59
+ if (!keyForThisConnection && password !== undefined) {
60
+ conn.on('keyboard-interactive', (_name, _instructions, _lang, prompts, finish) => {
61
+ if (prompts.length === 0) {
62
+ finish([]);
63
+ return;
64
+ }
65
+ finish(prompts.map(() => password));
66
+ });
67
+ }
68
+ else if (!keyForThisConnection) {
69
+ const onKeyboardInteractive = (_name, _instructions, _lang, prompts, finish) => {
70
+ if (prompts.length === 0) {
71
+ finish([]);
72
+ return;
73
+ }
74
+ const next = (index, answers) => {
75
+ if (index >= prompts.length) {
76
+ finish(answers);
77
+ return;
78
+ }
79
+ const p = prompts[index];
80
+ read({
81
+ prompt: p.prompt || (p.echo ? 'Response: ' : 'Password: '),
82
+ silent: !p.echo,
83
+ }, (err, value) => {
84
+ if (err) {
85
+ finish(answers);
86
+ return;
87
+ }
88
+ next(index + 1, answers.concat(value || ''));
89
+ });
90
+ };
91
+ next(0, []);
92
+ };
93
+ conn.on('keyboard-interactive', onKeyboardInteractive);
94
+ }
95
+ conn
96
+ .on('ready', () => resolve(conn))
97
+ .on('error', (err) => {
98
+ if (usedDefaultKey &&
99
+ useKey &&
100
+ isAuthError(err)) {
101
+ read({ prompt: 'Password: ', silent: true }, (errRead, value) => {
102
+ if (errRead) {
103
+ reject(errRead);
104
+ return;
105
+ }
106
+ doConnect(value || '', false);
107
+ });
108
+ }
109
+ else {
110
+ reject(err);
111
+ }
112
+ })
113
+ .connect({
114
+ host,
115
+ port,
116
+ username: user,
117
+ privateKey: keyForThisConnection || undefined,
118
+ password: !keyForThisConnection ? password : undefined,
119
+ tryKeyboard: !keyForThisConnection,
120
+ });
121
+ };
122
+ if (!privateKey) {
123
+ read({ prompt: 'Password: ', silent: true }, (err, value) => {
124
+ if (err) {
125
+ reject(err);
126
+ return;
127
+ }
128
+ doConnect(value || '');
129
+ });
130
+ }
131
+ else {
132
+ doConnect(undefined);
133
+ }
41
134
  });
42
135
  }
43
136
  export function exec(conn, command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/server-setup",
3
- "version": "1.3.0",
3
+ "version": "1.5.2",
4
4
  "description": "CLI to install, backup, and restore AI Fabrix builder-server (config + DB) over SSH",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -24,6 +24,7 @@
24
24
  "better-sqlite3": "^11.6.0",
25
25
  "commander": "^12.1.0",
26
26
  "extract-zip": "^2.0.1",
27
+ "read": "^1.0.7",
27
28
  "ssh2": "^1.15.0"
28
29
  },
29
30
  "devDependencies": {
@@ -32,6 +33,7 @@
32
33
  "@types/better-sqlite3": "^7.6.11",
33
34
  "@types/jest": "^29.5.14",
34
35
  "@types/node": "^22.10.2",
36
+ "@types/read": "^0.0.32",
35
37
  "@types/ssh2": "^1.15.5",
36
38
  "@typescript-eslint/eslint-plugin": "^8.17.0",
37
39
  "@typescript-eslint/parser": "^8.17.0",
@@ -44,5 +46,10 @@
44
46
  "files": [
45
47
  "dist",
46
48
  "assets"
47
- ]
49
+ ],
50
+ "pnpm": {
51
+ "onlyBuiltDependencies": [
52
+ "better-sqlite3"
53
+ ]
54
+ }
48
55
  }