git-daemon 0.1.8 → 0.1.10

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
@@ -8,10 +8,19 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
8
8
  ## What it does
9
9
 
10
10
  - Clone, fetch, list branches, and read Git status using your system Git credentials
11
+ - Provide a status summary for UI badges/tooltips
11
12
  - Stream long-running job logs via Server-Sent Events (SSE)
13
+ - Run user-defined healthchecks and return normalized results
12
14
  - Open a repo in the OS file browser, terminal, or VS Code (with approvals)
13
15
  - Install dependencies with safer defaults (`--ignore-scripts` by default)
14
16
 
17
+ ## Healthchecks
18
+
19
+ Healthchecks are user-supplied executables/scripts stored in local suites (which can be
20
+ private repos). The daemon runs them as jobs, streams logs over SSE, and returns a
21
+ normalized status (`na`, `failed`, `pass-partial`, `pass-full`) plus details. Suites are
22
+ configured via local paths, and each check has a `healthcheck.json` manifest.
23
+
15
24
  ## Security model (high level)
16
25
 
17
26
  - **Loopback-only**: binds to `127.0.0.1`
@@ -24,7 +33,7 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
24
33
  ## Requirements
25
34
 
26
35
  - Node.js (for running the daemon)
27
- - Git (for clone/fetch/branches/status)
36
+ - Git (for clone/fetch/branches/status/summary)
28
37
  - Optional: `code` CLI for VS Code, `pnpm`/`yarn` for dependency installs
29
38
 
30
39
  ## Install
@@ -161,6 +170,33 @@ curl -H "Origin: https://app.example.com" \
161
170
  "http://127.0.0.1:8790/v1/git/branches?repoPath=owner/repo"
162
171
  ```
163
172
 
173
+ Status summary (UI-friendly):
174
+
175
+ ```bash
176
+ curl -H "Origin: https://app.example.com" \
177
+ -H "Authorization: Bearer <TOKEN>" \
178
+ "http://127.0.0.1:8790/v1/git/summary?repoPath=owner/repo"
179
+ ```
180
+
181
+ Run healthchecks (job):
182
+
183
+ ```bash
184
+ curl -X POST \
185
+ -H "Origin: https://app.example.com" \
186
+ -H "Authorization: Bearer <TOKEN>" \
187
+ -H "Content-Type: application/json" \
188
+ -d '{"repoPath":"owner/repo","checks":[{"suiteId":"team-default","checkId":"lint","config":{"strict":true}}]}' \
189
+ http://127.0.0.1:8790/v1/healthchecks/run
190
+ ```
191
+
192
+ Fetch healthcheck results:
193
+
194
+ ```bash
195
+ curl -H "Origin: https://app.example.com" \
196
+ -H "Authorization: Bearer <TOKEN>" \
197
+ http://127.0.0.1:8790/v1/healthchecks/jobs/<JOB_ID>/result
198
+ ```
199
+
164
200
  ## Configuration
165
201
 
166
202
  Config is stored in OS-specific directories:
@@ -181,6 +217,7 @@ Key settings live in `config.json`:
181
217
  - `workspaceRoot`: absolute path to the workspace root
182
218
  - `deps.defaultSafer`: defaults to `true` for `--ignore-scripts`
183
219
  - `jobs.maxConcurrent` and `jobs.timeoutSeconds`
220
+ - `healthchecks.suites`: array of absolute or config-relative suite paths
184
221
 
185
222
  Tokens are stored (hashed) in `tokens.json`. Logs are written under the configured `logging.directory` with rotation.
186
223
 
@@ -124,6 +124,25 @@
124
124
  }
125
125
  }
126
126
  },
127
+ "healthchecks": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": [
131
+ "suites"
132
+ ],
133
+ "properties": {
134
+ "suites": {
135
+ "type": "array",
136
+ "default": [],
137
+ "items": {
138
+ "type": "string"
139
+ }
140
+ },
141
+ "defaultSuiteId": {
142
+ "type": "string"
143
+ }
144
+ }
145
+ },
127
146
  "logging": {
128
147
  "type": "object",
129
148
  "additionalProperties": false,
package/design.md CHANGED
@@ -26,6 +26,7 @@ The daemon exposes a **localhost HTTP API** guarded by:
26
26
  * run dependency installation (`npm i` / pnpm / yarn)
27
27
  * Prevent any website other than the approved UI origins from controlling the daemon.
28
28
  * Prevent the approved UI from writing outside a user-approved local workspace root.
29
+ * Run user-defined healthchecks against local repos and return normalized results.
29
30
 
30
31
  ## Non-goals
31
32
 
@@ -33,6 +34,7 @@ The daemon exposes a **localhost HTTP API** guarded by:
33
34
  * Building or shipping the React UI.
34
35
  * Acting as a full Git GUI (diff viewer, staging UI, merge tools) — those are UI concerns.
35
36
  * Running arbitrary shell commands. (Only explicit, whitelisted operations.)
37
+ * Shipping or hosting healthcheck suites (healthchecks remain user-managed).
36
38
 
37
39
  ---
38
40
 
@@ -150,6 +152,12 @@ First-time use of high-risk features requires explicit user approval:
150
152
  * Any endpoint that would accept arbitrary args must be constrained/validated.
151
153
  * Validate `repoUrl` formats (SSH/HTTPS only); disallow `file://` and local paths.
