git-daemon 0.1.2 → 0.1.3

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 CHANGED
@@ -14,5 +14,5 @@ module.exports = {
14
14
  rules: {
15
15
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }]
16
16
  },
17
- ignorePatterns: ["dist/", "node_modules/"]
17
+ ignorePatterns: ["dist/", "node_modules/", "workspace/"]
18
18
  };
package/README.md CHANGED
@@ -41,6 +41,27 @@ npm run daemon
41
41
 
42
42
  The daemon listens on `http://127.0.0.1:8790` by default.
43
43
 
44
+ ## Setup workspace root
45
+
46
+ ```bash
47
+ npm run setup
48
+ ```
49
+
50
+ 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.
51
+ For development, you can also run `npm run setup:dev`.
52
+
53
+ Non-interactive setup (no TTY):
54
+
55
+ ```bash
56
+ GIT_DAEMON_WORKSPACE_ROOT=/absolute/path npm run setup
57
+ ```
58
+
59
+ Or:
60
+
61
+ ```bash
62
+ npm run setup -- --workspace=/absolute/path
63
+ ```
64
+
44
65
  Verbose logging options:
45
66
 
46
67
  - `GIT_DAEMON_LOG_STDOUT=1` to mirror logs to stdout
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) => sendEvent(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,122 @@
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 getEnv = (key, fallback) => process.env[key] || fallback;
8
+ 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) => {
14
+ const payload = JSON.stringify(body);
15
+ const req = http_1.default.request(`${BASE}${path}`, {
16
+ method: "POST",
17
+ headers: {
18
+ Origin: ORIGIN,
19
+ "Content-Type": "application/json",
20
+ "Content-Length": Buffer.byteLength(payload),
21
+ },
22
+ }, (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
+ });
37
+ });
38
+ req.on("error", reject);
39
+ req.write(payload);
40
+ req.end();
41
+ });
42
+ const requestJsonAuth = (path, token, body) => new Promise((resolve, reject) => {
43
+ const payload = JSON.stringify(body);
44
+ const req = http_1.default.request(`${BASE}${path}`, {
45
+ method: "POST",
46
+ headers: {
47
+ Origin: ORIGIN,
48
+ Authorization: `Bearer ${token}`,
49
+ "Content-Type": "application/json",
50
+ "Content-Length": Buffer.byteLength(payload),
51
+ },
52
+ }, (res) => {
53
+ let raw = "";
54
+ res.setEncoding("utf8");
55
+ res.on("data", (chunk) => {
56
+ raw += chunk;
57
+ });
58
+ 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
+ }
66
+ });
67
+ });
68
+ req.on("error", reject);
69
+ req.write(payload);
70
+ req.end();
71
+ });
72
+ const streamEvents = (jobId, token) => new Promise((resolve, reject) => {
73
+ const req = http_1.default.request(`${BASE}/v1/jobs/${jobId}/stream`, {
74
+ method: "GET",
75
+ headers: {
76
+ Origin: ORIGIN,
77
+ Authorization: `Bearer ${token}`,
78
+ Accept: "text/event-stream",
79
+ },
80
+ }, (res) => {
81
+ res.setEncoding("utf8");
82
+ res.on("data", (chunk) => {
83
+ process.stdout.write(chunk);
84
+ });
85
+ res.on("end", () => {
86
+ resolve();
87
+ });
88
+ });
89
+ req.on("error", reject);
90
+ req.end();
91
+ });
92
+ const main = async () => {
93
+ const start = await requestJson("/v1/pair", { step: "start" });
94
+ if (start.status !== 200 || !start.data.code) {
95
+ console.error("Pairing start failed", start.data);
96
+ process.exit(1);
97
+ }
98
+ const confirm = await requestJson("/v1/pair", {
99
+ step: "confirm",
100
+ code: start.data.code,
101
+ });
102
+ if (confirm.status !== 200 || !confirm.data.accessToken) {
103
+ console.error("Pairing confirm failed", confirm.data);
104
+ process.exit(1);
105
+ }
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);
113
+ process.exit(1);
114
+ }
115
+ const jobId = clone.data.jobId;
116
+ console.log(`jobId=${jobId}`);
117
+ await streamEvents(jobId, token);
118
+ };
119
+ main().catch((err) => {
120
+ console.error("Test clone failed", err);
121
+ process.exit(1);
122
+ });
package/dist/daemon.js CHANGED
@@ -14,7 +14,12 @@ const start = async () => {
14
14
  };
15
15
  ctx.logger.info(startupSummary, "Git Daemon starting");
16
16
  if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
17
- console.log(`[Git Daemon] config=${startupSummary.configDir} host=${startupSummary.host} port=${startupSummary.port}`);
17
+ console.log("[Git Daemon] Startup");
18
+ console.log(` config: ${startupSummary.configDir}`);
19
+ console.log(` host: ${startupSummary.host}`);
20
+ console.log(` port: ${startupSummary.port}`);
21
+ console.log(` workspace: ${startupSummary.workspaceRoot}`);
22
+ console.log(` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`);
18
23
  }
