Skip to content

Commit 62a9a00

Browse files
luizomfclaude
andcommitted
feat(config): add SSH client config builder page
New /config page for building ~/.ssh/config visually: - Add/remove host entries with live preview - Fields: Host, HostName, User, Port, IdentityFile, ProxyJump - Advanced: ForwardAgent, RequestTTY, keepalive, forwards, RemoteCommand - Import existing config (parser handles comments, = syntax, wildcards) - Copy to clipboard and download as file - Roundtrip tested: parse → generate → reparse produces same result New libs: config-generator.ts, config-parser.ts (36 new tests, 82 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fbecc9c commit 62a9a00

8 files changed

Lines changed: 1107 additions & 3 deletions

File tree

src/layouts/Base.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const navItems = [
1111
{ href: '/', label: 'Home', disabled: false },
1212
{ href: '/tunnels/', label: 'Tunnels', disabled: false },
1313
{ href: '/hardening/', label: 'Hardening', disabled: true },
14-
{ href: '/config/', label: 'Config', disabled: true },
14+
{ href: '/config/', label: 'Config', disabled: false },
1515
{ href: '/keygen/', label: 'KeyGen', disabled: true },
1616
];
1717
---
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateHostBlock, generateFullConfig, createEmptyHost } from '../config-generator';
3+
import type { SshHostEntry } from '../types';
4+
5+
function makeHost(overrides: Partial<SshHostEntry> = {}): SshHostEntry {
6+
return {
7+
id: '1',
8+
host: 'prod',
9+
hostName: '192.168.1.10',
10+
user: 'deploy',
11+
...overrides,
12+
};
13+
}
14+
15+
describe('generateHostBlock', () => {
16+
it('generates a minimal host block', () => {
17+
const entry = makeHost();
18+
expect(generateHostBlock(entry)).toBe(
19+
['Host prod', ' HostName 192.168.1.10', ' User deploy'].join('\n'),
20+
);
21+
});
22+
23+
it('includes port when not 22', () => {
24+
const entry = makeHost({ port: 2222 });
25+
const result = generateHostBlock(entry);
26+
expect(result).toContain('Port 2222');
27+
});
28+
29+
it('omits port when 22', () => {
30+
const entry = makeHost({ port: 22 });
31+
const result = generateHostBlock(entry);
32+
expect(result).not.toContain('Port');
33+
});
34+
35+
it('includes IdentityFile', () => {
36+
const entry = makeHost({ identityFile: '~/.ssh/id_ed25519' });
37+
const result = generateHostBlock(entry);
38+
expect(result).toContain('IdentityFile ~/.ssh/id_ed25519');
39+
});
40+
41+
it('includes ProxyJump', () => {
42+
const entry = makeHost({ proxyJump: 'bastion' });
43+
const result = generateHostBlock(entry);
44+
expect(result).toContain('ProxyJump bastion');
45+
});
46+
47+
it('includes ForwardAgent yes/no', () => {
48+
expect(generateHostBlock(makeHost({ forwardAgent: true }))).toContain('ForwardAgent yes');
49+
expect(generateHostBlock(makeHost({ forwardAgent: false }))).toContain('ForwardAgent no');
50+
});
51+
52+
it('does not include ForwardAgent when undefined', () => {
53+
const result = generateHostBlock(makeHost());
54+
expect(result).not.toContain('ForwardAgent');
55+
});
56+
57+
it('includes LocalForward entries', () => {
58+
const entry = makeHost({
59+
localForward: ['5432 localhost:5432', '8080 localhost:80'],
60+
});
61+
const result = generateHostBlock(entry);
62+
expect(result).toContain('LocalForward 5432 localhost:5432');
63+
expect(result).toContain('LocalForward 8080 localhost:80');
64+
});
65+
66+
it('includes RemoteForward entries', () => {
67+
const entry = makeHost({
68+
remoteForward: ['0.0.0.0:8080 localhost:3000'],
69+
});
70+
const result = generateHostBlock(entry);
71+
expect(result).toContain('RemoteForward 0.0.0.0:8080 localhost:3000');
72+
});
73+
74+
it('includes DynamicForward entries', () => {
75+
const entry = makeHost({ dynamicForward: ['1080'] });
76+
const result = generateHostBlock(entry);
77+
expect(result).toContain('DynamicForward 1080');
78+
});
79+
80+
it('includes ExitOnForwardFailure', () => {
81+
const entry = makeHost({ exitOnForwardFailure: true });
82+
const result = generateHostBlock(entry);
83+
expect(result).toContain('ExitOnForwardFailure yes');
84+
});
85+
86+
it('includes keepalive options', () => {
87+
const entry = makeHost({ serverAliveInterval: 60, serverAliveCountMax: 3 });
88+
const result = generateHostBlock(entry);
89+
expect(result).toContain('ServerAliveInterval 60');
90+
expect(result).toContain('ServerAliveCountMax 3');
91+
});
92+
93+
it('includes RequestTTY when not auto', () => {
94+
expect(generateHostBlock(makeHost({ requestTTY: 'force' }))).toContain('RequestTTY force');
95+
expect(generateHostBlock(makeHost({ requestTTY: 'auto' }))).not.toContain('RequestTTY');
96+
});
97+
98+
it('includes RemoteCommand', () => {
99+
const entry = makeHost({ remoteCommand: 'tmux attach' });
100+
const result = generateHostBlock(entry);
101+
expect(result).toContain('RemoteCommand tmux attach');
102+
});
103+
104+
it('includes extra options', () => {
105+
const entry = makeHost({
106+
extraOptions: { StrictHostKeyChecking: 'no', LogLevel: 'VERBOSE' },
107+
});
108+
const result = generateHostBlock(entry);
109+
expect(result).toContain('StrictHostKeyChecking no');
110+
expect(result).toContain('LogLevel VERBOSE');
111+
});
112+
113+
it('generates a full featured host', () => {
114+
const entry: SshHostEntry = {
115+
id: '1',
116+
host: 'prod-db',
117+
hostName: 'db.internal',
118+
user: 'deploy',
119+
port: 2222,
120+
identityFile: '~/.ssh/prod_key',
121+
proxyJump: 'bastion',
122+
forwardAgent: false,
123+
localForward: ['5432 localhost:5432'],
124+
exitOnForwardFailure: true,
125+
serverAliveInterval: 60,
126+
serverAliveCountMax: 3,
127+
};
128+
expect(generateHostBlock(entry)).toBe(
129+
[
130+
'Host prod-db',
131+
' HostName db.internal',
132+
' User deploy',
133+
' Port 2222',
134+
' IdentityFile ~/.ssh/prod_key',
135+
' ProxyJump bastion',
136+
' ForwardAgent no',
137+
' LocalForward 5432 localhost:5432',
138+
' ExitOnForwardFailure yes',
139+
' ServerAliveInterval 60',
140+
' ServerAliveCountMax 3',
141+
].join('\n'),
142+
);
143+
});
144+
145+
it('handles wildcard hosts', () => {
146+
const entry = makeHost({ host: '*.dev', hostName: undefined, user: 'dev' });
147+
const result = generateHostBlock(entry);
148+
expect(result).toMatch(/^Host \*\.dev/);
149+
expect(result).not.toContain('HostName');
150+
});
151+
152+
it('omits empty string values', () => {
153+
const entry: SshHostEntry = {
154+
id: '1',
155+
host: 'test',
156+
hostName: '',
157+
user: '',
158+
identityFile: '',
159+
proxyJump: '',
160+
};
161+
const result = generateHostBlock(entry);
162+
expect(result).toBe('Host test');
163+
});
164+
});
165+
166+
describe('generateFullConfig', () => {
167+
it('joins multiple hosts with blank line', () => {
168+
const entries = [
169+
makeHost({ host: 'prod', hostName: '10.0.0.1' }),
170+
makeHost({ host: 'staging', hostName: '10.0.0.2', user: 'stage' }),
171+
];
172+
const result = generateFullConfig(entries);
173+
expect(result).toContain('Host prod');
174+
expect(result).toContain('Host staging');
175+
expect(result).toContain('\n\n');
176+
});
177+
178+
it('returns empty string for no entries', () => {
179+
expect(generateFullConfig([])).toBe('');
180+
});
181+
});
182+
183+
describe('createEmptyHost', () => {
184+
it('creates a host with unique id', () => {
185+
const a = createEmptyHost();
186+
const b = createEmptyHost();
187+
expect(a.id).not.toBe(b.id);
188+
expect(a.host).toBe('');
189+
});
190+
});

0 commit comments

Comments
 (0)