@getforgeai/cli 0.1.0-beta.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.
Files changed (221) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +80 -0
  3. package/dist/bin/forgeai.d.ts +3 -0
  4. package/dist/bin/forgeai.js +8 -0
  5. package/dist/cli.d.ts +4 -0
  6. package/dist/cli.js +180 -0
  7. package/dist/commands/auth-login.d.ts +4 -0
  8. package/dist/commands/auth-login.js +202 -0
  9. package/dist/commands/auth-logout.d.ts +2 -0
  10. package/dist/commands/auth-logout.js +41 -0
  11. package/dist/commands/auth-status.d.ts +4 -0
  12. package/dist/commands/auth-status.js +81 -0
  13. package/dist/commands/cli-list.d.ts +4 -0
  14. package/dist/commands/cli-list.js +40 -0
  15. package/dist/commands/cli-unlink.d.ts +4 -0
  16. package/dist/commands/cli-unlink.js +45 -0
  17. package/dist/commands/config-set.d.ts +5 -0
  18. package/dist/commands/config-set.js +63 -0
  19. package/dist/commands/connect.d.ts +9 -0
  20. package/dist/commands/connect.js +165 -0
  21. package/dist/commands/disconnect.d.ts +5 -0
  22. package/dist/commands/disconnect.js +126 -0
  23. package/dist/commands/journal-clear.d.ts +2 -0
  24. package/dist/commands/journal-clear.js +16 -0
  25. package/dist/commands/journal-list.d.ts +2 -0
  26. package/dist/commands/journal-list.js +20 -0
  27. package/dist/commands/journal-status.d.ts +2 -0
  28. package/dist/commands/journal-status.js +18 -0
  29. package/dist/commands/mcp-serve.d.ts +13 -0
  30. package/dist/commands/mcp-serve.js +74 -0
  31. package/dist/commands/org-list.d.ts +4 -0
  32. package/dist/commands/org-list.js +56 -0
  33. package/dist/commands/org-unlink.d.ts +4 -0
  34. package/dist/commands/org-unlink.js +63 -0
  35. package/dist/commands/reconnect.d.ts +4 -0
  36. package/dist/commands/reconnect.js +46 -0
  37. package/dist/commands/release-open.d.ts +2 -0
  38. package/dist/commands/release-open.js +43 -0
  39. package/dist/commands/session-respond.d.ts +2 -0
  40. package/dist/commands/session-respond.js +29 -0
  41. package/dist/commands/task-open.d.ts +2 -0
  42. package/dist/commands/task-open.js +42 -0
  43. package/dist/commands/vm.d.ts +21 -0
  44. package/dist/commands/vm.js +122 -0
  45. package/dist/commands/workflow-open.d.ts +2 -0
  46. package/dist/commands/workflow-open.js +43 -0
  47. package/dist/config/config-manager.d.ts +31 -0
  48. package/dist/config/config-manager.js +70 -0
  49. package/dist/config/token-manager.d.ts +11 -0
  50. package/dist/config/token-manager.js +87 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.js +22 -0
  53. package/dist/lib/auth-client.d.ts +1670 -0
  54. package/dist/lib/auth-client.js +10 -0
  55. package/dist/lib/branch-manager.d.ts +79 -0
  56. package/dist/lib/branch-manager.js +620 -0
  57. package/dist/lib/clone-and-checkout.d.ts +6 -0
  58. package/dist/lib/clone-and-checkout.js +41 -0
  59. package/dist/lib/connection-manager.d.ts +58 -0
  60. package/dist/lib/connection-manager.js +436 -0
  61. package/dist/lib/daemon-entry.d.ts +2 -0
  62. package/dist/lib/daemon-entry.js +210 -0
  63. package/dist/lib/daemon.d.ts +38 -0
  64. package/dist/lib/daemon.js +148 -0
  65. package/dist/lib/event-journal.d.ts +24 -0
  66. package/dist/lib/event-journal.js +76 -0
  67. package/dist/lib/ipc-client.d.ts +10 -0
  68. package/dist/lib/ipc-client.js +70 -0
  69. package/dist/lib/ipc-server.d.ts +36 -0
  70. package/dist/lib/ipc-server.js +70 -0
  71. package/dist/lib/node-version-guard.d.ts +8 -0
  72. package/dist/lib/node-version-guard.js +60 -0
  73. package/dist/lib/open-directory.d.ts +7 -0
  74. package/dist/lib/open-directory.js +41 -0
  75. package/dist/lib/repo-manager.d.ts +29 -0
  76. package/dist/lib/repo-manager.js +103 -0
  77. package/dist/lib/resilient-emitter.d.ts +53 -0
  78. package/dist/lib/resilient-emitter.js +160 -0
  79. package/dist/lib/rest-client.d.ts +164 -0
  80. package/dist/lib/rest-client.js +139 -0
  81. package/dist/lib/skill-manager.d.ts +23 -0
  82. package/dist/lib/skill-manager.js +167 -0
  83. package/dist/lib/socket-client.d.ts +68 -0
  84. package/dist/lib/socket-client.js +160 -0
  85. package/dist/mcp/mcp-probe.d.ts +11 -0
  86. package/dist/mcp/mcp-probe.js +106 -0
  87. package/dist/mcp/mcp-response.d.ts +29 -0
  88. package/dist/mcp/mcp-response.js +67 -0
  89. package/dist/mcp/mcp-server.d.ts +19 -0
  90. package/dist/mcp/mcp-server.js +76 -0
  91. package/dist/mcp/resources/project-conventions.d.ts +4 -0
  92. package/dist/mcp/resources/project-conventions.js +50 -0
  93. package/dist/mcp/resources/task-current.d.ts +4 -0
  94. package/dist/mcp/resources/task-current.js +51 -0
  95. package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
  96. package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
  97. package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
  98. package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
  99. package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
  100. package/dist/mcp/tools/forge-ask-human.js +80 -0
  101. package/dist/mcp/tools/forge-column-done.d.ts +4 -0
  102. package/dist/mcp/tools/forge-column-done.js +46 -0
  103. package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
  104. package/dist/mcp/tools/forge-complete-step.js +139 -0
  105. package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
  106. package/dist/mcp/tools/forge-create-task-group.js +22 -0
  107. package/dist/mcp/tools/forge-create-task.d.ts +4 -0
  108. package/dist/mcp/tools/forge-create-task.js +28 -0
  109. package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
  110. package/dist/mcp/tools/forge-delete-task-group.js +17 -0
  111. package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
  112. package/dist/mcp/tools/forge-delete-task.js +17 -0
  113. package/dist/mcp/tools/forge-get-task.d.ts +4 -0
  114. package/dist/mcp/tools/forge-get-task.js +17 -0
  115. package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
  116. package/dist/mcp/tools/forge-merge-back.js +179 -0
  117. package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
  118. package/dist/mcp/tools/forge-release-merge.js +159 -0
  119. package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
  120. package/dist/mcp/tools/forge-release-notes.js +51 -0
  121. package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
  122. package/dist/mcp/tools/forge-release-tag.js +125 -0
  123. package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
  124. package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
  125. package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
  126. package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
  127. package/dist/mcp/tools/forge-requeue.d.ts +4 -0
  128. package/dist/mcp/tools/forge-requeue.js +39 -0
  129. package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
  130. package/dist/mcp/tools/forge-submit-pr.js +68 -0
  131. package/dist/mcp/tools/forge-update-status.d.ts +4 -0
  132. package/dist/mcp/tools/forge-update-status.js +77 -0
  133. package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
  134. package/dist/mcp/tools/forge-update-task-group.js +21 -0
  135. package/dist/mcp/tools/forge-update-task.d.ts +4 -0
  136. package/dist/mcp/tools/forge-update-task.js +25 -0
  137. package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
  138. package/dist/mcp/tools/forge-validate-merge.js +194 -0
  139. package/dist/mcp/types.d.ts +39 -0
  140. package/dist/mcp/types.js +8 -0
  141. package/dist/methodology/artifact-manager.d.ts +14 -0
  142. package/dist/methodology/artifact-manager.js +55 -0
  143. package/dist/orchestrator/agent-registry.d.ts +53 -0
  144. package/dist/orchestrator/agent-registry.js +96 -0
  145. package/dist/orchestrator/agent-spawner.d.ts +112 -0
  146. package/dist/orchestrator/agent-spawner.js +1518 -0
  147. package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
  148. package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
  149. package/dist/orchestrator/connectors/connector.d.ts +95 -0
  150. package/dist/orchestrator/connectors/connector.js +25 -0
  151. package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
  152. package/dist/orchestrator/connectors/docker-connector.js +337 -0
  153. package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
  154. package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
  155. package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
  156. package/dist/orchestrator/connectors/opencode-connector.js +39 -0
  157. package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
  158. package/dist/orchestrator/connectors/vm-connector.js +139 -0
  159. package/dist/orchestrator/dispatch-queue.d.ts +43 -0
  160. package/dist/orchestrator/dispatch-queue.js +93 -0
  161. package/dist/orchestrator/resume-handler.d.ts +34 -0
  162. package/dist/orchestrator/resume-handler.js +159 -0
  163. package/dist/orchestrator/stream-batcher.d.ts +16 -0
  164. package/dist/orchestrator/stream-batcher.js +50 -0
  165. package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
  166. package/dist/orchestrator/stream-json-extractor.js +579 -0
  167. package/dist/vm/claude-token-manager.d.ts +36 -0
  168. package/dist/vm/claude-token-manager.js +223 -0
  169. package/dist/vm/docker-executor.d.ts +131 -0
  170. package/dist/vm/docker-executor.js +360 -0
  171. package/dist/vm/docker-process-proxy.d.ts +31 -0
  172. package/dist/vm/docker-process-proxy.js +105 -0
  173. package/dist/vm/forge-vm-agent.d.ts +11 -0
  174. package/dist/vm/forge-vm-agent.js +159 -0
  175. package/dist/vm/git-credentials.d.ts +43 -0
  176. package/dist/vm/git-credentials.js +53 -0
  177. package/dist/vm/image-manager.d.ts +7 -0
  178. package/dist/vm/image-manager.js +61 -0
  179. package/dist/vm/log.d.ts +9 -0
  180. package/dist/vm/log.js +18 -0
  181. package/dist/vm/mcp-bridge.d.ts +63 -0
  182. package/dist/vm/mcp-bridge.js +243 -0
  183. package/dist/vm/mcp-stub.d.ts +15 -0
  184. package/dist/vm/mcp-stub.js +67 -0
  185. package/dist/vm/native-addon.d.ts +69 -0
  186. package/dist/vm/native-addon.js +68 -0
  187. package/dist/vm/provider-resolver.d.ts +7 -0
  188. package/dist/vm/provider-resolver.js +27 -0
  189. package/dist/vm/providers/lima.d.ts +17 -0
  190. package/dist/vm/providers/lima.js +191 -0
  191. package/dist/vm/providers/qemu.d.ts +28 -0
  192. package/dist/vm/providers/qemu.js +260 -0
  193. package/dist/vm/providers/vfkit.d.ts +40 -0
  194. package/dist/vm/providers/vfkit.js +371 -0
  195. package/dist/vm/providers/wsl2.d.ts +27 -0
  196. package/dist/vm/providers/wsl2.js +226 -0
  197. package/dist/vm/session-manager.d.ts +130 -0
  198. package/dist/vm/session-manager.js +494 -0
  199. package/dist/vm/session-snapshot.d.ts +31 -0
  200. package/dist/vm/session-snapshot.js +180 -0
  201. package/dist/vm/types.d.ts +73 -0
  202. package/dist/vm/types.js +2 -0
  203. package/dist/vm/vm-helper-client.d.ts +81 -0
  204. package/dist/vm/vm-helper-client.js +335 -0
  205. package/dist/vm/vm-helper.d.ts +11 -0
  206. package/dist/vm/vm-helper.js +156 -0
  207. package/dist/vm/vm-ipc-protocol.d.ts +84 -0
  208. package/dist/vm/vm-ipc-protocol.js +9 -0
  209. package/dist/vm/vm-manager-facade.d.ts +23 -0
  210. package/dist/vm/vm-manager-facade.js +43 -0
  211. package/dist/vm/vm-manager.d.ts +74 -0
  212. package/dist/vm/vm-manager.js +515 -0
  213. package/dist/vm/vm-process-proxy.d.ts +34 -0
  214. package/dist/vm/vm-process-proxy.js +64 -0
  215. package/dist/vm/vsock-bridge.d.ts +44 -0
  216. package/dist/vm/vsock-bridge.js +140 -0
  217. package/dist/vm/vsock-protocol.d.ts +98 -0
  218. package/dist/vm/vsock-protocol.js +57 -0
  219. package/package.json +65 -0
  220. package/rootfs/Dockerfile +50 -0
  221. package/rootfs/forge-mcp-relay.mjs +158 -0
