@xagents_in/unmask 0.1.3 → 0.1.4

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 (50) hide show
  1. package/README.md +86 -4
  2. package/dist/cli/annotations.mjs +145 -0
  3. package/dist/cli/audit.mjs +223 -0
  4. package/dist/cli/cache.mjs +488 -0
  5. package/dist/cli/compare.mjs +261 -0
  6. package/dist/cli/config.mjs +141 -0
  7. package/dist/cli/discovery.mjs +372 -0
  8. package/dist/cli/doctor.mjs +354 -0
  9. package/dist/cli/exceptions.mjs +170 -0
  10. package/dist/cli/file.mjs +271 -0
  11. package/dist/cli/files.mjs +141 -0
  12. package/dist/cli/history.mjs +232 -0
  13. package/dist/cli/hosted.mjs +251 -43
  14. package/dist/cli/info.mjs +125 -0
  15. package/dist/cli/init.mjs +57 -0
  16. package/dist/cli/install-guard.mjs +896 -0
  17. package/dist/cli/lockfile.mjs +330 -0
  18. package/dist/cli/main.mjs +97 -14
  19. package/dist/cli/preflight.mjs +388 -0
  20. package/dist/cli/sarif.mjs +100 -0
  21. package/dist/cli/shell-integration.mjs +122 -0
  22. package/dist/cli/terminal.mjs +396 -0
  23. package/dist/cli/watch.mjs +233 -0
  24. package/dist/cli/why.mjs +256 -0
  25. package/dist/core/advisories.mjs +101 -24
  26. package/dist/core/advisory-cache.mjs +295 -0
  27. package/dist/core/behavior-signals.mjs +1088 -0
  28. package/dist/core/dependency-graph.mjs +332 -0
  29. package/dist/core/evidence-exports.mjs +469 -0
  30. package/dist/core/guard-config.mjs +463 -0
  31. package/dist/core/guard-policy.mjs +308 -0
  32. package/dist/core/package-details.mjs +42 -6
  33. package/dist/core/report.mjs +7 -0
  34. package/dist/core/sarif.mjs +556 -0
  35. package/dist/core/syntax.mjs +296 -15
  36. package/dist/model/client.mjs +204 -56
  37. package/dist/model/diff-evidence.mjs +177 -0
  38. package/dist/model/summary.mjs +183 -0
  39. package/dist/server/advisory-result.mjs +137 -0
  40. package/dist/server/app.mjs +60 -2
  41. package/dist/server/dependency-screening.mjs +386 -0
  42. package/dist/server/exports.mjs +63 -0
  43. package/dist/server/guard-decision.mjs +37 -0
  44. package/dist/server/main.mjs +25 -7
  45. package/dist/server/queue.mjs +95 -53
  46. package/dist/server/store.mjs +21 -1
  47. package/dist/version.json +1 -1
  48. package/dist/viewer/render.mjs +10 -2
  49. package/dist/viewer/shell.mjs +1 -1
  50. package/package.json +1 -1
