@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for runBackup with mocked SSH (no real connections).
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import extract from 'extract-zip';
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
|
+
import { runBackup, runBackupLocal } from './backup.js';
|
|
9
|
+
import { BUILDER_DB, BACKUP_DB, CONFIG_JSON, JSON_FILES } from './config.js';
|
|
10
|
+
const TEST_TMP_BASE = path.join(process.cwd(), 'tmp');
|
|
11
|
+
const mockConn = {};
|
|
12
|
+
const mockExec = jest.fn();
|
|
13
|
+
const mockReadFile = jest.fn();
|
|
14
|
+
const mockClose = jest.fn();
|
|
15
|
+
jest.mock('./ssh.js', () => ({
|
|
16
|
+
createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
|
|
17
|
+
readFile: jest.fn((...args) => mockReadFile(...args)),
|
|
18
|
+
exec: jest.fn((...args) => mockExec(...args)),
|
|
19
|
+
close: jest.fn((...args) => mockClose(...args)),
|
|
20
|
+
}));
|
|
21
|
+
describe('runBackup', () => {
|
|
22
|
+
let outDir;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
fs.mkdirSync(TEST_TMP_BASE, { recursive: true });
|
|
26
|
+
outDir = fs.mkdtempSync(path.join(TEST_TMP_BASE, 'af-backup-out-'));
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
try {
|
|
30
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it('builds zip from builder.db when present on server', async () => {
|
|
37
|
+
const dbPath = path.join(outDir, `minimal-${Date.now()}.db`);
|
|
38
|
+
const db = new Database(dbPath);
|
|
39
|
+
db.exec("CREATE TABLE users (id TEXT PRIMARY KEY); INSERT INTO users (id) VALUES ('1');");
|
|
40
|
+
db.close();
|
|
41
|
+
const sqliteBuf = fs.readFileSync(dbPath);
|
|
42
|
+
fs.unlinkSync(dbPath);
|
|
43
|
+
mockExec.mockResolvedValue({ stdout: '1\n', stderr: '', code: 0 });
|
|
44
|
+
mockReadFile.mockImplementation((_conn, p) => {
|
|
45
|
+
if (p.includes('builder.db'))
|
|
46
|
+
return Promise.resolve(sqliteBuf);
|
|
47
|
+
return Promise.resolve(Buffer.from(''));
|
|
48
|
+
});
|
|
49
|
+
const outPath = path.join(outDir, 'backup.zip');
|
|
50
|
+
const result = await runBackup({
|
|
51
|
+
target: 'user@host',
|
|
52
|
+
dataDir: '/data',
|
|
53
|
+
outputPath: outPath,
|
|
54
|
+
});
|
|
55
|
+
expect(result).toBe(path.resolve(outPath));
|
|
56
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
57
|
+
expect(mockClose).toHaveBeenCalledWith(mockConn);
|
|
58
|
+
});
|
|
59
|
+
it('builds zip from JSON files when builder.db absent', async () => {
|
|
60
|
+
mockExec.mockResolvedValue({ stdout: '0\n', stderr: '', code: 0 });
|
|
61
|
+
mockReadFile.mockImplementation((_conn, p) => {
|
|
62
|
+
if (p.endsWith('users.json'))
|
|
63
|
+
return Promise.resolve(Buffer.from('{"users":[{"id":"01","name":"A","email":"a@b.com","createdAt":"2025-01-01"}]}'));
|
|
64
|
+
if (p.endsWith('tokens.json'))
|
|
65
|
+
return Promise.resolve(Buffer.from('{"pins":[]}'));
|
|
66
|
+
if (p.endsWith('secrets.json'))
|
|
67
|
+
return Promise.resolve(Buffer.from('{"secrets":{}}'));
|
|
68
|
+
if (p.endsWith('ssh-public-keys.json'))
|
|
69
|
+
return Promise.resolve(Buffer.from('{"byUser":{}}'));
|
|
70
|
+
return Promise.reject(new Error('ENOENT'));
|
|
71
|
+
});
|
|
72
|
+
const outPath = path.join(outDir, 'backup2.zip');
|
|
73
|
+
const result = await runBackup({
|
|
74
|
+
target: 'user@host',
|
|
75
|
+
dataDir: '/data',
|
|
76
|
+
outputPath: outPath,
|
|
77
|
+
});
|
|
78
|
+
expect(result).toBe(path.resolve(outPath));
|
|
79
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
80
|
+
expect(mockClose).toHaveBeenCalledWith(mockConn);
|
|
81
|
+
});
|
|
82
|
+
it('handles missing key files (catch path)', async () => {
|
|
83
|
+
mockExec.mockResolvedValue({ stdout: '0\n', stderr: '', code: 0 });
|
|
84
|
+
mockReadFile
|
|
85
|
+
.mockResolvedValueOnce(Buffer.from('{"users":[]}'))
|
|
86
|
+
.mockResolvedValueOnce(Buffer.from('{"pins":[]}'))
|
|
87
|
+
.mockResolvedValueOnce(Buffer.from('{"secrets":{}}'))
|
|
88
|
+
.mockResolvedValueOnce(Buffer.from('{"byUser":{}}'))
|
|
89
|
+
.mockRejectedValue(new Error('ENOENT'));
|
|
90
|
+
const outPath = path.join(outDir, 'backup3.zip');
|
|
91
|
+
const result = await runBackup({
|
|
92
|
+
target: 'user@host',
|
|
93
|
+
dataDir: '/data',
|
|
94
|
+
outputPath: outPath,
|
|
95
|
+
});
|
|
96
|
+
expect(result).toBe(path.resolve(outPath));
|
|
97
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
it('uses default output path when outputPath not provided', async () => {
|
|
100
|
+
mockExec.mockResolvedValue({ stdout: '1\n', stderr: '', code: 0 });
|
|
101
|
+
mockReadFile.mockResolvedValue(Buffer.from(''));
|
|
102
|
+
const result = await runBackup({ target: 'user@host', dataDir: '/data' });
|
|
103
|
+
expect(result).toMatch(/\/aifabrix-backup-\d{8}-\d{4}\.zip$/);
|
|
104
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(result);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it('uses fallback JSON when readFile fails for each JSON file', async () => {
|
|
113
|
+
mockExec.mockResolvedValue({ stdout: '0\n', stderr: '', code: 0 });
|
|
114
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
115
|
+
const outPath = path.join(outDir, 'fallback.zip');
|
|
116
|
+
const result = await runBackup({
|
|
117
|
+
target: 'user@host',
|
|
118
|
+
dataDir: '/data',
|
|
119
|
+
outputPath: outPath,
|
|
120
|
+
});
|
|
121
|
+
expect(result).toBe(path.resolve(outPath));
|
|
122
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
123
|
+
const tmpExtract = path.join(outDir, 'extract');
|
|
124
|
+
fs.mkdirSync(tmpExtract, { recursive: true });
|
|
125
|
+
await extract(result, { dir: tmpExtract });
|
|
126
|
+
const db = new Database(path.join(tmpExtract, 'backup.db'), { readonly: true });
|
|
127
|
+
const userCount = db.prepare('SELECT COUNT(*) as n FROM users').get();
|
|
128
|
+
expect(userCount.n).toBe(0);
|
|
129
|
+
db.close();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('runBackupLocal', () => {
|
|
133
|
+
let dataDir;
|
|
134
|
+
const TEST_TMP_BASE = path.join(process.cwd(), 'tmp');
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
fs.mkdirSync(TEST_TMP_BASE, { recursive: true });
|
|
137
|
+
dataDir = fs.mkdtempSync(path.join(TEST_TMP_BASE, 'af-backup-local-'));
|
|
138
|
+
});
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
try {
|
|
141
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
it('builds zip from local builder.db when present', async () => {
|
|
148
|
+
const dbPath = path.join(dataDir, BUILDER_DB);
|
|
149
|
+
const db = new Database(dbPath);
|
|
150
|
+
db.exec("CREATE TABLE users (id TEXT PRIMARY KEY); INSERT INTO users (id) VALUES ('1');");
|
|
151
|
+
db.close();
|
|
152
|
+
const outPath = path.join(dataDir, 'out.zip');
|
|
153
|
+
const result = await runBackupLocal({ dataDir, outputPath: outPath });
|
|
154
|
+
expect(result).toBe(path.resolve(outPath));
|
|
155
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
156
|
+
const tmpExtract = path.join(dataDir, 'extract');
|
|
157
|
+
fs.mkdirSync(tmpExtract, { recursive: true });
|
|
158
|
+
await extract(result, { dir: tmpExtract });
|
|
159
|
+
const db2 = new Database(path.join(tmpExtract, BACKUP_DB), { readonly: true });
|
|
160
|
+
const config = JSON.parse(fs.readFileSync(path.join(tmpExtract, CONFIG_JSON), 'utf8'));
|
|
161
|
+
expect(config.source).toBe('builder.db');
|
|
162
|
+
const userCount = db2.prepare('SELECT COUNT(*) as n FROM users').get();
|
|
163
|
+
expect(userCount.n).toBe(1);
|
|
164
|
+
db2.close();
|
|
165
|
+
});
|
|
166
|
+
it('builds zip from JSON files when builder.db absent', async () => {
|
|
167
|
+
for (const f of JSON_FILES) {
|
|
168
|
+
if (f === 'users.json')
|
|
169
|
+
fs.writeFileSync(path.join(dataDir, f), '{"users":[{"id":"02"}]}');
|
|
170
|
+
else if (f === 'tokens.json')
|
|
171
|
+
fs.writeFileSync(path.join(dataDir, f), '{"pins":[]}');
|
|
172
|
+
else if (f === 'secrets.json')
|
|
173
|
+
fs.writeFileSync(path.join(dataDir, f), '{"secrets":{}}');
|
|
174
|
+
else if (f === 'ssh-public-keys.json')
|
|
175
|
+
fs.writeFileSync(path.join(dataDir, f), '{"byUser":{}}');
|
|
176
|
+
}
|
|
177
|
+
const outPath = path.join(dataDir, 'out2.zip');
|
|
178
|
+
const result = await runBackupLocal({ dataDir, outputPath: outPath });
|
|
179
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
180
|
+
const tmpExtract = path.join(dataDir, 'extract2');
|
|
181
|
+
fs.mkdirSync(tmpExtract, { recursive: true });
|
|
182
|
+
await extract(result, { dir: tmpExtract });
|
|
183
|
+
const config = JSON.parse(fs.readFileSync(path.join(tmpExtract, CONFIG_JSON), 'utf8'));
|
|
184
|
+
expect(config.source).toBe('json');
|
|
185
|
+
});
|
|
186
|
+
it('includes key files when present in dataDir', async () => {
|
|
187
|
+
const dbPath = path.join(dataDir, BUILDER_DB);
|
|
188
|
+
const db = new Database(dbPath);
|
|
189
|
+
db.exec('CREATE TABLE users (id TEXT PRIMARY KEY);');
|
|
190
|
+
db.close();
|
|
191
|
+
fs.writeFileSync(path.join(dataDir, 'ca.crt'), 'cert-content');
|
|
192
|
+
const outPath = path.join(dataDir, 'out3.zip');
|
|
193
|
+
const result = await runBackupLocal({ dataDir, outputPath: outPath });
|
|
194
|
+
const tmpExtract = path.join(dataDir, 'extract3');
|
|
195
|
+
fs.mkdirSync(tmpExtract, { recursive: true });
|
|
196
|
+
await extract(result, { dir: tmpExtract });
|
|
197
|
+
expect(fs.existsSync(path.join(tmpExtract, 'ca.crt'))).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* af-server — Install, backup, and restore AI Fabrix builder-server (config + DB) over SSH.
|
|
4
|
+
* Usage: af-server <command> [options] [user@host]
|
|
5
|
+
*/
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { runInstall, runInstallLocal } from './install.js';
|
|
8
|
+
import { runBackup, runBackupLocal } from './backup.js';
|
|
9
|
+
import { runBackupScheduleInstall, runBackupScheduleInstallLocal } from './backup-schedule.js';
|
|
10
|
+
import { runRestore, runRestoreLocal } from './restore.js';
|
|
11
|
+
import { runSshCertRequest, runSshCertInstall, runSshCertInstallLocal } from './ssh-cert.js';
|
|
12
|
+
import { requireUbuntu } from './ubuntu.js';
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name('af-server')
|
|
16
|
+
.description('Install, backup, and restore AI Fabrix builder-server over SSH (config + DB only)')
|
|
17
|
+
.version('0.1.0');
|
|
18
|
+
program
|
|
19
|
+
.command('install [user@host]')
|
|
20
|
+
.description('Run server setup on host or locally (omit target on server). Docker, nginx, SSL, cron.')
|
|
21
|
+
.option('-d, --data-dir <path>', 'DATA_DIR', '/opt/aifabrix/builder-server/data')
|
|
22
|
+
.option('--dev-domain <domain>', 'DEV_DOMAIN for nginx', 'dev.aifabrix.dev')
|
|
23
|
+
.option('--ssl-dir <path>', 'SSL_DIR', '/opt/aifabrix/ssl')
|
|
24
|
+
.option('--builder-port <port>', 'BUILDER_SERVER_PORT', '3000')
|
|
25
|
+
.option('-i, --identity <path>', 'SSH private key path')
|
|
26
|
+
.action(async (target, opts) => {
|
|
27
|
+
try {
|
|
28
|
+
if (!target || !target.trim()) {
|
|
29
|
+
requireUbuntu();
|
|
30
|
+
runInstallLocal({
|
|
31
|
+
dataDir: opts.dataDir,
|
|
32
|
+
devDomain: opts.devDomain,
|
|
33
|
+
sslDir: opts.sslDir,
|
|
34
|
+
builderServerPort: opts.builderPort ? parseInt(opts.builderPort, 10) : undefined,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
await runInstall({
|
|
39
|
+
target: target.trim(),
|
|
40
|
+
privateKeyPath: opts.identity,
|
|
41
|
+
dataDir: opts.dataDir,
|
|
42
|
+
devDomain: opts.devDomain,
|
|
43
|
+
sslDir: opts.sslDir,
|
|
44
|
+
builderServerPort: opts.builderPort ? parseInt(opts.builderPort, 10) : undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
console.log('Install complete.');
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error(err instanceof Error ? err.message : err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
program
|
|
55
|
+
.command('backup [user@host]')
|
|
56
|
+
.description('On-demand backup, or --schedule to install cron (omit target for local).')
|
|
57
|
+
.option('-d, --data-dir <path>', 'DATA_DIR', '/opt/aifabrix/builder-server/data')
|
|
58
|
+
.option('-o, --output <path>', 'Output zip path (on-demand only)')
|
|
59
|
+
.option('--schedule', 'Install cron backup (daily 02:00, keep last 7)')
|
|
60
|
+
.option('--backup-dir <path>', 'Backup dir (with --schedule)', '/opt/aifabrix/backups')
|
|
61
|
+
.option('--keep-days <n>', 'Retention days (with --schedule)', '7')
|
|
62
|
+
.option('-i, --identity <path>', 'SSH private key path')
|
|
63
|
+
.action(async (target, opts) => {
|
|
64
|
+
try {
|
|
65
|
+
const isLocal = !target || !target.trim();
|
|
66
|
+
if (opts.schedule) {
|
|
67
|
+
if (isLocal) {
|
|
68
|
+
requireUbuntu();
|
|
69
|
+
runBackupScheduleInstallLocal({
|
|
70
|
+
dataDir: opts.dataDir,
|
|
71
|
+
backupDir: opts.backupDir,
|
|
72
|
+
keepDays: opts.keepDays ? parseInt(opts.keepDays, 10) : 7,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await runBackupScheduleInstall({
|
|
77
|
+
target: target.trim(),
|
|
78
|
+
privateKeyPath: opts.identity,
|
|
79
|
+
dataDir: opts.dataDir,
|
|
80
|
+
backupDir: opts.backupDir,
|
|
81
|
+
keepDays: opts.keepDays ? parseInt(opts.keepDays, 10) : 7,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
console.log('Cron backup installed. Backups will run daily at 02:00.');
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (isLocal) {
|
|
88
|
+
requireUbuntu();
|
|
89
|
+
const out = await runBackupLocal({ dataDir: opts.dataDir, outputPath: opts.output });
|
|
90
|
+
console.log(`Backup saved: ${out}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const out = await runBackup({
|
|
94
|
+
target: target.trim(),
|
|
95
|
+
privateKeyPath: opts.identity,
|
|
96
|
+
dataDir: opts.dataDir,
|
|
97
|
+
outputPath: opts.output,
|
|
98
|
+
});
|
|
99
|
+
console.log(`Backup saved: ${out}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error(err instanceof Error ? err.message : err);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
program
|
|
109
|
+
.command('restore <zip-path> [user@host]')
|
|
110
|
+
.description('Restore backup zip to DATA_DIR (omit user@host for local).')
|
|
111
|
+
.option('-d, --data-dir <path>', 'DATA_DIR (overrides backup config)')
|
|
112
|
+
.option('-f, --force', 'Overwrite existing builder.db')
|
|
113
|
+
.option('-i, --identity <path>', 'SSH private key path')
|
|
114
|
+
.action(async (zipPath, target, opts) => {
|
|
115
|
+
try {
|
|
116
|
+
if (!target || !target.trim()) {
|
|
117
|
+
requireUbuntu();
|
|
118
|
+
await runRestoreLocal({ zipPath, dataDir: opts.dataDir, force: opts.force });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
await runRestore({
|
|
122
|
+
target: target.trim(),
|
|
123
|
+
privateKeyPath: opts.identity,
|
|
124
|
+
zipPath,
|
|
125
|
+
dataDir: opts.dataDir,
|
|
126
|
+
force: opts.force,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
console.log('Restore complete. Builder-server container restarted if present.');
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.error(err instanceof Error ? err.message : err);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const sshCert = program.command('ssh-cert').description('Passwordless auth: install = append your SSH public key; request = stub for future SSH CA.');
|
|
137
|
+
sshCert
|
|
138
|
+
.command('request')
|
|
139
|
+
.description('Request an SSH certificate for your key (not yet implemented)')
|
|
140
|
+
.option('--user <id>', 'Builder-server user id if tied to API')
|
|
141
|
+
.option('-i, --identity <path>', 'SSH private key path')
|
|
142
|
+
.action(async (opts) => {
|
|
143
|
+
try {
|
|
144
|
+
await runSshCertRequest({ privateKeyPath: opts.identity, target: '' });
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error(err instanceof Error ? err.message : err);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
sshCert
|
|
152
|
+
.command('install [user@host]')
|
|
153
|
+
.description('Append your local SSH public key to server (or local) authorized_keys for passwordless auth')
|
|
154
|
+
.option('-i, --identity <path>', 'SSH private key path (its .pub is installed)')
|
|
155
|
+
.action(async (target, opts) => {
|
|
156
|
+
try {
|
|
157
|
+
if (!target || !target.trim()) {
|
|
158
|
+
requireUbuntu();
|
|
159
|
+
runSshCertInstallLocal(opts.identity);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
await runSshCertInstall({ target: target.trim(), privateKeyPath: opts.identity });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
console.error(err instanceof Error ? err.message : err);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
program.parse();
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup config and paths. Stored in backup zip as config.json.
|
|
3
|
+
*/
|
|
4
|
+
export interface BackupConfig {
|
|
5
|
+
dataDir: string;
|
|
6
|
+
sslDir?: string;
|
|
7
|
+
devDomain?: string;
|
|
8
|
+
builderServerPort?: number;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
source: 'builder.db' | 'json';
|
|
11
|
+
}
|
|
12
|
+
export declare const DATA_DIR_DEFAULT = "/opt/aifabrix/builder-server/data";
|
|
13
|
+
export declare const BUILDER_DB = "builder.db";
|
|
14
|
+
export declare const BACKUP_DB = "backup.db";
|
|
15
|
+
export declare const CONFIG_JSON = "config.json";
|
|
16
|
+
export declare const KEY_FILES: readonly ["ca.crt", "ca.key", "secrets-encryption.key"];
|
|
17
|
+
export declare const JSON_FILES: readonly ["users.json", "tokens.json", "secrets.json", "ssh-public-keys.json"];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup config and paths. Stored in backup zip as config.json.
|
|
3
|
+
*/
|
|
4
|
+
export const DATA_DIR_DEFAULT = '/opt/aifabrix/builder-server/data';
|
|
5
|
+
export const BUILDER_DB = 'builder.db';
|
|
6
|
+
export const BACKUP_DB = 'backup.db';
|
|
7
|
+
export const CONFIG_JSON = 'config.json';
|
|
8
|
+
export const KEY_FILES = ['ca.crt', 'ca.key', 'secrets-encryption.key'];
|
|
9
|
+
export const JSON_FILES = ['users.json', 'tokens.json', 'secrets.json', 'ssh-public-keys.json'];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for backup config constants and types.
|
|
3
|
+
*/
|
|
4
|
+
import { DATA_DIR_DEFAULT, BUILDER_DB, BACKUP_DB, CONFIG_JSON, KEY_FILES, JSON_FILES, } from './config.js';
|
|
5
|
+
describe('config', () => {
|
|
6
|
+
describe('DATA_DIR_DEFAULT', () => {
|
|
7
|
+
it('is the expected builder-server data path', () => {
|
|
8
|
+
expect(DATA_DIR_DEFAULT).toBe('/opt/aifabrix/builder-server/data');
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
describe('BUILDER_DB', () => {
|
|
12
|
+
it('is builder.db', () => {
|
|
13
|
+
expect(BUILDER_DB).toBe('builder.db');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('BACKUP_DB', () => {
|
|
17
|
+
it('is backup.db', () => {
|
|
18
|
+
expect(BACKUP_DB).toBe('backup.db');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('CONFIG_JSON', () => {
|
|
22
|
+
it('is config.json', () => {
|
|
23
|
+
expect(CONFIG_JSON).toBe('config.json');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('KEY_FILES', () => {
|
|
27
|
+
it('includes ca.crt, ca.key, secrets-encryption.key', () => {
|
|
28
|
+
expect(KEY_FILES).toEqual(['ca.crt', 'ca.key', 'secrets-encryption.key']);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('JSON_FILES', () => {
|
|
32
|
+
it('includes users, tokens, secrets, ssh-public-keys', () => {
|
|
33
|
+
expect(JSON_FILES).toEqual([
|
|
34
|
+
'users.json',
|
|
35
|
+
'tokens.json',
|
|
36
|
+
'secrets.json',
|
|
37
|
+
'ssh-public-keys.json',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install command: upload setup script + nginx template and run via SSH with sudo, or locally (no target).
|
|
3
|
+
*/
|
|
4
|
+
import { type SSHConnectionOptions } from './ssh.js';
|
|
5
|
+
export interface InstallOptions extends SSHConnectionOptions {
|
|
6
|
+
dataDir?: string;
|
|
7
|
+
devDomain?: string;
|
|
8
|
+
sslDir?: string;
|
|
9
|
+
builderServerPort?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface InstallLocalOptions {
|
|
12
|
+
dataDir?: string;
|
|
13
|
+
devDomain?: string;
|
|
14
|
+
sslDir?: string;
|
|
15
|
+
builderServerPort?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function runInstall(options: InstallOptions): Promise<void>;
|
|
18
|
+
/** Run setup script locally (no SSH). Call requireUbuntu() before this. */
|
|
19
|
+
export declare function runInstallLocal(options?: InstallLocalOptions): void;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install command: upload setup script + nginx template and run via SSH with sudo, or locally (no target).
|
|
3
|
+
*/
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
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 getSetupScript() {
|
|
12
|
+
const p = path.join(ASSETS_DIR, 'setup-dev-server-no-node.sh');
|
|
13
|
+
return fs.readFileSync(p, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
function getNginxTemplate() {
|
|
16
|
+
const p = path.join(ASSETS_DIR, 'builder', 'builder-server', 'nginx-builder-server.conf.template');
|
|
17
|
+
return fs.readFileSync(p, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
export async function runInstall(options) {
|
|
20
|
+
const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
|
|
21
|
+
const devDomain = options.devDomain ?? 'dev.aifabrix.dev';
|
|
22
|
+
const sslDir = options.sslDir ?? '/opt/aifabrix/ssl';
|
|
23
|
+
const builderServerPort = options.builderServerPort ?? 3000;
|
|
24
|
+
const conn = await createSSHClient(options);
|
|
25
|
+
try {
|
|
26
|
+
const tmpDir = `/tmp/aifabrix-setup-${Date.now()}`;
|
|
27
|
+
await exec(conn, `mkdir -p ${tmpDir}/builder/builder-server`);
|
|
28
|
+
await exec(conn, `chmod 755 ${tmpDir}`);
|
|
29
|
+
const setupScript = getSetupScript();
|
|
30
|
+
const nginxTemplate = getNginxTemplate();
|
|
31
|
+
await writeFile(conn, `${tmpDir}/setup.sh`, setupScript);
|
|
32
|
+
await writeFile(conn, `${tmpDir}/builder/builder-server/nginx-builder-server.conf.template`, nginxTemplate);
|
|
33
|
+
await exec(conn, `chmod +x ${tmpDir}/setup.sh`);
|
|
34
|
+
const env = [
|
|
35
|
+
`REPO_ROOT=${tmpDir}`,
|
|
36
|
+
`DATA_DIR=${dataDir}`,
|
|
37
|
+
`DEV_DOMAIN=${devDomain}`,
|
|
38
|
+
`SSL_DIR=${sslDir}`,
|
|
39
|
+
`BUILDER_SERVER_PORT=${builderServerPort}`,
|
|
40
|
+
].join(' ');
|
|
41
|
+
const cmd = `sudo ${env} ${tmpDir}/setup.sh`;
|
|
42
|
+
const result = await exec(conn, cmd);
|
|
43
|
+
if (result.stderr)
|
|
44
|
+
process.stderr.write(result.stderr);
|
|
45
|
+
if (result.stdout)
|
|
46
|
+
process.stdout.write(result.stdout);
|
|
47
|
+
if (result.code !== 0) {
|
|
48
|
+
throw new Error(`Install script exited with code ${result.code}`);
|
|
49
|
+
}
|
|
50
|
+
await exec(conn, `rm -rf ${tmpDir}`);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
close(conn);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Run setup script locally (no SSH). Call requireUbuntu() before this. */
|
|
57
|
+
export function runInstallLocal(options = {}) {
|
|
58
|
+
const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
|
|
59
|
+
const devDomain = options.devDomain ?? 'dev.aifabrix.dev';
|
|
60
|
+
const sslDir = options.sslDir ?? '/opt/aifabrix/ssl';
|
|
61
|
+
const builderServerPort = options.builderServerPort ?? 3000;
|
|
62
|
+
const tmpDir = `/tmp/aifabrix-setup-${Date.now()}`;
|
|
63
|
+
const builderSubdir = path.join(tmpDir, 'builder', 'builder-server');
|
|
64
|
+
fs.mkdirSync(builderSubdir, { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(path.join(tmpDir, 'setup.sh'), getSetupScript(), { mode: 0o755 });
|
|
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}`;
|
|
69
|
+
execSync(`sudo ${env} ${tmpDir}/setup.sh`, { stdio: 'inherit' });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve local SSH public key for install (default paths or from -i private key path).
|
|
3
|
+
* Never log or return private key material.
|
|
4
|
+
*/
|
|
5
|
+
/** Options for testing (inject homedir). */
|
|
6
|
+
export interface ResolveLocalPublicKeyOptions {
|
|
7
|
+
homedir?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve public key content: first try identityPath.pub if given, then ~/.ssh/id_ed25519.pub, then id_rsa.pub.
|
|
11
|
+
* Returns single trimmed line. Throws with actionable message if none found.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveLocalPublicKey(identityPath?: string, options?: ResolveLocalPublicKeyOptions): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve local SSH public key for install (default paths or from -i private key path).
|
|
3
|
+
* Never log or return private key material.
|
|
4
|
+
*/
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
const DEFAULT_PUB_PATHS = ['id_ed25519.pub', 'id_rsa.pub'];
|
|
9
|
+
const NO_KEY_MESSAGE = 'No default SSH public key found. Create one with: ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve public key content: first try identityPath.pub if given, then ~/.ssh/id_ed25519.pub, then id_rsa.pub.
|
|
12
|
+
* Returns single trimmed line. Throws with actionable message if none found.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveLocalPublicKey(identityPath, options) {
|
|
15
|
+
const home = options?.homedir ?? os.homedir();
|
|
16
|
+
const sshDir = path.join(home, '.ssh');
|
|
17
|
+
if (identityPath) {
|
|
18
|
+
const resolved = path.resolve(identityPath);
|
|
19
|
+
const pubPath = resolved.endsWith('.pub') ? resolved : `${resolved}.pub`;
|
|
20
|
+
if (fs.existsSync(pubPath)) {
|
|
21
|
+
const content = fs.readFileSync(pubPath, 'utf8').trim().split(/\r?\n/)[0];
|
|
22
|
+
if (content)
|
|
23
|
+
return content;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const name of DEFAULT_PUB_PATHS) {
|
|
27
|
+
const full = path.join(sshDir, name);
|
|
28
|
+
if (fs.existsSync(full)) {
|
|
29
|
+
const content = fs.readFileSync(full, 'utf8').trim().split(/\r?\n/)[0];
|
|
30
|
+
if (content)
|
|
31
|
+
return content;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(NO_KEY_MESSAGE);
|
|
35
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for local SSH public key resolution (using temp dir via homedir option).
|
|
3
|
+
*/
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import { resolveLocalPublicKey } from './local-pubkey.js';
|
|
7
|
+
const TEST_TMP = path.join(process.cwd(), 'tmp', 'local-pubkey');
|
|
8
|
+
describe('local-pubkey', () => {
|
|
9
|
+
let fakeHome;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
fs.mkdirSync(TEST_TMP, { recursive: true });
|
|
12
|
+
fakeHome = fs.mkdtempSync(path.join(TEST_TMP, 'home-'));
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
try {
|
|
16
|
+
fs.rmSync(TEST_TMP, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// ignore
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('throws with actionable message when no default key exists', () => {
|
|
23
|
+
expect(() => resolveLocalPublicKey(undefined, { homedir: fakeHome })).toThrow(/No default SSH public key found/);
|
|
24
|
+
expect(() => resolveLocalPublicKey(undefined, { homedir: fakeHome })).toThrow(/ssh-keygen/);
|
|
25
|
+
});
|
|
26
|
+
it('returns content of ~/.ssh/id_ed25519.pub when it exists', () => {
|
|
27
|
+
const sshDir = path.join(fakeHome, '.ssh');
|
|
28
|
+
fs.mkdirSync(sshDir, { recursive: true });
|
|
29
|
+
const content = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host';
|
|
30
|
+
fs.writeFileSync(path.join(sshDir, 'id_ed25519.pub'), content);
|
|
31
|
+
expect(resolveLocalPublicKey(undefined, { homedir: fakeHome })).toBe(content);
|
|
32
|
+
});
|
|
33
|
+
it('returns content of ~/.ssh/id_rsa.pub when id_ed25519.pub absent', () => {
|
|
34
|
+
const sshDir = path.join(fakeHome, '.ssh');
|
|
35
|
+
fs.mkdirSync(sshDir, { recursive: true });
|
|
36
|
+
const content = 'ssh-rsa AAAAB3 user@host';
|
|
37
|
+
fs.writeFileSync(path.join(sshDir, 'id_rsa.pub'), content);
|
|
38
|
+
expect(resolveLocalPublicKey(undefined, { homedir: fakeHome })).toBe(content);
|
|
39
|
+
});
|
|
40
|
+
it('prefers id_ed25519.pub over id_rsa.pub', () => {
|
|
41
|
+
const sshDir = path.join(fakeHome, '.ssh');
|
|
42
|
+
fs.mkdirSync(sshDir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(sshDir, 'id_rsa.pub'), 'ssh-rsa old');
|
|
44
|
+
fs.writeFileSync(path.join(sshDir, 'id_ed25519.pub'), 'ssh-ed25519 preferred');
|
|
45
|
+
expect(resolveLocalPublicKey(undefined, { homedir: fakeHome })).toBe('ssh-ed25519 preferred');
|
|
46
|
+
});
|
|
47
|
+
it('returns content of identityPath.pub when -i path given', () => {
|
|
48
|
+
const customPub = path.join(fakeHome, 'mykey.pub');
|
|
49
|
+
const content = 'ssh-ed25519 AAAAC3 user@pc';
|
|
50
|
+
fs.writeFileSync(customPub, content);
|
|
51
|
+
expect(resolveLocalPublicKey(path.join(fakeHome, 'mykey'), { homedir: fakeHome })).toBe(content);
|
|
52
|
+
});
|
|
53
|
+
it('uses identityPath as-is when it already ends with .pub', () => {
|
|
54
|
+
const customPub = path.join(fakeHome, 'key.pub');
|
|
55
|
+
fs.writeFileSync(customPub, 'ssh-ed25519 from-pub');
|
|
56
|
+
expect(resolveLocalPublicKey(customPub, { homedir: fakeHome })).toBe('ssh-ed25519 from-pub');
|
|
57
|
+
});
|
|
58
|
+
it('returns first line only when file has multiple lines', () => {
|
|
59
|
+
const sshDir = path.join(fakeHome, '.ssh');
|
|
60
|
+
fs.mkdirSync(sshDir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(sshDir, 'id_ed25519.pub'), 'line1\nline2\n');
|
|
62
|
+
expect(resolveLocalPublicKey(undefined, { homedir: fakeHome })).toBe('line1');
|
|
63
|
+
});
|
|
64
|
+
});
|