@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.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/actions/action-error.js +11 -0
- package/dist/actions/action-jobs.js +199 -0
- package/dist/actions/action-service.js +268 -0
- package/dist/actions/apply.js +120 -0
- package/dist/actions/availability.js +79 -0
- package/dist/actions/change-sets.js +117 -0
- package/dist/actions/dry-run.js +287 -0
- package/dist/actions/edge-moves.js +108 -0
- package/dist/actions/edit-plan.js +179 -0
- package/dist/actions/file-scope.js +118 -0
- package/dist/actions/fix-options.js +141 -0
- package/dist/actions/goal-check.js +80 -0
- package/dist/actions/install.js +27 -0
- package/dist/actions/job-registry.js +104 -0
- package/dist/actions/json-edit.js +189 -0
- package/dist/actions/labels.js +35 -0
- package/dist/actions/node-actions.js +102 -0
- package/dist/actions/overrides-location.js +59 -0
- package/dist/actions/parent-chain.js +210 -0
- package/dist/actions/range-style.js +38 -0
- package/dist/actions/recipes.js +31 -0
- package/dist/actions/root-files.js +30 -0
- package/dist/actions/sandbox.js +175 -0
- package/dist/actions/set-range.js +59 -0
- package/dist/actions/shared-packages.js +50 -0
- package/dist/actions/text-diff.js +203 -0
- package/dist/actions/types.js +1 -0
- package/dist/actions/update-options.js +232 -0
- package/dist/actions/workspace-edit.js +33 -0
- package/dist/adapters/command-runner.js +127 -0
- package/dist/adapters/manifest-only.js +20 -0
- package/dist/adapters/node-modules.js +148 -0
- package/dist/adapters/npm-lock.js +149 -0
- package/dist/adapters/package-manifest.js +57 -0
- package/dist/adapters/pnpm-lock.js +177 -0
- package/dist/adapters/resolve-today.js +93 -0
- package/dist/cli/cli.js +227 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/package-version.js +6 -0
- package/dist/cli/usage-text.js +27 -0
- package/dist/config/glob.js +119 -0
- package/dist/config/npmrc.js +116 -0
- package/dist/config/root-config.js +103 -0
- package/dist/config/secrets.js +18 -0
- package/dist/discovery/find-roots.js +107 -0
- package/dist/enrich/credentials.js +143 -0
- package/dist/enrich/enrichment-cache.js +42 -0
- package/dist/enrich/enrichment-keys.js +71 -0
- package/dist/enrich/enrichment-service.js +399 -0
- package/dist/enrich/enrichment-status.js +86 -0
- package/dist/enrich/enrichment-store.js +143 -0
- package/dist/enrich/http.js +201 -0
- package/dist/enrich/on-demand.js +220 -0
- package/dist/enrich/osv-client.js +260 -0
- package/dist/enrich/registry-client.js +123 -0
- package/dist/enrich/types.js +1 -0
- package/dist/git/blob-reader.js +180 -0
- package/dist/git/git-commands.js +32 -0
- package/dist/git/git-process.js +71 -0
- package/dist/git/git-repository.js +136 -0
- package/dist/git/ref-list.js +78 -0
- package/dist/git/tree-index.js +87 -0
- package/dist/graph/global-index.js +19 -0
- package/dist/graph/root-graph-builder.js +114 -0
- package/dist/graph/serialize.js +19 -0
- package/dist/model.js +2 -0
- package/dist/query/children.js +38 -0
- package/dist/query/compare-shape.js +13 -0
- package/dist/query/compare.js +245 -0
- package/dist/query/decorations.js +121 -0
- package/dist/query/dependents.js +135 -0
- package/dist/query/duplicates.js +23 -0
- package/dist/query/enrichment-lookup.js +78 -0
- package/dist/query/fixes.js +60 -0
- package/dist/query/graph-diff.js +42 -0
- package/dist/query/issues.js +235 -0
- package/dist/query/lookup.js +49 -0
- package/dist/query/node-fix.js +46 -0
- package/dist/query/package-insight.js +112 -0
- package/dist/query/search.js +61 -0
- package/dist/query/subgraph.js +107 -0
- package/dist/query/versions.js +103 -0
- package/dist/scan/build-root.js +137 -0
- package/dist/scan/cache-shape.js +52 -0
- package/dist/scan/scan-cache.js +32 -0
- package/dist/scan/scan-session.js +127 -0
- package/dist/scan/source-registry.js +55 -0
- package/dist/server/auth.js +45 -0
- package/dist/server/branches.js +105 -0
- package/dist/server/error-log.js +9 -0
- package/dist/server/root-segment.js +13 -0
- package/dist/server/router.js +474 -0
- package/dist/server/server.js +166 -0
- package/dist/server/source-key.js +1 -0
- package/dist/server/static.js +59 -0
- package/dist/sources/file-reader.js +55 -0
- package/dist/sources/git-reader.js +45 -0
- package/dist/ui/assets/index-C4uyB5v3.css +1 -0
- package/dist/ui/assets/index-DYAG2LM-.js +51 -0
- package/dist/ui/index.html +15 -0
- package/package.json +72 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import { IMPORTER_PREFIX } from "../model.js";
|
|
3
|
+
import { nodeAdvisories, nodeMeta, worstSeverity } from "../query/enrichment-lookup.js";
|
|
4
|
+
import { combineFixes } from "../query/fixes.js";
|
|
5
|
+
import { findNode } from "../query/lookup.js";
|
|
6
|
+
import { edgeFixes, normalizedRequestedRange, safeCandidates } from "../query/node-fix.js";
|
|
7
|
+
import { acceptedClause, importerLabel } from "./labels.js";
|
|
8
|
+
import { parentChain } from "./parent-chain.js";
|
|
9
|
+
const ROOT_IMPORTER_ID = `${IMPORTER_PREFIX}.`;
|
|
10
|
+
const NO_COMMON_SAFE_VERSION = 'the requesting ranges have no safe version in common';
|
|
11
|
+
const NPM_DIRECT_DEPENDENCY = 'npm does not allow an override of a direct dependency of the root package; update it instead';
|
|
12
|
+
const MAJOR_JUMP = 'major jump';
|
|
13
|
+
export const NPM_LOCKFILE_OVERRIDES_VERSION = '11.2.0';
|
|
14
|
+
export function npmIgnoresLockfileOverrides(manager) {
|
|
15
|
+
if (manager.name !== 'npm')
|
|
16
|
+
return false;
|
|
17
|
+
const version = semver.coerce(manager.version);
|
|
18
|
+
return version !== null && semver.lt(version, NPM_LOCKFILE_OVERRIDES_VERSION);
|
|
19
|
+
}
|
|
20
|
+
function lockfileOverridesReason(manager) {
|
|
21
|
+
return `npm ${manager.version} does not apply overrides to an existing lockfile, npm ${NPM_LOCKFILE_OVERRIDES_VERSION} and later do`;
|
|
22
|
+
}
|
|
23
|
+
function knownRanges(ranges) {
|
|
24
|
+
const known = ranges.filter((range) => range !== undefined);
|
|
25
|
+
return known.length === ranges.length ? known : undefined;
|
|
26
|
+
}
|
|
27
|
+
export function relockTarget(ranges, candidates) {
|
|
28
|
+
const known = knownRanges(ranges);
|
|
29
|
+
if (known === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
return candidates
|
|
32
|
+
.filter((candidate) => semver.valid(candidate) !== null && known.every((range) => semver.satisfies(candidate, range)))
|
|
33
|
+
.sort(semver.compare)
|
|
34
|
+
.at(-1);
|
|
35
|
+
}
|
|
36
|
+
function fixGoal(node, advisories) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'fix',
|
|
39
|
+
name: node.name,
|
|
40
|
+
version: node.version,
|
|
41
|
+
advisoryIds: advisories.map((advisory) => advisory.id),
|
|
42
|
+
severity: worstSeverity(advisories.map((advisory) => advisory.severity)) ?? 'unknown',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function parentLabel(root, edge) {
|
|
46
|
+
if (edge.parentIsImporter)
|
|
47
|
+
return importerLabel(edge.parent);
|
|
48
|
+
return findNode(root, edge.parent)?.name ?? edge.parent;
|
|
49
|
+
}
|
|
50
|
+
function requestingRanges(context, node, edges) {
|
|
51
|
+
const meta = nodeMeta(context.view, context.root, node);
|
|
52
|
+
return edges.map((edge) => ({
|
|
53
|
+
label: parentLabel(context.root, edge),
|
|
54
|
+
declared: edge.range.range,
|
|
55
|
+
range: normalizedRequestedRange(context.root, node, meta, edge.range),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
function relockOption(node, target, requesting) {
|
|
59
|
+
const labels = [...new Set(requesting.map((entry) => entry.label))];
|
|
60
|
+
const step = { kind: 'relock', name: node.name, from: node.version, to: target, reason: acceptedClause(labels) };
|
|
61
|
+
return { choice: 'relock', steps: [step], warnings: [] };
|
|
62
|
+
}
|
|
63
|
+
async function chainOption(context, node, safe) {
|
|
64
|
+
const chain = await parentChain({ view: context.view, root: context.root, node, safe, kinds: context.kinds, loadVersions: context.loadVersions });
|
|
65
|
+
return 'steps' in chain ? { choice: 'chain', steps: chain.steps, warnings: [] } : { choice: 'chain', reason: chain.unavailable };
|
|
66
|
+
}
|
|
67
|
+
function overrideTarget(current, safe) {
|
|
68
|
+
if (semver.valid(current) === null)
|
|
69
|
+
return undefined;
|
|
70
|
+
const above = safe.filter((version) => semver.valid(version) !== null && semver.gt(version, current)).sort(semver.compare);
|
|
71
|
+
const highestSameMajor = above.filter((version) => semver.major(version) === semver.major(current)).at(-1);
|
|
72
|
+
if (highestSameMajor !== undefined)
|
|
73
|
+
return { version: highestSameMajor, majorJump: false };
|
|
74
|
+
const lowest = above[0];
|
|
75
|
+
return lowest === undefined ? undefined : { version: lowest, majorJump: true };
|
|
76
|
+
}
|
|
77
|
+
function rootDeclares(root, name) {
|
|
78
|
+
const rootImporter = root.importers.find((importer) => importer.id === ROOT_IMPORTER_ID);
|
|
79
|
+
return rootImporter?.deps.some((edge) => findNode(root, edge.to)?.name === name) ?? false;
|
|
80
|
+
}
|
|
81
|
+
function overrideReason(target, requesting) {
|
|
82
|
+
const excluding = requesting.find((entry) => entry.range !== undefined && !semver.satisfies(target, entry.range));
|
|
83
|
+
return excluding === undefined ? `forces ${target}` : `forces ${target} although ${excluding.label} declares ${excluding.declared}`;
|
|
84
|
+
}
|
|
85
|
+
function overrideOption(context, node, safe, requesting) {
|
|
86
|
+
const target = overrideTarget(node.version, safe);
|
|
87
|
+
if (target === undefined)
|
|
88
|
+
return { choice: 'override', reason: `no safe version of ${node.name} above ${node.version}` };
|
|
89
|
+
if (context.manager.name === 'npm' && rootDeclares(context.root, node.name))
|
|
90
|
+
return { choice: 'override', reason: NPM_DIRECT_DEPENDENCY };
|
|
91
|
+
if (npmIgnoresLockfileOverrides(context.manager))
|
|
92
|
+
return { choice: 'override', reason: lockfileOverridesReason(context.manager) };
|
|
93
|
+
const step = {
|
|
94
|
+
kind: 'override',
|
|
95
|
+
selector: `${node.name}@<${target.version}`,
|
|
96
|
+
value: target.version,
|
|
97
|
+
file: context.overrides.file,
|
|
98
|
+
reason: overrideReason(target.version, requesting),
|
|
99
|
+
};
|
|
100
|
+
const locationWarnings = context.overrides.warning === undefined ? [] : [context.overrides.warning];
|
|
101
|
+
return { choice: 'override', steps: [step], warnings: [...(target.majorJump ? [MAJOR_JUMP] : []), ...locationWarnings] };
|
|
102
|
+
}
|
|
103
|
+
async function optionOutcomes(context, node, kind, edges) {
|
|
104
|
+
const safe = safeCandidates(context.view, context.root, node, nodeMeta(context.view, context.root, node)) ?? [];
|
|
105
|
+
const requesting = requestingRanges(context, node, edges);
|
|
106
|
+
if (kind === 'relock') {
|
|
107
|
+
const target = relockTarget(requesting.map((entry) => entry.range), safe);
|
|
108
|
+
const needsLockfileOverride = edges.some((edge) => !edge.parentIsImporter);
|
|
109
|
+
if (target !== undefined && needsLockfileOverride && npmIgnoresLockfileOverrides(context.manager)) {
|
|
110
|
+
return [{ choice: 'relock', reason: lockfileOverridesReason(context.manager) }, overrideOption(context, node, safe, requesting)];
|
|
111
|
+
}
|
|
112
|
+
if (target !== undefined)
|
|
113
|
+
return [relockOption(node, target, requesting)];
|
|
114
|
+
return [{ choice: 'relock', reason: NO_COMMON_SAFE_VERSION }, overrideOption(context, node, safe, requesting)];
|
|
115
|
+
}
|
|
116
|
+
return [await chainOption(context, node, safe), overrideOption(context, node, safe, requesting)];
|
|
117
|
+
}
|
|
118
|
+
function isUnavailable(outcome) {
|
|
119
|
+
return 'reason' in outcome;
|
|
120
|
+
}
|
|
121
|
+
function collectOptions(goal, outcomes) {
|
|
122
|
+
const offered = outcomes.filter((outcome) => !isUnavailable(outcome));
|
|
123
|
+
return {
|
|
124
|
+
goal,
|
|
125
|
+
options: offered.map((option, index) => ({ choice: option.choice, recommended: index === 0, steps: option.steps, warnings: option.warnings })),
|
|
126
|
+
unavailable: outcomes.filter(isUnavailable),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function fixActions(context, node) {
|
|
130
|
+
const advisories = nodeAdvisories(context.view, context.root, node);
|
|
131
|
+
if (advisories.length === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
const goal = fixGoal(node, advisories);
|
|
134
|
+
const edges = edgeFixes(context.view, context.root, node, context.kinds);
|
|
135
|
+
const { kind } = combineFixes(edges);
|
|
136
|
+
if (kind === 'no-fix')
|
|
137
|
+
return { goal, options: [], unavailable: [], reason: `No published version of ${node.name} is free of these advisories` };
|
|
138
|
+
if (kind === 'unknown')
|
|
139
|
+
return { goal, options: [], unavailable: [], reason: `Registry or advisory data for ${node.name} is incomplete` };
|
|
140
|
+
return collectOptions(goal, await optionOutcomes(context, node, kind, edges));
|
|
141
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import { IMPORTER_PREFIX, PLACEHOLDER_PREFIX } from "../model.js";
|
|
3
|
+
import { compareVersions, packageVersions } from "../query/graph-diff.js";
|
|
4
|
+
import { isAffected } from "../query/versions.js";
|
|
5
|
+
import { importerLabel } from "./labels.js";
|
|
6
|
+
function parentText(graph, id) {
|
|
7
|
+
if (id.startsWith(IMPORTER_PREFIX))
|
|
8
|
+
return importerLabel(id);
|
|
9
|
+
const parent = graph.packages.get(id);
|
|
10
|
+
return parent === undefined ? id : `${parent.name} ${parent.version}`;
|
|
11
|
+
}
|
|
12
|
+
function remainingPathText(graph, nodes, version) {
|
|
13
|
+
const parent = nodes.find((node) => node.dependents.length > 0)?.dependents[0];
|
|
14
|
+
return parent === undefined ? `${version} is still in the lockfile` : `${version} is still required by ${parentText(graph, parent)}`;
|
|
15
|
+
}
|
|
16
|
+
function loadedAdvisories(view, name) {
|
|
17
|
+
const loaded = view.advisoriesOf(name);
|
|
18
|
+
return loaded.state === 'ok' || loaded.state === 'stale' ? loaded.advisories : [];
|
|
19
|
+
}
|
|
20
|
+
function packagesNamed(graph, name) {
|
|
21
|
+
return [...graph.packages.values()].filter((node) => node.name === name && !node.id.startsWith(PLACEHOLDER_PREFIX));
|
|
22
|
+
}
|
|
23
|
+
function checkFix(goal, before, after, view) {
|
|
24
|
+
const remaining = packagesNamed(after, goal.name);
|
|
25
|
+
const targeted = remaining.filter((node) => node.version === goal.version);
|
|
26
|
+
if (targeted.length > 0)
|
|
27
|
+
return { met: false, detail: remainingPathText(after, targeted, goal.version) };
|
|
28
|
+
const versions = [...new Set(remaining.map((node) => node.version))].sort(compareVersions);
|
|
29
|
+
const previous = packageVersions(before).get(goal.name);
|
|
30
|
+
const advisories = loadedAdvisories(view, goal.name);
|
|
31
|
+
for (const version of versions.filter((candidate) => !previous?.has(candidate))) {
|
|
32
|
+
const advisory = advisories.find((candidate) => isAffected(version, candidate));
|
|
33
|
+
if (advisory !== undefined)
|
|
34
|
+
return { met: false, detail: `${version} is affected by ${advisory.id}` };
|
|
35
|
+
}
|
|
36
|
+
if (versions.length === 0)
|
|
37
|
+
return { met: true, detail: `${goal.name} is no longer in the lockfile` };
|
|
38
|
+
return { met: true, detail: `no affected version left (${versions.join(', ')})` };
|
|
39
|
+
}
|
|
40
|
+
function joinLabels(labels) {
|
|
41
|
+
if (labels.length <= 1)
|
|
42
|
+
return labels.join('');
|
|
43
|
+
if (labels.length === 2)
|
|
44
|
+
return `${labels[0]} and ${labels[1]}`;
|
|
45
|
+
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
|
|
46
|
+
}
|
|
47
|
+
function reachesTarget(version, target) {
|
|
48
|
+
return semver.valid(version) !== null && semver.valid(target) !== null && semver.gte(version, target);
|
|
49
|
+
}
|
|
50
|
+
function outsideWrittenRange(version, range) {
|
|
51
|
+
return semver.validRange(range) !== null && semver.valid(version) !== null && !semver.satisfies(version, range);
|
|
52
|
+
}
|
|
53
|
+
function checkUpdate(goal, after) {
|
|
54
|
+
const resolved = new Set();
|
|
55
|
+
for (const importerId of goal.importers) {
|
|
56
|
+
const label = importerLabel(importerId);
|
|
57
|
+
const importer = after.importers.find((candidate) => candidate.id === importerId);
|
|
58
|
+
const resolutions = (importer?.deps ?? []).flatMap((edge) => {
|
|
59
|
+
const node = edge.kind === 'peer' ? undefined : after.packages.get(edge.to);
|
|
60
|
+
return node?.name === goal.name ? [{ range: edge.range, version: node.version }] : [];
|
|
61
|
+
});
|
|
62
|
+
if (resolutions.length === 0)
|
|
63
|
+
return { met: false, detail: `${label} no longer depends on ${goal.name}` };
|
|
64
|
+
for (const { range, version } of resolutions) {
|
|
65
|
+
if (!reachesTarget(version, goal.target))
|
|
66
|
+
return { met: false, detail: `${label} resolves ${goal.name} ${version}, below ${goal.target}` };
|
|
67
|
+
if (outsideWrittenRange(version, range))
|
|
68
|
+
return { met: false, detail: `${label} resolves ${goal.name} ${version}, outside ${range}` };
|
|
69
|
+
resolved.add(version);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const versions = [...resolved].sort(compareVersions).join(', ');
|
|
73
|
+
return { met: true, detail: `${goal.name} ${versions} in ${joinLabels(goal.importers.map(importerLabel))}` };
|
|
74
|
+
}
|
|
75
|
+
export function checkGoals(items, before, after, view) {
|
|
76
|
+
return items.map((item) => ({
|
|
77
|
+
itemId: item.id,
|
|
78
|
+
...(item.goal.kind === 'fix' ? checkFix(item.goal, before, after, view) : checkUpdate(item.goal, after)),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FROZEN_INSTALL_ARGS, INSTALL_TIMEOUT_MS, commandText } from "./recipes.js";
|
|
2
|
+
export async function runFrozenInstall(input) {
|
|
3
|
+
const args = FROZEN_INSTALL_ARGS[input.manager];
|
|
4
|
+
const text = commandText(input.manager, args);
|
|
5
|
+
try {
|
|
6
|
+
const result = await input.runner.run({
|
|
7
|
+
command: input.manager,
|
|
8
|
+
args,
|
|
9
|
+
cwd: input.rootDir,
|
|
10
|
+
env: input.env,
|
|
11
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
12
|
+
signal: input.signal,
|
|
13
|
+
onLine: (line) => input.onLine(input.secrets.redact(line)),
|
|
14
|
+
});
|
|
15
|
+
if (result.cancelled)
|
|
16
|
+
return { ok: false, reason: 'canceled', cancelled: true };
|
|
17
|
+
if (result.timedOut)
|
|
18
|
+
return { ok: false, reason: `${text} did not finish within ${INSTALL_TIMEOUT_MS / 60_000} minutes`, cancelled: false };
|
|
19
|
+
if (result.code !== 0)
|
|
20
|
+
return { ok: false, reason: `${text} exited with code ${result.code}`, cancelled: false };
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
23
|
+
catch (failure) {
|
|
24
|
+
const reason = failure.code === 'ENOENT' ? `${input.manager} was not found` : `${input.manager} could not be started`;
|
|
25
|
+
return { ok: false, reason, cancelled: false };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { processSecrets } from "../config/secrets.js";
|
|
3
|
+
import { messageOf } from "../scan/build-root.js";
|
|
4
|
+
export const JOB_LOG_LINES = 2000;
|
|
5
|
+
function snapshot(record, since) {
|
|
6
|
+
const firstLine = Math.max(since, record.droppedLines);
|
|
7
|
+
return {
|
|
8
|
+
id: record.id,
|
|
9
|
+
rootId: record.rootId,
|
|
10
|
+
kind: record.kind,
|
|
11
|
+
state: record.state,
|
|
12
|
+
...(record.phase === undefined ? {} : { phase: record.phase }),
|
|
13
|
+
log: record.lines.slice(firstLine - record.droppedLines),
|
|
14
|
+
firstLine,
|
|
15
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
16
|
+
...(record.result === undefined ? {} : { result: record.result }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function startRun(run, context) {
|
|
20
|
+
try {
|
|
21
|
+
return run(context);
|
|
22
|
+
}
|
|
23
|
+
catch (failure) {
|
|
24
|
+
return Promise.reject(failure);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function createJobRegistry(secrets = processSecrets) {
|
|
28
|
+
const records = new Map();
|
|
29
|
+
function appendLine(record, line) {
|
|
30
|
+
if (record.state !== 'running')
|
|
31
|
+
return;
|
|
32
|
+
record.lines.push(secrets.redact(line));
|
|
33
|
+
if (record.lines.length <= JOB_LOG_LINES)
|
|
34
|
+
return;
|
|
35
|
+
record.lines.shift();
|
|
36
|
+
record.droppedLines++;
|
|
37
|
+
}
|
|
38
|
+
function settle(record, outcome) {
|
|
39
|
+
if (record.controller.signal.aborted) {
|
|
40
|
+
record.state = 'cancelled';
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if ('failure' in outcome) {
|
|
44
|
+
record.state = 'failed';
|
|
45
|
+
record.error = secrets.redact(messageOf(outcome.failure));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
record.state = 'done';
|
|
49
|
+
if (outcome.result !== undefined)
|
|
50
|
+
record.result = outcome.result;
|
|
51
|
+
}
|
|
52
|
+
function dropFinished(rootId) {
|
|
53
|
+
for (const [id, record] of records) {
|
|
54
|
+
if (record.rootId === rootId && record.state !== 'running')
|
|
55
|
+
records.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function newest(rootId, accepts) {
|
|
59
|
+
let found;
|
|
60
|
+
for (const record of records.values()) {
|
|
61
|
+
if (record.rootId === rootId && accepts(record))
|
|
62
|
+
found = record;
|
|
63
|
+
}
|
|
64
|
+
return found === undefined ? undefined : snapshot(found, 0);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
start(rootId, kind, run) {
|
|
68
|
+
dropFinished(rootId);
|
|
69
|
+
const record = { id: randomUUID(), rootId, kind, state: 'running', lines: [], droppedLines: 0, controller: new AbortController() };
|
|
70
|
+
records.set(record.id, record);
|
|
71
|
+
const context = {
|
|
72
|
+
signal: record.controller.signal,
|
|
73
|
+
log: (line) => appendLine(record, line),
|
|
74
|
+
phase: (phase) => {
|
|
75
|
+
if (record.state === 'running')
|
|
76
|
+
record.phase = phase;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
startRun(run, context).then((result) => settle(record, { result }), (failure) => settle(record, { failure }));
|
|
80
|
+
return snapshot(record, 0);
|
|
81
|
+
},
|
|
82
|
+
get(id, since) {
|
|
83
|
+
const record = records.get(id);
|
|
84
|
+
return record === undefined ? undefined : snapshot(record, since);
|
|
85
|
+
},
|
|
86
|
+
running(rootId) {
|
|
87
|
+
return newest(rootId, (record) => record.state === 'running');
|
|
88
|
+
},
|
|
89
|
+
latest(rootId) {
|
|
90
|
+
return newest(rootId, () => true);
|
|
91
|
+
},
|
|
92
|
+
cancel(id) {
|
|
93
|
+
const record = records.get(id);
|
|
94
|
+
if (record?.state === 'running')
|
|
95
|
+
record.controller.abort();
|
|
96
|
+
},
|
|
97
|
+
cancelAll() {
|
|
98
|
+
for (const record of records.values()) {
|
|
99
|
+
if (record.state === 'running')
|
|
100
|
+
record.controller.abort();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export class JsonEditError extends Error {
|
|
2
|
+
}
|
|
3
|
+
const DEFAULT_INDENT_UNIT = ' ';
|
|
4
|
+
const WHITESPACE = new Set([' ', '\t', '\n', '\r']);
|
|
5
|
+
const VALUE_TERMINATORS = new Set([',', '}', ']', ...WHITESPACE]);
|
|
6
|
+
class JsonScanner {
|
|
7
|
+
text;
|
|
8
|
+
position = 0;
|
|
9
|
+
constructor(text) {
|
|
10
|
+
this.text = text;
|
|
11
|
+
}
|
|
12
|
+
parseDocument() {
|
|
13
|
+
const root = this.parseValue();
|
|
14
|
+
this.skipWhitespace();
|
|
15
|
+
if (this.position < this.text.length)
|
|
16
|
+
this.fail('unexpected content after the document');
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
fail(reason) {
|
|
20
|
+
throw new JsonEditError(`Malformed JSON at offset ${this.position}: ${reason}`);
|
|
21
|
+
}
|
|
22
|
+
skipWhitespace() {
|
|
23
|
+
while (this.position < this.text.length && WHITESPACE.has(this.text[this.position]))
|
|
24
|
+
this.position++;
|
|
25
|
+
}
|
|
26
|
+
parseValue() {
|
|
27
|
+
this.skipWhitespace();
|
|
28
|
+
const character = this.text[this.position];
|
|
29
|
+
if (character === undefined)
|
|
30
|
+
return this.fail('unexpected end of input');
|
|
31
|
+
if (character === '{')
|
|
32
|
+
return this.parseObject();
|
|
33
|
+
if (character === '[')
|
|
34
|
+
return this.parseArray();
|
|
35
|
+
if (character === '"') {
|
|
36
|
+
const start = this.position;
|
|
37
|
+
this.readString();
|
|
38
|
+
return { kind: 'string', start, end: this.position, members: [] };
|
|
39
|
+
}
|
|
40
|
+
return this.parseLiteral();
|
|
41
|
+
}
|
|
42
|
+
readString() {
|
|
43
|
+
const start = this.position;
|
|
44
|
+
this.position++;
|
|
45
|
+
while (this.position < this.text.length) {
|
|
46
|
+
const character = this.text[this.position];
|
|
47
|
+
if (character === '\\')
|
|
48
|
+
this.position += 2;
|
|
49
|
+
else if (character === '"') {
|
|
50
|
+
this.position++;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(this.text.slice(start, this.position));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return this.fail('invalid string');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
this.position++;
|
|
60
|
+
}
|
|
61
|
+
return this.fail('unterminated string');
|
|
62
|
+
}
|
|
63
|
+
parseLiteral() {
|
|
64
|
+
const start = this.position;
|
|
65
|
+
while (this.position < this.text.length && !VALUE_TERMINATORS.has(this.text[this.position]))
|
|
66
|
+
this.position++;
|
|
67
|
+
try {
|
|
68
|
+
JSON.parse(this.text.slice(start, this.position));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
this.position = start;
|
|
72
|
+
this.fail('invalid value');
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'other', start, end: this.position, members: [] };
|
|
75
|
+
}
|
|
76
|
+
parseArray() {
|
|
77
|
+
const start = this.position;
|
|
78
|
+
this.position++;
|
|
79
|
+
this.skipWhitespace();
|
|
80
|
+
if (this.text[this.position] === ']') {
|
|
81
|
+
this.position++;
|
|
82
|
+
return { kind: 'other', start, end: this.position, members: [] };
|
|
83
|
+
}
|
|
84
|
+
for (;;) {
|
|
85
|
+
this.parseValue();
|
|
86
|
+
this.skipWhitespace();
|
|
87
|
+
const separator = this.text[this.position++];
|
|
88
|
+
if (separator === ']')
|
|
89
|
+
return { kind: 'other', start, end: this.position, members: [] };
|
|
90
|
+
if (separator !== ',') {
|
|
91
|
+
this.position--;
|
|
92
|
+
this.fail('expected , or ]');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
parseObject() {
|
|
97
|
+
const start = this.position;
|
|
98
|
+
const members = [];
|
|
99
|
+
this.position++;
|
|
100
|
+
this.skipWhitespace();
|
|
101
|
+
if (this.text[this.position] === '}') {
|
|
102
|
+
this.position++;
|
|
103
|
+
return { kind: 'object', start, end: this.position, members };
|
|
104
|
+
}
|
|
105
|
+
for (;;) {
|
|
106
|
+
this.skipWhitespace();
|
|
107
|
+
if (this.text[this.position] !== '"')
|
|
108
|
+
this.fail('expected a string key');
|
|
109
|
+
const memberStart = this.position;
|
|
110
|
+
const key = this.readString();
|
|
111
|
+
this.skipWhitespace();
|
|
112
|
+
if (this.text[this.position] !== ':')
|
|
113
|
+
this.fail('expected :');
|
|
114
|
+
this.position++;
|
|
115
|
+
members.push({ key, start: memberStart, value: this.parseValue() });
|
|
116
|
+
this.skipWhitespace();
|
|
117
|
+
const separator = this.text[this.position++];
|
|
118
|
+
if (separator === '}')
|
|
119
|
+
return { kind: 'object', start, end: this.position, members };
|
|
120
|
+
if (separator !== ',') {
|
|
121
|
+
this.position--;
|
|
122
|
+
this.fail('expected , or }');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function lineStartOf(text, offset) {
|
|
128
|
+
return text.lastIndexOf('\n', offset - 1) + 1;
|
|
129
|
+
}
|
|
130
|
+
function leadingWhitespace(text, lineStart) {
|
|
131
|
+
let end = lineStart;
|
|
132
|
+
while (text[end] === ' ' || text[end] === '\t')
|
|
133
|
+
end++;
|
|
134
|
+
return text.slice(lineStart, end);
|
|
135
|
+
}
|
|
136
|
+
function detectIndentUnit(text) {
|
|
137
|
+
const match = /^([ \t]+)[^ \t\r\n]/m.exec(text);
|
|
138
|
+
return match ? match[1] : DEFAULT_INDENT_UNIT;
|
|
139
|
+
}
|
|
140
|
+
function renderMember(keys, value, indent, unit, lineBreak) {
|
|
141
|
+
const [key, ...rest] = keys;
|
|
142
|
+
if (rest.length === 0)
|
|
143
|
+
return `${JSON.stringify(key)}: ${JSON.stringify(value)}`;
|
|
144
|
+
const innerIndent = indent + unit;
|
|
145
|
+
const inner = renderMember(rest, value, innerIndent, unit, lineBreak);
|
|
146
|
+
return `${JSON.stringify(key)}: {${lineBreak}${innerIndent}${inner}${lineBreak}${indent}}`;
|
|
147
|
+
}
|
|
148
|
+
function insertMember(text, object, keys, value) {
|
|
149
|
+
const lineBreak = text.includes('\r\n') ? '\r\n' : '\n';
|
|
150
|
+
const unit = detectIndentUnit(text);
|
|
151
|
+
const last = object.members[object.members.length - 1];
|
|
152
|
+
if (!last) {
|
|
153
|
+
const objectIndent = leadingWhitespace(text, lineStartOf(text, object.start));
|
|
154
|
+
const memberIndent = objectIndent + unit;
|
|
155
|
+
const member = renderMember(keys, value, memberIndent, unit, lineBreak);
|
|
156
|
+
return (text.slice(0, object.start + 1) +
|
|
157
|
+
lineBreak + memberIndent + member + lineBreak + objectIndent +
|
|
158
|
+
text.slice(object.end - 1));
|
|
159
|
+
}
|
|
160
|
+
const lastLineStart = lineStartOf(text, last.start);
|
|
161
|
+
const sharesLineWithBrace = lastLineStart <= object.start;
|
|
162
|
+
const memberIndent = leadingWhitespace(text, lastLineStart);
|
|
163
|
+
const separator = sharesLineWithBrace ? ', ' : `,${lineBreak}${memberIndent}`;
|
|
164
|
+
const member = renderMember(keys, value, memberIndent, unit, lineBreak);
|
|
165
|
+
return text.slice(0, last.value.end) + separator + member + text.slice(last.value.end);
|
|
166
|
+
}
|
|
167
|
+
export function setJsonString(text, path, value) {
|
|
168
|
+
if (path.length === 0)
|
|
169
|
+
throw new JsonEditError('The path is empty');
|
|
170
|
+
let current = new JsonScanner(text).parseDocument();
|
|
171
|
+
if (current.kind !== 'object')
|
|
172
|
+
throw new JsonEditError('The document is not an object');
|
|
173
|
+
for (let index = 0; index < path.length; index++) {
|
|
174
|
+
const key = path[index];
|
|
175
|
+
const member = current.members.filter((candidate) => candidate.key === key).at(-1);
|
|
176
|
+
if (!member)
|
|
177
|
+
return insertMember(text, current, path.slice(index), value);
|
|
178
|
+
const isLast = index === path.length - 1;
|
|
179
|
+
if (isLast) {
|
|
180
|
+
if (member.value.kind !== 'string')
|
|
181
|
+
throw new JsonEditError(`${path.join('.')} is not a string`);
|
|
182
|
+
return text.slice(0, member.value.start) + JSON.stringify(value) + text.slice(member.value.end);
|
|
183
|
+
}
|
|
184
|
+
if (member.value.kind !== 'object')
|
|
185
|
+
throw new JsonEditError(`${path.slice(0, index + 1).join('.')} is not an object`);
|
|
186
|
+
current = member.value;
|
|
187
|
+
}
|
|
188
|
+
throw new JsonEditError('The path was not resolved');
|
|
189
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const IMPORTER_ID_PREFIX = 'importer:';
|
|
2
|
+
export const REPOSITORY_ROOT_ID = '.';
|
|
3
|
+
export const INSTALL_FAILURE_PREFIX = 'install failed:';
|
|
4
|
+
const ROOT_IMPORTER_ID = `${IMPORTER_ID_PREFIX}${REPOSITORY_ROOT_ID}`;
|
|
5
|
+
const REPOSITORY_ROOT_LABEL = 'repository root';
|
|
6
|
+
const LISTED_LABEL_LIMIT = 3;
|
|
7
|
+
const FIELD_BY_KIND = {
|
|
8
|
+
prod: 'dependencies',
|
|
9
|
+
dev: 'devDependencies',
|
|
10
|
+
optional: 'optionalDependencies',
|
|
11
|
+
peer: undefined,
|
|
12
|
+
};
|
|
13
|
+
export function rootLabel(rootId) {
|
|
14
|
+
return rootId === REPOSITORY_ROOT_ID ? REPOSITORY_ROOT_LABEL : rootId;
|
|
15
|
+
}
|
|
16
|
+
export function importerLabel(id) {
|
|
17
|
+
if (id === ROOT_IMPORTER_ID)
|
|
18
|
+
return 'the root package';
|
|
19
|
+
return id.startsWith(IMPORTER_ID_PREFIX) ? id.slice(IMPORTER_ID_PREFIX.length) : id;
|
|
20
|
+
}
|
|
21
|
+
export function goalText(goal) {
|
|
22
|
+
return goal.kind === 'fix' ? `Fix ${goal.name} ${goal.version}` : `Update ${goal.name} to ${goal.target}`;
|
|
23
|
+
}
|
|
24
|
+
export function acceptedClause(labels) {
|
|
25
|
+
if (labels.length === 1)
|
|
26
|
+
return `${labels[0]} accepts it`;
|
|
27
|
+
if (labels.length === 2)
|
|
28
|
+
return `${labels[0]} and ${labels[1]} accept it`;
|
|
29
|
+
if (labels.length <= LISTED_LABEL_LIMIT)
|
|
30
|
+
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]} accept it`;
|
|
31
|
+
return `${labels.slice(0, LISTED_LABEL_LIMIT).join(', ')}, and ${labels.length - LISTED_LABEL_LIMIT} more accept it`;
|
|
32
|
+
}
|
|
33
|
+
export function fieldOf(kind) {
|
|
34
|
+
return FIELD_BY_KIND[kind];
|
|
35
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { IMPORTER_PREFIX, PLACEHOLDER_PREFIX } from "../model.js";
|
|
4
|
+
import { ALL_KINDS, findNode } from "../query/lookup.js";
|
|
5
|
+
import { ActionError } from "./action-error.js";
|
|
6
|
+
import { ROOT_MANIFEST } from "./edit-plan.js";
|
|
7
|
+
import { fixActions } from "./fix-options.js";
|
|
8
|
+
import { overridesLocationOf, readTextOrNull, rootDirOf } from "./root-files.js";
|
|
9
|
+
import { updateActions, updateSteps } from "./update-options.js";
|
|
10
|
+
const NOT_FROM_REGISTRY = 'This package does not come from a registry';
|
|
11
|
+
const INVALID_KINDS = 'Invalid kinds';
|
|
12
|
+
const FIX_CHOICES = ['relock', 'chain', 'override'];
|
|
13
|
+
const UPDATE_CHOICES = ['wanted', 'latest', 'version'];
|
|
14
|
+
function isImporter(node) {
|
|
15
|
+
return node.id.startsWith(IMPORTER_PREFIX);
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function isFixChoice(choice) {
|
|
21
|
+
return FIX_CHOICES.includes(choice);
|
|
22
|
+
}
|
|
23
|
+
function isUpdateChoice(choice) {
|
|
24
|
+
return UPDATE_CHOICES.includes(choice);
|
|
25
|
+
}
|
|
26
|
+
export function nodeLabel(node) {
|
|
27
|
+
return isImporter(node) ? node.name : `${node.name} ${node.version}`;
|
|
28
|
+
}
|
|
29
|
+
export function kindFilterOf(kinds) {
|
|
30
|
+
if (kinds === undefined)
|
|
31
|
+
return ALL_KINDS;
|
|
32
|
+
if (!Array.isArray(kinds) || kinds.length === 0 || !kinds.every((kind) => ALL_KINDS.has(kind)))
|
|
33
|
+
throw new ActionError(400, INVALID_KINDS);
|
|
34
|
+
return new Set(kinds);
|
|
35
|
+
}
|
|
36
|
+
function parsedManifest(text) {
|
|
37
|
+
if (text === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(text);
|
|
41
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function manifestsDeclaring(repoPath, root, name) {
|
|
48
|
+
const importers = root.importers.filter((importer) => importer.deps.some((edge) => findNode(root, edge.to)?.name === name));
|
|
49
|
+
const manifests = new Map();
|
|
50
|
+
await Promise.all(importers.map(async (importer) => {
|
|
51
|
+
const manifest = parsedManifest(await readTextOrNull(path.join(repoPath, importer.path, ROOT_MANIFEST)));
|
|
52
|
+
if (manifest !== undefined)
|
|
53
|
+
manifests.set(importer.id, manifest);
|
|
54
|
+
}));
|
|
55
|
+
return manifests;
|
|
56
|
+
}
|
|
57
|
+
export async function actionsOfNode(context, node) {
|
|
58
|
+
if (isImporter(node))
|
|
59
|
+
return {};
|
|
60
|
+
if (node.id.startsWith(PLACEHOLDER_PREFIX) || node.nonRegistry === true)
|
|
61
|
+
return { unavailable: NOT_FROM_REGISTRY };
|
|
62
|
+
const { repoPath, enrichment, root, manager, kinds } = context;
|
|
63
|
+
const [overrides, manifests] = await Promise.all([overridesLocationOf(rootDirOf(repoPath, root), manager), manifestsDeclaring(repoPath, root, node.name)]);
|
|
64
|
+
const options = { view: enrichment, root, kinds, manager, overrides, loadVersions: (name) => enrichment.publishedVersions(root, name) };
|
|
65
|
+
const fix = await fixActions(options, node);
|
|
66
|
+
const update = updateActions(options, node, manifests);
|
|
67
|
+
return { ...(fix === undefined ? {} : { fix }), ...(update === undefined ? {} : { update }) };
|
|
68
|
+
}
|
|
69
|
+
function newItem(goal, choice, steps, warnings) {
|
|
70
|
+
return { id: randomUUID(), goal, choice, steps, warnings };
|
|
71
|
+
}
|
|
72
|
+
function notOffered(choice, node) {
|
|
73
|
+
return new ActionError(400, `${choice} is not offered for ${nodeLabel(node)}`);
|
|
74
|
+
}
|
|
75
|
+
function editableImporters(update) {
|
|
76
|
+
return update.importers.filter((importer) => importer.editable).map((importer) => importer.id);
|
|
77
|
+
}
|
|
78
|
+
function fixItem(goal, option) {
|
|
79
|
+
return newItem(goal, option.choice, option.steps, option.warnings);
|
|
80
|
+
}
|
|
81
|
+
export function requestedItem(root, node, actions, request) {
|
|
82
|
+
const { choice } = request;
|
|
83
|
+
const { fix, update } = actions;
|
|
84
|
+
if (choice === 'recommended') {
|
|
85
|
+
const option = fix?.options.find((candidate) => candidate.recommended);
|
|
86
|
+
if (fix === undefined || option === undefined)
|
|
87
|
+
throw new ActionError(400, `No fix is available for ${nodeLabel(node)}`);
|
|
88
|
+
return fixItem(fix.goal, option);
|
|
89
|
+
}
|
|
90
|
+
if (isFixChoice(choice)) {
|
|
91
|
+
const option = fix?.options.find((candidate) => candidate.choice === choice);
|
|
92
|
+
if (fix === undefined || option === undefined)
|
|
93
|
+
throw notOffered(choice, node);
|
|
94
|
+
return fixItem(fix.goal, option);
|
|
95
|
+
}
|
|
96
|
+
if (!isUpdateChoice(choice) || update === undefined)
|
|
97
|
+
throw notOffered(choice, node);
|
|
98
|
+
const plan = updateSteps(update, root, choice, request.version, request.importers ?? editableImporters(update));
|
|
99
|
+
if ('error' in plan)
|
|
100
|
+
throw new ActionError(400, plan.error);
|
|
101
|
+
return newItem(plan.goal, choice, plan.steps, plan.warnings);
|
|
102
|
+
}
|