@loomhelix/deptrail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/actions/action-error.js +11 -0
  4. package/dist/actions/action-jobs.js +199 -0
  5. package/dist/actions/action-service.js +268 -0
  6. package/dist/actions/apply.js +120 -0
  7. package/dist/actions/availability.js +79 -0
  8. package/dist/actions/change-sets.js +117 -0
  9. package/dist/actions/dry-run.js +287 -0
  10. package/dist/actions/edge-moves.js +108 -0
  11. package/dist/actions/edit-plan.js +179 -0
  12. package/dist/actions/file-scope.js +118 -0
  13. package/dist/actions/fix-options.js +141 -0
  14. package/dist/actions/goal-check.js +80 -0
  15. package/dist/actions/install.js +27 -0
  16. package/dist/actions/job-registry.js +104 -0
  17. package/dist/actions/json-edit.js +189 -0
  18. package/dist/actions/labels.js +35 -0
  19. package/dist/actions/node-actions.js +102 -0
  20. package/dist/actions/overrides-location.js +59 -0
  21. package/dist/actions/parent-chain.js +210 -0
  22. package/dist/actions/range-style.js +38 -0
  23. package/dist/actions/recipes.js +31 -0
  24. package/dist/actions/root-files.js +30 -0
  25. package/dist/actions/sandbox.js +175 -0
  26. package/dist/actions/set-range.js +59 -0
  27. package/dist/actions/shared-packages.js +50 -0
  28. package/dist/actions/text-diff.js +203 -0
  29. package/dist/actions/types.js +1 -0
  30. package/dist/actions/update-options.js +232 -0
  31. package/dist/actions/workspace-edit.js +33 -0
  32. package/dist/adapters/command-runner.js +127 -0
  33. package/dist/adapters/manifest-only.js +20 -0
  34. package/dist/adapters/node-modules.js +148 -0
  35. package/dist/adapters/npm-lock.js +149 -0
  36. package/dist/adapters/package-manifest.js +57 -0
  37. package/dist/adapters/pnpm-lock.js +177 -0
  38. package/dist/adapters/resolve-today.js +93 -0
  39. package/dist/cli/cli.js +227 -0
  40. package/dist/cli/open-browser.js +14 -0
  41. package/dist/cli/package-version.js +6 -0
  42. package/dist/cli/usage-text.js +27 -0
  43. package/dist/config/glob.js +119 -0
  44. package/dist/config/npmrc.js +116 -0
  45. package/dist/config/root-config.js +103 -0
  46. package/dist/config/secrets.js +18 -0
  47. package/dist/discovery/find-roots.js +107 -0
  48. package/dist/enrich/credentials.js +143 -0
  49. package/dist/enrich/enrichment-cache.js +42 -0
  50. package/dist/enrich/enrichment-keys.js +71 -0
  51. package/dist/enrich/enrichment-service.js +399 -0
  52. package/dist/enrich/enrichment-status.js +86 -0
  53. package/dist/enrich/enrichment-store.js +143 -0
  54. package/dist/enrich/http.js +201 -0
  55. package/dist/enrich/on-demand.js +220 -0
  56. package/dist/enrich/osv-client.js +260 -0
  57. package/dist/enrich/registry-client.js +123 -0
  58. package/dist/enrich/types.js +1 -0
  59. package/dist/git/blob-reader.js +180 -0
  60. package/dist/git/git-commands.js +32 -0
  61. package/dist/git/git-process.js +71 -0
  62. package/dist/git/git-repository.js +136 -0
  63. package/dist/git/ref-list.js +78 -0
  64. package/dist/git/tree-index.js +87 -0
  65. package/dist/graph/global-index.js +19 -0
  66. package/dist/graph/root-graph-builder.js +114 -0
  67. package/dist/graph/serialize.js +19 -0
  68. package/dist/model.js +2 -0
  69. package/dist/query/children.js +38 -0
  70. package/dist/query/compare-shape.js +13 -0
  71. package/dist/query/compare.js +245 -0
  72. package/dist/query/decorations.js +121 -0
  73. package/dist/query/dependents.js +135 -0
  74. package/dist/query/duplicates.js +23 -0
  75. package/dist/query/enrichment-lookup.js +78 -0
  76. package/dist/query/fixes.js +60 -0
  77. package/dist/query/graph-diff.js +42 -0
  78. package/dist/query/issues.js +235 -0
  79. package/dist/query/lookup.js +49 -0
  80. package/dist/query/node-fix.js +46 -0
  81. package/dist/query/package-insight.js +112 -0
  82. package/dist/query/search.js +61 -0
  83. package/dist/query/subgraph.js +107 -0
  84. package/dist/query/versions.js +103 -0
  85. package/dist/scan/build-root.js +137 -0
  86. package/dist/scan/cache-shape.js +52 -0
  87. package/dist/scan/scan-cache.js +32 -0
  88. package/dist/scan/scan-session.js +127 -0
  89. package/dist/scan/source-registry.js +55 -0
  90. package/dist/server/auth.js +45 -0
  91. package/dist/server/branches.js +105 -0
  92. package/dist/server/error-log.js +9 -0
  93. package/dist/server/root-segment.js +13 -0
  94. package/dist/server/router.js +474 -0
  95. package/dist/server/server.js +166 -0
  96. package/dist/server/source-key.js +1 -0
  97. package/dist/server/static.js +59 -0
  98. package/dist/sources/file-reader.js +55 -0
  99. package/dist/sources/git-reader.js +45 -0
  100. package/dist/ui/assets/index-C4uyB5v3.css +1 -0
  101. package/dist/ui/assets/index-DYAG2LM-.js +51 -0
  102. package/dist/ui/index.html +15 -0
  103. package/package.json +72 -0
