@ipul-labs/forge 1.13.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.
Files changed (113) hide show
  1. package/README.md +273 -0
  2. package/bin/README.md +210 -0
  3. package/bin/cli/analyze.js +516 -0
  4. package/bin/cli/apply.js +264 -0
  5. package/bin/cli/cifix.js +300 -0
  6. package/bin/cli/close.js +125 -0
  7. package/bin/cli/doctor.js +161 -0
  8. package/bin/cli/init.js +306 -0
  9. package/bin/cli/ink.js +57 -0
  10. package/bin/cli/issue.js +116 -0
  11. package/bin/cli/issues.js +155 -0
  12. package/bin/cli/merge.js +175 -0
  13. package/bin/cli/refine.js +253 -0
  14. package/bin/cli/repos.js +91 -0
  15. package/bin/cli/resolution.js +126 -0
  16. package/bin/cli/session.js +230 -0
  17. package/bin/cli/sessionStore.js +214 -0
  18. package/bin/cli/ship.js +762 -0
  19. package/bin/cli/utils.js +145 -0
  20. package/bin/cli/validate.js +280 -0
  21. package/bin/cli/watch.js +274 -0
  22. package/bin/forge-agent.js +174 -0
  23. package/dist/assets/index-CNTCMbW9.js +80 -0
  24. package/dist/assets/index-InpYS-zg.css +1 -0
  25. package/dist/favicon.svg +8 -0
  26. package/dist/index.html +19 -0
  27. package/dist-lib/favicon.svg +8 -0
  28. package/dist-lib/index.d.ts +1 -0
  29. package/dist-lib/style.css +1 -0
  30. package/dist-lib/ui.d.ts +2 -0
  31. package/dist-lib/ui.js +7390 -0
  32. package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
  33. package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
  34. package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
  35. package/dist-lib/views/Forge/api.d.ts +4 -0
  36. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
  37. package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
  38. package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
  39. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
  40. package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
  41. package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
  42. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
  43. package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
  44. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
  45. package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
  46. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
  47. package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
  48. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
  49. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
  50. package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
  51. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
  52. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
  53. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
  54. package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
  55. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
  56. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
  57. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
  58. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
  59. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
  60. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
  61. package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
  62. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
  63. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
  64. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
  65. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
  66. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
  67. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
  68. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
  69. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
  70. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
  71. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
  72. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
  73. package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
  74. package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
  75. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
  76. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
  77. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
  78. package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
  79. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
  80. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
  81. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
  82. package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
  83. package/dist-lib/views/Forge/types.d.ts +235 -0
  84. package/dist-lib/views/Forge/utils.d.ts +11 -0
  85. package/package.json +88 -0
  86. package/server/forge/core/ForgeAgent.js +1144 -0
  87. package/server/forge/core/agentClient.js +36 -0
  88. package/server/forge/core/branchName.js +41 -0
  89. package/server/forge/core/claudeClient.js +356 -0
  90. package/server/forge/core/configStore.js +214 -0
  91. package/server/forge/core/gitlabClient.js +340 -0
  92. package/server/forge/core/jobs.js +180 -0
  93. package/server/forge/core/k8s.js +54 -0
  94. package/server/forge/core/localRepo.js +136 -0
  95. package/server/forge/core/openclawClient.js +125 -0
  96. package/server/forge/core/prompts.js +369 -0
  97. package/server/forge/core/providers/azuredevops.js +408 -0
  98. package/server/forge/core/providers/base.js +208 -0
  99. package/server/forge/core/providers/github.js +349 -0
  100. package/server/forge/core/providers/gitlab.js +529 -0
  101. package/server/forge/core/providers/index.js +47 -0
  102. package/server/forge/core/quotaClient.js +101 -0
  103. package/server/forge/core/validator/attribute.js +84 -0
  104. package/server/forge/core/validator/clone.js +91 -0
  105. package/server/forge/core/validator/cmd.js +98 -0
  106. package/server/forge/core/validator/fs.js +9 -0
  107. package/server/forge/core/validator/overlay.js +52 -0
  108. package/server/forge/core/validator/projectType.js +87 -0
  109. package/server/forge/core/validator/runners/go.js +107 -0
  110. package/server/forge/core/validator/runners/node.js +84 -0
  111. package/server/forge/core/validator/tdd.js +105 -0
  112. package/server/forge/core/validator.js +166 -0
  113. package/server/forge/routes.js +1012 -0