19
24
  app.listen(ctx.config.server.port, ctx.config.server.host, () => {
20
25
  ctx.logger.info({
package/dist/setup.js ADDED
@@ -0,0 +1,165 @@
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
+ while (true) {
85
+ const answer = await askQuestion(rl, `Workspace root directory (absolute path) [${initialValue}]: `);
86
+ const trimmed = answer.trim();
87
+ const value = trimmed.length > 0 ? trimmed : initialValue;
88
+ if (!value) {
89
+ return null;
90
+ }
91
+ const expanded = expandHome(value);
92
+ if (!path_1.default.isAbsolute(expanded)) {
93
+ console.log("Workspace root must be an absolute path.");
94
+ continue;
95
+ }
96
+ return value;
97
+ }
98
+ };
99
+ const promptYesNo = async (rl, message, defaultValue) => {
100
+ const suffix = defaultValue ? "[Y/n]" : "[y/N]";
101
+ const answer = await askQuestion(rl, `${message} ${suffix} `);
102
+ const normalized = answer.trim().toLowerCase();
103
+ if (!normalized) {
104
+ return defaultValue;
105
+ }
106
+ return normalized === "y" || normalized === "yes";
107
+ };
108
+ const readWorkspaceArg = () => {
109
+ const args = process.argv.slice(2);
110
+ const flagIndex = args.findIndex((arg) => arg === "--workspace");
111
+ if (flagIndex >= 0 && args[flagIndex + 1]) {
112
+ return args[flagIndex + 1];
113
+ }
114
+ const inline = args.find((arg) => arg.startsWith("--workspace="));
115
+ if (inline) {
116
+ return inline.split("=").slice(1).join("=");
117
+ }
118
+ return null;
119
+ };
120
+ const setup = async () => {
121
+ const configDir = (0, config_1.getConfigDir)();
122
+ const config = await (0, config_1.loadConfig)(configDir);
123
+ console.log(`[Git Daemon setup] config=${configDir}`);
124
+ const provided = process.env.GIT_DAEMON_WORKSPACE_ROOT || readWorkspaceArg();
125
+ let workspaceInput = provided?.trim();
126
+ let rl = null;
127
+ if (!workspaceInput) {
128
+ rl = createPromptInterface();
129
+ if (!rl) {
130
+ console.error("No interactive prompt available. Use GIT_DAEMON_WORKSPACE_ROOT=/path or --workspace=/path.");
131
+ process.exit(1);
132
+ }
133
+ workspaceInput = await promptForWorkspace(rl, config.workspaceRoot ?? process.cwd());
134
+ }
135
+ if (!workspaceInput) {
136
+ console.error("Workspace root was not provided.");
137
+ process.exit(1);
138
+ }
139
+ const expanded = expandHome(workspaceInput);
140
+ const resolved = path_1.default.resolve(expanded);
141
+ if (!(await pathExists(resolved))) {
142
+ if (!rl) {
143
+ rl = createPromptInterface();
144
+ }
145
+ if (!rl) {
146
+ console.error(`Directory does not exist: ${resolved}`);
147
+ console.error("Create it manually, then rerun setup.");
148
+ process.exit(1);
149
+ }
150
+ const create = await promptYesNo(rl, `Directory does not exist. Create ${resolved}?`, true);
151
+ if (!create) {
152
+ console.log("Setup aborted. Workspace root not saved.");
153
+ process.exit(1);
154
+ }
155
+ await fs_1.promises.mkdir(resolved, { recursive: true });
156
+ }
157
+ config.workspaceRoot = resolved;
158
+ await (0, config_1.saveConfig)(configDir, config);
159
+ console.log(`Workspace root set to ${resolved}`);
160
+ rl?.close();
161
+ };
162
+ setup().catch((err) => {
163
+ console.error("Setup failed", err);
164
+ process.exit(1);
165
+ });
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "git-daemon",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": "tsx src/cli-test-clone.ts",
12
14
  "lint": "eslint . --ext .ts",
13
15
  "lint:fix": "eslint . --ext .ts --fix"
14
16
  },
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) => sendEvent(event);
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,154 @@
1
+ import http from "http";
2
+
3
+ const getEnv = (key: string, fallback: string) => process.env[key] || fallback;
4
+
5
+ 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");
10
+
11
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
12
+ value && typeof value === "object"
13
+ ? (value as Record<string, unknown>)
14
+ : null;
15
+
16
+ const getString = (value: unknown): string | null =>
17
+ typeof value === "string" ? value : null;
18
+
19
+ const requestJson = (path: string, body: unknown) =>
20
+ new Promise<{ status: number; data: unknown }>((resolve, reject) => {
21
+ const payload = JSON.stringify(body);
22
+ const req = http.request(
23
+ `${BASE}${path}`,
24
+ {
25
+ method: "POST",
26
+ headers: {
27
+ Origin: ORIGIN,
28
+ "Content-Type": "application/json",
29
+ "Content-Length": Buffer.byteLength(payload),
30
+ },
31
+ },
32
+ (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
+ });
46
+ },
47
+ );
48
+
49
+ req.on("error", reject);
50
+ req.write(payload);
51
+ req.end();
52
+ });
53
+
54
+ const requestJsonAuth = (path: string, token: string, body: unknown) =>
55
+ new Promise<{ status: number; data: unknown }>((resolve, reject) => {
56
+ const payload = JSON.stringify(body);
57
+ const req = http.request(
58
+ `${BASE}${path}`,
59
+ {
60
+ method: "POST",
61
+ headers: {
62
+ Origin: ORIGIN,
63
+ Authorization: `Bearer ${token}`,
64
+ "Content-Type": "application/json",
65
+ "Content-Length": Buffer.byteLength(payload),
66
+ },
67
+ },
68
+ (res) => {
69
+ let raw = "";
70
+ res.setEncoding("utf8");
71
+ res.on("data", (chunk) => {
72
+ raw += chunk;
73
+ });
74
+ 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
+ }
81
+ });
82
+ },
83
+ );
84
+
85
+ req.on("error", reject);
86
+ req.write(payload);
87
+ req.end();
88
+ });
89
+
90
+ const streamEvents = (jobId: string, token: string) =>
91
+ new Promise<void>((resolve, reject) => {
92
+ const req = http.request(
93
+ `${BASE}/v1/jobs/${jobId}/stream`,
94
+ {
95
+ method: "GET",
96
+ headers: {
97
+ Origin: ORIGIN,
98
+ Authorization: `Bearer ${token}`,
99
+ Accept: "text/event-stream",
100
+ },
101
+ },
102
+ (res) => {
103
+ res.setEncoding("utf8");
104
+ res.on("data", (chunk) => {
105
+ process.stdout.write(chunk);
106
+ });
107
+ res.on("end", () => {
108
+ resolve();
109
+ });
110
+ },
111
+ );
112
+ req.on("error", reject);
113
+ req.end();
114
+ });
115
+
116
+ const main = async () => {
117
+ const start = await requestJson("/v1/pair", { step: "start" });
118
+ const startData = asRecord(start.data);
119
+ const code = startData ? getString(startData.code) : null;
120
+ if (start.status !== 200 || !code) {
121
+ console.error("Pairing start failed", start.data);
122
+ process.exit(1);
123
+ }
124
+
125
+ const confirm = await requestJson("/v1/pair", {
126
+ step: "confirm",
127
+ code,
128
+ });
129
+ const confirmData = asRecord(confirm.data);
130
+ const token = confirmData ? getString(confirmData.accessToken) : null;
131
+ if (confirm.status !== 200 || !token) {
132
+ console.error("Pairing confirm failed", confirm.data);
133
+ process.exit(1);
134
+ }
135
+
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);
145
+ }
146
+
147
+ console.log(`jobId=${jobId}`);
148
+ await streamEvents(jobId, token);
149
+ };
150
+
151
+ main().catch((err) => {
152
+ console.error("Test clone failed", err);
153
+ process.exit(1);
154
+ });
package/src/daemon.ts CHANGED
@@ -14,8 +14,13 @@ const start = async () => {
14
14
  };