152
154
 
155
+ #### 6b) Healthcheck execution boundaries
156
+
157
+ * Healthchecks are user-supplied but executed by the daemon with strict inputs.
158
+ * Repo access is read-only; the runner provides only `REPO_PATH`, `REPO_INFO`, and `CHECK_CONFIG`.
159
+ * Network access is disabled by default and must be explicitly allowed by the check manifest.
160
+
153
161
  #### 7) Abuse limits (recommended)
154
162
 
155
163
  * Rate limit pairing attempts and auth failures.
@@ -169,6 +177,7 @@ First-time use of high-risk features requires explicit user approval:
169
177
  * `config.json` (workspace root, approvals, options)
170
178
  * `tokens.json` (hashed tokens, per-origin records)
171
179
  * `logs/` (structured logs with rotation)
180
+ * `healthchecks/` (optional local suites, if not stored elsewhere)
172
181
  * Log rotation: keep 5 files × 5MB each.
173
182
 
174
183
  ---
@@ -229,6 +238,121 @@ Capture output:
229
238
 
230
239
  ---
231
240
 
241
+ ## Healthchecks
242
+
243
+ Healthchecks are user-provided executables/scripts that run against a local repo and
244
+ return normalized results. Suites can be private repos; the daemon only executes
245
+ locally configured suites and never ships or hosts them.
246
+
247
+ ### Suite discovery
248
+
249
+ * A suite is a folder or repo containing one or more checks.
250
+ * The daemon is configured with one or more suite roots (e.g., `healthchecks.suites` in config).
251
+ * Each check includes a `healthcheck.json` manifest next to its entrypoint.
252
+ * Suites can include shared libraries or scripts used by multiple checks.
253
+
254
+ ### Manifest format (healthcheck.json)
255
+
256
+ ```json
257
+ {
258
+ "id": "lint",
259
+ "name": "Repo Lint",
260
+ "version": "1.2.0",
261
+ "description": "Runs repo-specific lint checks",
262
+ "entrypoint": "./run.sh",
263
+ "args": ["--strict"],
264
+ "timeoutSeconds": 120,
265
+ "permissions": {
266
+ "repoRead": true,
267
+ "network": false,
268
+ "secrets": []
269
+ },
270
+ "configSchema": {
271
+ "type": "object",
272
+ "properties": {
273
+ "strict": { "type": "boolean", "default": false }
274
+ },
275
+ "additionalProperties": false
276
+ },
277
+ "cacheable": true,
278
+ "cacheMaxAgeSeconds": 3600,
279
+ "outputSchemaVersion": 1
280
+ }
281
+ ```
282
+
283
+ ### Runtime input contract
284
+
285
+ Checks receive inputs via environment variables:
286
+
287
+ * `REPO_PATH`: absolute path to the repo (read-only).
288
+ * `REPO_INFO`: JSON string of repo metadata supplied by the UI (GitHub-facing fields).
289
+ * `CHECK_CONFIG`: JSON string validated against `configSchema` (if provided).
290
+
291
+ The daemon does not call GitHub APIs; it passes through `REPO_INFO` from the UI.
292
+ Suggested fields include `owner`, `name`, `fullName`, `defaultBranch`, `visibility`,
293
+ `cloneUrl`, `htmlUrl`, `topics`, `archived`, and `fork`.
294
+
295
+ ### Runtime output contract
296
+
297
+ Checks write JSON to `stdout` (logs go to `stderr`):
298
+
299
+ ```json
300
+ {
301
+ "status": "na|failed|pass-partial|pass-full",
302
+ "summary": "Short human summary",
303
+ "explanation": "Longer explanation of the status",
304
+ "details": [
305
+ {
306
+ "code": "CHK001",
307
+ "severity": "info|low|medium|high|critical",
308
+ "message": "Missing README",
309
+ "path": "README.md",
310
+ "line": 1,
311
+ "column": 1,
312
+ "remediation": "Add a README.md with project description"
313
+ }
314
+ ],
315
+ "metrics": {
316
+ "filesChecked": 134,
317
+ "issuesFound": 2
318
+ },
319
+ "artifacts": [
320
+ {
321
+ "name": "full-report.json",
322
+ "path": "reports/report.json",
323
+ "contentType": "application/json"
324
+ }
325
+ ],
326
+ "durationMs": 5234
327
+ }
328
+ ```
329
+
330
+ ### Status model
331
+
332
+ * `na`: not applicable (missing stack, no files, or explicitly skipped).
333
+ * `failed`: healthcheck failed a required condition.
334
+ * `pass-partial`: issues found but acceptable or best-effort.
335
+ * `pass-full`: all requirements met.
336
+
337
+ For a multi-check run, the aggregate status is derived by severity ordering:
338
+ `failed` > `pass-partial` > `pass-full` > `na`.
339
+
340
+ ### Execution and security
341
+
342
+ * Healthchecks run as daemon-managed jobs; logs stream via SSE like clone/fetch.
343
+ * Repo contents are read-only to healthchecks.
344
+ * Network access defaults to off and must be explicitly allowed via manifest
345
+ permissions (and optionally user approval).
346
+
347
+ ### Results and caching
348
+
349
+ * Results are stored per job and retrieved via a dedicated results endpoint.
350
+ * Cache key includes repo commit SHA, check id/version, config, and daemon version.
351
+ * Caching is opt-in per check (`cacheable` + `cacheMaxAgeSeconds`) and can be
352
+ overridden per run with `cacheMode` (`prefer`, `refresh`, or `bypass`).
353
+
354
+ ---
355
+
232
356
  ## API Design
