worktree-compose 0.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/README.md +618 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/commands/clean.d.ts +1 -0
- package/dist/commands/clean.js +49 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +71 -0
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +35 -0
- package/dist/commands/restart.d.ts +1 -0
- package/dist/commands/restart.js +6 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +37 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +24 -0
- package/dist/compose/detect.d.ts +3 -0
- package/dist/compose/detect.js +25 -0
- package/dist/compose/parse.d.ts +2 -0
- package/dist/compose/parse.js +55 -0
- package/dist/compose/types.d.ts +13 -0
- package/dist/compose/types.js +1 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +16 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.js +29 -0
- package/dist/git/promote.d.ts +4 -0
- package/dist/git/promote.js +58 -0
- package/dist/git/worktree.d.ts +10 -0
- package/dist/git/worktree.js +40 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +132 -0
- package/dist/ports/allocate.d.ts +3 -0
- package/dist/ports/allocate.js +29 -0
- package/dist/ports/extract.d.ts +4 -0
- package/dist/ports/extract.js +92 -0
- package/dist/ports/types.d.ts +13 -0
- package/dist/ports/types.js +1 -0
- package/dist/sync/env.d.ts +5 -0
- package/dist/sync/env.js +58 -0
- package/dist/sync/files.d.ts +2 -0
- package/dist/sync/files.js +59 -0
- package/dist/utils/exec.d.ts +4 -0
- package/dist/utils/exec.js +23 -0
- package/dist/utils/log.d.ts +5 -0
- package/dist/utils/log.js +16 -0
- package/dist/utils/sanitize.d.ts +2 -0
- package/dist/utils/sanitize.js +11 -0
- package/package.json +49 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { buildContext, filterWorktrees } from "../context.js";
|
|
5
|
+
import { allocateWorktreePorts } from "../ports/allocate.js";
|
|
6
|
+
import { composeProjectName } from "../utils/sanitize.js";
|
|
7
|
+
import { exec, execSafe } from "../utils/exec.js";
|
|
8
|
+
import { syncWorktreeFiles } from "../sync/files.js";
|
|
9
|
+
import { copyBaseEnv, injectPortOverrides } from "../sync/env.js";
|
|
10
|
+
import { getRepoRoot } from "../compose/detect.js";
|
|
11
|
+
import { getWorktreeByIndex, getWorktreeBranch } from "../git/worktree.js";
|
|
12
|
+
import { getChangedFiles, getLocalDirtyFiles, findConflicts, promoteFiles, } from "../git/promote.js";
|
|
13
|
+
function startWorktrees(indices) {
|
|
14
|
+
const ctx = buildContext();
|
|
15
|
+
if (ctx.worktrees.length === 0)
|
|
16
|
+
return "No worktrees found.";
|
|
17
|
+
const targets = filterWorktrees(ctx.worktrees, indices);
|
|
18
|
+
const results = [];
|
|
19
|
+
for (const wt of targets) {
|
|
20
|
+
const idx = ctx.worktrees.indexOf(wt) + 1;
|
|
21
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
22
|
+
const allocations = allocateWorktreePorts(ctx.portMappings, idx);
|
|
23
|
+
syncWorktreeFiles(ctx.repoRoot, wt.path, ctx.composeFile, ctx.config.sync);
|
|
24
|
+
copyBaseEnv(ctx.repoRoot, wt.path);
|
|
25
|
+
injectPortOverrides(`${wt.path}/.env`, allocations, ctx.config.envOverrides);
|
|
26
|
+
exec(`docker compose -p "${project}" up -d --build`, { cwd: wt.path });
|
|
27
|
+
const ports = allocations.map((a) => `${a.envVar}=${a.port}`).join(", ");
|
|
28
|
+
results.push(`Worktree ${idx} (${wt.branch}): started [${ports}]`);
|
|
29
|
+
}
|
|
30
|
+
return results.join("\n");
|
|
31
|
+
}
|
|
32
|
+
function stopWorktrees(indices) {
|
|
33
|
+
const ctx = buildContext();
|
|
34
|
+
if (ctx.worktrees.length === 0)
|
|
35
|
+
return "No worktrees found.";
|
|
36
|
+
const targets = filterWorktrees(ctx.worktrees, indices);
|
|
37
|
+
const results = [];
|
|
38
|
+
for (const wt of targets) {
|
|
39
|
+
const idx = ctx.worktrees.indexOf(wt) + 1;
|
|
40
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
41
|
+
try {
|
|
42
|
+
exec(`docker compose -p "${project}" down`, { cwd: wt.path });
|
|
43
|
+
results.push(`Worktree ${idx} (${wt.branch}): stopped`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
results.push(`Worktree ${idx} (${wt.branch}): already stopped`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results.join("\n");
|
|
50
|
+
}
|
|
51
|
+
function listWorktrees() {
|
|
52
|
+
const ctx = buildContext();
|
|
53
|
+
return ctx.worktrees.map((wt, i) => {
|
|
54
|
+
const idx = i + 1;
|
|
55
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
56
|
+
const allocations = allocateWorktreePorts(ctx.portMappings, idx);
|
|
57
|
+
const up = execSafe(`docker compose -p "${project}" ps --format json`, {
|
|
58
|
+
cwd: wt.path,
|
|
59
|
+
}) !== null;
|
|
60
|
+
return {
|
|
61
|
+
index: idx,
|
|
62
|
+
branch: wt.branch,
|
|
63
|
+
path: wt.path,
|
|
64
|
+
project,
|
|
65
|
+
status: up ? "up" : "down",
|
|
66
|
+
ports: Object.fromEntries(allocations.map((a) => [a.envVar, a.port])),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function promoteWorktree(index) {
|
|
71
|
+
const repoRoot = getRepoRoot();
|
|
72
|
+
const wt = getWorktreeByIndex(repoRoot, index);
|
|
73
|
+
if (!wt)
|
|
74
|
+
return `Worktree index ${index} not found.`;
|
|
75
|
+
const currentBranch = getWorktreeBranch(repoRoot);
|
|
76
|
+
const files = getChangedFiles(repoRoot, wt.path, currentBranch, wt.branch);
|
|
77
|
+
if (files.length === 0)
|
|
78
|
+
return "No changes to promote.";
|
|
79
|
+
const dirtyFiles = getLocalDirtyFiles(repoRoot);
|
|
80
|
+
const conflicts = findConflicts(files, dirtyFiles);
|
|
81
|
+
if (conflicts.length > 0) {
|
|
82
|
+
return `Abort: ${conflicts.length} file(s) have uncommitted changes: ${conflicts.join(", ")}`;
|
|
83
|
+
}
|
|
84
|
+
promoteFiles(repoRoot, wt.path, files);
|
|
85
|
+
return `Promoted ${files.length} file(s) from worktree ${index} (${wt.branch}) into ${currentBranch}:\n${files.join("\n")}`;
|
|
86
|
+
}
|
|
87
|
+
export async function startMcpServer() {
|
|
88
|
+
const server = new McpServer({
|
|
89
|
+
name: "worktree-compose",
|
|
90
|
+
version: "0.1.0",
|
|
91
|
+
});
|
|
92
|
+
server.tool("wtc_start", "Start Docker Compose stacks for worktrees. Syncs files, injects isolated ports, runs docker compose up.", { indices: z.array(z.number()).optional().describe("Worktree indices to start. Omit for all.") }, async ({ indices }) => ({
|
|
93
|
+
content: [{ type: "text", text: startWorktrees(indices ?? []) }],
|
|
94
|
+
}));
|
|
95
|
+
server.tool("wtc_stop", "Stop Docker Compose stacks for worktrees.", { indices: z.array(z.number()).optional().describe("Worktree indices to stop. Omit for all.") }, async ({ indices }) => ({
|
|
96
|
+
content: [{ type: "text", text: stopWorktrees(indices ?? []) }],
|
|
97
|
+
}));
|
|
98
|
+
server.tool("wtc_restart", "Restart Docker Compose stacks for worktrees. Use after DB migrations, Dockerfile changes, or config updates.", { indices: z.array(z.number()).optional().describe("Worktree indices to restart. Omit for all.") }, async ({ indices }) => {
|
|
99
|
+
stopWorktrees(indices ?? []);
|
|
100
|
+
const result = startWorktrees(indices ?? []);
|
|
101
|
+
return { content: [{ type: "text", text: result }] };
|
|
102
|
+
});
|
|
103
|
+
server.tool("wtc_list", "List all worktrees with branch, status, ports, and URLs.", {}, async () => ({
|
|
104
|
+
content: [
|
|
105
|
+
{ type: "text", text: JSON.stringify(listWorktrees(), null, 2) },
|
|
106
|
+
],
|
|
107
|
+
}));
|
|
108
|
+
server.tool("wtc_promote", "Copy changed files from a worktree into the current branch as uncommitted changes.", { index: z.number().describe("Worktree index to promote.") }, async ({ index }) => ({
|
|
109
|
+
content: [{ type: "text", text: promoteWorktree(index) }],
|
|
110
|
+
}));
|
|
111
|
+
server.tool("wtc_clean", "Stop all worktree containers, remove all worktrees, prune everything.", {}, async () => {
|
|
112
|
+
const { cleanCommand } = await import("../commands/clean.js");
|
|
113
|
+
try {
|
|
114
|
+
cleanCommand();
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: "Cleanup complete." }],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const transport = new StdioServerTransport();
|
|
131
|
+
await server.connect(transport);
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const BASE_OFFSET = 20000;
|
|
2
|
+
export function allocatePort(defaultPort, worktreeIndex) {
|
|
3
|
+
let port = BASE_OFFSET + defaultPort + worktreeIndex;
|
|
4
|
+
if (port > 65535) {
|
|
5
|
+
port = defaultPort + 100 * worktreeIndex;
|
|
6
|
+
}
|
|
7
|
+
if (port > 65535 || port < 1024) {
|
|
8
|
+
throw new Error(`Cannot allocate port for default ${defaultPort} at worktree index ${worktreeIndex}. ` +
|
|
9
|
+
`Computed port ${port} is out of valid range (1024-65535).`);
|
|
10
|
+
}
|
|
11
|
+
return port;
|
|
12
|
+
}
|
|
13
|
+
export function allocateWorktreePorts(mappings, worktreeIndex) {
|
|
14
|
+
const overridable = mappings.filter((m) => m.envVar !== null);
|
|
15
|
+
const allocations = overridable.map((m) => ({
|
|
16
|
+
serviceName: m.serviceName,
|
|
17
|
+
envVar: m.envVar,
|
|
18
|
+
port: allocatePort(m.defaultPort, worktreeIndex),
|
|
19
|
+
containerPort: m.containerPort,
|
|
20
|
+
}));
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const a of allocations) {
|
|
23
|
+
if (seen.has(a.port)) {
|
|
24
|
+
throw new Error(`Port collision: ${a.port} is assigned to multiple services in worktree ${worktreeIndex}.`);
|
|
25
|
+
}
|
|
26
|
+
seen.add(a.port);
|
|
27
|
+
}
|
|
28
|
+
return allocations;
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const ENV_VAR_PATTERN = /^\$\{([A-Z_][A-Z0-9_]*):-(\d+)\}$/;
|
|
2
|
+
function stripProtocol(port) {
|
|
3
|
+
return port.replace(/\/(tcp|udp|sctp)$/i, "");
|
|
4
|
+
}
|
|
5
|
+
function splitPortString(raw) {
|
|
6
|
+
const cleaned = stripProtocol(raw);
|
|
7
|
+
const segments = [];
|
|
8
|
+
let current = "";
|
|
9
|
+
let depth = 0;
|
|
10
|
+
for (const ch of cleaned) {
|
|
11
|
+
if (ch === "$" || (depth > 0 && ch !== "}")) {
|
|
12
|
+
current += ch;
|
|
13
|
+
if (ch === "{")
|
|
14
|
+
depth++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ch === "{" && current.endsWith("$")) {
|
|
18
|
+
current += ch;
|
|
19
|
+
depth++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "}") {
|
|
23
|
+
current += ch;
|
|
24
|
+
depth--;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === ":" && depth === 0) {
|
|
28
|
+
segments.push(current);
|
|
29
|
+
current = "";
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
current += ch;
|
|
33
|
+
}
|
|
34
|
+
segments.push(current);
|
|
35
|
+
if (segments.length === 1)
|
|
36
|
+
return null;
|
|
37
|
+
if (segments.length === 2)
|
|
38
|
+
return { host: segments[0], container: segments[1] };
|
|
39
|
+
if (segments.length === 3)
|
|
40
|
+
return { host: segments[1], container: segments[2] };
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function resolveEnvVar(segment) {
|
|
44
|
+
const match = segment.match(ENV_VAR_PATTERN);
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
return { envVar: match[1], defaultPort: parseInt(match[2], 10) };
|
|
48
|
+
}
|
|
49
|
+
function resolvePort(segment) {
|
|
50
|
+
const n = parseInt(segment, 10);
|
|
51
|
+
return isNaN(n) ? null : n;
|
|
52
|
+
}
|
|
53
|
+
export function extractPortMappings(services) {
|
|
54
|
+
const mappings = [];
|
|
55
|
+
for (const svc of services) {
|
|
56
|
+
for (const raw of svc.ports) {
|
|
57
|
+
const split = splitPortString(raw);
|
|
58
|
+
if (!split)
|
|
59
|
+
continue;
|
|
60
|
+
const envResult = resolveEnvVar(split.host);
|
|
61
|
+
const containerEnv = resolveEnvVar(split.container);
|
|
62
|
+
const containerPort = containerEnv?.defaultPort ?? resolvePort(split.container);
|
|
63
|
+
if (containerPort === null)
|
|
64
|
+
continue;
|
|
65
|
+
if (envResult) {
|
|
66
|
+
mappings.push({
|
|
67
|
+
serviceName: svc.name,
|
|
68
|
+
envVar: envResult.envVar,
|
|
69
|
+
defaultPort: envResult.defaultPort,
|
|
70
|
+
containerPort,
|
|
71
|
+
raw,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const hostPort = resolvePort(split.host);
|
|
76
|
+
if (hostPort !== null) {
|
|
77
|
+
mappings.push({
|
|
78
|
+
serviceName: svc.name,
|
|
79
|
+
envVar: null,
|
|
80
|
+
defaultPort: hostPort,
|
|
81
|
+
containerPort,
|
|
82
|
+
raw,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return mappings;
|
|
89
|
+
}
|
|
90
|
+
export function suggestEnvVar(serviceName) {
|
|
91
|
+
return `${serviceName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_PORT`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface PortMapping {
|
|
2
|
+
serviceName: string;
|
|
3
|
+
envVar: string | null;
|
|
4
|
+
defaultPort: number;
|
|
5
|
+
containerPort: number;
|
|
6
|
+
raw: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PortAllocation {
|
|
9
|
+
serviceName: string;
|
|
10
|
+
envVar: string;
|
|
11
|
+
port: number;
|
|
12
|
+
containerPort: number;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PortAllocation } from "../ports/types.js";
|
|
2
|
+
export declare function stripOverrideBlock(content: string): string;
|
|
3
|
+
export declare function buildOverrideBlock(allocations: PortAllocation[], envOverrides?: Record<string, string>): string;
|
|
4
|
+
export declare function injectPortOverrides(envPath: string, allocations: PortAllocation[], envOverrides?: Record<string, string>): void;
|
|
5
|
+
export declare function copyBaseEnv(repoRoot: string, wtPath: string): void;
|
package/dist/sync/env.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const BLOCK_START = "# --- wtc port overrides ---";
|
|
4
|
+
const BLOCK_END = "# --- end wtc ---";
|
|
5
|
+
export function stripOverrideBlock(content) {
|
|
6
|
+
const startIdx = content.indexOf(BLOCK_START);
|
|
7
|
+
if (startIdx === -1)
|
|
8
|
+
return content;
|
|
9
|
+
const endIdx = content.indexOf(BLOCK_END, startIdx);
|
|
10
|
+
if (endIdx === -1)
|
|
11
|
+
return content;
|
|
12
|
+
const before = content.slice(0, startIdx).trimEnd();
|
|
13
|
+
const after = content.slice(endIdx + BLOCK_END.length).trimStart();
|
|
14
|
+
return [before, after].filter(Boolean).join("\n") + "\n";
|
|
15
|
+
}
|
|
16
|
+
export function buildOverrideBlock(allocations, envOverrides) {
|
|
17
|
+
const lines = [BLOCK_START];
|
|
18
|
+
const portValues = new Map();
|
|
19
|
+
for (const a of allocations) {
|
|
20
|
+
lines.push(`${a.envVar}=${a.port}`);
|
|
21
|
+
portValues.set(a.envVar, a.port);
|
|
22
|
+
}
|
|
23
|
+
if (envOverrides) {
|
|
24
|
+
for (const [key, template] of Object.entries(envOverrides)) {
|
|
25
|
+
let value = template;
|
|
26
|
+
for (const [envVar, port] of portValues) {
|
|
27
|
+
value = value.replace(`\${${envVar}}`, String(port));
|
|
28
|
+
}
|
|
29
|
+
lines.push(`${key}=${value}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
lines.push(BLOCK_END);
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
export function injectPortOverrides(envPath, allocations, envOverrides) {
|
|
36
|
+
let content = "";
|
|
37
|
+
if (fs.existsSync(envPath)) {
|
|
38
|
+
content = fs.readFileSync(envPath, "utf-8");
|
|
39
|
+
}
|
|
40
|
+
content = stripOverrideBlock(content);
|
|
41
|
+
const block = buildOverrideBlock(allocations, envOverrides);
|
|
42
|
+
const result = content.trimEnd() + "\n\n" + block + "\n";
|
|
43
|
+
fs.writeFileSync(envPath, result, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
export function copyBaseEnv(repoRoot, wtPath) {
|
|
46
|
+
const envSrc = path.join(repoRoot, ".env");
|
|
47
|
+
const envExampleSrc = path.join(repoRoot, ".env.example");
|
|
48
|
+
const envDst = path.join(wtPath, ".env");
|
|
49
|
+
if (fs.existsSync(envSrc)) {
|
|
50
|
+
fs.copyFileSync(envSrc, envDst);
|
|
51
|
+
}
|
|
52
|
+
else if (fs.existsSync(envExampleSrc)) {
|
|
53
|
+
fs.copyFileSync(envExampleSrc, envDst);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
fs.writeFileSync(envDst, "", "utf-8");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function copyFile(src, dst) {
|
|
4
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
5
|
+
if (fs.existsSync(src)) {
|
|
6
|
+
fs.copyFileSync(src, dst);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function copyDir(src, dst) {
|
|
10
|
+
if (!fs.existsSync(src))
|
|
11
|
+
return;
|
|
12
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
13
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
14
|
+
const srcPath = path.join(src, entry.name);
|
|
15
|
+
const dstPath = path.join(dst, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
copyDir(srcPath, dstPath);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function getDockerfiles(composeFile, repoRoot) {
|
|
25
|
+
const files = [];
|
|
26
|
+
for (const svc of composeFile.services) {
|
|
27
|
+
if (!svc.build)
|
|
28
|
+
continue;
|
|
29
|
+
const context = svc.build.context ?? ".";
|
|
30
|
+
const dockerfile = svc.build.dockerfile ?? "Dockerfile";
|
|
31
|
+
const resolvedDir = path.resolve(path.dirname(composeFile.composePath), context);
|
|
32
|
+
const fullPath = path.join(resolvedDir, dockerfile);
|
|
33
|
+
const rel = path.relative(repoRoot, fullPath);
|
|
34
|
+
files.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return [...new Set(files)];
|
|
37
|
+
}
|
|
38
|
+
export function syncWorktreeFiles(repoRoot, wtPath, composeFile, extraSync) {
|
|
39
|
+
const composeRel = path.relative(repoRoot, composeFile.composePath);
|
|
40
|
+
copyFile(path.join(repoRoot, composeRel), path.join(wtPath, composeRel));
|
|
41
|
+
for (const df of getDockerfiles(composeFile, repoRoot)) {
|
|
42
|
+
copyFile(path.join(repoRoot, df), path.join(wtPath, df));
|
|
43
|
+
}
|
|
44
|
+
if (extraSync) {
|
|
45
|
+
for (const p of extraSync) {
|
|
46
|
+
const src = path.join(repoRoot, p);
|
|
47
|
+
const dst = path.join(wtPath, p);
|
|
48
|
+
if (fs.existsSync(src)) {
|
|
49
|
+
const stat = fs.statSync(src);
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
copyDir(src, dst);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
copyFile(src, dst);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ExecSyncOptions } from "node:child_process";
|
|
2
|
+
export declare function exec(cmd: string, opts?: ExecSyncOptions): string;
|
|
3
|
+
export declare function execLive(cmd: string, opts?: ExecSyncOptions): void;
|
|
4
|
+
export declare function execSafe(cmd: string, opts?: ExecSyncOptions): string | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export function exec(cmd, opts) {
|
|
3
|
+
const result = execSync(cmd, {
|
|
4
|
+
encoding: "utf-8",
|
|
5
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
6
|
+
...opts,
|
|
7
|
+
});
|
|
8
|
+
return result.trim();
|
|
9
|
+
}
|
|
10
|
+
export function execLive(cmd, opts) {
|
|
11
|
+
execSync(cmd, {
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
...opts,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function execSafe(cmd, opts) {
|
|
17
|
+
try {
|
|
18
|
+
return exec(cmd, opts);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export function info(msg) {
|
|
3
|
+
console.log(chalk.blue("ℹ"), msg);
|
|
4
|
+
}
|
|
5
|
+
export function success(msg) {
|
|
6
|
+
console.log(chalk.green("✔"), msg);
|
|
7
|
+
}
|
|
8
|
+
export function warn(msg) {
|
|
9
|
+
console.log(chalk.yellow("⚠"), msg);
|
|
10
|
+
}
|
|
11
|
+
export function error(msg) {
|
|
12
|
+
console.error(chalk.red("✖"), msg);
|
|
13
|
+
}
|
|
14
|
+
export function header(msg) {
|
|
15
|
+
console.log(chalk.bold.cyan(`\n=== ${msg} ===`));
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function sanitize(input) {
|
|
2
|
+
return input
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
5
|
+
.replace(/-+/g, "-")
|
|
6
|
+
.replace(/^-/, "")
|
|
7
|
+
.replace(/-$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function composeProjectName(repoName, index, branch) {
|
|
10
|
+
return sanitize(`${repoName}-wt-${index}-${branch}`);
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "worktree-compose",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-config Docker Compose isolation for git worktrees",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"worktree-compose": "./dist/cli.js",
|
|
8
|
+
"wtc": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"worktree",
|
|
25
|
+
"docker",
|
|
26
|
+
"compose",
|
|
27
|
+
"isolation",
|
|
28
|
+
"parallel",
|
|
29
|
+
"development",
|
|
30
|
+
"mcp"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
|
+
"chalk": "^5.4.1",
|
|
36
|
+
"cli-table3": "^0.6.5",
|
|
37
|
+
"commander": "^14.0.0",
|
|
38
|
+
"yaml": "^2.8.0",
|
|
39
|
+
"zod": "^3.24.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/jest": "^29.5.14",
|
|
43
|
+
"@types/node": "^22.15.21",
|
|
44
|
+
"jest": "^29.7.0",
|
|
45
|
+
"ts-jest": "^29.3.4",
|
|
46
|
+
"tsx": "^4.19.4",
|
|
47
|
+
"typescript": "^5.8.3"
|
|
48
|
+
}
|
|
49
|
+
}
|