git-daemon 0.1.2 → 0.1.4
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 +1 -1
- package/README.md +52 -0
- package/config.schema.json +22 -0
- package/dist/app.js +21 -1
- package/dist/cli-test-clone.js +177 -0
- package/dist/config.js +8 -0
- package/dist/daemon.js +56 -2
- package/dist/logger.js +3 -3
- package/dist/setup.js +167 -0
- package/openapi.yaml +1 -0
- package/package.json +4 -1
- package/scripts/generate-local-cert.sh +21 -0
- package/scripts/test-clone.sh +10 -0
- package/src/app.ts +24 -1
- package/src/cli-test-clone.ts +238 -0
- package/src/config.ts +8 -0
- package/src/daemon.ts +61 -2
- package/src/jobs.ts +7 -1
- package/src/logger.ts +5 -3
- package/src/setup.ts +165 -0
- package/src/types.ts +6 -0
package/.eslintrc.cjs
CHANGED
package/README.md
CHANGED
|
@@ -41,6 +41,58 @@ 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
|
+
|
|
75
|
+
## Setup workspace root
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm run setup
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This prompts for an absolute workspace root path and saves it to your config. The prompt reads from the terminal directly (via `/dev/tty` on macOS/Linux) so it still works in many IDE run configurations.
|
|
82
|
+
For development, you can also run `npm run setup:dev`.
|
|
83
|
+
|
|
84
|
+
Non-interactive setup (no TTY):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
GIT_DAEMON_WORKSPACE_ROOT=/absolute/path npm run setup
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run setup -- --workspace=/absolute/path
|
|
94
|
+
```
|
|
95
|
+
|
|
44
96
|
Verbose logging options:
|
|
45
97
|
|
|
46
98
|
- `GIT_DAEMON_LOG_STDOUT=1` to mirror logs to stdout
|
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/app.js
CHANGED
|
@@ -116,10 +116,30 @@ const createApp = (ctx) => {
|
|
|
116
116
|
const sendEvent = (event) => {
|
|
117
117
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
118
118
|
};
|
|
119
|
+
const isTerminalState = (event) => {
|
|
120
|
+
if (!event || typeof event !== "object") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const record = event;
|
|
124
|
+
return (record.type === "state" &&
|
|
125
|
+
(record.state === "done" ||
|
|
126
|
+
record.state === "error" ||
|
|
127
|
+
record.state === "cancelled"));
|
|
128
|
+
};
|
|
119
129
|
for (const event of job.events) {
|
|
120
130
|
sendEvent(event);
|
|
131
|
+
if (isTerminalState(event)) {
|
|
132
|
+
res.end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
121
135
|
}
|
|
122
|
-
const listener = (event) =>
|
|
136
|
+
const listener = (event) => {
|
|
137
|
+
sendEvent(event);
|
|
138
|
+
if (isTerminalState(event)) {
|
|
139
|
+
job.emitter.off("event", listener);
|
|
140
|
+
res.end();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
123
143
|
job.emitter.on("event", listener);
|
|
124
144
|
req.on("close", () => {
|
|
125
145
|
job.emitter.off("event", listener);
|
|
@@ -0,0 +1,177 @@
|
|
|
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 http_1 = __importDefault(require("http"));
|
|
7
|
+
const https_1 = __importDefault(require("https"));
|
|
8
|
+
const getEnv = (key, fallback) => process.env[key] || fallback;
|
|
9
|
+
const ORIGIN = getEnv("ORIGIN", "https://app.example.com");
|
|
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) => {
|
|
40
|
+
const payload = JSON.stringify(body);
|
|
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}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers,
|
|
52
|
+
}, (res) => {
|
|
53
|
+
readJson(res).then(resolve).catch(reject);
|
|
54
|
+
});
|
|
55
|
+
req.on("error", reject);
|
|
56
|
+
req.write(payload);
|
|
57
|
+
req.end();
|
|
58
|
+
});
|
|
59
|
+
const requestJsonHttps = (path, body, token) => new Promise((resolve, reject) => {
|
|
60
|
+
const payload = JSON.stringify(body);
|
|
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}`, {
|
|
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",
|
|
83
|
+
headers: {
|
|
84
|
+
Origin: ORIGIN,
|
|
85
|
+
Authorization: `Bearer ${token}`,
|
|
86
|
+
Accept: "text/event-stream",
|
|
87
|
+
},
|
|
88
|
+
}, (res) => {
|
|
89
|
+
res.setEncoding("utf8");
|
|
90
|
+
res.on("data", (chunk) => {
|
|
91
|
+
process.stdout.write(chunk);
|
|
92
|
+
});
|
|
93
|
+
res.on("end", () => {
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
req.on("error", reject);
|
|
98
|
+
req.end();
|
|
99
|
+
});
|
|
100
|
+
const streamEventsHttps = (jobId, token) => new Promise((resolve, reject) => {
|
|
101
|
+
const req = https_1.default.request(`${HTTPS_BASE}/v1/jobs/${jobId}/stream`, {
|
|
102
|
+
method: "GET",
|
|
103
|
+
headers: {
|
|
104
|
+
Origin: ORIGIN,
|
|
105
|
+
Authorization: `Bearer ${token}`,
|
|
106
|
+
Accept: "text/event-stream",
|
|
107
|
+
},
|
|
108
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
109
|
+
}, (res) => {
|
|
110
|
+
res.setEncoding("utf8");
|
|
111
|
+
res.on("data", (chunk) => {
|
|
112
|
+
process.stdout.write(chunk);
|
|
113
|
+
});
|
|
114
|
+
res.on("end", () => {
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
req.on("error", reject);
|
|
119
|
+
req.end();
|
|
120
|
+
});
|
|
121
|
+
const main = async () => {
|
|
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) {
|
|
128
|
+
console.error("Pairing start failed", start.data);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const confirm = await requestJson(http_1.default, HTTP_BASE, "/v1/pair", {
|
|
132
|
+
step: "confirm",
|
|
133
|
+
code,
|
|
134
|
+
});
|
|
135
|
+
const confirmData = asRecord(confirm.data);
|
|
136
|
+
const token = confirmData ? getString(confirmData.accessToken) : null;
|
|
137
|
+
if (confirm.status !== 200 || !token) {
|
|
138
|
+
console.error("Pairing confirm failed", confirm.data);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
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) {
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
main().catch((err) => {
|
|
175
|
+
console.error("Test clone failed", err);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
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);
|
|
@@ -14,14 +21,61 @@ const start = async () => {
|
|
|
14
21
|
};
|
|
15
22
|
ctx.logger.info(startupSummary, "Git Daemon starting");
|
|
16
23
|
if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
|
|
17
|
-
console.log(
|
|
24
|
+
console.log("[Git Daemon] Startup");
|
|
25
|
+
console.log(` config: ${startupSummary.configDir}`);
|
|
26
|
+
console.log(` host: ${startupSummary.host}`);
|
|
27
|
+
console.log(` port: ${startupSummary.port}`);
|
|
28
|
+
console.log(` workspace: ${startupSummary.workspaceRoot}`);
|
|
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
|
+
}
|
|
18
39
|
}
|
|
19
|
-
|
|
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, () => {
|
|
20
45
|
ctx.logger.info({
|
|
21
46
|
host: ctx.config.server.host,
|
|
22
47
|
port: ctx.config.server.port,
|
|
48
|
+
protocol: "http",
|
|
23
49
|
}, "Git Daemon listening");
|
|
24
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
|
+
}
|
|
25
79
|
};
|
|
26
80
|
start().catch((err) => {
|
|
27
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
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const path_1 = __importDefault(require("path"));
|
|
40
|
+
const os_1 = __importDefault(require("os"));
|
|
41
|
+
const fs_1 = require("fs");
|
|
42
|
+
const fsSync = __importStar(require("fs"));
|
|
43
|
+
const readline_1 = __importDefault(require("readline"));
|
|
44
|
+
const config_1 = require("./config");
|
|
45
|
+
const expandHome = (input) => {
|
|
46
|
+
if (input === "~") {
|
|
47
|
+
return os_1.default.homedir();
|
|
48
|
+
}
|
|
49
|
+
if (input.startsWith("~/")) {
|
|
50
|
+
return path_1.default.join(os_1.default.homedir(), input.slice(2));
|
|
51
|
+
}
|
|
52
|
+
return input;
|
|
53
|
+
};
|
|
54
|
+
const pathExists = async (target) => {
|
|
55
|
+
try {
|
|
56
|
+
const stats = await fs_1.promises.stat(target);
|
|
57
|
+
return stats.isDirectory();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const createPromptInterface = () => {
|
|
64
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
65
|
+
return readline_1.default.createInterface({
|
|
66
|
+
input: process.stdin,
|
|
67
|
+
output: process.stdout,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const ttyPath = process.platform === "win32" ? "CON" : "/dev/tty";
|
|
71
|
+
try {
|
|
72
|
+
const input = fsSync.createReadStream(ttyPath, { encoding: "utf8" });
|
|
73
|
+
const output = fsSync.createWriteStream(ttyPath);
|
|
74
|
+
return readline_1.default.createInterface({ input, output });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const askQuestion = (rl, question) => new Promise((resolve) => {
|
|
81
|
+
rl.question(question, (answer) => resolve(answer));
|
|
82
|
+
});
|
|
83
|
+
const promptForWorkspace = async (rl, initialValue) => {
|
|
84
|
+
let result = null;
|
|
85
|
+
while (result === null) {
|
|
86
|
+
const answer = await askQuestion(rl, `Workspace root directory (absolute path) [${initialValue}]: `);
|
|
87
|
+
const trimmed = answer.trim();
|
|
88
|
+
const value = trimmed.length > 0 ? trimmed : initialValue;
|
|
89
|
+
if (!value) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const expanded = expandHome(value);
|
|
93
|
+
if (!path_1.default.isAbsolute(expanded)) {
|
|
94
|
+
console.log("Workspace root must be an absolute path.");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
result = value;
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
const promptYesNo = async (rl, message, defaultValue) => {
|
|
102
|
+
const suffix = defaultValue ? "[Y/n]" : "[y/N]";
|
|
103
|
+
const answer = await askQuestion(rl, `${message} ${suffix} `);
|
|
104
|
+
const normalized = answer.trim().toLowerCase();
|
|
105
|
+
if (!normalized) {
|
|
106
|
+
return defaultValue;
|
|
107
|
+
}
|
|
108
|
+
return normalized === "y" || normalized === "yes";
|
|
109
|
+
};
|
|
110
|
+
const readWorkspaceArg = () => {
|
|
111
|
+
const args = process.argv.slice(2);
|
|
112
|
+
const flagIndex = args.findIndex((arg) => arg === "--workspace");
|
|
113
|
+
if (flagIndex >= 0 && args[flagIndex + 1]) {
|
|
114
|
+
return args[flagIndex + 1];
|
|
115
|
+
}
|
|
116
|
+
const inline = args.find((arg) => arg.startsWith("--workspace="));
|
|
117
|
+
if (inline) {
|
|
118
|
+
return inline.split("=").slice(1).join("=");
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
const setup = async () => {
|
|
123
|
+
const configDir = (0, config_1.getConfigDir)();
|
|
124
|
+
const config = await (0, config_1.loadConfig)(configDir);
|
|
125
|
+
console.log(`[Git Daemon setup] config=${configDir}`);
|
|
126
|
+
const provided = process.env.GIT_DAEMON_WORKSPACE_ROOT || readWorkspaceArg();
|
|
127
|
+
let workspaceInput = provided?.trim();
|
|
128
|
+
let rl = null;
|
|
129
|
+
if (!workspaceInput) {
|
|
130
|
+
rl = createPromptInterface();
|
|
131
|
+
if (!rl) {
|
|
132
|
+
console.error("No interactive prompt available. Use GIT_DAEMON_WORKSPACE_ROOT=/path or --workspace=/path.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
workspaceInput = await promptForWorkspace(rl, config.workspaceRoot ?? process.cwd());
|
|
136
|
+
}
|
|
137
|
+
if (!workspaceInput) {
|
|
138
|
+
console.error("Workspace root was not provided.");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const expanded = expandHome(workspaceInput);
|
|
142
|
+
const resolved = path_1.default.resolve(expanded);
|
|
143
|
+
if (!(await pathExists(resolved))) {
|
|
144
|
+
if (!rl) {
|
|
145
|
+
rl = createPromptInterface();
|
|
146
|
+
}
|
|
147
|
+
if (!rl) {
|
|
148
|
+
console.error(`Directory does not exist: ${resolved}`);
|
|
149
|
+
console.error("Create it manually, then rerun setup.");
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
const create = await promptYesNo(rl, `Directory does not exist. Create ${resolved}?`, true);
|
|
153
|
+
if (!create) {
|
|
154
|
+
console.log("Setup aborted. Workspace root not saved.");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
await fs_1.promises.mkdir(resolved, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
config.workspaceRoot = resolved;
|
|
160
|
+
await (0, config_1.saveConfig)(configDir, config);
|
|
161
|
+
console.log(`Workspace root set to ${resolved}`);
|
|
162
|
+
rl?.close();
|
|
163
|
+
};
|
|
164
|
+
setup().catch((err) => {
|
|
165
|
+
console.error("Setup failed", err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
package/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/daemon.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"daemon": "tsx src/daemon.ts",
|
|
10
|
+
"daemon:setup": "tsx src/setup.ts",
|
|
10
11
|
"test": "vitest run",
|
|
11
12
|
"test:watch": "vitest",
|
|
13
|
+
"test:clone": "bash scripts/test-clone.sh",
|
|
14
|
+
"cert:local": "bash scripts/generate-local-cert.sh",
|
|
12
15
|
"lint": "eslint . --ext .ts",
|
|
13
16
|
"lint:fix": "eslint . --ext .ts --fix"
|
|
14
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/app.ts
CHANGED
|
@@ -198,11 +198,34 @@ export const createApp = (ctx: DaemonContext) => {
|
|
|
198
198
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
+
const isTerminalState = (event: unknown) => {
|
|
202
|
+
if (!event || typeof event !== "object") {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const record = event as { type?: string; state?: string };
|
|
206
|
+
return (
|
|
207
|
+
record.type === "state" &&
|
|
208
|
+
(record.state === "done" ||
|
|
209
|
+
record.state === "error" ||
|
|
210
|
+
record.state === "cancelled")
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
201
214
|
for (const event of job.events) {
|
|
202
215
|
sendEvent(event);
|
|
216
|
+
if (isTerminalState(event)) {
|
|
217
|
+
res.end();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
const listener = (event: unknown) =>
|
|
222
|
+
const listener = (event: unknown) => {
|
|
223
|
+
sendEvent(event);
|
|
224
|
+
if (isTerminalState(event)) {
|
|
225
|
+
job.emitter.off("event", listener);
|
|
226
|
+
res.end();
|
|
227
|
+
}
|
|
228
|
+
};
|
|
206
229
|
job.emitter.on("event", listener);
|
|
207
230
|
|
|
208
231
|
req.on("close", () => {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import type { IncomingMessage } from "http";
|
|
4
|
+
|
|
5
|
+
const getEnv = (key: string, fallback: string) => process.env[key] || fallback;
|
|
6
|
+
|
|
7
|
+
const ORIGIN = getEnv("ORIGIN", "https://app.example.com");
|
|
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";
|
|
23
|
+
|
|
24
|
+
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
25
|
+
value && typeof value === "object"
|
|
26
|
+
? (value as Record<string, unknown>)
|
|
27
|
+
: null;
|
|
28
|
+
|
|
29
|
+
const getString = (value: unknown): string | null =>
|
|
30
|
+
typeof value === "string" ? value : null;
|
|
31
|
+
|
|
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
|
+
) =>
|
|
56
|
+
new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
57
|
+
const payload = JSON.stringify(body);
|
|
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}`,
|
|
68
|
+
{
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
},
|
|
72
|
+
(res) => {
|
|
73
|
+
readJson(res).then(resolve).catch(reject);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
req.on("error", reject);
|
|
78
|
+
req.write(payload);
|
|
79
|
+
req.end();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const requestJsonHttps = (path: string, body: unknown, token?: string) =>
|
|
83
|
+
new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
84
|
+
const payload = JSON.stringify(body);
|
|
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}`,
|
|
95
|
+
{
|
|
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",
|
|
121
|
+
headers: {
|
|
122
|
+
Origin: ORIGIN,
|
|
123
|
+
Authorization: `Bearer ${token}`,
|
|
124
|
+
Accept: "text/event-stream",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
(res) => {
|
|
128
|
+
res.setEncoding("utf8");
|
|
129
|
+
res.on("data", (chunk) => {
|
|
130
|
+
process.stdout.write(chunk);
|
|
131
|
+
});
|
|
132
|
+
res.on("end", () => {
|
|
133
|
+
resolve();
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const streamEventsHttps = (jobId: string, token: string) =>
|
|
142
|
+
new Promise<void>((resolve, reject) => {
|
|
143
|
+
const req = https.request(
|
|
144
|
+
`${HTTPS_BASE}/v1/jobs/${jobId}/stream`,
|
|
145
|
+
{
|
|
146
|
+
method: "GET",
|
|
147
|
+
headers: {
|
|
148
|
+
Origin: ORIGIN,
|
|
149
|
+
Authorization: `Bearer ${token}`,
|
|
150
|
+
Accept: "text/event-stream",
|
|
151
|
+
},
|
|
152
|
+
rejectUnauthorized: !HTTPS_INSECURE,
|
|
153
|
+
},
|
|
154
|
+
(res) => {
|
|
155
|
+
res.setEncoding("utf8");
|
|
156
|
+
res.on("data", (chunk) => {
|
|
157
|
+
process.stdout.write(chunk);
|
|
158
|
+
});
|
|
159
|
+
res.on("end", () => {
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
req.on("error", reject);
|
|
165
|
+
req.end();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const main = async () => {
|
|
169
|
+
const start = await requestJson(http, HTTP_BASE, "/v1/pair", {
|
|
170
|
+
step: "start",
|
|
171
|
+
});
|
|
172
|
+
const startData = asRecord(start.data);
|
|
173
|
+
const code = startData ? getString(startData.code) : null;
|
|
174
|
+
if (start.status !== 200 || !code) {
|
|
175
|
+
console.error("Pairing start failed", start.data);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const confirm = await requestJson(http, HTTP_BASE, "/v1/pair", {
|
|
180
|
+
step: "confirm",
|
|
181
|
+
code,
|
|
182
|
+
});
|
|
183
|
+
const confirmData = asRecord(confirm.data);
|
|
184
|
+
const token = confirmData ? getString(confirmData.accessToken) : null;
|
|
185
|
+
if (confirm.status !== 200 || !token) {
|
|
186
|
+
console.error("Pairing confirm failed", confirm.data);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
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);
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
main().catch((err) => {
|
|
236
|
+
console.error("Test clone failed", err);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
});
|
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();
|
|
@@ -14,20 +18,75 @@ const start = async () => {
|
|
|
14
18
|
};
|
|
15
19
|
ctx.logger.info(startupSummary, "Git Daemon starting");
|
|
16
20
|
if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
|
|
21
|
+
console.log("[Git Daemon] Startup");
|
|
22
|
+
console.log(` config: ${startupSummary.configDir}`);
|
|
23
|
+
console.log(` host: ${startupSummary.host}`);
|
|
24
|
+
console.log(` port: ${startupSummary.port}`);
|
|
25
|
+
console.log(` workspace: ${startupSummary.workspaceRoot}`);
|
|
17
26
|
console.log(
|
|
18
|
-
`
|
|
27
|
+
` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`,
|
|
19
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
|
+
}
|
|
20
39
|
}
|
|
21
40
|
|
|
22
|
-
|
|
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, () => {
|
|
23
46
|
ctx.logger.info(
|
|
24
47
|
{
|
|
25
48
|
host: ctx.config.server.host,
|
|
26
49
|
port: ctx.config.server.port,
|
|
50
|
+
protocol: "http",
|
|
27
51
|
},
|
|
28
52
|
"Git Daemon listening",
|
|
29
53
|
);
|
|
30
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
|
+
}
|
|
31
90
|
};
|
|
32
91
|
|
|
33
92
|
start().catch((err) => {
|
package/src/jobs.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import crypto from "crypto";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ApiErrorBody,
|
|
5
|
+
JobEvent,
|
|
6
|
+
JobProgressEvent,
|
|
7
|
+
JobState,
|
|
8
|
+
JobStatus,
|
|
9
|
+
} from "./types";
|
|
4
10
|
import { timeoutError } from "./errors";
|
|
5
11
|
|
|
6
12
|
const MAX_EVENTS = 2000;
|
package/src/logger.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
|
-
import pino from "pino";
|
|
3
|
+
import pino, { multistream } from "pino";
|
|
4
4
|
import pinoHttp from "pino-http";
|
|
5
5
|
import type { Logger } from "pino";
|
|
6
6
|
import { createStream } from "rotating-file-stream";
|
|
@@ -41,8 +41,10 @@ export const createLogger = async (
|
|
|
41
41
|
streams.push({ stream: process.stdout });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
return pino({ enabled, level },
|
|
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/setup.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import { promises as fs } from "fs";
|
|
4
|
+
import * as fsSync from "fs";
|
|
5
|
+
import readline from "readline";
|
|
6
|
+
import { getConfigDir, loadConfig, saveConfig } from "./config";
|
|
7
|
+
|
|
8
|
+
const expandHome = (input: string) => {
|
|
9
|
+
if (input === "~") {
|
|
10
|
+
return os.homedir();
|
|
11
|
+
}
|
|
12
|
+
if (input.startsWith("~/")) {
|
|
13
|
+
return path.join(os.homedir(), input.slice(2));
|
|
14
|
+
}
|
|
15
|
+
return input;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const pathExists = async (target: string) => {
|
|
19
|
+
try {
|
|
20
|
+
const stats = await fs.stat(target);
|
|
21
|
+
return stats.isDirectory();
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const createPromptInterface = () => {
|
|
28
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
29
|
+
return readline.createInterface({
|
|
30
|
+
input: process.stdin,
|
|
31
|
+
output: process.stdout,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const ttyPath = process.platform === "win32" ? "CON" : "/dev/tty";
|
|
35
|
+
try {
|
|
36
|
+
const input = fsSync.createReadStream(ttyPath, { encoding: "utf8" });
|
|
37
|
+
const output = fsSync.createWriteStream(ttyPath);
|
|
38
|
+
return readline.createInterface({ input, output });
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const askQuestion = (rl: readline.Interface, question: string) =>
|
|
45
|
+
new Promise<string>((resolve) => {
|
|
46
|
+
rl.question(question, (answer) => resolve(answer));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const promptForWorkspace = async (
|
|
50
|
+
rl: readline.Interface,
|
|
51
|
+
initialValue: string,
|
|
52
|
+
) => {
|
|
53
|
+
let result: string | null = null;
|
|
54
|
+
while (result === null) {
|
|
55
|
+
const answer = await askQuestion(
|
|
56
|
+
rl,
|
|
57
|
+
`Workspace root directory (absolute path) [${initialValue}]: `,
|
|
58
|
+
);
|
|
59
|
+
const trimmed = answer.trim();
|
|
60
|
+
const value = trimmed.length > 0 ? trimmed : initialValue;
|
|
61
|
+
if (!value) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const expanded = expandHome(value);
|
|
65
|
+
if (!path.isAbsolute(expanded)) {
|
|
66
|
+
console.log("Workspace root must be an absolute path.");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
result = value;
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const promptYesNo = async (
|
|
75
|
+
rl: readline.Interface,
|
|
76
|
+
message: string,
|
|
77
|
+
defaultValue: boolean,
|
|
78
|
+
) => {
|
|
79
|
+
const suffix = defaultValue ? "[Y/n]" : "[y/N]";
|
|
80
|
+
const answer = await askQuestion(rl, `${message} ${suffix} `);
|
|
81
|
+
const normalized = answer.trim().toLowerCase();
|
|
82
|
+
if (!normalized) {
|
|
83
|
+
return defaultValue;
|
|
84
|
+
}
|
|
85
|
+
return normalized === "y" || normalized === "yes";
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const readWorkspaceArg = () => {
|
|
89
|
+
const args = process.argv.slice(2);
|
|
90
|
+
const flagIndex = args.findIndex((arg) => arg === "--workspace");
|
|
91
|
+
if (flagIndex >= 0 && args[flagIndex + 1]) {
|
|
92
|
+
return args[flagIndex + 1];
|
|
93
|
+
}
|
|
94
|
+
const inline = args.find((arg) => arg.startsWith("--workspace="));
|
|
95
|
+
if (inline) {
|
|
96
|
+
return inline.split("=").slice(1).join("=");
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const setup = async () => {
|
|
102
|
+
const configDir = getConfigDir();
|
|
103
|
+
const config = await loadConfig(configDir);
|
|
104
|
+
|
|
105
|
+
console.log(`[Git Daemon setup] config=${configDir}`);
|
|
106
|
+
|
|
107
|
+
const provided = process.env.GIT_DAEMON_WORKSPACE_ROOT || readWorkspaceArg();
|
|
108
|
+
|
|
109
|
+
let workspaceInput: string | null | undefined = provided?.trim();
|
|
110
|
+
let rl: readline.Interface | null = null;
|
|
111
|
+
|
|
112
|
+
if (!workspaceInput) {
|
|
113
|
+
rl = createPromptInterface();
|
|
114
|
+
if (!rl) {
|
|
115
|
+
console.error(
|
|
116
|
+
"No interactive prompt available. Use GIT_DAEMON_WORKSPACE_ROOT=/path or --workspace=/path.",
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
workspaceInput = await promptForWorkspace(
|
|
121
|
+
rl,
|
|
122
|
+
config.workspaceRoot ?? process.cwd(),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!workspaceInput) {
|
|
127
|
+
console.error("Workspace root was not provided.");
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const expanded = expandHome(workspaceInput);
|
|
132
|
+
const resolved = path.resolve(expanded);
|
|
133
|
+
|
|
134
|
+
if (!(await pathExists(resolved))) {
|
|
135
|
+
if (!rl) {
|
|
136
|
+
rl = createPromptInterface();
|
|
137
|
+
}
|
|
138
|
+
if (!rl) {
|
|
139
|
+
console.error(`Directory does not exist: ${resolved}`);
|
|
140
|
+
console.error("Create it manually, then rerun setup.");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const create = await promptYesNo(
|
|
144
|
+
rl,
|
|
145
|
+
`Directory does not exist. Create ${resolved}?`,
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
if (!create) {
|
|
149
|
+
console.log("Setup aborted. Workspace root not saved.");
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
config.workspaceRoot = resolved;
|
|
156
|
+
await saveConfig(configDir, config);
|
|
157
|
+
|
|
158
|
+
console.log(`Workspace root set to ${resolved}`);
|
|
159
|
+
rl?.close();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
setup().catch((err) => {
|
|
163
|
+
console.error("Setup failed", err);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
package/src/types.ts
CHANGED