zdev 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/LICENSE +13 -0
- package/README.md +311 -0
- package/bun.lock +29 -0
- package/dist/index.js +3098 -0
- package/package.json +36 -0
- package/src/commands/clean.ts +122 -0
- package/src/commands/config.ts +105 -0
- package/src/commands/create.ts +381 -0
- package/src/commands/init.ts +80 -0
- package/src/commands/list.ts +78 -0
- package/src/commands/seed.ts +98 -0
- package/src/commands/start.ts +306 -0
- package/src/commands/stop.ts +101 -0
- package/src/config.ts +106 -0
- package/src/index.ts +129 -0
- package/src/utils.ts +230 -0
- package/tsconfig.json +17 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
|
|
5
|
+
export const ZEBU_HOME = join(homedir(), ".zdev");
|
|
6
|
+
export const CONFIG_PATH = join(ZEBU_HOME, "config.json");
|
|
7
|
+
export const WORKTREES_DIR = join(ZEBU_HOME, "worktrees");
|
|
8
|
+
export const SEEDS_DIR = join(ZEBU_HOME, "seeds");
|
|
9
|
+
|
|
10
|
+
export interface WorktreeAllocation {
|
|
11
|
+
project: string;
|
|
12
|
+
projectPath: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
webDir: string; // Subdirectory containing package.json
|
|
15
|
+
frontendPort: number;
|
|
16
|
+
convexPort: number;
|
|
17
|
+
funnelPath: string;
|
|
18
|
+
pids: {
|
|
19
|
+
frontend?: number;
|
|
20
|
+
convex?: number;
|
|
21
|
+
};
|
|
22
|
+
started: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface zdevConfig {
|
|
26
|
+
nextFrontendPort: number;
|
|
27
|
+
nextConvexPort: number;
|
|
28
|
+
// File patterns to auto-copy from main project to worktree
|
|
29
|
+
copyPatterns: string[];
|
|
30
|
+
// Docker host IP - how Traefik reaches host services
|
|
31
|
+
// Default 172.17.0.1 works for standard Docker on Linux
|
|
32
|
+
dockerHostIp: string;
|
|
33
|
+
// Dev domain for public URLs (e.g., "dev.example.com")
|
|
34
|
+
devDomain: string;
|
|
35
|
+
// Traefik dynamic config directory
|
|
36
|
+
traefikConfigDir: string;
|
|
37
|
+
allocations: Record<string, WorktreeAllocation>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CONFIG: zdevConfig = {
|
|
41
|
+
nextFrontendPort: 5173,
|
|
42
|
+
nextConvexPort: 3210,
|
|
43
|
+
copyPatterns: [
|
|
44
|
+
".env.local",
|
|
45
|
+
".env.development",
|
|
46
|
+
".env.development.local",
|
|
47
|
+
],
|
|
48
|
+
dockerHostIp: "172.17.0.1",
|
|
49
|
+
devDomain: "",
|
|
50
|
+
traefikConfigDir: "/infra/traefik/dynamic",
|
|
51
|
+
allocations: {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function ensurezdevDirs(): void {
|
|
55
|
+
if (!existsSync(ZEBU_HOME)) {
|
|
56
|
+
mkdirSync(ZEBU_HOME, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(WORKTREES_DIR)) {
|
|
59
|
+
mkdirSync(WORKTREES_DIR, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
if (!existsSync(SEEDS_DIR)) {
|
|
62
|
+
mkdirSync(SEEDS_DIR, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function loadConfig(): zdevConfig {
|
|
67
|
+
ensurezdevDirs();
|
|
68
|
+
|
|
69
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
70
|
+
saveConfig(DEFAULT_CONFIG);
|
|
71
|
+
return DEFAULT_CONFIG;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const data = readFileSync(CONFIG_PATH, "utf-8");
|
|
76
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
|
|
77
|
+
} catch {
|
|
78
|
+
return DEFAULT_CONFIG;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function saveConfig(config: zdevConfig): void {
|
|
83
|
+
ensurezdevDirs();
|
|
84
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function allocatePorts(config: zdevConfig, includeConvex: boolean = true): { frontend: number; convex: number } {
|
|
88
|
+
const frontend = config.nextFrontendPort;
|
|
89
|
+
config.nextFrontendPort = frontend + 1;
|
|
90
|
+
|
|
91
|
+
let convex = 0;
|
|
92
|
+
if (includeConvex) {
|
|
93
|
+
convex = config.nextConvexPort;
|
|
94
|
+
config.nextConvexPort = convex + 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { frontend, convex };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getWorktreePath(name: string): string {
|
|
101
|
+
return join(WORKTREES_DIR, name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getSeedPath(projectName: string): string {
|
|
105
|
+
return join(SEEDS_DIR, `${projectName}.zip`);
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { create } from "./commands/create.js";
|
|
4
|
+
import { init } from "./commands/init.js";
|
|
5
|
+
import { start } from "./commands/start.js";
|
|
6
|
+
import { stop } from "./commands/stop.js";
|
|
7
|
+
import { list } from "./commands/list.js";
|
|
8
|
+
import { clean } from "./commands/clean.js";
|
|
9
|
+
import { seedExport, seedImport } from "./commands/seed.js";
|
|
10
|
+
import { configCmd } from "./commands/config.js";
|
|
11
|
+
|
|
12
|
+
const program = new Command();
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name("zdev")
|
|
16
|
+
.description("🐂 zdev - Multi-agent worktree development environment")
|
|
17
|
+
.version("0.1.0");
|
|
18
|
+
|
|
19
|
+
// zdev create
|
|
20
|
+
program
|
|
21
|
+
.command("create <name>")
|
|
22
|
+
.description("Create a new TanStack Start project")
|
|
23
|
+
.option("--convex", "Add Convex backend integration")
|
|
24
|
+
.option("--flat", "Flat structure (no monorepo)")
|
|
25
|
+
.action(async (name, options) => {
|
|
26
|
+
await create(name, {
|
|
27
|
+
convex: options.convex,
|
|
28
|
+
flat: options.flat,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// zdev init
|
|
33
|
+
program
|
|
34
|
+
.command("init [path]")
|
|
35
|
+
.description("Initialize zdev for a project")
|
|
36
|
+
.option("-s, --seed", "Create initial seed data from current Convex state")
|
|
37
|
+
.action(async (path, options) => {
|
|
38
|
+
await init(path, options);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// zdev start
|
|
42
|
+
program
|
|
43
|
+
.command("start <feature>")
|
|
44
|
+
.description("Start working on a feature (creates worktree, starts servers)")
|
|
45
|
+
.option("-p, --project <path>", "Project path (default: current directory)", ".")
|
|
46
|
+
.option("--port <number>", "Frontend port (auto-allocated if not specified)", parseInt)
|
|
47
|
+
.option("--local", "Local only - skip public URL setup via Traefik")
|
|
48
|
+
.option("-s, --seed", "Import seed data into the new worktree")
|
|
49
|
+
.option("-b, --base-branch <branch>", "Base branch to create from", "origin/main")
|
|
50
|
+
.option("-w, --web-dir <dir>", "Subdirectory containing package.json (auto-detected if not specified)")
|
|
51
|
+
.action(async (feature, options) => {
|
|
52
|
+
await start(feature, options.project, {
|
|
53
|
+
port: options.port,
|
|
54
|
+
local: options.local,
|
|
55
|
+
seed: options.seed,
|
|
56
|
+
baseBranch: options.baseBranch,
|
|
57
|
+
webDir: options.webDir,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// zdev stop
|
|
62
|
+
program
|
|
63
|
+
.command("stop <feature>")
|
|
64
|
+
.description("Stop servers for a feature")
|
|
65
|
+
.option("-p, --project <path>", "Project path (to disambiguate features)")
|
|
66
|
+
.option("-k, --keep", "Keep worktree, just stop servers")
|
|
67
|
+
.action(async (feature, options) => {
|
|
68
|
+
await stop(feature, options);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// zdev list
|
|
72
|
+
program
|
|
73
|
+
.command("list")
|
|
74
|
+
.description("List active features and their status")
|
|
75
|
+
.option("--json", "Output as JSON")
|
|
76
|
+
.action(async (options) => {
|
|
77
|
+
await list(options);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// zdev clean
|
|
81
|
+
program
|
|
82
|
+
.command("clean <feature>")
|
|
83
|
+
.description("Remove a feature worktree (use after PR is merged)")
|
|
84
|
+
.option("-p, --project <path>", "Project path")
|
|
85
|
+
.option("-f, --force", "Force remove even if git worktree fails")
|
|
86
|
+
.action(async (feature, options) => {
|
|
87
|
+
await clean(feature, options);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// zdev seed
|
|
91
|
+
const seedCmd = program
|
|
92
|
+
.command("seed")
|
|
93
|
+
.description("Manage seed data for projects");
|
|
94
|
+
|
|
95
|
+
seedCmd
|
|
96
|
+
.command("export [path]")
|
|
97
|
+
.description("Export current Convex data as seed")
|
|
98
|
+
.action(async (path) => {
|
|
99
|
+
await seedExport(path);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
seedCmd
|
|
103
|
+
.command("import [path]")
|
|
104
|
+
.description("Import seed data into current worktree")
|
|
105
|
+
.action(async (path) => {
|
|
106
|
+
await seedImport(path);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// zdev config
|
|
110
|
+
program
|
|
111
|
+
.command("config")
|
|
112
|
+
.description("View and manage zdev configuration")
|
|
113
|
+
.option("-a, --add <pattern>", "Add a file pattern to auto-copy")
|
|
114
|
+
.option("-r, --remove <pattern>", "Remove a file pattern")
|
|
115
|
+
.option("-s, --set <key=value>", "Set a config value (devDomain, dockerHostIp, traefikConfigDir)")
|
|
116
|
+
.option("-l, --list", "List current configuration")
|
|
117
|
+
.action(async (options) => {
|
|
118
|
+
await configCmd(options);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// zdev status (alias for list)
|
|
122
|
+
program
|
|
123
|
+
.command("status")
|
|
124
|
+
.description("Show zdev status (alias for list)")
|
|
125
|
+
.action(async () => {
|
|
126
|
+
await list({});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
program.parse();
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn, spawnSync, type SpawnOptions } from "child_process";
|
|
2
|
+
import { existsSync, writeFileSync, unlinkSync } from "fs";
|
|
3
|
+
import { basename, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
export function run(
|
|
6
|
+
command: string,
|
|
7
|
+
args: string[],
|
|
8
|
+
options?: SpawnOptions
|
|
9
|
+
): { success: boolean; stdout: string; stderr: string; code: number | null } {
|
|
10
|
+
const result = spawnSync(command, args, {
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
...options,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
success: result.status === 0,
|
|
17
|
+
stdout: (result.stdout as string) || "",
|
|
18
|
+
stderr: (result.stderr as string) || "",
|
|
19
|
+
code: result.status,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function runBackground(
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options?: SpawnOptions
|
|
27
|
+
): number | undefined {
|
|
28
|
+
const child = spawn(command, args, {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
child.unref();
|
|
35
|
+
return child.pid;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isGitRepo(path: string): boolean {
|
|
39
|
+
return existsSync(resolve(path, ".git"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getRepoName(path: string): string {
|
|
43
|
+
const result = run("git", ["remote", "get-url", "origin"], { cwd: path });
|
|
44
|
+
if (result.success) {
|
|
45
|
+
// Extract repo name from URL
|
|
46
|
+
const url = result.stdout.trim();
|
|
47
|
+
const match = url.match(/\/([^\/]+?)(\.git)?$/);
|
|
48
|
+
if (match) return match[1];
|
|
49
|
+
}
|
|
50
|
+
return basename(resolve(path));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function gitFetch(repoPath: string): boolean {
|
|
54
|
+
const result = run("git", ["fetch", "origin"], { cwd: repoPath });
|
|
55
|
+
return result.success;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createWorktree(
|
|
59
|
+
repoPath: string,
|
|
60
|
+
worktreePath: string,
|
|
61
|
+
branch: string,
|
|
62
|
+
baseBranch: string = "origin/main"
|
|
63
|
+
): { success: boolean; error?: string } {
|
|
64
|
+
const result = run(
|
|
65
|
+
"git",
|
|
66
|
+
["worktree", "add", worktreePath, "-b", branch, baseBranch],
|
|
67
|
+
{ cwd: repoPath }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
return { success: false, error: result.stderr };
|
|
72
|
+
}
|
|
73
|
+
return { success: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function removeWorktree(
|
|
77
|
+
repoPath: string,
|
|
78
|
+
worktreePath: string
|
|
79
|
+
): { success: boolean; error?: string } {
|
|
80
|
+
const result = run("git", ["worktree", "remove", worktreePath, "--force"], {
|
|
81
|
+
cwd: repoPath,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
return { success: false, error: result.stderr };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Prune worktree references
|
|
89
|
+
run("git", ["worktree", "prune"], { cwd: repoPath });
|
|
90
|
+
|
|
91
|
+
return { success: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listWorktrees(repoPath: string): string[] {
|
|
95
|
+
const result = run("git", ["worktree", "list", "--porcelain"], {
|
|
96
|
+
cwd: repoPath,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!result.success) return [];
|
|
100
|
+
|
|
101
|
+
const worktrees: string[] = [];
|
|
102
|
+
const lines = result.stdout.split("\n");
|
|
103
|
+
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (line.startsWith("worktree ")) {
|
|
106
|
+
worktrees.push(line.replace("worktree ", ""));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return worktrees;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
import { loadConfig } from "./config.js";
|
|
114
|
+
|
|
115
|
+
export function traefikAddRoute(name: string, port: number): boolean {
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
const configPath = `${config.traefikConfigDir}/${name}.yml`;
|
|
118
|
+
const subdomain = name;
|
|
119
|
+
|
|
120
|
+
const traefikConfig = `# zdev auto-generated config for ${name}
|
|
121
|
+
http:
|
|
122
|
+
routers:
|
|
123
|
+
${name}:
|
|
124
|
+
rule: "Host(\`${subdomain}.${config.devDomain}\`)"
|
|
125
|
+
entrypoints:
|
|
126
|
+
- websecure
|
|
127
|
+
service: ${name}
|
|
128
|
+
tls:
|
|
129
|
+
certResolver: myresolver
|
|
130
|
+
|
|
131
|
+
services:
|
|
132
|
+
${name}:
|
|
133
|
+
loadBalancer:
|
|
134
|
+
servers:
|
|
135
|
+
- url: "http://${config.dockerHostIp}:${port}"
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(configPath, traefikConfig);
|
|
140
|
+
return true;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function traefikRemoveRoute(name: string): boolean {
|
|
147
|
+
const zdevConfig = loadConfig();
|
|
148
|
+
const configPath = `${zdevConfig.traefikConfigDir}/${name}.yml`;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
if (existsSync(configPath)) {
|
|
152
|
+
unlinkSync(configPath);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getTraefikStatus(): { baseUrl?: string; running: boolean; devDomain?: string } {
|
|
161
|
+
const config = loadConfig();
|
|
162
|
+
|
|
163
|
+
// If devDomain not configured, can't do public URLs
|
|
164
|
+
if (!config.devDomain) {
|
|
165
|
+
return { running: false, devDomain: undefined };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if Traefik config dir exists (don't need API - just file provider)
|
|
169
|
+
const configDirExists = existsSync(config.traefikConfigDir);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
running: configDirExists,
|
|
173
|
+
baseUrl: configDirExists ? `https://*.${config.devDomain}` : undefined,
|
|
174
|
+
devDomain: config.devDomain,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Legacy Tailscale functions (kept for compatibility)
|
|
179
|
+
export function tailscaleServe(path: string, port: number): boolean {
|
|
180
|
+
const result = run("tailscale", [
|
|
181
|
+
"funnel",
|
|
182
|
+
"--bg",
|
|
183
|
+
"--set-path",
|
|
184
|
+
path,
|
|
185
|
+
`http://127.0.0.1:${port}`,
|
|
186
|
+
]);
|
|
187
|
+
return result.success;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function tailscaleRemove(path: string): boolean {
|
|
191
|
+
const result = run("tailscale", ["funnel", "--set-path", path, "off"]);
|
|
192
|
+
return result.success;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getTailscaleStatus(): { baseUrl?: string; running: boolean } {
|
|
196
|
+
const result = run("tailscale", ["status", "--json"]);
|
|
197
|
+
|
|
198
|
+
if (!result.success) {
|
|
199
|
+
return { running: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const status = JSON.parse(result.stdout);
|
|
204
|
+
const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
|
|
205
|
+
return {
|
|
206
|
+
running: true,
|
|
207
|
+
baseUrl: dnsName ? `https://${dnsName}` : undefined,
|
|
208
|
+
};
|
|
209
|
+
} catch {
|
|
210
|
+
return { running: false };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function killProcess(pid: number): boolean {
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, "SIGTERM");
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function isProcessRunning(pid: number): boolean {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"types": ["bun-types"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|