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 +31 -0
- package/config.schema.json +24 -0
- package/design.md +156 -2
- package/dist/app.js +221 -4
- package/dist/approvals.js +4 -1
- package/dist/config.js +29 -1
- package/dist/context.js +3 -0
- package/dist/git.js +117 -8
- package/dist/healthchecks.js +425 -0
- package/dist/jobs.js +1 -0
- package/dist/validation.js +20 -1
- package/healthchecks/example-suite/README.md +10 -0
- package/healthchecks/example-suite/about-description/healthcheck.json +17 -0
- package/healthchecks/example-suite/about-description/run.js +241 -0
- package/healthchecks/example-suite/package-manager/healthcheck.json +17 -0
- package/healthchecks/example-suite/package-manager/run.js +106 -0
- package/healthchecks/example-suite/suite.json +6 -0
- package/openapi.yaml +277 -0
- package/package.json +3 -1
- package/src/app.ts +111 -0
- package/src/config.ts +35 -1
- package/src/context.ts +3 -0
- package/src/daemon.ts +4 -0
- package/src/healthchecks.ts +620 -0
- package/src/jobs.ts +2 -0
- package/src/types.ts +6 -1
- package/src/validation.ts +14 -0
- package/tests/app.test.ts +177 -20
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
|
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
326
|
+
await ensureApproval(ctx, origin, resolved, "open-terminal", workspaceRoot);
|
|
240
327
|
}
|
|
241
328
|
if (payload.target === "vscode") {
|
|
242
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|