@neuralnomads/codenomad-dev 0.13.3-dev-20260413-1ce58b9d → 0.14.0-dev-20260417-9bf4d351
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +22 -0
- package/dist/plugins/voice-mode.js +2 -1
- package/dist/server/http-server.js +5 -45
- package/dist/server/routes/plugin.js +5 -1
- package/dist/server/routes/workspaces.js +118 -0
- package/dist/workspaces/git-mutations.js +98 -0
- package/dist/workspaces/git-status.js +323 -0
- package/dist/workspaces/git-worktrees.js +18 -1
- package/dist/workspaces/worktree-directory.js +74 -0
- package/package.json +1 -1
- package/public/assets/{ChangesTab-CFkk3tAp.js → ChangesTab-DEgBUjVz.js} +2 -2
- package/public/assets/{DiffToolbar-CQQLR1mp.js → DiffToolbar-C0C8bWki.js} +1 -1
- package/public/assets/{FilesTab-CXgucPtN.js → FilesTab-B9WaUTzy.js} +2 -2
- package/public/assets/GitChangesTab-C2Asw384.js +2 -0
- package/public/assets/{SplitFilePanel-CupweEA5.js → SplitFilePanel-VxErPoCs.js} +1 -1
- package/public/assets/StatusTab-DTCwmZhf.js +1 -0
- package/public/assets/{bundle-full-B-5u6Iv3.js → bundle-full-CZ8BwlPh.js} +1 -1
- package/public/assets/{diff-viewer-_ehhYA-Z.js → diff-viewer-Djp9avVa.js} +1 -1
- package/public/assets/index-Br2gh9Q_.js +1 -0
- package/public/assets/index-CCdacqcD.js +1 -0
- package/public/assets/index-CMpU59Nd.js +1 -0
- package/public/assets/index-CP2CRaiZ.js +2 -0
- package/public/assets/{index-CxQr-wOF.js → index-CS8igigR.js} +1 -1
- package/public/assets/index-Cf8hOS2M.js +1 -0
- package/public/assets/index-CiwaMGVS.js +1 -0
- package/public/assets/{index-BwCEJ4BE.css → index-D6aXme8N.css} +1 -1
- package/public/assets/index-Dwg_Dz3e.js +1 -0
- package/public/assets/{index-BqiNjV6R.js → index-gOb9MvU2.js} +1 -1
- package/public/assets/{loading-Dck2H3XH.js → loading-Cr_BWqKy.js} +1 -1
- package/public/assets/main-CBLtYtDo.js +48 -0
- package/public/assets/{markdown-CFV9kR80.js → markdown-c_rVsd2S.js} +3 -3
- package/public/assets/monaco-viewer-DrmF2KUz.js +25 -0
- package/public/assets/{todo-Ghwirj-b.js → todo-DXEegYgH.js} +1 -1
- package/public/assets/{tool-call-BC2Pv3uG.js → tool-call-CTg71hze.js} +3 -3
- package/public/assets/{unified-picker-Dw0kTXoT.js → unified-picker-BgW5kaQo.js} +1 -1
- package/public/assets/{wrap-text-D2MJk2ad.js → wrap-text-DKmMMyWB.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/ui-version.json +1 -1
- package/public/assets/GitChangesTab-BidSb28_.js +0 -2
- package/public/assets/StatusTab-C2Pl_Vce.js +0 -1
- package/public/assets/index-BnfDkr8C.js +0 -1
- package/public/assets/index-BntAaLZs.js +0 -1
- package/public/assets/index-DXaitXVC.js +0 -1
- package/public/assets/index-DzRzoTK8.js +0 -2
- package/public/assets/index-ShHad48G.js +0 -1
- package/public/assets/index-_EaXFUIj.js +0 -1
- package/public/assets/index-mMh5TJvW.js +0 -1
- package/public/assets/main-BsIf5lOY.js +0 -50
- package/public/assets/monaco-viewer-CMM6n0T3.js +0 -25
package/dist/index.js
CHANGED
|
@@ -24,6 +24,9 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ
|
|
|
24
24
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor";
|
|
25
25
|
import { SpeechService } from "./speech/service";
|
|
26
26
|
import { SideCarManager } from "./sidecars/manager";
|
|
27
|
+
import { ClientConnectionManager } from "./clients/connection-manager";
|
|
28
|
+
import { PluginChannelManager } from "./plugins/channel";
|
|
29
|
+
import { VoiceModeManager } from "./plugins/voice-mode";
|
|
27
30
|
const require = createRequire(import.meta.url);
|
|
28
31
|
const packageJson = require("../package.json");
|
|
29
32
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -263,6 +266,13 @@ async function main() {
|
|
|
263
266
|
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true");
|
|
264
267
|
}
|
|
265
268
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host);
|
|
269
|
+
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }));
|
|
270
|
+
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }));
|
|
271
|
+
const voiceModeManager = new VoiceModeManager({
|
|
272
|
+
connections: clientConnectionManager,
|
|
273
|
+
channel: pluginChannel,
|
|
274
|
+
logger: logger.child({ component: "voice-mode" }),
|
|
275
|
+
});
|
|
266
276
|
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT);
|
|
267
277
|
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT);
|
|
268
278
|
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0;
|
|
@@ -289,6 +299,9 @@ async function main() {
|
|
|
289
299
|
speechService,
|
|
290
300
|
sidecarManager,
|
|
291
301
|
authManager,
|
|
302
|
+
clientConnectionManager,
|
|
303
|
+
pluginChannel,
|
|
304
|
+
voiceModeManager,
|
|
292
305
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
|
293
306
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
|
294
307
|
logger,
|
|
@@ -310,6 +323,9 @@ async function main() {
|
|
|
310
323
|
speechService,
|
|
311
324
|
sidecarManager,
|
|
312
325
|
authManager,
|
|
326
|
+
clientConnectionManager,
|
|
327
|
+
pluginChannel,
|
|
328
|
+
voiceModeManager,
|
|
313
329
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
|
314
330
|
uiDevServerUrl: undefined,
|
|
315
331
|
logger,
|
|
@@ -405,6 +421,12 @@ async function main() {
|
|
|
405
421
|
catch (error) {
|
|
406
422
|
logger.error({ err: error }, "SideCar manager shutdown failed");
|
|
407
423
|
}
|
|
424
|
+
try {
|
|
425
|
+
clientConnectionManager.shutdown();
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
logger.warn({ err: error }, "Client connection manager shutdown failed");
|
|
429
|
+
}
|
|
408
430
|
try {
|
|
409
431
|
await workspaceManager.shutdown();
|
|
410
432
|
logger.info("Workspace manager shutdown complete");
|
|
@@ -12,7 +12,7 @@ export class VoiceModeManager {
|
|
|
12
12
|
setEnabled(instanceId, connection, enabled) {
|
|
13
13
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
|
14
14
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, "Ignoring voice mode enable for disconnected client connection");
|
|
15
|
-
return;
|
|
15
|
+
return false;
|
|
16
16
|
}
|
|
17
17
|
const key = getConnectionKey(connection);
|
|
18
18
|
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set();
|
|
@@ -30,6 +30,7 @@ export class VoiceModeManager {
|
|
|
30
30
|
}
|
|
31
31
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection");
|
|
32
32
|
this.publishIfChanged(instanceId);
|
|
33
|
+
return true;
|
|
33
34
|
}
|
|
34
35
|
syncInstance(instanceId) {
|
|
35
36
|
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)));
|
|
@@ -7,7 +7,8 @@ import { connect as connectTcp } from "net";
|
|
|
7
7
|
import path from "path";
|
|
8
8
|
import { connect as connectTls } from "tls";
|
|
9
9
|
import { fetch } from "undici";
|
|
10
|
-
import { isValidWorktreeSlug
|
|
10
|
+
import { isValidWorktreeSlug } from "../workspaces/git-worktrees";
|
|
11
|
+
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory";
|
|
11
12
|
import { registerWorkspaceRoutes } from "./routes/workspaces";
|
|
12
13
|
import { registerSettingsRoutes } from "./routes/settings";
|
|
13
14
|
import { registerFilesystemRoutes } from "./routes/filesystem";
|
|
@@ -23,9 +24,6 @@ import { registerSideCarRoutes } from "./routes/sidecars";
|
|
|
23
24
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
24
25
|
import { registerAuthRoutes } from "./routes/auth";
|
|
25
26
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
|
|
26
|
-
import { ClientConnectionManager } from "../clients/connection-manager";
|
|
27
|
-
import { PluginChannelManager } from "../plugins/channel";
|
|
28
|
-
import { VoiceModeManager } from "../plugins/voice-mode";
|
|
29
27
|
export function createHttpServer(deps) {
|
|
30
28
|
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
|
31
29
|
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
|
@@ -132,13 +130,6 @@ export function createHttpServer(deps) {
|
|
|
132
130
|
eventBus: deps.eventBus,
|
|
133
131
|
logger: deps.logger.child({ component: "background-processes" }),
|
|
134
132
|
});
|
|
135
|
-
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }));
|
|
136
|
-
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }));
|
|
137
|
-
const voiceModeManager = new VoiceModeManager({
|
|
138
|
-
connections: clientConnectionManager,
|
|
139
|
-
channel: pluginChannel,
|
|
140
|
-
logger: deps.logger.child({ component: "voice-mode" }),
|
|
141
|
-
});
|
|
142
133
|
registerAuthRoutes(app, { authManager: deps.authManager });
|
|
143
134
|
app.addHook("preHandler", (request, reply, done) => {
|
|
144
135
|
const rawUrl = request.raw.url ?? request.url;
|
|
@@ -203,7 +194,7 @@ export function createHttpServer(deps) {
|
|
|
203
194
|
eventBus: deps.eventBus,
|
|
204
195
|
registerClient: registerSseClient,
|
|
205
196
|
logger: sseLogger,
|
|
206
|
-
connectionManager: clientConnectionManager,
|
|
197
|
+
connectionManager: deps.clientConnectionManager,
|
|
207
198
|
});
|
|
208
199
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager });
|
|
209
200
|
registerStorageRoutes(app, {
|
|
@@ -224,8 +215,8 @@ export function createHttpServer(deps) {
|
|
|
224
215
|
workspaceManager: deps.workspaceManager,
|
|
225
216
|
eventBus: deps.eventBus,
|
|
226
217
|
logger: proxyLogger,
|
|
227
|
-
channel: pluginChannel,
|
|
228
|
-
voiceModeManager,
|
|
218
|
+
channel: deps.pluginChannel,
|
|
219
|
+
voiceModeManager: deps.voiceModeManager,
|
|
229
220
|
});
|
|
230
221
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager });
|
|
231
222
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
|
|
@@ -284,7 +275,6 @@ export function createHttpServer(deps) {
|
|
|
284
275
|
},
|
|
285
276
|
stop: () => {
|
|
286
277
|
closeSseClients();
|
|
287
|
-
clientConnectionManager.shutdown();
|
|
288
278
|
return app.close();
|
|
289
279
|
},
|
|
290
280
|
};
|
|
@@ -613,36 +603,6 @@ function normalizeInstanceSuffix(pathSuffix) {
|
|
|
613
603
|
const trimmed = pathSuffix.replace(/^\/+/, "");
|
|
614
604
|
return trimmed.length === 0 ? "/" : `/${trimmed}`;
|
|
615
605
|
}
|
|
616
|
-
const WORKTREE_CACHE_TTL_MS = 2000;
|
|
617
|
-
const worktreeCache = new Map();
|
|
618
|
-
async function getCachedWorktrees(params) {
|
|
619
|
-
const cached = worktreeCache.get(params.workspaceId);
|
|
620
|
-
const now = Date.now();
|
|
621
|
-
if (cached && cached.expiresAt > now) {
|
|
622
|
-
return cached;
|
|
623
|
-
}
|
|
624
|
-
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger);
|
|
625
|
-
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger });
|
|
626
|
-
const entry = {
|
|
627
|
-
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
|
628
|
-
repoRoot,
|
|
629
|
-
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
|
630
|
-
};
|
|
631
|
-
worktreeCache.set(params.workspaceId, entry);
|
|
632
|
-
return entry;
|
|
633
|
-
}
|
|
634
|
-
async function resolveWorktreeDirectory(params) {
|
|
635
|
-
const { worktreeSlug } = params;
|
|
636
|
-
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger });
|
|
637
|
-
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug);
|
|
638
|
-
if (match) {
|
|
639
|
-
return match.directory;
|
|
640
|
-
}
|
|
641
|
-
// If the slug is new (e.g., created moments ago), refresh once.
|
|
642
|
-
worktreeCache.delete(params.workspaceId);
|
|
643
|
-
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger });
|
|
644
|
-
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null;
|
|
645
|
-
}
|
|
646
606
|
function setupStaticUi(app, uiDir, authManager) {
|
|
647
607
|
if (!uiDir) {
|
|
648
608
|
app.log.warn("UI static directory not provided; API endpoints only");
|
|
@@ -41,7 +41,11 @@ export function registerPluginRoutes(app, deps) {
|
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const payload = VoiceModeStateSchema.parse(request.body ?? {});
|
|
44
|
-
deps.voiceModeManager.setEnabled(request.params.id, { clientId: payload.clientId, connectionId: payload.connectionId }, payload.enabled);
|
|
44
|
+
const applied = deps.voiceModeManager.setEnabled(request.params.id, { clientId: payload.clientId, connectionId: payload.connectionId }, payload.enabled);
|
|
45
|
+
if (payload.enabled && !applied) {
|
|
46
|
+
reply.code(409).send({ error: "Client connection not active for voice mode enable" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
45
49
|
return { enabled: payload.enabled };
|
|
46
50
|
});
|
|
47
51
|
const handleWildcard = async (request, reply) => {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status";
|
|
3
|
+
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations";
|
|
4
|
+
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees";
|
|
5
|
+
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory";
|
|
2
6
|
const WorkspaceCreateSchema = z.object({
|
|
3
7
|
path: z.string(),
|
|
4
8
|
name: z.string().optional(),
|
|
@@ -12,6 +16,17 @@ const WorkspaceFileContentQuerySchema = z.object({
|
|
|
12
16
|
const WorkspaceFileContentBodySchema = z.object({
|
|
13
17
|
contents: z.string(),
|
|
14
18
|
});
|
|
19
|
+
const WorktreeGitDiffQuerySchema = z.object({
|
|
20
|
+
path: z.string().trim().min(1, "Path is required"),
|
|
21
|
+
originalPath: z.string().trim().optional(),
|
|
22
|
+
scope: z.enum(["staged", "unstaged"]),
|
|
23
|
+
});
|
|
24
|
+
const WorktreeGitPathsBodySchema = z.object({
|
|
25
|
+
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
|
|
26
|
+
});
|
|
27
|
+
const WorktreeGitCommitBodySchema = z.object({
|
|
28
|
+
message: z.string().trim().min(1, "Commit message is required"),
|
|
29
|
+
});
|
|
15
30
|
const WorkspaceFileSearchQuerySchema = z.object({
|
|
16
31
|
q: z.string().trim().min(1, "Query is required"),
|
|
17
32
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
|
@@ -92,8 +107,111 @@ export function registerWorkspaceRoutes(app, deps) {
|
|
|
92
107
|
return handleWorkspaceError(error, reply);
|
|
93
108
|
}
|
|
94
109
|
});
|
|
110
|
+
app.get("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
|
|
111
|
+
try {
|
|
112
|
+
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
|
|
113
|
+
if (!directory)
|
|
114
|
+
return;
|
|
115
|
+
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return handleWorkspaceError(error, reply);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
app.get("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
|
|
122
|
+
try {
|
|
123
|
+
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {});
|
|
124
|
+
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
|
|
125
|
+
if (!directory)
|
|
126
|
+
return;
|
|
127
|
+
return await getWorktreeGitDiff({
|
|
128
|
+
workspaceFolder: directory,
|
|
129
|
+
path: query.path,
|
|
130
|
+
originalPath: query.originalPath,
|
|
131
|
+
scope: query.scope,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return handleWorkspaceError(error, reply);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
app.post("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
|
|
139
|
+
try {
|
|
140
|
+
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {});
|
|
141
|
+
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
|
|
142
|
+
if (!directory)
|
|
143
|
+
return;
|
|
144
|
+
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths });
|
|
145
|
+
return { ok: true };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
return handleWorkspaceError(error, reply);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
app.post("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
|
|
152
|
+
try {
|
|
153
|
+
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {});
|
|
154
|
+
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
|
|
155
|
+
if (!directory)
|
|
156
|
+
return;
|
|
157
|
+
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths });
|
|
158
|
+
return { ok: true };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return handleWorkspaceError(error, reply);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
app.post("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
|
|
165
|
+
try {
|
|
166
|
+
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {});
|
|
167
|
+
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
|
|
168
|
+
if (!directory)
|
|
169
|
+
return;
|
|
170
|
+
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message });
|
|
171
|
+
return { ok: true, ...result };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
return handleWorkspaceError(error, reply);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function resolveGitWorktreeDirectory(workspaceManager, workspaceId, worktreeSlug, logger, reply) {
|
|
179
|
+
const workspace = workspaceManager.get(workspaceId);
|
|
180
|
+
if (!workspace) {
|
|
181
|
+
reply.code(404);
|
|
182
|
+
reply.send({ error: "Workspace not found" });
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const gitAvailable = await isGitAvailable(workspace.path);
|
|
186
|
+
if (!gitAvailable) {
|
|
187
|
+
reply.code(503);
|
|
188
|
+
reply.send({ error: "Git is not installed or not available in PATH" });
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger);
|
|
192
|
+
if (!isGitRepo) {
|
|
193
|
+
reply.code(400);
|
|
194
|
+
reply.send({ error: "Workspace is not a Git repository" });
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const directory = await resolveWorktreeDirectory({
|
|
198
|
+
workspaceId: workspace.id,
|
|
199
|
+
workspacePath: workspace.path,
|
|
200
|
+
worktreeSlug,
|
|
201
|
+
logger,
|
|
202
|
+
});
|
|
203
|
+
if (!directory) {
|
|
204
|
+
reply.code(404);
|
|
205
|
+
reply.send({ error: "Worktree not found" });
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return directory;
|
|
95
209
|
}
|
|
96
210
|
function handleWorkspaceError(error, reply) {
|
|
211
|
+
if (isGitMutationError(error)) {
|
|
212
|
+
reply.code(error.statusCode);
|
|
213
|
+
return { error: error.message };
|
|
214
|
+
}
|
|
97
215
|
if (error instanceof Error && error.message === "Workspace not found") {
|
|
98
216
|
reply.code(404);
|
|
99
217
|
return { error: "Workspace not found" };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
class GitMutationError extends Error {
|
|
4
|
+
constructor(message, statusCode = 400) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "GitMutationError";
|
|
7
|
+
this.statusCode = statusCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function runGit(args, cwd) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
let stdout = "";
|
|
14
|
+
let stderr = "";
|
|
15
|
+
child.stdout?.on("data", (chunk) => {
|
|
16
|
+
stdout += chunk.toString();
|
|
17
|
+
});
|
|
18
|
+
child.stderr?.on("data", (chunk) => {
|
|
19
|
+
stderr += chunk.toString();
|
|
20
|
+
});
|
|
21
|
+
child.once("error", (error) => {
|
|
22
|
+
resolve({ ok: false, error, stdout, stderr });
|
|
23
|
+
});
|
|
24
|
+
child.once("close", (code) => {
|
|
25
|
+
if (code === 0) {
|
|
26
|
+
resolve({ ok: true, stdout });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`);
|
|
30
|
+
resolve({ ok: false, error, stdout, stderr });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function normalizeGitWorktreeRelativePath(input) {
|
|
36
|
+
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "");
|
|
37
|
+
if (!normalized) {
|
|
38
|
+
throw new GitMutationError("Path is required", 400);
|
|
39
|
+
}
|
|
40
|
+
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
|
41
|
+
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400);
|
|
42
|
+
}
|
|
43
|
+
if (normalized === "." || normalized === "..") {
|
|
44
|
+
throw new GitMutationError(`Invalid path: ${input}`, 400);
|
|
45
|
+
}
|
|
46
|
+
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
|
|
47
|
+
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
function normalizeGitMutationPaths(paths) {
|
|
52
|
+
const deduped = new Set();
|
|
53
|
+
for (const rawPath of paths) {
|
|
54
|
+
deduped.add(normalizeGitWorktreeRelativePath(rawPath));
|
|
55
|
+
}
|
|
56
|
+
const normalized = Array.from(deduped);
|
|
57
|
+
if (normalized.length === 0) {
|
|
58
|
+
throw new GitMutationError("At least one path is required", 400);
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
async function ensureGitCommandSucceeded(resultPromise, fallbackMessage) {
|
|
63
|
+
const result = await resultPromise;
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
const message = result.stderr?.trim() || result.error.message || fallbackMessage;
|
|
66
|
+
throw new GitMutationError(message, 409);
|
|
67
|
+
}
|
|
68
|
+
return result.stdout;
|
|
69
|
+
}
|
|
70
|
+
export function isGitMutationError(error) {
|
|
71
|
+
return error instanceof GitMutationError;
|
|
72
|
+
}
|
|
73
|
+
export async function stageWorktreePaths(params) {
|
|
74
|
+
const paths = normalizeGitMutationPaths(params.paths);
|
|
75
|
+
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files");
|
|
76
|
+
}
|
|
77
|
+
export async function unstageWorktreePaths(params) {
|
|
78
|
+
const paths = normalizeGitMutationPaths(params.paths);
|
|
79
|
+
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder);
|
|
80
|
+
if (headResult.ok) {
|
|
81
|
+
await ensureGitCommandSucceeded(runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder), "Failed to unstage files");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await ensureGitCommandSucceeded(runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder), "Failed to unstage files");
|
|
85
|
+
}
|
|
86
|
+
export async function commitWorktreeChanges(params) {
|
|
87
|
+
const message = params.message.trim();
|
|
88
|
+
if (!message) {
|
|
89
|
+
throw new GitMutationError("Commit message is required", 400);
|
|
90
|
+
}
|
|
91
|
+
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit");
|
|
92
|
+
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder);
|
|
93
|
+
if (!shaResult.ok) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
const commitSha = shaResult.stdout.trim();
|
|
97
|
+
return commitSha ? { commitSha } : {};
|
|
98
|
+
}
|