pi-forge 0.0.0 → 1.1.4

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -4
  3. package/bin/pi-forge.mjs +37 -0
  4. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
  5. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
  6. package/dist/client/assets/index-B-529kgJ.css +32 -0
  7. package/dist/client/assets/index-BzKzxXFs.js +392 -0
  8. package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
  9. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
  10. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
  11. package/dist/client/icons/icon-192.png +0 -0
  12. package/dist/client/icons/icon-512.png +0 -0
  13. package/dist/client/icons/icon-maskable-512.png +0 -0
  14. package/dist/client/icons/icon.svg +9 -0
  15. package/dist/client/index.html +24 -0
  16. package/dist/client/manifest.webmanifest +1 -0
  17. package/dist/client/offline.html +142 -0
  18. package/dist/client/sw.js +3 -0
  19. package/dist/client/sw.js.map +1 -0
  20. package/dist/client/workbox-6d7155ed.js +3 -0
  21. package/dist/client/workbox-6d7155ed.js.map +1 -0
  22. package/dist/server/agent-resource-loader.js +126 -0
  23. package/dist/server/agent-resource-loader.js.map +1 -0
  24. package/dist/server/attachment-converters.js +96 -0
  25. package/dist/server/attachment-converters.js.map +1 -0
  26. package/dist/server/auth.js +209 -0
  27. package/dist/server/auth.js.map +1 -0
  28. package/dist/server/compaction-history.js +106 -0
  29. package/dist/server/compaction-history.js.map +1 -0
  30. package/dist/server/concurrency.js +49 -0
  31. package/dist/server/concurrency.js.map +1 -0
  32. package/dist/server/config-export.js +220 -0
  33. package/dist/server/config-export.js.map +1 -0
  34. package/dist/server/config-manager.js +528 -0
  35. package/dist/server/config-manager.js.map +1 -0
  36. package/dist/server/config.js +326 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/conversion-worker.mjs +90 -0
  39. package/dist/server/diagnostics.js +137 -0
  40. package/dist/server/diagnostics.js.map +1 -0
  41. package/dist/server/extensions-discovery.js +147 -0
  42. package/dist/server/extensions-discovery.js.map +1 -0
  43. package/dist/server/file-manager.js +734 -0
  44. package/dist/server/file-manager.js.map +1 -0
  45. package/dist/server/file-references.js +215 -0
  46. package/dist/server/file-references.js.map +1 -0
  47. package/dist/server/file-searcher.js +385 -0
  48. package/dist/server/file-searcher.js.map +1 -0
  49. package/dist/server/git-runner.js +684 -0
  50. package/dist/server/git-runner.js.map +1 -0
  51. package/dist/server/index.js +468 -0
  52. package/dist/server/index.js.map +1 -0
  53. package/dist/server/mcp/config.js +133 -0
  54. package/dist/server/mcp/config.js.map +1 -0
  55. package/dist/server/mcp/manager.js +351 -0
  56. package/dist/server/mcp/manager.js.map +1 -0
  57. package/dist/server/mcp/tool-bridge.js +173 -0
  58. package/dist/server/mcp/tool-bridge.js.map +1 -0
  59. package/dist/server/project-manager.js +301 -0
  60. package/dist/server/project-manager.js.map +1 -0
  61. package/dist/server/pty-manager.js +354 -0
  62. package/dist/server/pty-manager.js.map +1 -0
  63. package/dist/server/routes/_schemas.js +73 -0
  64. package/dist/server/routes/_schemas.js.map +1 -0
  65. package/dist/server/routes/auth.js +164 -0
  66. package/dist/server/routes/auth.js.map +1 -0
  67. package/dist/server/routes/config.js +1163 -0
  68. package/dist/server/routes/config.js.map +1 -0
  69. package/dist/server/routes/control.js +464 -0
  70. package/dist/server/routes/control.js.map +1 -0
  71. package/dist/server/routes/exec.js +217 -0
  72. package/dist/server/routes/exec.js.map +1 -0
  73. package/dist/server/routes/files.js +847 -0
  74. package/dist/server/routes/files.js.map +1 -0
  75. package/dist/server/routes/git.js +837 -0
  76. package/dist/server/routes/git.js.map +1 -0
  77. package/dist/server/routes/health.js +97 -0
  78. package/dist/server/routes/health.js.map +1 -0
  79. package/dist/server/routes/mcp.js +300 -0
  80. package/dist/server/routes/mcp.js.map +1 -0
  81. package/dist/server/routes/projects.js +259 -0
  82. package/dist/server/routes/projects.js.map +1 -0
  83. package/dist/server/routes/prompt.js +496 -0
  84. package/dist/server/routes/prompt.js.map +1 -0
  85. package/dist/server/routes/sessions.js +783 -0
  86. package/dist/server/routes/sessions.js.map +1 -0
  87. package/dist/server/routes/stream.js +69 -0
  88. package/dist/server/routes/stream.js.map +1 -0
  89. package/dist/server/routes/terminal.js +335 -0
  90. package/dist/server/routes/terminal.js.map +1 -0
  91. package/dist/server/session-registry.js +1197 -0
  92. package/dist/server/session-registry.js.map +1 -0
  93. package/dist/server/skill-overrides.js +151 -0
  94. package/dist/server/skill-overrides.js.map +1 -0
  95. package/dist/server/skills-export.js +257 -0
  96. package/dist/server/skills-export.js.map +1 -0
  97. package/dist/server/sse-bridge.js +220 -0
  98. package/dist/server/sse-bridge.js.map +1 -0
  99. package/dist/server/tool-overrides.js +277 -0
  100. package/dist/server/tool-overrides.js.map +1 -0
  101. package/dist/server/turn-diff-builder.js +280 -0
  102. package/dist/server/turn-diff-builder.js.map +1 -0
  103. package/package.json +53 -12
