git-daemon 0.1.1 → 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 +1 -1
- package/README.md +33 -6
- package/config.schema.json +1 -1
- package/design.md +3 -3
- package/dist/app.js +326 -0
- package/dist/approvals.js +27 -0
- package/dist/cli-test-clone.js +122 -0
- package/dist/config.js +107 -0
- package/dist/context.js +58 -0
- package/dist/daemon.js +34 -0
- package/dist/deps.js +101 -0
- package/dist/errors.js +43 -0
- package/dist/git.js +156 -0
- package/dist/jobs.js +163 -0
- package/dist/logger.js +77 -0
- package/dist/os.js +42 -0
- package/dist/pairing.js +41 -0
- package/dist/process.js +46 -0
- package/dist/security.js +73 -0
- package/dist/setup.js +165 -0
- package/dist/tokens.js +88 -0
- package/dist/tools.js +43 -0
- package/dist/types.js +2 -0
- package/dist/validation.js +62 -0
- package/dist/workspace.js +79 -0
- package/openapi.yaml +1 -1
- package/package.json +4 -1
- package/src/app.ts +24 -1
- package/src/cli-test-clone.ts +154 -0
- package/src/config.ts +1 -1
- package/src/daemon.ts +19 -0
- package/src/jobs.ts +9 -3
- package/src/logger.ts +27 -5
- package/src/process.ts +3 -2
- package/src/setup.ts +165 -0
- package/src/types.ts +20 -17
- package/src/typings/pino-pretty.d.ts +9 -0
- package/tests/app.test.js +103 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +9 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ensureRelative = exports.resolveInsideWorkspace = exports.ensureWorkspaceRoot = exports.MissingPathError = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const errors_1 = require("./errors");
|
|
10
|
+
class MissingPathError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.MissingPathError = MissingPathError;
|
|
13
|
+
const MAX_PATH_LENGTH = 4096;
|
|
14
|
+
const ensureWorkspaceRoot = (root) => {
|
|
15
|
+
if (!root) {
|
|
16
|
+
throw (0, errors_1.workspaceRequired)();
|
|
17
|
+
}
|
|
18
|
+
if (root.length > MAX_PATH_LENGTH) {
|
|
19
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
};
|
|
23
|
+
exports.ensureWorkspaceRoot = ensureWorkspaceRoot;
|
|
24
|
+
const realpathSafe = async (target) => {
|
|
25
|
+
try {
|
|
26
|
+
return await fs_1.promises.realpath(target);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const resolveInsideWorkspace = async (workspaceRoot, candidate, allowMissing = false) => {
|
|
36
|
+
if (candidate.length > MAX_PATH_LENGTH) {
|
|
37
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
38
|
+
}
|
|
39
|
+
const rootReal = await fs_1.promises.realpath(workspaceRoot);
|
|
40
|
+
const resolved = path_1.default.resolve(rootReal, candidate);
|
|
41
|
+
if (!isInside(rootReal, resolved)) {
|
|
42
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
43
|
+
}
|
|
44
|
+
const realResolved = await realpathSafe(resolved);
|
|
45
|
+
if (realResolved) {
|
|
46
|
+
if (!isInside(rootReal, realResolved)) {
|
|
47
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
48
|
+
}
|
|
49
|
+
return realResolved;
|
|
50
|
+
}
|
|
51
|
+
if (!allowMissing) {
|
|
52
|
+
throw new MissingPathError("Path does not exist.");
|
|
53
|
+
}
|
|
54
|
+
const parent = path_1.default.dirname(resolved);
|
|
55
|
+
const parentReal = await realpathSafe(parent);
|
|
56
|
+
if (parentReal && !isInside(rootReal, parentReal)) {
|
|
57
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
};
|
|
61
|
+
exports.resolveInsideWorkspace = resolveInsideWorkspace;
|
|
62
|
+
const isInside = (root, candidate) => {
|
|
63
|
+
const relative = path_1.default.relative(root, candidate);
|
|
64
|
+
if (relative === "") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return !relative.startsWith("..") && !path_1.default.isAbsolute(relative);
|
|
68
|
+
};
|
|
69
|
+
const ensureRelative = (target) => {
|
|
70
|
+
if (path_1.default.isAbsolute(target)) {
|
|
71
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
72
|
+
}
|
|
73
|
+
const normalized = path_1.default.normalize(target);
|
|
74
|
+
if (normalized === "." || normalized.startsWith("..")) {
|
|
75
|
+
throw (0, errors_1.pathOutsideWorkspace)();
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
};
|
|
79
|
+
exports.ensureRelative = ensureRelative;
|
package/openapi.yaml
CHANGED
|
@@ -6,7 +6,7 @@ info:
|
|
|
6
6
|
Localhost API for the Git Daemon. All requests must include an Origin header
|
|
7
7
|
that matches the allowlist. Non-public endpoints require a Bearer token.
|
|
8
8
|
servers:
|
|
9
|
-
- url: http://127.0.0.1:
|
|
9
|
+
- url: http://127.0.0.1:8790
|
|
10
10
|
security:
|
|
11
11
|
- bearerAuth: []
|
|
12
12
|
paths:
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-daemon",
|
|
3
|
-
"version": "0.1.
|
|
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
|
},
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
"express-rate-limit": "^7.4.0",
|
|
20
22
|
"pino": "^9.3.2",
|
|
21
23
|
"pino-http": "^9.0.0",
|
|
24
|
+
"pino-pretty": "^13.0.0",
|
|
22
25
|
"rotating-file-stream": "^3.2.5",
|
|
23
26
|
"tree-kill": "^1.2.2",
|
|
24
27
|
"zod": "^3.23.8"
|
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,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/config.ts
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -5,6 +5,25 @@ const start = async () => {
|
|
|
5
5
|
const ctx = await createContext();
|
|
6
6
|
const app = createApp(ctx);
|
|
7
7
|
|
|
8
|
+
const startupSummary = {
|
|
9
|
+
configDir: ctx.configDir,
|
|
10
|
+
host: ctx.config.server.host,
|
|
11
|
+
port: ctx.config.server.port,
|
|
12
|
+
workspaceRoot: ctx.config.workspaceRoot ?? "not configured",
|
|
13
|
+
originAllowlist: ctx.config.originAllowlist,
|
|
14
|
+
};
|
|
15
|
+
ctx.logger.info(startupSummary, "Git Daemon starting");
|
|
16
|
+
if (process.env.GIT_DAEMON_LOG_STDOUT !== "1") {
|
|
17
|
+
console.log("[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(
|
|
23
|
+
` allowlist: ${startupSummary.originAllowlist.join(", ") || "none"}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
app.listen(ctx.config.server.port, ctx.config.server.host, () => {
|
|
9
28
|
ctx.logger.info(
|
|
10
29
|
{
|
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;
|
|
@@ -8,7 +14,7 @@ const MAX_EVENTS = 2000;
|
|
|
8
14
|
export type JobContext = {
|
|
9
15
|
logStdout: (line: string) => void;
|
|
10
16
|
logStderr: (line: string) => void;
|
|
11
|
-
progress: (event: Omit<
|
|
17
|
+
progress: (event: Omit<JobProgressEvent, "type">) => void;
|
|
12
18
|
setCancel: (cancel: () => Promise<void>) => void;
|
|
13
19
|
isCancelled: () => boolean;
|
|
14
20
|
};
|
|
@@ -157,7 +163,7 @@ export class JobManager {
|
|
|
157
163
|
const ctx: JobContext = {
|
|
158
164
|
logStdout: (line) => job.emit({ type: "log", stream: "stdout", line }),
|
|
159
165
|
logStderr: (line) => job.emit({ type: "log", stream: "stderr", line }),
|
|
160
|
-
progress: (event) => job.emit({
|
|
166
|
+
progress: (event) => job.emit({ type: "progress", ...event }),
|
|
161
167
|
setCancel: (fn) => job.setCancel(fn),
|
|
162
168
|
isCancelled: () => job.cancelRequested,
|
|
163
169
|
};
|
package/src/logger.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import
|
|
6
|
+
import { createStream } from "rotating-file-stream";
|
|
7
7
|
import type { AppConfig } from "./types";
|
|
8
8
|
|
|
9
9
|
export const createLogger = async (
|
|
@@ -11,16 +11,38 @@ export const createLogger = async (
|
|
|
11
11
|
logging: AppConfig["logging"],
|
|
12
12
|
enabled = true,
|
|
13
13
|
): Promise<Logger> => {
|
|
14
|
+
const level = process.env.GIT_DAEMON_LOG_LEVEL || "info";
|
|
15
|
+
const logToStdout = process.env.GIT_DAEMON_LOG_STDOUT === "1";
|
|
16
|
+
const prettyStdout = logToStdout && process.env.GIT_DAEMON_LOG_PRETTY !== "0";
|
|
14
17
|
const logDir = path.join(configDir, logging.directory);
|
|
15
18
|
await fs.mkdir(logDir, { recursive: true });
|
|
16
19
|
|
|
17
|
-
const stream =
|
|
20
|
+
const stream = createStream("daemon.log", {
|
|
18
21
|
size: `${logging.maxBytes}B`,
|
|
19
22
|
maxFiles: logging.maxFiles,
|
|
20
23
|
path: logDir,
|
|
21
24
|
});
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
if (!logToStdout) {
|
|
27
|
+
return pino({ enabled, level }, stream);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const streams: Array<{ stream: NodeJS.WritableStream }> = [{ stream }];
|
|
31
|
+
if (prettyStdout) {
|
|
32
|
+
const { default: pretty } = await import("pino-pretty");
|
|
33
|
+
streams.push({
|
|
34
|
+
stream: pretty({
|
|
35
|
+
colorize: true,
|
|
36
|
+
translateTime: "SYS:standard",
|
|
37
|
+
ignore: "pid,hostname",
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
streams.push({ stream: process.stdout });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return pino({ enabled, level }, multistream(streams));
|
|
24
45
|
};
|
|
25
46
|
|
|
26
|
-
export const createHttpLogger = (logger: Logger) =>
|
|
47
|
+
export const createHttpLogger = (logger: Logger) =>
|
|
48
|
+
pinoHttp({ logger: logger as unknown as Logger });
|
package/src/process.ts
CHANGED
|
@@ -39,11 +39,12 @@ export const runCommand = async (
|
|
|
39
39
|
stderr: "pipe",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const pid = subprocess.pid;
|
|
43
|
+
if (pid) {
|
|
43
44
|
ctx.setCancel(
|
|
44
45
|
() =>
|
|
45
46
|
new Promise<void>((resolve) => {
|
|
46
|
-
treeKill(
|
|
47
|
+
treeKill(pid, "SIGTERM", () => resolve());
|
|
47
48
|
}),
|
|
48
49
|
);
|
|
49
50
|
}
|
package/src/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
|
@@ -65,23 +65,26 @@ export type TokenStoreData = {
|
|
|
65
65
|
|
|
66
66
|
export type JobState = "queued" | "running" | "done" | "error" | "cancelled";
|
|
67
67
|
|
|
68
|
-
export type
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
68
|
+
export type JobLogEvent = {
|
|
69
|
+
type: "log";
|
|
70
|
+
stream: "stdout" | "stderr";
|
|
71
|
+
line: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type JobProgressEvent = {
|
|
75
|
+
type: "progress";
|
|
76
|
+
kind: "git" | "deps";
|
|
77
|
+
percent?: number;
|
|
78
|
+
detail?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type JobStateEvent = {
|
|
82
|
+
type: "state";
|
|
83
|
+
state: JobState;
|
|
84
|
+
message?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type JobEvent = JobLogEvent | JobProgressEvent | JobStateEvent;
|
|
85
88
|
|
|
86
89
|
export type JobStatus = {
|
|
87
90
|
id: string;
|