git-daemon 0.1.1 → 0.1.2
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 +12 -6
- package/config.schema.json +1 -1
- package/design.md +3 -3
- package/dist/app.js +306 -0
- package/dist/approvals.js +27 -0
- package/dist/config.js +107 -0
- package/dist/context.js +58 -0
- package/dist/daemon.js +29 -0
- package/dist/deps.js +101 -0
- package/dist/errors.js +43 -0
- package/dist/git.js +156 -0
- package/dist/jobs.js +163 -0
- package/dist/logger.js +77 -0
- package/dist/os.js +42 -0
- package/dist/pairing.js +41 -0
- package/dist/process.js +46 -0
- package/dist/security.js +73 -0
- package/dist/tokens.js +88 -0
- package/dist/tools.js +43 -0
- package/dist/types.js +2 -0
- package/dist/validation.js +62 -0
- package/dist/workspace.js +79 -0
- package/openapi.yaml +1 -1
- package/package.json +2 -1
- package/src/config.ts +1 -1
- package/src/daemon.ts +14 -0
- package/src/jobs.ts +3 -3
- package/src/logger.ts +26 -4
- package/src/process.ts +3 -2
- package/src/types.ts +20 -17
- package/src/typings/pino-pretty.d.ts +9 -0
- package/tests/app.test.js +103 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +9 -0
package/dist/security.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authGuard = exports.loopbackGuard = exports.hostGuard = exports.originGuard = exports.getOrigin = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
const ALLOWED_HOSTS = new Set(["127.0.0.1", "localhost"]);
|
|
6
|
+
const getOrigin = (req) => req.headers.origin || "";
|
|
7
|
+
exports.getOrigin = getOrigin;
|
|
8
|
+
const isLoopbackAddress = (address) => {
|
|
9
|
+
if (!address) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (address === "127.0.0.1" || address === "::1") {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return address.startsWith("::ffff:127.0.0.1");
|
|
16
|
+
};
|
|
17
|
+
const originGuard = (allowlist) => {
|
|
18
|
+
return (req, res, next) => {
|
|
19
|
+
const origin = (0, exports.getOrigin)(req);
|
|
20
|
+
if (!origin || !allowlist.includes(origin)) {
|
|
21
|
+
return next((0, errors_1.originNotAllowed)());
|
|
22
|
+
}
|
|
23
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
24
|
+
res.setHeader("Vary", "Origin");
|
|
25
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
26
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
27
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
28
|
+
if (req.method === "OPTIONS") {
|
|
29
|
+
res.status(204).end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
exports.originGuard = originGuard;
|
|
36
|
+
const hostGuard = () => {
|
|
37
|
+
return (req, _res, next) => {
|
|
38
|
+
const host = req.headers.host?.split(":")[0];
|
|
39
|
+
if (!host || !ALLOWED_HOSTS.has(host)) {
|
|
40
|
+
return next((0, errors_1.originNotAllowed)());
|
|
41
|
+
}
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
exports.hostGuard = hostGuard;
|
|
46
|
+
const loopbackGuard = () => {
|
|
47
|
+
return (req, _res, next) => {
|
|
48
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
|
49
|
+
return next((0, errors_1.originNotAllowed)());
|
|
50
|
+
}
|
|
51
|
+
next();
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
exports.loopbackGuard = loopbackGuard;
|
|
55
|
+
const authGuard = (tokenStore) => {
|
|
56
|
+
return (req, _res, next) => {
|
|
57
|
+
const origin = (0, exports.getOrigin)(req);
|
|
58
|
+
const auth = req.headers.authorization;
|
|
59
|
+
if (!auth) {
|
|
60
|
+
return next((0, errors_1.authRequired)());
|
|
61
|
+
}
|
|
62
|
+
const match = auth.match(/^Bearer (.+)$/i);
|
|
63
|
+
if (!match) {
|
|
64
|
+
return next((0, errors_1.authInvalid)());
|
|
65
|
+
}
|
|
66
|
+
const token = match[1];
|
|
67
|
+
if (!tokenStore.verifyToken(origin, token)) {
|
|
68
|
+
return next((0, errors_1.authInvalid)());
|
|
69
|
+
}
|
|
70
|
+
next();
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
exports.authGuard = authGuard;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TokenStore = void 0;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const TOKEN_BYTES = 32;
|
|
11
|
+
const HASH_BYTES = 32;
|
|
12
|
+
class TokenStore {
|
|
13
|
+
constructor(configDir) {
|
|
14
|
+
this.entries = [];
|
|
15
|
+
this.tokensPath = (0, config_1.getTokensPath)(configDir);
|
|
16
|
+
}
|
|
17
|
+
async load() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = await fs_1.promises.readFile(this.tokensPath, "utf8");
|
|
20
|
+
const data = JSON.parse(raw);
|
|
21
|
+
this.entries = Array.isArray(data.entries) ? data.entries : [];
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code !== "ENOENT") {
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
this.entries = [];
|
|
28
|
+
await this.save();
|
|
29
|
+
}
|
|
30
|
+
this.pruneExpired();
|
|
31
|
+
}
|
|
32
|
+
async save() {
|
|
33
|
+
const payload = { entries: this.entries };
|
|
34
|
+
await fs_1.promises.writeFile(this.tokensPath, JSON.stringify(payload, null, 2));
|
|
35
|
+
}
|
|
36
|
+
pruneExpired() {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
this.entries = this.entries.filter((entry) => {
|
|
39
|
+
const expiresAt = Date.parse(entry.expiresAt);
|
|
40
|
+
return Number.isNaN(expiresAt) || expiresAt > now;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
getActiveToken(origin) {
|
|
44
|
+
this.pruneExpired();
|
|
45
|
+
return this.entries.find((entry) => entry.origin === origin);
|
|
46
|
+
}
|
|
47
|
+
async issueToken(origin, ttlDays) {
|
|
48
|
+
const token = crypto_1.default.randomBytes(TOKEN_BYTES).toString("base64url");
|
|
49
|
+
const salt = crypto_1.default.randomBytes(16).toString("base64url");
|
|
50
|
+
const tokenHash = crypto_1.default
|
|
51
|
+
.scryptSync(token, salt, HASH_BYTES)
|
|
52
|
+
.toString("base64url");
|
|
53
|
+
const now = new Date();
|
|
54
|
+
const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000);
|
|
55
|
+
const entry = {
|
|
56
|
+
origin,
|
|
57
|
+
tokenHash,
|
|
58
|
+
salt,
|
|
59
|
+
createdAt: now.toISOString(),
|
|
60
|
+
expiresAt: expiresAt.toISOString(),
|
|
61
|
+
};
|
|
62
|
+
this.entries = this.entries.filter((item) => item.origin !== origin);
|
|
63
|
+
this.entries.push(entry);
|
|
64
|
+
await this.save();
|
|
65
|
+
return {
|
|
66
|
+
token,
|
|
67
|
+
expiresAt: entry.expiresAt,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async revokeToken(origin) {
|
|
71
|
+
this.entries = this.entries.filter((item) => item.origin !== origin);
|
|
72
|
+
await this.save();
|
|
73
|
+
}
|
|
74
|
+
verifyToken(origin, token) {
|
|
75
|
+
this.pruneExpired();
|
|
76
|
+
const entry = this.entries.find((item) => item.origin === origin);
|
|
77
|
+
if (!entry) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const derived = crypto_1.default.scryptSync(token, entry.salt, HASH_BYTES);
|
|
81
|
+
const stored = Buffer.from(entry.tokenHash, "base64url");
|
|
82
|
+
if (stored.length !== derived.length) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return crypto_1.default.timingSafeEqual(stored, derived);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.TokenStore = TokenStore;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isToolInstalled = exports.detectCapabilities = void 0;
|
|
4
|
+
const execa_1 = require("execa");
|
|
5
|
+
const detect = async (command, args = ["--version"]) => {
|
|
6
|
+
try {
|
|
7
|
+
const result = await (0, execa_1.execa)(command, args);
|
|
8
|
+
const version = result.stdout.trim();
|
|
9
|
+
return { installed: true, version };
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
if (err.code === "ENOENT") {
|
|
13
|
+
return { installed: false };
|
|
14
|
+
}
|
|
15
|
+
return { installed: false };
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const detectCapabilities = async () => {
|
|
19
|
+
const [git, node, npm, pnpm, yarn, code] = await Promise.all([
|
|
20
|
+
detect("git", ["--version"]),
|
|
21
|
+
detect("node", ["--version"]),
|
|
22
|
+
detect("npm", ["--version"]),
|
|
23
|
+
detect("pnpm", ["--version"]),
|
|
24
|
+
detect("yarn", ["--version"]),
|
|
25
|
+
detect("code", ["--version"]),
|
|
26
|
+
]);
|
|
27
|
+
return {
|
|
28
|
+
tools: {
|
|
29
|
+
git,
|
|
30
|
+
node,
|
|
31
|
+
npm,
|
|
32
|
+
pnpm,
|
|
33
|
+
yarn,
|
|
34
|
+
code,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
exports.detectCapabilities = detectCapabilities;
|
|
39
|
+
const isToolInstalled = async (command) => {
|
|
40
|
+
const info = await detect(command, ["--version"]);
|
|
41
|
+
return info.installed;
|
|
42
|
+
};
|
|
43
|
+
exports.isToolInstalled = isToolInstalled;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.depsInstallRequestSchema = exports.osOpenRequestSchema = exports.gitStatusQuerySchema = exports.gitFetchRequestSchema = exports.gitCloneRequestSchema = exports.pairRequestSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const MAX_PATH_LENGTH = 4096;
|
|
6
|
+
const isValidRepoUrl = (value) => {
|
|
7
|
+
if (value.startsWith("file://")) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (value.startsWith("/") ||
|
|
11
|
+
value.startsWith("./") ||
|
|
12
|
+
value.startsWith("../")) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (/^git@[^:]+:.+/.test(value)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (/^https:\/\/[^/]+\/.+/.test(value)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (/^ssh:\/\/[^/]+\/.+/.test(value)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
exports.pairRequestSchema = zod_1.z.discriminatedUnion("step", [
|
|
27
|
+
zod_1.z.object({
|
|
28
|
+
step: zod_1.z.literal("start"),
|
|
29
|
+
}),
|
|
30
|
+
zod_1.z.object({
|
|
31
|
+
step: zod_1.z.literal("confirm"),
|
|
32
|
+
code: zod_1.z.string().min(1),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
exports.gitCloneRequestSchema = zod_1.z.object({
|
|
36
|
+
repoUrl: zod_1.z.string().min(1).refine(isValidRepoUrl),
|
|
37
|
+
destRelative: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
38
|
+
options: zod_1.z
|
|
39
|
+
.object({
|
|
40
|
+
branch: zod_1.z.string().min(1).optional(),
|
|
41
|
+
depth: zod_1.z.number().int().min(1).optional(),
|
|
42
|
+
})
|
|
43
|
+
.optional(),
|
|
44
|
+
});
|
|
45
|
+
exports.gitFetchRequestSchema = zod_1.z.object({
|
|
46
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
47
|
+
remote: zod_1.z.string().min(1).optional(),
|
|
48
|
+
prune: zod_1.z.boolean().optional(),
|
|
49
|
+
});
|
|
50
|
+
exports.gitStatusQuerySchema = zod_1.z.object({
|
|
51
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
52
|
+
});
|
|
53
|
+
exports.osOpenRequestSchema = zod_1.z.object({
|
|
54
|
+
target: zod_1.z.enum(["folder", "terminal", "vscode"]),
|
|
55
|
+
path: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
56
|
+
});
|
|
57
|
+
exports.depsInstallRequestSchema = zod_1.z.object({
|
|
58
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
59
|
+
manager: zod_1.z.enum(["auto", "npm", "pnpm", "yarn"]).optional(),
|
|
60
|
+
mode: zod_1.z.enum(["auto", "ci", "install"]).optional(),
|
|
61
|
+
safer: zod_1.z.boolean().optional(),
|
|
62
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ensureRelative = exports.resolveInsideWorkspace = exports.ensureWorkspaceRoot = exports.MissingPathError = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const errors_1 = require("./errors");
|
|
10
|
+
class MissingPathError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.MissingPathError = MissingPathError;
|
|
13
|
+
const MAX_PATH_LENGTH = 4096;
|
|
14
|
+
const ensureWorkspaceRoot = (root) => {
|
|
15
|
+
if (!root) {
|
|
16
|
+
throw (0, errors_1.workspaceRequired)();
|
|
17
|
+
}
|
|
18
|
+
if (root.length > MAX_PATH_LENGTH) {
|
|
19
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
};
|
|
23
|
+
exports.ensureWorkspaceRoot = ensureWorkspaceRoot;
|
|
24
|
+
const realpathSafe = async (target) => {
|
|
25
|
+
try {
|
|
26
|
+
return await fs_1.promises.realpath(target);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const resolveInsideWorkspace = async (workspaceRoot, candidate, allowMissing = false) => {
|
|
36
|
+
if (candidate.length > MAX_PATH_LENGTH) {
|
|
37
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
38
|
+
}
|
|
39
|
+
const rootReal = await fs_1.promises.realpath(workspaceRoot);
|
|
40
|
+
const resolved = path_1.default.resolve(rootReal, candidate);
|
|
41
|
+
if (!isInside(rootReal, resolved)) {
|
|
42
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
43
|
+
}
|
|
44
|
+
const realResolved = await realpathSafe(resolved);
|
|
45
|
+
if (realResolved) {
|
|
46
|
+
if (!isInside(rootReal, realResolved)) {
|
|
47
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
48
|
+
}
|
|
49
|
+
return realResolved;
|
|
50
|
+
}
|
|
51
|
+
if (!allowMissing) {
|
|
52
|
+
throw new MissingPathError("Path does not exist.");
|
|
53
|
+
}
|
|
54
|
+
const parent = path_1.default.dirname(resolved);
|
|
55
|
+
const parentReal = await realpathSafe(parent);
|
|
56
|
+
if (parentReal && !isInside(rootReal, parentReal)) {
|
|
57
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
};
|
|
61
|
+
exports.resolveInsideWorkspace = resolveInsideWorkspace;
|
|
62
|
+
const isInside = (root, candidate) => {
|
|
63
|
+
const relative = path_1.default.relative(root, candidate);
|
|
64
|
+
if (relative === "") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return !relative.startsWith("..") && !path_1.default.isAbsolute(relative);
|
|
68
|
+
};
|
|
69
|
+
const ensureRelative = (target) => {
|
|
70
|
+
if (path_1.default.isAbsolute(target)) {
|
|
71
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
72
|
+
}
|
|
73
|
+
const normalized = path_1.default.normalize(target);
|
|
74
|
+
if (normalized === "." || normalized.startsWith("..")) {
|
|
75
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
};
|
|
79
|
+
exports.ensureRelative = ensureRelative;
|
package/openapi.yaml
CHANGED
|
@@ -6,7 +6,7 @@ info:
|
|
|
6
6
|
Localhost API for the Git Daemon. All requests must include an Origin header
|
|
7
7
|
that matches the allowlist. Non-public endpoints require a Bearer token.
|
|
8
8
|
servers:
|
|
9
|
-
- url: http://127.0.0.1:
|
|
9
|
+
- url: http://127.0.0.1:8790
|
|
10
10
|
security:
|
|
11
11
|
- bearerAuth: []
|
|
12
12
|
paths:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/daemon.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"express-rate-limit": "^7.4.0",
|
|
20
20
|
"pino": "^9.3.2",
|
|
21
21
|
"pino-http": "^9.0.0",
|
|
22
|
+
"pino-pretty": "^13.0.0",
|
|
22
23
|
"rotating-file-stream": "^3.2.5",
|
|
23
24
|
"tree-kill": "^1.2.2",
|
|
24
25
|
"zod": "^3.23.8"
|
package/src/config.ts
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -5,6 +5,20 @@ const start = async () => {
|
|
|
5
5
|
const ctx = await createContext();
|
|
6
6
|
const app = createApp(ctx);
|
|
7
7
|
|
|
8
|
+
const startupSummary = {
|
|
9
|
+
configDir: ctx.configDir,
|
|
10
|
+
host: ctx.config.server.host,
|
|
11
|
+
port: ctx.config.server.port,
|
|
12
|
+
workspaceRoot: ctx.config.workspaceRoot ?? "not configured",
|
|
13
|
+
originAllowlist: ctx.config.originAllowlist,
|
|
14
|
+
};
|
|
15
|
+
ctx.logger.info(startupSummary, "Git Daemon starting");
|
|
16
|
+
if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
|
|
17
|
+
console.log(
|
|
18
|
+
`[Git Daemon] config=${startupSummary.configDir} host=${startupSummary.host} port=${startupSummary.port}`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
app.listen(ctx.config.server.port, ctx.config.server.host, () => {
|
|
9
23
|
ctx.logger.info(
|
|
10
24
|
{
|
package/src/jobs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import crypto from "crypto";
|
|
3
|
-
import type { ApiErrorBody, JobEvent, JobState, JobStatus } from "./types";
|
|
3
|
+
import type { ApiErrorBody, JobEvent, JobProgressEvent, JobState, JobStatus } from "./types";
|
|
4
4
|
import { timeoutError } from "./errors";
|
|
5
5
|
|
|
6
6
|
const MAX_EVENTS = 2000;
|
|
@@ -8,7 +8,7 @@ const MAX_EVENTS = 2000;
|
|
|
8
8
|
export type JobContext = {
|
|
9
9
|
logStdout: (line: string) => void;
|
|
10
10
|
logStderr: (line: string) => void;
|
|
11
|
-
progress: (event: Omit<
|
|
11
|
+
progress: (event: Omit<JobProgressEvent, "type">) => void;
|
|
12
12
|
setCancel: (cancel: () => Promise<void>) => void;
|
|
13
13
|
isCancelled: () => boolean;
|
|
14
14
|
};
|
|
@@ -157,7 +157,7 @@ export class JobManager {
|
|
|
157
157
|
const ctx: JobContext = {
|
|
158
158
|
logStdout: (line) => job.emit({ type: "log", stream: "stdout", line }),
|
|
159
159
|
logStderr: (line) => job.emit({ type: "log", stream: "stderr", line }),
|
|
160
|
-
progress: (event) => job.emit({
|
|
160
|
+
progress: (event) => job.emit({ type: "progress", ...event }),
|
|
161
161
|
setCancel: (fn) => job.setCancel(fn),
|
|
162
162
|
isCancelled: () => job.cancelRequested,
|
|
163
163
|
};
|
package/src/logger.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { promises as fs } from "fs";
|
|
|
3
3
|
import pino from "pino";
|
|
4
4
|
import pinoHttp from "pino-http";
|
|
5
5
|
import type { Logger } from "pino";
|
|
6
|
-
import
|
|
6
|
+
import { createStream } from "rotating-file-stream";
|
|
7
7
|
import type { AppConfig } from "./types";
|
|
8
8
|
|
|
9
9
|
export const createLogger = async (
|
|
@@ -11,16 +11,38 @@ export const createLogger = async (
|
|
|
11
11
|
logging: AppConfig["logging"],
|
|
12
12
|
enabled = true,
|
|
13
13
|
): Promise<Logger> => {
|
|
14
|
+
const level = process.env.GIT_DAEMON_LOG_LEVEL || "info";
|
|
15
|
+
const logToStdout = process.env.GIT_DAEMON_LOG_STDOUT === "1";
|
|
16
|
+
const prettyStdout = logToStdout && process.env.GIT_DAEMON_LOG_PRETTY !== "0";
|
|
14
17
|
const logDir = path.join(configDir, logging.directory);
|
|
15
18
|
await fs.mkdir(logDir, { recursive: true });
|
|
16
19
|
|
|
17
|
-
const stream =
|
|
20
|
+
const stream = createStream("daemon.log", {
|
|
18
21
|
size: `${logging.maxBytes}B`,
|
|
19
22
|
maxFiles: logging.maxFiles,
|
|
20
23
|
path: logDir,
|
|
21
24
|
});
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
if (!logToStdout) {
|
|
27
|
+
return pino({ enabled, level }, stream);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const streams: Array<{ stream: NodeJS.WritableStream }> = [{ stream }];
|
|
31
|
+
if (prettyStdout) {
|
|
32
|
+
const { default: pretty } = await import("pino-pretty");
|
|
33
|
+
streams.push({
|
|
34
|
+
stream: pretty({
|
|
35
|
+
colorize: true,
|
|
36
|
+
translateTime: "SYS:standard",
|
|
37
|
+
ignore: "pid,hostname",
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
streams.push({ stream: process.stdout });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return pino({ enabled, level }, (pino as any).multistream(streams));
|
|
24
45
|
};
|
|
25
46
|
|
|
26
|
-
export const createHttpLogger = (logger: Logger) =>
|
|
47
|
+
export const createHttpLogger = (logger: Logger) =>
|
|
48
|
+
pinoHttp({ logger: logger as any });
|
package/src/process.ts
CHANGED
|
@@ -39,11 +39,12 @@ export const runCommand = async (
|
|
|
39
39
|
stderr: "pipe",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const pid = subprocess.pid;
|
|
43
|
+
if (pid) {
|
|
43
44
|
ctx.setCancel(
|
|
44
45
|
() =>
|
|
45
46
|
new Promise<void>((resolve) => {
|
|
46
|
-
treeKill(
|
|
47
|
+
treeKill(pid, "SIGTERM", () => resolve());
|
|
47
48
|
}),
|
|
48
49
|
);
|
|
49
50
|
}
|
package/src/types.ts
CHANGED
|
@@ -65,23 +65,26 @@ export type TokenStoreData = {
|
|
|
65
65
|
|
|
66
66
|
export type JobState = "queued" | "running" | "done" | "error" | "cancelled";
|
|
67
67
|
|
|
68
|
-
export type
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
68
|
+
export type JobLogEvent = {
|
|
69
|
+
type: "log";
|
|
70
|
+
stream: "stdout" | "stderr";
|
|
71
|
+
line: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type JobProgressEvent = {
|
|
75
|
+
type: "progress";
|
|
76
|
+
kind: "git" | "deps";
|
|
77
|
+
percent?: number;
|
|
78
|
+
detail?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type JobStateEvent = {
|
|
82
|
+
type: "state";
|
|
83
|
+
state: JobState;
|
|
84
|
+
message?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type JobEvent = JobLogEvent | JobProgressEvent | JobStateEvent;
|
|
85
88
|
|
|
86
89
|
export type JobStatus = {
|
|
87
90
|
id: string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const vitest_1 = require("vitest");
|
|
7
|
+
const supertest_1 = __importDefault(require("supertest"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const fs_1 = require("fs");
|
|
11
|
+
const pino_1 = __importDefault(require("pino"));
|
|
12
|
+
const app_1 = require("../src/app");
|
|
13
|
+
const tokens_1 = require("../src/tokens");
|
|
14
|
+
const pairing_1 = require("../src/pairing");
|
|
15
|
+
const jobs_1 = require("../src/jobs");
|
|
16
|
+
const createTempDir = async () => fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "git-daemon-test-"));
|
|
17
|
+
const createConfig = (workspaceRoot, origin) => ({
|
|
18
|
+
configVersion: 1,
|
|
19
|
+
server: { host: "127.0.0.1", port: 0 },
|
|
20
|
+
originAllowlist: [origin],
|
|
21
|
+
workspaceRoot,
|
|
22
|
+
pairing: { tokenTtlDays: 30 },
|
|
23
|
+
jobs: { maxConcurrent: 1, timeoutSeconds: 60 },
|
|
24
|
+
deps: { defaultSafer: true },
|
|
25
|
+
logging: { directory: "logs", maxFiles: 1, maxBytes: 1024 },
|
|
26
|
+
approvals: { entries: [] },
|
|
27
|
+
});
|
|
28
|
+
const createContext = async (workspaceRoot, origin) => {
|
|
29
|
+
const configDir = await createTempDir();
|
|
30
|
+
const config = createConfig(workspaceRoot, origin);
|
|
31
|
+
const tokenStore = new tokens_1.TokenStore(configDir);
|
|
32
|
+
await tokenStore.load();
|
|
33
|
+
const pairingManager = new pairing_1.PairingManager(tokenStore, config.pairing.tokenTtlDays);
|
|
34
|
+
const jobManager = new jobs_1.JobManager(config.jobs.maxConcurrent, config.jobs.timeoutSeconds);
|
|
35
|
+
const logger = (0, pino_1.default)({ enabled: false });
|
|
36
|
+
const capabilities = { tools: {} };
|
|
37
|
+
const ctx = {
|
|
38
|
+
config,
|
|
39
|
+
configDir,
|
|
40
|
+
tokenStore,
|
|
41
|
+
pairingManager,
|
|
42
|
+
jobManager,
|
|
43
|
+
capabilities,
|
|
44
|
+
logger,
|
|
45
|
+
version: "0.1.0",
|
|
46
|
+
build: undefined,
|
|
47
|
+
};
|
|
48
|
+
return { ctx, app: (0, app_1.createApp)(ctx) };
|
|
49
|
+
};
|
|
50
|
+
(0, vitest_1.describe)("Git Daemon API", () => {
|
|
51
|
+
const origin = "http://localhost:5173";
|
|
52
|
+
(0, vitest_1.it)("rejects missing Origin header", async () => {
|
|
53
|
+
const { app } = await createContext(null, origin);
|
|
54
|
+
const res = await (0, supertest_1.default)(app).get("/v1/meta").set("Host", "127.0.0.1");
|
|
55
|
+
(0, vitest_1.expect)(res.status).toBe(403);
|
|
56
|
+
(0, vitest_1.expect)(res.body.errorCode).toBe("origin_not_allowed");
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.it)("returns meta for allowed origin", async () => {
|
|
59
|
+
const { app } = await createContext(null, origin);
|
|
60
|
+
const res = await (0, supertest_1.default)(app)
|
|
61
|
+
.get("/v1/meta")
|
|
62
|
+
.set("Origin", origin)
|
|
63
|
+
.set("Host", "127.0.0.1");
|
|
64
|
+
(0, vitest_1.expect)(res.status).toBe(200);
|
|
65
|
+
(0, vitest_1.expect)(res.body.version).toBeTypeOf("string");
|
|
66
|
+
(0, vitest_1.expect)(res.body.pairing).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
(0, vitest_1.it)("requires auth for protected routes", async () => {
|
|
69
|
+
const { app } = await createContext(null, origin);
|
|
70
|
+
const res = await (0, supertest_1.default)(app)
|
|
71
|
+
.get("/v1/git/status")
|
|
72
|
+
.query({ repoPath: "repo" })
|
|
73
|
+
.set("Origin", origin)
|
|
74
|
+
.set("Host", "127.0.0.1");
|
|
75
|
+
(0, vitest_1.expect)(res.status).toBe(401);
|
|
76
|
+
(0, vitest_1.expect)(res.body.errorCode).toBe("auth_required");
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)("returns workspace_required when not configured", async () => {
|
|
79
|
+
const { app, ctx } = await createContext(null, origin);
|
|
80
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
81
|
+
const res = await (0, supertest_1.default)(app)
|
|
82
|
+
.get("/v1/git/status")
|
|
83
|
+
.query({ repoPath: "repo" })
|
|
84
|
+
.set("Origin", origin)
|
|
85
|
+
.set("Host", "127.0.0.1")
|
|
86
|
+
.set("Authorization", `Bearer ${token}`);
|
|
87
|
+
(0, vitest_1.expect)(res.status).toBe(409);
|
|
88
|
+
(0, vitest_1.expect)(res.body.errorCode).toBe("workspace_required");
|
|
89
|
+
});
|
|
90
|
+
(0, vitest_1.it)("validates repoUrl on clone", async () => {
|
|
91
|
+
const workspaceRoot = await createTempDir();
|
|
92
|
+
const { app, ctx } = await createContext(workspaceRoot, origin);
|
|
93
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
94
|
+
const res = await (0, supertest_1.default)(app)
|
|
95
|
+
.post("/v1/git/clone")
|
|
96
|
+
.set("Origin", origin)
|
|
97
|
+
.set("Host", "127.0.0.1")
|
|
98
|
+
.set("Authorization", `Bearer ${token}`)
|
|
99
|
+
.send({ repoUrl: "file:///tmp/repo", destRelative: "repo" });
|
|
100
|
+
(0, vitest_1.expect)(res.status).toBe(422);
|
|
101
|
+
(0, vitest_1.expect)(res.body.errorCode).toBe("invalid_repo_url");
|
|
102
|
+
});
|
|
103
|
+
});
|