@aifabrix/server-setup 0.1.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 +68 -0
- package/assets/builder/builder-server/nginx-builder-server.conf.template +26 -0
- package/assets/cron-backup.sh +25 -0
- package/assets/setup-dev-server-no-node.sh +227 -0
- package/dist/backup-db.d.ts +13 -0
- package/dist/backup-db.js +125 -0
- package/dist/backup-db.spec.d.ts +5 -0
- package/dist/backup-db.spec.js +260 -0
- package/dist/backup-schedule.d.ts +17 -0
- package/dist/backup-schedule.js +60 -0
- package/dist/backup.d.ts +15 -0
- package/dist/backup.js +184 -0
- package/dist/backup.spec.d.ts +4 -0
- package/dist/backup.spec.js +199 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +170 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +9 -0
- package/dist/config.spec.d.ts +4 -0
- package/dist/config.spec.js +41 -0
- package/dist/install.d.ts +19 -0
- package/dist/install.js +74 -0
- package/dist/local-pubkey.d.ts +13 -0
- package/dist/local-pubkey.js +35 -0
- package/dist/local-pubkey.spec.d.ts +4 -0
- package/dist/local-pubkey.spec.js +64 -0
- package/dist/restore.d.ts +17 -0
- package/dist/restore.js +101 -0
- package/dist/restore.spec.d.ts +4 -0
- package/dist/restore.spec.js +215 -0
- package/dist/ssh-cert.d.ts +18 -0
- package/dist/ssh-cert.js +92 -0
- package/dist/ssh-cert.spec.d.ts +4 -0
- package/dist/ssh-cert.spec.js +101 -0
- package/dist/ssh.d.ts +27 -0
- package/dist/ssh.js +122 -0
- package/dist/ssh.spec.d.ts +4 -0
- package/dist/ssh.spec.js +31 -0
- package/dist/ubuntu.d.ts +7 -0
- package/dist/ubuntu.js +33 -0
- package/dist/ubuntu.spec.d.ts +4 -0
- package/dist/ubuntu.spec.js +56 -0
- package/package.json +48 -0
package/dist/ssh.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH client wrapper using ssh2. Used for install, backup, restore.
|
|
3
|
+
* Never log private keys or sensitive data.
|
|
4
|
+
*/
|
|
5
|
+
import { Client } from 'ssh2';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
export function parseTarget(target) {
|
|
9
|
+
const at = target.lastIndexOf('@');
|
|
10
|
+
if (at <= 0 || at === target.length - 1) {
|
|
11
|
+
throw new Error(`Invalid target "${target}"; use user@host`);
|
|
12
|
+
}
|
|
13
|
+
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
14
|
+
}
|
|
15
|
+
export function createSSHClient(options) {
|
|
16
|
+
const { user, host } = parseTarget(options.target);
|
|
17
|
+
const port = options.port ?? 22;
|
|
18
|
+
let privateKey;
|
|
19
|
+
if (options.privateKey) {
|
|
20
|
+
privateKey = options.privateKey;
|
|
21
|
+
}
|
|
22
|
+
else if (options.privateKeyPath) {
|
|
23
|
+
const resolved = path.resolve(options.privateKeyPath);
|
|
24
|
+
if (!fs.existsSync(resolved)) {
|
|
25
|
+
throw new Error(`Private key file not found: ${resolved}`);
|
|
26
|
+
}
|
|
27
|
+
privateKey = fs.readFileSync(resolved, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
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
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function exec(conn, command) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
conn.exec(command, (err, stream) => {
|
|
46
|
+
if (err) {
|
|
47
|
+
reject(err);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let stdout = '';
|
|
51
|
+
let stderr = '';
|
|
52
|
+
stream
|
|
53
|
+
.on('close', (code) => resolve({ stdout, stderr, code: code ?? null }))
|
|
54
|
+
.on('data', (data) => { stdout += data.toString(); });
|
|
55
|
+
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Get remote user home directory (e.g. for ~/.ssh). Uses exec so paths work with SFTP. */
|
|
60
|
+
export async function getRemoteHome(conn) {
|
|
61
|
+
const result = await exec(conn, 'echo $HOME');
|
|
62
|
+
if (result.code !== 0) {
|
|
63
|
+
throw new Error('Could not determine remote home directory.');
|
|
64
|
+
}
|
|
65
|
+
const home = result.stdout.trim();
|
|
66
|
+
if (!home) {
|
|
67
|
+
throw new Error('Remote HOME is empty.');
|
|
68
|
+
}
|
|
69
|
+
return home;
|
|
70
|
+
}
|
|
71
|
+
export function readFile(conn, remotePath) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
conn.sftp((err, sftp) => {
|
|
74
|
+
if (err) {
|
|
75
|
+
reject(err);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
sftp.readFile(remotePath, (err2, data) => {
|
|
79
|
+
if (err2)
|
|
80
|
+
reject(err2);
|
|
81
|
+
else
|
|
82
|
+
resolve(data);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export function writeFile(conn, remotePath, data) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
conn.sftp((err, sftp) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
reject(err);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
95
|
+
sftp.writeFile(remotePath, buf, (err2) => {
|
|
96
|
+
if (err2)
|
|
97
|
+
reject(err2);
|
|
98
|
+
else
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function mkdir(conn, remotePath) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
conn.sftp((err, sftp) => {
|
|
107
|
+
if (err) {
|
|
108
|
+
reject(err);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
sftp.mkdir(remotePath, (err2) => {
|
|
112
|
+
if (err2 && err2.code !== 4)
|
|
113
|
+
reject(err2); // 4 = already exists
|
|
114
|
+
else
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function close(conn) {
|
|
121
|
+
conn.end();
|
|
122
|
+
}
|
package/dist/ssh.spec.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for SSH helpers (parseTarget). No real SSH connections.
|
|
3
|
+
*/
|
|
4
|
+
import { parseTarget } from './ssh.js';
|
|
5
|
+
describe('parseTarget', () => {
|
|
6
|
+
it('parses user@host', () => {
|
|
7
|
+
expect(parseTarget('alice@192.168.1.1')).toEqual({
|
|
8
|
+
user: 'alice',
|
|
9
|
+
host: '192.168.1.1',
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
it('parses user@hostname with multiple @', () => {
|
|
13
|
+
expect(parseTarget('user@host@extra')).toEqual({
|
|
14
|
+
user: 'user@host',
|
|
15
|
+
host: 'extra',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it('parses single-char user and host', () => {
|
|
19
|
+
expect(parseTarget('a@b')).toEqual({ user: 'a', host: 'b' });
|
|
20
|
+
});
|
|
21
|
+
it('throws when no @', () => {
|
|
22
|
+
expect(() => parseTarget('nobody')).toThrow('Invalid target');
|
|
23
|
+
expect(() => parseTarget('nobody')).toThrow('user@host');
|
|
24
|
+
});
|
|
25
|
+
it('throws when @ at start', () => {
|
|
26
|
+
expect(() => parseTarget('@host')).toThrow('Invalid target');
|
|
27
|
+
});
|
|
28
|
+
it('throws when @ at end', () => {
|
|
29
|
+
expect(() => parseTarget('user@')).toThrow('Invalid target');
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/ubuntu.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Require Ubuntu for local mode (read /etc/os-release). Used before any local execution path.
|
|
3
|
+
*/
|
|
4
|
+
/** Optional path to os-release file for testing (default: /etc/os-release). */
|
|
5
|
+
export declare function requireUbuntu(options?: {
|
|
6
|
+
osReleasePath?: string;
|
|
7
|
+
}): void;
|
package/dist/ubuntu.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Require Ubuntu for local mode (read /etc/os-release). Used before any local execution path.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
const NOT_UBUNTU_MESSAGE = 'Local mode is supported only on Ubuntu. Use user@host to target an Ubuntu server over SSH.';
|
|
6
|
+
/** Optional path to os-release file for testing (default: /etc/os-release). */
|
|
7
|
+
export function requireUbuntu(options) {
|
|
8
|
+
if (process.platform !== 'linux') {
|
|
9
|
+
throw new Error(NOT_UBUNTU_MESSAGE);
|
|
10
|
+
}
|
|
11
|
+
const releasePath = options?.osReleasePath ?? '/etc/os-release';
|
|
12
|
+
let id = '';
|
|
13
|
+
let idLike = '';
|
|
14
|
+
try {
|
|
15
|
+
const content = fs.readFileSync(releasePath, 'utf8');
|
|
16
|
+
for (const line of content.split('\n')) {
|
|
17
|
+
const m = line.match(/^ID=(.*)$/);
|
|
18
|
+
if (m)
|
|
19
|
+
id = m[1].replace(/^"|"$/g, '').trim();
|
|
20
|
+
const mLike = line.match(/^ID_LIKE=(.*)$/);
|
|
21
|
+
if (mLike)
|
|
22
|
+
idLike = mLike[1].replace(/^"|"$/g, '').trim();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(NOT_UBUNTU_MESSAGE);
|
|
27
|
+
}
|
|
28
|
+
const isUbuntu = id === 'ubuntu' || (idLike && idLike.includes('ubuntu'));
|
|
29
|
+
if (!isUbuntu) {
|
|
30
|
+
const detected = id || 'unknown';
|
|
31
|
+
throw new Error(`Local mode is supported only on Ubuntu. Detected: ${detected}. Use user@host to target an Ubuntu server.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for requireUbuntu (local mode OS check) using temp os-release file.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { requireUbuntu } from './ubuntu.js';
|
|
7
|
+
const TEST_TMP = path.join(process.cwd(), 'tmp', 'ubuntu');
|
|
8
|
+
describe('ubuntu', () => {
|
|
9
|
+
let releasePath;
|
|
10
|
+
const originalPlatform = process.platform;
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
fs.mkdirSync(TEST_TMP, { recursive: true });
|
|
13
|
+
releasePath = path.join(TEST_TMP, `os-release-${Date.now()}`);
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
try {
|
|
17
|
+
fs.rmSync(TEST_TMP, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
|
25
|
+
});
|
|
26
|
+
it('throws on non-Linux with message to use user@host', () => {
|
|
27
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
28
|
+
expect(() => requireUbuntu()).toThrow(/Local mode is supported only on Ubuntu/);
|
|
29
|
+
expect(() => requireUbuntu()).toThrow(/user@host/);
|
|
30
|
+
});
|
|
31
|
+
it('throws on Linux when os-release is not Ubuntu', () => {
|
|
32
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
33
|
+
fs.writeFileSync(releasePath, 'ID=debian\nID_LIKE=debian\n');
|
|
34
|
+
expect(() => requireUbuntu({ osReleasePath: releasePath })).toThrow(/Local mode is supported only on Ubuntu/);
|
|
35
|
+
expect(() => requireUbuntu({ osReleasePath: releasePath })).toThrow(/Detected: debian/);
|
|
36
|
+
});
|
|
37
|
+
it('does not throw when ID=ubuntu', () => {
|
|
38
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
39
|
+
fs.writeFileSync(releasePath, 'ID=ubuntu\nID_LIKE=debian\n');
|
|
40
|
+
expect(() => requireUbuntu({ osReleasePath: releasePath })).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
it('does not throw when ID_LIKE contains ubuntu', () => {
|
|
43
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
44
|
+
fs.writeFileSync(releasePath, 'ID=pop\nID_LIKE="ubuntu debian"\n');
|
|
45
|
+
expect(() => requireUbuntu({ osReleasePath: releasePath })).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
it('strips quotes from ID and ID_LIKE', () => {
|
|
48
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
49
|
+
fs.writeFileSync(releasePath, 'ID="ubuntu"\nID_LIKE="debian"\n');
|
|
50
|
+
expect(() => requireUbuntu({ osReleasePath: releasePath })).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
it('throws when os-release file cannot be read', () => {
|
|
53
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
54
|
+
expect(() => requireUbuntu({ osReleasePath: path.join(TEST_TMP, 'nonexistent') })).toThrow(/Local mode is supported only on Ubuntu/);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aifabrix/server-setup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to install, backup, and restore AI Fabrix builder-server (config + DB) over SSH",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"af-server": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"build:ci": "npm run lint && npm run test && npm run build",
|
|
13
|
+
"lint": "eslint src",
|
|
14
|
+
"test": "jest",
|
|
15
|
+
"test:cov": "jest --coverage",
|
|
16
|
+
"test:watch": "jest --watch",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"archiver": "^7.0.1",
|
|
24
|
+
"better-sqlite3": "^11.6.0",
|
|
25
|
+
"commander": "^12.1.0",
|
|
26
|
+
"extract-zip": "^2.0.1",
|
|
27
|
+
"ssh2": "^1.15.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@eslint/js": "^9.17.0",
|
|
31
|
+
"@types/archiver": "^6.0.2",
|
|
32
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
33
|
+
"@types/jest": "^29.5.14",
|
|
34
|
+
"@types/node": "^22.10.2",
|
|
35
|
+
"@types/ssh2": "^1.15.5",
|
|
36
|
+
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
|
37
|
+
"@typescript-eslint/parser": "^8.17.0",
|
|
38
|
+
"eslint": "^9.17.0",
|
|
39
|
+
"globals": "^15.15.0",
|
|
40
|
+
"jest": "^29.7.0",
|
|
41
|
+
"ts-jest": "^29.4.6",
|
|
42
|
+
"typescript": "^5.7.2"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"assets"
|
|
47
|
+
]
|
|
48
|
+
}
|