git-daemon 0.1.8 → 0.1.10
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/README.md +38 -1
- package/config.schema.json +19 -0
- package/design.md +160 -2
- package/dist/app.js +221 -4
- package/dist/approvals.js +4 -1
- package/dist/config.js +11 -0
- package/dist/context.js +3 -0
- package/dist/git.js +117 -8
- package/dist/healthchecks.js +425 -0
- package/dist/jobs.js +1 -0
- package/dist/validation.js +20 -1
- package/healthchecks/example-suite/README.md +10 -0
- package/healthchecks/example-suite/about-description/healthcheck.json +17 -0
- package/healthchecks/example-suite/about-description/run.js +241 -0
- package/healthchecks/example-suite/package-manager/healthcheck.json +17 -0
- package/healthchecks/example-suite/package-manager/run.js +106 -0
- package/healthchecks/example-suite/suite.json +6 -0
- package/openapi.yaml +347 -0
- package/package.json +1 -1
- package/src/app.ts +150 -0
- package/src/config.ts +11 -0
- package/src/context.ts +3 -0
- package/src/git.ts +104 -8
- package/src/healthchecks.ts +620 -0
- package/src/jobs.ts +2 -0
- package/src/types.ts +5 -1
- package/src/validation.ts +18 -0
- package/tests/app.test.ts +223 -16
package/tests/app.test.ts
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import request from "supertest";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import os from "os";
|
|
5
5
|
import { promises as fs } from "fs";
|
|
6
6
|
import pino from "pino";
|
|
7
7
|
import { execa } from "execa";
|
|
8
|
+
import type { Server } from "http";
|
|
8
9
|
import { createApp, type DaemonContext } from "../src/app";
|
|
9
10
|
import { TokenStore } from "../src/tokens";
|
|
10
11
|
import { PairingManager } from "../src/pairing";
|
|
11
12
|
import { JobManager } from "../src/jobs";
|
|
12
13
|
import type { AppConfig } from "../src/types";
|
|
14
|
+
import { HealthcheckResultStore } from "../src/healthchecks";
|
|
15
|
+
|
|
16
|
+
const servers: Server[] = [];
|
|
17
|
+
|
|
18
|
+
const createHttpServer = (app: ReturnType<typeof createApp>) =>
|
|
19
|
+
new Promise<Server>((resolve, reject) => {
|
|
20
|
+
const server = app.listen(0, "127.0.0.1");
|
|
21
|
+
server.once("listening", () => {
|
|
22
|
+
servers.push(server);
|
|
23
|
+
resolve(server);
|
|
24
|
+
});
|
|
25
|
+
server.once("error", (err) => {
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const closeServer = (server: Server) =>
|
|
31
|
+
new Promise<void>((resolve, reject) => {
|
|
32
|
+
server.close((err) => {
|
|
33
|
+
if (err) {
|
|
34
|
+
reject(err);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
const closing = servers.splice(0, servers.length).map(closeServer);
|
|
43
|
+
await Promise.all(closing);
|
|
44
|
+
});
|
|
13
45
|
|
|
14
46
|
const createTempDir = async () =>
|
|
15
47
|
fs.mkdtemp(path.join(os.tmpdir(), "git-daemon-test-"));
|
|
@@ -43,6 +75,7 @@ const setupRepoWithRemote = async (workspaceRoot: string) => {
|
|
|
43
75
|
const createConfig = (
|
|
44
76
|
workspaceRoot: string | null,
|
|
45
77
|
origin: string,
|
|
78
|
+
healthchecks?: AppConfig["healthchecks"],
|
|
46
79
|
): AppConfig => ({
|
|
47
80
|
configVersion: 1,
|
|
48
81
|
server: { host: "127.0.0.1", port: 0 },
|
|
@@ -51,13 +84,18 @@ const createConfig = (
|
|
|
51
84
|
pairing: { tokenTtlDays: 30 },
|
|
52
85
|
jobs: { maxConcurrent: 1, timeoutSeconds: 60 },
|
|
53
86
|
deps: { defaultSafer: true },
|
|
87
|
+
healthchecks,
|
|
54
88
|
logging: { directory: "logs", maxFiles: 1, maxBytes: 1024 },
|
|
55
89
|
approvals: { entries: [] },
|
|
56
90
|
});
|
|
57
91
|
|
|
58
|
-
const createContext = async (
|
|
92
|
+
const createContext = async (
|
|
93
|
+
workspaceRoot: string | null,
|
|
94
|
+
origin: string,
|
|
95
|
+
healthchecks?: AppConfig["healthchecks"],
|
|
96
|
+
) => {
|
|
59
97
|
const configDir = await createTempDir();
|
|
60
|
-
const config = createConfig(workspaceRoot, origin);
|
|
98
|
+
const config = createConfig(workspaceRoot, origin, healthchecks);
|
|
61
99
|
const tokenStore = new TokenStore(configDir);
|
|
62
100
|
await tokenStore.load();
|
|
63
101
|
const pairingManager = new PairingManager(
|
|
@@ -68,6 +106,7 @@ const createContext = async (workspaceRoot: string | null, origin: string) => {
|
|
|
68
106
|
config.jobs.maxConcurrent,
|
|
69
107
|
config.jobs.timeoutSeconds,
|
|
70
108
|
);
|
|
109
|
+
const healthcheckStore = new HealthcheckResultStore();
|
|
71
110
|
const logger = pino({ enabled: false });
|
|
72
111
|
const capabilities = { tools: {} };
|
|
73
112
|
const ctx: DaemonContext = {
|
|
@@ -76,27 +115,66 @@ const createContext = async (workspaceRoot: string | null, origin: string) => {
|
|
|
76
115
|
tokenStore,
|
|
77
116
|
pairingManager,
|
|
78
117
|
jobManager,
|
|
118
|
+
healthcheckStore,
|
|
79
119
|
capabilities,
|
|
80
120
|
logger,
|
|
81
121
|
version: "0.1.0",
|
|
82
122
|
build: undefined,
|
|
83
123
|
};
|
|
84
|
-
|
|
124
|
+
const app = createApp(ctx);
|
|
125
|
+
const server = await createHttpServer(app);
|
|
126
|
+
const client = request(server);
|
|
127
|
+
return { ctx, app, server, client };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const waitForJob = async (
|
|
131
|
+
ctx: DaemonContext,
|
|
132
|
+
jobId: string,
|
|
133
|
+
timeoutMs = 5000,
|
|
134
|
+
) => {
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
while (Date.now() - start < timeoutMs) {
|
|
137
|
+
const job = ctx.jobManager.get(jobId);
|
|
138
|
+
if (job && job.state !== "queued" && job.state !== "running") {
|
|
139
|
+
return job.state;
|
|
140
|
+
}
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Job ${jobId} did not finish in time.`);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const setupHealthcheckRepo = async (workspaceRoot: string) => {
|
|
147
|
+
const repoDir = path.join(workspaceRoot, "repo");
|
|
148
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
149
|
+
await runGit(repoDir, ["init", "-b", "main"]);
|
|
150
|
+
await runGit(repoDir, ["config", "user.email", "test@example.com"]);
|
|
151
|
+
await runGit(repoDir, ["config", "user.name", "Test User"]);
|
|
152
|
+
await fs.writeFile(
|
|
153
|
+
path.join(repoDir, "README.md"),
|
|
154
|
+
"# Title\n\nHello world.",
|
|
155
|
+
);
|
|
156
|
+
await fs.writeFile(
|
|
157
|
+
path.join(repoDir, "package.json"),
|
|
158
|
+
JSON.stringify({ name: "repo", packageManager: "pnpm@8.0.0" }, null, 2),
|
|
159
|
+
);
|
|
160
|
+
await runGit(repoDir, ["add", "README.md", "package.json"]);
|
|
161
|
+
await runGit(repoDir, ["commit", "-m", "init"]);
|
|
162
|
+
return { repoPath: "repo" };
|
|
85
163
|
};
|
|
86
164
|
|
|
87
165
|
describe("Git Daemon API", () => {
|
|
88
166
|
const origin = "http://localhost:5173";
|
|
89
167
|
|
|
90
168
|
it("rejects missing Origin header", async () => {
|
|
91
|
-
const {
|
|
92
|
-
const res = await
|
|
169
|
+
const { client } = await createContext(null, origin);
|
|
170
|
+
const res = await client.get("/v1/meta").set("Host", "127.0.0.1");
|
|
93
171
|
expect(res.status).toBe(403);
|
|
94
172
|
expect(res.body.errorCode).toBe("origin_not_allowed");
|
|
95
173
|
});
|
|
96
174
|
|
|
97
175
|
it("returns meta for allowed origin", async () => {
|
|
98
|
-
const {
|
|
99
|
-
const res = await
|
|
176
|
+
const { client } = await createContext(null, origin);
|
|
177
|
+
const res = await client
|
|
100
178
|
.get("/v1/meta")
|
|
101
179
|
.set("Origin", origin)
|
|
102
180
|
.set("Host", "127.0.0.1");
|
|
@@ -106,8 +184,8 @@ describe("Git Daemon API", () => {
|
|
|
106
184
|
});
|
|
107
185
|
|
|
108
186
|
it("requires auth for protected routes", async () => {
|
|
109
|
-
const {
|
|
110
|
-
const res = await
|
|
187
|
+
const { client } = await createContext(null, origin);
|
|
188
|
+
const res = await client
|
|
111
189
|
.get("/v1/git/status")
|
|
112
190
|
.query({ repoPath: "repo" })
|
|
113
191
|
.set("Origin", origin)
|
|
@@ -117,10 +195,10 @@ describe("Git Daemon API", () => {
|
|
|
117
195
|
});
|
|
118
196
|
|
|
119
197
|
it("returns workspace_required when not configured", async () => {
|
|
120
|
-
const {
|
|
198
|
+
const { client, ctx } = await createContext(null, origin);
|
|
121
199
|
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
122
200
|
|
|
123
|
-
const res = await
|
|
201
|
+
const res = await client
|
|
124
202
|
.get("/v1/git/status")
|
|
125
203
|
.query({ repoPath: "repo" })
|
|
126
204
|
.set("Origin", origin)
|
|
@@ -133,10 +211,10 @@ describe("Git Daemon API", () => {
|
|
|
133
211
|
|
|
134
212
|
it("validates repoUrl on clone", async () => {
|
|
135
213
|
const workspaceRoot = await createTempDir();
|
|
136
|
-
const {
|
|
214
|
+
const { client, ctx } = await createContext(workspaceRoot, origin);
|
|
137
215
|
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
138
216
|
|
|
139
|
-
const res = await
|
|
217
|
+
const res = await client
|
|
140
218
|
.post("/v1/git/clone")
|
|
141
219
|
.set("Origin", origin)
|
|
142
220
|
.set("Host", "127.0.0.1")
|
|
@@ -150,10 +228,10 @@ describe("Git Daemon API", () => {
|
|
|
150
228
|
it("lists branches including remotes by default", async () => {
|
|
151
229
|
const workspaceRoot = await createTempDir();
|
|
152
230
|
const { repoPath } = await setupRepoWithRemote(workspaceRoot);
|
|
153
|
-
const {
|
|
231
|
+
const { client, ctx } = await createContext(workspaceRoot, origin);
|
|
154
232
|
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
155
233
|
|
|
156
|
-
const res = await
|
|
234
|
+
const res = await client
|
|
157
235
|
.get("/v1/git/branches")
|
|
158
236
|
.query({ repoPath })
|
|
159
237
|
.set("Origin", origin)
|
|
@@ -180,4 +258,133 @@ describe("Git Daemon API", () => {
|
|
|
180
258
|
const originMain = branches.find((branch) => branch.name === "origin/main");
|
|
181
259
|
expect(originMain?.type).toBe("remote");
|
|
182
260
|
});
|
|
261
|
+
|
|
262
|
+
it("returns a UI-friendly status summary", async () => {
|
|
263
|
+
const workspaceRoot = await createTempDir();
|
|
264
|
+
const { repoPath } = await setupRepoWithRemote(workspaceRoot);
|
|
265
|
+
const repoDir = path.join(workspaceRoot, repoPath);
|
|
266
|
+
await fs.writeFile(path.join(repoDir, "scratch.txt"), "dirty");
|
|
267
|
+
const { client, ctx } = await createContext(workspaceRoot, origin);
|
|
268
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
269
|
+
|
|
270
|
+
const res = await client
|
|
271
|
+
.get("/v1/git/summary")
|
|
272
|
+
.query({ repoPath })
|
|
273
|
+
.set("Origin", origin)
|
|
274
|
+
.set("Host", "127.0.0.1")
|
|
275
|
+
.set("Authorization", `Bearer ${token}`);
|
|
276
|
+
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
expect(res.body.repoPath).toBe(repoPath);
|
|
279
|
+
expect(res.body.exists).toBe(true);
|
|
280
|
+
expect(res.body.branch).toBe("feature");
|
|
281
|
+
expect(res.body.upstream).toBe("origin/feature");
|
|
282
|
+
expect(res.body.ahead).toBe(0);
|
|
283
|
+
expect(res.body.behind).toBe(0);
|
|
284
|
+
expect(res.body.dirty).toBe(true);
|
|
285
|
+
expect(res.body.untracked).toBe(1);
|
|
286
|
+
expect(res.body.staged).toBe(0);
|
|
287
|
+
expect(res.body.unstaged).toBe(0);
|
|
288
|
+
expect(res.body.conflicts).toBe(0);
|
|
289
|
+
expect(res.body.detached).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns exists false when summary repo is missing", async () => {
|
|
293
|
+
const workspaceRoot = await createTempDir();
|
|
294
|
+
const { client, ctx } = await createContext(workspaceRoot, origin);
|
|
295
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
296
|
+
|
|
297
|
+
const res = await client
|
|
298
|
+
.get("/v1/git/summary")
|
|
299
|
+
.query({ repoPath: "missing" })
|
|
300
|
+
.set("Origin", origin)
|
|
301
|
+
.set("Host", "127.0.0.1")
|
|
302
|
+
.set("Authorization", `Bearer ${token}`);
|
|
303
|
+
|
|
304
|
+
expect(res.status).toBe(200);
|
|
305
|
+
expect(res.body.exists).toBe(false);
|
|
306
|
+
expect(res.body.branch).toBe("");
|
|
307
|
+
expect(res.body.dirty).toBe(false);
|
|
308
|
+
expect(res.body.ahead).toBe(0);
|
|
309
|
+
expect(res.body.behind).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("lists available healthchecks", async () => {
|
|
313
|
+
const workspaceRoot = await createTempDir();
|
|
314
|
+
const suitePath = path.resolve(
|
|
315
|
+
process.cwd(),
|
|
316
|
+
"healthchecks",
|
|
317
|
+
"example-suite",
|
|
318
|
+
);
|
|
319
|
+
const { client, ctx } = await createContext(workspaceRoot, origin, {
|
|
320
|
+
suites: [suitePath],
|
|
321
|
+
defaultSuiteId: "example-suite",
|
|
322
|
+
});
|
|
323
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
324
|
+
|
|
325
|
+
const res = await client
|
|
326
|
+
.get("/v1/healthchecks")
|
|
327
|
+
.set("Origin", origin)
|
|
328
|
+
.set("Host", "127.0.0.1")
|
|
329
|
+
.set("Authorization", `Bearer ${token}`);
|
|
330
|
+
|
|
331
|
+
expect(res.status).toBe(200);
|
|
332
|
+
const suiteIds = (res.body.suites as Array<{ id: string }>).map(
|
|
333
|
+
(suite) => suite.id,
|
|
334
|
+
);
|
|
335
|
+
expect(suiteIds).toContain("example-suite");
|
|
336
|
+
const suite = (
|
|
337
|
+
res.body.suites as Array<{
|
|
338
|
+
id: string;
|
|
339
|
+
checks: Array<{ id: string }>;
|
|
340
|
+
}>
|
|
341
|
+
).find((item) => item.id === "example-suite");
|
|
342
|
+
expect(suite?.checks.map((check) => check.id)).toEqual(
|
|
343
|
+
expect.arrayContaining(["about-description", "package-manager"]),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("runs healthchecks and returns results", async () => {
|
|
348
|
+
const workspaceRoot = await createTempDir();
|
|
349
|
+
const { repoPath } = await setupHealthcheckRepo(workspaceRoot);
|
|
350
|
+
const suitePath = path.resolve(
|
|
351
|
+
process.cwd(),
|
|
352
|
+
"healthchecks",
|
|
353
|
+
"example-suite",
|
|
354
|
+
);
|
|
355
|
+
const { client, ctx } = await createContext(workspaceRoot, origin, {
|
|
356
|
+
suites: [suitePath],
|
|
357
|
+
defaultSuiteId: "example-suite",
|
|
358
|
+
});
|
|
359
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
360
|
+
|
|
361
|
+
const runRes = await client
|
|
362
|
+
.post("/v1/healthchecks/run")
|
|
363
|
+
.set("Origin", origin)
|
|
364
|
+
.set("Host", "127.0.0.1")
|
|
365
|
+
.set("Authorization", `Bearer ${token}`)
|
|
366
|
+
.send({
|
|
367
|
+
repoPath,
|
|
368
|
+
repoInfo: { description: "Hello world." },
|
|
369
|
+
checks: [
|
|
370
|
+
{ checkId: "about-description" },
|
|
371
|
+
{ checkId: "package-manager" },
|
|
372
|
+
],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(runRes.status).toBe(202);
|
|
376
|
+
const jobId = runRes.body.jobId as string;
|
|
377
|
+
await waitForJob(ctx, jobId);
|
|
378
|
+
|
|
379
|
+
const resultRes = await client
|
|
380
|
+
.get(`/v1/healthchecks/jobs/${jobId}/result`)
|
|
381
|
+
.set("Origin", origin)
|
|
382
|
+
.set("Host", "127.0.0.1")
|
|
383
|
+
.set("Authorization", `Bearer ${token}`);
|
|
384
|
+
|
|
385
|
+
expect(resultRes.status).toBe(200);
|
|
386
|
+
expect(resultRes.body.repoPath).toBe(repoPath);
|
|
387
|
+
expect(resultRes.body.status).toBe("pass-full");
|
|
388
|
+
expect(resultRes.body.checks).toHaveLength(2);
|
|
389
|
+
});
|
|
183
390
|
});
|