@ue-shed/project-custodian 0.2.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 +35 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +3 -0
- package/dist/browser.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/node-executor.d.ts +21 -0
- package/dist/node-executor.d.ts.map +1 -0
- package/dist/node-executor.js +360 -0
- package/dist/node-executor.js.map +1 -0
- package/dist/node-scanner.d.ts +7 -0
- package/dist/node-scanner.d.ts.map +1 -0
- package/dist/node-scanner.js +903 -0
- package/dist/node-scanner.js.map +1 -0
- package/dist/policy.d.ts +20 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +205 -0
- package/dist/policy.js.map +1 -0
- package/dist/schema.d.ts +648 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +217 -0
- package/dist/schema.js.map +1 -0
- package/dist/service.d.ts +24 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +151 -0
- package/dist/service.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, lstat, readdir, readFile, realpath, stat, statfs } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { Schema } from "effect";
|
|
5
|
+
import { defaultCustodianPolicy, projectTargetDefinitions, resolvePolicyDocument } from "./policy.js";
|
|
6
|
+
import { CustodianTargetId } from "./schema.js";
|
|
7
|
+
const gibibyte = 1024 ** 3;
|
|
8
|
+
const maximumDiscoveryDepth = 8;
|
|
9
|
+
const scanConcurrency = 4;
|
|
10
|
+
const policyFilename = ".ueclean.json";
|
|
11
|
+
const logStamp = /-backup-(\d{4})\.(\d{2})\.(\d{2})-(\d{2})\.(\d{2})\.(\d{2})\.log$/iu;
|
|
12
|
+
const excludedProjectComponents = new Set(["templates", "samples"]);
|
|
13
|
+
const discoveryPruneDirectories = new Set([
|
|
14
|
+
"Intermediate",
|
|
15
|
+
"Saved",
|
|
16
|
+
"Binaries",
|
|
17
|
+
"DerivedDataCache",
|
|
18
|
+
"Content",
|
|
19
|
+
"Source",
|
|
20
|
+
"Config",
|
|
21
|
+
"node_modules",
|
|
22
|
+
".git",
|
|
23
|
+
".svn",
|
|
24
|
+
"Windows",
|
|
25
|
+
"WinSxS",
|
|
26
|
+
"$Recycle.Bin",
|
|
27
|
+
"System Volume Information",
|
|
28
|
+
"AppData",
|
|
29
|
+
"Library"
|
|
30
|
+
].map((value) => value.toLocaleLowerCase()));
|
|
31
|
+
function targetId(options) {
|
|
32
|
+
const digest = createHash("sha256")
|
|
33
|
+
.update(JSON.stringify([options.kind, resolve(options.root), options.key, options.relativePath]))
|
|
34
|
+
.digest("hex");
|
|
35
|
+
return CustodianTargetId.make(`target-${digest}`);
|
|
36
|
+
}
|
|
37
|
+
const JsonObject = Schema.Record(Schema.String, Schema.Json);
|
|
38
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.Json);
|
|
39
|
+
const NodeError = Schema.Struct({ code: Schema.optional(Schema.String) });
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return Schema.is(JsonObject)(value);
|
|
42
|
+
}
|
|
43
|
+
const engineTargetDefinitions = [
|
|
44
|
+
{
|
|
45
|
+
key: "engine_ddc",
|
|
46
|
+
relativePath: "Engine/DerivedDataCache",
|
|
47
|
+
description: "Engine-wide derived data cache",
|
|
48
|
+
rebuildCost: "Re-derived on the next editor start",
|
|
49
|
+
risk: "low",
|
|
50
|
+
defaultOn: true,
|
|
51
|
+
sourceBuildOnly: false
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: "engine_logs",
|
|
55
|
+
relativePath: "Engine/Saved/Logs",
|
|
56
|
+
description: "Engine logs",
|
|
57
|
+
rebuildCost: "No rebuild cost; historical diagnostics are lost",
|
|
58
|
+
risk: "low",
|
|
59
|
+
defaultOn: true,
|
|
60
|
+
sourceBuildOnly: false
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "engine_crashes",
|
|
64
|
+
relativePath: "Engine/Saved/Crashes",
|
|
65
|
+
description: "Engine crash reports",
|
|
66
|
+
rebuildCost: "No rebuild cost; crash evidence is lost",
|
|
67
|
+
risk: "low",
|
|
68
|
+
defaultOn: true,
|
|
69
|
+
sourceBuildOnly: false
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "engine_intermediate",
|
|
73
|
+
relativePath: "Engine/Intermediate",
|
|
74
|
+
description: "Engine build intermediates",
|
|
75
|
+
rebuildCost: "The next engine compile becomes a full rebuild",
|
|
76
|
+
risk: "high",
|
|
77
|
+
defaultOn: false,
|
|
78
|
+
sourceBuildOnly: true
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "engine_binaries",
|
|
82
|
+
relativePath: "Engine/Binaries",
|
|
83
|
+
description: "Compiled engine binaries",
|
|
84
|
+
rebuildCost: "The editor cannot launch until the engine is fully rebuilt",
|
|
85
|
+
risk: "critical",
|
|
86
|
+
defaultOn: false,
|
|
87
|
+
sourceBuildOnly: true
|
|
88
|
+
}
|
|
89
|
+
];
|
|
90
|
+
const defaultNodeScannerDependencies = {
|
|
91
|
+
deviceOf: async (path) => (await stat(path)).dev,
|
|
92
|
+
freeBytesAt: async (path) => {
|
|
93
|
+
const volume = await statfs(path);
|
|
94
|
+
return Math.max(0, Math.floor(volume.bavail * volume.bsize));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
function throwIfAborted(signal) {
|
|
98
|
+
if (signal.aborted)
|
|
99
|
+
throw signal.reason ?? new Error("Custodian scan was cancelled.");
|
|
100
|
+
}
|
|
101
|
+
async function isDirectory(path) {
|
|
102
|
+
try {
|
|
103
|
+
return (await stat(path)).isDirectory();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function discover(root, rootDevice, signal, dependencies) {
|
|
110
|
+
const descriptors = [];
|
|
111
|
+
const engineManifests = [];
|
|
112
|
+
const diagnostics = [];
|
|
113
|
+
const queue = [
|
|
114
|
+
{ path: root, depth: 0 }
|
|
115
|
+
];
|
|
116
|
+
for (let index = 0; index < queue.length; index++) {
|
|
117
|
+
throwIfAborted(signal);
|
|
118
|
+
const current = queue[index];
|
|
119
|
+
if (current === undefined)
|
|
120
|
+
continue;
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(current.path, { withFileTypes: true });
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
diagnostics.push({
|
|
127
|
+
code: "discovery_incomplete",
|
|
128
|
+
message: `Could not inspect directory: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
129
|
+
path: current.path
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const path = join(current.path, entry.name);
|
|
135
|
+
if (entry.isFile()) {
|
|
136
|
+
if (entry.name.toLocaleLowerCase().endsWith(".uproject"))
|
|
137
|
+
descriptors.push(path);
|
|
138
|
+
if (entry.name === "Build.version" &&
|
|
139
|
+
basename(current.path) === "Build" &&
|
|
140
|
+
basename(dirname(current.path)) === "Engine") {
|
|
141
|
+
engineManifests.push(path);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!entry.isDirectory() || current.depth >= maximumDiscoveryDepth)
|
|
146
|
+
continue;
|
|
147
|
+
if (discoveryPruneDirectories.has(entry.name.toLocaleLowerCase()))
|
|
148
|
+
continue;
|
|
149
|
+
try {
|
|
150
|
+
if ((await dependencies.deviceOf(path)) !== rootDevice) {
|
|
151
|
+
diagnostics.push({
|
|
152
|
+
code: "cross_volume_skipped",
|
|
153
|
+
message: "Directory is on a different filesystem than the explicit scan root.",
|
|
154
|
+
path
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (cause) {
|
|
160
|
+
diagnostics.push({
|
|
161
|
+
code: "discovery_incomplete",
|
|
162
|
+
message: `Could not identify directory filesystem: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
163
|
+
path
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
queue.push({ path, depth: current.depth + 1 });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { descriptors, engineManifests, diagnostics };
|
|
171
|
+
}
|
|
172
|
+
function isWithin(path, root) {
|
|
173
|
+
const fromRoot = relative(root, path);
|
|
174
|
+
return (fromRoot === "" ||
|
|
175
|
+
(!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)));
|
|
176
|
+
}
|
|
177
|
+
async function identifyEngines(manifests, signal) {
|
|
178
|
+
const found = new Map();
|
|
179
|
+
for (const manifest of manifests) {
|
|
180
|
+
throwIfAborted(signal);
|
|
181
|
+
const root = dirname(dirname(dirname(manifest)));
|
|
182
|
+
const hasBuildMachinery = (await isDirectory(join(root, "Engine", "Source"))) ||
|
|
183
|
+
(await isDirectory(join(root, "Engine", "Build", "BatchFiles")));
|
|
184
|
+
if (!hasBuildMachinery)
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const document = decodeJson(JSON.parse(await readFile(manifest, "utf8")));
|
|
188
|
+
if (!isRecord(document))
|
|
189
|
+
continue;
|
|
190
|
+
const version = document;
|
|
191
|
+
const label = ["MajorVersion", "MinorVersion", "PatchVersion"]
|
|
192
|
+
.map((key) => (Schema.is(Schema.Number)(version[key]) ? String(version[key]) : "0"))
|
|
193
|
+
.join(".");
|
|
194
|
+
const build = join(root, "Engine", "Build");
|
|
195
|
+
const installedMarker = await access(join(build, "InstalledBuild.txt")).then(() => true, () => false);
|
|
196
|
+
const sourceMarker = await access(join(build, "SourceDistribution.txt")).then(() => true, () => false);
|
|
197
|
+
const canonical = await realpath(root);
|
|
198
|
+
found.set(canonical.toLocaleLowerCase(), {
|
|
199
|
+
root,
|
|
200
|
+
version: label,
|
|
201
|
+
buildKind: !installedMarker && sourceMarker ? "source" : "installed"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...found.values()].sort((left, right) => left.root.localeCompare(right.root));
|
|
209
|
+
}
|
|
210
|
+
async function newestMtime(root, signal) {
|
|
211
|
+
if (!(await isDirectory(root)))
|
|
212
|
+
return undefined;
|
|
213
|
+
let newest;
|
|
214
|
+
const queue = [root];
|
|
215
|
+
for (let index = 0; index < queue.length; index++) {
|
|
216
|
+
throwIfAborted(signal);
|
|
217
|
+
const directory = queue[index];
|
|
218
|
+
if (directory === undefined)
|
|
219
|
+
continue;
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.name.startsWith(".") || entry.isSymbolicLink())
|
|
229
|
+
continue;
|
|
230
|
+
const path = join(directory, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
queue.push(path);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!entry.isFile())
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
const modified = (await stat(path)).mtimeMs;
|
|
239
|
+
if (newest === undefined || modified > newest)
|
|
240
|
+
newest = modified;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return newest;
|
|
248
|
+
}
|
|
249
|
+
async function fileMtime(path) {
|
|
250
|
+
try {
|
|
251
|
+
const metadata = await stat(path);
|
|
252
|
+
return metadata.isFile() ? metadata.mtimeMs : undefined;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function newestTimestamp(values) {
|
|
259
|
+
return values.reduce((current, value) => value === undefined
|
|
260
|
+
? current
|
|
261
|
+
: current === undefined || value > current
|
|
262
|
+
? value
|
|
263
|
+
: current, undefined);
|
|
264
|
+
}
|
|
265
|
+
async function discoverProjectPlugins(projectRoot, signal) {
|
|
266
|
+
const pluginsRoot = join(projectRoot, "Plugins");
|
|
267
|
+
if (!(await isDirectory(pluginsRoot)))
|
|
268
|
+
return [];
|
|
269
|
+
const plugins = [];
|
|
270
|
+
const queue = [
|
|
271
|
+
{ path: pluginsRoot, depth: 0 }
|
|
272
|
+
];
|
|
273
|
+
for (let index = 0; index < queue.length; index++) {
|
|
274
|
+
throwIfAborted(signal);
|
|
275
|
+
const current = queue[index];
|
|
276
|
+
if (current === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
let entries;
|
|
279
|
+
try {
|
|
280
|
+
entries = await readdir(current.path, { withFileTypes: true });
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const descriptor = entries
|
|
286
|
+
.filter((entry) => entry.isFile() && entry.name.toLocaleLowerCase().endsWith(".uplugin"))
|
|
287
|
+
.map((entry) => join(current.path, entry.name))
|
|
288
|
+
.sort((left, right) => left.localeCompare(right))[0];
|
|
289
|
+
if (descriptor !== undefined) {
|
|
290
|
+
plugins.push({ descriptor, root: current.path });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (current.depth >= maximumDiscoveryDepth)
|
|
294
|
+
continue;
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
297
|
+
continue;
|
|
298
|
+
if (discoveryPruneDirectories.has(entry.name.toLocaleLowerCase()))
|
|
299
|
+
continue;
|
|
300
|
+
queue.push({ path: join(current.path, entry.name), depth: current.depth + 1 });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return plugins.sort((left, right) => left.root.localeCompare(right.root));
|
|
304
|
+
}
|
|
305
|
+
async function containsBuildRule(root, signal) {
|
|
306
|
+
if (!(await isDirectory(root)))
|
|
307
|
+
return false;
|
|
308
|
+
const queue = [root];
|
|
309
|
+
for (let index = 0; index < queue.length; index++) {
|
|
310
|
+
throwIfAborted(signal);
|
|
311
|
+
const directory = queue[index];
|
|
312
|
+
if (directory === undefined)
|
|
313
|
+
continue;
|
|
314
|
+
let entries;
|
|
315
|
+
try {
|
|
316
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
if (entry.isSymbolicLink())
|
|
323
|
+
continue;
|
|
324
|
+
if (entry.isFile() && entry.name.toLocaleLowerCase().endsWith(".build.cs"))
|
|
325
|
+
return true;
|
|
326
|
+
if (entry.isDirectory())
|
|
327
|
+
queue.push(join(directory, entry.name));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
async function pluginDeclaresNativeModules(descriptor) {
|
|
333
|
+
try {
|
|
334
|
+
const document = decodeJson(JSON.parse(await readFile(descriptor, "utf8")));
|
|
335
|
+
if (!isRecord(document))
|
|
336
|
+
return true;
|
|
337
|
+
const modules = document["Modules"];
|
|
338
|
+
if (modules === undefined)
|
|
339
|
+
return false;
|
|
340
|
+
return !Array.isArray(modules) || modules.length > 0;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Descriptor uncertainty must preserve binaries rather than broaden reclaim authority.
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function hasNativeCode(projectRoot, plugins, signal) {
|
|
348
|
+
if (await isDirectory(join(projectRoot, "Source")))
|
|
349
|
+
return true;
|
|
350
|
+
for (const plugin of plugins) {
|
|
351
|
+
if (await containsBuildRule(join(plugin.root, "Source"), signal))
|
|
352
|
+
return true;
|
|
353
|
+
if (await pluginDeclaresNativeModules(plugin.descriptor))
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
async function newestLogSession(savedLogs) {
|
|
359
|
+
let entries;
|
|
360
|
+
try {
|
|
361
|
+
entries = await readdir(savedLogs, { withFileTypes: true });
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
let newest;
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
const match = logStamp.exec(entry.name);
|
|
369
|
+
if (match === null)
|
|
370
|
+
continue;
|
|
371
|
+
const parts = match.slice(1).map(Number);
|
|
372
|
+
if (parts.length !== 6 || parts.some((part) => !Number.isInteger(part)))
|
|
373
|
+
continue;
|
|
374
|
+
const [year, month, day, hour, minute, second] = parts;
|
|
375
|
+
if (year === undefined ||
|
|
376
|
+
month === undefined ||
|
|
377
|
+
day === undefined ||
|
|
378
|
+
hour === undefined ||
|
|
379
|
+
minute === undefined ||
|
|
380
|
+
second === undefined) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const stamp = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
384
|
+
if (!Number.isFinite(stamp))
|
|
385
|
+
continue;
|
|
386
|
+
if (newest === undefined || stamp > newest)
|
|
387
|
+
newest = stamp;
|
|
388
|
+
}
|
|
389
|
+
return newest;
|
|
390
|
+
}
|
|
391
|
+
async function measureFreshness(projectRoot, descriptor, plugins, now, signal) {
|
|
392
|
+
const projectAuthored = await Promise.all([
|
|
393
|
+
fileMtime(descriptor),
|
|
394
|
+
...["Content", "Source", "Config"].map((name) => newestMtime(join(projectRoot, name), signal)),
|
|
395
|
+
...plugins.flatMap((plugin) => [
|
|
396
|
+
fileMtime(plugin.descriptor),
|
|
397
|
+
...["Content", "Source", "Config"].map((name) => newestMtime(join(plugin.root, name), signal))
|
|
398
|
+
])
|
|
399
|
+
]);
|
|
400
|
+
const authoredAtMs = newestTimestamp(projectAuthored);
|
|
401
|
+
const lastSessionAtMs = await newestLogSession(join(projectRoot, "Saved", "Logs"));
|
|
402
|
+
const effectiveAtMs = newestTimestamp([authoredAtMs, lastSessionAtMs]);
|
|
403
|
+
return {
|
|
404
|
+
...(authoredAtMs === undefined
|
|
405
|
+
? undefined
|
|
406
|
+
: { authoredAt: new Date(authoredAtMs).toISOString() }),
|
|
407
|
+
...(lastSessionAtMs === undefined
|
|
408
|
+
? undefined
|
|
409
|
+
: { lastSessionAt: new Date(lastSessionAtMs).toISOString() }),
|
|
410
|
+
...(effectiveAtMs === undefined
|
|
411
|
+
? undefined
|
|
412
|
+
: {
|
|
413
|
+
effectiveAt: new Date(effectiveAtMs).toISOString(),
|
|
414
|
+
ageDays: Math.max(0, (now - effectiveAtMs) / 86_400_000)
|
|
415
|
+
}),
|
|
416
|
+
mtimesLookRewritten: authoredAtMs !== undefined &&
|
|
417
|
+
lastSessionAtMs !== undefined &&
|
|
418
|
+
authoredAtMs - lastSessionAtMs > 30 * 86_400_000
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
async function measureDirectory(options) {
|
|
422
|
+
let bytes = 0;
|
|
423
|
+
let hardlinkExcludedBytes = 0;
|
|
424
|
+
const crossVolumePaths = [];
|
|
425
|
+
const queue = [options.path];
|
|
426
|
+
for (let index = 0; index < queue.length; index++) {
|
|
427
|
+
throwIfAborted(options.signal);
|
|
428
|
+
const directory = queue[index];
|
|
429
|
+
if (directory === undefined)
|
|
430
|
+
continue;
|
|
431
|
+
if ((await options.dependencies.deviceOf(directory)) !== options.expectedDevice) {
|
|
432
|
+
crossVolumePaths.push(directory);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
if (entry.isSymbolicLink())
|
|
438
|
+
continue;
|
|
439
|
+
const child = join(directory, entry.name);
|
|
440
|
+
if (entry.isDirectory()) {
|
|
441
|
+
queue.push(child);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (!entry.isFile())
|
|
445
|
+
continue;
|
|
446
|
+
const metadata = await lstat(child);
|
|
447
|
+
if (metadata.nlink > 1) {
|
|
448
|
+
// A read-only inventory cannot prove that every other name will be removed by a
|
|
449
|
+
// future plan. Excluding the allocation is conservative and prevents both
|
|
450
|
+
// cross-target double counting and credit while a retained link survives.
|
|
451
|
+
hardlinkExcludedBytes += metadata.size;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
bytes += metadata.size;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { bytes, crossVolumePaths, hardlinkExcludedBytes };
|
|
458
|
+
}
|
|
459
|
+
async function loadPolicy(projectRoot) {
|
|
460
|
+
const path = join(projectRoot, policyFilename);
|
|
461
|
+
try {
|
|
462
|
+
const contents = await readFile(path, "utf8");
|
|
463
|
+
const resolution = resolvePolicyDocument(decodeJson(JSON.parse(contents)));
|
|
464
|
+
return {
|
|
465
|
+
policy: resolution.policy,
|
|
466
|
+
...(resolution.error === undefined
|
|
467
|
+
? undefined
|
|
468
|
+
: {
|
|
469
|
+
diagnostic: {
|
|
470
|
+
code: "invalid_policy",
|
|
471
|
+
message: resolution.error,
|
|
472
|
+
path
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
catch (cause) {
|
|
478
|
+
if (Schema.is(NodeError)(cause) && cause.code === "ENOENT") {
|
|
479
|
+
return { policy: defaultCustodianPolicy };
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
policy: { ...defaultCustodianPolicy, enabled: false, targets: [] },
|
|
483
|
+
diagnostic: {
|
|
484
|
+
code: "invalid_policy",
|
|
485
|
+
message: `Could not read ${policyFilename}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
486
|
+
path
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function projectCandidates(projectRoot, policy, isCpp) {
|
|
492
|
+
const selected = new Set(policy.targets);
|
|
493
|
+
const candidates = [];
|
|
494
|
+
for (const definition of projectTargetDefinitions) {
|
|
495
|
+
if (!selected.has(definition.key))
|
|
496
|
+
continue;
|
|
497
|
+
if (isCpp &&
|
|
498
|
+
policy.keepBinariesForCpp &&
|
|
499
|
+
(definition.key === "binaries" || definition.key === "plugin_binaries")) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (definition.wildcard === "plugin") {
|
|
503
|
+
let plugins;
|
|
504
|
+
try {
|
|
505
|
+
plugins = await readdir(join(projectRoot, "Plugins"), { withFileTypes: true });
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const tail = definition.relativePath.endsWith("Intermediate")
|
|
511
|
+
? "Intermediate"
|
|
512
|
+
: "Binaries";
|
|
513
|
+
for (const plugin of plugins) {
|
|
514
|
+
candidates.push({
|
|
515
|
+
definition,
|
|
516
|
+
path: join(projectRoot, "Plugins", plugin.name, tail)
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
candidates.push({ definition, path: join(projectRoot, definition.relativePath) });
|
|
522
|
+
}
|
|
523
|
+
return candidates;
|
|
524
|
+
}
|
|
525
|
+
function refusal(projectRoot, path, code, reason) {
|
|
526
|
+
return {
|
|
527
|
+
path,
|
|
528
|
+
relativePath: relative(projectRoot, path).split(sep).join("/"),
|
|
529
|
+
code,
|
|
530
|
+
reason
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
async function measureProjectTargets(options) {
|
|
534
|
+
const targets = [];
|
|
535
|
+
const refusals = [];
|
|
536
|
+
const diagnostics = [];
|
|
537
|
+
const canonicalRoot = await realpath(options.projectRoot);
|
|
538
|
+
const candidates = await projectCandidates(options.projectRoot, options.policy, options.isCpp);
|
|
539
|
+
for (const candidate of candidates) {
|
|
540
|
+
throwIfAborted(options.signal);
|
|
541
|
+
if (!(await isDirectory(candidate.path)))
|
|
542
|
+
continue;
|
|
543
|
+
let canonical;
|
|
544
|
+
try {
|
|
545
|
+
canonical = await realpath(candidate.path);
|
|
546
|
+
}
|
|
547
|
+
catch (cause) {
|
|
548
|
+
refusals.push(refusal(options.projectRoot, candidate.path, "unreadable", cause instanceof Error ? cause.message : String(cause)));
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (!isWithin(canonical, canonicalRoot)) {
|
|
552
|
+
refusals.push(refusal(options.projectRoot, candidate.path, "outside_root", "Symlink resolves outside the named project."));
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const relativePath = relative(options.projectRoot, candidate.path).split(sep).join("/");
|
|
556
|
+
const parts = relativePath.split("/");
|
|
557
|
+
if (relativePath === "" ||
|
|
558
|
+
["Content", "Source", "Config", "Plugins", "Saved"].includes(relativePath) ||
|
|
559
|
+
parts.includes("SaveGames")) {
|
|
560
|
+
refusals.push(refusal(options.projectRoot, candidate.path, "protected_path", "Authored content, project roots, plugin roots, and save data are protected."));
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (candidate.definition.minimumAgeDays > 0) {
|
|
564
|
+
const newestTargetAt = await newestMtime(candidate.path, options.signal);
|
|
565
|
+
const targetAgeDays = newestTargetAt === undefined
|
|
566
|
+
? undefined
|
|
567
|
+
: Math.max(0, (options.now - newestTargetAt) / 86_400_000);
|
|
568
|
+
if (targetAgeDays === undefined ||
|
|
569
|
+
targetAgeDays < candidate.definition.minimumAgeDays) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const measurement = await measureDirectory({
|
|
575
|
+
path: candidate.path,
|
|
576
|
+
expectedDevice: options.expectedDevice,
|
|
577
|
+
signal: options.signal,
|
|
578
|
+
dependencies: options.dependencies
|
|
579
|
+
});
|
|
580
|
+
if (measurement.crossVolumePaths.length > 0) {
|
|
581
|
+
refusals.push(refusal(options.projectRoot, candidate.path, "different_volume", "Target contains storage on a different filesystem than the scan root."));
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (measurement.hardlinkExcludedBytes > 0) {
|
|
585
|
+
diagnostics.push({
|
|
586
|
+
code: "hardlink_excluded",
|
|
587
|
+
message: `${measurement.hardlinkExcludedBytes} byte(s) have multiple names and ` +
|
|
588
|
+
"were conservatively excluded from reclaim estimates.",
|
|
589
|
+
path: candidate.path
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
targets.push({
|
|
593
|
+
id: targetId({
|
|
594
|
+
kind: "project",
|
|
595
|
+
root: options.projectRoot,
|
|
596
|
+
key: candidate.definition.key,
|
|
597
|
+
relativePath
|
|
598
|
+
}),
|
|
599
|
+
key: candidate.definition.key,
|
|
600
|
+
path: candidate.path,
|
|
601
|
+
relativePath,
|
|
602
|
+
bytes: measurement.bytes,
|
|
603
|
+
description: candidate.definition.description,
|
|
604
|
+
rebuildCost: candidate.definition.rebuildCost,
|
|
605
|
+
risk: candidate.definition.risk
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (cause) {
|
|
609
|
+
diagnostics.push({
|
|
610
|
+
code: "target_unreadable",
|
|
611
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
612
|
+
path: candidate.path
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
targets.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
|
|
617
|
+
return { targets, refusals, diagnostics };
|
|
618
|
+
}
|
|
619
|
+
async function mapConcurrent(values, concurrency, map) {
|
|
620
|
+
const results = [];
|
|
621
|
+
results.length = values.length;
|
|
622
|
+
let cursor = 0;
|
|
623
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
624
|
+
while (cursor < values.length) {
|
|
625
|
+
const index = cursor++;
|
|
626
|
+
const value = values[index];
|
|
627
|
+
if (value !== undefined)
|
|
628
|
+
results[index] = await map(value);
|
|
629
|
+
}
|
|
630
|
+
}));
|
|
631
|
+
return results;
|
|
632
|
+
}
|
|
633
|
+
async function scanProject(options) {
|
|
634
|
+
const root = dirname(options.descriptor);
|
|
635
|
+
let engineAssociation = "not specified";
|
|
636
|
+
const diagnostics = [];
|
|
637
|
+
try {
|
|
638
|
+
const document = decodeJson(JSON.parse(await readFile(options.descriptor, "utf8")));
|
|
639
|
+
if (isRecord(document)) {
|
|
640
|
+
const association = document.EngineAssociation;
|
|
641
|
+
if (Schema.is(Schema.String)(association) && association.trim() !== "") {
|
|
642
|
+
engineAssociation = association.trim();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch (cause) {
|
|
647
|
+
diagnostics.push({
|
|
648
|
+
code: "descriptor_unreadable",
|
|
649
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
650
|
+
path: options.descriptor
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const loadedPolicy = await loadPolicy(root);
|
|
654
|
+
if (loadedPolicy.diagnostic !== undefined)
|
|
655
|
+
diagnostics.push(loadedPolicy.diagnostic);
|
|
656
|
+
const plugins = await discoverProjectPlugins(root, options.signal);
|
|
657
|
+
const freshness = await measureFreshness(root, options.descriptor, plugins, options.now, options.signal);
|
|
658
|
+
const isCpp = await hasNativeCode(root, plugins, options.signal);
|
|
659
|
+
const measured = await measureProjectTargets({
|
|
660
|
+
projectRoot: root,
|
|
661
|
+
policy: loadedPolicy.policy,
|
|
662
|
+
isCpp,
|
|
663
|
+
now: options.now,
|
|
664
|
+
expectedDevice: options.expectedDevice,
|
|
665
|
+
signal: options.signal,
|
|
666
|
+
dependencies: options.dependencies
|
|
667
|
+
});
|
|
668
|
+
diagnostics.push(...measured.diagnostics);
|
|
669
|
+
const reclaimableBytes = measured.targets.reduce((total, target) => total + target.bytes, 0);
|
|
670
|
+
const eligibility = loadedPolicy.diagnostic?.code === "invalid_policy"
|
|
671
|
+
? { kind: "invalid_policy" }
|
|
672
|
+
: !loadedPolicy.policy.enabled
|
|
673
|
+
? { kind: "opted_out" }
|
|
674
|
+
: reclaimableBytes === 0
|
|
675
|
+
? { kind: "empty" }
|
|
676
|
+
: freshness.ageDays === undefined && loadedPolicy.policy.minAgeDays > 0
|
|
677
|
+
? { kind: "unknown_age" }
|
|
678
|
+
: freshness.ageDays !== undefined &&
|
|
679
|
+
freshness.ageDays < loadedPolicy.policy.minAgeDays
|
|
680
|
+
? {
|
|
681
|
+
kind: "recent",
|
|
682
|
+
eligibleAfterDays: loadedPolicy.policy.minAgeDays - freshness.ageDays
|
|
683
|
+
}
|
|
684
|
+
: { kind: "candidate" };
|
|
685
|
+
return {
|
|
686
|
+
kind: "project",
|
|
687
|
+
name: basename(options.descriptor, ".uproject"),
|
|
688
|
+
root,
|
|
689
|
+
descriptor: options.descriptor,
|
|
690
|
+
engineAssociation,
|
|
691
|
+
isCpp,
|
|
692
|
+
policy: loadedPolicy.policy,
|
|
693
|
+
freshness,
|
|
694
|
+
eligibility,
|
|
695
|
+
targets: measured.targets,
|
|
696
|
+
refusals: measured.refusals,
|
|
697
|
+
diagnostics,
|
|
698
|
+
reclaimableBytes
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
async function scanEngine(options) {
|
|
702
|
+
const { engine } = options;
|
|
703
|
+
const targets = [];
|
|
704
|
+
const refusals = [];
|
|
705
|
+
const diagnostics = [];
|
|
706
|
+
for (const definition of engineTargetDefinitions) {
|
|
707
|
+
throwIfAborted(options.signal);
|
|
708
|
+
const path = join(engine.root, ...definition.relativePath.split("/"));
|
|
709
|
+
if (!(await isDirectory(path)))
|
|
710
|
+
continue;
|
|
711
|
+
if (definition.sourceBuildOnly && engine.buildKind === "installed") {
|
|
712
|
+
refusals.push({
|
|
713
|
+
path,
|
|
714
|
+
relativePath: definition.relativePath,
|
|
715
|
+
code: "installed_engine",
|
|
716
|
+
reason: "Precompiled install: this is engine product, not rebuildable output."
|
|
717
|
+
});
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!definition.defaultOn)
|
|
721
|
+
continue;
|
|
722
|
+
try {
|
|
723
|
+
const measurement = await measureDirectory({
|
|
724
|
+
path,
|
|
725
|
+
expectedDevice: options.expectedDevice,
|
|
726
|
+
signal: options.signal,
|
|
727
|
+
dependencies: options.dependencies
|
|
728
|
+
});
|
|
729
|
+
if (measurement.crossVolumePaths.length > 0) {
|
|
730
|
+
refusals.push({
|
|
731
|
+
path,
|
|
732
|
+
relativePath: definition.relativePath,
|
|
733
|
+
code: "different_volume",
|
|
734
|
+
reason: "Target contains storage on a different filesystem than the scan root."
|
|
735
|
+
});
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (measurement.hardlinkExcludedBytes > 0) {
|
|
739
|
+
diagnostics.push({
|
|
740
|
+
code: "hardlink_excluded",
|
|
741
|
+
message: `${measurement.hardlinkExcludedBytes} byte(s) have multiple names and ` +
|
|
742
|
+
"were conservatively excluded from reclaim estimates.",
|
|
743
|
+
path
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
targets.push({
|
|
747
|
+
id: targetId({
|
|
748
|
+
kind: "engine",
|
|
749
|
+
root: engine.root,
|
|
750
|
+
key: definition.key,
|
|
751
|
+
relativePath: definition.relativePath
|
|
752
|
+
}),
|
|
753
|
+
key: definition.key,
|
|
754
|
+
path,
|
|
755
|
+
relativePath: definition.relativePath,
|
|
756
|
+
bytes: measurement.bytes,
|
|
757
|
+
description: definition.description,
|
|
758
|
+
rebuildCost: definition.rebuildCost,
|
|
759
|
+
risk: definition.risk
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
catch (cause) {
|
|
763
|
+
diagnostics.push({
|
|
764
|
+
code: "target_unreadable",
|
|
765
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
766
|
+
path
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
targets.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
|
|
771
|
+
return {
|
|
772
|
+
kind: "engine",
|
|
773
|
+
name: `UE ${engine.version}`,
|
|
774
|
+
root: engine.root,
|
|
775
|
+
version: engine.version,
|
|
776
|
+
buildKind: engine.buildKind,
|
|
777
|
+
targets,
|
|
778
|
+
refusals,
|
|
779
|
+
diagnostics,
|
|
780
|
+
reclaimableBytes: targets.reduce((total, target) => total + target.bytes, 0)
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function makePlan(options) {
|
|
784
|
+
const thresholdGb = options.projects.length === 0
|
|
785
|
+
? defaultCustodianPolicy.minFreeGb
|
|
786
|
+
: Math.max(...options.projects.map(({ policy }) => policy.minFreeGb));
|
|
787
|
+
const thresholdBytes = Math.round(thresholdGb * gibibyte);
|
|
788
|
+
const eligible = [
|
|
789
|
+
...options.projects
|
|
790
|
+
.filter(({ eligibility }) => eligibility.kind === "candidate")
|
|
791
|
+
.map(({ kind, name, root, reclaimableBytes: bytes, targets }) => ({
|
|
792
|
+
kind,
|
|
793
|
+
name,
|
|
794
|
+
root,
|
|
795
|
+
bytes,
|
|
796
|
+
targets
|
|
797
|
+
})),
|
|
798
|
+
...options.engines
|
|
799
|
+
.filter(({ reclaimableBytes }) => reclaimableBytes > 0)
|
|
800
|
+
.map(({ kind, name, root, reclaimableBytes: bytes, targets }) => ({
|
|
801
|
+
kind,
|
|
802
|
+
name,
|
|
803
|
+
root,
|
|
804
|
+
bytes,
|
|
805
|
+
targets
|
|
806
|
+
}))
|
|
807
|
+
].sort((left, right) => right.bytes - left.bytes || left.root.localeCompare(right.root));
|
|
808
|
+
if (!options.ignorePressure && options.freeBytes >= thresholdBytes) {
|
|
809
|
+
return {
|
|
810
|
+
status: "pressure_satisfied",
|
|
811
|
+
freeBytes: options.freeBytes,
|
|
812
|
+
thresholdBytes,
|
|
813
|
+
projectedFreeBytes: options.freeBytes,
|
|
814
|
+
reclaimableBytes: 0,
|
|
815
|
+
items: []
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (eligible.length === 0) {
|
|
819
|
+
return {
|
|
820
|
+
status: "nothing_eligible",
|
|
821
|
+
freeBytes: options.freeBytes,
|
|
822
|
+
thresholdBytes,
|
|
823
|
+
projectedFreeBytes: options.freeBytes,
|
|
824
|
+
reclaimableBytes: 0,
|
|
825
|
+
items: []
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const items = [];
|
|
829
|
+
let projectedFreeBytes = options.freeBytes;
|
|
830
|
+
for (const item of eligible) {
|
|
831
|
+
if (!options.ignorePressure && projectedFreeBytes >= thresholdBytes)
|
|
832
|
+
break;
|
|
833
|
+
items.push(item);
|
|
834
|
+
projectedFreeBytes += item.bytes;
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
status: items.length === 0 ? "nothing_eligible" : "ready",
|
|
838
|
+
freeBytes: options.freeBytes,
|
|
839
|
+
thresholdBytes,
|
|
840
|
+
projectedFreeBytes,
|
|
841
|
+
reclaimableBytes: items.reduce((total, item) => total + item.bytes, 0),
|
|
842
|
+
items
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
export async function scanCustodian(request, signal, dependencies = defaultNodeScannerDependencies) {
|
|
846
|
+
const root = resolve(request.root);
|
|
847
|
+
const rootStat = await stat(root);
|
|
848
|
+
if (!rootStat.isDirectory())
|
|
849
|
+
throw new Error(`Scan root is not a directory: ${root}`);
|
|
850
|
+
const rootDevice = await dependencies.deviceOf(root);
|
|
851
|
+
const now = Date.now();
|
|
852
|
+
const discovery = await discover(root, rootDevice, signal, dependencies);
|
|
853
|
+
const engines = await identifyEngines(discovery.engineManifests, signal);
|
|
854
|
+
const canonicalEngineRoots = await Promise.all(engines.map(({ root }) => realpath(root)));
|
|
855
|
+
const descriptors = [];
|
|
856
|
+
const seen = new Set();
|
|
857
|
+
for (const descriptor of discovery.descriptors) {
|
|
858
|
+
throwIfAborted(signal);
|
|
859
|
+
const canonical = await realpath(descriptor);
|
|
860
|
+
const components = canonical.split(sep).map((part) => part.toLocaleLowerCase());
|
|
861
|
+
if (components.some((part) => excludedProjectComponents.has(part)))
|
|
862
|
+
continue;
|
|
863
|
+
if (canonicalEngineRoots.some((engineRoot) => isWithin(canonical, engineRoot)))
|
|
864
|
+
continue;
|
|
865
|
+
const identity = canonical.toLocaleLowerCase();
|
|
866
|
+
if (seen.has(identity))
|
|
867
|
+
continue;
|
|
868
|
+
seen.add(identity);
|
|
869
|
+
descriptors.push(descriptor);
|
|
870
|
+
}
|
|
871
|
+
const [projects, engineReports] = await Promise.all([
|
|
872
|
+
mapConcurrent(descriptors, scanConcurrency, (descriptor) => scanProject({
|
|
873
|
+
descriptor,
|
|
874
|
+
now,
|
|
875
|
+
expectedDevice: rootDevice,
|
|
876
|
+
signal,
|
|
877
|
+
dependencies
|
|
878
|
+
})),
|
|
879
|
+
mapConcurrent(engines, scanConcurrency, (engine) => scanEngine({ engine, expectedDevice: rootDevice, signal, dependencies }))
|
|
880
|
+
]);
|
|
881
|
+
const freeBytes = await dependencies.freeBytesAt(root);
|
|
882
|
+
const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
|
|
883
|
+
const sortedEngines = [...engineReports].sort((left, right) => left.root.localeCompare(right.root));
|
|
884
|
+
const totalReclaimableBytes = [...sortedProjects, ...sortedEngines].reduce((total, report) => total + report.reclaimableBytes, 0);
|
|
885
|
+
return {
|
|
886
|
+
schemaVersion: 2,
|
|
887
|
+
root,
|
|
888
|
+
measuredAt: new Date(now).toISOString(),
|
|
889
|
+
freeBytes,
|
|
890
|
+
totalReclaimableBytes,
|
|
891
|
+
projects: sortedProjects,
|
|
892
|
+
engines: sortedEngines,
|
|
893
|
+
diagnostics: discovery.diagnostics,
|
|
894
|
+
plan: makePlan({
|
|
895
|
+
projects: sortedProjects,
|
|
896
|
+
engines: sortedEngines,
|
|
897
|
+
freeBytes,
|
|
898
|
+
ignorePressure: request.ignorePressure ?? false
|
|
899
|
+
}),
|
|
900
|
+
destructiveOperationsAvailable: true
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
//# sourceMappingURL=node-scanner.js.map
|