git-daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +18 -0
- package/README.md +143 -0
- package/config.schema.json +180 -0
- package/design.md +481 -0
- package/logo.png +0 -0
- package/openapi.yaml +678 -0
- package/package.json +41 -0
- package/src/app.ts +459 -0
- package/src/approvals.ts +35 -0
- package/src/config.ts +104 -0
- package/src/context.ts +64 -0
- package/src/daemon.ts +22 -0
- package/src/deps.ts +134 -0
- package/src/errors.ts +76 -0
- package/src/git.ts +160 -0
- package/src/jobs.ts +194 -0
- package/src/logger.ts +26 -0
- package/src/os.ts +45 -0
- package/src/pairing.ts +52 -0
- package/src/process.ts +55 -0
- package/src/security.ts +80 -0
- package/src/tokens.ts +95 -0
- package/src/tools.ts +45 -0
- package/src/types.ts +111 -0
- package/src/typings/tree-kill.d.ts +9 -0
- package/src/validation.ts +69 -0
- package/src/workspace.ts +83 -0
- package/tests/app.test.ts +122 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import type { Logger } from "pino";
|
|
4
|
+
import type { Request, Response, NextFunction } from "express";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { createHttpLogger } from "./logger";
|
|
8
|
+
import type { AppConfig, Capabilities } from "./types";
|
|
9
|
+
import {
|
|
10
|
+
authGuard,
|
|
11
|
+
getOrigin,
|
|
12
|
+
hostGuard,
|
|
13
|
+
loopbackGuard,
|
|
14
|
+
originGuard,
|
|
15
|
+
} from "./security";
|
|
16
|
+
import {
|
|
17
|
+
errorBody,
|
|
18
|
+
ApiError,
|
|
19
|
+
internalError,
|
|
20
|
+
jobNotFound,
|
|
21
|
+
pathNotFound,
|
|
22
|
+
rateLimited,
|
|
23
|
+
repoNotFound,
|
|
24
|
+
} from "./errors";
|
|
25
|
+
import {
|
|
26
|
+
pairRequestSchema,
|
|
27
|
+
gitCloneRequestSchema,
|
|
28
|
+
gitFetchRequestSchema,
|
|
29
|
+
gitStatusQuerySchema,
|
|
30
|
+
osOpenRequestSchema,
|
|
31
|
+
depsInstallRequestSchema,
|
|
32
|
+
} from "./validation";
|
|
33
|
+
import {
|
|
34
|
+
ensureWorkspaceRoot,
|
|
35
|
+
resolveInsideWorkspace,
|
|
36
|
+
ensureRelative,
|
|
37
|
+
MissingPathError,
|
|
38
|
+
} from "./workspace";
|
|
39
|
+
import {
|
|
40
|
+
cloneRepo,
|
|
41
|
+
fetchRepo,
|
|
42
|
+
getRepoStatus,
|
|
43
|
+
RepoNotFoundError,
|
|
44
|
+
resolveRepoPath,
|
|
45
|
+
} from "./git";
|
|
46
|
+
import { installDeps } from "./deps";
|
|
47
|
+
import { openTarget } from "./os";
|
|
48
|
+
import type { TokenStore } from "./tokens";
|
|
49
|
+
import type { PairingManager } from "./pairing";
|
|
50
|
+
import type { JobManager } from "./jobs";
|
|
51
|
+
import { requireApproval } from "./approvals";
|
|
52
|
+
|
|
53
|
+
export type DaemonContext = {
|
|
54
|
+
config: AppConfig;
|
|
55
|
+
configDir: string;
|
|
56
|
+
tokenStore: TokenStore;
|
|
57
|
+
pairingManager: PairingManager;
|
|
58
|
+
jobManager: JobManager;
|
|
59
|
+
capabilities: Capabilities;
|
|
60
|
+
logger: Logger;
|
|
61
|
+
version: string;
|
|
62
|
+
build?: { commit?: string; date?: string };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const parseBody = <T>(
|
|
66
|
+
schema: {
|
|
67
|
+
safeParse: (
|
|
68
|
+
input: unknown,
|
|
69
|
+
) =>
|
|
70
|
+
| { success: true; data: T }
|
|
71
|
+
| { success: false; error: { issues: { path: (string | number)[] }[] } };
|
|
72
|
+
},
|
|
73
|
+
body: unknown,
|
|
74
|
+
opts?: { repoUrl?: boolean },
|
|
75
|
+
): T => {
|
|
76
|
+
const parsed = schema.safeParse(body);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
if (
|
|
79
|
+
opts?.repoUrl &&
|
|
80
|
+
parsed.error.issues.some((issue) => issue.path.includes("repoUrl"))
|
|
81
|
+
) {
|
|
82
|
+
throw new ApiError(
|
|
83
|
+
422,
|
|
84
|
+
errorBody("invalid_repo_url", "Repository URL is invalid."),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
throw new ApiError(422, errorBody("internal_error", "Invalid input."));
|
|
88
|
+
}
|
|
89
|
+
return parsed.data;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const rateLimitHandler = (_req: Request, res: Response) => {
|
|
93
|
+
const body = rateLimited().body;
|
|
94
|
+
res.status(429).json(body);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const createApp = (ctx: DaemonContext) => {
|
|
98
|
+
const app = express();
|
|
99
|
+
app.disable("x-powered-by");
|
|
100
|
+
|
|
101
|
+
app.use(createHttpLogger(ctx.logger));
|
|
102
|
+
app.use(express.json({ limit: "256kb" }));
|
|
103
|
+
app.use(loopbackGuard());
|
|
104
|
+
app.use(hostGuard());
|
|
105
|
+
app.use(originGuard(ctx.config.originAllowlist));
|
|
106
|
+
|
|
107
|
+
app.use(
|
|
108
|
+
"/v1",
|
|
109
|
+
rateLimit({
|
|
110
|
+
windowMs: 5 * 60 * 1000,
|
|
111
|
+
max: 300,
|
|
112
|
+
standardHeaders: true,
|
|
113
|
+
legacyHeaders: false,
|
|
114
|
+
handler: rateLimitHandler,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
app.get("/v1/meta", (_req, res) => {
|
|
119
|
+
const origin = getOrigin(_req);
|
|
120
|
+
const pairingRecord = ctx.tokenStore.getActiveToken(origin);
|
|
121
|
+
res.json({
|
|
122
|
+
version: ctx.version,
|
|
123
|
+
build: ctx.build,
|
|
124
|
+
pairing: {
|
|
125
|
+
required: true,
|
|
126
|
+
paired: Boolean(pairingRecord),
|
|
127
|
+
},
|
|
128
|
+
workspace: {
|
|
129
|
+
configured: Boolean(ctx.config.workspaceRoot),
|
|
130
|
+
root: ctx.config.workspaceRoot ?? undefined,
|
|
131
|
+
},
|
|
132
|
+
capabilities: ctx.capabilities,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
app.post(
|
|
137
|
+
"/v1/pair",
|
|
138
|
+
rateLimit({
|
|
139
|
+
windowMs: 10 * 60 * 1000,
|
|
140
|
+
max: 10,
|
|
141
|
+
standardHeaders: true,
|
|
142
|
+
legacyHeaders: false,
|
|
143
|
+
handler: rateLimitHandler,
|
|
144
|
+
}),
|
|
145
|
+
async (req, res, next) => {
|
|
146
|
+
try {
|
|
147
|
+
const origin = getOrigin(req);
|
|
148
|
+
const payload = parseBody(pairRequestSchema, req.body);
|
|
149
|
+
if (payload.step === "start") {
|
|
150
|
+
res.json(ctx.pairingManager.start(origin));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const response = await ctx.pairingManager.confirm(origin, payload.code);
|
|
154
|
+
if (!response) {
|
|
155
|
+
throw new ApiError(
|
|
156
|
+
422,
|
|
157
|
+
errorBody("internal_error", "Invalid pairing code."),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
res.json(response);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
next(err);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
app.get("/v1/jobs/:id", authGuard(ctx.tokenStore), (req, res, next) => {
|
|
168
|
+
try {
|
|
169
|
+
const job = ctx.jobManager.get(req.params.id);
|
|
170
|
+
if (!job) {
|
|
171
|
+
throw jobNotFound();
|
|
172
|
+
}
|
|
173
|
+
res.json(job.snapshot());
|
|
174
|
+
} catch (err) {
|
|
175
|
+
next(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
app.get(
|
|
180
|
+
"/v1/jobs/:id/stream",
|
|
181
|
+
authGuard(ctx.tokenStore),
|
|
182
|
+
(req, res, next) => {
|
|
183
|
+
const job = ctx.jobManager.get(req.params.id);
|
|
184
|
+
if (!job) {
|
|
185
|
+
next(jobNotFound());
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
"Content-Type": "text/event-stream",
|
|
191
|
+
"Cache-Control": "no-cache",
|
|
192
|
+
Connection: "keep-alive",
|
|
193
|
+
"X-Accel-Buffering": "no",
|
|
194
|
+
});
|
|
195
|
+
res.flushHeaders?.();
|
|
196
|
+
|
|
197
|
+
const sendEvent = (event: unknown) => {
|
|
198
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
for (const event of job.events) {
|
|
202
|
+
sendEvent(event);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const listener = (event: unknown) => sendEvent(event);
|
|
206
|
+
job.emitter.on("event", listener);
|
|
207
|
+
|
|
208
|
+
req.on("close", () => {
|
|
209
|
+
job.emitter.off("event", listener);
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
app.post(
|
|
215
|
+
"/v1/jobs/:id/cancel",
|
|
216
|
+
authGuard(ctx.tokenStore),
|
|
217
|
+
(req, res, next) => {
|
|
218
|
+
try {
|
|
219
|
+
const job = ctx.jobManager.get(req.params.id);
|
|
220
|
+
if (!job) {
|
|
221
|
+
throw jobNotFound();
|
|
222
|
+
}
|
|
223
|
+
if (job.state !== "queued" && job.state !== "running") {
|
|
224
|
+
res
|
|
225
|
+
.status(409)
|
|
226
|
+
.json(errorBody("internal_error", "Job is not cancellable."));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
ctx.jobManager.cancel(job.id);
|
|
230
|
+
res.json({ accepted: true });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
next(err);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
app.post(
|
|
238
|
+
"/v1/git/clone",
|
|
239
|
+
authGuard(ctx.tokenStore),
|
|
240
|
+
async (req, res, next) => {
|
|
241
|
+
try {
|
|
242
|
+
const payload = parseBody(gitCloneRequestSchema, req.body, {
|
|
243
|
+
repoUrl: true,
|
|
244
|
+
});
|
|
245
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
246
|
+
ensureRelative(payload.destRelative);
|
|
247
|
+
const destPath = await resolveInsideWorkspace(
|
|
248
|
+
workspaceRoot,
|
|
249
|
+
payload.destRelative,
|
|
250
|
+
true,
|
|
251
|
+
);
|
|
252
|
+
try {
|
|
253
|
+
await fs.access(destPath);
|
|
254
|
+
res
|
|
255
|
+
.status(409)
|
|
256
|
+
.json(errorBody("internal_error", "Destination already exists."));
|
|
257
|
+
return;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
264
|
+
|
|
265
|
+
const job = ctx.jobManager.enqueue(async (jobCtx) => {
|
|
266
|
+
await cloneRepo(
|
|
267
|
+
jobCtx,
|
|
268
|
+
workspaceRoot,
|
|
269
|
+
payload.repoUrl,
|
|
270
|
+
payload.destRelative,
|
|
271
|
+
payload.options,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
res.status(202).json({ jobId: job.id });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
next(err);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
app.post(
|
|
282
|
+
"/v1/git/fetch",
|
|
283
|
+
authGuard(ctx.tokenStore),
|
|
284
|
+
async (req, res, next) => {
|
|
285
|
+
try {
|
|
286
|
+
const payload = parseBody(gitFetchRequestSchema, req.body);
|
|
287
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
288
|
+
await resolveRepoPath(workspaceRoot, payload.repoPath);
|
|
289
|
+
const job = ctx.jobManager.enqueue(async (jobCtx) => {
|
|
290
|
+
await fetchRepo(
|
|
291
|
+
jobCtx,
|
|
292
|
+
workspaceRoot,
|
|
293
|
+
payload.repoPath,
|
|
294
|
+
payload.remote,
|
|
295
|
+
payload.prune,
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
res.status(202).json({ jobId: job.id });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (
|
|
301
|
+
err instanceof RepoNotFoundError ||
|
|
302
|
+
err instanceof MissingPathError
|
|
303
|
+
) {
|
|
304
|
+
next(repoNotFound());
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
next(err);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
app.get(
|
|
313
|
+
"/v1/git/status",
|
|
314
|
+
authGuard(ctx.tokenStore),
|
|
315
|
+
async (req, res, next) => {
|
|
316
|
+
try {
|
|
317
|
+
const { repoPath } = parseBody(gitStatusQuerySchema, req.query);
|
|
318
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
319
|
+
const status = await getRepoStatus(workspaceRoot, repoPath);
|
|
320
|
+
res.json(status);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (
|
|
323
|
+
err instanceof RepoNotFoundError ||
|
|
324
|
+
err instanceof MissingPathError
|
|
325
|
+
) {
|
|
326
|
+
next(repoNotFound());
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
next(err);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
app.post("/v1/os/open", authGuard(ctx.tokenStore), async (req, res, next) => {
|
|
335
|
+
try {
|
|
336
|
+
const origin = getOrigin(req);
|
|
337
|
+
const payload = parseBody(osOpenRequestSchema, req.body);
|
|
338
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
339
|
+
const resolved = await resolveInsideWorkspace(
|
|
340
|
+
workspaceRoot,
|
|
341
|
+
payload.path,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (payload.target === "terminal") {
|
|
345
|
+
requireApproval(
|
|
346
|
+
ctx.config,
|
|
347
|
+
origin,
|
|
348
|
+
resolved,
|
|
349
|
+
"open-terminal",
|
|
350
|
+
workspaceRoot,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (payload.target === "vscode") {
|
|
354
|
+
requireApproval(
|
|
355
|
+
ctx.config,
|
|
356
|
+
origin,
|
|
357
|
+
resolved,
|
|
358
|
+
"open-vscode",
|
|
359
|
+
workspaceRoot,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await openTarget(payload.target, resolved);
|
|
364
|
+
res.json({ ok: true });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (err instanceof MissingPathError) {
|
|
367
|
+
next(pathNotFound());
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
next(err);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
app.post(
|
|
375
|
+
"/v1/deps/install",
|
|
376
|
+
authGuard(ctx.tokenStore),
|
|
377
|
+
async (req, res, next) => {
|
|
378
|
+
try {
|
|
379
|
+
const origin = getOrigin(req);
|
|
380
|
+
const payload = parseBody(depsInstallRequestSchema, req.body);
|
|
381
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
382
|
+
const resolved = await resolveInsideWorkspace(
|
|
383
|
+
workspaceRoot,
|
|
384
|
+
payload.repoPath,
|
|
385
|
+
);
|
|
386
|
+
try {
|
|
387
|
+
await fs.access(path.join(resolved, "package.json"));
|
|
388
|
+
} catch {
|
|
389
|
+
throw repoNotFound();
|
|
390
|
+
}
|
|
391
|
+
requireApproval(
|
|
392
|
+
ctx.config,
|
|
393
|
+
origin,
|
|
394
|
+
resolved,
|
|
395
|
+
"deps/install",
|
|
396
|
+
workspaceRoot,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const job = ctx.jobManager.enqueue(async (jobCtx) => {
|
|
400
|
+
await installDeps(jobCtx, workspaceRoot, {
|
|
401
|
+
repoPath: payload.repoPath,
|
|
402
|
+
manager: payload.manager ?? "auto",
|
|
403
|
+
mode: payload.mode ?? "auto",
|
|
404
|
+
safer: payload.safer ?? ctx.config.deps.defaultSafer,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
res.status(202).json({ jobId: job.id });
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err instanceof MissingPathError) {
|
|
411
|
+
next(pathNotFound());
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
next(err);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
app.get("/v1/diagnostics", authGuard(ctx.tokenStore), (req, res, next) => {
|
|
420
|
+
try {
|
|
421
|
+
const summary = {
|
|
422
|
+
configVersion: ctx.config.configVersion,
|
|
423
|
+
server: ctx.config.server,
|
|
424
|
+
originAllowlist: ctx.config.originAllowlist,
|
|
425
|
+
workspaceRoot: ctx.config.workspaceRoot,
|
|
426
|
+
pairing: ctx.config.pairing,
|
|
427
|
+
jobs: ctx.config.jobs,
|
|
428
|
+
deps: ctx.config.deps,
|
|
429
|
+
logging: ctx.config.logging,
|
|
430
|
+
};
|
|
431
|
+
res.json({
|
|
432
|
+
config: summary,
|
|
433
|
+
recentErrors: [],
|
|
434
|
+
jobs: ctx.jobManager.listRecent(),
|
|
435
|
+
logTail: [],
|
|
436
|
+
});
|
|
437
|
+
} catch (err) {
|
|
438
|
+
next(err);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
443
|
+
if (err instanceof ApiError) {
|
|
444
|
+
res.status(err.status).json(err.body);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if ((err as { type?: string }).type === "entity.too.large") {
|
|
448
|
+
res
|
|
449
|
+
.status(413)
|
|
450
|
+
.json(errorBody("request_too_large", "Request too large."));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
ctx.logger.error({ err }, "Unhandled error");
|
|
454
|
+
const fallback = internalError();
|
|
455
|
+
res.status(fallback.status).json(fallback.body);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return app;
|
|
459
|
+
};
|
package/src/approvals.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import type { AppConfig, Capability } from "./types";
|
|
3
|
+
import { capabilityNotGranted } from "./errors";
|
|
4
|
+
|
|
5
|
+
export const hasApproval = (
|
|
6
|
+
config: AppConfig,
|
|
7
|
+
origin: string,
|
|
8
|
+
repoPath: string,
|
|
9
|
+
capability: Capability,
|
|
10
|
+
workspaceRoot?: string | null,
|
|
11
|
+
) =>
|
|
12
|
+
config.approvals.entries.some((entry) => {
|
|
13
|
+
if (entry.origin !== origin || !entry.capabilities.includes(capability)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (entry.repoPath === repoPath) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (workspaceRoot && !path.isAbsolute(entry.repoPath)) {
|
|
20
|
+
return path.resolve(workspaceRoot, entry.repoPath) === repoPath;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const requireApproval = (
|
|
26
|
+
config: AppConfig,
|
|
27
|
+
origin: string,
|
|
28
|
+
repoPath: string,
|
|
29
|
+
capability: Capability,
|
|
30
|
+
workspaceRoot?: string | null,
|
|
31
|
+
) => {
|
|
32
|
+
if (!hasApproval(config, origin, repoPath, capability, workspaceRoot)) {
|
|
33
|
+
throw capabilityNotGranted();
|
|
34
|
+
}
|
|
35
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import envPaths from "env-paths";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { promises as fs } from "fs";
|
|
4
|
+
import type { AppConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
const CONFIG_VERSION = 1;
|
|
7
|
+
const CONFIG_FILE = "config.json";
|
|
8
|
+
const TOKENS_FILE = "tokens.json";
|
|
9
|
+
|
|
10
|
+
export const getConfigDir = () => {
|
|
11
|
+
const override = process.env.GIT_DAEMON_CONFIG_DIR;
|
|
12
|
+
if (override) {
|
|
13
|
+
return override;
|
|
14
|
+
}
|
|
15
|
+
const paths = envPaths("Git Daemon", { suffix: "" });
|
|
16
|
+
return paths.config;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const getConfigPath = (configDir: string) =>
|
|
20
|
+
path.join(configDir, CONFIG_FILE);
|
|
21
|
+
|
|
22
|
+
export const getTokensPath = (configDir: string) =>
|
|
23
|
+
path.join(configDir, TOKENS_FILE);
|
|
24
|
+
|
|
25
|
+
export const defaultConfig = (): AppConfig => ({
|
|
26
|
+
configVersion: CONFIG_VERSION,
|
|
27
|
+
server: {
|
|
28
|
+
host: "127.0.0.1",
|
|
29
|
+
port: 8787,
|
|
30
|
+
},
|
|
31
|
+
originAllowlist: ["https://app.example.com"],
|
|
32
|
+
workspaceRoot: null,
|
|
33
|
+
pairing: {
|
|
34
|
+
tokenTtlDays: 30,
|
|
35
|
+
},
|
|
36
|
+
jobs: {
|
|
37
|
+
maxConcurrent: 1,
|
|
38
|
+
timeoutSeconds: 3600,
|
|
39
|
+
},
|
|
40
|
+
deps: {
|
|
41
|
+
defaultSafer: true,
|
|
42
|
+
},
|
|
43
|
+
logging: {
|
|
44
|
+
directory: "logs",
|
|
45
|
+
maxFiles: 5,
|
|
46
|
+
maxBytes: 5 * 1024 * 1024,
|
|
47
|
+
},
|
|
48
|
+
approvals: {
|
|
49
|
+
entries: [],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const ensureDir = async (dir: string) => {
|
|
54
|
+
await fs.mkdir(dir, { recursive: true });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const loadConfig = async (configDir: string): Promise<AppConfig> => {
|
|
58
|
+
await ensureDir(configDir);
|
|
59
|
+
const configPath = getConfigPath(configDir);
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
62
|
+
const data = JSON.parse(raw) as AppConfig;
|
|
63
|
+
return {
|
|
64
|
+
...defaultConfig(),
|
|
65
|
+
...data,
|
|
66
|
+
server: {
|
|
67
|
+
...defaultConfig().server,
|
|
68
|
+
...data.server,
|
|
69
|
+
},
|
|
70
|
+
pairing: {
|
|
71
|
+
...defaultConfig().pairing,
|
|
72
|
+
...data.pairing,
|
|
73
|
+
},
|
|
74
|
+
jobs: {
|
|
75
|
+
...defaultConfig().jobs,
|
|
76
|
+
...data.jobs,
|
|
77
|
+
},
|
|
78
|
+
deps: {
|
|
79
|
+
...defaultConfig().deps,
|
|
80
|
+
...data.deps,
|
|
81
|
+
},
|
|
82
|
+
logging: {
|
|
83
|
+
...defaultConfig().logging,
|
|
84
|
+
...data.logging,
|
|
85
|
+
},
|
|
86
|
+
approvals: {
|
|
87
|
+
entries: data.approvals?.entries ?? [],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
const config = defaultConfig();
|
|
95
|
+
await saveConfig(configDir, config);
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const saveConfig = async (configDir: string, config: AppConfig) => {
|
|
101
|
+
const configPath = getConfigPath(configDir);
|
|
102
|
+
await ensureDir(configDir);
|
|
103
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
|
104
|
+
};
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import { createLogger } from "./logger";
|
|
4
|
+
import { detectCapabilities } from "./tools";
|
|
5
|
+
import { getConfigDir, loadConfig } from "./config";
|
|
6
|
+
import { TokenStore } from "./tokens";
|
|
7
|
+
import { PairingManager } from "./pairing";
|
|
8
|
+
import { JobManager } from "./jobs";
|
|
9
|
+
import type { DaemonContext } from "./app";
|
|
10
|
+
|
|
11
|
+
const readPackageVersion = async () => {
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = path.resolve(__dirname, "..", "package.json");
|
|
14
|
+
const raw = await fs.readFile(pkgPath, "utf8");
|
|
15
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
16
|
+
return parsed.version ?? "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
return "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const createContext = async (): Promise<DaemonContext> => {
|
|
23
|
+
const configDir = getConfigDir();
|
|
24
|
+
const config = await loadConfig(configDir);
|
|
25
|
+
|
|
26
|
+
if (!config.originAllowlist.length) {
|
|
27
|
+
throw new Error("originAllowlist must contain at least one entry.");
|
|
28
|
+
}
|
|
29
|
+
if (config.server.host !== "127.0.0.1") {
|
|
30
|
+
throw new Error("Server host must be 127.0.0.1 for loopback-only binding.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tokenStore = new TokenStore(configDir);
|
|
34
|
+
await tokenStore.load();
|
|
35
|
+
|
|
36
|
+
const logger = await createLogger(configDir, config.logging);
|
|
37
|
+
const capabilities = await detectCapabilities();
|
|
38
|
+
const pairingManager = new PairingManager(
|
|
39
|
+
tokenStore,
|
|
40
|
+
config.pairing.tokenTtlDays,
|
|
41
|
+
);
|
|
42
|
+
const jobManager = new JobManager(
|
|
43
|
+
config.jobs.maxConcurrent,
|
|
44
|
+
config.jobs.timeoutSeconds,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const version = await readPackageVersion();
|
|
48
|
+
const build = {
|
|
49
|
+
commit: process.env.GIT_DAEMON_BUILD_COMMIT,
|
|
50
|
+
date: process.env.GIT_DAEMON_BUILD_DATE,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
config,
|
|
55
|
+
configDir,
|
|
56
|
+
tokenStore,
|
|
57
|
+
pairingManager,
|
|
58
|
+
jobManager,
|
|
59
|
+
capabilities,
|
|
60
|
+
logger,
|
|
61
|
+
version,
|
|
62
|
+
build,
|
|
63
|
+
};
|
|
64
|
+
};
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext } from "./context";
|
|
2
|
+
import { createApp } from "./app";
|
|
3
|
+
|
|
4
|
+
const start = async () => {
|
|
5
|
+
const ctx = await createContext();
|
|
6
|
+
const app = createApp(ctx);
|
|
7
|
+
|
|
8
|
+
app.listen(ctx.config.server.port, ctx.config.server.host, () => {
|
|
9
|
+
ctx.logger.info(
|
|
10
|
+
{
|
|
11
|
+
host: ctx.config.server.host,
|
|
12
|
+
port: ctx.config.server.port,
|
|
13
|
+
},
|
|
14
|
+
"Git Daemon listening",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
start().catch((err) => {
|
|
20
|
+
console.error("Failed to start Git Daemon", err);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|