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 +38 -1
- package/config.schema.json +19 -0
- package/design.md +160 -2
- package/dist/app.js +221 -4
- package/dist/approvals.js +4 -1
- package/dist/config.js +11 -0
- 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 +347 -0
- package/package.json +1 -1
- package/src/app.ts +150 -0
- package/src/config.ts +11 -0
- package/src/context.ts +3 -0
- package/src/git.ts +104 -8
- package/src/healthchecks.ts +620 -0
- package/src/jobs.ts +2 -0
- package/src/types.ts +5 -1
- package/src/validation.ts +18 -0
- package/tests/app.test.ts +223 -16
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
|
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,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,
|