233
357
 
234
358
  ### Conventions
@@ -280,6 +404,10 @@ Meta response fields (examples):
280
404
 
281
405
  * returns: `{ branches: [{ name, fullName, type: "local"|"remote", current, upstream? }] }`
282
406
  * `includeRemote` defaults to `true` and includes remote tracking branches (e.g. `origin/main`)
407
+ * `GET /v1/git/summary?repoPath=...` → status summary for UI badges/tooltips
408
+
409
+ * returns: `{ repoPath, exists, branch, upstream, ahead, behind, dirty, staged, unstaged, untracked, conflicts, detached }`
410
+ * if repo is missing, `exists` is `false` with zeroed counts
283
411
  * `GET /v1/git/status?repoPath=...` → structured status
284
412
 
285
413
  * returns: `{ branch, ahead, behind, stagedCount, unstagedCount, untrackedCount, conflictsCount, clean }`
@@ -300,6 +428,16 @@ Recommendations:
300
428
  * `safer=true` maps to flags that reduce script execution risk (e.g., `--ignore-scripts` for npm/pnpm/yarn).
301
429
  * Default to `safer=true` for all runs; allow a per-repo override to enable scripts.
302
430
 
431
+ ### Healthcheck endpoints
432
+
433
+ * `GET /v1/healthchecks` → list installed suites and checks.
434
+ * `POST /v1/healthchecks/run` → job
435
+
436
+ * body: `{ repoPath, repoInfo?, suiteId?, checks?: [{ suiteId?, checkId, config?, cacheMode? }] }`
437
+ * `repoInfo` is a UI-supplied GitHub metadata object (passed through to checks).
438
+ * If `checks` is omitted, run all checks in the default suite.
439
+ * `GET /v1/healthchecks/jobs/:id/result` → structured healthcheck results for a completed job.
440
+
303
441
  ---
304
442
 
305
443
  ## Job Model
@@ -313,7 +451,7 @@ Recommendations:
313
451
  Each event is JSON:
314
452
 
315
453
  * `{ type: "log", stream: "stdout"|"stderr", line: "..." }`
316
- * `{ type: "progress", kind: "git"|"deps", percent?: number, detail?: string }`
454
+ * `{ type: "progress", kind: "git"|"deps"|"healthcheck", percent?: number, detail?: string }`
317
455
  * `{ type: "state", state: "running"|"done"|"error", message?: string }`
