tlc-claude-code 2.0.1 → 2.2.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/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- 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-plan.md +363 -0
- package/.claude/commands/tlc/review.md +172 -57
- 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 +13 -0
- package/bin/install.js +268 -2
- package/bin/postinstall.js +102 -24
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- 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 +4 -2
- package/scripts/project-docs.js +1 -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/cost-tracker.test.js +49 -12
- 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/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -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/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -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/server/setup.sh +271 -271
- 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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file memory-store-adapter.test.js
|
|
3
|
+
* @description Tests for the file-based memory store adapter.
|
|
4
|
+
*
|
|
5
|
+
* The adapter reads decisions and gotchas from .tlc/memory/team/ markdown
|
|
6
|
+
* files on disk. Returns empty arrays when directories don't exist.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
const { createMemoryStoreAdapter } = await import('./memory-store-adapter.js');
|
|
11
|
+
|
|
12
|
+
describe('memory-store-adapter', () => {
|
|
13
|
+
let mockFs;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockFs = {
|
|
17
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
18
|
+
readdirSync: vi.fn().mockReturnValue([]),
|
|
19
|
+
readFileSync: vi.fn().mockReturnValue(''),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('createMemoryStoreAdapter', () => {
|
|
24
|
+
it('returns an object with listDecisions, listGotchas, getStats', () => {
|
|
25
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
26
|
+
expect(adapter).toHaveProperty('listDecisions');
|
|
27
|
+
expect(adapter).toHaveProperty('listGotchas');
|
|
28
|
+
expect(adapter).toHaveProperty('getStats');
|
|
29
|
+
expect(typeof adapter.listDecisions).toBe('function');
|
|
30
|
+
expect(typeof adapter.listGotchas).toBe('function');
|
|
31
|
+
expect(typeof adapter.getStats).toBe('function');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('listDecisions', () => {
|
|
36
|
+
it('returns empty array when decisions directory does not exist', async () => {
|
|
37
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
38
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
39
|
+
const result = await adapter.listDecisions();
|
|
40
|
+
expect(result).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reads markdown files from decisions directory', async () => {
|
|
44
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
45
|
+
mockFs.readdirSync.mockReturnValue(['001-use-postgres.md', '002-rest-api.md']);
|
|
46
|
+
mockFs.readFileSync
|
|
47
|
+
.mockReturnValueOnce('# Use Postgres\n\nWe chose Postgres for the database.\n\n**Date:** 2026-01-20\n**Status:** accepted')
|
|
48
|
+
.mockReturnValueOnce('# REST over GraphQL\n\nREST is simpler for our use case.\n\n**Date:** 2026-01-22\n**Status:** accepted');
|
|
49
|
+
|
|
50
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
51
|
+
const result = await adapter.listDecisions();
|
|
52
|
+
|
|
53
|
+
expect(result).toHaveLength(2);
|
|
54
|
+
expect(result[0]).toHaveProperty('title', 'Use Postgres');
|
|
55
|
+
expect(result[0]).toHaveProperty('content');
|
|
56
|
+
expect(result[1]).toHaveProperty('title', 'REST over GraphQL');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles malformed markdown gracefully', async () => {
|
|
60
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
61
|
+
mockFs.readdirSync.mockReturnValue(['bad.md']);
|
|
62
|
+
mockFs.readFileSync.mockReturnValue('no heading here just text');
|
|
63
|
+
|
|
64
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
65
|
+
const result = await adapter.listDecisions();
|
|
66
|
+
|
|
67
|
+
expect(result).toHaveLength(1);
|
|
68
|
+
expect(result[0]).toHaveProperty('title');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('skips non-markdown files', async () => {
|
|
72
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
73
|
+
mockFs.readdirSync.mockReturnValue(['decision.md', 'notes.txt', '.DS_Store']);
|
|
74
|
+
mockFs.readFileSync.mockReturnValue('# A Decision\n\nContent here');
|
|
75
|
+
|
|
76
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
77
|
+
const result = await adapter.listDecisions();
|
|
78
|
+
|
|
79
|
+
expect(result).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('listGotchas', () => {
|
|
84
|
+
it('returns empty array when gotchas directory does not exist', async () => {
|
|
85
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
86
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
87
|
+
const result = await adapter.listGotchas();
|
|
88
|
+
expect(result).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reads markdown files from gotchas directory', async () => {
|
|
92
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
93
|
+
mockFs.readdirSync.mockReturnValue(['001-cold-starts.md']);
|
|
94
|
+
mockFs.readFileSync.mockReturnValue('# Cold Start Delay\n\nLambda cold starts cause 2s delay.\n\n**Severity:** high');
|
|
95
|
+
|
|
96
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
97
|
+
const result = await adapter.listGotchas();
|
|
98
|
+
|
|
99
|
+
expect(result).toHaveLength(1);
|
|
100
|
+
expect(result[0]).toHaveProperty('title', 'Cold Start Delay');
|
|
101
|
+
expect(result[0]).toHaveProperty('content');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles read errors gracefully', async () => {
|
|
105
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
106
|
+
mockFs.readdirSync.mockReturnValue(['broken.md']);
|
|
107
|
+
mockFs.readFileSync.mockImplementation(() => { throw new Error('EACCES'); });
|
|
108
|
+
|
|
109
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
110
|
+
const result = await adapter.listGotchas();
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('getStats', () => {
|
|
117
|
+
it('returns zero counts when no directories exist', async () => {
|
|
118
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
119
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
120
|
+
const stats = await adapter.getStats();
|
|
121
|
+
|
|
122
|
+
expect(stats).toHaveProperty('decisions', 0);
|
|
123
|
+
expect(stats).toHaveProperty('gotchas', 0);
|
|
124
|
+
expect(stats).toHaveProperty('total', 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('returns file counts from both directories', async () => {
|
|
128
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
129
|
+
mockFs.readdirSync
|
|
130
|
+
.mockReturnValueOnce(['d1.md', 'd2.md', 'd3.md'])
|
|
131
|
+
.mockReturnValueOnce(['g1.md']);
|
|
132
|
+
|
|
133
|
+
const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
|
|
134
|
+
const stats = await adapter.getStats();
|
|
135
|
+
|
|
136
|
+
expect(stats.decisions).toBe(3);
|
|
137
|
+
expect(stats.gotchas).toBe(1);
|
|
138
|
+
expect(stats.total).toBe(4);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory wiring E2E tests - Phase 84 Task 4
|
|
3
|
+
*
|
|
4
|
+
* Proves the full memory loop: exchange → observeAndRemember → file written → adapter reads back.
|
|
5
|
+
* This is the definitive test that memory actually works end-to-end.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
|
|
13
|
+
import { observeAndRemember } from './memory-observer.js';
|
|
14
|
+
import { createMemoryStoreAdapter } from './memory-store-adapter.js';
|
|
15
|
+
|
|
16
|
+
describe('memory wiring e2e', () => {
|
|
17
|
+
let testDir;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-wiring-e2e-'));
|
|
21
|
+
// Create full memory directory structure
|
|
22
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
23
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
|
|
24
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local', 'preferences'), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(testDir, '.tlc.json'), JSON.stringify({ project: 'wiring-test' }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('decision exchange → file created → adapter reads it back', async () => {
|
|
34
|
+
const exchange = {
|
|
35
|
+
user: "let's use PostgreSQL instead of MySQL for better JSONB support.",
|
|
36
|
+
assistant: 'Good choice. PostgreSQL has excellent JSONB support.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
await observeAndRemember(testDir, exchange);
|
|
40
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
41
|
+
|
|
42
|
+
// Adapter reads it back
|
|
43
|
+
const adapter = createMemoryStoreAdapter(testDir);
|
|
44
|
+
const decisions = await adapter.listDecisions();
|
|
45
|
+
expect(decisions.length).toBeGreaterThanOrEqual(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('gotcha exchange → file created → adapter reads it back', async () => {
|
|
49
|
+
const exchange = {
|
|
50
|
+
user: 'watch out for the SQLite WAL mode issue under concurrent writes.',
|
|
51
|
+
assistant: 'Noted. Serialize database operations to avoid corruption.',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
await observeAndRemember(testDir, exchange);
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
56
|
+
|
|
57
|
+
const adapter = createMemoryStoreAdapter(testDir);
|
|
58
|
+
const gotchas = await adapter.listGotchas();
|
|
59
|
+
expect(gotchas.length).toBeGreaterThanOrEqual(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('stats reflect actual file counts', async () => {
|
|
63
|
+
// Write a decision
|
|
64
|
+
const exchange = {
|
|
65
|
+
user: "we decided to use Redis as our caching layer instead of Memcached.",
|
|
66
|
+
assistant: 'Redis is more versatile for caching.',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await observeAndRemember(testDir, exchange);
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
71
|
+
|
|
72
|
+
const adapter = createMemoryStoreAdapter(testDir);
|
|
73
|
+
const stats = await adapter.getStats();
|
|
74
|
+
expect(stats.decisions).toBeGreaterThanOrEqual(1);
|
|
75
|
+
expect(stats.total).toBeGreaterThanOrEqual(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('empty project returns empty arrays without crashing', async () => {
|
|
79
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-empty-'));
|
|
80
|
+
fs.writeFileSync(path.join(emptyDir, '.tlc.json'), JSON.stringify({ project: 'empty' }));
|
|
81
|
+
|
|
82
|
+
const adapter = createMemoryStoreAdapter(emptyDir);
|
|
83
|
+
const decisions = await adapter.listDecisions();
|
|
84
|
+
const gotchas = await adapter.listGotchas();
|
|
85
|
+
const stats = await adapter.getStats();
|
|
86
|
+
|
|
87
|
+
expect(decisions).toEqual([]);
|
|
88
|
+
expect(gotchas).toEqual([]);
|
|
89
|
+
expect(stats.total).toBe(0);
|
|
90
|
+
|
|
91
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nginx Config Generator — server blocks, wildcard routing, SSL
|
|
3
|
+
* Phase 80 Task 5 (replaces caddy-config.js)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { isValidDomain } = require('./input-sanitizer.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate Nginx site config for a project
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} options.domain - Server domain
|
|
12
|
+
* @param {number} options.port - App port
|
|
13
|
+
* @param {string} options.proxyPass - Upstream URL
|
|
14
|
+
* @returns {string} Nginx config
|
|
15
|
+
*/
|
|
16
|
+
function generateSiteConfig({ domain, port, proxyPass }) {
|
|
17
|
+
if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
|
|
18
|
+
return `# TLC generated Nginx config for ${domain}
|
|
19
|
+
server {
|
|
20
|
+
listen 80;
|
|
21
|
+
server_name ${domain};
|
|
22
|
+
|
|
23
|
+
location / {
|
|
24
|
+
proxy_pass ${proxyPass};
|
|
25
|
+
|
|
26
|
+
# Proxy headers
|
|
27
|
+
proxy_set_header Host $host;
|
|
28
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
29
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
30
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
31
|
+
|
|
32
|
+
# WebSocket support
|
|
33
|
+
proxy_http_version 1.1;
|
|
34
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
35
|
+
proxy_set_header Connection "upgrade";
|
|
36
|
+
|
|
37
|
+
# Timeouts
|
|
38
|
+
proxy_connect_timeout 60s;
|
|
39
|
+
proxy_send_timeout 60s;
|
|
40
|
+
proxy_read_timeout 60s;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate wildcard Nginx config for branch previews
|
|
48
|
+
* @param {string} baseDomain - Base domain (e.g. myapp.dev)
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {Array} [options.branches] - [{ subdomain, port }]
|
|
51
|
+
* @returns {string} Nginx config
|
|
52
|
+
*/
|
|
53
|
+
function generateWildcardConfig(baseDomain, options = {}) {
|
|
54
|
+
if (!isValidDomain(baseDomain)) throw new Error(`Invalid domain: ${baseDomain}`);
|
|
55
|
+
const branches = options.branches || [];
|
|
56
|
+
|
|
57
|
+
// Map blocks for each branch
|
|
58
|
+
const mapEntries = branches
|
|
59
|
+
.map(b => ` ${b.subdomain}.${baseDomain} 127.0.0.1:${b.port};`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
|
|
62
|
+
return `# TLC wildcard config for *.${baseDomain}
|
|
63
|
+
|
|
64
|
+
map $host $branch_upstream {
|
|
65
|
+
default "";
|
|
66
|
+
${mapEntries}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Default server for unknown subdomains
|
|
70
|
+
server {
|
|
71
|
+
listen 80 default_server;
|
|
72
|
+
server_name *.${baseDomain};
|
|
73
|
+
|
|
74
|
+
location / {
|
|
75
|
+
if ($branch_upstream = "") {
|
|
76
|
+
return 404 "No deployment for this branch";
|
|
77
|
+
}
|
|
78
|
+
proxy_pass http://$branch_upstream;
|
|
79
|
+
|
|
80
|
+
proxy_set_header Host $host;
|
|
81
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
82
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
83
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
84
|
+
|
|
85
|
+
proxy_http_version 1.1;
|
|
86
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
87
|
+
proxy_set_header Connection "upgrade";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate SSL config snippet for a domain
|
|
95
|
+
* @param {string} domain
|
|
96
|
+
* @returns {string} SSL config lines
|
|
97
|
+
*/
|
|
98
|
+
function generateSslConfig(domain) {
|
|
99
|
+
if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
|
|
100
|
+
return ` # SSL Configuration for ${domain}
|
|
101
|
+
listen 443 ssl;
|
|
102
|
+
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
|
103
|
+
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
|
|
104
|
+
|
|
105
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
106
|
+
ssl_prefer_server_ciphers on;
|
|
107
|
+
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
|
108
|
+
|
|
109
|
+
# HSTS
|
|
110
|
+
add_header Strict-Transport-Security "max-age=63072000" always;
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { generateSiteConfig, generateWildcardConfig, generateSslConfig };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { generateSiteConfig, generateWildcardConfig, generateSslConfig } = await import('./nginx-config.js');
|
|
4
|
+
|
|
5
|
+
describe('Nginx Config Generator', () => {
|
|
6
|
+
describe('generateSiteConfig', () => {
|
|
7
|
+
it('produces valid Nginx server block', () => {
|
|
8
|
+
const config = generateSiteConfig({
|
|
9
|
+
domain: 'myapp.dev',
|
|
10
|
+
port: 3000,
|
|
11
|
+
proxyPass: 'http://127.0.0.1:3000',
|
|
12
|
+
});
|
|
13
|
+
expect(config).toContain('server {');
|
|
14
|
+
expect(config).toContain('server_name myapp.dev');
|
|
15
|
+
expect(config).toContain('proxy_pass http://127.0.0.1:3000');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('includes proxy headers', () => {
|
|
19
|
+
const config = generateSiteConfig({
|
|
20
|
+
domain: 'myapp.dev',
|
|
21
|
+
port: 3000,
|
|
22
|
+
proxyPass: 'http://127.0.0.1:3000',
|
|
23
|
+
});
|
|
24
|
+
expect(config).toContain('proxy_set_header Host');
|
|
25
|
+
expect(config).toContain('proxy_set_header X-Real-IP');
|
|
26
|
+
expect(config).toContain('proxy_set_header X-Forwarded-For');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('includes WebSocket upgrade headers', () => {
|
|
30
|
+
const config = generateSiteConfig({
|
|
31
|
+
domain: 'myapp.dev',
|
|
32
|
+
port: 3000,
|
|
33
|
+
proxyPass: 'http://127.0.0.1:3000',
|
|
34
|
+
});
|
|
35
|
+
expect(config).toContain('proxy_http_version 1.1');
|
|
36
|
+
expect(config).toContain('Upgrade');
|
|
37
|
+
expect(config).toContain('Connection');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('listens on port 80 by default', () => {
|
|
41
|
+
const config = generateSiteConfig({
|
|
42
|
+
domain: 'myapp.dev',
|
|
43
|
+
port: 3000,
|
|
44
|
+
proxyPass: 'http://127.0.0.1:3000',
|
|
45
|
+
});
|
|
46
|
+
expect(config).toContain('listen 80');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('generateWildcardConfig', () => {
|
|
51
|
+
it('routes subdomains to container ports', () => {
|
|
52
|
+
const config = generateWildcardConfig('myapp.dev', {
|
|
53
|
+
branches: [
|
|
54
|
+
{ subdomain: 'feat-login', port: 4001 },
|
|
55
|
+
{ subdomain: 'main', port: 4000 },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
expect(config).toContain('*.myapp.dev');
|
|
59
|
+
expect(config).toContain('feat-login');
|
|
60
|
+
expect(config).toContain('4001');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('includes default server for unknown subdomains', () => {
|
|
64
|
+
const config = generateWildcardConfig('myapp.dev', { branches: [] });
|
|
65
|
+
expect(config).toContain('default_server');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('generateSslConfig', () => {
|
|
70
|
+
it('references Lets Encrypt certificate paths', () => {
|
|
71
|
+
const config = generateSslConfig('myapp.dev');
|
|
72
|
+
expect(config).toContain('ssl_certificate');
|
|
73
|
+
expect(config).toContain('/etc/letsencrypt');
|
|
74
|
+
expect(config).toContain('myapp.dev');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('includes SSL security settings', () => {
|
|
78
|
+
const config = generateSslConfig('myapp.dev');
|
|
79
|
+
expect(config).toContain('ssl_protocols');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama health checker.
|
|
3
|
+
*
|
|
4
|
+
* Checks whether Ollama is installed, running, and has the required
|
|
5
|
+
* embedding model. Returns actionable messages for each failure state.
|
|
6
|
+
* Results are cached for 60 seconds.
|
|
7
|
+
*
|
|
8
|
+
* @module ollama-health
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
12
|
+
const REQUIRED_MODEL = 'mxbai-embed-large';
|
|
13
|
+
const CACHE_TTL_MS = 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** @enum {string} */
|
|
16
|
+
const OLLAMA_STATUS = {
|
|
17
|
+
READY: 'ready',
|
|
18
|
+
NOT_INSTALLED: 'not_installed',
|
|
19
|
+
NOT_RUNNING: 'not_running',
|
|
20
|
+
NO_MODEL: 'no_model',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let cachedResult = null;
|
|
24
|
+
let cachedAt = 0;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check Ollama health status.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [deps] - Injectable dependencies for testing
|
|
30
|
+
* @param {Function} [deps.fetch] - Fetch implementation
|
|
31
|
+
* @returns {Promise<{status: string, message: string, action: string}>}
|
|
32
|
+
*/
|
|
33
|
+
async function checkOllamaHealth(deps = {}) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
|
|
36
|
+
return cachedResult;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetchFn(`${OLLAMA_URL}/api/tags`, {
|
|
43
|
+
signal: AbortSignal.timeout(3000),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
cachedResult = {
|
|
48
|
+
status: OLLAMA_STATUS.NOT_RUNNING,
|
|
49
|
+
message: 'Ollama responded with an error',
|
|
50
|
+
action: 'Restart Ollama: ollama serve',
|
|
51
|
+
};
|
|
52
|
+
cachedAt = now;
|
|
53
|
+
return cachedResult;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
const models = data.models || [];
|
|
58
|
+
const hasModel = models.some(m => m.name && m.name.startsWith(REQUIRED_MODEL));
|
|
59
|
+
|
|
60
|
+
if (hasModel) {
|
|
61
|
+
cachedResult = {
|
|
62
|
+
status: OLLAMA_STATUS.READY,
|
|
63
|
+
message: 'Memory: full (pattern detection + semantic search)',
|
|
64
|
+
action: '',
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
cachedResult = {
|
|
68
|
+
status: OLLAMA_STATUS.NO_MODEL,
|
|
69
|
+
message: `Ollama running but ${REQUIRED_MODEL} model not found`,
|
|
70
|
+
action: `ollama pull ${REQUIRED_MODEL}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
cachedResult = {
|
|
75
|
+
status: OLLAMA_STATUS.NOT_RUNNING,
|
|
76
|
+
message: 'Ollama not running. Semantic search disabled, pattern detection still works.',
|
|
77
|
+
action: 'brew install ollama && ollama serve && ollama pull mxbai-embed-large',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cachedAt = now;
|
|
82
|
+
return cachedResult;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Clear the cache (for testing). */
|
|
86
|
+
checkOllamaHealth._clearCache = function () {
|
|
87
|
+
cachedResult = null;
|
|
88
|
+
cachedAt = 0;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
module.exports = { checkOllamaHealth, OLLAMA_STATUS };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama health checker tests - Phase 84 Task 1
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { checkOllamaHealth, OLLAMA_STATUS } from './ollama-health.js';
|
|
8
|
+
|
|
9
|
+
describe('ollama-health', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Clear cache between tests
|
|
12
|
+
checkOllamaHealth._clearCache?.();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns ready when Ollama responds with correct model', async () => {
|
|
16
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
17
|
+
ok: true,
|
|
18
|
+
json: async () => ({ models: [{ name: 'mxbai-embed-large:latest' }] }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = await checkOllamaHealth({ fetch: mockFetch });
|
|
22
|
+
expect(result.status).toBe(OLLAMA_STATUS.READY);
|
|
23
|
+
expect(result.message).toContain('full');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns not_running when connection refused', async () => {
|
|
27
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
28
|
+
|
|
29
|
+
const result = await checkOllamaHealth({ fetch: mockFetch });
|
|
30
|
+
expect([OLLAMA_STATUS.NOT_INSTALLED, OLLAMA_STATUS.NOT_RUNNING]).toContain(result.status);
|
|
31
|
+
expect(result.action).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns no_model when Ollama responds but model missing', async () => {
|
|
35
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
36
|
+
ok: true,
|
|
37
|
+
json: async () => ({ models: [{ name: 'llama3:latest' }] }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await checkOllamaHealth({ fetch: mockFetch });
|
|
41
|
+
expect(result.status).toBe(OLLAMA_STATUS.NO_MODEL);
|
|
42
|
+
expect(result.action).toContain('ollama pull');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('caches result within 60s window', async () => {
|
|
46
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: async () => ({ models: [{ name: 'mxbai-embed-large:latest' }] }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result1 = await checkOllamaHealth({ fetch: mockFetch });
|
|
52
|
+
const result2 = await checkOllamaHealth({ fetch: mockFetch });
|
|
53
|
+
|
|
54
|
+
expect(result1.status).toBe(result2.status);
|
|
55
|
+
// Should only call fetch once due to caching
|
|
56
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns actionable message for each status', async () => {
|
|
60
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
61
|
+
|
|
62
|
+
const result = await checkOllamaHealth({ fetch: mockFetch });
|
|
63
|
+
expect(result.action).toBeTruthy();
|
|
64
|
+
expect(typeof result.action).toBe('string');
|
|
65
|
+
expect(result.message).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('exports status constants', () => {
|
|
69
|
+
expect(OLLAMA_STATUS.READY).toBe('ready');
|
|
70
|
+
expect(OLLAMA_STATUS.NOT_INSTALLED).toBe('not_installed');
|
|
71
|
+
expect(OLLAMA_STATUS.NOT_RUNNING).toBe('not_running');
|
|
72
|
+
expect(OLLAMA_STATUS.NO_MODEL).toBe('no_model');
|
|
73
|
+
});
|
|
74
|
+
});
|