tlc-claude-code 1.8.4 → 2.0.1
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/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +29 -4
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-parser.js +33 -7
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +96 -0
- package/server/lib/remember-command.test.js +265 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +552 -0
- package/server/package.json +4 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Bootstrap — clones repos from the projects registry and sets up
|
|
3
|
+
* a workspace on a new machine.
|
|
4
|
+
*
|
|
5
|
+
* Factory function `createWorkspaceBootstrap` accepts dependencies:
|
|
6
|
+
* - registry — projects registry (listProjects / load)
|
|
7
|
+
* - vectorIndexer — optional vector indexer (rebuildIndex)
|
|
8
|
+
* - execAsync — optional async exec function (defaults to no-op for testing)
|
|
9
|
+
*
|
|
10
|
+
* The returned object exposes:
|
|
11
|
+
* - execute(workspaceRoot, options) — clone repos, install deps, rebuild vectors
|
|
12
|
+
*
|
|
13
|
+
* @module workspace-bootstrap
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default no-op exec function used when no execAsync is injected.
|
|
21
|
+
* @param {string} _cmd - Command string (ignored)
|
|
22
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
23
|
+
*/
|
|
24
|
+
async function defaultExecAsync(_cmd) {
|
|
25
|
+
return { stdout: '', stderr: '' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a workspace bootstrap instance.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} deps
|
|
32
|
+
* @param {object} deps.registry - Projects registry with listProjects()
|
|
33
|
+
* @param {object} [deps.vectorIndexer] - Optional vector indexer with rebuildIndex()
|
|
34
|
+
* @param {Function} [deps.execAsync] - Async exec function for shell commands
|
|
35
|
+
* @returns {{ execute: Function }}
|
|
36
|
+
*/
|
|
37
|
+
export function createWorkspaceBootstrap({ registry, vectorIndexer, execAsync } = {}) {
|
|
38
|
+
const exec = execAsync || defaultExecAsync;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Execute the bootstrap workflow: clone repos, install deps, rebuild vectors.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} workspaceRoot - Absolute path to the workspace root directory
|
|
44
|
+
* @param {object} [options={}]
|
|
45
|
+
* @param {boolean} [options.dryRun=false] - If true, no exec calls are made
|
|
46
|
+
* @param {boolean} [options.skipInstall=false] - If true, skip npm install
|
|
47
|
+
* @param {number} [options.parallel=1] - Concurrency (reserved for future use)
|
|
48
|
+
* @param {Function} [options.onProgress] - Progress callback (phase, project, status)
|
|
49
|
+
* @returns {Promise<{cloned: number, skipped: number, failed: number, errors: Array, plan?: Array}>}
|
|
50
|
+
*/
|
|
51
|
+
async function execute(workspaceRoot, options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
dryRun = false,
|
|
54
|
+
skipInstall = false,
|
|
55
|
+
parallel = 1,
|
|
56
|
+
onProgress,
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const projects = await registry.listProjects();
|
|
60
|
+
|
|
61
|
+
const result = {
|
|
62
|
+
cloned: 0,
|
|
63
|
+
skipped: 0,
|
|
64
|
+
failed: 0,
|
|
65
|
+
errors: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// In dry-run mode, build a plan but execute nothing
|
|
69
|
+
if (dryRun) {
|
|
70
|
+
const plan = [];
|
|
71
|
+
|
|
72
|
+
for (const project of projects) {
|
|
73
|
+
const targetDir = path.join(workspaceRoot, project.localPath);
|
|
74
|
+
const gitDir = path.join(targetDir, '.git');
|
|
75
|
+
const alreadyCloned = fs.existsSync(gitDir);
|
|
76
|
+
|
|
77
|
+
if (alreadyCloned) {
|
|
78
|
+
result.skipped++;
|
|
79
|
+
if (onProgress) {
|
|
80
|
+
onProgress({ phase: 'scan', project: project.name, status: 'skipped' });
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
plan.push({
|
|
84
|
+
name: project.name,
|
|
85
|
+
gitUrl: project.gitUrl,
|
|
86
|
+
localPath: project.localPath,
|
|
87
|
+
defaultBranch: project.defaultBranch,
|
|
88
|
+
});
|
|
89
|
+
if (onProgress) {
|
|
90
|
+
onProgress({ phase: 'plan', project: project.name, status: 'would-clone' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
result.plan = plan;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Live run: clone each project sequentially
|
|
100
|
+
for (const project of projects) {
|
|
101
|
+
const targetDir = path.join(workspaceRoot, project.localPath);
|
|
102
|
+
const gitDir = path.join(targetDir, '.git');
|
|
103
|
+
|
|
104
|
+
// Check if already cloned
|
|
105
|
+
if (fs.existsSync(gitDir)) {
|
|
106
|
+
result.skipped++;
|
|
107
|
+
if (onProgress) {
|
|
108
|
+
onProgress({ phase: 'clone', project: project.name, status: 'skipped' });
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ensure parent directories exist for nested localPath values
|
|
114
|
+
const parentDir = path.dirname(targetDir);
|
|
115
|
+
if (!fs.existsSync(parentDir)) {
|
|
116
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Clone the repository
|
|
121
|
+
if (onProgress) {
|
|
122
|
+
onProgress({ phase: 'clone', project: project.name, status: 'cloning' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await exec(`git clone ${project.gitUrl} ${targetDir}`);
|
|
126
|
+
|
|
127
|
+
// Checkout the configured default branch
|
|
128
|
+
await exec(`git -C ${targetDir} checkout ${project.defaultBranch}`);
|
|
129
|
+
|
|
130
|
+
// Run npm install unless skipInstall is set
|
|
131
|
+
if (!skipInstall) {
|
|
132
|
+
await exec(`npm install --prefix ${targetDir}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result.cloned++;
|
|
136
|
+
if (onProgress) {
|
|
137
|
+
onProgress({ phase: 'clone', project: project.name, status: 'done' });
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
result.failed++;
|
|
141
|
+
result.errors.push({
|
|
142
|
+
project: project.name,
|
|
143
|
+
error: err.message || String(err),
|
|
144
|
+
});
|
|
145
|
+
if (onProgress) {
|
|
146
|
+
onProgress({ phase: 'clone', project: project.name, status: 'failed' });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Rebuild vector index if vectorIndexer is provided
|
|
152
|
+
if (vectorIndexer && typeof vectorIndexer.rebuildIndex === 'function') {
|
|
153
|
+
try {
|
|
154
|
+
await vectorIndexer.rebuildIndex();
|
|
155
|
+
} catch (_err) {
|
|
156
|
+
// Vector rebuild failure is non-fatal
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { execute };
|
|
164
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Bootstrap Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the /tlc:bootstrap command that clones all repos from
|
|
5
|
+
* projects.json and sets up the workspace on a new machine.
|
|
6
|
+
*
|
|
7
|
+
* The bootstrap module:
|
|
8
|
+
* - Reads the projects registry to discover repos
|
|
9
|
+
* - Clones each repo to its configured localPath
|
|
10
|
+
* - Checks out the configured defaultBranch
|
|
11
|
+
* - Optionally runs npm install per project
|
|
12
|
+
* - Optionally triggers vector index rebuild
|
|
13
|
+
* - Reports progress via callback and returns a summary
|
|
14
|
+
*
|
|
15
|
+
* These tests are written BEFORE the implementation (Red phase).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import os from 'os';
|
|
22
|
+
import { createWorkspaceBootstrap } from './workspace-bootstrap.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mock factories
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a mock projects registry that returns a configurable list of
|
|
30
|
+
* projects. Mirrors the shape returned by createProjectsRegistry().
|
|
31
|
+
* @param {Array} projects - Array of project entries
|
|
32
|
+
* @returns {object} Mock registry with listProjects / load stubs
|
|
33
|
+
*/
|
|
34
|
+
function createMockRegistry(projects = []) {
|
|
35
|
+
return {
|
|
36
|
+
load: vi.fn().mockResolvedValue({ version: 1, projects }),
|
|
37
|
+
listProjects: vi.fn().mockResolvedValue(projects),
|
|
38
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
addProject: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
removeProject: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a mock vectorIndexer dependency.
|
|
46
|
+
* @returns {object}
|
|
47
|
+
*/
|
|
48
|
+
function createMockVectorIndexer() {
|
|
49
|
+
return {
|
|
50
|
+
rebuildIndex: vi.fn().mockResolvedValue({ indexed: 5, errors: 0 }),
|
|
51
|
+
indexFile: vi.fn().mockResolvedValue({ success: true }),
|
|
52
|
+
indexAll: vi.fn().mockResolvedValue({ indexed: 0, errors: 0 }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a mock execAsync function that tracks calls and simulates
|
|
58
|
+
* successful git operations by default.
|
|
59
|
+
* @returns {vi.fn} Mock exec function
|
|
60
|
+
*/
|
|
61
|
+
function createMockExec() {
|
|
62
|
+
return vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Sample project data
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const sampleProjects = [
|
|
70
|
+
{
|
|
71
|
+
name: 'api-service',
|
|
72
|
+
gitUrl: 'git@github.com:myorg/api-service.git',
|
|
73
|
+
localPath: 'api-service',
|
|
74
|
+
defaultBranch: 'main',
|
|
75
|
+
hasTlc: true,
|
|
76
|
+
description: 'REST API service',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'web-frontend',
|
|
80
|
+
gitUrl: 'https://github.com/myorg/web-frontend.git',
|
|
81
|
+
localPath: 'web-frontend',
|
|
82
|
+
defaultBranch: 'develop',
|
|
83
|
+
hasTlc: false,
|
|
84
|
+
description: 'React frontend app',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'shared-lib',
|
|
88
|
+
gitUrl: 'git@github.com:myorg/shared-lib.git',
|
|
89
|
+
localPath: 'libs/shared',
|
|
90
|
+
defaultBranch: 'main',
|
|
91
|
+
hasTlc: true,
|
|
92
|
+
description: 'Shared utilities library',
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Tests
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('workspace-bootstrap', () => {
|
|
101
|
+
let tempDir;
|
|
102
|
+
let mockRegistry;
|
|
103
|
+
let mockVectorIndexer;
|
|
104
|
+
let bootstrap;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-bootstrap-test-'));
|
|
108
|
+
mockRegistry = createMockRegistry(sampleProjects);
|
|
109
|
+
mockVectorIndexer = createMockVectorIndexer();
|
|
110
|
+
|
|
111
|
+
bootstrap = createWorkspaceBootstrap({
|
|
112
|
+
registry: mockRegistry,
|
|
113
|
+
vectorIndexer: mockVectorIndexer,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// -------------------------------------------------------------------------
|
|
122
|
+
// 1. Clones repos listed in projects.json
|
|
123
|
+
// -------------------------------------------------------------------------
|
|
124
|
+
it('clones repos listed in projects.json (mock exec for git clone)', async () => {
|
|
125
|
+
const result = await bootstrap.execute(tempDir, {
|
|
126
|
+
dryRun: false,
|
|
127
|
+
skipInstall: true,
|
|
128
|
+
parallel: 1,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Should have attempted to clone all 3 projects
|
|
132
|
+
expect(result.cloned).toBe(3);
|
|
133
|
+
|
|
134
|
+
// Registry should have been queried
|
|
135
|
+
expect(
|
|
136
|
+
mockRegistry.listProjects.mock.calls.length +
|
|
137
|
+
mockRegistry.load.mock.calls.length
|
|
138
|
+
).toBeGreaterThanOrEqual(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// 2. Skips already-cloned repos (directory already exists with .git/)
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
it('skips already-cloned repos (directory already exists with .git/)', async () => {
|
|
145
|
+
// Pre-create one repo directory with .git/ to simulate already-cloned
|
|
146
|
+
const existingRepo = path.join(tempDir, 'api-service');
|
|
147
|
+
fs.mkdirSync(existingRepo, { recursive: true });
|
|
148
|
+
fs.mkdirSync(path.join(existingRepo, '.git'));
|
|
149
|
+
|
|
150
|
+
const result = await bootstrap.execute(tempDir, {
|
|
151
|
+
dryRun: false,
|
|
152
|
+
skipInstall: true,
|
|
153
|
+
parallel: 1,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// api-service should be skipped, the other 2 cloned
|
|
157
|
+
expect(result.skipped).toBe(1);
|
|
158
|
+
expect(result.cloned).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
// 3. Checks out correct branch per repo
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
it('checks out correct branch per repo (git checkout command)', async () => {
|
|
165
|
+
const execCalls = [];
|
|
166
|
+
|
|
167
|
+
// Create a bootstrap with an exec spy that captures calls
|
|
168
|
+
const spyBootstrap = createWorkspaceBootstrap({
|
|
169
|
+
registry: mockRegistry,
|
|
170
|
+
vectorIndexer: mockVectorIndexer,
|
|
171
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
172
|
+
execCalls.push(cmd);
|
|
173
|
+
return { stdout: '', stderr: '' };
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await spyBootstrap.execute(tempDir, {
|
|
178
|
+
dryRun: false,
|
|
179
|
+
skipInstall: true,
|
|
180
|
+
parallel: 1,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Should contain checkout commands for the configured branches
|
|
184
|
+
const checkoutCmds = execCalls.filter(cmd => cmd.includes('checkout'));
|
|
185
|
+
// web-frontend uses 'develop', others use 'main'
|
|
186
|
+
const developCheckout = checkoutCmds.find(cmd => cmd.includes('develop'));
|
|
187
|
+
expect(developCheckout).toBeDefined();
|
|
188
|
+
|
|
189
|
+
// At least one checkout for 'main'
|
|
190
|
+
const mainCheckout = checkoutCmds.find(cmd => cmd.includes('main'));
|
|
191
|
+
expect(mainCheckout).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
// 4. Dry-run shows plan without cloning
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
it('dry-run shows plan without cloning (no exec calls, returns plan)', async () => {
|
|
198
|
+
const execCalls = [];
|
|
199
|
+
|
|
200
|
+
const spyBootstrap = createWorkspaceBootstrap({
|
|
201
|
+
registry: mockRegistry,
|
|
202
|
+
vectorIndexer: mockVectorIndexer,
|
|
203
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
204
|
+
execCalls.push(cmd);
|
|
205
|
+
return { stdout: '', stderr: '' };
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await spyBootstrap.execute(tempDir, {
|
|
210
|
+
dryRun: true,
|
|
211
|
+
skipInstall: true,
|
|
212
|
+
parallel: 1,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// No git clone commands should have been executed
|
|
216
|
+
const cloneCmds = execCalls.filter(cmd => cmd.includes('git clone'));
|
|
217
|
+
expect(cloneCmds).toHaveLength(0);
|
|
218
|
+
|
|
219
|
+
// Result should still list what would be done
|
|
220
|
+
expect(result.cloned).toBe(0);
|
|
221
|
+
|
|
222
|
+
// Should indicate 3 projects would be cloned (or return a plan array)
|
|
223
|
+
// The plan could be in result.plan or result.wouldClone
|
|
224
|
+
expect(result.skipped + (result.plan || result.wouldClone || []).length || 0)
|
|
225
|
+
.toBeGreaterThanOrEqual(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
// 5. Skip-install flag respected
|
|
230
|
+
// -------------------------------------------------------------------------
|
|
231
|
+
it('skip-install flag prevents npm install calls', async () => {
|
|
232
|
+
const execCalls = [];
|
|
233
|
+
|
|
234
|
+
const spyBootstrap = createWorkspaceBootstrap({
|
|
235
|
+
registry: mockRegistry,
|
|
236
|
+
vectorIndexer: mockVectorIndexer,
|
|
237
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
238
|
+
execCalls.push(cmd);
|
|
239
|
+
return { stdout: '', stderr: '' };
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await spyBootstrap.execute(tempDir, {
|
|
244
|
+
dryRun: false,
|
|
245
|
+
skipInstall: true,
|
|
246
|
+
parallel: 1,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// No npm install / pip install commands should have been called
|
|
250
|
+
const installCmds = execCalls.filter(cmd =>
|
|
251
|
+
cmd.includes('npm install') ||
|
|
252
|
+
cmd.includes('yarn install') ||
|
|
253
|
+
cmd.includes('pip install')
|
|
254
|
+
);
|
|
255
|
+
expect(installCmds).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// 6. Progress callback fires per repo with phase/project/status
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
it('progress callback fires per repo with phase/project/status', async () => {
|
|
262
|
+
const progressEvents = [];
|
|
263
|
+
|
|
264
|
+
await bootstrap.execute(tempDir, {
|
|
265
|
+
dryRun: false,
|
|
266
|
+
skipInstall: true,
|
|
267
|
+
parallel: 1,
|
|
268
|
+
onProgress: (event) => {
|
|
269
|
+
progressEvents.push(event);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Should have received progress events
|
|
274
|
+
expect(progressEvents.length).toBeGreaterThan(0);
|
|
275
|
+
|
|
276
|
+
// Each event should have phase, project, and status
|
|
277
|
+
for (const event of progressEvents) {
|
|
278
|
+
expect(event).toHaveProperty('phase');
|
|
279
|
+
expect(event).toHaveProperty('project');
|
|
280
|
+
expect(event).toHaveProperty('status');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Should have events for each project
|
|
284
|
+
const projectNames = progressEvents.map(e => e.project);
|
|
285
|
+
expect(projectNames).toContain('api-service');
|
|
286
|
+
expect(projectNames).toContain('web-frontend');
|
|
287
|
+
expect(projectNames).toContain('shared-lib');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// -------------------------------------------------------------------------
|
|
291
|
+
// 7. Summary reports counts correctly
|
|
292
|
+
// -------------------------------------------------------------------------
|
|
293
|
+
it('summary reports counts correctly (cloned/skipped/failed)', async () => {
|
|
294
|
+
// Pre-create one repo to be skipped
|
|
295
|
+
const existingRepo = path.join(tempDir, 'web-frontend');
|
|
296
|
+
fs.mkdirSync(existingRepo, { recursive: true });
|
|
297
|
+
fs.mkdirSync(path.join(existingRepo, '.git'));
|
|
298
|
+
|
|
299
|
+
const result = await bootstrap.execute(tempDir, {
|
|
300
|
+
dryRun: false,
|
|
301
|
+
skipInstall: true,
|
|
302
|
+
parallel: 1,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Should report correct counts
|
|
306
|
+
expect(result).toHaveProperty('cloned');
|
|
307
|
+
expect(result).toHaveProperty('skipped');
|
|
308
|
+
expect(result).toHaveProperty('failed');
|
|
309
|
+
expect(result).toHaveProperty('errors');
|
|
310
|
+
|
|
311
|
+
expect(result.cloned).toBe(2);
|
|
312
|
+
expect(result.skipped).toBe(1);
|
|
313
|
+
expect(result.failed).toBe(0);
|
|
314
|
+
expect(result.errors).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
// 8. Handles clone failure gracefully (continues with others, reports error)
|
|
319
|
+
// -------------------------------------------------------------------------
|
|
320
|
+
it('handles clone failure gracefully (continues with others, reports error)', async () => {
|
|
321
|
+
let callCount = 0;
|
|
322
|
+
|
|
323
|
+
const failingBootstrap = createWorkspaceBootstrap({
|
|
324
|
+
registry: mockRegistry,
|
|
325
|
+
vectorIndexer: mockVectorIndexer,
|
|
326
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
327
|
+
// Fail the first clone attempt (api-service), succeed the rest
|
|
328
|
+
if (cmd.includes('git clone') && cmd.includes('api-service')) {
|
|
329
|
+
throw new Error('Connection refused');
|
|
330
|
+
}
|
|
331
|
+
return { stdout: '', stderr: '' };
|
|
332
|
+
}),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await failingBootstrap.execute(tempDir, {
|
|
336
|
+
dryRun: false,
|
|
337
|
+
skipInstall: true,
|
|
338
|
+
parallel: 1,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Should have continued past the failure
|
|
342
|
+
expect(result.failed).toBe(1);
|
|
343
|
+
expect(result.cloned).toBe(2);
|
|
344
|
+
|
|
345
|
+
// Errors array should contain the failure details
|
|
346
|
+
expect(result.errors).toHaveLength(1);
|
|
347
|
+
expect(result.errors[0]).toHaveProperty('project', 'api-service');
|
|
348
|
+
expect(result.errors[0]).toHaveProperty('error');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
// 9. Triggers vector index rebuild after clone
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
it('triggers vector index rebuild after clone (calls vectorIndexer.rebuildIndex)', async () => {
|
|
355
|
+
await bootstrap.execute(tempDir, {
|
|
356
|
+
dryRun: false,
|
|
357
|
+
skipInstall: true,
|
|
358
|
+
parallel: 1,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// vectorIndexer.rebuildIndex should have been called after cloning
|
|
362
|
+
expect(mockVectorIndexer.rebuildIndex).toHaveBeenCalled();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
// 9b. Vector index rebuild skipped when vectorIndexer not provided
|
|
367
|
+
// -------------------------------------------------------------------------
|
|
368
|
+
it('skips vector index rebuild when vectorIndexer is not provided', async () => {
|
|
369
|
+
const noVectorBootstrap = createWorkspaceBootstrap({
|
|
370
|
+
registry: mockRegistry,
|
|
371
|
+
// No vectorIndexer provided
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Should not throw when vectorIndexer is absent
|
|
375
|
+
const result = await noVectorBootstrap.execute(tempDir, {
|
|
376
|
+
dryRun: false,
|
|
377
|
+
skipInstall: true,
|
|
378
|
+
parallel: 1,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(result.cloned).toBe(3);
|
|
382
|
+
// No rebuildIndex call since vectorIndexer was not provided
|
|
383
|
+
expect(mockVectorIndexer.rebuildIndex).not.toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// 10. Creates local directories as needed (parent dirs)
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
it('creates local directories as needed (nested parent dirs)', async () => {
|
|
390
|
+
// shared-lib has localPath 'libs/shared' which requires creating 'libs/' first
|
|
391
|
+
// Use a registry with only the nested-path project
|
|
392
|
+
const nestedRegistry = createMockRegistry([
|
|
393
|
+
{
|
|
394
|
+
name: 'shared-lib',
|
|
395
|
+
gitUrl: 'git@github.com:myorg/shared-lib.git',
|
|
396
|
+
localPath: 'libs/shared',
|
|
397
|
+
defaultBranch: 'main',
|
|
398
|
+
hasTlc: true,
|
|
399
|
+
description: 'Shared utilities library',
|
|
400
|
+
},
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
const execCalls = [];
|
|
404
|
+
const dirBootstrap = createWorkspaceBootstrap({
|
|
405
|
+
registry: nestedRegistry,
|
|
406
|
+
vectorIndexer: mockVectorIndexer,
|
|
407
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
408
|
+
execCalls.push(cmd);
|
|
409
|
+
// Simulate git clone by creating the target directory with .git
|
|
410
|
+
if (cmd.includes('git clone')) {
|
|
411
|
+
const targetDir = path.join(tempDir, 'libs', 'shared');
|
|
412
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
413
|
+
fs.mkdirSync(path.join(targetDir, '.git'), { recursive: true });
|
|
414
|
+
}
|
|
415
|
+
return { stdout: '', stderr: '' };
|
|
416
|
+
}),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const result = await dirBootstrap.execute(tempDir, {
|
|
420
|
+
dryRun: false,
|
|
421
|
+
skipInstall: true,
|
|
422
|
+
parallel: 1,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// The parent directory 'libs/' should exist (created before clone)
|
|
426
|
+
expect(fs.existsSync(path.join(tempDir, 'libs'))).toBe(true);
|
|
427
|
+
expect(result.cloned).toBe(1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// -------------------------------------------------------------------------
|
|
431
|
+
// 11. Handles SSH git URLs in clone command
|
|
432
|
+
// -------------------------------------------------------------------------
|
|
433
|
+
it('handles SSH git URLs in clone command', async () => {
|
|
434
|
+
const sshOnlyRegistry = createMockRegistry([
|
|
435
|
+
{
|
|
436
|
+
name: 'ssh-repo',
|
|
437
|
+
gitUrl: 'git@github.com:myorg/ssh-repo.git',
|
|
438
|
+
localPath: 'ssh-repo',
|
|
439
|
+
defaultBranch: 'main',
|
|
440
|
+
hasTlc: false,
|
|
441
|
+
description: 'SSH-cloned repo',
|
|
442
|
+
},
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
const execCalls = [];
|
|
446
|
+
const sshBootstrap = createWorkspaceBootstrap({
|
|
447
|
+
registry: sshOnlyRegistry,
|
|
448
|
+
vectorIndexer: mockVectorIndexer,
|
|
449
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
450
|
+
execCalls.push(cmd);
|
|
451
|
+
return { stdout: '', stderr: '' };
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await sshBootstrap.execute(tempDir, {
|
|
456
|
+
dryRun: false,
|
|
457
|
+
skipInstall: true,
|
|
458
|
+
parallel: 1,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// The clone command should contain the SSH URL
|
|
462
|
+
const cloneCmd = execCalls.find(cmd => cmd.includes('git clone'));
|
|
463
|
+
expect(cloneCmd).toBeDefined();
|
|
464
|
+
expect(cloneCmd).toContain('git@github.com:myorg/ssh-repo.git');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
// 12. Handles HTTPS git URLs in clone command
|
|
469
|
+
// -------------------------------------------------------------------------
|
|
470
|
+
it('handles HTTPS git URLs in clone command', async () => {
|
|
471
|
+
const httpsOnlyRegistry = createMockRegistry([
|
|
472
|
+
{
|
|
473
|
+
name: 'https-repo',
|
|
474
|
+
gitUrl: 'https://github.com/myorg/https-repo.git',
|
|
475
|
+
localPath: 'https-repo',
|
|
476
|
+
defaultBranch: 'main',
|
|
477
|
+
hasTlc: false,
|
|
478
|
+
description: 'HTTPS-cloned repo',
|
|
479
|
+
},
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
const execCalls = [];
|
|
483
|
+
const httpsBootstrap = createWorkspaceBootstrap({
|
|
484
|
+
registry: httpsOnlyRegistry,
|
|
485
|
+
vectorIndexer: mockVectorIndexer,
|
|
486
|
+
execAsync: vi.fn().mockImplementation(async (cmd) => {
|
|
487
|
+
execCalls.push(cmd);
|
|
488
|
+
return { stdout: '', stderr: '' };
|
|
489
|
+
}),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await httpsBootstrap.execute(tempDir, {
|
|
493
|
+
dryRun: false,
|
|
494
|
+
skipInstall: true,
|
|
495
|
+
parallel: 1,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// The clone command should contain the HTTPS URL
|
|
499
|
+
const cloneCmd = execCalls.find(cmd => cmd.includes('git clone'));
|
|
500
|
+
expect(cloneCmd).toBeDefined();
|
|
501
|
+
expect(cloneCmd).toContain('https://github.com/myorg/https-repo.git');
|
|
502
|
+
});
|
|
503
|
+
});
|