@@ -0,0 +1,161 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import React, { useEffect, useState } from "react";
8
+ import Spinner from "ink-spinner";
9
+ import { h, Box, Text, useApp, runApp } from "./ink.js";
10
+ import { projectRoot } from "./utils.js";
11
+
12
+ const execFileP = promisify(execFile);
13
+
14
+ function CheckRow({ name, status, message }) {
15
+ const label = name.padEnd(18);
16
+ if (status === "pending") {
17
+ return h`<${Box}>
18
+ <${Text} color="gray">○ ${label} </>
19
+ <${Text} dimColor>queued</>
20
+ </>`;
21
+ }
22
+ if (status === "running") {
23
+ return h`<${Box}>
24
+ <${Text} color="cyan"><${Spinner} type="dots"/></>
25
+ <${Text}> ${label} </>
26
+ <${Text} dimColor>checking…</>
27
+ </>`;
28
+ }
29
+ if (status === "pass") {
30
+ return h`<${Box}>
31
+ <${Text} color="green">✓</>
32
+ <${Text}> ${label} </>
33
+ <${Text} dimColor>${message ?? ""}</>
34
+ </>`;
35
+ }
36
+ return h`<${Box}>
37
+ <${Text} color="red">✘</>
38
+ <${Text}> ${label} </>
39
+ <${Text} color="red">${message ?? ""}</>
40
+ </>`;
41
+ }
42
+
43
+ function Doctor({ checks, onDone }) {
44
+ const { exit } = useApp();
45
+ const [rows, setRows] = useState(() =>
46
+ checks.map((c) => ({ name: c.name, status: "pending", message: "" })),
47
+ );
48
+ const [done, setDone] = useState(null);
49
+
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ (async () => {
53
+ let failed = 0;
54
+ for (let i = 0; i < checks.length; i++) {
55
+ if (cancelled) return;
56
+ setRows((s) =>
57
+ s.map((row, idx) => (idx === i ? { ...row, status: "running" } : row)),
58
+ );
59
+ try {
60
+ const msg = await checks[i].fn();
61
+ if (cancelled) return;
62
+ setRows((s) =>
63
+ s.map((row, idx) =>
64
+ idx === i ? { ...row, status: "pass", message: msg } : row,
65
+ ),
66
+ );
67
+ } catch (err) {
68
+ if (cancelled) return;
69
+ setRows((s) =>
70
+ s.map((row, idx) =>
71
+ idx === i
72
+ ? { ...row, status: "fail", message: err.message || String(err) }
73
+ : row,
74
+ ),
75
+ );
76
+ failed += 1;
77
+ }
78
+ }
79
+ setDone({ failed });
80
+ })();
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, []);
85
+
86
+ useEffect(() => {
87
+ if (!done) return;
88
+ onDone(done.failed);
89
+ exit();
90
+ }, [done]);
91
+
92
+ return h`<${Box} flexDirection="column">
93
+ ${rows.map((r, i) =>
94
+ h`<${CheckRow} key=${i} name=${r.name} status=${r.status} message=${r.message} />`,
95
+ )}
96
+ </>`;
97
+ }
98
+
99
+ export async function cmdDoctor() {
100
+ const checks = [
101
+ {
102
+ name: "node >= 20",
103
+ fn: () => {
104
+ const major = parseInt(process.versions.node.split(".")[0], 10);
105
+ if (major < 20) throw new Error(`found ${process.versions.node}`);
106
+ return process.versions.node;
107
+ },
108
+ },
109
+ {
110
+ name: "git",
111
+ fn: async () => {
112
+ const { stdout } = await execFileP("git", ["--version"]);
113
+ return stdout.trim();
114
+ },
115
+ },
116
+ {
117
+ name: "claude CLI",
118
+ fn: async () => {
119
+ const { stdout } = await execFileP("claude", ["--version"]);
120
+ return stdout.trim();
121
+ },
122
+ },
123
+ {
124
+ name: "FORGE_TOKEN",
125
+ fn: () => {
126
+ if (!process.env.FORGE_TOKEN) throw new Error("not set (run: forge-agent init)");
127
+ return "set";
128
+ },
129
+ },
130
+ {
131
+ name: "config file",
132
+ fn: async () => {
133
+ const p =
134
+ process.env.FORGE_CONFIG_PATH ??
135
+ path.join(os.homedir(), ".forge", "config.json");
136
+ if (!existsSync(p)) throw new Error(`missing at ${p}`);
137
+ const cfg = JSON.parse(await fs.readFile(p, "utf8"));
138
+ const n = cfg.repos?.length ?? 0;
139
+ return `${p} (${n} repo${n === 1 ? "" : "s"})`;
140
+ },
141
+ },
142
+ {
143
+ name: "frontend build",
144
+ fn: () => {
145
+ const spa = path.join(projectRoot, "dist", "index.html");
146
+ if (!existsSync(spa)) throw new Error("not built (run: npm run build:app)");
147
+ return "dist/";
148
+ },
149
+ },
150
+ ];
151
+
152
+ let failed = 0;
153
+ await runApp(
154
+ h`<${Doctor} checks=${checks} onDone=${(n) => (failed = n)} />`,
155
+ );
156
+
157
+ if (failed) {
158
+ process.stderr.write(`\n${failed} check${failed === 1 ? "" : "s"} failed.\n`);
159
+ process.exit(1);
160
+ }
161
+ }
@@ -0,0 +1,306 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import React, { useEffect, useState } from "react";
7
+ import TextInput from "ink-text-input";
8
+ import SelectInput from "ink-select-input";
9
+ import { h, Box, Text, useApp, runApp } from "./ink.js";
10
+ import { projectRoot } from "./utils.js";
11
+
12
+ const SCM_OPTIONS = [
13
+ { label: "gitlab", value: "gitlab" },
14
+ { label: "github", value: "github" },
15
+ { label: "azuredevops", value: "azuredevops" },
16
+ ];
17
+
18
+ const YESNO = [
19
+ { label: "no, abort", value: "no" },
20
+ { label: "yes, overwrite", value: "yes" },
21
+ ];
22
+
23
+ const ADD_REPO_YESNO = [
24
+ { label: "no, that's all", value: "no" },
25
+ { label: "yes, add another", value: "yes" },
26
+ ];
27
+
28
+ function Field({ label, hint, children }) {
29
+ return h`<${Box} flexDirection="column" marginBottom=${1}>
30
+ <${Box}>
31
+ <${Text} color="cyan">? </>
32
+ <${Text}>${label}</>
33
+ ${hint ? h`<${Text} dimColor> (${hint})</>` : null}
34
+ </>
35
+ <${Box} marginLeft=${2}>${children}</>
36
+ </>`;
37
+ }
38
+
39
+ function Summary({ field, value }) {
40
+ return h`<${Box}>
41
+ <${Text} color="green">✓ </>
42
+ <${Text}>${field}: </>
43
+ <${Text} dimColor>${value}</>
44
+ </>`;
45
+ }
46
+
47
+ function InitWizard({ envPath, hasExisting, force, onDone }) {
48
+ const { exit } = useApp();
49
+ const generatedToken = React.useMemo(() => randomBytes(24).toString("hex"), []);
50
+
51
+ const [step, setStep] = useState(hasExisting && !force ? "overwrite" : "token");
52
+ const [data, setData] = useState({
53
+ token: "",
54
+ port: "",
55
+ scm: "gitlab",
56
+ repos: [],
57
+ scmToken: "",
58
+ });
59
+ const [input, setInput] = useState("");
60
+ const [savedPaths, setSavedPaths] = useState(null);
61
+ const [error, setError] = useState(null);
62
+
63
+ useEffect(() => {
64
+ if (step === "abort") {
65
+ onDone({ aborted: true });
66
+ exit();
67
+ } else if (step === "done") {
68
+ onDone({ ok: true, savedPaths });
69
+ exit();
70
+ } else if (step === "error") {
71
+ onDone({ error });
72
+ exit(error);
73
+ }
74
+ }, [step]);
75
+
76
+ useEffect(() => {
77
+ if (step !== "save") return;
78
+ (async () => {
79
+ try {
80
+ await fs.writeFile(
81
+ envPath,
82
+ `FORGE_TOKEN=${data.token}\nPORT=${data.port}\nNODE_ENV=production\n`,
83
+ "utf8",
84
+ );
85
+ const { patch } = await import(
86
+ path.join(projectRoot, "server/forge/core/configStore.js")
87
+ );
88
+ await patch({
89
+ repos: data.repos.map((id) => ({ id, label: id })),
90
+ connections: { [data.scm]: { token: data.scmToken } },
91
+ });
92
+ const cfgPath =
93
+ process.env.FORGE_CONFIG_PATH ??
94
+ path.join(os.homedir(), ".forge", "config.json");
95
+ setSavedPaths({ envPath, cfgPath });
96
+ setStep("done");
97
+ } catch (err) {
98
+ setError(err);
99
+ setStep("error");
100
+ }
101
+ })();
102
+ }, [step]);
103
+
104
+ if (step === "overwrite") {
105
+ return h`<${Box} flexDirection="column">
106
+ <${Text}>forge-agent init</>
107
+ <${Box} marginTop=${1}>
108
+ <${Text} color="yellow">! </>
109
+ <${Text}>${envPath} exists — overwrite?</>
110
+ </>
111
+ <${Box} marginLeft=${2}>
112
+ <${SelectInput}
113
+ items=${YESNO}
114
+ onSelect=${(item) => {
115
+ if (item.value === "yes") setStep("token");
116
+ else setStep("abort");
117
+ }} />
118
+ </>
119
+ </>`;
120
+ }
121
+
122
+ if (step === "token") {
123
+ return h`<${Box} flexDirection="column">
124
+ <${Text}>forge-agent init</>
125
+ <${Box} marginTop=${1}/>
126
+ <${Field} label="FORGE_TOKEN" hint="press Enter to use generated">
127
+ <${TextInput}
128
+ value=${input}
129
+ placeholder=${generatedToken}
130
+ onChange=${setInput}
131
+ onSubmit=${(v) => {
132
+ const value = v || generatedToken;
133
+ setData((d) => ({ ...d, token: value }));
134
+ setInput("");
135
+ setStep("port");
136
+ }} />
137
+ </>
138
+ </>`;
139
+ }
140
+
141
+ if (step === "port") {
142
+ return h`<${Box} flexDirection="column">
143
+ <${Summary} field="FORGE_TOKEN" value=${maskToken(data.token)} />
144
+ <${Field} label="PORT" hint="default 5001">
145
+ <${TextInput}
146
+ value=${input}
147
+ placeholder="5001"
148
+ onChange=${setInput}
149
+ onSubmit=${(v) => {
150
+ const value = v || "5001";
151
+ setData((d) => ({ ...d, port: value }));
152
+ setInput("");
153
+ setStep("scm");
154
+ }} />
155
+ </>
156
+ </>`;
157
+ }
158
+
159
+ if (step === "scm") {
160
+ return h`<${Box} flexDirection="column">
161
+ <${Summary} field="FORGE_TOKEN" value=${maskToken(data.token)} />
162
+ <${Summary} field="PORT" value=${data.port} />
163
+ <${Field} label="SCM provider" hint="↑/↓ to choose">
164
+ <${SelectInput}
165
+ items=${SCM_OPTIONS}
166
+ onSelect=${(item) => {
167
+ setData((d) => ({ ...d, scm: item.value }));
168
+ setStep("repo");
169
+ }} />
170
+ </>
171
+ </>`;
172
+ }
173
+
174
+ if (step === "repo") {
175
+ const isFirst = data.repos.length === 0;
176
+ const label = isFirst ? "Primary repo" : `Additional repo #${data.repos.length + 1}`;
177
+ const hint = isFirst
178
+ ? "group/repo (this becomes the default)"
179
+ : "group/repo for cross-repo fixes";
180
+ return h`<${Box} flexDirection="column">
181
+ <${Summary} field="FORGE_TOKEN" value=${maskToken(data.token)} />
182
+ <${Summary} field="PORT" value=${data.port} />
183
+ <${Summary} field="SCM provider" value=${data.scm} />
184
+ ${data.repos.map((r, i) =>
185
+ h`<${Summary} key=${i} field=${i === 0 ? "Primary repo" : `Repo #${i + 1}`} value=${r} />`,
186
+ )}
187
+ <${Field} label=${label} hint=${hint}>
188
+ <${TextInput}
189
+ value=${input}
190
+ onChange=${setInput}
191
+ onSubmit=${(v) => {
192
+ const trimmed = v.trim();
193
+ if (!trimmed) {
194
+ if (isFirst) return; // primary required, stay on step
195
+ setStep("scmToken");
196
+ return;
197
+ }
198
+ setData((d) => ({ ...d, repos: [...d.repos, trimmed] }));
199
+ setInput("");
200
+ setStep("repoMore");
201
+ }} />
202
+ </>
203
+ </>`;
204
+ }
205
+
206
+ if (step === "repoMore") {
207
+ return h`<${Box} flexDirection="column">
208
+ <${Summary} field="FORGE_TOKEN" value=${maskToken(data.token)} />
209
+ <${Summary} field="PORT" value=${data.port} />
210
+ <${Summary} field="SCM provider" value=${data.scm} />
211
+ ${data.repos.map((r, i) =>
212
+ h`<${Summary} key=${i} field=${i === 0 ? "Primary repo" : `Repo #${i + 1}`} value=${r} />`,
213
+ )}
214
+ <${Field} label="Add another repo?" hint="enables cross-repo fixes (e.g. web-app + api)">
215
+ <${SelectInput}
216
+ items=${ADD_REPO_YESNO}
217
+ onSelect=${(item) => {
218
+ if (item.value === "yes") setStep("repo");
219
+ else setStep("scmToken");
220
+ }} />
221
+ </>
222
+ </>`;
223
+ }
224
+
225
+ if (step === "scmToken") {
226
+ return h`<${Box} flexDirection="column">
227
+ <${Summary} field="FORGE_TOKEN" value=${maskToken(data.token)} />
228
+ <${Summary} field="PORT" value=${data.port} />
229
+ <${Summary} field="SCM provider" value=${data.scm} />
230
+ ${data.repos.map((r, i) =>
231
+ h`<${Summary} key=${i} field=${i === 0 ? "Primary repo" : `Repo #${i + 1}`} value=${r} />`,
232
+ )}
233
+ <${Field} label=${`${data.scm} token`}>
234
+ <${TextInput}
235
+ value=${input}
236
+ onChange=${setInput}
237
+ mask="*"
238
+ onSubmit=${(v) => {
239
+ setData((d) => ({ ...d, scmToken: v }));
240
+ setInput("");
241
+ setStep("save");
242
+ }} />
243
+ </>
244
+ </>`;
245
+ }
246
+
247
+ if (step === "save") {
248
+ return h`<${Box}>
249
+ <${Text} color="cyan">… </>
250
+ <${Text}>Writing config…</>
251
+ </>`;
252
+ }
253
+
254
+ if (step === "done") {
255
+ return h`<${Box} flexDirection="column">
256
+ <${Box}>
257
+ <${Text} color="green">✓ </>
258
+ <${Text}>Wrote ${savedPaths?.envPath}</>
259
+ </>
260
+ <${Box}>
261
+ <${Text} color="green">✓ </>
262
+ <${Text}>Wrote ${savedPaths?.cfgPath}</>
263
+ </>
264
+ <${Box} marginTop=${1}>
265
+ <${Text} dimColor>${`Configured ${data.repos.length} repo${data.repos.length === 1 ? "" : "s"} (primary: ${data.repos[0] ?? "none"}).`}</>
266
+ </>
267
+ <${Box}>
268
+ <${Text} dimColor>${"Next: forge-agent doctor | forge-agent issues | forge-agent start"}</>
269
+ </>
270
+ </>`;
271
+ }
272
+
273
+ if (step === "abort") {
274
+ return h`<${Text} dimColor>Aborted.</>`;
275
+ }
276
+
277
+ if (step === "error") {
278
+ return h`<${Box}>
279
+ <${Text} color="red">✘ </>
280
+ <${Text}>${error?.message || String(error)}</>
281
+ </>`;
282
+ }
283
+
284
+ return null;
285
+ }
286
+
287
+ function maskToken(t) {
288
+ if (!t) return "";
289
+ if (t.length <= 8) return "*".repeat(t.length);
290
+ return `${t.slice(0, 4)}…${t.slice(-4)}`;
291
+ }
292
+
293
+ export async function cmdInit({ force }) {
294
+ const envPath = path.join(process.cwd(), ".env");
295
+ const hasExisting = existsSync(envPath);
296
+
297
+ let outcome = { ok: false };
298
+ await runApp(
299
+ h`<${InitWizard}
300
+ envPath=${envPath}
301
+ hasExisting=${hasExisting}
302
+ force=${force}
303
+ onDone=${(o) => (outcome = o)} />`,
304
+ );
305
+ if (outcome.error) process.exit(1);
306
+ }
package/bin/cli/ink.js ADDED
@@ -0,0 +1,57 @@
1
+ import React, { useEffect } from "react";
2
+ import htm from "htm";
3
+ import SelectInput from "ink-select-input";
4
+ import { Box, Text, Newline, render, useApp, useInput } from "ink";
5
+
6
+ export const h = htm.bind(React.createElement);
7
+ export { Box, Text, Newline, render, useApp, useInput, React };
8
+
9
+ /**
10
+ * Render an Ink component, resolve when it exits via useApp().exit().
11
+ * If the component calls exit(err), the promise rejects with that error.
12
+ */
13
+ export async function runApp(element) {
14
+ const instance = render(element);
15
+ try {
16
+ await instance.waitUntilExit();
17
+ } finally {
18
+ instance.unmount();
19
+ }
20
+ }
21
+
22
+ const CONFIRM_ITEMS = [
23
+ { label: "no", value: "no" },
24
+ { label: "yes", value: "yes" },
25
+ ];
26
+
27
+ /**
28
+ * Yes/no confirmation prompt. Defaults to "no" (cursor on the safe option).
29
+ * Calls onSelect(true) on yes, onSelect(false) on no.
30
+ *
31
+ * Bypass it programmatically by setting `bypassYes={true}` — fires onSelect(true)
32
+ * once on mount, doesn't render an interactive picker. Lets `--yes` callers
33
+ * reuse the same component.
34
+ */
35
+ export function Confirm({ message, onSelect, bypassYes = false }) {
36
+ useEffect(() => {
37
+ if (bypassYes) onSelect(true);
38
+ }, [bypassYes]);
39
+ if (bypassYes) {
40
+ return h`<${Box}>
41
+ <${Text} color="green">✓ </>
42
+ <${Text}>${message}</>
43
+ <${Text} dimColor> (--yes)</>
44
+ </>`;
45
+ }
46
+ return h`<${Box} flexDirection="column" marginTop=${1}>
47
+ <${Box}>
48
+ <${Text} color="yellow">? </>
49
+ <${Text}>${message}</>
50
+ </>
51
+ <${Box} marginLeft=${2}>
52
+ <${SelectInput}
53
+ items=${CONFIRM_ITEMS}
54
+ onSelect=${(item) => onSelect(item.value === "yes")} />
55
+ </>
56
+ </>`;
57
+ }
@@ -0,0 +1,116 @@
1
+ import path from "node:path";
2
+ import React, { useEffect, useState } from "react";
3
+ import Spinner from "ink-spinner";
4
+ import { h, Box, Text, useApp, runApp } from "./ink.js";
5
+ import { getFlag, projectRoot, resolvePrimaryRepo } from "./utils.js";
6
+
7
+ function IssueDetail({ issue, repo }) {
8
+ const labels = (issue.labels ?? []).join(", ");
9
+ return h`<${Box} flexDirection="column">
10
+ <${Box}>
11
+ <${Text} color="yellow">#${issue.iid} </>
12
+ <${Text} bold>${issue.title}</>
13
+ </>
14
+ <${Box}>
15
+ <${Text} dimColor>${`${repo} · ${issue.author?.name ?? "unknown"} · ${issue.created_at ?? ""}`}</>
16
+ </>
17
+ ${labels
18
+ ? h`<${Box}>
19
+ <${Text} dimColor>labels: </>
20
+ <${Text}>${labels}</>
21
+ </>`
22
+ : null}
23
+ ${issue.web_url
24
+ ? h`<${Box}>
25
+ <${Text} dimColor>${issue.web_url}</>
26
+ </>`
27
+ : null}
28
+ <${Box} marginTop=${1}>
29
+ <${Text} dimColor>${"─── description ───"}</>
30
+ </>
31
+ <${Box} marginTop=${1}>
32
+ <${Text}>${issue.description ?? "(no description)"}</>
33
+ </>
34
+ <${Box} marginTop=${1}>
35
+ <${Text} dimColor>${`Next: forge-agent ship ${issue.iid}`}</>
36
+ </>
37
+ </>`;
38
+ }
39
+
40
+ function IssueView({ iid, repo, onDone }) {
41
+ const { exit } = useApp();
42
+ const [issue, setIssue] = useState(null);
43
+ const [error, setError] = useState(null);
44
+ const [done, setDone] = useState(null);
45
+
46
+ useEffect(() => {
47
+ if (!done) return;
48
+ onDone(done);
49
+ exit();
50
+ }, [done]);
51
+
52
+ useEffect(() => {
53
+ (async () => {
54
+ try {
55
+ const { createProvider } = await import(
56
+ path.join(projectRoot, "server/forge/core/providers/index.js")
57
+ );
58
+ const provider = createProvider(repo);
59
+ const i = await provider.getIssue(iid);
60
+ setIssue(i);
61
+ setDone({ ok: true });
62
+ } catch (err) {
63
+ setError(err);
64
+ setDone({ ok: false, error: err });
65
+ }
66
+ })();
67
+ }, []);
68
+
69
+ if (error) {
70
+ return h`<${Box}>
71
+ <${Text} color="red">${`✘ ${error.message ?? String(error)}`}</>
72
+ </>`;
73
+ }
74
+ if (!issue) {
75
+ return h`<${Box}>
76
+ <${Text} color="cyan"><${Spinner} type="dots"/></>
77
+ <${Text}>${` Fetching issue #${iid} from ${repo}…`}</>
78
+ </>`;
79
+ }
80
+ return h`<${IssueDetail} issue=${issue} repo=${repo} />`;
81
+ }
82
+
83
+ export async function cmdIssue(argv) {
84
+ const iid = Number(argv[1]);
85
+ if (!Number.isInteger(iid) || iid <= 0) {
86
+ console.error("Usage: forge-agent issue <iid> [--repo <id>]");
87
+ process.exit(1);
88
+ }
89
+ const repo = (await resolvePrimaryRepo(argv)) ?? null;
90
+ if (!repo) {
91
+ console.error(
92
+ "No repo. Pass --repo <group/repo>, set FORGE_GITLAB_PROJECT, or run `forge-agent init`.",
93
+ );
94
+ process.exit(1);
95
+ }
96
+ const json = argv.includes("--json");
97
+ if (json) {
98
+ const { createProvider } = await import(
99
+ path.join(projectRoot, "server/forge/core/providers/index.js")
100
+ );
101
+ try {
102
+ const i = await createProvider(repo).getIssue(iid);
103
+ process.stdout.write(`${JSON.stringify(i, null, 2)}\n`);
104
+ } catch (err) {
105
+ process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
106
+ process.exit(2);
107
+ }
108
+ return;
109
+ }
110
+
111
+ let outcome = { ok: false };
112
+ await runApp(
113
+ h`<${IssueView} iid=${iid} repo=${repo} onDone=${(o) => (outcome = o)} />`,
114
+ );
115
+ if (!outcome.ok) process.exit(2);
116
+ }