git-daemon 0.1.3 → 0.1.5
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 +4 -4
- package/README.md +31 -0
- package/config.schema.json +22 -0
- package/dist/cli-test-clone.js +111 -56
- package/dist/config.js +8 -0
- package/dist/daemon.js +50 -1
- package/dist/logger.js +3 -3
- package/dist/setup.js +4 -2
- package/openapi.yaml +1 -0
- package/package.json +3 -2
- package/scripts/generate-local-cert.sh +21 -0
- package/scripts/test-clone.sh +10 -0
- package/src/cli-test-clone.ts +140 -56
- package/src/config.ts +8 -0
- package/src/daemon.ts +55 -1
- package/src/logger.ts +3 -1
- package/src/types.ts +6 -0
package/.eslintrc.cjs
CHANGED
|
@@ -2,17 +2,17 @@ module.exports = {
|
|
|
2
2
|
root: true,
|
|
3
3
|
env: {
|
|
4
4
|
node: true,
|
|
5
|
-
es2020: true
|
|
5
|
+
es2020: true,
|
|
6
6
|
},
|
|
7
7
|
parser: "@typescript-eslint/parser",
|
|
8
8
|
plugins: ["@typescript-eslint", "prettier"],
|
|
9
9
|
extends: [
|
|
10
10
|
"eslint:recommended",
|
|
11
11
|
"plugin:@typescript-eslint/recommended",
|
|
12
|
-
"plugin:prettier/recommended"
|
|
12
|
+
"plugin:prettier/recommended",
|
|
13
13
|
],
|
|
14
14
|
rules: {
|
|
15
|
-
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }]
|
|
15
|
+
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
16
16
|
},
|
|
17
|
-
ignorePatterns: ["dist/", "node_modules/", "workspace/"]
|
|
17
|
+
ignorePatterns: ["dist/", "node_modules/", "workspace/"],
|
|
18
18
|
};
|
package/README.md
CHANGED
|
@@ -41,6 +41,37 @@ npm run daemon
|
|
|
41
41
|
|
|
42
42
|
The daemon listens on `http://127.0.0.1:8790` by default.
|
|
43
43
|
|
|
44
|
+
## HTTPS support
|
|
45
|
+
|
|
46
|
+
The daemon can also listen on HTTPS (with a locally-trusted certificate).
|
|
47
|
+
|
|
48
|
+
Generate a local cert/key (requires `mkcert`):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm run cert:local
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This writes certs under your daemon config directory (e.g. `~/Library/Preferences/Git Daemon/certs` on macOS).
|
|
55
|
+
Then update your config (example):
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"server": {
|
|
60
|
+
"host": "127.0.0.1",
|
|
61
|
+
"port": 8790,
|
|
62
|
+
"https": {
|
|
63
|
+
"enabled": true,
|
|
64
|
+
"port": 8791,
|
|
65
|
+
"keyPath": "/absolute/path/to/certs/localhost-key.pem",
|
|
66
|
+
"certPath": "/absolute/path/to/certs/localhost.pem"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For HTTPS test clones, `npm run test:clone` auto-detects mkcert and sets
|
|
73
|
+
`NODE_EXTRA_CA_CERTS` unless you disable it with `MKCERT_AUTO_TRUST=0`.
|
|
74
|
+
|
|
44
75
|
## Setup workspace root
|
|
45
76
|
|
|
46
77
|
```bash
|
package/config.schema.json
CHANGED
|
@@ -36,6 +36,28 @@
|
|
|
36
36
|
"minimum": 1,
|
|
37
37
|
"maximum": 65535,
|
|
38
38
|
"default": 8790
|
|
39
|
+
},
|
|
40
|
+
"https": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"properties": {
|
|
44
|
+
"enabled": {
|
|
45
|
+
"type": "boolean",
|
|
46
|
+
"default": false
|
|
47
|
+
},
|
|
48
|
+
"port": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"minimum": 1,
|
|
51
|
+
"maximum": 65535,
|
|
52
|
+
"default": 8791
|
|
53
|
+
},
|
|
54
|
+
"keyPath": {
|
|
55
|
+
"type": "string"
|
|
56
|
+
},
|
|
57
|
+
"certPath": {
|
|
58
|
+
"type": "string"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
39
61
|
}
|
|
40
62
|
}
|
|
41
63
|
},
|
package/dist/cli-test-clone.js
CHANGED
|
@@ -4,79 +4,108 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const http_1 = __importDefault(require("http"));
|
|
7
|
+
const https_1 = __importDefault(require("https"));
|
|
7
8
|
const getEnv = (key, fallback) => process.env[key] || fallback;
|
|
8
9
|
const ORIGIN = getEnv("ORIGIN", "https://app.example.com");
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
10
|
+
const HTTP_PORT = Number(getEnv("PORT", "8790"));
|
|
11
|
+
const HTTPS_PORT = Number(getEnv("HTTPS_PORT", "8791"));
|
|
12
|
+
const HTTP_BASE = `http://127.0.0.1:${HTTP_PORT}`;
|
|
13
|
+
const HTTPS_BASE = `https://127.0.0.1:${HTTPS_PORT}`;
|
|
14
|
+
const HTTP_REPO = getEnv("HTTP_REPO_URL", "git@github.com:bunnybones1/git-daemon.git");
|
|
15
|
+
const HTTP_DEST = getEnv("HTTP_DEST_RELATIVE", "bunnybones1/git-daemon");
|
|
16
|
+
const HTTPS_REPO = getEnv("HTTPS_REPO_URL", "git@github.com:bunnybones1/github-thing.git");
|
|
17
|
+
const HTTPS_DEST = getEnv("HTTPS_DEST_RELATIVE", "bunnybones1/github-thing");
|
|
18
|
+
const HTTPS_INSECURE = getEnv("HTTPS_INSECURE", "0") === "1";
|
|
19
|
+
const asRecord = (value) => value && typeof value === "object"
|
|
20
|
+
? value
|
|
21
|
+
: null;
|
|
22
|
+
const getString = (value) => typeof value === "string" ? value : null;
|
|
23
|
+
const readJson = (res) => new Promise((resolve, reject) => {
|
|
24
|
+
let raw = "";
|
|
25
|
+
res.setEncoding("utf8");
|
|
26
|
+
res.on("data", (chunk) => {
|
|
27
|
+
raw += chunk;
|
|
28
|
+
});
|
|
29
|
+
res.on("end", () => {
|
|
30
|
+
try {
|
|
31
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
32
|
+
resolve({ status: res.statusCode || 0, data });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
reject(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
const requestJson = (client, baseUrl, path, body, token) => new Promise((resolve, reject) => {
|
|
14
40
|
const payload = JSON.stringify(body);
|
|
15
|
-
const
|
|
41
|
+
const headers = {
|
|
42
|
+
Origin: ORIGIN,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
45
|
+
};
|
|
46
|
+
if (token) {
|
|
47
|
+
headers.Authorization = `Bearer ${token}`;
|
|
48
|
+
}
|
|
49
|
+
const req = client.request(`${baseUrl}${path}`, {
|
|
16
50
|
method: "POST",
|
|
17
|
-
headers
|
|
18
|
-
Origin: ORIGIN,
|
|
19
|
-
"Content-Type": "application/json",
|
|
20
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
21
|
-
},
|
|
51
|
+
headers,
|
|
22
52
|
}, (res) => {
|
|
23
|
-
|
|
24
|
-
res.setEncoding("utf8");
|
|
25
|
-
res.on("data", (chunk) => {
|
|
26
|
-
raw += chunk;
|
|
27
|
-
});
|
|
28
|
-
res.on("end", () => {
|
|
29
|
-
try {
|
|
30
|
-
const data = raw ? JSON.parse(raw) : {};
|
|
31
|
-
resolve({ status: res.statusCode || 0, data });
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
reject(err);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
53
|
+
readJson(res).then(resolve).catch(reject);
|
|
37
54
|
});
|
|
38
55
|
req.on("error", reject);
|
|
39
56
|
req.write(payload);
|
|
40
57
|
req.end();
|
|
41
58
|
});
|
|
42
|
-
const
|
|
59
|
+
const requestJsonHttps = (path, body, token) => new Promise((resolve, reject) => {
|
|
43
60
|
const payload = JSON.stringify(body);
|
|
44
|
-
const
|
|
61
|
+
const headers = {
|
|
62
|
+
Origin: ORIGIN,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
65
|
+
};
|
|
66
|
+
if (token) {
|
|
67
|
+
headers.Authorization = `Bearer ${token}`;
|
|
68
|
+
}
|
|
69
|
+
const req = https_1.default.request(`${HTTPS_BASE}${path}`, {
|
|
45
70
|
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
73
|
+
}, (res) => {
|
|
74
|
+
readJson(res).then(resolve).catch(reject);
|
|
75
|
+
});
|
|
76
|
+
req.on("error", reject);
|
|
77
|
+
req.write(payload);
|
|
78
|
+
req.end();
|
|
79
|
+
});
|
|
80
|
+
const streamEvents = (client, baseUrl, jobId, token) => new Promise((resolve, reject) => {
|
|
81
|
+
const req = client.request(`${baseUrl}/v1/jobs/${jobId}/stream`, {
|
|
82
|
+
method: "GET",
|
|
46
83
|
headers: {
|
|
47
84
|
Origin: ORIGIN,
|
|
48
85
|
Authorization: `Bearer ${token}`,
|
|
49
|
-
|
|
50
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
86
|
+
Accept: "text/event-stream",
|
|
51
87
|
},
|
|
52
88
|
}, (res) => {
|
|
53
|
-
let raw = "";
|
|
54
89
|
res.setEncoding("utf8");
|
|
55
90
|
res.on("data", (chunk) => {
|
|
56
|
-
|
|
91
|
+
process.stdout.write(chunk);
|
|
57
92
|
});
|
|
58
93
|
res.on("end", () => {
|
|
59
|
-
|
|
60
|
-
const data = raw ? JSON.parse(raw) : {};
|
|
61
|
-
resolve({ status: res.statusCode || 0, data });
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
reject(err);
|
|
65
|
-
}
|
|
94
|
+
resolve();
|
|
66
95
|
});
|
|
67
96
|
});
|
|
68
97
|
req.on("error", reject);
|
|
69
|
-
req.write(payload);
|
|
70
98
|
req.end();
|
|
71
99
|
});
|
|
72
|
-
const
|
|
73
|
-
const req =
|
|
100
|
+
const streamEventsHttps = (jobId, token) => new Promise((resolve, reject) => {
|
|
101
|
+
const req = https_1.default.request(`${HTTPS_BASE}/v1/jobs/${jobId}/stream`, {
|
|
74
102
|
method: "GET",
|
|
75
103
|
headers: {
|
|
76
104
|
Origin: ORIGIN,
|
|
77
105
|
Authorization: `Bearer ${token}`,
|
|
78
106
|
Accept: "text/event-stream",
|
|
79
107
|
},
|
|
108
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
80
109
|
}, (res) => {
|
|
81
110
|
res.setEncoding("utf8");
|
|
82
111
|
res.on("data", (chunk) => {
|
|
@@ -90,31 +119,57 @@ const streamEvents = (jobId, token) => new Promise((resolve, reject) => {
|
|
|
90
119
|
req.end();
|
|
91
120
|
});
|
|
92
121
|
const main = async () => {
|
|
93
|
-
const start = await requestJson("/v1/pair", {
|
|
94
|
-
|
|
122
|
+
const start = await requestJson(http_1.default, HTTP_BASE, "/v1/pair", {
|
|
123
|
+
step: "start",
|
|
124
|
+
});
|
|
125
|
+
const startData = asRecord(start.data);
|
|
126
|
+
const code = startData ? getString(startData.code) : null;
|
|
127
|
+
if (start.status !== 200 || !code) {
|
|
95
128
|
console.error("Pairing start failed", start.data);
|
|
96
129
|
process.exit(1);
|
|
97
130
|
}
|
|
98
|
-
const confirm = await requestJson("/v1/pair", {
|
|
131
|
+
const confirm = await requestJson(http_1.default, HTTP_BASE, "/v1/pair", {
|
|
99
132
|
step: "confirm",
|
|
100
|
-
code
|
|
133
|
+
code,
|
|
101
134
|
});
|
|
102
|
-
|
|
135
|
+
const confirmData = asRecord(confirm.data);
|
|
136
|
+
const token = confirmData ? getString(confirmData.accessToken) : null;
|
|
137
|
+
if (confirm.status !== 200 || !token) {
|
|
103
138
|
console.error("Pairing confirm failed", confirm.data);
|
|
104
139
|
process.exit(1);
|
|
105
140
|
}
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
repoUrl:
|
|
109
|
-
destRelative:
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
|
|
141
|
+
let hadError = false;
|
|
142
|
+
const httpClone = await requestJson(http_1.default, HTTP_BASE, "/v1/git/clone", {
|
|
143
|
+
repoUrl: HTTP_REPO,
|
|
144
|
+
destRelative: HTTP_DEST,
|
|
145
|
+
}, token);
|
|
146
|
+
const httpCloneData = asRecord(httpClone.data);
|
|
147
|
+
const httpJobId = httpCloneData ? getString(httpCloneData.jobId) : null;
|
|
148
|
+
if (httpClone.status !== 202 || !httpJobId) {
|
|
149
|
+
console.error("HTTP clone request failed", httpClone.data);
|
|
150
|
+
hadError = true;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log(`http jobId=${httpJobId}`);
|
|
154
|
+
await streamEvents(http_1.default, HTTP_BASE, httpJobId, token);
|
|
155
|
+
}
|
|
156
|
+
const httpsClone = await requestJsonHttps("/v1/git/clone", {
|
|
157
|
+
repoUrl: HTTPS_REPO,
|
|
158
|
+
destRelative: HTTPS_DEST,
|
|
159
|
+
}, token);
|
|
160
|
+
const httpsCloneData = asRecord(httpsClone.data);
|
|
161
|
+
const httpsJobId = httpsCloneData ? getString(httpsCloneData.jobId) : null;
|
|
162
|
+
if (httpsClone.status !== 202 || !httpsJobId) {
|
|
163
|
+
console.error("HTTPS clone request failed", httpsClone.data);
|
|
164
|
+
hadError = true;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log(`https jobId=${httpsJobId}`);
|
|
168
|
+
await streamEventsHttps(httpsJobId, token);
|
|
169
|
+
}
|
|
170
|
+
if (hadError) {
|
|
113
171
|
process.exit(1);
|
|
114
172
|
}
|
|
115
|
-
const jobId = clone.data.jobId;
|
|
116
|
-
console.log(`jobId=${jobId}`);
|
|
117
|
-
await streamEvents(jobId, token);
|
|
118
173
|
};
|
|
119
174
|
main().catch((err) => {
|
|
120
175
|
console.error("Test clone failed", err);
|
package/dist/config.js
CHANGED
|
@@ -28,6 +28,10 @@ const defaultConfig = () => ({
|
|
|
28
28
|
server: {
|
|
29
29
|
host: "127.0.0.1",
|
|
30
30
|
port: 8790,
|
|
31
|
+
https: {
|
|
32
|
+
enabled: false,
|
|
33
|
+
port: 8791,
|
|
34
|
+
},
|
|
31
35
|
},
|
|
32
36
|
originAllowlist: ["https://app.example.com"],
|
|
33
37
|
workspaceRoot: null,
|
|
@@ -67,6 +71,10 @@ const loadConfig = async (configDir) => {
|
|
|
67
71
|
server: {
|
|
68
72
|
...(0, exports.defaultConfig)().server,
|
|
69
73
|
...data.server,
|
|
74
|
+
https: {
|
|
75
|
+
...(0, exports.defaultConfig)().server.https,
|
|
76
|
+
...data.server?.https,
|
|
77
|
+
},
|
|
70
78
|
},
|
|
71
79
|
pairing: {
|
|
72
80
|
...(0, exports.defaultConfig)().pairing,
|
package/dist/daemon.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
const context_1 = require("./context");
|
|
4
7
|
const app_1 = require("./app");
|
|
8
|
+
const http_1 = __importDefault(require("http"));
|
|
9
|
+
const https_1 = __importDefault(require("https"));
|
|
10
|
+
const fs_1 = require("fs");
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
5
12
|
const start = async () => {
|
|
6
13
|
const ctx = await (0, context_1.createContext)();
|
|
7
14
|
const app = (0, app_1.createApp)(ctx);
|
|
@@ -20,13 +27,55 @@ const start = async () => {
|
|
|
20
27
|
console.log(` port: ${startupSummary.port}`);
|
|
21
28
|
console.log(` workspace: ${startupSummary.workspaceRoot}`);
|
|
22
29
|
console.log(` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`);
|
|
30
|
+
const httpsConfig = ctx.config.server.https;
|
|
31
|
+
if (httpsConfig?.enabled) {
|
|
32
|
+
console.log(` https: enabled (port ${httpsConfig.port ?? ctx.config.server.port + 1})`);
|
|
33
|
+
console.log(` https.key: ${httpsConfig.keyPath ?? "missing"}`);
|
|
34
|
+
console.log(` https.cert: ${httpsConfig.certPath ?? "missing"}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(" https: disabled");
|
|
38
|
+
}
|
|
23
39
|
}
|
|
24
|
-
|
|
40
|
+
const httpServer = http_1.default.createServer(app);
|
|
41
|
+
httpServer.on("error", (err) => {
|
|
42
|
+
ctx.logger.error({ err }, "HTTP server error");
|
|
43
|
+
});
|
|
44
|
+
httpServer.listen(ctx.config.server.port, ctx.config.server.host, () => {
|
|
25
45
|
ctx.logger.info({
|
|
26
46
|
host: ctx.config.server.host,
|
|
27
47
|
port: ctx.config.server.port,
|
|
48
|
+
protocol: "http",
|
|
28
49
|
}, "Git Daemon listening");
|
|
29
50
|
});
|
|
51
|
+
const httpsConfig = ctx.config.server.https;
|
|
52
|
+
if (httpsConfig?.enabled) {
|
|
53
|
+
if (!httpsConfig.keyPath || !httpsConfig.certPath) {
|
|
54
|
+
throw new Error("HTTPS enabled but keyPath/certPath not configured in server.https.");
|
|
55
|
+
}
|
|
56
|
+
const keyPath = path_1.default.isAbsolute(httpsConfig.keyPath)
|
|
57
|
+
? httpsConfig.keyPath
|
|
58
|
+
: path_1.default.join(ctx.configDir, httpsConfig.keyPath);
|
|
59
|
+
const certPath = path_1.default.isAbsolute(httpsConfig.certPath)
|
|
60
|
+
? httpsConfig.certPath
|
|
61
|
+
: path_1.default.join(ctx.configDir, httpsConfig.certPath);
|
|
62
|
+
const [key, cert] = await Promise.all([
|
|
63
|
+
fs_1.promises.readFile(keyPath),
|
|
64
|
+
fs_1.promises.readFile(certPath),
|
|
65
|
+
]);
|
|
66
|
+
const httpsServer = https_1.default.createServer({ key, cert }, app);
|
|
67
|
+
httpsServer.on("error", (err) => {
|
|
68
|
+
ctx.logger.error({ err }, "HTTPS server error");
|
|
69
|
+
});
|
|
70
|
+
const httpsPort = httpsConfig.port ?? ctx.config.server.port + 1;
|
|
71
|
+
httpsServer.listen(httpsPort, ctx.config.server.host, () => {
|
|
72
|
+
ctx.logger.info({
|
|
73
|
+
host: ctx.config.server.host,
|
|
74
|
+
port: httpsPort,
|
|
75
|
+
protocol: "https",
|
|
76
|
+
}, "Git Daemon listening (TLS)");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
30
79
|
};
|
|
31
80
|
start().catch((err) => {
|
|
32
81
|
console.error("Failed to start Git Daemon", err);
|
package/dist/logger.js
CHANGED
|
@@ -39,7 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
exports.createHttpLogger = exports.createLogger = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const fs_1 = require("fs");
|
|
42
|
-
const pino_1 =
|
|
42
|
+
const pino_1 = __importStar(require("pino"));
|
|
43
43
|
const pino_http_1 = __importDefault(require("pino-http"));
|
|
44
44
|
const rotating_file_stream_1 = require("rotating-file-stream");
|
|
45
45
|
const createLogger = async (configDir, logging, enabled = true) => {
|
|
@@ -70,8 +70,8 @@ const createLogger = async (configDir, logging, enabled = true) => {
|
|
|
70
70
|
else {
|
|
71
71
|
streams.push({ stream: process.stdout });
|
|
72
72
|
}
|
|
73
|
-
return (0, pino_1.default)({ enabled, level }, pino_1.
|
|
73
|
+
return (0, pino_1.default)({ enabled, level }, (0, pino_1.multistream)(streams));
|
|
74
74
|
};
|
|
75
75
|
exports.createLogger = createLogger;
|
|
76
|
-
const createHttpLogger = (logger) => (0, pino_http_1.default)({ logger
|
|
76
|
+
const createHttpLogger = (logger) => (0, pino_http_1.default)({ logger });
|
|
77
77
|
exports.createHttpLogger = createHttpLogger;
|
package/dist/setup.js
CHANGED
|
@@ -81,7 +81,8 @@ const askQuestion = (rl, question) => new Promise((resolve) => {
|
|
|
81
81
|
rl.question(question, (answer) => resolve(answer));
|
|
82
82
|
});
|
|
83
83
|
const promptForWorkspace = async (rl, initialValue) => {
|
|
84
|
-
|
|
84
|
+
let result = null;
|
|
85
|
+
while (result === null) {
|
|
85
86
|
const answer = await askQuestion(rl, `Workspace root directory (absolute path) [${initialValue}]: `);
|
|
86
87
|
const trimmed = answer.trim();
|
|
87
88
|
const value = trimmed.length > 0 ? trimmed : initialValue;
|
|
@@ -93,8 +94,9 @@ const promptForWorkspace = async (rl, initialValue) => {
|
|
|
93
94
|
console.log("Workspace root must be an absolute path.");
|
|
94
95
|
continue;
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
+
result = value;
|
|
97
98
|
}
|
|
99
|
+
return result;
|
|
98
100
|
};
|
|
99
101
|
const promptYesNo = async (rl, message, defaultValue) => {
|
|
100
102
|
const suffix = defaultValue ? "[Y/n]" : "[y/N]";
|
package/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/daemon.js",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"daemon:setup": "tsx src/setup.ts",
|
|
11
11
|
"test": "vitest run",
|
|
12
12
|
"test:watch": "vitest",
|
|
13
|
-
"test:clone": "
|
|
13
|
+
"test:clone": "bash scripts/test-clone.sh",
|
|
14
|
+
"cert:local": "bash scripts/generate-local-cert.sh",
|
|
14
15
|
"lint": "eslint . --ext .ts",
|
|
15
16
|
"lint:fix": "eslint . --ext .ts --fix"
|
|
16
17
|
},
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
if ! command -v mkcert >/dev/null 2>&1; then
|
|
5
|
+
echo "mkcert is required to generate a locally-trusted certificate."
|
|
6
|
+
echo "Install it from https://github.com/FiloSottile/mkcert and retry."
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR=$(node -e "const mod=require('env-paths'); const envPaths=mod.default||mod; console.log(envPaths('Git Daemon',{suffix:''}).config)")
|
|
11
|
+
CERT_DIR="${CONFIG_DIR}/certs"
|
|
12
|
+
|
|
13
|
+
mkdir -p "${CERT_DIR}"
|
|
14
|
+
|
|
15
|
+
mkcert -install
|
|
16
|
+
mkcert \
|
|
17
|
+
-key-file "${CERT_DIR}/localhost-key.pem" \
|
|
18
|
+
-cert-file "${CERT_DIR}/localhost.pem" \
|
|
19
|
+
127.0.0.1 localhost ::1
|
|
20
|
+
|
|
21
|
+
echo "Generated certs in ${CERT_DIR}"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
if [[ "${MKCERT_AUTO_TRUST:-1}" != "0" ]] && command -v mkcert >/dev/null 2>&1; then
|
|
5
|
+
CAROOT=$(mkcert -CAROOT)
|
|
6
|
+
export NODE_EXTRA_CA_CERTS="${CAROOT}/rootCA.pem"
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
mkdir -p .tmp
|
|
10
|
+
TMPDIR="$PWD/.tmp" exec tsx src/cli-test-clone.ts
|
package/src/cli-test-clone.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import http from "http";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import type { IncomingMessage } from "http";
|
|
2
4
|
|
|
3
5
|
const getEnv = (key: string, fallback: string) => process.env[key] || fallback;
|
|
4
6
|
|
|
5
7
|
const ORIGIN = getEnv("ORIGIN", "https://app.example.com");
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const HTTP_PORT = Number(getEnv("PORT", "8790"));
|
|
9
|
+
const HTTPS_PORT = Number(getEnv("HTTPS_PORT", "8791"));
|
|
10
|
+
const HTTP_BASE = `http://127.0.0.1:${HTTP_PORT}`;
|
|
11
|
+
const HTTPS_BASE = `https://127.0.0.1:${HTTPS_PORT}`;
|
|
12
|
+
const HTTP_REPO = getEnv(
|
|
13
|
+
"HTTP_REPO_URL",
|
|
14
|
+
"git@github.com:bunnybones1/git-daemon.git",
|
|
15
|
+
);
|
|
16
|
+
const HTTP_DEST = getEnv("HTTP_DEST_RELATIVE", "bunnybones1/git-daemon");
|
|
17
|
+
const HTTPS_REPO = getEnv(
|
|
18
|
+
"HTTPS_REPO_URL",
|
|
19
|
+
"git@github.com:bunnybones1/github-thing.git",
|
|
20
|
+
);
|
|
21
|
+
const HTTPS_DEST = getEnv("HTTPS_DEST_RELATIVE", "bunnybones1/github-thing");
|
|
22
|
+
const HTTPS_INSECURE = getEnv("HTTPS_INSECURE", "0") === "1";
|
|
10
23
|
|
|
11
24
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
12
25
|
value && typeof value === "object"
|
|
@@ -16,33 +29,48 @@ const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
|
16
29
|
const getString = (value: unknown): string | null =>
|
|
17
30
|
typeof value === "string" ? value : null;
|
|
18
31
|
|
|
19
|
-
const
|
|
32
|
+
const readJson = (res: IncomingMessage) =>
|
|
33
|
+
new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
34
|
+
let raw = "";
|
|
35
|
+
res.setEncoding("utf8");
|
|
36
|
+
res.on("data", (chunk) => {
|
|
37
|
+
raw += chunk;
|
|
38
|
+
});
|
|
39
|
+
res.on("end", () => {
|
|
40
|
+
try {
|
|
41
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
42
|
+
resolve({ status: res.statusCode || 0, data });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
reject(err);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const requestJson = (
|
|
50
|
+
client: typeof http,
|
|
51
|
+
baseUrl: string,
|
|
52
|
+
path: string,
|
|
53
|
+
body: unknown,
|
|
54
|
+
token?: string,
|
|
55
|
+
) =>
|
|
20
56
|
new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
21
57
|
const payload = JSON.stringify(body);
|
|
22
|
-
const
|
|
23
|
-
|
|
58
|
+
const headers: Record<string, string | number> = {
|
|
59
|
+
Origin: ORIGIN,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
62
|
+
};
|
|
63
|
+
if (token) {
|
|
64
|
+
headers.Authorization = `Bearer ${token}`;
|
|
65
|
+
}
|
|
66
|
+
const req = client.request(
|
|
67
|
+
`${baseUrl}${path}`,
|
|
24
68
|
{
|
|
25
69
|
method: "POST",
|
|
26
|
-
headers
|
|
27
|
-
Origin: ORIGIN,
|
|
28
|
-
"Content-Type": "application/json",
|
|
29
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
30
|
-
},
|
|
70
|
+
headers,
|
|
31
71
|
},
|
|
32
72
|
(res) => {
|
|
33
|
-
|
|
34
|
-
res.setEncoding("utf8");
|
|
35
|
-
res.on("data", (chunk) => {
|
|
36
|
-
raw += chunk;
|
|
37
|
-
});
|
|
38
|
-
res.on("end", () => {
|
|
39
|
-
try {
|
|
40
|
-
const data = raw ? JSON.parse(raw) : {};
|
|
41
|
-
resolve({ status: res.statusCode || 0, data });
|
|
42
|
-
} catch (err) {
|
|
43
|
-
reject(err);
|
|
44
|
-
}
|
|
45
|
-
});
|
|
73
|
+
readJson(res).then(resolve).catch(reject);
|
|
46
74
|
},
|
|
47
75
|
);
|
|
48
76
|
|
|
@@ -51,46 +79,69 @@ const requestJson = (path: string, body: unknown) =>
|
|
|
51
79
|
req.end();
|
|
52
80
|
});
|
|
53
81
|
|
|
54
|
-
const
|
|
82
|
+
const requestJsonHttps = (path: string, body: unknown, token?: string) =>
|
|
55
83
|
new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
56
84
|
const payload = JSON.stringify(body);
|
|
57
|
-
const
|
|
58
|
-
|
|
85
|
+
const headers: Record<string, string | number> = {
|
|
86
|
+
Origin: ORIGIN,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
89
|
+
};
|
|
90
|
+
if (token) {
|
|
91
|
+
headers.Authorization = `Bearer ${token}`;
|
|
92
|
+
}
|
|
93
|
+
const req = https.request(
|
|
94
|
+
`${HTTPS_BASE}${path}`,
|
|
59
95
|
{
|
|
60
96
|
method: "POST",
|
|
97
|
+
headers,
|
|
98
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
99
|
+
},
|
|
100
|
+
(res) => {
|
|
101
|
+
readJson(res).then(resolve).catch(reject);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
req.on("error", reject);
|
|
106
|
+
req.write(payload);
|
|
107
|
+
req.end();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const streamEvents = (
|
|
111
|
+
client: typeof http,
|
|
112
|
+
baseUrl: string,
|
|
113
|
+
jobId: string,
|
|
114
|
+
token: string,
|
|
115
|
+
) =>
|
|
116
|
+
new Promise<void>((resolve, reject) => {
|
|
117
|
+
const req = client.request(
|
|
118
|
+
`${baseUrl}/v1/jobs/${jobId}/stream`,
|
|
119
|
+
{
|
|
120
|
+
method: "GET",
|
|
61
121
|
headers: {
|
|
62
122
|
Origin: ORIGIN,
|
|
63
123
|
Authorization: `Bearer ${token}`,
|
|
64
|
-
|
|
65
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
124
|
+
Accept: "text/event-stream",
|
|
66
125
|
},
|
|
67
126
|
},
|
|
68
127
|
(res) => {
|
|
69
|
-
let raw = "";
|
|
70
128
|
res.setEncoding("utf8");
|
|
71
129
|
res.on("data", (chunk) => {
|
|
72
|
-
|
|
130
|
+
process.stdout.write(chunk);
|
|
73
131
|
});
|
|
74
132
|
res.on("end", () => {
|
|
75
|
-
|
|
76
|
-
const data = raw ? JSON.parse(raw) : {};
|
|
77
|
-
resolve({ status: res.statusCode || 0, data });
|
|
78
|
-
} catch (err) {
|
|
79
|
-
reject(err);
|
|
80
|
-
}
|
|
133
|
+
resolve();
|
|
81
134
|
});
|
|
82
135
|
},
|
|
83
136
|
);
|
|
84
|
-
|
|
85
137
|
req.on("error", reject);
|
|
86
|
-
req.write(payload);
|
|
87
138
|
req.end();
|
|
88
139
|
});
|
|
89
140
|
|
|
90
|
-
const
|
|
141
|
+
const streamEventsHttps = (jobId: string, token: string) =>
|
|
91
142
|
new Promise<void>((resolve, reject) => {
|
|
92
|
-
const req =
|
|
93
|
-
`${
|
|
143
|
+
const req = https.request(
|
|
144
|
+
`${HTTPS_BASE}/v1/jobs/${jobId}/stream`,
|
|
94
145
|
{
|
|
95
146
|
method: "GET",
|
|
96
147
|
headers: {
|
|
@@ -98,6 +149,7 @@ const streamEvents = (jobId: string, token: string) =>
|
|
|
98
149
|
Authorization: `Bearer ${token}`,
|
|
99
150
|
Accept: "text/event-stream",
|
|
100
151
|
},
|
|
152
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
101
153
|
},
|
|
102
154
|
(res) => {
|
|
103
155
|
res.setEncoding("utf8");
|
|
@@ -114,7 +166,9 @@ const streamEvents = (jobId: string, token: string) =>
|
|
|
114
166
|
});
|
|
115
167
|
|
|
116
168
|
const main = async () => {
|
|
117
|
-
const start = await requestJson("/v1/pair", {
|
|
169
|
+
const start = await requestJson(http, HTTP_BASE, "/v1/pair", {
|
|
170
|
+
step: "start",
|
|
171
|
+
});
|
|
118
172
|
const startData = asRecord(start.data);
|
|
119
173
|
const code = startData ? getString(startData.code) : null;
|
|
120
174
|
if (start.status !== 200 || !code) {
|
|
@@ -122,7 +176,7 @@ const main = async () => {
|
|
|
122
176
|
process.exit(1);
|
|
123
177
|
}
|
|
124
178
|
|
|
125
|
-
const confirm = await requestJson("/v1/pair", {
|
|
179
|
+
const confirm = await requestJson(http, HTTP_BASE, "/v1/pair", {
|
|
126
180
|
step: "confirm",
|
|
127
181
|
code,
|
|
128
182
|
});
|
|
@@ -133,19 +187,49 @@ const main = async () => {
|
|
|
133
187
|
process.exit(1);
|
|
134
188
|
}
|
|
135
189
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
190
|
+
let hadError = false;
|
|
191
|
+
|
|
192
|
+
const httpClone = await requestJson(
|
|
193
|
+
http,
|
|
194
|
+
HTTP_BASE,
|
|
195
|
+
"/v1/git/clone",
|
|
196
|
+
{
|
|
197
|
+
repoUrl: HTTP_REPO,
|
|
198
|
+
destRelative: HTTP_DEST,
|
|
199
|
+
},
|
|
200
|
+
token,
|
|
201
|
+
);
|
|
202
|
+
const httpCloneData = asRecord(httpClone.data);
|
|
203
|
+
const httpJobId = httpCloneData ? getString(httpCloneData.jobId) : null;
|
|
204
|
+
if (httpClone.status !== 202 || !httpJobId) {
|
|
205
|
+
console.error("HTTP clone request failed", httpClone.data);
|
|
206
|
+
hadError = true;
|
|
207
|
+
} else {
|
|
208
|
+
console.log(`http jobId=${httpJobId}`);
|
|
209
|
+
await streamEvents(http, HTTP_BASE, httpJobId, token);
|
|
145
210
|
}
|
|
146
211
|
|
|
147
|
-
|
|
148
|
-
|
|
212
|
+
const httpsClone = await requestJsonHttps(
|
|
213
|
+
"/v1/git/clone",
|
|
214
|
+
{
|
|
215
|
+
repoUrl: HTTPS_REPO,
|
|
216
|
+
destRelative: HTTPS_DEST,
|
|
217
|
+
},
|
|
218
|
+
token,
|
|
219
|
+
);
|
|
220
|
+
const httpsCloneData = asRecord(httpsClone.data);
|
|
221
|
+
const httpsJobId = httpsCloneData ? getString(httpsCloneData.jobId) : null;
|
|
222
|
+
if (httpsClone.status !== 202 || !httpsJobId) {
|
|
223
|
+
console.error("HTTPS clone request failed", httpsClone.data);
|
|
224
|
+
hadError = true;
|
|
225
|
+
} else {
|
|
226
|
+
console.log(`https jobId=${httpsJobId}`);
|
|
227
|
+
await streamEventsHttps(httpsJobId, token);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (hadError) {
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
149
233
|
};
|
|
150
234
|
|
|
151
235
|
main().catch((err) => {
|
package/src/config.ts
CHANGED
|
@@ -27,6 +27,10 @@ export const defaultConfig = (): AppConfig => ({
|
|
|
27
27
|
server: {
|
|
28
28
|
host: "127.0.0.1",
|
|
29
29
|
port: 8790,
|
|
30
|
+
https: {
|
|
31
|
+
enabled: false,
|
|
32
|
+
port: 8791,
|
|
33
|
+
},
|
|
30
34
|
},
|
|
31
35
|
originAllowlist: ["https://app.example.com"],
|
|
32
36
|
workspaceRoot: null,
|
|
@@ -66,6 +70,10 @@ export const loadConfig = async (configDir: string): Promise<AppConfig> => {
|
|
|
66
70
|
server: {
|
|
67
71
|
...defaultConfig().server,
|
|
68
72
|
...data.server,
|
|
73
|
+
https: {
|
|
74
|
+
...defaultConfig().server.https,
|
|
75
|
+
...data.server?.https,
|
|
76
|
+
},
|
|
69
77
|
},
|
|
70
78
|
pairing: {
|
|
71
79
|
...defaultConfig().pairing,
|
package/src/daemon.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createContext } from "./context";
|
|
2
2
|
import { createApp } from "./app";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import https from "https";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import path from "path";
|
|
3
7
|
|
|
4
8
|
const start = async () => {
|
|
5
9
|
const ctx = await createContext();
|
|
@@ -22,17 +26,67 @@ const start = async () => {
|
|
|
22
26
|
console.log(
|
|
23
27
|
` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`,
|
|
24
28
|
);
|
|
29
|
+
const httpsConfig = ctx.config.server.https;
|
|
30
|
+
if (httpsConfig?.enabled) {
|
|
31
|
+
console.log(
|
|
32
|
+
` https: enabled (port ${httpsConfig.port ?? ctx.config.server.port + 1})`,
|
|
33
|
+
);
|
|
34
|
+
console.log(` https.key: ${httpsConfig.keyPath ?? "missing"}`);
|
|
35
|
+
console.log(` https.cert: ${httpsConfig.certPath ?? "missing"}`);
|
|
36
|
+
} else {
|
|
37
|
+
console.log(" https: disabled");
|
|
38
|
+
}
|
|
25
39
|
}
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
const httpServer = http.createServer(app);
|
|
42
|
+
httpServer.on("error", (err) => {
|
|
43
|
+
ctx.logger.error({ err }, "HTTP server error");
|
|
44
|
+
});
|
|
45
|
+
httpServer.listen(ctx.config.server.port, ctx.config.server.host, () => {
|
|
28
46
|
ctx.logger.info(
|
|
29
47
|
{
|
|
30
48
|
host: ctx.config.server.host,
|
|
31
49
|
port: ctx.config.server.port,
|
|
50
|
+
protocol: "http",
|
|
32
51
|
},
|
|
33
52
|
"Git Daemon listening",
|
|
34
53
|
);
|
|
35
54
|
});
|
|
55
|
+
|
|
56
|
+
const httpsConfig = ctx.config.server.https;
|
|
57
|
+
if (httpsConfig?.enabled) {
|
|
58
|
+
if (!httpsConfig.keyPath || !httpsConfig.certPath) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"HTTPS enabled but keyPath/certPath not configured in server.https.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const keyPath = path.isAbsolute(httpsConfig.keyPath)
|
|
64
|
+
? httpsConfig.keyPath
|
|
65
|
+
: path.join(ctx.configDir, httpsConfig.keyPath);
|
|
66
|
+
const certPath = path.isAbsolute(httpsConfig.certPath)
|
|
67
|
+
? httpsConfig.certPath
|
|
68
|
+
: path.join(ctx.configDir, httpsConfig.certPath);
|
|
69
|
+
|
|
70
|
+
const [key, cert] = await Promise.all([
|
|
71
|
+
fs.readFile(keyPath),
|
|
72
|
+
fs.readFile(certPath),
|
|
73
|
+
]);
|
|
74
|
+
const httpsServer = https.createServer({ key, cert }, app);
|
|
75
|
+
httpsServer.on("error", (err) => {
|
|
76
|
+
ctx.logger.error({ err }, "HTTPS server error");
|
|
77
|
+
});
|
|
78
|
+
const httpsPort = httpsConfig.port ?? ctx.config.server.port + 1;
|
|
79
|
+
httpsServer.listen(httpsPort, ctx.config.server.host, () => {
|
|
80
|
+
ctx.logger.info(
|
|
81
|
+
{
|
|
82
|
+
host: ctx.config.server.host,
|
|
83
|
+
port: httpsPort,
|
|
84
|
+
protocol: "https",
|
|
85
|
+
},
|
|
86
|
+
"Git Daemon listening (TLS)",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
36
90
|
};
|
|
37
91
|
|
|
38
92
|
start().catch((err) => {
|
package/src/logger.ts
CHANGED
|
@@ -44,5 +44,7 @@ export const createLogger = async (
|
|
|
44
44
|
return pino({ enabled, level }, multistream(streams));
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
type PinoHttpOptions = Parameters<typeof pinoHttp>[0];
|
|
48
|
+
|
|
47
49
|
export const createHttpLogger = (logger: Logger) =>
|
|
48
|
-
pinoHttp({ logger
|
|
50
|
+
pinoHttp({ logger } as unknown as PinoHttpOptions);
|
package/src/types.ts
CHANGED