git-daemon 0.1.3 → 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/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
@@ -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
  },
@@ -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 PORT = Number(getEnv("PORT", "8790"));
10
- const BASE = `http://127.0.0.1:${PORT}`;
11
- const REPO = getEnv("REPO_URL", "git@github.com:bunnybones1/git-daemon.git");
12
- const DEST = getEnv("DEST_RELATIVE", "bunnybones1/git-daemon");
13
- const requestJson = (path, body) => new Promise((resolve, reject) => {
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 req = http_1.default.request(`${BASE}${path}`, {
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
- let raw = "";
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 requestJsonAuth = (path, token, body) => new Promise((resolve, reject) => {
59
+ const requestJsonHttps = (path, body, token) => new Promise((resolve, reject) => {
43
60
  const payload = JSON.stringify(body);
44
- const req = http_1.default.request(`${BASE}${path}`, {
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
- "Content-Type": "application/json",
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
- raw += chunk;
91
+ process.stdout.write(chunk);
57
92
  });
58
93
  res.on("end", () => {
59
- try {
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 streamEvents = (jobId, token) => new Promise((resolve, reject) => {
73
- const req = http_1.default.request(`${BASE}/v1/jobs/${jobId}/stream`, {
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", { step: "start" });
94
- if (start.status !== 200 || !start.data.code) {
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: start.data.code,
133
+ code,
101
134
  });
102
- if (confirm.status !== 200 || !confirm.data.accessToken) {
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
- const token = confirm.data.accessToken;
107
- const clone = await requestJsonAuth("/v1/git/clone", token, {
108
- repoUrl: REPO,
109
- destRelative: DEST,
110
- });
111
- if (clone.status !== 202 || !clone.data.jobId) {
112
- console.error("Clone request failed", clone.data);
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
- app.listen(ctx.config.server.port, ctx.config.server.host, () => {
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 = __importDefault(require("pino"));
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.default.multistream(streams));
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: 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
- while (true) {
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
- return value;
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
@@ -7,6 +7,7 @@ info:
7
7
  that matches the allowlist. Non-public endpoints require a Bearer token.
8
8
  servers:
9
9
  - url: http://127.0.0.1:8790
10
+ - url: https://127.0.0.1:8791
10
11
  security:
11
12
  - bearerAuth: []
12
13
  paths:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-daemon",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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": "tsx src/cli-test-clone.ts",
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
@@ -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 PORT = Number(getEnv("PORT", "8790"));
7
- const BASE = `http://127.0.0.1:${PORT}`;
8
- const REPO = getEnv("REPO_URL", "git@github.com:bunnybones1/git-daemon.git");
9
- const DEST = getEnv("DEST_RELATIVE", "bunnybones1/git-daemon");
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 requestJson = (path: string, body: unknown) =>
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 req = http.request(
23
- `${BASE}${path}`,
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
- let raw = "";
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 requestJsonAuth = (path: string, token: string, body: unknown) =>
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 req = http.request(
58
- `${BASE}${path}`,
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
- "Content-Type": "application/json",
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
- raw += chunk;
130
+ process.stdout.write(chunk);
73
131
  });
74
132
  res.on("end", () => {
75
- try {
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 streamEvents = (jobId: string, token: string) =>
141
+ const streamEventsHttps = (jobId: string, token: string) =>
91
142
  new Promise<void>((resolve, reject) => {
92
- const req = http.request(
93
- `${BASE}/v1/jobs/${jobId}/stream`,
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", { step: "start" });
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
- const clone = await requestJsonAuth("/v1/git/clone", token, {
137
- repoUrl: REPO,
138
- destRelative: DEST,
139
- });
140
- const cloneData = asRecord(clone.data);
141
- const jobId = cloneData ? getString(cloneData.jobId) : null;
142
- if (clone.status !== 202 || !jobId) {
143
- console.error("Clone request failed", clone.data);
144
- process.exit(1);
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
- console.log(`jobId=${jobId}`);
148
- await streamEvents(jobId, token);
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
- app.listen(ctx.config.server.port, ctx.config.server.host, () => {
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: logger as unknown as Logger });
50
+ pinoHttp({ logger } as unknown as PinoHttpOptions);
package/src/types.ts CHANGED
@@ -12,6 +12,12 @@ export type AppConfig = {
12
12
  server: {
13
13
  host: string;
14
14
  port: number;
15
+ https?: {
16
+ enabled?: boolean;
17
+ port?: number;
18
+ keyPath?: string;
19
+ certPath?: string;
20
+ };
15
21
  };
16
22
  originAllowlist: string[];
17
23
  workspaceRoot: string | null;