git-daemon 0.1.9 → 0.1.11

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
@@ -10,9 +10,19 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
10
10
  - Clone, fetch, list branches, and read Git status using your system Git credentials
11
11
  - Provide a status summary for UI badges/tooltips
12
12
  - Stream long-running job logs via Server-Sent Events (SSE)
13
+ - Run user-defined healthchecks and return normalized results
13
14
  - Open a repo in the OS file browser, terminal, or VS Code (with approvals)
14
15
  - Install dependencies with safer defaults (`--ignore-scripts` by default)
15
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. For a
23
+ quick demo, set `healthchecks.demo: true` in config to auto-enable the bundled example
24
+ suite (when present).
25
+
16
26
  ## Security model (high level)
17
27
 
18
28
  - **Loopback-only**: binds to `127.0.0.1`
@@ -170,6 +180,25 @@ curl -H "Origin: https://app.example.com" \
170
180
  "http://127.0.0.1:8790/v1/git/summary?repoPath=owner/repo"
171
181
  ```
172
182
 
183
+ Run healthchecks (job):
184
+
185
+ ```bash
186
+ curl -X POST \
187
+ -H "Origin: https://app.example.com" \
188
+ -H "Authorization: Bearer <TOKEN>" \
189
+ -H "Content-Type: application/json" \
190
+ -d '{"repoPath":"owner/repo","checks":[{"suiteId":"team-default","checkId":"lint","config":{"strict":true}}]}' \
191
+ http://127.0.0.1:8790/v1/healthchecks/run
192
+ ```
193
+
194
+ Fetch healthcheck results:
195
+
196
+ ```bash
197
+ curl -H "Origin: https://app.example.com" \
198
+ -H "Authorization: Bearer <TOKEN>" \
199
+ http://127.0.0.1:8790/v1/healthchecks/jobs/<JOB_ID>/result
200
+ ```
201
+
173
202
  ## Configuration
174
203
 
175
204
  Config is stored in OS-specific directories:
@@ -190,6 +219,8 @@ Key settings live in `config.json`:
190
219
  - `workspaceRoot`: absolute path to the workspace root
191
220
  - `deps.defaultSafer`: defaults to `true` for `--ignore-scripts`
192
221
  - `jobs.maxConcurrent` and `jobs.timeoutSeconds`
222
+ - `healthchecks.suites`: array of absolute or config-relative suite paths
223
+ - `healthchecks.demo`: enable bundled example healthchecks if present
193
224
 
194
225
  Tokens are stored (hashed) in `tokens.json`. Logs are written under the configured `logging.directory` with rotation.
195
226
 
@@ -124,6 +124,30 @@
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
+ "demo": {
145
+ "type": "boolean",
146
+ "default": false,
147
+ "description": "When true, auto-enables the bundled example healthchecks if present."
148
+ }
149
+ }
150
+ },
127
151
  "logging": {
128
152
  "type": "object",
129
153
  "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
@@ -304,6 +428,16 @@ Recommendations:
304
428
  * `safer=true` maps to flags that reduce script execution risk (e.g., `--ignore-scripts` for npm/pnpm/yarn).
305
429
  * Default to `safer=true` for all runs; allow a per-repo override to enable scripts.
306
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
+
307
441
  ---
308
442
 
309
443
  ## Job Model
@@ -317,7 +451,7 @@ Recommendations:
317
451
  Each event is JSON:
318
452
 
319
453
  * `{ type: "log", stream: "stdout"|"stderr", line: "..." }`
320
- * `{ type: "progress", kind: "git"|"deps", percent?: number, detail?: string }`
454
+ * `{ type: "progress", kind: "git"|"deps"|"healthcheck", percent?: number, detail?: string }`
321
455
  * `{ type: "state", state: "running"|"done"|"error", message?: string }`
322
456
 
323
457
  ### Cancellation
@@ -373,7 +507,8 @@ Each event is JSON:
373
507
  4. Pair once (code/confirm)
374
508
  5. User clicks “Clone locally”
375
509
  6. UI calls daemon `/v1/git/clone`, streams job logs
376
- 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”
377
512
 
378
513
  ### 2) Local UI + daemon (dev)
379
514
 
@@ -433,6 +568,25 @@ curl -N \
433
568
  http://127.0.0.1:8790/v1/jobs/<JOB_ID>/stream
434
569
  ```
435
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
+
436
590
  ---
437
591
 
438
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,10 @@ const defaultConfig = () => ({
45
45
  deps: {
46
46
  defaultSafer: true,
47
47
  },
48
+ healthchecks: {
49
+ suites: [],
50
+ demo: false,
51
+ },
48
52
  logging: {
49
53
  directory: "logs",
50
54
  maxFiles: 5,
@@ -65,7 +69,7 @@ const loadConfig = async (configDir) => {
65
69
  try {
66
70
  const raw = await fs_1.promises.readFile(configPath, "utf8");
67
71
  const data = JSON.parse(raw);
68
- return {
72
+ const merged = {
69
73
  ...(0, exports.defaultConfig)(),
70
74
  ...data,
71
75
  server: {
@@ -88,6 +92,14 @@ const loadConfig = async (configDir) => {
88
92
  ...(0, exports.defaultConfig)().deps,
89
93
  ...data.deps,
90
94
  },
95
+ healthchecks: (() => {
96
+ const defaults = (0, exports.defaultConfig)().healthchecks ?? { suites: [] };
97
+ return {
98
+ ...defaults,
99
+ ...data.healthchecks,
100
+ suites: data.healthchecks?.suites ?? defaults.suites,
101
+ };
102
+ })(),
91
103
  logging: {
92
104
  ...(0, exports.defaultConfig)().logging,
93
105
  ...data.logging,
@@ -96,6 +108,22 @@ const loadConfig = async (configDir) => {
96
108
  entries: data.approvals?.entries ?? [],
97
109
  },
98
110
  };
111
+ if (merged.healthchecks?.demo) {
112
+ const demoSuitePath = path_1.default.resolve(__dirname, "..", "healthchecks", "example-suite");
113
+ try {
114
+ await fs_1.promises.access(demoSuitePath);
115
+ if (!merged.healthchecks.suites.includes(demoSuitePath)) {
116
+ merged.healthchecks.suites = [
117
+ ...merged.healthchecks.suites,
118
+ demoSuitePath,
119
+ ];
120
+ }
121
+ }
122
+ catch {
123
+ // Ignore missing demo suite; config can still load.
124
+ }
125
+ }
126
+ return merged;
99
127
  }
100
128
  catch (err) {
101
129
  if (err.code !== "ENOENT") {
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,