@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.
Files changed (43) hide show
  1. package/README.md +68 -0
  2. package/assets/builder/builder-server/nginx-builder-server.conf.template +26 -0
  3. package/assets/cron-backup.sh +25 -0
  4. package/assets/setup-dev-server-no-node.sh +227 -0
  5. package/dist/backup-db.d.ts +13 -0
  6. package/dist/backup-db.js +125 -0
  7. package/dist/backup-db.spec.d.ts +5 -0
  8. package/dist/backup-db.spec.js +260 -0
  9. package/dist/backup-schedule.d.ts +17 -0
  10. package/dist/backup-schedule.js +60 -0
  11. package/dist/backup.d.ts +15 -0
  12. package/dist/backup.js +184 -0
  13. package/dist/backup.spec.d.ts +4 -0
  14. package/dist/backup.spec.js +199 -0
  15. package/dist/cli.d.ts +6 -0
  16. package/dist/cli.js +170 -0
  17. package/dist/config.d.ts +17 -0
  18. package/dist/config.js +9 -0
  19. package/dist/config.spec.d.ts +4 -0
  20. package/dist/config.spec.js +41 -0
  21. package/dist/install.d.ts +19 -0
  22. package/dist/install.js +74 -0
  23. package/dist/local-pubkey.d.ts +13 -0
  24. package/dist/local-pubkey.js +35 -0
  25. package/dist/local-pubkey.spec.d.ts +4 -0
  26. package/dist/local-pubkey.spec.js +64 -0
  27. package/dist/restore.d.ts +17 -0
  28. package/dist/restore.js +101 -0
  29. package/dist/restore.spec.d.ts +4 -0
  30. package/dist/restore.spec.js +215 -0
  31. package/dist/ssh-cert.d.ts +18 -0
  32. package/dist/ssh-cert.js +92 -0
  33. package/dist/ssh-cert.spec.d.ts +4 -0
  34. package/dist/ssh-cert.spec.js +101 -0
  35. package/dist/ssh.d.ts +27 -0
  36. package/dist/ssh.js +122 -0
  37. package/dist/ssh.spec.d.ts +4 -0
  38. package/dist/ssh.spec.js +31 -0
  39. package/dist/ubuntu.d.ts +7 -0
  40. package/dist/ubuntu.js +33 -0
  41. package/dist/ubuntu.spec.d.ts +4 -0
  42. package/dist/ubuntu.spec.js +56 -0
  43. package/package.json +48 -0
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Restore: unpack zip (backup.db + config + keys), push builder.db and keys to server DATA_DIR.
3
+ */
4
+ import { type SSHConnectionOptions } from './ssh.js';
5
+ export interface RestoreOptions extends SSHConnectionOptions {
6
+ zipPath: string;
7
+ dataDir?: string;
8
+ force?: boolean;
9
+ }
10
+ export interface RestoreLocalOptions {
11
+ zipPath: string;
12
+ dataDir?: string;
13
+ force?: boolean;
14
+ }
15
+ export declare function runRestore(options: RestoreOptions): Promise<void>;
16
+ /** Run restore to local DATA_DIR (no SSH). Call requireUbuntu() before this. */
17
+ export declare function runRestoreLocal(options: RestoreLocalOptions): Promise<void>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Restore: unpack zip (backup.db + config + keys), push builder.db and keys to server DATA_DIR.
3
+ */
4
+ import * as path from 'path';
5
+ import * as fs from 'fs';
6
+ import { execSync } from 'child_process';
7
+ import extract from 'extract-zip';
8
+ import { createSSHClient, writeFile, exec, close } from './ssh.js';
9
+ import { DATA_DIR_DEFAULT, BUILDER_DB, BACKUP_DB, CONFIG_JSON, KEY_FILES } from './config.js';
10
+ export async function runRestore(options) {
11
+ const zipPath = path.resolve(process.cwd(), options.zipPath);
12
+ if (!fs.existsSync(zipPath)) {
13
+ throw new Error(`Zip not found: ${zipPath}`);
14
+ }
15
+ const tmpDir = path.join(process.cwd(), `.af-restore-${Date.now()}`);
16
+ fs.mkdirSync(tmpDir, { recursive: true });
17
+ try {
18
+ await extract(zipPath, { dir: tmpDir });
19
+ const configPath = path.join(tmpDir, CONFIG_JSON);
20
+ const backupDbPath = path.join(tmpDir, BACKUP_DB);
21
+ if (!fs.existsSync(configPath) || !fs.existsSync(backupDbPath)) {
22
+ throw new Error('Invalid backup zip: missing config.json or backup.db');
23
+ }
24
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
25
+ const dataDir = options.dataDir ?? config.dataDir ?? DATA_DIR_DEFAULT;
26
+ const conn = await createSSHClient(options);
27
+ try {
28
+ await exec(conn, `mkdir -p ${dataDir}`);
29
+ if (!options.force) {
30
+ const check = await exec(conn, `test -f ${dataDir}/${BUILDER_DB} && echo exists || echo missing`);
31
+ if (check.stdout.trim() === 'exists') {
32
+ throw new Error(`DATA_DIR already has ${BUILDER_DB}. Use --force to overwrite, or specify a different dataDir.`);
33
+ }
34
+ }
35
+ const dbBuf = fs.readFileSync(backupDbPath);
36
+ await writeFile(conn, `${dataDir}/${BUILDER_DB}`, dbBuf);
37
+ await exec(conn, `chmod 644 ${dataDir}/${BUILDER_DB}`);
38
+ await exec(conn, `chown 1001:65533 ${dataDir}/${BUILDER_DB}`);
39
+ for (const k of KEY_FILES) {
40
+ const localPath = path.join(tmpDir, k);
41
+ if (fs.existsSync(localPath)) {
42
+ const buf = fs.readFileSync(localPath);
43
+ await writeFile(conn, `${dataDir}/${k}`, buf);
44
+ await exec(conn, `chmod 600 ${dataDir}/${k}`);
45
+ await exec(conn, `chown 1001:65533 ${dataDir}/${k}`);
46
+ }
47
+ }
48
+ const restart = await exec(conn, 'docker restart builder-server 2>/dev/null || true');
49
+ if (restart.stderr && !restart.stderr.includes('No such container')) {
50
+ process.stderr.write(restart.stderr);
51
+ }
52
+ }
53
+ finally {
54
+ close(conn);
55
+ }
56
+ }
57
+ finally {
58
+ fs.rmSync(tmpDir, { recursive: true, force: true });
59
+ }
60
+ }
61
+ /** Run restore to local DATA_DIR (no SSH). Call requireUbuntu() before this. */
62
+ export async function runRestoreLocal(options) {
63
+ const zipPath = path.resolve(process.cwd(), options.zipPath);
64
+ if (!fs.existsSync(zipPath)) {
65
+ throw new Error(`Zip not found: ${zipPath}`);
66
+ }
67
+ const tmpDir = path.join(process.cwd(), `.af-restore-${Date.now()}`);
68
+ fs.mkdirSync(tmpDir, { recursive: true });
69
+ try {
70
+ await extract(zipPath, { dir: tmpDir });
71
+ const configPath = path.join(tmpDir, CONFIG_JSON);
72
+ const backupDbPath = path.join(tmpDir, BACKUP_DB);
73
+ if (!fs.existsSync(configPath) || !fs.existsSync(backupDbPath)) {
74
+ throw new Error('Invalid backup zip: missing config.json or backup.db');
75
+ }
76
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
77
+ const dataDir = options.dataDir ?? config.dataDir ?? DATA_DIR_DEFAULT;
78
+ fs.mkdirSync(dataDir, { recursive: true });
79
+ if (!options.force && fs.existsSync(path.join(dataDir, BUILDER_DB))) {
80
+ throw new Error(`DATA_DIR already has ${BUILDER_DB}. Use --force to overwrite, or specify a different dataDir.`);
81
+ }
82
+ const dbBuf = fs.readFileSync(backupDbPath);
83
+ fs.writeFileSync(path.join(dataDir, BUILDER_DB), dbBuf, { mode: 0o644 });
84
+ for (const k of KEY_FILES) {
85
+ const localPath = path.join(tmpDir, k);
86
+ if (fs.existsSync(localPath)) {
87
+ fs.copyFileSync(localPath, path.join(dataDir, k));
88
+ fs.chmodSync(path.join(dataDir, k), 0o600);
89
+ }
90
+ }
91
+ try {
92
+ execSync('docker restart builder-server 2>/dev/null || true', { stdio: 'inherit' });
93
+ }
94
+ catch {
95
+ // container may not exist
96
+ }
97
+ }
98
+ finally {
99
+ fs.rmSync(tmpDir, { recursive: true, force: true });
100
+ }
101
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for runRestore and runRestoreLocal (mocked SSH or no SSH).
3
+ */
4
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Tests for runRestore and runRestoreLocal (mocked SSH or no SSH).
3
+ */
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import archiver from 'archiver';
7
+ import Database from 'better-sqlite3';
8
+ import { BACKUP_DB, CONFIG_JSON, KEY_FILES, BUILDER_DB } from './config.js';
9
+ import { runRestore, runRestoreLocal } from './restore.js';
10
+ const TEST_TMP_BASE = path.join(process.cwd(), 'tmp');
11
+ const mockConn = {};
12
+ const mockExec = jest.fn();
13
+ const mockWriteFile = jest.fn();
14
+ const mockClose = jest.fn();
15
+ jest.mock('./ssh.js', () => ({
16
+ createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
17
+ writeFile: jest.fn((...args) => mockWriteFile(...args)),
18
+ exec: jest.fn((...args) => mockExec(...args)),
19
+ close: jest.fn((...args) => mockClose(...args)),
20
+ }));
21
+ jest.mock('child_process', () => ({ execSync: jest.fn() }));
22
+ describe('runRestore', () => {
23
+ let zipPath;
24
+ let workDir;
25
+ beforeEach(() => {
26
+ jest.clearAllMocks();
27
+ fs.mkdirSync(TEST_TMP_BASE, { recursive: true });
28
+ workDir = fs.mkdtempSync(path.join(TEST_TMP_BASE, 'af-restore-'));
29
+ zipPath = path.join(workDir, 'backup.zip');
30
+ mockExec.mockResolvedValue({ stdout: '', stderr: '', code: 0 });
31
+ mockWriteFile.mockResolvedValue(undefined);
32
+ });
33
+ afterEach(() => {
34
+ try {
35
+ fs.rmSync(workDir, { recursive: true, force: true });
36
+ }
37
+ catch {
38
+ // ignore
39
+ }
40
+ });
41
+ it('throws when zip not found', async () => {
42
+ await expect(runRestore({ target: 'user@host', zipPath: path.join(workDir, 'nonexistent.zip') })).rejects.toThrow('Zip not found');
43
+ });
44
+ it('unpacks zip and pushes builder.db and keys to server', async () => {
45
+ const zipContentDir = path.join(workDir, 'content');
46
+ fs.mkdirSync(zipContentDir, { recursive: true });
47
+ await new Promise((resolve, reject) => {
48
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
49
+ const db = new Database(dbPath);
50
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY);');
51
+ db.close();
52
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify({ dataDir: '/opt/data', createdAt: new Date().toISOString(), source: 'builder.db' }));
53
+ for (const k of KEY_FILES) {
54
+ fs.writeFileSync(path.join(zipContentDir, k), `content-${k}`);
55
+ }
56
+ const archive = archiver('zip', { zlib: { level: 6 } });
57
+ const out = fs.createWriteStream(zipPath);
58
+ out.on('close', () => resolve());
59
+ archive.on('error', reject);
60
+ archive.pipe(out);
61
+ archive.file(dbPath, { name: BACKUP_DB });
62
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
63
+ for (const k of KEY_FILES) {
64
+ archive.file(path.join(zipContentDir, k), { name: k });
65
+ }
66
+ archive.finalize();
67
+ });
68
+ await runRestore({ target: 'user@host', zipPath, force: true });
69
+ expect(mockWriteFile).toHaveBeenCalled();
70
+ expect(mockExec).toHaveBeenCalled();
71
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
72
+ });
73
+ it('throws when zip missing config or backup.db', async () => {
74
+ const badZip = path.join(workDir, 'bad.zip');
75
+ await new Promise((resolve, reject) => {
76
+ const archive = archiver('zip', { zlib: { level: 6 } });
77
+ const out = fs.createWriteStream(badZip);
78
+ out.on('close', () => resolve());
79
+ archive.on('error', reject);
80
+ archive.pipe(out);
81
+ archive.append('dummy', { name: 'dummy.txt' });
82
+ archive.finalize();
83
+ });
84
+ await expect(runRestore({ target: 'user@host', zipPath: badZip })).rejects.toThrow('Invalid backup zip');
85
+ });
86
+ it('throws when builder.db exists on server and force not set', async () => {
87
+ const zipContentDir = path.join(workDir, 'content');
88
+ fs.mkdirSync(zipContentDir, { recursive: true });
89
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
90
+ const db = new Database(dbPath);
91
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY);');
92
+ db.close();
93
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify({ dataDir: '/opt/data', createdAt: new Date().toISOString(), source: 'builder.db' }));
94
+ await new Promise((resolve, reject) => {
95
+ const archive = archiver('zip', { zlib: { level: 6 } });
96
+ const out = fs.createWriteStream(zipPath);
97
+ out.on('close', () => resolve());
98
+ archive.on('error', reject);
99
+ archive.pipe(out);
100
+ archive.file(dbPath, { name: BACKUP_DB });
101
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
102
+ archive.finalize();
103
+ });
104
+ mockExec.mockImplementation((_conn, cmd) => {
105
+ if (cmd.includes('test -f') && cmd.includes('builder.db')) {
106
+ return Promise.resolve({ stdout: 'exists\n', stderr: '', code: 0 });
107
+ }
108
+ return Promise.resolve({ stdout: '', stderr: '', code: 0 });
109
+ });
110
+ await expect(runRestore({ target: 'user@host', zipPath })).rejects.toThrow('DATA_DIR already has builder.db');
111
+ });
112
+ it('writes docker restart stderr when not "No such container"', async () => {
113
+ const zipContentDir = path.join(workDir, 'content');
114
+ fs.mkdirSync(zipContentDir, { recursive: true });
115
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
116
+ const db = new Database(dbPath);
117
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY);');
118
+ db.close();
119
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify({ dataDir: '/opt/data', createdAt: new Date().toISOString(), source: 'builder.db' }));
120
+ await new Promise((resolve, reject) => {
121
+ const archive = archiver('zip', { zlib: { level: 6 } });
122
+ const out = fs.createWriteStream(zipPath);
123
+ out.on('close', () => resolve());
124
+ archive.on('error', reject);
125
+ archive.pipe(out);
126
+ archive.file(dbPath, { name: BACKUP_DB });
127
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
128
+ archive.finalize();
129
+ });
130
+ mockExec.mockImplementation((_conn, cmd) => {
131
+ if (cmd.includes('docker restart')) {
132
+ return Promise.resolve({ stdout: '', stderr: 'Error: something failed\n', code: 1 });
133
+ }
134
+ return Promise.resolve({ stdout: '', stderr: '', code: 0 });
135
+ });
136
+ const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
137
+ await runRestore({ target: 'user@host', zipPath, force: true });
138
+ expect(stderrSpy).toHaveBeenCalledWith('Error: something failed\n');
139
+ stderrSpy.mockRestore();
140
+ });
141
+ });
142
+ describe('runRestoreLocal', () => {
143
+ let workDir;
144
+ let zipPath;
145
+ beforeEach(() => {
146
+ fs.mkdirSync(TEST_TMP_BASE, { recursive: true });
147
+ workDir = fs.mkdtempSync(path.join(TEST_TMP_BASE, 'af-restore-local-'));
148
+ zipPath = path.join(workDir, 'backup.zip');
149
+ });
150
+ afterEach(() => {
151
+ try {
152
+ fs.rmSync(workDir, { recursive: true, force: true });
153
+ }
154
+ catch {
155
+ // ignore
156
+ }
157
+ });
158
+ it('throws when zip not found', async () => {
159
+ await expect(runRestoreLocal({ zipPath: path.join(workDir, 'nonexistent.zip') })).rejects.toThrow('Zip not found');
160
+ });
161
+ it('unpacks zip to local dataDir and writes builder.db and keys', async () => {
162
+ const zipContentDir = path.join(workDir, 'content');
163
+ fs.mkdirSync(zipContentDir, { recursive: true });
164
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
165
+ const db = new Database(dbPath);
166
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY); INSERT INTO users (id) VALUES (\'1\');');
167
+ db.close();
168
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify({ dataDir: '/opt/data', createdAt: new Date().toISOString(), source: 'builder.db' }));
169
+ for (const k of KEY_FILES) {
170
+ fs.writeFileSync(path.join(zipContentDir, k), `content-${k}`);
171
+ }
172
+ await new Promise((resolve, reject) => {
173
+ const archive = archiver('zip', { zlib: { level: 6 } });
174
+ const out = fs.createWriteStream(zipPath);
175
+ out.on('close', () => resolve());
176
+ archive.on('error', reject);
177
+ archive.pipe(out);
178
+ archive.file(dbPath, { name: BACKUP_DB });
179
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
180
+ for (const k of KEY_FILES) {
181
+ archive.file(path.join(zipContentDir, k), { name: k });
182
+ }
183
+ archive.finalize();
184
+ });
185
+ const dataDir = path.join(workDir, 'data');
186
+ await runRestoreLocal({ zipPath, dataDir, force: true });
187
+ expect(fs.existsSync(path.join(dataDir, BUILDER_DB))).toBe(true);
188
+ for (const k of KEY_FILES) {
189
+ expect(fs.readFileSync(path.join(dataDir, k), 'utf8')).toBe(`content-${k}`);
190
+ }
191
+ });
192
+ it('throws when builder.db exists and force not set', async () => {
193
+ const zipContentDir = path.join(workDir, 'content');
194
+ fs.mkdirSync(zipContentDir, { recursive: true });
195
+ const dbPath = path.join(zipContentDir, BACKUP_DB);
196
+ const db = new Database(dbPath);
197
+ db.exec('CREATE TABLE users (id TEXT PRIMARY KEY);');
198
+ db.close();
199
+ fs.writeFileSync(path.join(zipContentDir, CONFIG_JSON), JSON.stringify({ dataDir: '/opt/data', createdAt: new Date().toISOString(), source: 'builder.db' }));
200
+ await new Promise((resolve, reject) => {
201
+ const archive = archiver('zip', { zlib: { level: 6 } });
202
+ const out = fs.createWriteStream(zipPath);
203
+ out.on('close', () => resolve());
204
+ archive.on('error', reject);
205
+ archive.pipe(out);
206
+ archive.file(dbPath, { name: BACKUP_DB });
207
+ archive.file(path.join(zipContentDir, CONFIG_JSON), { name: CONFIG_JSON });
208
+ archive.finalize();
209
+ });
210
+ const dataDir = path.join(workDir, 'data');
211
+ fs.mkdirSync(dataDir, { recursive: true });
212
+ fs.writeFileSync(path.join(dataDir, BUILDER_DB), 'existing');
213
+ await expect(runRestoreLocal({ zipPath, dataDir })).rejects.toThrow('DATA_DIR already has builder.db');
214
+ });
215
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SSH certificate commands for passwordless auth.
3
+ * request: obtain SSH cert (stub — requires builder-server or CA integration).
4
+ * install: append local SSH public key to server ~/.ssh/authorized_keys (or local when no target).
5
+ */
6
+ import { type SSHConnectionOptions } from './ssh.js';
7
+ export declare function runSshCertRequest(_options: SSHConnectionOptions & {
8
+ user?: string;
9
+ }): Promise<void>;
10
+ export declare function runSshCertInstall(options: SSHConnectionOptions): Promise<void>;
11
+ /** Options for testing (inject homedir). */
12
+ export interface RunSshCertInstallLocalOptions {
13
+ homedir?: string;
14
+ }
15
+ /**
16
+ * Append local default public key to local ~/.ssh/authorized_keys. Call after requireUbuntu().
17
+ */
18
+ export declare function runSshCertInstallLocal(identityPath?: string, options?: RunSshCertInstallLocalOptions): void;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SSH certificate commands for passwordless auth.
3
+ * request: obtain SSH cert (stub — requires builder-server or CA integration).
4
+ * install: append local SSH public key to server ~/.ssh/authorized_keys (or local when no target).
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { createSSHClient, exec, readFile, writeFile, getRemoteHome, close } from './ssh.js';
10
+ import { resolveLocalPublicKey } from './local-pubkey.js';
11
+ const SSH_FX_NO_SUCH_FILE = 2;
12
+ function authKeysPathLocal(homeOverride) {
13
+ return path.join(homeOverride ?? os.homedir(), '.ssh', 'authorized_keys');
14
+ }
15
+ function keyPartFromLine(line) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#'))
18
+ return null;
19
+ const parts = trimmed.split(/\s+/);
20
+ return parts[1] ?? null;
21
+ }
22
+ function keyAlreadyPresent(content, keyLine) {
23
+ const ourPart = keyPartFromLine(keyLine);
24
+ if (!ourPart)
25
+ return false;
26
+ const lines = content.split(/\r?\n/);
27
+ return lines.some((l) => keyPartFromLine(l) === ourPart);
28
+ }
29
+ export async function runSshCertRequest(_options) {
30
+ // Stub: would call builder-server API or local CA to sign user's public key.
31
+ console.log('ssh-cert request: not yet implemented. Requires SSH CA (e.g. builder-server or dedicated CA).');
32
+ console.log('Use af-server with key-based SSH (ssh-agent or -i key) for passwordless auth in the meantime.');
33
+ }
34
+ export async function runSshCertInstall(options) {
35
+ const keyLine = resolveLocalPublicKey(options.privateKeyPath).trim();
36
+ const conn = await createSSHClient(options);
37
+ try {
38
+ const home = await getRemoteHome(conn);
39
+ const sshDir = `${home}/.ssh`;
40
+ const authKeysPath = `${sshDir}/authorized_keys`;
41
+ await exec(conn, `mkdir -p "${sshDir.replace(/"/g, '\\"')}" && chmod 700 "${sshDir.replace(/"/g, '\\"')}"`);
42
+ let currentContent = '';
43
+ try {
44
+ const buf = await readFile(conn, authKeysPath);
45
+ currentContent = buf.toString('utf8');
46
+ }
47
+ catch (err) {
48
+ const code = err?.code;
49
+ if (code !== SSH_FX_NO_SUCH_FILE) {
50
+ throw new Error('Could not read authorized_keys; check server permissions.');
51
+ }
52
+ }
53
+ if (keyAlreadyPresent(currentContent, keyLine)) {
54
+ console.log('Key already installed.');
55
+ return;
56
+ }
57
+ const date = new Date().toISOString().slice(0, 10);
58
+ const newLine = `${keyLine} # af-server ${date}\n`;
59
+ const newContent = currentContent + (currentContent && !currentContent.endsWith('\n') ? '\n' : '') + newLine;
60
+ await writeFile(conn, authKeysPath, newContent);
61
+ await exec(conn, `chmod 600 "${authKeysPath.replace(/"/g, '\\"')}"`);
62
+ console.log(`SSH key installed for ${options.target}. You can now run install/backup/restore without password (use this key or ssh-agent).`);
63
+ }
64
+ finally {
65
+ close(conn);
66
+ }
67
+ }
68
+ /**
69
+ * Append local default public key to local ~/.ssh/authorized_keys. Call after requireUbuntu().
70
+ */
71
+ export function runSshCertInstallLocal(identityPath, options) {
72
+ const home = options?.homedir ?? os.homedir();
73
+ const keyLine = resolveLocalPublicKey(identityPath, { homedir: home }).trim();
74
+ const authKeysPath = authKeysPathLocal(home);
75
+ const sshDir = path.dirname(authKeysPath);
76
+ if (!fs.existsSync(sshDir)) {
77
+ fs.mkdirSync(sshDir, { mode: 0o700 });
78
+ }
79
+ let currentContent = '';
80
+ if (fs.existsSync(authKeysPath)) {
81
+ currentContent = fs.readFileSync(authKeysPath, 'utf8');
82
+ }
83
+ if (keyAlreadyPresent(currentContent, keyLine)) {
84
+ console.log('Key already installed.');
85
+ return;
86
+ }
87
+ const date = new Date().toISOString().slice(0, 10);
88
+ const newLine = `${keyLine} # af-server ${date}\n`;
89
+ const newContent = currentContent + (currentContent && !currentContent.endsWith('\n') ? '\n' : '') + newLine;
90
+ fs.writeFileSync(authKeysPath, newContent, { mode: 0o600 });
91
+ console.log('SSH key installed for local. You can now run install/backup/restore without password (use this key or ssh-agent).');
92
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for ssh-cert: request (stub), install (remote + local), and edge cases.
3
+ */
4
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tests for ssh-cert: request (stub), install (remote + local), and edge cases.
3
+ */
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { runSshCertRequest, runSshCertInstall, runSshCertInstallLocal } from './ssh-cert.js';
7
+ const mockConn = {};
8
+ const mockExec = jest.fn();
9
+ const mockClose = jest.fn();
10
+ const mockReadFile = jest.fn();
11
+ const mockWriteFile = jest.fn();
12
+ const mockGetRemoteHome = jest.fn();
13
+ const KEY_LINE = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@local';
14
+ jest.mock('./local-pubkey.js', () => ({
15
+ resolveLocalPublicKey: jest.fn(() => KEY_LINE),
16
+ }));
17
+ jest.mock('./ssh.js', () => ({
18
+ createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
19
+ exec: jest.fn((...args) => mockExec(...args)),
20
+ readFile: jest.fn((...args) => mockReadFile(...args)),
21
+ writeFile: jest.fn((...args) => mockWriteFile(...args)),
22
+ getRemoteHome: jest.fn((...args) => mockGetRemoteHome(...args)),
23
+ close: jest.fn((...args) => mockClose(...args)),
24
+ }));
25
+ const TEST_TMP = path.join(process.cwd(), 'tmp', 'ssh-cert');
26
+ describe('ssh-cert', () => {
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ mockExec.mockResolvedValue({ stdout: '/home/user', stderr: '', code: 0 });
30
+ mockGetRemoteHome.mockResolvedValue('/home/user');
31
+ mockReadFile.mockRejectedValue({ code: 2 }); // SSH_FX_NO_SUCH_FILE = no existing authorized_keys
32
+ mockWriteFile.mockResolvedValue(undefined);
33
+ jest.spyOn(console, 'log').mockImplementation(() => { });
34
+ });
35
+ afterEach(() => {
36
+ console.log.mockRestore();
37
+ });
38
+ describe('runSshCertRequest', () => {
39
+ it('runs without throwing and logs stub message', async () => {
40
+ await runSshCertRequest({ target: 'user@host' });
41
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('ssh-cert request'));
42
+ });
43
+ });
44
+ describe('runSshCertInstall', () => {
45
+ it('resolves key, connects, ensures .ssh, appends key to authorized_keys, and prints success', async () => {
46
+ await runSshCertInstall({ target: 'user@host' });
47
+ expect(mockGetRemoteHome).toHaveBeenCalledWith(mockConn);
48
+ expect(mockExec).toHaveBeenCalled();
49
+ expect(mockReadFile).toHaveBeenCalled();
50
+ expect(mockWriteFile).toHaveBeenCalled();
51
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
52
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH key installed for user@host'));
53
+ });
54
+ it('when key already present, does not write and logs "Key already installed."', async () => {
55
+ const existingContent = `${KEY_LINE}\n`;
56
+ mockReadFile.mockResolvedValueOnce(Buffer.from(existingContent, 'utf8'));
57
+ await runSshCertInstall({ target: 'user@host' });
58
+ expect(mockWriteFile).not.toHaveBeenCalled();
59
+ expect(console.log).toHaveBeenCalledWith('Key already installed.');
60
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
61
+ });
62
+ it('throws actionable error when readFile fails with code other than NO_SUCH_FILE', async () => {
63
+ mockReadFile.mockRejectedValueOnce({ code: 3 }); // permission or other
64
+ await expect(runSshCertInstall({ target: 'user@host' })).rejects.toThrow('Could not read authorized_keys');
65
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
66
+ });
67
+ });
68
+ describe('runSshCertInstallLocal', () => {
69
+ let fakeHome;
70
+ beforeEach(() => {
71
+ fs.mkdirSync(TEST_TMP, { recursive: true });
72
+ fakeHome = fs.mkdtempSync(path.join(TEST_TMP, 'home-'));
73
+ });
74
+ afterEach(() => {
75
+ try {
76
+ fs.rmSync(TEST_TMP, { recursive: true, force: true });
77
+ }
78
+ catch {
79
+ // ignore
80
+ }
81
+ });
82
+ it('creates .ssh and authorized_keys and appends key with af-server comment', () => {
83
+ runSshCertInstallLocal(undefined, { homedir: fakeHome });
84
+ const authKeysPath = path.join(fakeHome, '.ssh', 'authorized_keys');
85
+ expect(fs.existsSync(authKeysPath)).toBe(true);
86
+ const content = fs.readFileSync(authKeysPath, 'utf8');
87
+ expect(content).toContain(KEY_LINE);
88
+ expect(content).toMatch(/# af-server \d{4}-\d{2}-\d{2}/);
89
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH key installed for local'));
90
+ });
91
+ it('when key already present, does not duplicate and logs "Key already installed."', () => {
92
+ runSshCertInstallLocal(undefined, { homedir: fakeHome });
93
+ const authKeysPath = path.join(fakeHome, '.ssh', 'authorized_keys');
94
+ const afterFirst = fs.readFileSync(authKeysPath, 'utf8');
95
+ runSshCertInstallLocal(undefined, { homedir: fakeHome });
96
+ const afterSecond = fs.readFileSync(authKeysPath, 'utf8');
97
+ expect(afterSecond).toBe(afterFirst);
98
+ expect(console.log).toHaveBeenCalledWith('Key already installed.');
99
+ });
100
+ });
101
+ });
package/dist/ssh.d.ts ADDED
@@ -0,0 +1,27 @@
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
+ export interface SSHConnectionOptions {
7
+ target: string;
8
+ privateKeyPath?: string;
9
+ privateKey?: string;
10
+ port?: number;
11
+ }
12
+ export declare function parseTarget(target: string): {
13
+ user: string;
14
+ host: string;
15
+ };
16
+ export declare function createSSHClient(options: SSHConnectionOptions): Promise<Client>;
17
+ export declare function exec(conn: Client, command: string): Promise<{
18
+ stdout: string;
19
+ stderr: string;
20
+ code: number | null;
21
+ }>;
22
+ /** Get remote user home directory (e.g. for ~/.ssh). Uses exec so paths work with SFTP. */
23
+ export declare function getRemoteHome(conn: Client): Promise<string>;
24
+ export declare function readFile(conn: Client, remotePath: string): Promise<Buffer>;
25
+ export declare function writeFile(conn: Client, remotePath: string, data: Buffer | string): Promise<void>;
26
+ export declare function mkdir(conn: Client, remotePath: string): Promise<void>;
27
+ export declare function close(conn: Client): void;