git-daemon 0.1.0

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/src/deps.ts ADDED
@@ -0,0 +1,134 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import type { JobContext } from "./jobs";
4
+ import { runCommand } from "./process";
5
+ import { resolveInsideWorkspace } from "./workspace";
6
+ import { isToolInstalled } from "./tools";
7
+
8
+ export type DepsRequest = {
9
+ repoPath: string;
10
+ manager: "auto" | "npm" | "pnpm" | "yarn";
11
+ mode: "auto" | "ci" | "install";
12
+ safer: boolean;
13
+ };
14
+
15
+ export const installDeps = async (
16
+ ctx: JobContext,
17
+ workspaceRoot: string,
18
+ request: DepsRequest,
19
+ ) => {
20
+ const repoPath = await resolveInsideWorkspace(
21
+ workspaceRoot,
22
+ request.repoPath,
23
+ );
24
+ await fs.access(path.join(repoPath, "package.json"));
25
+
26
+ const manager = await selectManager(repoPath, request.manager);
27
+ const { command, args } = await buildInstallCommand(
28
+ repoPath,
29
+ manager,
30
+ request.mode,
31
+ request.safer,
32
+ );
33
+
34
+ ctx.progress({ kind: "deps", detail: `${command} ${args.join(" ")}` });
35
+ await runCommand(ctx, command, args, { cwd: repoPath });
36
+ };
37
+
38
+ const selectManager = async (
39
+ repoPath: string,
40
+ requested: DepsRequest["manager"],
41
+ ) => {
42
+ if (requested !== "auto") {
43
+ const installed = await isToolInstalled(requested);
44
+ if (!installed) {
45
+ throw new Error(`${requested} is not installed.`);
46
+ }
47
+ return requested;
48
+ }
49
+
50
+ const packageManager = await readPackageManager(repoPath);
51
+ if (packageManager) {
52
+ const name = packageManager.split("@")[0];
53
+ if (name === "pnpm" || name === "yarn" || name === "npm") {
54
+ const installed = await isToolInstalled(name);
55
+ if (installed) {
56
+ return name;
57
+ }
58
+ }
59
+ }
60
+
61
+ if (await fileExists(path.join(repoPath, "pnpm-lock.yaml"))) {
62
+ return "pnpm";
63
+ }
64
+ if (await fileExists(path.join(repoPath, "yarn.lock"))) {
65
+ return "yarn";
66
+ }
67
+ if (await fileExists(path.join(repoPath, "package-lock.json"))) {
68
+ return "npm";
69
+ }
70
+ return "npm";
71
+ };
72
+
73
+ const readPackageManager = async (repoPath: string) => {
74
+ try {
75
+ const raw = await fs.readFile(path.join(repoPath, "package.json"), "utf8");
76
+ const parsed = JSON.parse(raw) as { packageManager?: string };
77
+ return parsed.packageManager;
78
+ } catch {
79
+ return null;
80
+ }
81
+ };
82
+
83
+ const buildInstallCommand = async (
84
+ repoPath: string,
85
+ manager: "npm" | "pnpm" | "yarn",
86
+ mode: "auto" | "ci" | "install",
87
+ safer: boolean,
88
+ ) => {
89
+ const lockfileExists = await hasAnyLockfile(repoPath);
90
+ const useCi = mode === "ci" || (mode === "auto" && lockfileExists);
91
+
92
+ if (manager === "pnpm") {
93
+ const args = ["install"];
94
+ if (useCi) {
95
+ args.push("--frozen-lockfile");
96
+ }
97
+ if (safer) {
98
+ args.push("--ignore-scripts");
99
+ }
100
+ return { command: "pnpm", args };
101
+ }
102
+
103
+ if (manager === "yarn") {
104
+ const args = ["install"];
105
+ const isBerry = await fileExists(path.join(repoPath, ".yarnrc.yml"));
106
+ if (useCi || isBerry) {
107
+ args.push("--immutable");
108
+ }
109
+ if (safer) {
110
+ args.push("--ignore-scripts");
111
+ }
112
+ return { command: "yarn", args };
113
+ }
114
+
115
+ const args = [useCi ? "ci" : "install"];
116
+ if (safer) {
117
+ args.push("--ignore-scripts");
118
+ }
119
+ return { command: "npm", args };
120
+ };
121
+
122
+ const hasAnyLockfile = async (repoPath: string) =>
123
+ (await fileExists(path.join(repoPath, "pnpm-lock.yaml"))) ||
124
+ (await fileExists(path.join(repoPath, "yarn.lock"))) ||
125
+ (await fileExists(path.join(repoPath, "package-lock.json")));
126
+
127
+ const fileExists = async (target: string) => {
128
+ try {
129
+ await fs.access(target);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { ApiErrorBody } from "./types";
2
+
3
+ export class ApiError extends Error {
4
+ readonly status: number;
5
+ readonly body: ApiErrorBody;
6
+
7
+ constructor(status: number, body: ApiErrorBody) {
8
+ super(body.message);
9
+ this.status = status;
10
+ this.body = body;
11
+ }
12
+ }
13
+
14
+ export const errorBody = (
15
+ errorCode: ApiErrorBody["errorCode"],
16
+ message: string,
17
+ details?: Record<string, unknown>,
18
+ ): ApiErrorBody => ({
19
+ errorCode,
20
+ message,
21
+ ...(details ? { details } : {}),
22
+ });
23
+
24
+ export const authRequired = () =>
25
+ new ApiError(401, errorBody("auth_required", "Bearer token required."));
26
+
27
+ export const authInvalid = () =>
28
+ new ApiError(
29
+ 401,
30
+ errorBody("auth_invalid", "Bearer token invalid or expired."),
31
+ );
32
+
33
+ export const originNotAllowed = () =>
34
+ new ApiError(403, errorBody("origin_not_allowed", "Origin not allowed."));
35
+
36
+ export const rateLimited = () =>
37
+ new ApiError(429, errorBody("rate_limited", "Too many requests."));
38
+
39
+ export const workspaceRequired = () =>
40
+ new ApiError(
41
+ 409,
42
+ errorBody("workspace_required", "Workspace root not configured."),
43
+ );
44
+
45
+ export const pathOutsideWorkspace = () =>
46
+ new ApiError(
47
+ 409,
48
+ errorBody("path_outside_workspace", "Path is outside the workspace root."),
49
+ );
50
+
51
+ export const invalidRepoUrl = () =>
52
+ new ApiError(
53
+ 422,
54
+ errorBody("invalid_repo_url", "Repository URL is invalid."),
55
+ );
56
+
57
+ export const capabilityNotGranted = () =>
58
+ new ApiError(
59
+ 409,
60
+ errorBody("capability_not_granted", "Capability approval required."),
61
+ );
62
+
63
+ export const jobNotFound = () =>
64
+ new ApiError(404, errorBody("job_not_found", "Job not found."));
65
+
66
+ export const repoNotFound = () =>
67
+ new ApiError(404, errorBody("internal_error", "Repository not found."));
68
+
69
+ export const pathNotFound = () =>
70
+ new ApiError(404, errorBody("internal_error", "Path not found."));
71
+
72
+ export const timeoutError = () =>
73
+ new ApiError(500, errorBody("timeout", "Job timed out."));
74
+
75
+ export const internalError = (message = "Unexpected error.") =>
76
+ new ApiError(500, errorBody("internal_error", message));
package/src/git.ts ADDED
@@ -0,0 +1,160 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import type { JobContext } from "./jobs";
4
+ import { runCommand } from "./process";
5
+ import { resolveInsideWorkspace, ensureRelative } from "./workspace";
6
+
7
+ export type GitStatus = {
8
+ branch: string;
9
+ ahead: number;
10
+ behind: number;
11
+ stagedCount: number;
12
+ unstagedCount: number;
13
+ untrackedCount: number;
14
+ conflictsCount: number;
15
+ clean: boolean;
16
+ };
17
+
18
+ export class RepoNotFoundError extends Error {}
19
+
20
+ export const cloneRepo = async (
21
+ ctx: JobContext,
22
+ workspaceRoot: string,
23
+ repoUrl: string,
24
+ destRelative: string,
25
+ options?: { branch?: string; depth?: number },
26
+ ) => {
27
+ ensureRelative(destRelative);
28
+ const destPath = await resolveInsideWorkspace(
29
+ workspaceRoot,
30
+ destRelative,
31
+ true,
32
+ );
33
+
34
+ const args = ["clone", repoUrl, destPath];
35
+ if (options?.branch) {
36
+ args.splice(1, 0, "--branch", options.branch);
37
+ }
38
+ if (options?.depth) {
39
+ args.splice(1, 0, "--depth", options.depth.toString());
40
+ }
41
+
42
+ await runCommand(ctx, "git", args, { cwd: workspaceRoot });
43
+ };
44
+
45
+ export const fetchRepo = async (
46
+ ctx: JobContext,
47
+ workspaceRoot: string,
48
+ repoPath: string,
49
+ remote = "origin",
50
+ prune = false,
51
+ ) => {
52
+ const resolved = await resolveRepoPath(workspaceRoot, repoPath);
53
+
54
+ const args = ["-C", resolved, "fetch", remote];
55
+ if (prune) {
56
+ args.push("--prune");
57
+ }
58
+
59
+ await runCommand(ctx, "git", args);
60
+ };
61
+
62
+ export const getRepoStatus = async (
63
+ workspaceRoot: string,
64
+ repoPath: string,
65
+ ): Promise<GitStatus> => {
66
+ const resolved = await resolveRepoPath(workspaceRoot, repoPath);
67
+
68
+ const { execa } = await import("execa");
69
+ const result = await execa("git", [
70
+ "-C",
71
+ resolved,
72
+ "status",
73
+ "--porcelain=2",
74
+ "-b",
75
+ ]);
76
+ return parseStatus(result.stdout);
77
+ };
78
+
79
+ export const resolveRepoPath = async (
80
+ workspaceRoot: string,
81
+ repoPath: string,
82
+ ) => {
83
+ const resolved = await resolveInsideWorkspace(workspaceRoot, repoPath);
84
+ await assertRepoExists(resolved);
85
+ return resolved;
86
+ };
87
+
88
+ const assertRepoExists = async (repoPath: string) => {
89
+ const stats = await fs.stat(repoPath);
90
+ if (!stats.isDirectory()) {
91
+ throw new RepoNotFoundError("Repository path is not a directory.");
92
+ }
93
+ const gitPath = path.join(repoPath, ".git");
94
+ try {
95
+ await fs.access(gitPath);
96
+ } catch {
97
+ throw new RepoNotFoundError("Repository .git directory not found.");
98
+ }
99
+ };
100
+
101
+ const parseStatus = (output: string): GitStatus => {
102
+ let branch = "";
103
+ let ahead = 0;
104
+ let behind = 0;
105
+ let stagedCount = 0;
106
+ let unstagedCount = 0;
107
+ let untrackedCount = 0;
108
+ let conflictsCount = 0;
109
+
110
+ const lines = output.split(/\r?\n/);
111
+ for (const line of lines) {
112
+ if (line.startsWith("# branch.head")) {
113
+ branch = line.split(" ").slice(2).join(" ").trim();
114
+ continue;
115
+ }
116
+ if (line.startsWith("# branch.ab")) {
117
+ const parts = line.split(" ");
118
+ const aheadPart = parts.find((part) => part.startsWith("+"));
119
+ const behindPart = parts.find((part) => part.startsWith("-"));
120
+ ahead = aheadPart ? Number(aheadPart.slice(1)) : 0;
121
+ behind = behindPart ? Number(behindPart.slice(1)) : 0;
122
+ continue;
123
+ }
124
+ if (line.startsWith("?")) {
125
+ untrackedCount += 1;
126
+ continue;
127
+ }
128
+ if (line.startsWith("u")) {
129
+ conflictsCount += 1;
130
+ continue;
131
+ }
132
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
133
+ const x = line[2];
134
+ const y = line[3];
135
+ if (x && x !== ".") {
136
+ stagedCount += 1;
137
+ }
138
+ if (y && y !== ".") {
139
+ unstagedCount += 1;
140
+ }
141
+ }
142
+ }
143
+
144
+ const clean =
145
+ stagedCount === 0 &&
146
+ unstagedCount === 0 &&
147
+ untrackedCount === 0 &&
148
+ conflictsCount === 0;
149
+
150
+ return {
151
+ branch,
152
+ ahead,
153
+ behind,
154
+ stagedCount,
155
+ unstagedCount,
156
+ untrackedCount,
157
+ conflictsCount,
158
+ clean,
159
+ };
160
+ };
package/src/jobs.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { EventEmitter } from "events";
2
+ import crypto from "crypto";
3
+ import type { ApiErrorBody, JobEvent, JobState, JobStatus } from "./types";
4
+ import { timeoutError } from "./errors";
5
+
6
+ const MAX_EVENTS = 2000;
7
+
8
+ export type JobContext = {
9
+ logStdout: (line: string) => void;
10
+ logStderr: (line: string) => void;
11
+ progress: (event: Omit<JobEvent, "type"> & { type?: "progress" }) => void;
12
+ setCancel: (cancel: () => Promise<void>) => void;
13
+ isCancelled: () => boolean;
14
+ };
15
+
16
+ export type JobRunner = (ctx: JobContext) => Promise<void>;
17
+
18
+ export class Job {
19
+ readonly id: string;
20
+ state: JobState = "queued";
21
+ createdAt = new Date().toISOString();
22
+ startedAt?: string;
23
+ finishedAt?: string;
24
+ error?: ApiErrorBody;
25
+ events: JobEvent[] = [];
26
+ readonly emitter = new EventEmitter();
27
+ cancelRequested = false;
28
+ private cancelFn?: () => Promise<void>;
29
+
30
+ constructor() {
31
+ this.id = crypto.randomUUID();
32
+ }
33
+
34
+ setCancel(fn: () => Promise<void>) {
35
+ this.cancelFn = fn;
36
+ }
37
+
38
+ async cancel() {
39
+ this.cancelRequested = true;
40
+ if (this.cancelFn) {
41
+ await this.cancelFn();
42
+ }
43
+ }
44
+
45
+ emit(event: JobEvent) {
46
+ this.events.push(event);
47
+ if (this.events.length > MAX_EVENTS) {
48
+ this.events.shift();
49
+ }
50
+ this.emitter.emit("event", event);
51
+ }
52
+
53
+ snapshot(): JobStatus {
54
+ return {
55
+ id: this.id,
56
+ state: this.state,
57
+ createdAt: this.createdAt,
58
+ startedAt: this.startedAt,
59
+ finishedAt: this.finishedAt,
60
+ error: this.error,
61
+ };
62
+ }
63
+ }
64
+
65
+ export class JobManager {
66
+ private readonly maxConcurrent: number;
67
+ private readonly timeoutMs: number;
68
+ private running = 0;
69
+ private readonly queue: { job: Job; run: JobRunner }[] = [];
70
+ private readonly jobs = new Map<string, Job>();
71
+ private readonly history: Job[] = [];
72
+
73
+ constructor(maxConcurrent: number, timeoutSeconds: number) {
74
+ this.maxConcurrent = maxConcurrent;
75
+ this.timeoutMs = timeoutSeconds * 1000;
76
+ }
77
+
78
+ enqueue(run: JobRunner): Job {
79
+ const job = new Job();
80
+ this.jobs.set(job.id, job);
81
+ this.queue.push({ job, run });
82
+ this.track(job);
83
+ this.drain();
84
+ return job;
85
+ }
86
+
87
+ get(id: string) {
88
+ return this.jobs.get(id);
89
+ }
90
+
91
+ cancel(id: string) {
92
+ const queuedIndex = this.queue.findIndex((entry) => entry.job.id === id);
93
+ if (queuedIndex >= 0) {
94
+ const [entry] = this.queue.splice(queuedIndex, 1);
95
+ entry.job.state = "cancelled";
96
+ entry.job.finishedAt = new Date().toISOString();
97
+ entry.job.emit({ type: "state", state: "cancelled" });
98
+ return true;
99
+ }
100
+
101
+ const runningJob = this.jobs.get(id);
102
+ if (!runningJob) {
103
+ return false;
104
+ }
105
+ if (runningJob.state !== "running") {
106
+ return false;
107
+ }
108
+ void runningJob.cancel();
109
+ runningJob.state = "cancelled";
110
+ runningJob.finishedAt = new Date().toISOString();
111
+ runningJob.emit({ type: "state", state: "cancelled" });
112
+ return true;
113
+ }
114
+
115
+ listRecent() {
116
+ return this.history.map((job) => job.snapshot());
117
+ }
118
+
119
+ private track(job: Job) {
120
+ this.history.push(job);
121
+ if (this.history.length > 100) {
122
+ this.history.shift();
123
+ }
124
+ }
125
+
126
+ private drain() {
127
+ while (this.running < this.maxConcurrent && this.queue.length > 0) {
128
+ const entry = this.queue.shift();
129
+ if (!entry) {
130
+ return;
131
+ }
132
+ this.runJob(entry.job, entry.run);
133
+ }
134
+ }
135
+
136
+ private runJob(job: Job, run: JobRunner) {
137
+ this.running += 1;
138
+ job.state = "running";
139
+ job.startedAt = new Date().toISOString();
140
+ job.emit({ type: "state", state: "running" });
141
+
142
+ let timeoutHandle: NodeJS.Timeout | undefined;
143
+
144
+ if (this.timeoutMs > 0) {
145
+ timeoutHandle = setTimeout(async () => {
146
+ if (job.state !== "running") {
147
+ return;
148
+ }
149
+ job.error = timeoutError().body;
150
+ await job.cancel();
151
+ job.state = "error";
152
+ job.finishedAt = new Date().toISOString();
153
+ job.emit({ type: "state", state: "error", message: "Timed out" });
154
+ }, this.timeoutMs);
155
+ }
156
+
157
+ const ctx: JobContext = {
158
+ logStdout: (line) => job.emit({ type: "log", stream: "stdout", line }),
159
+ logStderr: (line) => job.emit({ type: "log", stream: "stderr", line }),
160
+ progress: (event) => job.emit({ ...event, type: "progress" }),
161
+ setCancel: (fn) => job.setCancel(fn),
162
+ isCancelled: () => job.cancelRequested,
163
+ };
164
+
165
+ run(ctx)
166
+ .then(() => {
167
+ if (job.state !== "running") {
168
+ return;
169
+ }
170
+ job.state = "done";
171
+ job.finishedAt = new Date().toISOString();
172
+ job.emit({ type: "state", state: "done" });
173
+ })
174
+ .catch((err: unknown) => {
175
+ if (job.state !== "running") {
176
+ return;
177
+ }
178
+ job.state = "error";
179
+ job.finishedAt = new Date().toISOString();
180
+ job.error = {
181
+ errorCode: "internal_error",
182
+ message: err instanceof Error ? err.message : "Job failed.",
183
+ };
184
+ job.emit({ type: "state", state: "error", message: job.error.message });
185
+ })
186
+ .finally(() => {
187
+ if (timeoutHandle) {
188
+ clearTimeout(timeoutHandle);
189
+ }
190
+ this.running -= 1;
191
+ this.drain();
192
+ });
193
+ }
194
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,26 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import pino from "pino";
4
+ import pinoHttp from "pino-http";
5
+ import type { Logger } from "pino";
6
+ import rfs from "rotating-file-stream";
7
+ import type { AppConfig } from "./types";
8
+
9
+ export const createLogger = async (
10
+ configDir: string,
11
+ logging: AppConfig["logging"],
12
+ enabled = true,
13
+ ): Promise<Logger> => {
14
+ const logDir = path.join(configDir, logging.directory);
15
+ await fs.mkdir(logDir, { recursive: true });
16
+
17
+ const stream = rfs.createStream("daemon.log", {
18
+ size: `${logging.maxBytes}B`,
19
+ maxFiles: logging.maxFiles,
20
+ path: logDir,
21
+ });
22
+
23
+ return pino({ enabled }, stream);
24
+ };
25
+
26
+ export const createHttpLogger = (logger: Logger) => pinoHttp({ logger });
package/src/os.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { execa } from "execa";
2
+
3
+ export const openTarget = async (
4
+ target: "folder" | "terminal" | "vscode",
5
+ resolvedPath: string,
6
+ ) => {
7
+ const platform = process.platform;
8
+
9
+ if (target === "folder") {
10
+ if (platform === "darwin") {
11
+ await execa("open", [resolvedPath]);
12
+ return;
13
+ }
14
+ if (platform === "win32") {
15
+ await execa("cmd", ["/c", "start", "", resolvedPath]);
16
+ return;
17
+ }
18
+ await execa("xdg-open", [resolvedPath]);
19
+ return;
20
+ }
21
+
22
+ if (target === "terminal") {
23
+ if (platform === "darwin") {
24
+ await execa("open", ["-a", "Terminal", resolvedPath]);
25
+ return;
26
+ }
27
+ if (platform === "win32") {
28
+ await execa("cmd", [
29
+ "/c",
30
+ "start",
31
+ "",
32
+ "cmd.exe",
33
+ "/k",
34
+ "cd",
35
+ "/d",
36
+ resolvedPath,
37
+ ]);
38
+ return;
39
+ }
40
+ await execa("x-terminal-emulator", ["--working-directory", resolvedPath]);
41
+ return;
42
+ }
43
+
44
+ await execa("code", [resolvedPath]);
45
+ };
package/src/pairing.ts ADDED
@@ -0,0 +1,52 @@
1
+ import crypto from "crypto";
2
+ import type { TokenStore } from "./tokens";
3
+
4
+ const PAIRING_TTL_MS = 10 * 60 * 1000;
5
+
6
+ export class PairingManager {
7
+ private readonly tokenStore: TokenStore;
8
+ private readonly ttlDays: number;
9
+ private readonly codes = new Map<
10
+ string,
11
+ { code: string; expiresAt: number }
12
+ >();
13
+
14
+ constructor(tokenStore: TokenStore, ttlDays: number) {
15
+ this.tokenStore = tokenStore;
16
+ this.ttlDays = ttlDays;
17
+ }
18
+
19
+ start(origin: string) {
20
+ const code = crypto.randomBytes(4).toString("hex");
21
+ const expiresAt = Date.now() + PAIRING_TTL_MS;
22
+ this.codes.set(origin, { code, expiresAt });
23
+
24
+ return {
25
+ step: "start" as const,
26
+ instructions:
27
+ "Enter this code in the Git Daemon pairing prompt within 10 minutes.",
28
+ code,
29
+ expiresAt: new Date(expiresAt).toISOString(),
30
+ };
31
+ }
32
+
33
+ async confirm(origin: string, code: string) {
34
+ const entry = this.codes.get(origin);
35
+ if (!entry || entry.code !== code || entry.expiresAt < Date.now()) {
36
+ return null;
37
+ }
38
+
39
+ this.codes.delete(origin);
40
+ const { token, expiresAt } = await this.tokenStore.issueToken(
41
+ origin,
42
+ this.ttlDays,
43
+ );
44
+
45
+ return {
46
+ step: "confirm" as const,
47
+ accessToken: token,
48
+ tokenType: "Bearer" as const,
49
+ expiresAt,
50
+ };
51
+ }
52
+ }