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/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
+ }