@@ -0,0 +1,474 @@
1
+ import { ActionError } from "../actions/action-error.js";
2
+ import { disabledActions } from "../actions/action-service.js";
3
+ import { isObjectId } from "../git/git-commands.js";
4
+ import { compareSources } from "../query/compare.js";
5
+ import { BranchError, COMMIT_ACTIONS_REASON, COMMIT_RESOLVE_REASON } from "./branches.js";
6
+ import { PLACEHOLDER_PREFIX } from "../model.js";
7
+ import { decodeRootSegment } from "./root-segment.js";
8
+ import { children } from "../query/children.js";
9
+ import { decorations, issueCounts } from "../query/decorations.js";
10
+ import { dependents } from "../query/dependents.js";
11
+ import { duplicates } from "../query/duplicates.js";
12
+ import { issuesByDirect, issuesByPackage } from "../query/issues.js";
13
+ import { ALL_KINDS, findNode, UnknownNodeError } from "../query/lookup.js";
14
+ import { packageInsight } from "../query/package-insight.js";
15
+ import { search } from "../query/search.js";
16
+ import { subgraph } from "../query/subgraph.js";
17
+ const DEFAULT_DEPTH = 1;
18
+ const DEFAULT_UP = 3;
19
+ const DEFAULT_DOWN = 1;
20
+ const DEFAULT_LIMIT = 300;
21
+ const DEFAULT_GROUP_BY = 'package';
22
+ export class BadRequestError extends Error {
23
+ }
24
+ function reply(status, body) {
25
+ return { status, body };
26
+ }
27
+ function failure(status, message) {
28
+ return reply(status, { error: message });
29
+ }
30
+ function importerSummaries(root) {
31
+ return root.importers.map(({ id, path, name, deps }) => ({ id, path, name, dependencyCount: deps.length }));
32
+ }
33
+ function summarizeRoot({ session, enrichment, actions }, root) {
34
+ const summary = {
35
+ id: root.id,
36
+ path: root.path,
37
+ manager: root.manager,
38
+ source: root.source,
39
+ status: root.status,
40
+ warnings: root.warnings,
41
+ importers: importerSummaries(root),
42
+ packageCount: [...root.packages.keys()].filter((id) => !id.startsWith(PLACEHOLDER_PREFIX)).length,
43
+ duplicateCount: duplicates(session.index, root.id).length,
44
+ hasAuth: root.config?.hasAuth ?? false,
45
+ actions: actions.summary(root),
46
+ };
47
+ if (root.error !== undefined)
48
+ summary.error = root.error;
49
+ if (root.status === 'ready')
50
+ summary.issueCounts = issueCounts(enrichment, root);
51
+ return summary;
52
+ }
53
+ function anyRootScanning(roots) {
54
+ return roots.some((root) => root.status === 'scanning');
55
+ }
56
+ export function summarizeScan(context) {
57
+ const { repoPath, scannedAt, roots } = context.session.scan;
58
+ return {
59
+ repoPath,
60
+ scannedAt,
61
+ roots: roots.map((root) => summarizeRoot(context, root)),
62
+ enrichment: context.enrichment.status(anyRootScanning(roots)),
63
+ };
64
+ }
65
+ function readyRoot(session, rootId) {
66
+ const root = session.root(rootId);
67
+ if (!root)
68
+ return { failure: failure(404, `Unknown root ${rootId}`) };
69
+ if (root.status !== 'ready')
70
+ return { failure: failure(409, `Root ${rootId} is ${root.status}`) };
71
+ return { root };
72
+ }
73
+ function integerParam(params, name, fallback, minimum) {
74
+ const raw = params.get(name);
75
+ if (raw === null)
76
+ return fallback;
77
+ const value = Number(raw);
78
+ if (raw.trim() === '' || !Number.isInteger(value) || value < minimum) {
79
+ throw new BadRequestError(`Invalid ${name}: expected an integer >= ${minimum}`);
80
+ }
81
+ return value;
82
+ }
83
+ function kindsParam(params) {
84
+ const raw = params.get('kinds');
85
+ if (raw === null)
86
+ return ALL_KINDS;
87
+ const requested = raw.split(',');
88
+ for (const kind of requested) {
89
+ if (!ALL_KINDS.has(kind))
90
+ throw new BadRequestError(`Unknown kind: ${kind}`);
91
+ }
92
+ return new Set(requested);
93
+ }
94
+ function groupByParam(params) {
95
+ const raw = params.get('groupBy') ?? DEFAULT_GROUP_BY;
96
+ if (raw !== 'package' && raw !== 'direct')
97
+ throw new BadRequestError('Invalid groupBy: expected package or direct');
98
+ return raw;
99
+ }
100
+ function monolithVersionsOf(session, root, nodeId) {
101
+ const node = findNode(root, nodeId);
102
+ if (!node)
103
+ return [];
104
+ return [...new Set((session.index.get(node.name) ?? []).map((entry) => entry.version))];
105
+ }
106
+ const NODE_HANDLERS = new Map(Object.entries({
107
+ actions: ({ actions }, root, nodeId, params) => actions.nodeActions(root.id, nodeId, kindsParam(params)),
108
+ children: (_context, root, nodeId, params) => children(root, nodeId, integerParam(params, 'depth', DEFAULT_DEPTH, 0), kindsParam(params)),
109
+ dependents: (_context, root, nodeId, params) => dependents(root, nodeId, integerParam(params, 'depth', DEFAULT_DEPTH, 0), kindsParam(params)),
110
+ subgraph: (_context, root, nodeId, params) => subgraph(root, nodeId, {
111
+ up: integerParam(params, 'up', DEFAULT_UP, 0),
112
+ down: integerParam(params, 'down', DEFAULT_DOWN, 0),
113
+ limit: integerParam(params, 'limit', DEFAULT_LIMIT, 1),
114
+ kinds: kindsParam(params),
115
+ }),
116
+ insight: ({ session, enrichment }, root, nodeId, params) => packageInsight(enrichment, root, nodeId, kindsParam(params), monolithVersionsOf(session, root, nodeId)),
117
+ }));
118
+ function runDetached(work, logError, context) {
119
+ work.catch((failure) => logError(context, failure));
120
+ }
121
+ function rootSegmentPosition(raw) {
122
+ if (raw[1] === 'roots')
123
+ return 2;
124
+ if (raw[1] === 'commits' && raw[3] === 'roots')
125
+ return 4;
126
+ return -1;
127
+ }
128
+ function decodeSegments(pathname) {
129
+ try {
130
+ const raw = pathname.split('/').slice(1);
131
+ const rootPosition = rootSegmentPosition(raw);
132
+ return raw.map((segment, position) => position === rootPosition ? decodeRootSegment(segment) : decodeURIComponent(segment));
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ async function handleNodeRoute(context, request, rootId, nodeId, action) {
139
+ const handler = NODE_HANDLERS.get(action);
140
+ if (!handler)
141
+ return failure(404, 'Not found');
142
+ if (request.method !== 'GET')
143
+ return failure(405, 'Method not allowed');
144
+ const lookup = readyRoot(context.session, rootId);
145
+ if (lookup.failure)
146
+ return lookup.failure;
147
+ return reply(200, await handler(context, lookup.root, nodeId, request.searchParams));
148
+ }
149
+ function handleDecorations(context, request, rootId) {
150
+ if (request.method !== 'GET')
151
+ return failure(405, 'Method not allowed');
152
+ const lookup = readyRoot(context.session, rootId);
153
+ if (lookup.failure)
154
+ return lookup.failure;
155
+ const { enrichment } = context;
156
+ const body = { revision: enrichment.revision, nodes: decorations(enrichment, lookup.root, kindsParam(request.searchParams)) };
157
+ return reply(200, body);
158
+ }
159
+ function invalidBody(expected) {
160
+ return new BadRequestError(`Invalid body: expected ${expected}`);
161
+ }
162
+ function isRecord(value) {
163
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
164
+ }
165
+ function isOptional(value, check) {
166
+ return value === undefined || check(value);
167
+ }
168
+ function isString(value) {
169
+ return typeof value === 'string';
170
+ }
171
+ function isStringArray(value) {
172
+ return Array.isArray(value) && value.every(isString);
173
+ }
174
+ const ADD_CHANGE_SHAPE = '{ nodeId: string, choice: string, version?: string, importers?: string[], kinds?: string[] }';
175
+ const KINDS_SHAPE = '{ kinds?: string[] }';
176
+ const INVALID_KINDS = 'Invalid kinds';
177
+ const APPLY_SHAPE = '{ previewId: string, install: boolean }';
178
+ function addChangeBody(body) {
179
+ if (!isRecord(body) ||
180
+ !isString(body.nodeId) ||
181
+ !isString(body.choice) ||
182
+ !isOptional(body.version, isString) ||
183
+ !isOptional(body.importers, isStringArray) ||
184
+ !isOptional(body.kinds, isStringArray)) {
185
+ throw invalidBody(ADD_CHANGE_SHAPE);
186
+ }
187
+ return body;
188
+ }
189
+ function kindsBody(body) {
190
+ if (body === undefined)
191
+ return ALL_KINDS;
192
+ if (!isRecord(body) || !isOptional(body.kinds, isStringArray))
193
+ throw invalidBody(KINDS_SHAPE);
194
+ if (body.kinds === undefined)
195
+ return ALL_KINDS;
196
+ if (body.kinds.length === 0)
197
+ throw new BadRequestError(INVALID_KINDS);
198
+ for (const kind of body.kinds) {
199
+ if (!ALL_KINDS.has(kind))
200
+ throw new BadRequestError(`Unknown kind: ${kind}`);
201
+ }
202
+ return new Set(body.kinds);
203
+ }
204
+ function applyBody(body) {
205
+ if (!isRecord(body) || !isString(body.previewId) || typeof body.install !== 'boolean')
206
+ throw invalidBody(APPLY_SHAPE);
207
+ return { previewId: body.previewId, install: body.install };
208
+ }
209
+ function fileIndex(segment) {
210
+ const value = Number(segment);
211
+ if (segment.trim() === '' || !Number.isInteger(value) || value < 0)
212
+ throw new BadRequestError('Invalid index: expected an integer >= 0');
213
+ return value;
214
+ }
215
+ function onlyMethod(request, method) {
216
+ if (request.method !== method)
217
+ throw new MethodNotAllowedError();
218
+ }
219
+ class MethodNotAllowedError extends Error {
220
+ }
221
+ function unknownRoot(rootId) {
222
+ return failure(404, `Unknown root ${rootId}`);
223
+ }
224
+ async function handleChangesRoute(context, request, rootId, rest) {
225
+ const { actions } = context;
226
+ if (rest.length === 1) {
227
+ if (request.method === 'GET')
228
+ return reply(200, await actions.changes(rootId));
229
+ if (request.method === 'POST')
230
+ return reply(201, await actions.addChange(rootId, addChangeBody(request.body)));
231
+ onlyMethod(request, 'DELETE');
232
+ actions.clearChanges(rootId);
233
+ return reply(204, null);
234
+ }
235
+ if (rest.length === 2 && rest[1] === 'recommended') {
236
+ onlyMethod(request, 'POST');
237
+ return reply(202, { jobId: actions.startCollect(rootId, kindsBody(request.body)).id });
238
+ }
239
+ if (rest.length === 2 && rest[1] === 'restore-cleared') {
240
+ onlyMethod(request, 'POST');
241
+ return reply(200, actions.restoreCleared(rootId));
242
+ }
243
+ if (rest.length === 2) {
244
+ if (request.method === 'PUT')
245
+ return reply(200, await actions.replaceChange(rootId, rest[1], addChangeBody(request.body)));
246
+ onlyMethod(request, 'DELETE');
247
+ actions.removeChange(rootId, rest[1]);
248
+ return reply(204, null);
249
+ }
250
+ return failure(404, 'Not found');
251
+ }
252
+ async function handleActionRoute(context, request, rootId, rest) {
253
+ const { actions } = context;
254
+ const [resource] = rest;
255
+ if (resource === 'changes')
256
+ return handleChangesRoute(context, request, rootId, rest);
257
+ if (resource === 'preview' && rest.length === 1) {
258
+ onlyMethod(request, 'POST');
259
+ return reply(202, { jobId: actions.startPreview(rootId).id });
260
+ }
261
+ if (resource === 'preview' && rest.length === 4 && rest[1] === 'files' && rest[3] === 'diff') {
262
+ onlyMethod(request, 'GET');
263
+ return reply(200, actions.previewFileDiff(rootId, fileIndex(rest[2])));
264
+ }
265
+ if (resource === 'apply' && rest.length === 1) {
266
+ onlyMethod(request, 'POST');
267
+ const { previewId, install } = applyBody(request.body);
268
+ return reply(202, { jobId: (await actions.startApply(rootId, previewId, install)).id });
269
+ }
270
+ if (resource === 'revert' && rest.length === 1) {
271
+ onlyMethod(request, 'POST');
272
+ return reply(202, { jobId: (await actions.startRevert(rootId)).id });
273
+ }
274
+ return undefined;
275
+ }
276
+ function handleJobRoute(context, request, rest) {
277
+ const { actions } = context;
278
+ if (rest.length === 1) {
279
+ onlyMethod(request, 'GET');
280
+ return reply(200, actions.job(rest[0], integerParam(request.searchParams, 'since', 0, 0)));
281
+ }
282
+ if (rest.length === 2 && rest[1] === 'cancel') {
283
+ onlyMethod(request, 'POST');
284
+ actions.cancelJob(rest[0]);
285
+ return reply(202, { accepted: true });
286
+ }
287
+ return failure(404, 'Not found');
288
+ }
289
+ const ACTION_RESOURCES = new Set(['changes', 'preview', 'apply', 'revert']);
290
+ function handleRootRoute(context, request, rootId, rest, logError) {
291
+ const { session, actions } = context;
292
+ const root = session.root(rootId);
293
+ if (rest.length === 1 && rest[0] === 'importers') {
294
+ if (request.method !== 'GET')
295
+ return failure(405, 'Method not allowed');
296
+ if (!root)
297
+ return failure(404, `Unknown root ${rootId}`);
298
+ return reply(200, importerSummaries(root));
299
+ }
300
+ if (rest.length === 1 && rest[0] === 'resolve') {
301
+ if (request.method !== 'POST')
302
+ return failure(405, 'Method not allowed');
303
+ if (!root)
304
+ return failure(404, `Unknown root ${rootId}`);
305
+ if (context.resolveUnavailable !== undefined)
306
+ return failure(409, context.resolveUnavailable);
307
+ if (actions.isBusy(rootId))
308
+ return failure(409, `A change job is running for ${rootId}`);
309
+ runDetached(session.resolve(rootId), logError, `resolve ${rootId}`);
310
+ return reply(202, { accepted: true });
311
+ }
312
+ if (rest.length === 1 && rest[0] === 'decorations')
313
+ return handleDecorations(context, request, rootId);
314
+ if (rest.length === 3 && rest[0] === 'nodes')
315
+ return handleNodeRoute(context, request, rootId, rest[1], rest[2]);
316
+ if (ACTION_RESOURCES.has(rest[0])) {
317
+ if (!root)
318
+ return unknownRoot(rootId);
319
+ return handleActionRoute(context, request, rootId, rest).then((response) => response ?? failure(404, 'Not found'));
320
+ }
321
+ return failure(404, 'Not found');
322
+ }
323
+ function handleIssues(context, request) {
324
+ if (request.method !== 'GET')
325
+ return failure(405, 'Method not allowed');
326
+ const params = request.searchParams;
327
+ const groupBy = groupByParam(params);
328
+ const kinds = kindsParam(params);
329
+ const rootId = params.get('root');
330
+ let roots;
331
+ if (rootId === null) {
332
+ roots = context.session.scan.roots.filter((root) => root.status === 'ready');
333
+ }
334
+ else {
335
+ const lookup = readyRoot(context.session, rootId);
336
+ if (lookup.failure)
337
+ return lookup.failure;
338
+ roots = [lookup.root];
339
+ }
340
+ const { enrichment } = context;
341
+ if (groupBy === 'direct') {
342
+ const body = { revision: enrichment.revision, groups: issuesByDirect(enrichment, roots, kinds) };
343
+ return reply(200, body);
344
+ }
345
+ const body = { revision: enrichment.revision, rows: issuesByPackage(enrichment, roots, kinds) };
346
+ return reply(200, body);
347
+ }
348
+ function handleEnrichmentRefresh(context, request) {
349
+ if (request.method !== 'POST')
350
+ return failure(405, 'Method not allowed');
351
+ const { session, enrichment } = context;
352
+ if (enrichment.status(anyRootScanning(session.scan.roots)).state === 'offline')
353
+ return failure(409, 'Package checks are disabled with --offline');
354
+ enrichment.refresh();
355
+ return reply(202, { accepted: true });
356
+ }
357
+ const SOURCE_RESOURCES = new Set(['scan', 'search', 'duplicates', 'issues', 'roots']);
358
+ const SOURCES_SHAPE = '{ ref: string } or { mergeBase: { base: string, target: string } }';
359
+ const BRANCHES_DISABLED = { available: false, reason: 'Branches are not enabled', quiet: true };
360
+ const COMMIT_ACTIONS = disabledActions(COMMIT_ACTIONS_REASON);
361
+ function sourcesBody(body) {
362
+ if (isRecord(body) && isString(body.ref))
363
+ return { ref: body.ref };
364
+ const mergeBase = isRecord(body) ? body.mergeBase : undefined;
365
+ if (isRecord(mergeBase) && isString(mergeBase.base) && isString(mergeBase.target)) {
366
+ return { mergeBase: { base: mergeBase.base, target: mergeBase.target } };
367
+ }
368
+ throw invalidBody(SOURCES_SHAPE);
369
+ }
370
+ function unknownSource(key) {
371
+ return failure(404, `Unknown source ${key}`);
372
+ }
373
+ async function handleGit(context, request) {
374
+ onlyMethod(request, 'GET');
375
+ return reply(200, context.branches ? await context.branches.info() : BRANCHES_DISABLED);
376
+ }
377
+ async function handleSources(context, request) {
378
+ onlyMethod(request, 'POST');
379
+ if (!context.branches)
380
+ return failure(409, BRANCHES_DISABLED.reason);
381
+ return reply(200, await context.branches.open(sourcesBody(request.body)));
382
+ }
383
+ function handleCommitRoute(context, request, rest, logError) {
384
+ const [commit, resource] = rest;
385
+ if (!SOURCE_RESOURCES.has(resource))
386
+ return failure(404, 'Not found');
387
+ const session = isObjectId(commit) ? context.branches?.sessionOf(commit) : undefined;
388
+ if (!session)
389
+ return unknownSource(commit);
390
+ return route({ session, enrichment: context.enrichment, actions: COMMIT_ACTIONS, resolveUnavailable: COMMIT_RESOLVE_REASON }, request, ['api', ...rest.slice(1)], logError);
391
+ }
392
+ function compareSideOf(session) {
393
+ const { roots, skippedSubmodules } = session.scan;
394
+ return skippedSubmodules === undefined ? { roots } : { roots, skippedSubmodules };
395
+ }
396
+ function handleCompare(context, request) {
397
+ onlyMethod(request, 'GET');
398
+ const base = request.searchParams.get('base');
399
+ const target = request.searchParams.get('target');
400
+ if (base === null || target === null)
401
+ throw new BadRequestError('Missing base or target');
402
+ const baseSession = context.branches?.sessionOf(base);
403
+ const targetSession = context.branches?.sessionOf(target);
404
+ if (!baseSession)
405
+ return unknownSource(base);
406
+ if (!targetSession)
407
+ return unknownSource(target);
408
+ return reply(200, compareSources(context.enrichment, compareSideOf(baseSession), compareSideOf(targetSession)));
409
+ }
410
+ function route(context, request, segments, logError) {
411
+ const { session } = context;
412
+ const [, resource, ...rest] = segments;
413
+ if (resource === 'scan' && rest.length === 0) {
414
+ return request.method === 'GET' ? reply(200, summarizeScan(context)) : failure(405, 'Method not allowed');
415
+ }
416
+ if (resource === 'rescan' && rest.length === 0) {
417
+ if (request.method !== 'POST')
418
+ return failure(405, 'Method not allowed');
419
+ if (context.actions.anyBusy())
420
+ return failure(409, 'A change job is running');
421
+ runDetached(session.rescan(), logError, 'rescan');
422
+ return reply(202, { accepted: true });
423
+ }
424
+ if (resource === 'search' && rest.length === 0) {
425
+ if (request.method !== 'GET')
426
+ return failure(405, 'Method not allowed');
427
+ return reply(200, search(session.index, request.searchParams.get('q') ?? ''));
428
+ }
429
+ if (resource === 'duplicates' && rest.length === 0) {
430
+ if (request.method !== 'GET')
431
+ return failure(405, 'Method not allowed');
432
+ return reply(200, duplicates(session.index, request.searchParams.get('root') ?? undefined));
433
+ }
434
+ if (resource === 'git' && rest.length === 0)
435
+ return handleGit(context, request);
436
+ if (resource === 'sources' && rest.length === 0)
437
+ return handleSources(context, request);
438
+ if (resource === 'commits' && rest.length >= 2)
439
+ return handleCommitRoute(context, request, rest, logError);
440
+ if (resource === 'compare' && rest.length === 0)
441
+ return handleCompare(context, request);
442
+ if (resource === 'issues' && rest.length === 0)
443
+ return handleIssues(context, request);
444
+ if (resource === 'enrichment' && rest.length === 1 && rest[0] === 'refresh')
445
+ return handleEnrichmentRefresh(context, request);
446
+ if (resource === 'jobs' && rest.length >= 1)
447
+ return handleJobRoute(context, request, rest);
448
+ if (resource === 'roots' && rest.length >= 1)
449
+ return handleRootRoute(context, request, rest[0], rest.slice(1), logError);
450
+ return failure(404, 'Not found');
451
+ }
452
+ export async function handleApi(context, request, logError) {
453
+ const segments = decodeSegments(request.pathname);
454
+ if (!segments)
455
+ return failure(400, 'Malformed path encoding');
456
+ try {
457
+ return await route(context, request, segments, logError);
458
+ }
459
+ catch (error) {
460
+ if (error instanceof BadRequestError)
461
+ return failure(400, error.message);
462
+ if (error instanceof BranchError)
463
+ return failure(error.status, error.message);
464
+ if (error instanceof MethodNotAllowedError)
465
+ return failure(405, 'Method not allowed');
466
+ if (error instanceof ActionError) {
467
+ return reply(error.status, { error: error.message, ...(error.conflictsWith === undefined ? {} : { conflictsWith: error.conflictsWith }) });
468
+ }
469
+ if (error instanceof UnknownNodeError)
470
+ return failure(404, error.message);
471
+ logError(`${request.method} ${request.pathname}`, error);
472
+ return failure(500, 'Internal error');
473
+ }
474
+ }
@@ -0,0 +1,166 @@
1
+ import { createServer } from 'node:http';
2
+ import { disabledActions } from "../actions/action-service.js";
3
+ import { offlineEnrichment } from "../enrich/enrichment-service.js";
4
+ import { generateToken, isAllowedHost, isAllowedOrigin, sessionCookie, tokensMatch, requestToken } from "./auth.js";
5
+ import { createErrorLog } from "./error-log.js";
6
+ import { handleApi } from "./router.js";
7
+ import { serveStatic } from "./static.js";
8
+ export const MAX_BODY_BYTES = 65_536;
9
+ const LOOPBACK = '127.0.0.1';
10
+ const JSON_MEDIA_TYPE = 'application/json';
11
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
12
+ class RequestRejection extends Error {
13
+ status;
14
+ constructor(status, message) {
15
+ super(message);
16
+ this.status = status;
17
+ }
18
+ }
19
+ function sendText(response, status, text) {
20
+ response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
21
+ response.end(text);
22
+ }
23
+ function sendJson(response, status, body) {
24
+ if (status === 204) {
25
+ response.writeHead(status, { 'Cache-Control': 'no-store' });
26
+ response.end();
27
+ return;
28
+ }
29
+ const text = JSON.stringify(body);
30
+ response.writeHead(status, {
31
+ 'Content-Type': 'application/json; charset=utf-8',
32
+ 'Cache-Control': 'no-store',
33
+ 'X-Content-Type-Options': 'nosniff',
34
+ });
35
+ response.end(text);
36
+ }
37
+ function redirectWithSessionCookie(response, url, token, port) {
38
+ const target = new URL(url);
39
+ target.searchParams.delete('token');
40
+ response.writeHead(302, {
41
+ 'Set-Cookie': sessionCookie(token, port),
42
+ Location: `${target.pathname}${target.search}`,
43
+ 'Cache-Control': 'no-store',
44
+ });
45
+ response.end();
46
+ }
47
+ function isApiPath(pathname) {
48
+ return pathname === '/api' || pathname.startsWith('/api/');
49
+ }
50
+ function pathnameOf(request) {
51
+ return (request.url ?? '/').split('?')[0];
52
+ }
53
+ function hasBody(request) {
54
+ return Number(request.headers['content-length'] ?? 0) > 0 || request.headers['transfer-encoding'] !== undefined;
55
+ }
56
+ function mediaTypeOf(request) {
57
+ return (request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
58
+ }
59
+ function readBodyText(request) {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks = [];
62
+ let received = 0;
63
+ let oversized = false;
64
+ request.on('data', (chunk) => {
65
+ received += chunk.length;
66
+ if (received > MAX_BODY_BYTES)
67
+ oversized = true;
68
+ if (!oversized)
69
+ chunks.push(chunk);
70
+ });
71
+ request.on('end', () => oversized ? reject(new RequestRejection(413, 'Request body is larger than 64 KB')) : resolve(Buffer.concat(chunks).toString('utf8')));
72
+ request.on('error', reject);
73
+ });
74
+ }
75
+ async function readJsonBody(request) {
76
+ if (!hasBody(request))
77
+ return undefined;
78
+ if (mediaTypeOf(request) !== JSON_MEDIA_TYPE)
79
+ throw new RequestRejection(415, 'Content-Type must be application/json');
80
+ if (Number(request.headers['content-length'] ?? 0) > MAX_BODY_BYTES)
81
+ throw new RequestRejection(413, 'Request body is larger than 64 KB');
82
+ const text = await readBodyText(request);
83
+ if (text === '')
84
+ return undefined;
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ throw new RequestRejection(400, 'Malformed JSON body');
90
+ }
91
+ }
92
+ export async function startServer(options) {
93
+ const { session, staticDir } = options;
94
+ const enrichment = options.enrichment ?? offlineEnrichment();
95
+ const actions = options.actions ?? disabledActions('Actions are not enabled');
96
+ const token = options.token ?? generateToken();
97
+ const logError = createErrorLog(options.errorStream ?? process.stderr);
98
+ let port = 0;
99
+ async function handle(request, response) {
100
+ response.setHeader('X-Frame-Options', 'DENY');
101
+ response.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
102
+ const url = new URL(request.url ?? '/', `http://${LOOPBACK}:${port}`);
103
+ const reject = (status, message) => isApiPath(url.pathname) ? sendJson(response, status, { error: message }) : sendText(response, status, message);
104
+ if (!isAllowedHost(request.headers.host, port))
105
+ return reject(403, 'Forbidden');
106
+ const method = request.method ?? 'GET';
107
+ const urlToken = url.searchParams.get('token');
108
+ if (method === 'GET' && url.pathname === '/' && urlToken !== null) {
109
+ if (!tokensMatch(token, urlToken))
110
+ return reject(401, 'Unauthorized');
111
+ return redirectWithSessionCookie(response, url, token, port);
112
+ }
113
+ if (!tokensMatch(token, requestToken(request, port)))
114
+ return reject(401, 'Unauthorized');
115
+ const safeMethod = SAFE_METHODS.has(method);
116
+ if (!safeMethod && !isAllowedOrigin(request.headers.origin, port))
117
+ return reject(403, 'Forbidden origin');
118
+ if (isApiPath(url.pathname)) {
119
+ let body;
120
+ if (!safeMethod) {
121
+ try {
122
+ body = await readJsonBody(request);
123
+ }
124
+ catch (failure) {
125
+ if (!(failure instanceof RequestRejection))
126
+ throw failure;
127
+ request.resume();
128
+ return reject(failure.status, failure.message);
129
+ }
130
+ }
131
+ const result = await handleApi({ session, enrichment, actions, branches: options.branches }, { method, pathname: url.pathname, searchParams: url.searchParams, body }, logError);
132
+ return sendJson(response, result.status, result.body);
133
+ }
134
+ if (!safeMethod)
135
+ return sendText(response, 405, 'Method not allowed');
136
+ const file = await serveStatic(staticDir, url.pathname);
137
+ response.writeHead(file.status, { 'Content-Type': file.contentType, 'X-Content-Type-Options': 'nosniff' });
138
+ response.end(method === 'HEAD' ? undefined : file.body);
139
+ }
140
+ const server = createServer((request, response) => {
141
+ handle(request, response).catch((failure) => {
142
+ logError(`${request.method ?? 'GET'} ${pathnameOf(request)}`, failure);
143
+ if (!response.headersSent)
144
+ sendText(response, 500, 'Internal error');
145
+ else
146
+ response.end();
147
+ });
148
+ });
149
+ await new Promise((resolve, reject) => {
150
+ server.once('error', reject);
151
+ server.listen(options.port ?? 0, LOOPBACK, () => {
152
+ server.off('error', reject);
153
+ resolve();
154
+ });
155
+ });
156
+ port = server.address().port;
157
+ return {
158
+ url: `http://${LOOPBACK}:${port}/?token=${token}`,
159
+ port,
160
+ token,
161
+ close: () => new Promise((resolve, reject) => {
162
+ server.close((error) => (error ? reject(error) : resolve()));
163
+ server.closeAllConnections();
164
+ }),
165
+ };
166
+ }
@@ -0,0 +1 @@
1
+ export const WORKING_KEY = 'work';