@@ -0,0 +1,620 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ import chalk from "chalk";
5
+ import { Mutex } from "async-mutex";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ /**
9
+ * Resolve the local repo path for a project.
10
+ * Clone if missing, fetch if existing. Used for branch operations on the host
11
+ * (listing branches, creating workflow branches) before a VM is booted.
12
+ */
13
+ async function resolveRepoPath(projectSlug, repoUrl) {
14
+ if (!repoUrl) {
15
+ console.warn(chalk.yellow("[branch] No repoUrl provided, falling back to process.cwd()"));
16
+ return process.cwd();
17
+ }
18
+ const VALID_GIT_URL = /^(https?:\/\/[\w.-]+\w(\/|$)|git@\w[\w.-]*:)/;
19
+ const targetDir = join(homedir(), ".forgeai", "repos", projectSlug);
20
+ const mutex = getRepoMutex(targetDir);
21
+ return mutex.runExclusive(async () => {
22
+ const gitPath = join(targetDir, ".git");
23
+ const repoExists = existsSync(gitPath) && statSync(gitPath).isDirectory();
24
+ if (repoExists) {
25
+ try {
26
+ await execFileAsync("git", ["fetch", "--all"], {
27
+ cwd: targetDir,
28
+ timeout: 60_000,
29
+ });
30
+ }
31
+ catch {
32
+ // Non-fatal fetch failure
33
+ }
34
+ }
35
+ else {
36
+ if (!VALID_GIT_URL.test(repoUrl)) {
37
+ throw new Error(`Invalid git URL format: ${repoUrl}`);
38
+ }
39
+ await execFileAsync("git", ["clone", repoUrl, targetDir], {
40
+ timeout: 120_000,
41
+ });
42
+ }
43
+ return targetDir;
44
+ });
45
+ }
46
+ const execFileAsync = promisify(execFile);
47
+ // Per-repo mutex to serialize git worktree operations (AC: #6)
48
+ const repoMutexes = new Map();
49
+ function getRepoMutex(repoPath) {
50
+ let mutex = repoMutexes.get(repoPath);
51
+ if (!mutex) {
52
+ mutex = new Mutex();
53
+ repoMutexes.set(repoPath, mutex);
54
+ }
55
+ return mutex;
56
+ }
57
+ function getProjectSlugOrDefault(payload) {
58
+ if (!payload.projectSlug) {
59
+ console.error(chalk.red('[branch] Missing projectSlug in payload, using "default" — this WILL cause repo path conflicts if multiple projects are active. Ensure Cloud sends projectSlug in all event payloads.'));
60
+ return "default";
61
+ }
62
+ return payload.projectSlug;
63
+ }
64
+ /**
65
+ * Check if a repository has zero commits (freshly created / empty).
66
+ */
67
+ async function isRepoEmpty(repoPath) {
68
+ try {
69
+ await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoPath });
70
+ return false;
71
+ }
72
+ catch {
73
+ return true;
74
+ }
75
+ }
76
+ /**
77
+ * Initialize an empty repository with an initial commit and push the source branch.
78
+ * Used for greenfield projects where the remote repo was just created (no commits).
79
+ *
80
+ * Must be called inside the repo mutex (callers already hold it).
81
+ */
82
+ async function initializeSourceBranch(repoPath, sourceBranch) {
83
+ try {
84
+ // Create an initial empty commit
85
+ await execFileAsync("git", ["commit", "--allow-empty", "-m", "Initial commit"], { cwd: repoPath });
86
+ // Ensure we're on the expected source branch name
87
+ const { stdout: currentBranch } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath });
88
+ if (currentBranch.trim() !== sourceBranch) {
89
+ await execFileAsync("git", ["branch", "-M", sourceBranch], {
90
+ cwd: repoPath,
91
+ });
92
+ }
93
+ // Push the source branch to remote
94
+ await execFileAsync("git", ["push", "-u", "origin", sourceBranch], {
95
+ cwd: repoPath,
96
+ });
97
+ console.log(chalk.green(`[branch] Initialized empty repo: created and pushed "${sourceBranch}"`));
98
+ return { success: true };
99
+ }
100
+ catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ console.error(chalk.red(`[branch] Failed to initialize empty repo: ${message}`));
103
+ return { success: false, error: message };
104
+ }
105
+ }
106
+ /**
107
+ * List remote branches for a git repository at the given path.
108
+ * Returns branch names (short format, without origin/ prefix) and the default branch.
109
+ */
110
+ export async function listBranches(repoPath) {
111
+ const { stdout: branchOutput } = await execFileAsync("git", ["branch", "-r", "--format=%(refname:short)"], { cwd: repoPath });
112
+ const branches = branchOutput
113
+ .split("\n")
114
+ .map((b) => b.trim())
115
+ .filter((b) => b.length > 0 && !b.includes("HEAD"))
116
+ .map((b) => b.replace(/^origin\//, ""));
117
+ let defaultBranch = "main";
118
+ try {
119
+ const { stdout: headRef } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: repoPath });
120
+ const ref = headRef.trim();
121
+ // refs/remotes/origin/main → main
122
+ const match = /refs\/remotes\/origin\/(.+)/.exec(ref);
123
+ if (match?.[1]) {
124
+ defaultBranch = match[1];
125
+ }
126
+ }
127
+ catch {
128
+ // Fallback: if HEAD ref is not set, try to detect main/master
129
+ if (branches.includes("main")) {
130
+ defaultBranch = "main";
131
+ }
132
+ else if (branches.includes("master")) {
133
+ defaultBranch = "master";
134
+ }
135
+ else if (branches.length > 0) {
136
+ defaultBranch = branches[0];
137
+ }
138
+ }
139
+ return { branches, defaultBranch };
140
+ }
141
+ /**
142
+ * Create a workflow branch from a source branch.
143
+ * Idempotent: if the branch already exists from the same source, reports success.
144
+ *
145
+ * Steps:
146
+ * 1. Fetch the latest source branch from remote (ensures it's up-to-date)
147
+ * 2. Create the workflow branch from `origin/<sourceBranch>`
148
+ * 3. Push the workflow branch to remote with tracking (`-u`)
149
+ */
150
+ export async function createWorkflowBranch(repoPath, sourceBranch, baseBranch) {
151
+ const mutex = getRepoMutex(repoPath);
152
+ return mutex.runExclusive(async () => {
153
+ try {
154
+ // Check if branch already exists
155
+ try {
156
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd: repoPath });
157
+ if (stdout.trim()) {
158
+ // Branch exists — check if it was created from the same source
159
+ const { stdout: baseCommit } = await execFileAsync("git", ["merge-base", baseBranch, sourceBranch], { cwd: repoPath });
160
+ const { stdout: sourceHead } = await execFileAsync("git", ["rev-parse", sourceBranch], { cwd: repoPath });
161
+ // If merge-base equals source HEAD, the branch was created from this source
162
+ if (baseCommit.trim() === sourceHead.trim()) {
163
+ console.log(chalk.green(`[branch] Branch "${baseBranch}" already exists from "${sourceBranch}" — idempotent success`));
164
+ return { success: true };
165
+ }
166
+ return {
167
+ success: false,
168
+ error: `Branch "${baseBranch}" already exists but was not created from "${sourceBranch}"`,
169
+ };
170
+ }
171
+ }
172
+ catch {
173
+ // Branch doesn't exist — this is the expected path, continue to create
174
+ }
175
+ // Fetch latest source branch from remote to ensure we branch
176
+ // from the most recent state
177
+ let baseRef = sourceBranch;
178
+ try {
179
+ await execFileAsync("git", ["fetch", "origin", sourceBranch], {
180
+ cwd: repoPath,
181
+ });
182
+ baseRef = `origin/${sourceBranch}`;
183
+ console.log(chalk.blue(`[branch] Fetched latest "${sourceBranch}" from remote`));
184
+ }
185
+ catch {
186
+ // Source branch not on remote — check if repo is empty (greenfield)
187
+ if (await isRepoEmpty(repoPath)) {
188
+ console.log(chalk.cyan(`[branch] Empty repo detected — initializing with branch "${sourceBranch}"`));
189
+ const initResult = await initializeSourceBranch(repoPath, sourceBranch);
190
+ if (!initResult.success) {
191
+ return {
192
+ success: false,
193
+ error: `Failed to initialize empty repo: ${initResult.error}`,
194
+ };
195
+ }
196
+ // After init, sourceBranch exists locally — baseRef is already set
197
+ }
198
+ else {
199
+ console.log(chalk.yellow(`[branch] Could not fetch "${sourceBranch}" from remote, using local ref`));
200
+ }
201
+ }
202
+ // Create the branch
203
+ await execFileAsync("git", ["branch", baseBranch, baseRef], {
204
+ cwd: repoPath,
205
+ });
206
+ console.log(chalk.green(`[branch] Created "${baseBranch}" from "${baseRef}"`));
207
+ // Push workflow branch to remote with tracking
208
+ try {
209
+ await execFileAsync("git", ["push", "-u", "origin", baseBranch], {
210
+ cwd: repoPath,
211
+ });
212
+ console.log(chalk.green(`[branch] Pushed "${baseBranch}" to remote`));
213
+ }
214
+ catch (error_) {
215
+ const msg = error_ instanceof Error ? error_.message : String(error_);
216
+ console.warn(chalk.yellow(`[branch] Failed to push "${baseBranch}" to remote: ${msg} — branch exists locally`));
217
+ // Non-fatal: branch exists locally, can be pushed later
218
+ }
219
+ return { success: true };
220
+ }
221
+ catch (error) {
222
+ const message = error instanceof Error ? error.message : String(error);
223
+ console.error(chalk.red(`[branch] Failed to create branch: ${message}`));
224
+ return { success: false, error: message };
225
+ }
226
+ });
227
+ }
228
+ /**
229
+ * Normalize a git remote URL for comparison.
230
+ * Handles https, ssh, and .git suffix differences.
231
+ */
232
+ export function normalizeRepoUrl(url) {
233
+ return url
234
+ .replace(/\.git$/, "")
235
+ .replace(/^git@([^:]+):/, "https://$1/")
236
+ .replace(/\/$/, "")
237
+ .toLowerCase();
238
+ }
239
+ /**
240
+ * Verify the local repo's origin URL matches the expected URL.
241
+ * Returns null on match, or an error message on mismatch.
242
+ */
243
+ async function verifyRepoUrl(repoPath, expectedUrl) {
244
+ try {
245
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd: repoPath });
246
+ const localUrl = stdout.trim();
247
+ if (normalizeRepoUrl(localUrl) !== normalizeRepoUrl(expectedUrl)) {
248
+ return `Repo URL mismatch: local="${localUrl}", expected="${expectedUrl}"`;
249
+ }
250
+ return null;
251
+ }
252
+ catch {
253
+ return "Could not determine local repo origin URL";
254
+ }
255
+ }
256
+ // --- PR operations (Story 5c.3) ---
257
+ /**
258
+ * Check if the GitHub CLI (gh) is installed and authenticated.
259
+ */
260
+ export async function checkGhCliAvailable() {
261
+ try {
262
+ await execFileAsync("gh", ["auth", "status"]);
263
+ return { available: true };
264
+ }
265
+ catch {
266
+ return {
267
+ available: false,
268
+ error: "GitHub CLI (gh) not installed or not authenticated. Run 'gh auth login'.",
269
+ };
270
+ }
271
+ }
272
+ /**
273
+ * Delete a local branch.
274
+ * Uses per-repo mutex to prevent concurrent branch operations.
275
+ */
276
+ async function deleteLocalBranch(repoPath, branchName) {
277
+ const mutex = getRepoMutex(repoPath);
278
+ return mutex.runExclusive(async () => {
279
+ try {
280
+ // Check if branch exists
281
+ try {
282
+ await execFileAsync("git", ["rev-parse", "--verify", branchName], {
283
+ cwd: repoPath,
284
+ });
285
+ }
286
+ catch {
287
+ console.log(chalk.yellow(`[merge] Local branch "${branchName}" does not exist — already cleaned`));
288
+ return { success: true };
289
+ }
290
+ await execFileAsync("git", ["branch", "-D", branchName], {
291
+ cwd: repoPath,
292
+ });
293
+ console.log(chalk.green(`[merge] Deleted local branch "${branchName}"`));
294
+ return { success: true };
295
+ }
296
+ catch (error) {
297
+ const message = error instanceof Error ? error.message : String(error);
298
+ console.error(chalk.red(`[merge] Failed to delete local branch: ${message}`));
299
+ return { success: false, error: message };
300
+ }
301
+ });
302
+ }
303
+ // --- Worktree operations (Story 5c.2) ---
304
+ /**
305
+ * Create a task branch from a workflow base branch and push it to remote.
306
+ * Idempotent: if the branch already exists, reports success.
307
+ *
308
+ * Steps:
309
+ * 1. Fetch the latest workflow base branch from remote (ensures it's up-to-date)
310
+ * 2. Create the task branch from `origin/<workflowBaseBranch>`
311
+ * 3. Push the task branch to remote with tracking (`-u`)
312
+ */
313
+ /** Check if a git branch already exists (idempotent guard). Returns true if it exists. */
314
+ async function branchExists(repoPath, branch) {
315
+ try {
316
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd: repoPath });
317
+ if (stdout.trim()) {
318
+ console.log(chalk.green(`[worktree] Task branch "${branch}" already exists — idempotent success`));
319
+ return true;
320
+ }
321
+ }
322
+ catch {
323
+ // Branch doesn't exist — expected path
324
+ }
325
+ return false;
326
+ }
327
+ /** Push a branch to remote with tracking (non-fatal on failure). */
328
+ async function pushBranchToRemote(repoPath, branch) {
329
+ try {
330
+ await execFileAsync("git", ["push", "-u", "origin", branch], {
331
+ cwd: repoPath,
332
+ });
333
+ console.log(chalk.green(`[worktree] Pushed task branch "${branch}" to remote`));
334
+ }
335
+ catch (error_) {
336
+ const msg = error_ instanceof Error ? error_.message : String(error_);
337
+ console.warn(chalk.yellow(`[worktree] Failed to push task branch "${branch}": ${msg} — agent can push later`));
338
+ }
339
+ }
340
+ export async function createTaskBranch(repoPath, workflowBaseBranch, taskBranch) {
341
+ const mutex = getRepoMutex(repoPath);
342
+ return mutex.runExclusive(async () => {
343
+ try {
344
+ if (await branchExists(repoPath, taskBranch)) {
345
+ return { success: true };
346
+ }
347
+ // Fetch latest workflow base branch from remote to ensure we branch
348
+ // from the most recent state (other agents may have pushed commits)
349
+ let baseRef = workflowBaseBranch;
350
+ try {
351
+ await execFileAsync("git", ["fetch", "origin", workflowBaseBranch], {
352
+ cwd: repoPath,
353
+ });
354
+ baseRef = `origin/${workflowBaseBranch}`;
355
+ console.log(chalk.blue(`[worktree] Fetched latest "${workflowBaseBranch}" from remote`));
356
+ }
357
+ catch {
358
+ console.log(chalk.yellow(`[worktree] Could not fetch "${workflowBaseBranch}" from remote, using local ref`));
359
+ }
360
+ await execFileAsync("git", ["branch", taskBranch, baseRef], {
361
+ cwd: repoPath,
362
+ });
363
+ console.log(chalk.green(`[worktree] Created task branch "${taskBranch}" from "${baseRef}"`));
364
+ await pushBranchToRemote(repoPath, taskBranch);
365
+ return { success: true };
366
+ }
367
+ catch (error) {
368
+ const message = error instanceof Error ? error.message : String(error);
369
+ console.error(chalk.red(`[worktree] Failed to create task branch: ${message}`));
370
+ return { success: false, error: message };
371
+ }
372
+ });
373
+ }
374
+ /**
375
+ * Create a task branch from HEAD (fallback for non-workflow tasks).
376
+ * Idempotent: if the branch already exists, reports success.
377
+ *
378
+ * Used when a task is dispatched without a workflow base branch,
379
+ * providing worktree isolation even for standalone tasks.
380
+ */
381
+ export async function createTaskBranchFromHead(repoPath, taskBranch) {
382
+ const mutex = getRepoMutex(repoPath);
383
+ return mutex.runExclusive(async () => {
384
+ try {
385
+ if (await branchExists(repoPath, taskBranch)) {
386
+ return { success: true };
387
+ }
388
+ await execFileAsync("git", ["branch", taskBranch, "HEAD"], {
389
+ cwd: repoPath,
390
+ });
391
+ console.log(chalk.green(`[worktree] Created task branch "${taskBranch}" from HEAD (no workflow branch)`));
392
+ await pushBranchToRemote(repoPath, taskBranch);
393
+ return { success: true };
394
+ }
395
+ catch (error) {
396
+ const message = error instanceof Error ? error.message : String(error);
397
+ console.error(chalk.red(`[worktree] Failed to create task branch from HEAD: ${message}`));
398
+ return { success: false, error: message };
399
+ }
400
+ });
401
+ }
402
+ /**
403
+ * Ensure all changes in a repository are committed and pushed.
404
+ * Best-effort safety net: auto-commits dirty working tree and pushes unpushed commits.
405
+ * Uses per-repo mutex to prevent race conditions between concurrent callers.
406
+ */
407
+ export async function ensureChangesCommittedAndPushed(repoPath, commitMessage) {
408
+ const mutex = getRepoMutex(repoPath);
409
+ return mutex.runExclusive(async () => {
410
+ let committed = false;
411
+ let pushed = false;
412
+ try {
413
+ // Verify path exists and is a git directory
414
+ if (!existsSync(repoPath)) {
415
+ return {
416
+ committed,
417
+ pushed,
418
+ error: `Path does not exist: ${repoPath}`,
419
+ };
420
+ }
421
+ // 1. Check for uncommitted changes
422
+ const { stdout: status } = await execFileAsync("git", ["status", "--porcelain"], { cwd: repoPath });
423
+ if (status.trim()) {
424
+ // Dirty working tree — auto-commit
425
+ await execFileAsync("git", ["add", "-A"], { cwd: repoPath });
426
+ await execFileAsync("git", ["commit", "-m", commitMessage], {
427
+ cwd: repoPath,
428
+ });
429
+ committed = true;
430
+ console.log(chalk.green(`[guard] Auto-committed changes in ${repoPath}`));
431
+ }
432
+ // 2. Push any unpushed commits
433
+ // First try with upstream tracking, fall back to explicit push if no upstream
434
+ try {
435
+ const { stdout: unpushedLog } = await execFileAsync("git", ["log", "@{upstream}..HEAD", "--oneline"], { cwd: repoPath });
436
+ if (unpushedLog.trim()) {
437
+ await execFileAsync("git", ["push"], { cwd: repoPath });
438
+ pushed = true;
439
+ console.log(chalk.green(`[guard] Auto-pushed commits from ${repoPath}`));
440
+ }
441
+ }
442
+ catch {
443
+ // No upstream configured — detect current branch and push with --set-upstream
444
+ try {
445
+ const { stdout: branchOutput } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath });
446
+ const currentBranch = branchOutput.trim();
447
+ if (currentBranch && currentBranch !== "HEAD") {
448
+ await execFileAsync("git", ["push", "--set-upstream", "origin", currentBranch], { cwd: repoPath });
449
+ pushed = true;
450
+ console.log(chalk.green(`[guard] Auto-pushed and set upstream for "${currentBranch}" from ${repoPath}`));
451
+ }
452
+ }
453
+ catch (error_) {
454
+ const msg = error_ instanceof Error ? error_.message : String(error_);
455
+ console.warn(chalk.yellow(`[guard] Failed to push from ${repoPath}: ${msg}`));
456
+ return { committed, pushed, error: `Push failed: ${msg}` };
457
+ }
458
+ }
459
+ return { committed, pushed };
460
+ }
461
+ catch (error) {
462
+ const message = error instanceof Error ? error.message : String(error);
463
+ console.error(chalk.red(`[guard] Failed to ensure changes committed/pushed: ${message}`));
464
+ return { committed, pushed, error: message };
465
+ }
466
+ });
467
+ }
468
+ /**
469
+ * Ensure a workflow branch exists locally.
470
+ * Handles 3 scenarios (in order), using per-repo mutex:
471
+ * 1. Branch exists locally → pull latest from remote (fast-forward)
472
+ * 2. Branch only on remote → fetch + create local tracking branch
473
+ * 3. Branch doesn't exist anywhere → create from sourceBranch
474
+ */
475
+ export async function ensureWorkflowBranch(repoPath, baseBranch, sourceBranch) {
476
+ const mutex = getRepoMutex(repoPath);
477
+ return mutex.runExclusive(async () => {
478
+ try {
479
+ // 1. Check if branch exists locally
480
+ try {
481
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd: repoPath });
482
+ if (stdout.trim()) {
483
+ console.log(chalk.green(`[worktree] Workflow branch "${baseBranch}" already exists locally — idempotent success`));
484
+ // Branch exists locally — update to latest remote state
485
+ // Uses `git branch -f` instead of `git merge` to avoid affecting the
486
+ // currently checked-out branch in the main repo
487
+ try {
488
+ await execFileAsync("git", ["fetch", "origin", baseBranch], {
489
+ cwd: repoPath,
490
+ });
491
+ await execFileAsync("git", ["branch", "-f", baseBranch, `origin/${baseBranch}`], { cwd: repoPath });
492
+ console.log(chalk.blue(`[branch] Updated "${baseBranch}" to latest remote state`));
493
+ }
494
+ catch {
495
+ // Remote may not have the branch yet (first workflow step) — that's OK
496
+ console.log(chalk.yellow(`[branch] Could not update "${baseBranch}" from remote, using local state`));
497
+ }
498
+ return { success: true };
499
+ }
500
+ }
501
+ catch {
502
+ // Branch doesn't exist locally — continue
503
+ }
504
+ // 2. Check if branch exists on remote → fetch + create local branch
505
+ try {
506
+ await execFileAsync("git", ["fetch", "origin", baseBranch], {
507
+ cwd: repoPath,
508
+ });
509
+ // Fetch succeeded — create local branch from remote
510
+ await execFileAsync("git", ["branch", baseBranch, `origin/${baseBranch}`], { cwd: repoPath });
511
+ console.log(chalk.green(`[worktree] Fetched and created local branch "${baseBranch}" from remote`));
512
+ return { success: true };
513
+ }
514
+ catch {
515
+ // Branch not on remote either — continue to create from source
516
+ }
517
+ // 3. Branch doesn't exist anywhere — create from sourceBranch
518
+ // If repo is empty (no commits), initialize with the source branch first
519
+ if (await isRepoEmpty(repoPath)) {
520
+ console.log(chalk.cyan(`[worktree] Empty repo detected — initializing with branch "${sourceBranch}"`));
521
+ const initResult = await initializeSourceBranch(repoPath, sourceBranch);
522
+ if (!initResult.success) {
523
+ return {
524
+ success: false,
525
+ error: `Failed to initialize empty repo: ${initResult.error}`,
526
+ };
527
+ }
528
+ }
529
+ await execFileAsync("git", ["branch", baseBranch, sourceBranch], {
530
+ cwd: repoPath,
531
+ });
532
+ console.log(chalk.green(`[worktree] Created workflow branch "${baseBranch}" from "${sourceBranch}"`));
533
+ return { success: true };
534
+ }
535
+ catch (error) {
536
+ const message = error instanceof Error ? error.message : String(error);
537
+ console.error(chalk.red(`[worktree] Failed to ensure workflow branch: ${message}`));
538
+ return { success: false, error: message };
539
+ }
540
+ });
541
+ }
542
+ /**
543
+ * Register branch-related Socket.io event handlers on a CLI socket.
544
+ * Handles branch listing and branch creation requests from Cloud.
545
+ */
546
+ export function registerBranchHandlers(socket, orgId) {
547
+ socket.on("workflow:branch:list:request", async (payload) => {
548
+ try {
549
+ const repoPath = await resolveRepoPath(getProjectSlugOrDefault(payload), payload.repoUrl);
550
+ // Verify repo URL matches before listing
551
+ const mismatch = await verifyRepoUrl(repoPath, payload.repoUrl);
552
+ if (mismatch) {
553
+ console.warn(chalk.yellow(`[branch] ${mismatch} — skipping list`));
554
+ return;
555
+ }
556
+ const result = await listBranches(repoPath);
557
+ socket.emit("workflow:branch:list:response", {
558
+ orgId,
559
+ projectId: payload.projectId,
560
+ branches: result.branches,
561
+ defaultBranch: result.defaultBranch,
562
+ timestamp: new Date().toISOString(),
563
+ });
564
+ console.log(chalk.green(`[branch] Listed ${result.branches.length} branches (default: ${result.defaultBranch})`));
565
+ }
566
+ catch (error) {
567
+ const message = error instanceof Error ? error.message : String(error);
568
+ console.error(chalk.red(`[branch] Failed to list branches: ${message}`));
569
+ // Send empty response so UI can fall back to text input
570
+ socket.emit("workflow:branch:list:response", {
571
+ orgId,
572
+ projectId: payload.projectId,
573
+ branches: [],
574
+ defaultBranch: "main",
575
+ timestamp: new Date().toISOString(),
576
+ });
577
+ }
578
+ });
579
+ socket.on("workflow:branch:create", async (payload) => {
580
+ try {
581
+ const repoPath = await resolveRepoPath(getProjectSlugOrDefault(payload), payload.repoUrl);
582
+ // Verify repo URL matches before creating branch
583
+ const mismatch = await verifyRepoUrl(repoPath, payload.repoUrl);
584
+ if (mismatch) {
585
+ console.warn(chalk.yellow(`[branch] ${mismatch}`));
586
+ socket.emit("workflow:branch:created", {
587
+ orgId,
588
+ workflowId: payload.workflowId,
589
+ baseBranch: payload.baseBranch,
590
+ success: false,
591
+ error: mismatch,
592
+ timestamp: new Date().toISOString(),
593
+ });
594
+ return;
595
+ }
596
+ const result = await createWorkflowBranch(repoPath, payload.sourceBranch, payload.baseBranch);
597
+ socket.emit("workflow:branch:created", {
598
+ orgId,
599
+ workflowId: payload.workflowId,
600
+ baseBranch: payload.baseBranch,
601
+ success: result.success,
602
+ error: result.error,
603
+ timestamp: new Date().toISOString(),
604
+ });
605
+ }
606
+ catch (error) {
607
+ const message = error instanceof Error ? error.message : String(error);
608
+ console.error(chalk.red(`[branch] Unexpected error creating branch: ${message}`));
609
+ socket.emit("workflow:branch:created", {
610
+ orgId,
611
+ workflowId: payload.workflowId,
612
+ baseBranch: payload.baseBranch,
613
+ success: false,
614
+ error: message,
615
+ timestamp: new Date().toISOString(),
616
+ });
617
+ }
618
+ });
619
+ }
620
+ //# sourceMappingURL=branch-manager.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Clone a repo and checkout a specific branch, or fetch + checkout if already cloned.
3
+ * Target directory: ~/.forgeai/open/<projectSlug>/<branchSlug>/
4
+ */
5
+ export declare function cloneAndCheckout(repoUrl: string, branch: string, targetDir: string): Promise<void>;
6
+ //# sourceMappingURL=clone-and-checkout.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import chalk from "chalk";
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * Clone a repo and checkout a specific branch, or fetch + checkout if already cloned.
10
+ * Target directory: ~/.forgeai/open/<projectSlug>/<branchSlug>/
11
+ */
12
+ export async function cloneAndCheckout(repoUrl, branch, targetDir) {
13
+ const gitDir = join(targetDir, ".git");
14
+ if (existsSync(gitDir)) {
15
+ // Already cloned — fetch and checkout
16
+ console.log(chalk.dim(`[open] Fetching latest changes in ${targetDir}`));
17
+ await execFileAsync("git", ["fetch", "--all"], { cwd: targetDir });
18
+ await execFileAsync("git", ["checkout", branch], { cwd: targetDir }).catch(async () => {
19
+ // Branch might be remote-only
20
+ await execFileAsync("git", ["checkout", "-b", branch, `origin/${branch}`], { cwd: targetDir });
21
+ });
22
+ await execFileAsync("git", ["pull", "--ff-only"], { cwd: targetDir }).catch(() => {
23
+ // Pull may fail if detached or diverged — non-fatal
24
+ });
25
+ console.log(chalk.green(`[open] Checked out ${branch}`));
26
+ }
27
+ else {
28
+ // Fresh clone
29
+ await mkdir(dirname(targetDir), { recursive: true });
30
+ console.log(chalk.dim(`[open] Cloning ${repoUrl} → ${targetDir}`));
31
+ await execFileAsync("git", [
32
+ "clone",
33
+ "--branch",
34
+ branch,
35
+ repoUrl,
36
+ targetDir,
37
+ ]);
38
+ console.log(chalk.green(`[open] Cloned and checked out ${branch}`));
39
+ }
40
+ }
41
+ //# sourceMappingURL=clone-and-checkout.js.map