318
456
 
319
457
  ### Cancellation
@@ -369,7 +507,8 @@ Each event is JSON:
369
507
  4. Pair once (code/confirm)
370
508
  5. User clicks “Clone locally”
371
509
  6. UI calls daemon `/v1/git/clone`, streams job logs
372
- 7. UI shows “Open in VS Code / Terminal / Folder”
510
+ 7. UI optionally calls `/v1/healthchecks/run`, streams job logs, and fetches results
511
+ 8. UI shows “Open in VS Code / Terminal / Folder”
373
512
 
374
513
  ### 2) Local UI + daemon (dev)
375
514
 
@@ -429,6 +568,25 @@ curl -N \
429
568
  http://127.0.0.1:8790/v1/jobs/<JOB_ID>/stream
430
569
  ```
431
570
 
571
+ ### Run healthchecks (job)
572
+
573
+ ```bash
574
+ curl -X POST \
575
+ -H "Origin: https://app.example.com" \
576
+ -H "Authorization: Bearer <TOKEN>" \
577
+ -H "Content-Type: application/json" \
578
+ -d '{"repoPath":"owner/repo","checks":[{"suiteId":"team-default","checkId":"lint","config":{"strict":true}}]}' \
579
+ http://127.0.0.1:8790/v1/healthchecks/run
580
+ ```
581
+
582
+ ### Fetch healthcheck results
583
+
584
+ ```bash
585
+ curl -H "Origin: https://app.example.com" \
586
+ -H "Authorization: Bearer <TOKEN>" \
587
+ http://127.0.0.1:8790/v1/healthchecks/jobs/<JOB_ID>/result
588
+ ```
589
+
432
590
  ---
433
591
 
434
592
  ## Error Handling
package/dist/app.js CHANGED
@@ -1,4 +1,37 @@
1
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
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -7,7 +40,9 @@ exports.createApp = void 0;
7
40
  const express_1 = __importDefault(require("express"));
8
41
  const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
9
42
  const fs_1 = require("fs");
43
+ const fsSync = __importStar(require("fs"));
10
44
  const path_1 = __importDefault(require("path"));
45
+ const readline_1 = __importDefault(require("readline"));
11
46
  const logger_1 = require("./logger");
12
47
  const security_1 = require("./security");
13
48
  const errors_1 = require("./errors");
@@ -16,7 +51,9 @@ const workspace_1 = require("./workspace");
16
51
  const git_1 = require("./git");
17
52
  const deps_1 = require("./deps");
18
53
  const os_1 = require("./os");
54
+ const healthchecks_1 = require("./healthchecks");
19
55
  const approvals_1 = require("./approvals");
56
+ const config_1 = require("./config");
20
57
  const parseBody = (schema, body, opts) => {
21
58
  const parsed = schema.safeParse(body);
22
59
  if (!parsed.success) {
@@ -213,6 +250,56 @@ const createApp = (ctx) => {
213
250
  next(err);
214
251
  }
215
252
  });
253
+ app.get("/v1/git/branches", (0, security_1.authGuard)(ctx.tokenStore), async (req, res, next) => {
254
+ try {
255
+ const payload = parseBody(validation_1.gitBranchesQuerySchema, req.query);
256
+ const workspaceRoot = (0, workspace_1.ensureWorkspaceRoot)(ctx.config.workspaceRoot);
257
+ const includeRemote = payload.includeRemote !== "false";
258
+ const branches = await (0, git_1.listRepoBranches)(workspaceRoot, payload.repoPath, {
259
+ includeRemote,
260
+ });
261
+ res.json({ branches });
262
+ }
263
+ catch (err) {
264
+ if (err instanceof git_1.RepoNotFoundError ||
265
+ err instanceof workspace_1.MissingPathError) {
266
+ next((0, errors_1.repoNotFound)());
267
+ return;
268
+ }
269
+ next(err);
270
+ }
271
+ });
272
+ app.get("/v1/git/summary", (0, security_1.authGuard)(ctx.tokenStore), async (req, res, next) => {
273
+ let payload;
274
+ try {
275
+ payload = parseBody(validation_1.gitSummaryQuerySchema, req.query);
276
+ const workspaceRoot = (0, workspace_1.ensureWorkspaceRoot)(ctx.config.workspaceRoot);
277
+ const summary = await (0, git_1.getRepoSummary)(workspaceRoot, payload.repoPath);
278
+ res.json(summary);
279
+ }
280
+ catch (err) {
281
+ if ((err instanceof git_1.RepoNotFoundError ||
282
+ err instanceof workspace_1.MissingPathError) &&
283
+ payload) {
284
+ res.json({
285
+ repoPath: payload.repoPath,
286
+ exists: false,
287
+ branch: "",
288
+ upstream: null,
289
+ ahead: 0,
290
+ behind: 0,
291
+ dirty: false,
292
+ staged: 0,
293
+ unstaged: 0,
294
+ untracked: 0,
295
+ conflicts: 0,
296
+ detached: false,
297
+ });
298
+ return;
299
+ }
300
+ next(err);
301
+ }
302
+ });
216
303
  app.get("/v1/git/status", (0, security_1.authGuard)(ctx.tokenStore), async (req, res, next) => {
217
304
  try {
218
305
  const { repoPath } = parseBody(validation_1.gitStatusQuerySchema, req.query);
@@ -236,10 +323,10 @@ const createApp = (ctx) => {
236
323
  const workspaceRoot = (0, workspace_1.ensureWorkspaceRoot)(ctx.config.workspaceRoot);
237
324
  const resolved = await (0, workspace_1.resolveInsideWorkspace)(workspaceRoot, payload.path);
238
325
  if (payload.target === "terminal") {
239
- (0, approvals_1.requireApproval)(ctx.config, origin, resolved, "open-terminal", workspaceRoot);
326
+ await ensureApproval(ctx, origin, resolved, "open-terminal", workspaceRoot);
240
327
  }
241
328
  if (payload.target === "vscode") {
242
- (0, approvals_1.requireApproval)(ctx.config, origin, resolved, "open-vscode", workspaceRoot);
329
+ await ensureApproval(ctx, origin, resolved, "open-vscode", workspaceRoot);
243
330
  }
244
331
  await (0, os_1.openTarget)(payload.target, resolved);
245
332
  res.json({ ok: true });
@@ -264,7 +351,7 @@ const createApp = (ctx) => {
264
351
  catch {
265
352
  throw (0, errors_1.repoNotFound)();
266
353
  }
267
- (0, approvals_1.requireApproval)(ctx.config, origin, resolved, "deps/install", workspaceRoot);
354
+ await ensureApproval(ctx, origin, resolved, "deps/install", workspaceRoot);
268
355
  const job = ctx.jobManager.enqueue(async (jobCtx) => {
269
356
  await (0, deps_1.installDeps)(jobCtx, workspaceRoot, {
270
357
  repoPath: payload.repoPath,
@@ -283,6 +370,68 @@ const createApp = (ctx) => {
283
370
  next(err);
284
371
  }
285
372
  });
373
+ app.get("/v1/healthchecks", (0, security_1.authGuard)(ctx.tokenStore), async (_req, res, next) => {
374
+ try {
375
+ const suites = await (0, healthchecks_1.loadHealthcheckSuites)(ctx.configDir, ctx.config.healthchecks?.suites ?? []);
376
+ res.json({ suites: (0, healthchecks_1.listHealthcheckSuites)(suites) });
377
+ }
378
+ catch (err) {
379
+ next(err);
380
+ }
381
+ });
382
+ app.post("/v1/healthchecks/run", (0, security_1.authGuard)(ctx.tokenStore), async (req, res, next) => {
383
+ try {
384
+ const payload = parseBody(validation_1.healthcheckRunRequestSchema, req.body);
385
+ const workspaceRoot = (0, workspace_1.ensureWorkspaceRoot)(ctx.config.workspaceRoot);
386
+ const resolved = await (0, git_1.resolveRepoPath)(workspaceRoot, payload.repoPath);
387
+ const suites = await (0, healthchecks_1.loadHealthcheckSuites)(ctx.configDir, ctx.config.healthchecks?.suites ?? []);
388
+ let selections;
389
+ try {
390
+ selections = (0, healthchecks_1.resolveSelections)(suites, payload, ctx.config.healthchecks?.defaultSuiteId);
391
+ }
392
+ catch (err) {
393
+ throw new errors_1.ApiError(422, (0, errors_1.errorBody)("internal_error", err instanceof Error
394
+ ? err.message
395
+ : "Invalid healthcheck request."));
396
+ }
397
+ const job = ctx.jobManager.enqueue(async (jobCtx) => {
398
+ const result = await (0, healthchecks_1.runHealthchecks)(jobCtx, jobCtx.jobId, resolved, selections, payload.repoInfo);
399
+ ctx.healthcheckStore.set(jobCtx.jobId, {
400
+ ...result,
401
+ repoPath: payload.repoPath,
402
+ });
403
+ });
404
+ res.status(202).json({ jobId: job.id });
405
+ }
406
+ catch (err) {
407
+ if (err instanceof git_1.RepoNotFoundError ||
408
+ err instanceof workspace_1.MissingPathError) {
409
+ next((0, errors_1.repoNotFound)());
410
+ return;
411
+ }
412
+ next(err);
413
+ }
414
+ });
415
+ app.get("/v1/healthchecks/jobs/:id/result", (0, security_1.authGuard)(ctx.tokenStore), (req, res, next) => {
416
+ try {
417
+ const jobId = req.params.id;
418
+ const job = ctx.jobManager.get(jobId);
419
+ if (!job) {
420
+ throw (0, errors_1.jobNotFound)();
421
+ }
422
+ const result = ctx.healthcheckStore.get(jobId);
423
+ if (!result) {
424
+ res
425
+ .status(409)
426
+ .json((0, errors_1.errorBody)("internal_error", "Healthcheck results not available."));
427
+ return;
428
+ }
429
+ res.json(result);
430
+ }
431
+ catch (err) {
432
+ next(err);
433
+ }
434
+ });
286
435
  app.get("/v1/diagnostics", (0, security_1.authGuard)(ctx.tokenStore), (req, res, next) => {
287
436
  try {
288
437
  const summary = {
@@ -293,6 +442,7 @@ const createApp = (ctx) => {
293
442
  pairing: ctx.config.pairing,
294
443
  jobs: ctx.config.jobs,
295
444
  deps: ctx.config.deps,
445
+ healthchecks: ctx.config.healthchecks,
296
446
  logging: ctx.config.logging,
297
447
  };
298
448
  res.json({
@@ -306,8 +456,17 @@ const createApp = (ctx) => {
306
456
  next(err);
307
457
  }
308
458
  });
309
- app.use((err, _req, res, _next) => {
459
+ app.use((err, req, res, _next) => {
310
460
  if (err instanceof errors_1.ApiError) {
461
+ if (err.status === 409) {
462
+ console.warn(`[Git Daemon] 409 ${err.body.errorCode} ${req.method} ${req.originalUrl} origin=${req.headers.origin ?? ""}`);
463
+ ctx.logger.warn({
464
+ errorCode: err.body.errorCode,
465
+ method: req.method,
466
+ path: req.originalUrl,
467
+ origin: req.headers.origin,
468
+ }, "Request rejected with conflict");
469
+ }
311
470
  res.status(err.status).json(err.body);
312
471
  return;
313
472
  }
@@ -324,3 +483,61 @@ const createApp = (ctx) => {
324
483
  return app;
325
484
  };
326
485
  exports.createApp = createApp;
486
+ const ensureApproval = async (ctx, origin, repoPath, capability, workspaceRoot) => {
487
+ if ((0, approvals_1.hasApproval)(ctx.config, origin, repoPath, capability, workspaceRoot)) {
488
+ return;
489
+ }
490
+ const approved = await promptApproval(origin, repoPath, capability);
491
+ if (!approved) {
492
+ (0, approvals_1.requireApproval)(ctx.config, origin, repoPath, capability, workspaceRoot);
493
+ return;
494
+ }
495
+ upsertOriginApproval(ctx, origin, capability);
496
+ await (0, config_1.saveConfig)(ctx.configDir, ctx.config);
497
+ };
498
+ const createPromptInterface = () => {
499
+ if (process.stdin.isTTY && process.stdout.isTTY) {
500
+ return readline_1.default.createInterface({
501
+ input: process.stdin,
502
+ output: process.stdout,
503
+ });
504
+ }
505
+ const ttyPath = process.platform === "win32" ? "CON" : "/dev/tty";
506
+ try {
507
+ const input = fsSync.createReadStream(ttyPath, { encoding: "utf8" });
508
+ const output = fsSync.createWriteStream(ttyPath);
509
+ return readline_1.default.createInterface({ input, output });
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ };
515
+ const askQuestion = (rl, question) => new Promise((resolve) => {
516
+ rl.question(question, (answer) => resolve(answer));
517
+ });
518
+ const promptApproval = async (origin, repoPath, capability) => {
519
+ const rl = createPromptInterface();
520
+ if (!rl) {
521
+ return false;
522
+ }
523
+ const answer = await askQuestion(rl, `Approve ${capability} for origin ${origin} (all repos)? [y/N] Requested path: ${repoPath} `);
524
+ rl.close();
525
+ const normalized = answer.trim().toLowerCase();
526
+ return normalized === "y" || normalized === "yes";
527
+ };
528
+ const upsertOriginApproval = (ctx, origin, capability) => {
529
+ const existing = ctx.config.approvals.entries.find((entry) => entry.origin === origin &&
530
+ (entry.repoPath === null || entry.repoPath === "*"));
531
+ if (existing) {
532
+ if (!existing.capabilities.includes(capability)) {
533
+ existing.capabilities.push(capability);
534
+ }
535
+ return;
536
+ }
537
+ ctx.config.approvals.entries.push({
538
+ origin,
539
+ repoPath: null,
540
+ capabilities: [capability],
541
+ approvedAt: new Date().toISOString(),
542
+ });
543
+ };
package/dist/approvals.js CHANGED
@@ -10,10 +10,13 @@ const hasApproval = (config, origin, repoPath, capability, workspaceRoot) => con
10
10
  if (entry.origin !== origin || !entry.capabilities.includes(capability)) {
11
11
  return false;
12
12
  }
13
+ if (entry.repoPath === null || entry.repoPath === "*") {
14
+ return true;
15
+ }
13
16
  if (entry.repoPath === repoPath) {
14
17
  return true;
15
18
  }
16
- if (workspaceRoot && !path_1.default.isAbsolute(entry.repoPath)) {
19
+ if (workspaceRoot && entry.repoPath && !path_1.default.isAbsolute(entry.repoPath)) {
17
20
  return path_1.default.resolve(workspaceRoot, entry.repoPath) === repoPath;
18
21
  }
19
22
  return false;
package/dist/config.js CHANGED
@@ -45,6 +45,9 @@ const defaultConfig = () => ({
45
45
  deps: {
46
46
  defaultSafer: true,
47
47
  },
48
+ healthchecks: {
49
+ suites: [],
50
+ },
48
51
  logging: {
49
52
  directory: "logs",
50
53
  maxFiles: 5,
@@ -88,6 +91,14 @@ const loadConfig = async (configDir) => {
88
91
  ...(0, exports.defaultConfig)().deps,
89
92
  ...data.deps,
90
93
  },
94
+ healthchecks: (() => {
95
+ const defaults = (0, exports.defaultConfig)().healthchecks ?? { suites: [] };
96
+ return {
97
+ ...defaults,
98
+ ...data.healthchecks,
99
+ suites: data.healthchecks?.suites ?? defaults.suites,
100
+ };
101
+ })(),
91
102
  logging: {
92
103
  ...(0, exports.defaultConfig)().logging,
93
104
  ...data.logging,
package/dist/context.js CHANGED
@@ -12,6 +12,7 @@ const config_1 = require("./config");
12
12
  const tokens_1 = require("./tokens");
13
13
  const pairing_1 = require("./pairing");
14
14
  const jobs_1 = require("./jobs");
15
+ const healthchecks_1 = require("./healthchecks");
15
16
  const readPackageVersion = async () => {
16
17
  try {
17
18
  const pkgPath = path_1.default.resolve(__dirname, "..", "package.json");
@@ -38,6 +39,7 @@ const createContext = async () => {
38
39
  const capabilities = await (0, tools_1.detectCapabilities)();
39
40
  const pairingManager = new pairing_1.PairingManager(tokenStore, config.pairing.tokenTtlDays);
40
41
  const jobManager = new jobs_1.JobManager(config.jobs.maxConcurrent, config.jobs.timeoutSeconds);
42
+ const healthcheckStore = new healthchecks_1.HealthcheckResultStore();
41
43
  const version = await readPackageVersion();
42
44
  const build = {
43
45
  commit: process.env.GIT_DAEMON_BUILD_COMMIT,
@@ -49,6 +51,7 @@ const createContext = async () => {
49
51
  tokenStore,
50
52
  pairingManager,
51
53
  jobManager,
54
+ healthcheckStore,
52
55
  capabilities,
53
56
  logger,
54
57
  version,