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,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
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port guard - checks if a port is available before server startup.
|
|
3
|
+
*
|
|
4
|
+
* Detects port conflicts and reports which process holds the port.
|
|
5
|
+
* Designed for use with launchd ThrottleInterval to prevent restart spam.
|
|
6
|
+
*
|
|
7
|
+
* @module port-guard
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const net = require('net');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if a port is available.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} port - Port number to check
|
|
16
|
+
* @returns {Promise<{available: boolean, port: number, pid?: number, command?: string}>}
|
|
17
|
+
*/
|
|
18
|
+
async function checkPort(port) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const server = net.createServer();
|
|
21
|
+
|
|
22
|
+
server.once('error', (err) => {
|
|
23
|
+
if (err.code === 'EADDRINUSE') {
|
|
24
|
+
resolve({ available: false, port });
|
|
25
|
+
} else {
|
|
26
|
+
// Unexpected error — treat as unavailable
|
|
27
|
+
resolve({ available: false, port });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.once('listening', () => {
|
|
32
|
+
// Port is free — close the test server
|
|
33
|
+
const addr = server.address();
|
|
34
|
+
const actualPort = addr ? addr.port : port;
|
|
35
|
+
server.close(() => {
|
|
36
|
+
resolve({ available: true, port: actualPort });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
server.listen(port);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { checkPort };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port guard tests - Phase 83 Task 2
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
6
|
+
import net from 'net';
|
|
7
|
+
|
|
8
|
+
import { checkPort } from './port-guard.js';
|
|
9
|
+
|
|
10
|
+
describe('port-guard', () => {
|
|
11
|
+
let tempServer;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (tempServer) {
|
|
15
|
+
tempServer.close();
|
|
16
|
+
tempServer = null;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns available:true when port is free', async () => {
|
|
21
|
+
// Use a high ephemeral port unlikely to be in use
|
|
22
|
+
const result = await checkPort(0);
|
|
23
|
+
expect(result.available).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns available:false when port is occupied', async () => {
|
|
27
|
+
// Occupy a port first
|
|
28
|
+
tempServer = net.createServer();
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
tempServer.listen(0, resolve);
|
|
31
|
+
tempServer.on('error', reject);
|
|
32
|
+
});
|
|
33
|
+
const port = tempServer.address().port;
|
|
34
|
+
|
|
35
|
+
const result = await checkPort(port);
|
|
36
|
+
expect(result.available).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes pid info when port is occupied (best effort)', async () => {
|
|
40
|
+
tempServer = net.createServer();
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
tempServer.listen(0, resolve);
|
|
43
|
+
tempServer.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
const port = tempServer.address().port;
|
|
46
|
+
|
|
47
|
+
const result = await checkPort(port);
|
|
48
|
+
expect(result.available).toBe(false);
|
|
49
|
+
// pid is best-effort (may not be available on all platforms)
|
|
50
|
+
expect(result).toHaveProperty('port', port);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles EADDRINUSE gracefully', async () => {
|
|
54
|
+
tempServer = net.createServer();
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
tempServer.listen(0, resolve);
|
|
57
|
+
tempServer.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
const port = tempServer.address().port;
|
|
60
|
+
|
|
61
|
+
// Should not throw
|
|
62
|
+
const result = await checkPort(port);
|
|
63
|
+
expect(result.available).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -109,9 +109,11 @@ function readProjectMetadata(projectDir) {
|
|
|
109
109
|
const hasTlc = fs.existsSync(path.join(projectDir, '.tlc.json'));
|
|
110
110
|
const hasPlanning = fs.existsSync(path.join(projectDir, '.planning'));
|
|
111
111
|
|
|
112
|
-
// Read name and
|
|
112
|
+
// Read name, version, and workspaces from package.json if present
|
|
113
113
|
let name = path.basename(projectDir);
|
|
114
114
|
let version = null;
|
|
115
|
+
let isMonorepo = false;
|
|
116
|
+
let workspaces = [];
|
|
115
117
|
|
|
116
118
|
const pkgPath = path.join(projectDir, 'package.json');
|
|
117
119
|
if (fs.existsSync(pkgPath)) {
|
|
@@ -123,6 +125,34 @@ function readProjectMetadata(projectDir) {
|
|
|
123
125
|
if (pkg.version) {
|
|
124
126
|
version = pkg.version;
|
|
125
127
|
}
|
|
128
|
+
|
|
129
|
+
// Detect monorepo workspaces (npm array or yarn object format)
|
|
130
|
+
let workspacePatterns = null;
|
|
131
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
132
|
+
workspacePatterns = pkg.workspaces;
|
|
133
|
+
} else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
|
|
134
|
+
workspacePatterns = pkg.workspaces.packages;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (workspacePatterns) {
|
|
138
|
+
isMonorepo = true;
|
|
139
|
+
// Resolve glob patterns to actual directories
|
|
140
|
+
for (const pattern of workspacePatterns) {
|
|
141
|
+
try {
|
|
142
|
+
const globDir = path.join(projectDir, path.dirname(pattern));
|
|
143
|
+
if (fs.existsSync(globDir)) {
|
|
144
|
+
const entries = fs.readdirSync(globDir, { withFileTypes: true });
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
workspaces.push(path.join(path.dirname(pattern), entry.name));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore glob resolution errors
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
126
156
|
} catch {
|
|
127
157
|
// Ignore malformed package.json
|
|
128
158
|
}
|
|
@@ -147,6 +177,8 @@ function readProjectMetadata(projectDir) {
|
|
|
147
177
|
phaseName: phaseInfo.phaseName,
|
|
148
178
|
totalPhases: phaseInfo.totalPhases,
|
|
149
179
|
completedPhases: phaseInfo.completedPhases,
|
|
180
|
+
isMonorepo,
|
|
181
|
+
workspaces,
|
|
150
182
|
};
|
|
151
183
|
}
|
|
152
184
|
|
|
@@ -235,9 +267,12 @@ class ProjectScanner {
|
|
|
235
267
|
if (typeof onProgress === 'function') {
|
|
236
268
|
onProgress(projectsByPath.size);
|
|
237
269
|
}
|
|
270
|
+
|
|
271
|
+
// Stop recursion: a project's children are not separate projects
|
|
272
|
+
return;
|
|
238
273
|
}
|
|
239
274
|
|
|
240
|
-
// Recurse into subdirectories
|
|
275
|
+
// Recurse into subdirectories (only for non-project directories)
|
|
241
276
|
let entries;
|
|
242
277
|
try {
|
|
243
278
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -386,4 +386,156 @@ describe('ProjectScanner', () => {
|
|
|
386
386
|
// The last reported count should match total found
|
|
387
387
|
expect(progressCounts[progressCounts.length - 1]).toBe(2);
|
|
388
388
|
});
|
|
389
|
+
|
|
390
|
+
// =========================================================================
|
|
391
|
+
// Phase 79 — Task 1: Stop recursion at project boundaries
|
|
392
|
+
// =========================================================================
|
|
393
|
+
|
|
394
|
+
// Test 20: Does NOT recurse into subdirectories of a detected project
|
|
395
|
+
it('does not recurse into subdirectories of a detected project', () => {
|
|
396
|
+
// Create a TLC project with a nested sub-package that also looks like a project
|
|
397
|
+
const projectDir = createTlcProject(tempDir, 'monorepo-project');
|
|
398
|
+
const subPkgDir = path.join(projectDir, 'packages', 'sub-package');
|
|
399
|
+
fs.mkdirSync(subPkgDir, { recursive: true });
|
|
400
|
+
fs.writeFileSync(path.join(subPkgDir, 'package.json'), JSON.stringify({ name: 'sub-package' }));
|
|
401
|
+
fs.mkdirSync(path.join(subPkgDir, '.git'), { recursive: true });
|
|
402
|
+
|
|
403
|
+
const results = scanner.scan([tempDir]);
|
|
404
|
+
|
|
405
|
+
// Should only find the parent project, not the sub-package
|
|
406
|
+
expect(results).toHaveLength(1);
|
|
407
|
+
expect(results[0].name).toBe('monorepo-project');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Test 21: TLC project's server/ subdirectory not listed separately
|
|
411
|
+
it('does not list subdirectories of a TLC project as separate projects', () => {
|
|
412
|
+
const projectDir = createTlcProject(tempDir, 'tlc-project');
|
|
413
|
+
// Create a server/ subdirectory with its own package.json + .git
|
|
414
|
+
const serverDir = path.join(projectDir, 'server');
|
|
415
|
+
fs.mkdirSync(serverDir, { recursive: true });
|
|
416
|
+
fs.writeFileSync(path.join(serverDir, 'package.json'), JSON.stringify({ name: 'tlc-server' }));
|
|
417
|
+
fs.mkdirSync(path.join(serverDir, '.git'), { recursive: true });
|
|
418
|
+
|
|
419
|
+
const results = scanner.scan([tempDir]);
|
|
420
|
+
|
|
421
|
+
expect(results).toHaveLength(1);
|
|
422
|
+
expect(results[0].name).toBe('tlc-project');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Test 22: Top-level non-project directories are still traversed
|
|
426
|
+
it('still traverses non-project directories to find nested projects', () => {
|
|
427
|
+
// Create a plain directory (not a project) with a project nested inside
|
|
428
|
+
const groupDir = path.join(tempDir, 'my-workspace');
|
|
429
|
+
fs.mkdirSync(groupDir, { recursive: true });
|
|
430
|
+
// No .tlc.json, no .planning, no package.json+.git — just a folder
|
|
431
|
+
createTlcProject(groupDir, 'nested-real-project');
|
|
432
|
+
|
|
433
|
+
const results = scanner.scan([tempDir]);
|
|
434
|
+
|
|
435
|
+
expect(results).toHaveLength(1);
|
|
436
|
+
expect(results[0].name).toBe('nested-real-project');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Test 23: Multiple projects at same level, none recurse into children
|
|
440
|
+
it('finds sibling projects but does not recurse into either', () => {
|
|
441
|
+
const projA = createTlcProject(tempDir, 'project-a');
|
|
442
|
+
const projB = createTlcProject(tempDir, 'project-b');
|
|
443
|
+
|
|
444
|
+
// Add nested sub-projects inside each
|
|
445
|
+
const nestedA = path.join(projA, 'nested');
|
|
446
|
+
fs.mkdirSync(nestedA, { recursive: true });
|
|
447
|
+
fs.writeFileSync(path.join(nestedA, '.tlc.json'), '{}');
|
|
448
|
+
|
|
449
|
+
const nestedB = path.join(projB, 'apps', 'frontend');
|
|
450
|
+
fs.mkdirSync(nestedB, { recursive: true });
|
|
451
|
+
fs.writeFileSync(path.join(nestedB, 'package.json'), JSON.stringify({ name: 'frontend' }));
|
|
452
|
+
fs.mkdirSync(path.join(nestedB, '.git'), { recursive: true });
|
|
453
|
+
|
|
454
|
+
const results = scanner.scan([tempDir]);
|
|
455
|
+
|
|
456
|
+
expect(results).toHaveLength(2);
|
|
457
|
+
const names = results.map(r => r.name);
|
|
458
|
+
expect(names).toContain('project-a');
|
|
459
|
+
expect(names).toContain('project-b');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// =========================================================================
|
|
463
|
+
// Phase 79 — Task 2: Monorepo sub-package metadata
|
|
464
|
+
// =========================================================================
|
|
465
|
+
|
|
466
|
+
// Test 24: Detects npm workspaces array format
|
|
467
|
+
it('detects npm workspaces and returns isMonorepo: true', () => {
|
|
468
|
+
createTlcProject(tempDir, 'npm-monorepo', {
|
|
469
|
+
packageJson: {
|
|
470
|
+
name: 'npm-monorepo',
|
|
471
|
+
version: '1.0.0',
|
|
472
|
+
workspaces: ['packages/*'],
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Create a matching sub-package directory
|
|
477
|
+
const pkgDir = path.join(tempDir, 'npm-monorepo', 'packages', 'core');
|
|
478
|
+
fs.mkdirSync(pkgDir, { recursive: true });
|
|
479
|
+
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/core' }));
|
|
480
|
+
|
|
481
|
+
const results = scanner.scan([tempDir]);
|
|
482
|
+
|
|
483
|
+
expect(results).toHaveLength(1);
|
|
484
|
+
expect(results[0].isMonorepo).toBe(true);
|
|
485
|
+
expect(results[0].workspaces).toBeInstanceOf(Array);
|
|
486
|
+
expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Test 25: Detects yarn workspaces object format
|
|
490
|
+
it('detects yarn workspaces object format', () => {
|
|
491
|
+
createTlcProject(tempDir, 'yarn-monorepo', {
|
|
492
|
+
packageJson: {
|
|
493
|
+
name: 'yarn-monorepo',
|
|
494
|
+
version: '1.0.0',
|
|
495
|
+
workspaces: { packages: ['packages/*'] },
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const pkgDir = path.join(tempDir, 'yarn-monorepo', 'packages', 'utils');
|
|
500
|
+
fs.mkdirSync(pkgDir, { recursive: true });
|
|
501
|
+
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/utils' }));
|
|
502
|
+
|
|
503
|
+
const results = scanner.scan([tempDir]);
|
|
504
|
+
|
|
505
|
+
expect(results).toHaveLength(1);
|
|
506
|
+
expect(results[0].isMonorepo).toBe(true);
|
|
507
|
+
expect(results[0].workspaces).toBeInstanceOf(Array);
|
|
508
|
+
expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Test 26: Non-monorepo returns isMonorepo: false and empty workspaces
|
|
512
|
+
it('returns isMonorepo false and empty workspaces for regular project', () => {
|
|
513
|
+
createTlcProject(tempDir, 'regular-project', {
|
|
514
|
+
packageJson: { name: 'regular-project', version: '1.0.0' },
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const results = scanner.scan([tempDir]);
|
|
518
|
+
|
|
519
|
+
expect(results).toHaveLength(1);
|
|
520
|
+
expect(results[0].isMonorepo).toBe(false);
|
|
521
|
+
expect(results[0].workspaces).toEqual([]);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Test 27: Monorepo with no matching workspace directories
|
|
525
|
+
it('returns empty workspaces when glob pattern matches nothing', () => {
|
|
526
|
+
createTlcProject(tempDir, 'empty-mono', {
|
|
527
|
+
packageJson: {
|
|
528
|
+
name: 'empty-mono',
|
|
529
|
+
version: '1.0.0',
|
|
530
|
+
workspaces: ['packages/*'],
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
// Don't create the packages/ directory at all
|
|
534
|
+
|
|
535
|
+
const results = scanner.scan([tempDir]);
|
|
536
|
+
|
|
537
|
+
expect(results).toHaveLength(1);
|
|
538
|
+
expect(results[0].isMonorepo).toBe(true);
|
|
539
|
+
expect(results[0].workspaces).toEqual([]);
|
|
540
|
+
});
|
|
389
541
|
});
|