@@ -0,0 +1,837 @@
1
+ import { GitCommandError, GitNotInstalledError, InvalidBranchNameError, checkoutBranch, commit, createBranch, deleteBranch, fetch, addRemote, getBranches, getRemotes, removeRemote, getDiff, getFileDiff, getLog, getStagedDiff, getStatus, initRepo, isGitRepo, pull, push, revertPaths, stagePaths, unstagePaths, } from "../git-runner.js";
2
+ import { config } from "../config.js";
3
+ import { getProject } from "../project-manager.js";
4
+ import { errorSchema } from "./_schemas.js";
5
+ /* ----------------------------- schemas ----------------------------- */
6
+ const fileStatusEntrySchema = {
7
+ type: "object",
8
+ required: ["path", "staged", "unstaged", "kind", "code"],
9
+ properties: {
10
+ path: { type: "string" },
11
+ staged: { type: "boolean" },
12
+ unstaged: { type: "boolean" },
13
+ kind: {
14
+ type: "string",
15
+ enum: [
16
+ "modified",
17
+ "added",
18
+ "deleted",
19
+ "renamed",
20
+ "copied",
21
+ "untracked",
22
+ "ignored",
23
+ "conflicted",
24
+ "unknown",
25
+ ],
26
+ },
27
+ code: { type: "string" },
28
+ originalPath: { type: "string" },
29
+ },
30
+ };
31
+ const statusSchema = {
32
+ type: "object",
33
+ required: ["isGitRepo", "files"],
34
+ properties: {
35
+ isGitRepo: { type: "boolean" },
36
+ branch: { type: "string" },
37
+ files: { type: "array", items: fileStatusEntrySchema },
38
+ },
39
+ };
40
+ const diffSchema = {
41
+ type: "object",
42
+ required: ["isGitRepo", "diff"],
43
+ properties: {
44
+ isGitRepo: { type: "boolean" },
45
+ diff: { type: "string" },
46
+ },
47
+ };
48
+ const logSchema = {
49
+ type: "object",
50
+ required: ["isGitRepo", "commits"],
51
+ properties: {
52
+ isGitRepo: { type: "boolean" },
53
+ commits: {
54
+ type: "array",
55
+ items: {
56
+ type: "object",
57
+ required: ["hash", "message", "author", "date", "parents", "refs"],
58
+ properties: {
59
+ hash: { type: "string" },
60
+ message: { type: "string" },
61
+ author: { type: "string" },
62
+ date: { type: "string" },
63
+ parents: { type: "array", items: { type: "string" } },
64
+ refs: { type: "array", items: { type: "string" } },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ };
70
+ const branchesSchema = {
71
+ type: "object",
72
+ required: ["isGitRepo", "branches"],
73
+ properties: {
74
+ isGitRepo: { type: "boolean" },
75
+ current: { type: "string" },
76
+ branches: {
77
+ type: "array",
78
+ items: {
79
+ type: "object",
80
+ required: ["name", "current", "remote"],
81
+ properties: {
82
+ name: { type: "string" },
83
+ current: { type: "boolean" },
84
+ remote: { type: "boolean" },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ };
90
+ const remotesSchema = {
91
+ type: "object",
92
+ required: ["isGitRepo", "remotes"],
93
+ properties: {
94
+ isGitRepo: { type: "boolean" },
95
+ remotes: {
96
+ type: "array",
97
+ items: {
98
+ type: "object",
99
+ required: ["name", "fetchUrl", "pushUrl"],
100
+ properties: {
101
+ name: { type: "string" },
102
+ fetchUrl: { type: "string" },
103
+ pushUrl: { type: "string" },
104
+ },
105
+ },
106
+ },
107
+ },
108
+ };
109
+ /* ----------------------------- error mapping ----------------------------- */
110
+ function mapError(reply, err) {
111
+ if (err instanceof GitNotInstalledError) {
112
+ return reply.code(500).send({
113
+ error: "git_not_installed",
114
+ message: "git binary is not on PATH on the server",
115
+ });
116
+ }
117
+ if (err instanceof InvalidBranchNameError) {
118
+ return reply.code(400).send({ error: "invalid_branch_name", message: err.message });
119
+ }
120
+ if (err instanceof GitCommandError) {
121
+ // Git "rejected" / "non-fast-forward" / commit hook failures /
122
+ // missing upstream are user-actionable, not server bugs. 400
123
+ // with a sanitized message lets the client surface the hint
124
+ // verbatim. Network / auth failures during push are reported the
125
+ // same way — we don't try to enumerate every git failure mode.
126
+ return reply.code(400).send({ error: "git_failed", message: err.userMessage });
127
+ }
128
+ reply.log.error({ err }, "unmapped git-runner error");
129
+ return reply.code(500).send({ error: "internal_error" });
130
+ }
131
+ /**
132
+ * Resolve the project for a request. On miss, sends 404 + returns
133
+ * undefined; caller MUST `return reply` immediately. Returning bare
134
+ * `undefined` trips Fastify's `FST_ERR_REP_ALREADY_SENT` because the
135
+ * route handler's resolved `undefined` is interpreted as "send this,"
136
+ * which races the 404 the helper already sent. See
137
+ * files.ts:resolveProject for the same contract.
138
+ */
139
+ async function resolveProject(projectId, reply) {
140
+ const project = await getProject(projectId);
141
+ if (project === undefined) {
142
+ await reply.code(404).send({ error: "project_not_found", message: "no project with that id" });
143
+ return undefined;
144
+ }
145
+ return { id: project.id, path: project.path };
146
+ }
147
+ /**
148
+ * Resolve-then-run helper that collapses the project-not-found 404 +
149
+ * runner-error 4xx mapping every git GET route shares. Without this
150
+ * each route was 6 lines of identical boilerplate (resolveProject,
151
+ * undefined-check, try, runner call, catch, mapError); routes shrink
152
+ * to a one-liner.
153
+ *
154
+ * On project-not-found the resolveProject helper has already sent the
155
+ * 404 reply; we return its `undefined` so the route handler short-
156
+ * circuits without trying to return a value Fastify would re-send.
157
+ * On runner success the result is returned verbatim (Fastify
158
+ * serializes via the response schema). On runner throw we route to
159
+ * mapError for the typed-error → wire-shape mapping.
160
+ */
161
+ async function withProject(projectId, reply, fn) {
162
+ const project = await resolveProject(projectId, reply);
163
+ // resolveProject already called reply.send for the 404 path —
164
+ // returning the reply here tells Fastify the response was handled,
165
+ // avoiding the FST_ERR_REP_ALREADY_SENT double-send error.
166
+ if (project === undefined)
167
+ return reply;
168
+ try {
169
+ return await fn(project);
170
+ }
171
+ catch (err) {
172
+ return mapError(reply, err);
173
+ }
174
+ }
175
+ /* ----------------------------- routes ----------------------------- */
176
+ export const gitRoutes = async (fastify) => {
177
+ fastify.post("/git/init", {
178
+ schema: {
179
+ description: "Initialize a fresh git repo at the project's path with `main` as the " +
180
+ "initial branch. Idempotent: returns 200 with `{ alreadyInitialised: " +
181
+ "true }` if the project is already a git working tree. Falls back to " +
182
+ "plain `git init` (no `-b main`) on git versions < 2.28.",
183
+ tags: ["git"],
184
+ body: {
185
+ type: "object",
186
+ required: ["projectId"],
187
+ additionalProperties: false,
188
+ properties: { projectId: { type: "string", minLength: 1 } },
189
+ },
190
+ response: {
191
+ 200: {
192
+ type: "object",
193
+ required: ["alreadyInitialised", "isGitRepo"],
194
+ properties: {
195
+ alreadyInitialised: { type: "boolean" },
196
+ isGitRepo: { type: "boolean" },
197
+ },
198
+ },
199
+ 400: errorSchema,
200
+ 404: errorSchema,
201
+ 500: errorSchema,
202
+ },
203
+ },
204
+ }, async (req, reply) => {
205
+ const project = await resolveProject(req.body.projectId, reply);
206
+ if (project === undefined)
207
+ return reply;
208
+ try {
209
+ if (await isGitRepo(project.path)) {
210
+ return { alreadyInitialised: true, isGitRepo: true };
211
+ }
212
+ await initRepo(project.path);
213
+ return { alreadyInitialised: false, isGitRepo: true };
214
+ }
215
+ catch (err) {
216
+ return mapError(reply, err);
217
+ }
218
+ });
219
+ fastify.get("/git/status", {
220
+ // Polled by the client every ~15s while a project is open. We
221
+ // silence the access logs for this route specifically so the
222
+ // poll doesn't drown out interesting events; errors still log
223
+ // at warn+.
224
+ logLevel: "warn",
225
+ schema: {
226
+ description: "Parsed `git status --porcelain=v1 -uall` for the project. Files " +
227
+ "include staged/unstaged flags, a coarse `kind` classification, and " +
228
+ "the raw two-char porcelain code. Non-git directories return " +
229
+ "`{ isGitRepo: false, files: [] }` (NOT 500) so the panel can sit " +
230
+ "quiet on plain folders.",
231
+ tags: ["git"],
232
+ querystring: {
233
+ type: "object",
234
+ required: ["projectId"],
235
+ properties: { projectId: { type: "string", minLength: 1 } },
236
+ },
237
+ response: { 200: statusSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
238
+ },
239
+ }, async (req, reply) => {
240
+ const project = await resolveProject(req.query.projectId, reply);
241
+ if (project === undefined)
242
+ return reply;
243
+ try {
244
+ return await getStatus(project.path);
245
+ }
246
+ catch (err) {
247
+ return mapError(reply, err);
248
+ }
249
+ });
250
+ fastify.get("/git/diff", {
251
+ schema: {
252
+ description: "Unstaged unified diff for the project (working tree vs index).",
253
+ tags: ["git"],
254
+ querystring: {
255
+ type: "object",
256
+ required: ["projectId"],
257
+ properties: { projectId: { type: "string", minLength: 1 } },
258
+ },
259
+ response: { 200: diffSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
260
+ },
261
+ }, async (req, reply) => withProject(req.query.projectId, reply, (p) => getDiff(p.path)));
262
+ fastify.get("/git/diff/staged", {
263
+ schema: {
264
+ description: "Staged unified diff (index vs HEAD).",
265
+ tags: ["git"],
266
+ querystring: {
267
+ type: "object",
268
+ required: ["projectId"],
269
+ properties: { projectId: { type: "string", minLength: 1 } },
270
+ },
271
+ response: { 200: diffSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
272
+ },
273
+ }, async (req, reply) => withProject(req.query.projectId, reply, (p) => getStagedDiff(p.path)));
274
+ fastify.get("/git/diff/file", {
275
+ schema: {
276
+ description: "Unified diff for a single file. `?staged=1` for the index↔HEAD diff; " +
277
+ "default is working-tree↔index.",
278
+ tags: ["git"],
279
+ querystring: {
280
+ type: "object",
281
+ required: ["projectId", "path"],
282
+ properties: {
283
+ projectId: { type: "string", minLength: 1 },
284
+ path: { type: "string", minLength: 1 },
285
+ staged: { type: "string", enum: ["0", "1", "true", "false"] },
286
+ },
287
+ },
288
+ response: { 200: diffSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
289
+ },
290
+ }, async (req, reply) => {
291
+ const staged = req.query.staged === "1" || req.query.staged === "true";
292
+ return withProject(req.query.projectId, reply, (p) => getFileDiff(p.path, req.query.path, staged));
293
+ });
294
+ fastify.get("/git/log", {
295
+ schema: {
296
+ description: "Recent commits as `{ hash, message, author, date }[]`. Default " + "limit 30; max 1000.",
297
+ tags: ["git"],
298
+ querystring: {
299
+ type: "object",
300
+ required: ["projectId"],
301
+ properties: {
302
+ projectId: { type: "string", minLength: 1 },
303
+ limit: { type: "string", pattern: "^[0-9]+$" },
304
+ },
305
+ },
306
+ response: { 200: logSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
307
+ },
308
+ }, async (req, reply) => {
309
+ const limit = req.query.limit !== undefined
310
+ ? Math.min(1000, Math.max(1, Number.parseInt(req.query.limit, 10)))
311
+ : 30;
312
+ return withProject(req.query.projectId, reply, (p) => getLog(p.path, limit));
313
+ });
314
+ fastify.get("/git/branches", {
315
+ schema: {
316
+ description: "Local + remote branch list with `current` flag.",
317
+ tags: ["git"],
318
+ querystring: {
319
+ type: "object",
320
+ required: ["projectId"],
321
+ properties: { projectId: { type: "string", minLength: 1 } },
322
+ },
323
+ response: { 200: branchesSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
324
+ },
325
+ }, async (req, reply) => withProject(req.query.projectId, reply, (p) => getBranches(p.path)));
326
+ fastify.get("/git/remotes", {
327
+ schema: {
328
+ description: "Configured git remotes with their fetch + push URLs. " +
329
+ "Empty array for non-git projects or repos with no remotes.",
330
+ tags: ["git"],
331
+ querystring: {
332
+ type: "object",
333
+ required: ["projectId"],
334
+ properties: { projectId: { type: "string", minLength: 1 } },
335
+ },
336
+ response: { 200: remotesSchema, 400: errorSchema, 404: errorSchema, 500: errorSchema },
337
+ },
338
+ }, async (req, reply) => withProject(req.query.projectId, reply, (p) => getRemotes(p.path)));
339
+ fastify.post("/git/remote/add", {
340
+ schema: {
341
+ description: "Add a git remote (`git remote add <name> <url>`). Name is " +
342
+ "validated against the same character set as branch names. " +
343
+ "URL accepts any string git itself accepts (https://, git@, " +
344
+ "file://, etc.). Duplicate name → 400 `git_failed`.",
345
+ tags: ["git"],
346
+ body: {
347
+ type: "object",
348
+ required: ["projectId", "name", "url"],
349
+ additionalProperties: false,
350
+ properties: {
351
+ projectId: { type: "string", minLength: 1 },
352
+ name: { type: "string", minLength: 1 },
353
+ url: { type: "string", minLength: 1, maxLength: 1024 },
354
+ },
355
+ },
356
+ response: {
357
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
358
+ 400: errorSchema,
359
+ 404: errorSchema,
360
+ 500: errorSchema,
361
+ },
362
+ },
363
+ }, async (req, reply) => {
364
+ const project = await resolveProject(req.body.projectId, reply);
365
+ if (project === undefined)
366
+ return reply;
367
+ try {
368
+ await addRemote(project.path, req.body.name, req.body.url);
369
+ return { ok: true };
370
+ }
371
+ catch (err) {
372
+ return mapError(reply, err);
373
+ }
374
+ });
375
+ fastify.delete("/git/remote/:name", {
376
+ schema: {
377
+ description: "Remove a git remote (`git remote remove <name>`). 400 " +
378
+ "`git_failed` if the name is unknown.",
379
+ tags: ["git"],
380
+ params: {
381
+ type: "object",
382
+ required: ["name"],
383
+ properties: { name: { type: "string", minLength: 1 } },
384
+ },
385
+ querystring: {
386
+ type: "object",
387
+ required: ["projectId"],
388
+ properties: { projectId: { type: "string", minLength: 1 } },
389
+ },
390
+ response: {
391
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
392
+ 400: errorSchema,
393
+ 404: errorSchema,
394
+ 500: errorSchema,
395
+ },
396
+ },
397
+ }, async (req, reply) => {
398
+ const project = await resolveProject(req.query.projectId, reply);
399
+ if (project === undefined)
400
+ return reply;
401
+ try {
402
+ await removeRemote(project.path, req.params.name);
403
+ return { ok: true };
404
+ }
405
+ catch (err) {
406
+ return mapError(reply, err);
407
+ }
408
+ });
409
+ fastify.post("/git/checkout", {
410
+ schema: {
411
+ description: "Switch the working tree to `branch`. Refuses on a dirty tree (git's " +
412
+ "default) — caller surfaces the resulting `git_failed` message so the " +
413
+ "user can stash or revert first. Pass `origin/feature` to start a " +
414
+ "tracking branch from the remote ref.",
415
+ tags: ["git"],
416
+ body: {
417
+ type: "object",
418
+ required: ["projectId", "branch"],
419
+ additionalProperties: false,
420
+ properties: {
421
+ projectId: { type: "string", minLength: 1 },
422
+ branch: { type: "string", minLength: 1 },
423
+ },
424
+ },
425
+ response: {
426
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
427
+ 400: errorSchema,
428
+ 404: errorSchema,
429
+ 500: errorSchema,
430
+ },
431
+ },
432
+ }, async (req, reply) => {
433
+ const project = await resolveProject(req.body.projectId, reply);
434
+ if (project === undefined)
435
+ return reply;
436
+ try {
437
+ await checkoutBranch(project.path, req.body.branch);
438
+ return { ok: true };
439
+ }
440
+ catch (err) {
441
+ return mapError(reply, err);
442
+ }
443
+ });
444
+ fastify.post("/git/branch/create", {
445
+ schema: {
446
+ description: "Create a local branch. `startPoint` (defaults to HEAD) accepts any ref " +
447
+ "the user could pass to `git branch`. `checkout: true` creates and " +
448
+ "switches in one step via `git checkout -b`.",
449
+ tags: ["git"],
450
+ body: {
451
+ type: "object",
452
+ required: ["projectId", "name"],
453
+ additionalProperties: false,
454
+ properties: {
455
+ projectId: { type: "string", minLength: 1 },
456
+ name: { type: "string", minLength: 1 },
457
+ startPoint: { type: "string", minLength: 1 },
458
+ checkout: { type: "boolean" },
459
+ },
460
+ },
461
+ response: {
462
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
463
+ 400: errorSchema,
464
+ 404: errorSchema,
465
+ 500: errorSchema,
466
+ },
467
+ },
468
+ }, async (req, reply) => {
469
+ const project = await resolveProject(req.body.projectId, reply);
470
+ if (project === undefined)
471
+ return reply;
472
+ try {
473
+ const opts = {};
474
+ if (req.body.startPoint !== undefined)
475
+ opts.startPoint = req.body.startPoint;
476
+ if (req.body.checkout !== undefined)
477
+ opts.checkout = req.body.checkout;
478
+ await createBranch(project.path, req.body.name, opts);
479
+ return { ok: true };
480
+ }
481
+ catch (err) {
482
+ return mapError(reply, err);
483
+ }
484
+ });
485
+ fastify.delete("/git/branch/:name", {
486
+ schema: {
487
+ description: "Delete a local branch via `git branch -d <name>`. `?force=1` switches " +
488
+ "to `-D` for branches that haven't been merged. Refuses to delete the " +
489
+ "currently-checked-out branch (git surfaces a `git_failed`).",
490
+ tags: ["git"],
491
+ params: {
492
+ type: "object",
493
+ required: ["name"],
494
+ properties: { name: { type: "string", minLength: 1 } },
495
+ },
496
+ querystring: {
497
+ type: "object",
498
+ required: ["projectId"],
499
+ properties: {
500
+ projectId: { type: "string", minLength: 1 },
501
+ force: { type: "string", enum: ["0", "1", "true", "false"] },
502
+ },
503
+ },
504
+ response: {
505
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
506
+ 400: errorSchema,
507
+ 404: errorSchema,
508
+ 500: errorSchema,
509
+ },
510
+ },
511
+ }, async (req, reply) => {
512
+ const project = await resolveProject(req.query.projectId, reply);
513
+ if (project === undefined)
514
+ return reply;
515
+ try {
516
+ const force = req.query.force === "1" || req.query.force === "true";
517
+ await deleteBranch(project.path, req.params.name, { force });
518
+ return { ok: true };
519
+ }
520
+ catch (err) {
521
+ return mapError(reply, err);
522
+ }
523
+ });
524
+ fastify.post("/git/stage", {
525
+ schema: {
526
+ description: "Stage one or more files (`git add -- <paths>`).",
527
+ tags: ["git"],
528
+ body: {
529
+ type: "object",
530
+ required: ["projectId", "paths"],
531
+ additionalProperties: false,
532
+ properties: {
533
+ projectId: { type: "string", minLength: 1 },
534
+ paths: {
535
+ type: "array",
536
+ items: { type: "string", minLength: 1, maxLength: 4096 },
537
+ minItems: 1,
538
+ // Hard cap to keep argv under typical OS limits (~128KB on
539
+ // Linux). Single-tenant + trusted user, so this is mostly
540
+ // a guard against accidental loops or oversized JSON
541
+ // bodies; legitimate stage/unstage/revert operations on
542
+ // even a very wide repo land well under 1000 paths.
543
+ maxItems: 1000,
544
+ },
545
+ },
546
+ },
547
+ response: {
548
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
549
+ 400: errorSchema,
550
+ 404: errorSchema,
551
+ 500: errorSchema,
552
+ },
553
+ },
554
+ }, async (req, reply) => {
555
+ const project = await resolveProject(req.body.projectId, reply);
556
+ if (project === undefined)
557
+ return reply;
558
+ try {
559
+ await stagePaths(project.path, req.body.paths);
560
+ return { ok: true };
561
+ }
562
+ catch (err) {
563
+ return mapError(reply, err);
564
+ }
565
+ });
566
+ fastify.post("/git/unstage", {
567
+ schema: {
568
+ description: "Unstage one or more files (`git restore --staged -- <paths>`).",
569
+ tags: ["git"],
570
+ body: {
571
+ type: "object",
572
+ required: ["projectId", "paths"],
573
+ additionalProperties: false,
574
+ properties: {
575
+ projectId: { type: "string", minLength: 1 },
576
+ paths: {
577
+ type: "array",
578
+ items: { type: "string", minLength: 1, maxLength: 4096 },
579
+ minItems: 1,
580
+ // Hard cap to keep argv under typical OS limits (~128KB on
581
+ // Linux). Single-tenant + trusted user, so this is mostly
582
+ // a guard against accidental loops or oversized JSON
583
+ // bodies; legitimate stage/unstage/revert operations on
584
+ // even a very wide repo land well under 1000 paths.
585
+ maxItems: 1000,
586
+ },
587
+ },
588
+ },
589
+ response: {
590
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
591
+ 400: errorSchema,
592
+ 404: errorSchema,
593
+ 500: errorSchema,
594
+ },
595
+ },
596
+ }, async (req, reply) => {
597
+ const project = await resolveProject(req.body.projectId, reply);
598
+ if (project === undefined)
599
+ return reply;
600
+ try {
601
+ await unstagePaths(project.path, req.body.paths);
602
+ return { ok: true };
603
+ }
604
+ catch (err) {
605
+ return mapError(reply, err);
606
+ }
607
+ });
608
+ fastify.post("/git/revert", {
609
+ schema: {
610
+ description: "Discard local changes for the given files via `git restore " +
611
+ "--staged --worktree --source=HEAD`. Restores both the index " +
612
+ "and the working tree to HEAD — destructive, the caller is " +
613
+ "expected to gate behind a confirmation. Untracked files " +
614
+ "produce a 400 with git's stderr ('pathspec did not match'); " +
615
+ "delete those via /files/delete instead.",
616
+ tags: ["git"],
617
+ body: {
618
+ type: "object",
619
+ required: ["projectId", "paths"],
620
+ additionalProperties: false,
621
+ properties: {
622
+ projectId: { type: "string", minLength: 1 },
623
+ paths: {
624
+ type: "array",
625
+ items: { type: "string", minLength: 1, maxLength: 4096 },
626
+ minItems: 1,
627
+ // Hard cap to keep argv under typical OS limits (~128KB on
628
+ // Linux). Single-tenant + trusted user, so this is mostly
629
+ // a guard against accidental loops or oversized JSON
630
+ // bodies; legitimate stage/unstage/revert operations on
631
+ // even a very wide repo land well under 1000 paths.
632
+ maxItems: 1000,
633
+ },
634
+ },
635
+ },
636
+ response: {
637
+ 200: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
638
+ 400: errorSchema,
639
+ 404: errorSchema,
640
+ 500: errorSchema,
641
+ },
642
+ },
643
+ }, async (req, reply) => {
644
+ const project = await resolveProject(req.body.projectId, reply);
645
+ if (project === undefined)
646
+ return reply;
647
+ try {
648
+ await revertPaths(project.path, req.body.paths);
649
+ return { ok: true };
650
+ }
651
+ catch (err) {
652
+ return mapError(reply, err);
653
+ }
654
+ });
655
+ fastify.post("/git/commit", {
656
+ schema: {
657
+ description: "Commit the currently-staged changes. Pre-commit hooks fire as " +
658
+ "normal — `--no-verify` is intentionally NOT used so browser " +
659
+ "commits gate the same way terminal commits do.",
660
+ tags: ["git"],
661
+ body: {
662
+ type: "object",
663
+ required: ["projectId", "message"],
664
+ additionalProperties: false,
665
+ properties: {
666
+ projectId: { type: "string", minLength: 1 },
667
+ message: { type: "string", minLength: 1 },
668
+ },
669
+ },
670
+ response: {
671
+ 200: {
672
+ type: "object",
673
+ required: ["hash"],
674
+ properties: { hash: { type: "string" } },
675
+ },
676
+ 400: errorSchema,
677
+ 404: errorSchema,
678
+ 500: errorSchema,
679
+ },
680
+ },
681
+ }, async (req, reply) => {
682
+ const project = await resolveProject(req.body.projectId, reply);
683
+ if (project === undefined)
684
+ return reply;
685
+ const message = req.body.message.trim();
686
+ if (message.length === 0) {
687
+ return reply.code(400).send({ error: "empty_message" });
688
+ }
689
+ try {
690
+ return await commit(project.path, message);
691
+ }
692
+ catch (err) {
693
+ return mapError(reply, err);
694
+ }
695
+ });
696
+ fastify.post("/git/fetch", {
697
+ schema: {
698
+ description: "git fetch — never touches the working tree, safe regardless of " +
699
+ "dirty state. `prune: true` adds --prune so deleted upstream " +
700
+ "branches are removed locally. Returns the captured output.",
701
+ tags: ["git"],
702
+ body: {
703
+ type: "object",
704
+ required: ["projectId"],
705
+ additionalProperties: false,
706
+ properties: {
707
+ projectId: { type: "string", minLength: 1 },
708
+ remote: { type: "string", minLength: 1 },
709
+ prune: { type: "boolean" },
710
+ },
711
+ },
712
+ response: {
713
+ 200: { type: "object", required: ["output"], properties: { output: { type: "string" } } },
714
+ 400: errorSchema,
715
+ 404: errorSchema,
716
+ 500: errorSchema,
717
+ },
718
+ },
719
+ }, async (req, reply) => {
720
+ const project = await resolveProject(req.body.projectId, reply);
721
+ if (project === undefined)
722
+ return reply;
723
+ try {
724
+ const opts = {};
725
+ if (req.body.remote !== undefined)
726
+ opts.remote = req.body.remote;
727
+ if (req.body.prune !== undefined)
728
+ opts.prune = req.body.prune;
729
+ const { stdout } = await fetch(project.path, opts);
730
+ return { output: stdout };
731
+ }
732
+ catch (err) {
733
+ return mapError(reply, err);
734
+ }
735
+ });
736
+ fastify.post("/git/pull", {
737
+ schema: {
738
+ description: "git pull — fetches AND merges (or rebases with `rebase: true`). " +
739
+ "Conflicts are surfaced verbatim in the 400 message; the user can " +
740
+ "drop to the integrated terminal to resolve. No conflict-resolution " +
741
+ "UI in v1.",
742
+ tags: ["git"],
743
+ body: {
744
+ type: "object",
745
+ required: ["projectId"],
746
+ additionalProperties: false,
747
+ properties: {
748
+ projectId: { type: "string", minLength: 1 },
749
+ remote: { type: "string", minLength: 1 },
750
+ branch: { type: "string", minLength: 1 },
751
+ rebase: { type: "boolean" },
752
+ },
753
+ },
754
+ response: {
755
+ 200: { type: "object", required: ["output"], properties: { output: { type: "string" } } },
756
+ 400: errorSchema,
757
+ 404: errorSchema,
758
+ 500: errorSchema,
759
+ },
760
+ },
761
+ }, async (req, reply) => {
762
+ const project = await resolveProject(req.body.projectId, reply);
763
+ if (project === undefined)
764
+ return reply;
765
+ try {
766
+ const opts = {};
767
+ if (req.body.remote !== undefined)
768
+ opts.remote = req.body.remote;
769
+ if (req.body.branch !== undefined)
770
+ opts.branch = req.body.branch;
771
+ if (req.body.rebase !== undefined)
772
+ opts.rebase = req.body.rebase;
773
+ const { stdout } = await pull(project.path, opts);
774
+ return { output: stdout };
775
+ }
776
+ catch (err) {
777
+ return mapError(reply, err);
778
+ }
779
+ });
780
+ fastify.post("/git/push", {
781
+ config: {
782
+ rateLimit: {
783
+ max: config.rateLimits.pushMax,
784
+ timeWindow: config.rateLimits.pushWindowMs,
785
+ },
786
+ },
787
+ schema: {
788
+ description: "Push to a remote. With no `remote`/`branch` body fields, runs " +
789
+ "plain `git push` against the configured upstream. `setUpstream: " +
790
+ "true` adds `--set-upstream` so the remote/branch is recorded as " +
791
+ "the tracking ref (required on first push of a new local branch). " +
792
+ "Returns 400 with git's stderr message on failure (no upstream set, " +
793
+ "auth refused, rejected non-fast-forward, etc.).",
794
+ tags: ["git"],
795
+ body: {
796
+ type: "object",
797
+ required: ["projectId"],
798
+ additionalProperties: false,
799
+ properties: {
800
+ projectId: { type: "string", minLength: 1 },
801
+ remote: { type: "string", minLength: 1 },
802
+ branch: { type: "string", minLength: 1 },
803
+ setUpstream: { type: "boolean" },
804
+ },
805
+ },
806
+ response: {
807
+ 200: {
808
+ type: "object",
809
+ required: ["output"],
810
+ properties: { output: { type: "string" } },
811
+ },
812
+ 400: errorSchema,
813
+ 404: errorSchema,
814
+ 500: errorSchema,
815
+ },
816
+ },
817
+ }, async (req, reply) => {
818
+ const project = await resolveProject(req.body.projectId, reply);
819
+ if (project === undefined)
820
+ return reply;
821
+ try {
822
+ const opts = {};
823
+ if (req.body.remote !== undefined)
824
+ opts.remote = req.body.remote;
825
+ if (req.body.branch !== undefined)
826
+ opts.branch = req.body.branch;
827
+ if (req.body.setUpstream !== undefined)
828
+ opts.setUpstream = req.body.setUpstream;
829
+ const { stdout } = await push(project.path, opts);
830
+ return { output: stdout };
831
+ }
832
+ catch (err) {
833
+ return mapError(reply, err);
834
+ }
835
+ });
836
+ };
837
+ //# sourceMappingURL=git.js.map