15
15
  ctx.logger.info(startupSummary, "Git Daemon starting");
16
16
  if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
17
+ console.log("[Git Daemon] Startup");
18
+ console.log(` config: ${startupSummary.configDir}`);
19
+ console.log(` host: ${startupSummary.host}`);
20
+ console.log(` port: ${startupSummary.port}`);
21
+ console.log(` workspace: ${startupSummary.workspaceRoot}`);
17
22
  console.log(
18
- `[Git Daemon] config=${startupSummary.configDir} host=${startupSummary.host} port=${startupSummary.port}`,
23
+ ` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`,
19
24
  );
20
25
  }
21
26
 
package/src/jobs.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { EventEmitter } from "events";
2
2
  import crypto from "crypto";
3
- import type { ApiErrorBody, JobEvent, JobProgressEvent, JobState, JobStatus } from "./types";
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,8 @@ export const createLogger = async (
41
41
  streams.push({ stream: process.stdout });
42
42
  }
43
43
 
44
- return pino({ enabled, level }, (pino as any).multistream(streams));
44
+ return pino({ enabled, level }, multistream(streams));
45
45
  };
46
46
 
47
47
  export const createHttpLogger = (logger: Logger) =>
48
- pinoHttp({ logger: logger as any });
48
+ pinoHttp({ logger: logger as unknown as Logger });
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
+ });