@@ -0,0 +1,271 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { LocalInputError } from "../core/pipeline.mjs";
3
+ import { getHostedDetailsForReport } from "./hosted.mjs";
4
+ import { exactPackage, hostedOptions, terminalText } from "./info.mjs";
5
+
6
+ const MAX_BYTES = 131072;
7
+ const fail = (code, message) => new LocalInputError(code, message);
8
+ const invalid = () =>
9
+ fail(
10
+ "file-evidence-invalid",
11
+ "Recorded file evidence failed schema, report, file or evidence validation.",
12
+ );
13
+ function parse(args) {
14
+ const usage = () =>
15
+ fail(
16
+ "usage",
17
+ "Use file name@exact-version --report REPORT_ID --file-id FILE_ID",
18
+ );
19
+ if (args.length !== 5) throw usage();
20
+ exactPackage(args[0]);
21
+ const options = { spec: args[0] };
22
+ for (let index = 1; index < args.length; index += 2) {
23
+ const key =
24
+ args[index] === "--report"
25
+ ? "reportId"
26
+ : args[index] === "--file-id"
27
+ ? "fileId"
28
+ : null;
29
+ if (!key || Object.hasOwn(options, key)) throw usage();
30
+ options[key] = args[index + 1];
31
+ }
32
+ if (
33
+ !/^[a-f0-9]{32}$/.test(options.reportId ?? "") ||
34
+ !/^f-[A-Za-z0-9_-]{43}$/.test(options.fileId ?? "")
35
+ )
36
+ throw usage();
37
+ return options;
38
+ }
39
+ const integer = (value) => Number.isSafeInteger(value) && value >= 0;
40
+ const range = (value, size) =>
41
+ Array.isArray(value) &&
42
+ value.length === 2 &&
43
+ value.every(integer) &&
44
+ value[0] <= value[1] &&
45
+ value[1] <= size;
46
+
47
+ function validateEvidence(value, report, options) {
48
+ const file = value?.file;
49
+ if (
50
+ value?.schemaVersion !== "1" ||
51
+ value.reportId !== options.reportId ||
52
+ value.scope !== "artifact-source" ||
53
+ value.recordedOnly !== true ||
54
+ file?.fileId !== options.fileId ||
55
+ !Array.isArray(value.evidence) ||
56
+ value.evidence.length > 32 ||
57
+ !["recorded", "partial", "unavailable"].includes(value.evidenceStatus) ||
58
+ !integer(value.evidenceOmitted) ||
59
+ !integer(value.invalidEvidenceOmitted) ||
60
+ !Array.isArray(value.limitations) ||
61
+ value.limitations.length > 16 ||
62
+ value.limitations.some(
63
+ (item) => typeof item !== "string" || item.length > 4096,
64
+ )
65
+ )
66
+ throw invalid();
67
+ if (file.state === "removed") {
68
+ if (
69
+ file.comparisonState !== "source-only" ||
70
+ !report.coverage.sourceOnly.includes(file.path) ||
71
+ file.originalPath !== null ||
72
+ file.artifactSha256 !== null ||
73
+ file.size !== null ||
74
+ (file.sourceSha256 !== null &&
75
+ !report.evidence.some(
76
+ (item) =>
77
+ item.side === "source" &&
78
+ item.path === file.path &&
79
+ item.fileSha256 === file.sourceSha256 &&
80
+ item.blobDigest === report.inputs.sourceArchive?.sha256,
81
+ ))
82
+ )
83
+ throw invalid();
84
+ } else {
85
+ const raw = report.files.find(
86
+ (item) =>
87
+ item.path === file.path && item.originalPath === file.originalPath,
88
+ );
89
+ const states = {
90
+ "byte-identical": "identical",
91
+ changed: "changed",
92
+ "artifact-only": "added",
93
+ "not-compared": "not-compared",
94
+ };
95
+ if (
96
+ !raw ||
97
+ file.comparisonState !== raw.state ||
98
+ file.state !== states[raw.state] ||
99
+ file.size !== raw.size ||
100
+ file.artifactSha256 !== raw.artifactSha256 ||
101
+ file.sourceSha256 !== (raw.sourceSha256 ?? null)
102
+ )
103
+ throw invalid();
104
+ }
105
+ const status =
106
+ value.evidence.length === 0
107
+ ? "unavailable"
108
+ : value.evidenceOmitted || value.invalidEvidenceOmitted
109
+ ? "partial"
110
+ : "recorded";
111
+ if (value.evidenceStatus !== status) throw invalid();
112
+ const known = new Map(report.evidence.map((item) => [item.id, item]));
113
+ const seen = new Set();
114
+ for (const item of value.evidence) {
115
+ const raw = known.get(item?.id);
116
+ if (
117
+ !raw ||
118
+ seen.has(item.id) ||
119
+ item.path !== file.path ||
120
+ ![
121
+ "side",
122
+ "path",
123
+ "originalPath",
124
+ "size",
125
+ "fileSha256",
126
+ "blobDigest",
127
+ "range",
128
+ ].every((key) => isDeepStrictEqual(item[key], raw[key])) ||
129
+ item.fileSha256 !==
130
+ (item.side === "artifact" ? file.artifactSha256 : file.sourceSha256) ||
131
+ (item.side === "artifact" && item.originalPath !== file.originalPath)
132
+ )
133
+ throw invalid();
134
+ seen.add(item.id);
135
+ if (typeof item.excerptTruncated !== "boolean") throw invalid();
136
+ if (item.excerpt === null) {
137
+ if (
138
+ !["not-recorded", "invalid-recorded-range"].includes(
139
+ item.excerptStatus,
140
+ ) ||
141
+ item.excerptRange !== null
142
+ )
143
+ throw invalid();
144
+ } else if (
145
+ item.excerptStatus !== "recorded" ||
146
+ typeof item.excerpt !== "string" ||
147
+ typeof raw.excerpt !== "string" ||
148
+ !raw.excerpt.startsWith(item.excerpt) ||
149
+ Buffer.byteLength(item.excerpt) > 2048 ||
150
+ !range(item.excerptRange, item.size) ||
151
+ !isDeepStrictEqual(item.recordedExcerptRange, raw.excerptRange) ||
152
+ item.excerptRange[0] !== raw.excerptRange?.[0] ||
153
+ item.excerptRange[1] > raw.excerptRange?.[1] ||
154
+ Buffer.byteLength(item.excerpt) !==
155
+ item.excerptRange[1] - item.excerptRange[0]
156
+ )
157
+ throw invalid();
158
+ }
159
+ }
160
+
161
+ async function fetchEvidence(url, runtime) {
162
+ const controller = new AbortController();
163
+ const timeout = setTimeout(
164
+ () => controller.abort(),
165
+ Math.min(runtime.hostedLimits?.requestTimeoutMs ?? 15000, 15000),
166
+ );
167
+ const signal = runtime.signal
168
+ ? AbortSignal.any([runtime.signal, controller.signal])
169
+ : controller.signal;
170
+ let response;
171
+ try {
172
+ response = await (runtime.fetchImpl ?? globalThis.fetch)(url.href, {
173
+ method: "GET",
174
+ headers: { accept: "application/json" },
175
+ redirect: "manual",
176
+ credentials: "omit",
177
+ referrerPolicy: "no-referrer",
178
+ signal,
179
+ });
180
+ if (
181
+ response.status !== 200 ||
182
+ response.redirected ||
183
+ (response.url && new URL(response.url).origin !== url.origin)
184
+ )
185
+ throw fail(
186
+ "file-evidence-unavailable",
187
+ `Stored file evidence is unavailable (HTTP ${response.status}); no new check was submitted.`,
188
+ );
189
+ const declared = response.headers.get("content-length");
190
+ if (
191
+ !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
192
+ response.headers.get("content-type") ?? "",
193
+ ) ||
194
+ (declared !== null &&
195
+ (!/^\d+$/.test(declared) || Number(declared) > MAX_BYTES))
196
+ )
197
+ throw invalid();
198
+ let bytes = 0;
199
+ const chunks = [];
200
+ for await (const chunk of response.body ?? []) {
201
+ bytes += chunk.byteLength;
202
+ if (bytes > MAX_BYTES) throw invalid();
203
+ chunks.push(chunk);
204
+ }
205
+ try {
206
+ return JSON.parse(
207
+ new TextDecoder("utf-8", { fatal: true }).decode(
208
+ Buffer.concat(chunks, bytes),
209
+ ),
210
+ );
211
+ } catch {
212
+ throw invalid();
213
+ }
214
+ } catch (error) {
215
+ if (error instanceof LocalInputError) throw error;
216
+ throw fail(
217
+ "file-evidence-unavailable",
218
+ signal.aborted
219
+ ? "Stored file evidence timed out or was cancelled; no new check was submitted."
220
+ : "Stored file evidence could not be read; no new check was submitted.",
221
+ );
222
+ } finally {
223
+ clearTimeout(timeout);
224
+ try {
225
+ await response?.body?.cancel();
226
+ } catch {}
227
+ }
228
+ }
229
+
230
+ function render(value, options) {
231
+ const file = value.file,
232
+ text = terminalText;
233
+ const lines = [
234
+ `UNMASK file ${text(options.spec)}`,
235
+ `${text(file.path ?? file.originalPath)} [${text(file.state)}] ${file.size === null ? "size unavailable" : file.size + " bytes"}`,
236
+ `Report ID: ${options.reportId}; File ID: ${options.fileId}`,
237
+ `artifact-sha256=${text(file.artifactSha256 ?? "unavailable")} source-sha256=${text(file.sourceSha256 ?? "unavailable")}`,
238
+ `Previously recorded evidence only: ${text(value.evidenceStatus)}; omitted=${value.evidenceOmitted}; invalid omitted=${value.invalidEvidenceOmitted}.`,
239
+ ];
240
+ for (const item of value.evidence) {
241
+ lines.push(
242
+ ` ${text(item.side)} ${text(item.id)} bytes [${item.range.join(", ")}]`,
243
+ item.excerpt === null
244
+ ? ` Excerpt: ${text(item.excerptStatus)}`
245
+ : ` Excerpt [${item.excerptRange.join(", ")}]: ${text(item.excerpt)}`,
246
+ );
247
+ if (item.excerptTruncated)
248
+ lines.push(" Recorded excerpt truncated by the API limit.");
249
+ }
250
+ for (const limitation of value.limitations)
251
+ lines.push(` ${text(limitation)}`);
252
+ lines.push(
253
+ "This is not a safety verdict. No file was opened, reconstructed or executed locally.",
254
+ );
255
+ return lines.join("\n") + "\n";
256
+ }
257
+
258
+ export async function fileCommand(args, runtime = {}) {
259
+ const options = parse(args);
260
+ const { report, reportUrl } = await getHostedDetailsForReport(
261
+ options.spec,
262
+ options.reportId,
263
+ hostedOptions(runtime),
264
+ );
265
+ const url = new URL(`/r/${options.reportId}/file.json`, reportUrl);
266
+ url.searchParams.set("fileId", options.fileId);
267
+ const evidence = await fetchEvidence(url, runtime);
268
+ validateEvidence(evidence, report, options);
269
+ (runtime.stdout ?? process.stdout).write(render(evidence, options));
270
+ return 0;
271
+ }
@@ -0,0 +1,141 @@
1
+ import { LocalInputError } from "../core/pipeline.mjs";
2
+ import { getHostedDetails, getHostedDetailsForReport } from "./hosted.mjs";
3
+ import { exactPackage, hostedOptions, terminalText, aiLines } from "./info.mjs";
4
+
5
+ const states = ["byte-identical", "changed", "artifact-only", "not-compared"];
6
+ const usage = () =>
7
+ new LocalInputError(
8
+ "usage",
9
+ "Use files name@exact-version [--report ID] [--path TEXT] [--state STATE] [--limit 1..200] [--offset N]",
10
+ );
11
+ function parse(args) {
12
+ if (!args[0]) throw usage();
13
+ exactPackage(args[0]);
14
+ const options = {
15
+ spec: args[0],
16
+ path: "",
17
+ state: "all",
18
+ limit: 50,
19
+ offset: 0,
20
+ };
21
+ const seen = new Set();
22
+ for (let index = 1; index < args.length; index += 2) {
23
+ const flag = args[index];
24
+ const value = args[index + 1];
25
+ if (
26
+ !["--path", "--state", "--limit", "--offset", "--report"].includes(
27
+ flag,
28
+ ) ||
29
+ seen.has(flag) ||
30
+ value === undefined
31
+ )
32
+ throw usage();
33
+ seen.add(flag);
34
+ if (flag === "--report") {
35
+ if (!/^[a-f0-9]{32}$/.test(value)) throw usage();
36
+ options.reportId = value;
37
+ } else if (flag === "--path") {
38
+ if (value.length > 1024) throw usage();
39
+ options.path = value;
40
+ } else if (flag === "--state") {
41
+ if (!["all", ...states].includes(value)) throw usage();
42
+ options.state = value;
43
+ } else {
44
+ if (
45
+ !/^(0|[1-9][0-9]*)$/.test(value) ||
46
+ !Number.isSafeInteger(Number(value))
47
+ )
48
+ throw usage();
49
+ const number = Number(value);
50
+ if (flag === "--limit" && (number < 1 || number > 200)) throw usage();
51
+ options[flag.slice(2)] = number;
52
+ }
53
+ }
54
+ return options;
55
+ }
56
+
57
+ function validateFiles(list) {
58
+ const digest = (value) =>
59
+ typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
60
+ if (
61
+ list.items.some(
62
+ (file) =>
63
+ !(file.path === null || typeof file.path === "string") ||
64
+ typeof file.originalPath !== "string" ||
65
+ !states.includes(file.state) ||
66
+ !Number.isSafeInteger(file.size) ||
67
+ file.size < 0 ||
68
+ !digest(file.artifactSha256) ||
69
+ (file.sourceSha256 !== undefined && !digest(file.sourceSha256)),
70
+ )
71
+ )
72
+ throw new LocalInputError(
73
+ "hosted-details-invalid",
74
+ "Hosted file inventory contains invalid paths, states, sizes or hashes.",
75
+ );
76
+ }
77
+
78
+ export function renderFiles({ details, report, reportUrl }, options) {
79
+ const reportId = new URL(reportUrl).pathname
80
+ .split("/")[2]
81
+ .replace(/\.json$/, "");
82
+ const inventory = details.inventory;
83
+ validateFiles(inventory.files);
84
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
85
+ const files = inventory.files.items
86
+ .filter(
87
+ (file) =>
88
+ (options.state === "all" || file.state === options.state) &&
89
+ (file.path ?? file.originalPath).includes(options.path),
90
+ )
91
+ .sort(
92
+ (a, b) =>
93
+ order(a.path ?? "", b.path ?? "") ||
94
+ order(a.originalPath, b.originalPath),
95
+ );
96
+ const page = files.slice(options.offset, options.offset + options.limit);
97
+ const start = page.length ? options.offset + 1 : 0;
98
+ const end = page.length ? options.offset + page.length : 0;
99
+ const text = terminalText;
100
+ const lines = [
101
+ `UNMASK files ${text(options.spec)}`,
102
+ "Scope: accepted artifact regular files; source-only paths and inert links are outside this list.",
103
+ `Filters: path contains ${text(JSON.stringify(options.path))}; state=${text(options.state)}`,
104
+ `Rows ${start}-${end} of ${files.length} matching returned records; hosted projected total=${inventory.files.total}.`,
105
+ `Inventory complete: ${inventory.complete === true}; ${inventory.files.omitted} records omitted by hosted projection.`,
106
+ ];
107
+ if (inventory.files.omitted)
108
+ lines.push(
109
+ "Filtering and pagination cover returned records only; omitted records may also match.",
110
+ );
111
+ for (const file of page)
112
+ lines.push(
113
+ `${text(file.path ?? file.originalPath)} [${text(file.state)}] ${file.size} bytes`,
114
+ ` artifact-sha256=${file.artifactSha256} source-sha256=${file.sourceSha256 ?? "unavailable"}`,
115
+ );
116
+ if (options.offset + page.length < files.length)
117
+ lines.push(
118
+ `Next offset: ${options.offset + page.length} (use the same filters and limit).`,
119
+ );
120
+ lines.push(...aiLines(report.model));
121
+ lines.push(
122
+ "This is not a safety verdict; byte-identical means only recorded byte correspondence.",
123
+ `Report: ${text(reportUrl)}`,
124
+ `Report ID: ${reportId}`,
125
+ `Reuse this report for paging: --report ${reportId} (with the same exact package spec and filters; no new check).`,
126
+ );
127
+ return lines.join("\n") + "\n";
128
+ }
129
+
130
+ export async function filesCommand(args, runtime = {}) {
131
+ const options = parse(args);
132
+ const result = options.reportId
133
+ ? await getHostedDetailsForReport(
134
+ options.spec,
135
+ options.reportId,
136
+ hostedOptions(runtime),
137
+ )
138
+ : await getHostedDetails(options.spec, hostedOptions(runtime));
139
+ (runtime.stdout ?? process.stdout).write(renderFiles(result, options));
140
+ return 0;
141
+ }
@@ -0,0 +1,232 @@
1
+ import {
2
+ AcquisitionError,
3
+ fetchBlob,
4
+ parsePackageSpec,
5
+ } from "../core/acquire.mjs";
6
+ import { LocalInputError } from "../core/pipeline.mjs";
7
+
8
+ const MAX_PACKUMENT_BYTES = 40 * 1024 * 1024;
9
+ const TIMEOUT_MS = 20000;
10
+ const DEFAULT_LIMIT = 15;
11
+ const MAX_LIMIT = 100;
12
+ const RECENT_MS = 7 * 24 * 60 * 60 * 1000;
13
+ const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"];
14
+ const usage = () =>
15
+ new LocalInputError("usage", "Use history NAME [--limit 1-100] [--json]");
16
+ export function parseHistoryArgs(argv) {
17
+ const options = { limit: DEFAULT_LIMIT, json: false };
18
+ const seen = new Set();
19
+ let name;
20
+ for (let index = 0; index < argv.length; index++) {
21
+ const value = argv[index];
22
+ if (seen.has(value)) throw usage();
23
+ if (value === "--json") options.json = true;
24
+ else if (value === "--limit") {
25
+ const raw = argv[++index];
26
+ if (!/^[1-9][0-9]{0,2}$/.test(raw ?? "") || Number(raw) > MAX_LIMIT)
27
+ throw new LocalInputError(
28
+ "invalid-limit",
29
+ `--limit must be an integer from 1 to ${MAX_LIMIT}`,
30
+ );
31
+ options.limit = Number(raw);
32
+ } else if (value.startsWith("--") || name !== undefined) throw usage();
33
+ else name = value;
34
+ if (value.startsWith("--")) seen.add(value);
35
+ }
36
+ if (name === undefined) throw usage();
37
+ try {
38
+ parsePackageSpec(`${name}@0.0.0`);
39
+ } catch (error) {
40
+ if (error instanceof AcquisitionError)
41
+ throw new LocalInputError(
42
+ "invalid-package-name",
43
+ "History requires a bare npm package name without a version",
44
+ );
45
+ throw error;
46
+ }
47
+ return { ...options, name };
48
+ }
49
+ const UNSAFE =
50
+ /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
51
+ const JSON_UNSAFE = /[\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
52
+ const escapeUnsafe = (text, pattern = UNSAFE) =>
53
+ text.replace(
54
+ pattern,
55
+ (char) => "\\u" + char.charCodeAt(0).toString(16).padStart(4, "0"),
56
+ );
57
+ const abbreviate = (value, max) =>
58
+ value.length > max ? value.slice(0, max) + "..." : value;
59
+ const validDate = (value) =>
60
+ typeof value === "string" && Number.isFinite(Date.parse(value));
61
+ function exactPin(name, version) {
62
+ try {
63
+ parsePackageSpec(`${name}@${version}`);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function summarizeHistory(packument, { limit, now = Date.now() }) {
70
+ if (!packument || typeof packument !== "object" || Array.isArray(packument))
71
+ throw new LocalInputError(
72
+ "registry-invalid",
73
+ "Registry metadata is not a JSON object",
74
+ );
75
+ const versions =
76
+ packument.versions && typeof packument.versions === "object"
77
+ ? packument.versions
78
+ : {};
79
+ const time =
80
+ packument.time && typeof packument.time === "object" ? packument.time : {};
81
+ const tags = new Map();
82
+ for (const [tag, version] of Object.entries(packument["dist-tags"] ?? {}))
83
+ if (typeof version === "string")
84
+ tags.set(version, [...(tags.get(version) ?? []), tag]);
85
+ const known = new Set([
86
+ ...Object.keys(versions),
87
+ ...Object.keys(time).filter(
88
+ (key) => !["created", "modified"].includes(key) && validDate(time[key]),
89
+ ),
90
+ ]);
91
+ const releases = [];
92
+ for (const version of known) {
93
+ const manifest = Object.hasOwn(versions, version)
94
+ ? versions[version]
95
+ : undefined;
96
+ const available = Boolean(manifest && typeof manifest === "object");
97
+ releases.push({
98
+ version,
99
+ exactPin: exactPin(packument.name, version),
100
+ published: validDate(time[version]) ? time[version] : null,
101
+ available,
102
+ tags: tags.get(version) ?? [],
103
+ deprecated:
104
+ available && typeof manifest.deprecated === "string"
105
+ ? manifest.deprecated
106
+ : null,
107
+ installScripts: available
108
+ ? INSTALL_SCRIPTS.filter(
109
+ (name) => typeof manifest.scripts?.[name] === "string",
110
+ )
111
+ : [],
112
+ recent: validDate(time[version])
113
+ ? now - Date.parse(time[version]) < RECENT_MS
114
+ : false,
115
+ });
116
+ }
117
+ const stamp = (release) =>
118
+ release.published ? Date.parse(release.published) : 0;
119
+ releases.sort(
120
+ (a, b) => stamp(b) - stamp(a) || (a.version < b.version ? 1 : -1),
121
+ );
122
+ const selectable = (release) =>
123
+ release.available && !release.deprecated && release.exactPin;
124
+ const latest = releases.find(
125
+ (release) => release.version === packument["dist-tags"]?.latest,
126
+ );
127
+ const candidate =
128
+ latest && selectable(latest)
129
+ ? latest
130
+ : releases.find(
131
+ (release) => selectable(release) && !release.version.includes("-"),
132
+ );
133
+ return {
134
+ name: packument.name,
135
+ totalVersions: releases.length,
136
+ removedVersions: releases.filter((release) => !release.available).length,
137
+ deprecatedVersions: releases.filter((release) => release.deprecated).length,
138
+ distTags: Object.fromEntries(
139
+ [...tags].flatMap(([version, names]) =>
140
+ names.map((tag) => [tag, version]),
141
+ ),
142
+ ),
143
+ unpublished: Boolean(time.unpublished),
144
+ shown: releases.slice(0, limit),
145
+ candidate: candidate?.version ?? null,
146
+ };
147
+ }
148
+ export function renderHistory(summary) {
149
+ const lines = [
150
+ `History ${summary.name}: ${summary.totalVersions} versions, ${summary.deprecatedVersions} deprecated, ${summary.removedVersions} removed from registry`,
151
+ `Dist-tags: ${
152
+ Object.entries(summary.distTags)
153
+ .map(
154
+ ([tag, version]) =>
155
+ `${abbreviate(tag, 40)}=${abbreviate(version, 64)}`,
156
+ )
157
+ .join(", ") || "none"
158
+ }`,
159
+ ];
160
+ if (summary.unpublished)
161
+ lines.push("! The package is marked unpublished in the registry.");
162
+ lines.push(`Newest ${summary.shown.length} releases:`);
163
+ for (const release of summary.shown) {
164
+ const marks = [
165
+ ...release.tags.map((tag) => `[${abbreviate(tag, 40)}]`),
166
+ ...(release.available ? [] : ["[removed]"]),
167
+ ...(release.recent ? ["[recent]"] : []),
168
+ ...(release.installScripts.length
169
+ ? [`[install scripts: ${release.installScripts.join(",")}]`]
170
+ : []),
171
+ ];
172
+ lines.push(
173
+ ` ${(release.published ?? "unknown date").slice(0, 10)} ${abbreviate(release.version, 64)}${marks.length ? " " + marks.join(" ") : ""}`,
174
+ );
175
+ if (release.deprecated)
176
+ lines.push(` deprecated: ${abbreviate(release.deprecated, 160)}`);
177
+ }
178
+ lines.push("Selection guidance:");
179
+ if (summary.candidate)
180
+ lines.push(
181
+ ` Pin an exact version, e.g. ${summary.name}@${summary.candidate}, and check it first: unmask check ${summary.name}@${summary.candidate} --out DIR`,
182
+ );
183
+ else
184
+ lines.push(
185
+ " No non-deprecated stable release is available; do not select a version without review.",
186
+ );
187
+ lines.push(
188
+ " Avoid ranges and dist-tags in installs; removed versions cannot be verified from the registry.",
189
+ " Release age, popularity or a lack of findings is not proof of safety.",
190
+ );
191
+ return lines.map((line) => escapeUnsafe(line)).join("\n") + "\n";
192
+ }
193
+ export async function runHistory(argv, runtime = {}) {
194
+ const { name, limit, json } = parseHistoryArgs(argv);
195
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
196
+ let packument;
197
+ try {
198
+ const blob = await fetchBlob(url, {
199
+ fetchImpl: runtime.fetchImpl,
200
+ signal: runtime.signal,
201
+ maxBytes: MAX_PACKUMENT_BYTES,
202
+ timeoutMs: TIMEOUT_MS,
203
+ });
204
+ packument = JSON.parse(blob.bytes.toString("utf8"));
205
+ } catch (error) {
206
+ if (error instanceof AcquisitionError)
207
+ throw new LocalInputError(
208
+ error.observation?.status === 404
209
+ ? "registry-not-found"
210
+ : "registry-unavailable",
211
+ error.observation?.status === 404
212
+ ? `The registry has no package named ${name}.`
213
+ : `Registry metadata could not be read (${error.code}).`,
214
+ );
215
+ throw new LocalInputError(
216
+ "registry-invalid",
217
+ "Registry metadata is not valid JSON",
218
+ );
219
+ }
220
+ if (packument?.name !== name)
221
+ throw new LocalInputError(
222
+ "registry-identity-mismatch",
223
+ "Registry metadata names a different package",
224
+ );
225
+ const summary = summarizeHistory(packument, { limit, now: runtime.now });
226
+ (runtime.stdout ?? process.stdout).write(
227
+ json
228
+ ? escapeUnsafe(JSON.stringify(summary, null, 2), JSON_UNSAFE) + "\n"
229
+ : renderHistory(summary),
230
+ );
231
+ return 0;
232
+ }