@packall/core 0.0.1 → 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 +58 -0
- package/dist/archive-6sqTn1C4.js +274 -0
- package/dist/archive-6sqTn1C4.js.map +1 -0
- package/dist/archive.d.ts +64 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/{Bundle.d.ts → bundle.d.ts} +56 -38
- package/dist/bundle.d.ts.map +1 -0
- package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
- package/dist/dependency-range.d.ts.map +1 -0
- package/dist/{Download.d.ts → download.d.ts} +9 -8
- package/dist/download.d.ts.map +1 -0
- package/dist/enums/artifact-kind.d.ts +8 -0
- package/dist/enums/artifact-kind.d.ts.map +1 -0
- package/dist/enums/edge-kind.d.ts +29 -0
- package/dist/enums/edge-kind.d.ts.map +1 -0
- package/dist/enums/input-file-kind.d.ts +12 -0
- package/dist/enums/input-file-kind.d.ts.map +1 -0
- package/dist/enums/layout.d.ts +23 -0
- package/dist/enums/layout.d.ts.map +1 -0
- package/dist/enums/lockfile-format.d.ts +9 -0
- package/dist/enums/lockfile-format.d.ts.map +1 -0
- package/dist/enums/phase.d.ts +11 -0
- package/dist/enums/phase.d.ts.map +1 -0
- package/dist/{Errors.d.ts → errors.d.ts} +49 -7
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +46 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2121 -1060
- package/dist/index.js.map +1 -1
- package/dist/input-file.d.ts +77 -0
- package/dist/input-file.d.ts.map +1 -0
- package/dist/integrity.d.ts +80 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/layout.d.ts +87 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/locked-resolve.d.ts +47 -0
- package/dist/locked-resolve.d.ts.map +1 -0
- package/dist/lockfile/bun.d.ts +3 -0
- package/dist/lockfile/bun.d.ts.map +1 -0
- package/dist/lockfile/detect.d.ts +31 -0
- package/dist/lockfile/detect.d.ts.map +1 -0
- package/dist/lockfile/json.d.ts +23 -0
- package/dist/lockfile/json.d.ts.map +1 -0
- package/dist/lockfile/names.d.ts +16 -0
- package/dist/lockfile/names.d.ts.map +1 -0
- package/dist/lockfile/npm.d.ts +3 -0
- package/dist/lockfile/npm.d.ts.map +1 -0
- package/dist/lockfile/parse.d.ts +23 -0
- package/dist/lockfile/parse.d.ts.map +1 -0
- package/dist/lockfile/pnpm.d.ts +20 -0
- package/dist/lockfile/pnpm.d.ts.map +1 -0
- package/dist/lockfile/tree-builder.d.ts +70 -0
- package/dist/lockfile/tree-builder.d.ts.map +1 -0
- package/dist/lockfile/types.d.ts +70 -0
- package/dist/lockfile/types.d.ts.map +1 -0
- package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
- package/dist/manifest.d.ts.map +1 -0
- package/dist/node/archiver.d.ts +5 -0
- package/dist/node/archiver.d.ts.map +1 -0
- package/dist/node/index.d.ts +13 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +55 -0
- package/dist/node/index.js.map +1 -0
- package/dist/{Options.d.ts → options.d.ts} +12 -30
- package/dist/options.d.ts.map +1 -0
- package/dist/{Platform.d.ts → platform.d.ts} +14 -19
- package/dist/platform.d.ts.map +1 -0
- package/dist/{Progress.d.ts → progress.d.ts} +10 -9
- package/dist/progress.d.ts.map +1 -0
- package/dist/{Registry.d.ts → registry.d.ts} +42 -44
- package/dist/registry.d.ts.map +1 -0
- package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
- package/dist/resolve.d.ts.map +1 -0
- package/dist/schemas/lenient.d.ts +38 -0
- package/dist/schemas/lenient.d.ts.map +1 -0
- package/dist/schemas/package-json.d.ts +44 -0
- package/dist/schemas/package-json.d.ts.map +1 -0
- package/dist/{Spec.d.ts → spec.d.ts} +4 -4
- package/dist/spec.d.ts.map +1 -0
- package/dist/types/value-of.d.ts +9 -0
- package/dist/types/value-of.d.ts.map +1 -0
- package/dist/utils/is-record.d.ts +8 -0
- package/dist/utils/is-record.d.ts.map +1 -0
- package/package.json +13 -11
- package/src/archive.ts +95 -0
- package/src/bundle.ts +736 -0
- package/src/dependency-range.ts +121 -0
- package/src/download.ts +155 -0
- package/src/enums/artifact-kind.ts +9 -0
- package/src/enums/edge-kind.ts +32 -0
- package/src/enums/input-file-kind.ts +13 -0
- package/src/enums/layout.ts +25 -0
- package/src/enums/lockfile-format.ts +10 -0
- package/src/enums/phase.ts +12 -0
- package/src/errors.ts +300 -0
- package/src/index.ts +146 -35
- package/src/input-file.ts +324 -0
- package/src/integrity.ts +165 -0
- package/src/layout.ts +137 -0
- package/src/locked-resolve.ts +471 -0
- package/src/lockfile/bun.ts +214 -0
- package/src/lockfile/detect.ts +80 -0
- package/src/lockfile/json.ts +131 -0
- package/src/lockfile/names.ts +20 -0
- package/src/lockfile/npm.ts +328 -0
- package/src/lockfile/parse.ts +56 -0
- package/src/lockfile/pnpm.ts +230 -0
- package/src/lockfile/tree-builder.ts +166 -0
- package/src/lockfile/types.ts +74 -0
- package/src/manifest.ts +129 -0
- package/src/node/archiver.ts +85 -0
- package/src/node/index.ts +13 -0
- package/src/options.ts +103 -0
- package/src/platform.ts +178 -0
- package/src/progress.ts +105 -0
- package/src/registry.ts +116 -0
- package/src/resolve.ts +560 -0
- package/src/schemas/lenient.ts +130 -0
- package/src/schemas/package-json.ts +28 -0
- package/src/spec.ts +182 -0
- package/src/types/value-of.ts +8 -0
- package/src/utils/is-record.ts +8 -0
- package/dist/Archive.d.ts +0 -32
- package/dist/Archive.d.ts.map +0 -1
- package/dist/Bundle.d.ts.map +0 -1
- package/dist/DependencyRange.d.ts.map +0 -1
- package/dist/Download.d.ts.map +0 -1
- package/dist/Errors.d.ts.map +0 -1
- package/dist/InputFile.d.ts +0 -46
- package/dist/InputFile.d.ts.map +0 -1
- package/dist/Integrity.d.ts +0 -48
- package/dist/Integrity.d.ts.map +0 -1
- package/dist/Layout.d.ts +0 -53
- package/dist/Layout.d.ts.map +0 -1
- package/dist/Manifest.d.ts.map +0 -1
- package/dist/Options.d.ts.map +0 -1
- package/dist/Platform.d.ts.map +0 -1
- package/dist/Progress.d.ts.map +0 -1
- package/dist/Registry.d.ts.map +0 -1
- package/dist/Resolve.d.ts.map +0 -1
- package/dist/Spec.d.ts.map +0 -1
- package/src/Archive.ts +0 -92
- package/src/Bundle.ts +0 -570
- package/src/DependencyRange.ts +0 -115
- package/src/Download.ts +0 -132
- package/src/Errors.ts +0 -216
- package/src/InputFile.ts +0 -229
- package/src/Integrity.ts +0 -122
- package/src/Layout.ts +0 -103
- package/src/Manifest.ts +0 -133
- package/src/Options.ts +0 -123
- package/src/Platform.ts +0 -182
- package/src/Progress.ts +0 -106
- package/src/Registry.ts +0 -121
- package/src/Resolve.ts +0 -528
- package/src/Spec.ts +0 -186
package/dist/index.js
CHANGED
|
@@ -1,585 +1,396 @@
|
|
|
1
|
+
import { _ as OutputError, a as layerCallback, b as RegistryUnreachableError, c as ArchiveError, d as InvalidInputFileError, f as InvalidSpecError, g as NoMatchingVersionsError, h as LockfileOutOfDateError, i as emit, l as AuthenticationError, m as LockfileIncompleteError, n as createArchive, o as layerSilent, p as LockfileError, r as Progress, s as makeCollector, t as Archiver, u as IntegrityError, v as PackageNotFoundError, x as VersionNotFoundError, y as RegistryResponseError } from "./archive-6sqTn1C4.js";
|
|
1
2
|
import * as Effect from "effect/Effect";
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Getter from "effect/SchemaGetter";
|
|
6
|
+
import semver from "semver";
|
|
4
7
|
import * as Context from "effect/Context";
|
|
5
|
-
import * as
|
|
6
|
-
import * as
|
|
8
|
+
import * as Crypto from "effect/Crypto";
|
|
9
|
+
import * as Encoding from "effect/Encoding";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
import * as FileSystem from "effect/FileSystem";
|
|
7
12
|
import * as Path from "effect/Path";
|
|
8
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
9
|
-
import semver from "semver";
|
|
10
13
|
|
|
11
|
-
//#region
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
get: all[name],
|
|
17
|
-
enumerable: true
|
|
18
|
-
});
|
|
19
|
-
return target;
|
|
14
|
+
//#region src/enums/artifact-kind.ts
|
|
15
|
+
/** What a bundle run wrote to the output directory. */
|
|
16
|
+
const ArtifactKind = {
|
|
17
|
+
Archive: "archive",
|
|
18
|
+
Directory: "directory"
|
|
20
19
|
};
|
|
21
20
|
|
|
22
21
|
//#endregion
|
|
23
|
-
//#region src/
|
|
24
|
-
var BundlerErrorBase = class extends Error {
|
|
25
|
-
constructor(message, options) {
|
|
26
|
-
super(message, options);
|
|
27
|
-
this.name = new.target.name;
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
/** A package spec on the command line or in an input file could not be parsed. */
|
|
31
|
-
var InvalidSpecError = class extends BundlerErrorBase {
|
|
32
|
-
_tag = "InvalidSpecError";
|
|
33
|
-
spec;
|
|
34
|
-
reason;
|
|
35
|
-
constructor(spec, reason) {
|
|
36
|
-
super(`Invalid package spec ${JSON.stringify(spec)}: ${reason}`);
|
|
37
|
-
this.spec = spec;
|
|
38
|
-
this.reason = reason;
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
/** `--file` pointed at something that is neither a usable package.json nor a spec list. */
|
|
42
|
-
var InvalidInputFileError = class extends BundlerErrorBase {
|
|
43
|
-
_tag = "InvalidInputFileError";
|
|
44
|
-
path;
|
|
45
|
-
reason;
|
|
46
|
-
constructor(path, reason, options) {
|
|
47
|
-
super(`Cannot read specs from ${path}: ${reason}`, options);
|
|
48
|
-
this.path = path;
|
|
49
|
-
this.reason = reason;
|
|
50
|
-
}
|
|
51
|
-
};
|
|
22
|
+
//#region src/enums/edge-kind.ts
|
|
52
23
|
/**
|
|
53
|
-
*
|
|
54
|
-
* proxy blackhole, or a preflight that timed out.
|
|
24
|
+
* Why one package depends on another.
|
|
55
25
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
26
|
+
* `EdgeKind` and `LockedRootKind` are one concept in two parts, which is why
|
|
27
|
+
* they share a file: `Dev` exists only at the top level, because lockfiles
|
|
28
|
+
* record dev dependencies for the project alone — a transitive devDependency is
|
|
29
|
+
* never installed, so it is never locked.
|
|
58
30
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
constructor(registry, detail, options) {
|
|
64
|
-
super(`Cannot reach registry ${registry}: ${detail}.\n Check your network connection, VPN, and the proxy settings in your .npmrc.`, options);
|
|
65
|
-
this.registry = registry;
|
|
66
|
-
this.timeoutMillis = options?.timeoutMillis;
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
/** The registry answered, but has never heard of this package. */
|
|
70
|
-
var PackageNotFoundError = class extends BundlerErrorBase {
|
|
71
|
-
_tag = "PackageNotFoundError";
|
|
72
|
-
packageName;
|
|
73
|
-
registry;
|
|
74
|
-
constructor(packageName, registry) {
|
|
75
|
-
super(`Package "${packageName}" was not found on ${registry}`);
|
|
76
|
-
this.packageName = packageName;
|
|
77
|
-
this.registry = registry;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
/** The package exists but the exact version requested does not. */
|
|
81
|
-
var VersionNotFoundError = class extends BundlerErrorBase {
|
|
82
|
-
_tag = "VersionNotFoundError";
|
|
83
|
-
packageName;
|
|
84
|
-
version;
|
|
85
|
-
available;
|
|
86
|
-
constructor(packageName, version, available) {
|
|
87
|
-
const tail = available.slice(-5).join(", ");
|
|
88
|
-
super(`${packageName}@${version} does not exist.` + (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""));
|
|
89
|
-
this.packageName = packageName;
|
|
90
|
-
this.version = version;
|
|
91
|
-
this.available = available;
|
|
92
|
-
}
|
|
31
|
+
const EdgeKind = {
|
|
32
|
+
Prod: "prod",
|
|
33
|
+
Optional: "optional",
|
|
34
|
+
Peer: "peer"
|
|
93
35
|
};
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
constructor(packageName, selector, available) {
|
|
101
|
-
const tail = available.slice(-5).join(", ");
|
|
102
|
-
super(`No published version of ${packageName} satisfies "${selector}".` + (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""));
|
|
103
|
-
this.packageName = packageName;
|
|
104
|
-
this.selector = selector;
|
|
105
|
-
this.available = available;
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
/** The registry responded, but with something we cannot use. */
|
|
109
|
-
var RegistryResponseError = class extends BundlerErrorBase {
|
|
110
|
-
_tag = "RegistryResponseError";
|
|
111
|
-
url;
|
|
112
|
-
status;
|
|
113
|
-
constructor(url, detail, options) {
|
|
114
|
-
super(`Unexpected response from ${url}: ${detail}`, options);
|
|
115
|
-
this.url = url;
|
|
116
|
-
this.status = options?.status;
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
/** 401/403 — almost always a missing or stale token in `.npmrc`. */
|
|
120
|
-
var AuthenticationError = class extends BundlerErrorBase {
|
|
121
|
-
_tag = "AuthenticationError";
|
|
122
|
-
registry;
|
|
123
|
-
status;
|
|
124
|
-
constructor(registry, status, packageName) {
|
|
125
|
-
super(`Registry ${registry} rejected the request with HTTP ${status}` + (packageName ? ` while fetching "${packageName}"` : "") + `.\n Add credentials to your .npmrc, e.g.\n //${safeHost(registry)}/:_authToken=\${NPM_TOKEN}`);
|
|
126
|
-
this.registry = registry;
|
|
127
|
-
this.status = status;
|
|
128
|
-
}
|
|
36
|
+
/** The kind that only a direct dependency of the locked project can have. */
|
|
37
|
+
const DirectOnlyKind = { Dev: "dev" };
|
|
38
|
+
/** Why a direct dependency of the locked project is a root. */
|
|
39
|
+
const LockedRootKind = {
|
|
40
|
+
...EdgeKind,
|
|
41
|
+
...DirectOnlyKind
|
|
129
42
|
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
packageName;
|
|
139
|
-
version;
|
|
140
|
-
expected;
|
|
141
|
-
actual;
|
|
142
|
-
constructor(packageName, version, expected, actual) {
|
|
143
|
-
super(`Integrity check failed for ${packageName}@${version}.\n expected: ${expected}\n actual: ${actual}\n The download was discarded. This is either corruption in transit or a tampered artifact.`);
|
|
144
|
-
this.packageName = packageName;
|
|
145
|
-
this.version = version;
|
|
146
|
-
this.expected = expected;
|
|
147
|
-
this.actual = actual;
|
|
148
|
-
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/enums/input-file-kind.ts
|
|
46
|
+
/** Which of the three shapes a `--file` input turned out to be. */
|
|
47
|
+
const InputFileKind = {
|
|
48
|
+
PackageJson: "package.json",
|
|
49
|
+
Lockfile: "lockfile",
|
|
50
|
+
List: "list"
|
|
149
51
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/enums/layout.ts
|
|
55
|
+
/** How the resulting tarball(s) are shaped. */
|
|
56
|
+
const Layout = {
|
|
57
|
+
PerSpec: "per-spec",
|
|
58
|
+
Single: "single",
|
|
59
|
+
Dir: "dir"
|
|
158
60
|
};
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
61
|
+
const Layouts = Object.values(Layout);
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/enums/lockfile-format.ts
|
|
65
|
+
/** Which package manager wrote a lockfile. */
|
|
66
|
+
const LockfileFormat = {
|
|
67
|
+
Npm: "npm",
|
|
68
|
+
Pnpm: "pnpm",
|
|
69
|
+
Bun: "bun"
|
|
167
70
|
};
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/enums/phase.ts
|
|
74
|
+
/** The phases a bundle run moves through, in order. */
|
|
75
|
+
const Phase = {
|
|
76
|
+
Preflight: "preflight",
|
|
77
|
+
Resolve: "resolve",
|
|
78
|
+
Download: "download",
|
|
79
|
+
Archive: "archive",
|
|
80
|
+
Done: "done"
|
|
175
81
|
};
|
|
176
82
|
|
|
177
83
|
//#endregion
|
|
178
|
-
//#region src/
|
|
179
|
-
var Progress_exports = /* @__PURE__ */ __export({
|
|
180
|
-
Progress: () => Progress,
|
|
181
|
-
emit: () => emit,
|
|
182
|
-
layerCallback: () => layerCallback,
|
|
183
|
-
layerSilent: () => layerSilent,
|
|
184
|
-
makeCollector: () => makeCollector
|
|
185
|
-
});
|
|
186
|
-
/** Service tag for progress reporting. */
|
|
187
|
-
var Progress = class extends Context.Service()("@packall/core/Progress") {};
|
|
188
|
-
/** Emits an event to whichever reporter is installed. */
|
|
189
|
-
const emit = (event) => Effect.gen(function* () {
|
|
190
|
-
yield* (yield* Progress).emit(event);
|
|
191
|
-
});
|
|
84
|
+
//#region src/utils/is-record.ts
|
|
192
85
|
/**
|
|
193
|
-
*
|
|
86
|
+
* Narrows an unknown value to a plain object.
|
|
194
87
|
*
|
|
195
|
-
* The
|
|
196
|
-
*
|
|
88
|
+
* The entry point for reading anything the tool does not control — lockfiles,
|
|
89
|
+
* package.json files, registry responses — without reaching for a cast.
|
|
197
90
|
*/
|
|
198
|
-
const
|
|
199
|
-
/** Sends every event to a callback. Used by the CLI renderer. */
|
|
200
|
-
const layerCallback = (onEvent) => Layer.succeed(Progress)({ emit: (event) => Effect.sync(() => onEvent(event)) });
|
|
201
|
-
/**
|
|
202
|
-
* Accumulates every event into a `Ref`, for assertions.
|
|
203
|
-
*
|
|
204
|
-
* Returned as `[layer, ref]` so a test can provide the layer and then read the
|
|
205
|
-
* transcript afterwards.
|
|
206
|
-
*/
|
|
207
|
-
const makeCollector = Effect.gen(function* () {
|
|
208
|
-
const ref = yield* Ref.make([]);
|
|
209
|
-
return {
|
|
210
|
-
layer: Layer.succeed(Progress)({ emit: (event) => Ref.update(ref, (events) => [...events, event]) }),
|
|
211
|
-
events: Ref.get(ref)
|
|
212
|
-
};
|
|
213
|
-
});
|
|
91
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
214
92
|
|
|
215
93
|
//#endregion
|
|
216
|
-
//#region src/
|
|
217
|
-
var Archive_exports = /* @__PURE__ */ __export({ createArchive: () => createArchive });
|
|
94
|
+
//#region src/schemas/lenient.ts
|
|
218
95
|
/**
|
|
219
|
-
*
|
|
96
|
+
* An optional field that goes *absent* rather than failing when it is
|
|
97
|
+
* malformed.
|
|
220
98
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
99
|
+
* This is the difference between "we do not understand this field" and "we do
|
|
100
|
+
* not understand this document". An `os: "linux"` where an array was expected
|
|
101
|
+
* costs the platform filter one signal; it should not cost the run the package.
|
|
224
102
|
*/
|
|
225
|
-
const
|
|
226
|
-
const fs = yield* FileSystem.FileSystem;
|
|
227
|
-
yield* emit({
|
|
228
|
-
_tag: "ArchiveStarted",
|
|
229
|
-
path: options.outPath,
|
|
230
|
-
entryCount: options.entries.length
|
|
231
|
-
});
|
|
232
|
-
yield* Effect.tryPromise({
|
|
233
|
-
try: () => create({
|
|
234
|
-
gzip: options.gzipLevel === void 0 ? true : { level: options.gzipLevel },
|
|
235
|
-
file: options.outPath,
|
|
236
|
-
cwd: options.cwd,
|
|
237
|
-
portable: true,
|
|
238
|
-
onWriteEntry: (entry) => {
|
|
239
|
-
entry.path = entry.path.replace(/^\.\//, "");
|
|
240
|
-
}
|
|
241
|
-
}, [...options.entries].sort().map(dotSlash)),
|
|
242
|
-
catch: (cause) => new ArchiveError(options.outPath, describe$1(cause), { cause })
|
|
243
|
-
});
|
|
244
|
-
const info = yield* fs.stat(options.outPath);
|
|
245
|
-
const bytes = Number(info.size);
|
|
246
|
-
yield* emit({
|
|
247
|
-
_tag: "ArchiveCompleted",
|
|
248
|
-
path: options.outPath,
|
|
249
|
-
bytes
|
|
250
|
-
});
|
|
251
|
-
return {
|
|
252
|
-
path: options.outPath,
|
|
253
|
-
bytes
|
|
254
|
-
};
|
|
255
|
-
});
|
|
103
|
+
const tolerant = (schema) => Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(void 0)))));
|
|
256
104
|
/**
|
|
257
|
-
*
|
|
105
|
+
* Keeps only the string-valued members of an untyped object.
|
|
258
106
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
107
|
+
* `{ "lodash": "^4.0.0", "broken": 3 }` decodes to `{ "lodash": "^4.0.0" }`.
|
|
108
|
+
* Per-entry rather than whole-record tolerance, because one unreadable edge in
|
|
109
|
+
* a dependency block is no reason to forget the others.
|
|
110
|
+
*/
|
|
111
|
+
const StringRecordSchema = Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.decodeTo(Schema.Record(Schema.String, Schema.String), {
|
|
112
|
+
decode: Getter.transform((raw) => {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const [key, value] of Object.entries(raw)) if (typeof value === "string") out[key] = value;
|
|
115
|
+
return out;
|
|
116
|
+
}),
|
|
117
|
+
encode: Getter.passthroughSubtype()
|
|
118
|
+
}));
|
|
119
|
+
/** The same per-entry tolerance for a list: `["linux", 7]` decodes to `["linux"]`. */
|
|
120
|
+
const StringArraySchema = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Schema.Array(Schema.String), {
|
|
121
|
+
decode: Getter.transform((raw) => raw.flatMap((entry) => typeof entry === "string" ? entry : [])),
|
|
122
|
+
encode: Getter.passthroughSubtype()
|
|
123
|
+
}));
|
|
124
|
+
/**
|
|
125
|
+
* `peerDependenciesMeta`, normalised.
|
|
270
126
|
*
|
|
271
|
-
*
|
|
127
|
+
* Only `optional` is read, and only its `true` is meaningful — npm writes the
|
|
128
|
+
* flag as a boolean, a hand-edited file may carry anything, and every other
|
|
129
|
+
* value means "not optional".
|
|
272
130
|
*/
|
|
273
|
-
const
|
|
274
|
-
|
|
131
|
+
const OptionalFlagRecordSchema = Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.decodeTo(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.Boolean })), {
|
|
132
|
+
decode: Getter.transform((raw) => {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [key, value] of Object.entries(raw)) if (isRecord(value)) out[key] = { optional: value["optional"] === true };
|
|
135
|
+
return out;
|
|
136
|
+
}),
|
|
137
|
+
encode: Getter.passthroughSubtype()
|
|
138
|
+
}));
|
|
139
|
+
const absentWhenEmpty = (value) => Object.keys(value).length > 0 ? value : void 0;
|
|
140
|
+
const OptionalStringRecordSchema = StringRecordSchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.String)), {
|
|
141
|
+
decode: Getter.transform(absentWhenEmpty),
|
|
142
|
+
encode: Getter.transform((value) => value ?? {})
|
|
143
|
+
}));
|
|
144
|
+
const OptionalStringArraySchema = StringArraySchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Array(Schema.String)), {
|
|
145
|
+
decode: Getter.transform((value) => value.length > 0 ? value : void 0),
|
|
146
|
+
encode: Getter.transform((value) => value ?? [])
|
|
147
|
+
}));
|
|
148
|
+
const OptionalFlagRecordOrAbsentSchema = OptionalFlagRecordSchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.Boolean }))), {
|
|
149
|
+
decode: Getter.transform(absentWhenEmpty),
|
|
150
|
+
encode: Getter.transform((value) => value ?? {})
|
|
151
|
+
}));
|
|
275
152
|
|
|
276
153
|
//#endregion
|
|
277
|
-
//#region src/
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
154
|
+
//#region src/spec.ts
|
|
155
|
+
const MAX_NAME_LENGTH = 214;
|
|
156
|
+
const SEGMENT_RE = /^[a-z0-9\-._~]+$/;
|
|
157
|
+
const DIST_TAG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
158
|
+
/** Renders a spec back into the canonical `name@selector` form. */
|
|
159
|
+
const formatSpec = (spec) => `${spec.name}@${formatSelector(spec.selector)}`;
|
|
160
|
+
/** Human-readable form of just the selector part. */
|
|
161
|
+
const formatSelector = (selector) => {
|
|
162
|
+
switch (selector._tag) {
|
|
163
|
+
case "Exact": return selector.version;
|
|
164
|
+
case "Range": return selector.range;
|
|
165
|
+
case "Tag": return selector.tag;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
290
168
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* npm permits several space-separated hashes; we keep every one we can verify
|
|
294
|
-
* and ignore algorithms Node does not implement.
|
|
169
|
+
* Validates an npm package name against the rules the registry actually
|
|
170
|
+
* enforces. Returns `null` when valid, or the reason it is not.
|
|
295
171
|
*/
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
172
|
+
const validatePackageName = (name) => {
|
|
173
|
+
if (name.length === 0) return "name is empty";
|
|
174
|
+
if (name.length > MAX_NAME_LENGTH) return `name is longer than ${MAX_NAME_LENGTH} characters`;
|
|
175
|
+
if (name.trim() !== name) return "name has leading or trailing whitespace";
|
|
176
|
+
if (name.startsWith(".")) return "name cannot start with a period";
|
|
177
|
+
if (name.startsWith("_")) return "name cannot start with an underscore";
|
|
178
|
+
if (name !== name.toLowerCase()) return "name cannot contain capital letters";
|
|
179
|
+
if (name.startsWith("@")) {
|
|
180
|
+
const slash = name.indexOf("/");
|
|
181
|
+
if (slash === -1) return "scoped name is missing the '/name' part";
|
|
182
|
+
const scope = name.slice(1, slash);
|
|
183
|
+
const rest = name.slice(slash + 1);
|
|
184
|
+
if (scope.length === 0) return "scope is empty";
|
|
185
|
+
if (rest.length === 0) return "name after the scope is empty";
|
|
186
|
+
if (rest.includes("/")) return "name contains more than one '/'";
|
|
187
|
+
if (!SEGMENT_RE.test(scope)) return `scope "${scope}" contains illegal characters`;
|
|
188
|
+
if (!SEGMENT_RE.test(rest)) return `name "${rest}" contains illegal characters`;
|
|
189
|
+
return null;
|
|
309
190
|
}
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const digestOf = (data, algorithm) => createHash(algorithm).update(data).digest("base64");
|
|
314
|
-
/** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
|
|
315
|
-
const hexDigestOf = (data, algorithm) => createHash(algorithm).update(data).digest("hex");
|
|
316
|
-
/** Constant-time comparison of two digest strings of equal length. */
|
|
317
|
-
const digestsEqual = (a, b) => {
|
|
318
|
-
const left = Buffer.from(a);
|
|
319
|
-
const right = Buffer.from(b);
|
|
320
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
191
|
+
if (name.includes("/")) return "unscoped name cannot contain '/'";
|
|
192
|
+
if (!SEGMENT_RE.test(name)) return "name contains illegal characters";
|
|
193
|
+
return null;
|
|
321
194
|
};
|
|
322
195
|
/**
|
|
323
|
-
*
|
|
196
|
+
* Parses a single spec string.
|
|
324
197
|
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* as `Unverifiable` rather than silently treated as a pass — the caller decides
|
|
328
|
-
* whether to tolerate it.
|
|
198
|
+
* Splitting on `@` is the fiddly part: a scoped name *starts* with `@`, so we
|
|
199
|
+
* look for the last `@` that is not at index 0.
|
|
329
200
|
*/
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
if (dist.shasum !== void 0 && dist.shasum.length > 0) {
|
|
349
|
-
const actual = hexDigestOf(data, "sha1");
|
|
350
|
-
return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase()) ? {
|
|
351
|
-
_tag: "Verified",
|
|
352
|
-
using: "sha1"
|
|
353
|
-
} : {
|
|
354
|
-
_tag: "Mismatch",
|
|
355
|
-
expected: `sha1-${dist.shasum}`,
|
|
356
|
-
actual: `sha1-${actual}`
|
|
357
|
-
};
|
|
358
|
-
}
|
|
201
|
+
const parseSpec = (raw) => {
|
|
202
|
+
const input = raw.trim();
|
|
203
|
+
if (input.length === 0) throw new InvalidSpecError(raw, "spec is empty");
|
|
204
|
+
const at = input.lastIndexOf("@");
|
|
205
|
+
const hasSelector = at > 0;
|
|
206
|
+
const name = hasSelector ? input.slice(0, at) : input;
|
|
207
|
+
const selectorText = hasSelector ? input.slice(at + 1) : "";
|
|
208
|
+
const nameProblem = validatePackageName(name);
|
|
209
|
+
if (nameProblem !== null) throw new InvalidSpecError(raw, nameProblem);
|
|
210
|
+
if (!hasSelector || selectorText.length === 0) return {
|
|
211
|
+
name,
|
|
212
|
+
selector: {
|
|
213
|
+
_tag: "Tag",
|
|
214
|
+
tag: "latest"
|
|
215
|
+
},
|
|
216
|
+
raw: input
|
|
217
|
+
};
|
|
359
218
|
return {
|
|
360
|
-
|
|
361
|
-
|
|
219
|
+
name,
|
|
220
|
+
selector: parseSelector(raw, selectorText),
|
|
221
|
+
raw: input
|
|
362
222
|
};
|
|
363
223
|
};
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
//#endregion
|
|
374
|
-
//#region src/Layout.ts
|
|
375
|
-
var Layout_exports = /* @__PURE__ */ __export({
|
|
376
|
-
MANIFEST_FILE: () => MANIFEST_FILE,
|
|
377
|
-
README_FILE: () => README_FILE,
|
|
378
|
-
importGuide: () => importGuide,
|
|
379
|
-
packagePath: () => packagePath,
|
|
380
|
-
perSpecArchiveName: () => perSpecArchiveName,
|
|
381
|
-
singleArchiveName: () => singleArchiveName,
|
|
382
|
-
splitName: () => splitName,
|
|
383
|
-
tarballFileName: () => tarballFileName
|
|
384
|
-
});
|
|
385
|
-
/**
|
|
386
|
-
* The on-disk shape of a bundle.
|
|
387
|
-
*
|
|
388
|
-
* Everything is laid out exactly the way a registry serves it:
|
|
389
|
-
*
|
|
390
|
-
* lodash/-/lodash-4.17.21.tgz
|
|
391
|
-
* @babel/core/-/core-7.24.0.tgz
|
|
392
|
-
*
|
|
393
|
-
* That is not an Artifactory convention — it is the path structure in every
|
|
394
|
-
* `dist.tarball` URL npm publishes, which is why the same tree imports into
|
|
395
|
-
* Artifactory, Nexus and Verdaccio without translation. Note that a scoped
|
|
396
|
-
* package's file name drops the scope: `@babel/core` becomes `core-7.24.0.tgz`
|
|
397
|
-
* under an `@babel/core/-/` directory.
|
|
398
|
-
*/
|
|
399
|
-
/** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
|
|
400
|
-
const splitName = (name) => {
|
|
401
|
-
if (!name.startsWith("@")) return {
|
|
402
|
-
scope: void 0,
|
|
403
|
-
bare: name
|
|
224
|
+
/** Classifies the part after the `@` as an exact version, a range, or a dist-tag. */
|
|
225
|
+
const parseSelector = (raw, text) => {
|
|
226
|
+
const exact = semver.valid(text, { loose: false });
|
|
227
|
+
if (exact !== null) return {
|
|
228
|
+
_tag: "Exact",
|
|
229
|
+
version: exact
|
|
404
230
|
};
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
bare: name
|
|
231
|
+
if (semver.validRange(text, { loose: false }) !== null) return {
|
|
232
|
+
_tag: "Range",
|
|
233
|
+
range: text
|
|
409
234
|
};
|
|
235
|
+
if (!DIST_TAG_RE.test(text)) throw new InvalidSpecError(raw, `"${text}" is not a valid version, semver range, or dist-tag`);
|
|
410
236
|
return {
|
|
411
|
-
|
|
412
|
-
|
|
237
|
+
_tag: "Tag",
|
|
238
|
+
tag: text
|
|
413
239
|
};
|
|
414
240
|
};
|
|
415
|
-
/** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
|
|
416
|
-
const tarballFileName = (name, version) => {
|
|
417
|
-
const { bare } = splitName(name);
|
|
418
|
-
return `${bare}-${version}.tgz`;
|
|
419
|
-
};
|
|
420
|
-
/**
|
|
421
|
-
* The path of one package tarball within a bundle, using `/` separators
|
|
422
|
-
* regardless of host platform — these become tar entry names, and tar entries
|
|
423
|
-
* are always POSIX.
|
|
424
|
-
*/
|
|
425
|
-
const packagePath = (name, version) => `${name}/-/${tarballFileName(name, version)}`;
|
|
426
|
-
/** File name of the manifest that ships inside every bundle. */
|
|
427
|
-
const MANIFEST_FILE = "bundle-manifest.json";
|
|
428
|
-
/** File name of the short import guide that ships inside every bundle. */
|
|
429
|
-
const README_FILE = "IMPORT.md";
|
|
430
241
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* file name is not a thing, and `@` confuses enough shells and web UIs to be
|
|
435
|
-
* worth avoiding.
|
|
242
|
+
* Parses many specs, collecting *every* failure rather than stopping at the
|
|
243
|
+
* first. Somebody bundling forty packages should be told about all four typos
|
|
244
|
+
* in one go, not made to re-run four times.
|
|
436
245
|
*/
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
246
|
+
const parseSpecs = (inputs) => {
|
|
247
|
+
const specs = [];
|
|
248
|
+
const errors = [];
|
|
249
|
+
for (const input of inputs) try {
|
|
250
|
+
specs.push(parseSpec(input));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof InvalidSpecError) errors.push(error);
|
|
253
|
+
else throw error;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
specs,
|
|
257
|
+
errors
|
|
258
|
+
};
|
|
440
259
|
};
|
|
441
|
-
/** Name of the archive produced in `single` layout. */
|
|
442
|
-
const singleArchiveName = (base = "bundle") => `${base}.tgz`;
|
|
443
260
|
/**
|
|
444
|
-
*
|
|
261
|
+
* Collapses duplicate specs, keeping the first occurrence.
|
|
445
262
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
263
|
+
* `npmb react react@latest` is a plausible thing to type and should not
|
|
264
|
+
* download React twice.
|
|
448
265
|
*/
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
Unpack, then upload the tree into a **local npm** repository. The layout already
|
|
462
|
-
matches what Artifactory expects, so no path rewriting is needed:
|
|
463
|
-
|
|
464
|
-
tar -xzf <this-bundle>.tgz -C ./bundle
|
|
465
|
-
jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
|
|
466
|
-
|
|
467
|
-
## Verdaccio / Nexus / any npm registry
|
|
468
|
-
|
|
469
|
-
Publish each tarball individually:
|
|
470
|
-
|
|
471
|
-
find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
|
|
472
|
-
|
|
473
|
-
## Verifying before import
|
|
474
|
-
|
|
475
|
-
node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
|
|
476
|
-
|
|
477
|
-
Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
|
|
478
|
-
advertised, and each tarball was checked against it at download time.
|
|
479
|
-
`;
|
|
266
|
+
const dedupeSpecs = (specs) => {
|
|
267
|
+
const seen = /* @__PURE__ */ new Set();
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const spec of specs) {
|
|
270
|
+
const key = formatSpec(spec);
|
|
271
|
+
if (seen.has(key)) continue;
|
|
272
|
+
seen.add(key);
|
|
273
|
+
out.push(spec);
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
};
|
|
480
277
|
|
|
481
278
|
//#endregion
|
|
482
|
-
//#region src/
|
|
279
|
+
//#region src/dependency-range.ts
|
|
280
|
+
const UNSUPPORTED_PROTOCOLS = [
|
|
281
|
+
["file:", "local file path"],
|
|
282
|
+
["link:", "local link"],
|
|
283
|
+
["workspace:", "workspace protocol"],
|
|
284
|
+
["portal:", "portal protocol"],
|
|
285
|
+
["patch:", "patch protocol"],
|
|
286
|
+
["git:", "git dependency"],
|
|
287
|
+
["git+", "git dependency"],
|
|
288
|
+
["github:", "GitHub shorthand"],
|
|
289
|
+
["gitlab:", "GitLab shorthand"],
|
|
290
|
+
["bitbucket:", "Bitbucket shorthand"],
|
|
291
|
+
["http:", "remote tarball URL"],
|
|
292
|
+
["https:", "remote tarball URL"]
|
|
293
|
+
];
|
|
483
294
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* @example
|
|
487
|
-
* ```ts
|
|
488
|
-
* import { Effect } from "effect"
|
|
489
|
-
* import { Registry } from "@packall/core"
|
|
295
|
+
* Classifies one `name -> range` entry.
|
|
490
296
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* return yield* registry.packument("lodash")
|
|
494
|
-
* })
|
|
495
|
-
* ```
|
|
297
|
+
* An empty range, `*`, and `latest` all mean "any published version"; npm
|
|
298
|
+
* treats them interchangeably and so do we.
|
|
496
299
|
*/
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
300
|
+
const parseDependencyTarget = (name, raw) => {
|
|
301
|
+
const text = raw.trim();
|
|
302
|
+
if (text.length === 0 || text === "*" || text === "x" || text === "latest") return {
|
|
303
|
+
_tag: "Registry",
|
|
304
|
+
name,
|
|
305
|
+
selector: {
|
|
306
|
+
_tag: "Range",
|
|
307
|
+
range: "*"
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
if (text.startsWith("npm:")) return parseAlias(name, text);
|
|
311
|
+
for (const [prefix, reason] of UNSUPPORTED_PROTOCOLS) if (text.startsWith(prefix)) return {
|
|
312
|
+
_tag: "Unsupported",
|
|
313
|
+
name,
|
|
314
|
+
raw: text,
|
|
315
|
+
reason
|
|
316
|
+
};
|
|
317
|
+
if (semver.validRange(text, { loose: true }) === null && /^[\w.-]+\/[\w.-]+/.test(text)) return {
|
|
318
|
+
_tag: "Unsupported",
|
|
319
|
+
name,
|
|
320
|
+
raw: text,
|
|
321
|
+
reason: "git shorthand"
|
|
322
|
+
};
|
|
323
|
+
const exact = semver.valid(text, { loose: true });
|
|
324
|
+
if (exact !== null) return {
|
|
325
|
+
_tag: "Registry",
|
|
326
|
+
name,
|
|
327
|
+
selector: {
|
|
328
|
+
_tag: "Exact",
|
|
329
|
+
version: exact
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
if (semver.validRange(text, { loose: true }) !== null) return {
|
|
333
|
+
_tag: "Registry",
|
|
334
|
+
name,
|
|
335
|
+
selector: {
|
|
336
|
+
_tag: "Range",
|
|
337
|
+
range: text
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(text)) return {
|
|
341
|
+
_tag: "Registry",
|
|
342
|
+
name,
|
|
343
|
+
selector: {
|
|
344
|
+
_tag: "Tag",
|
|
345
|
+
tag: text
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
_tag: "Unsupported",
|
|
350
|
+
name,
|
|
351
|
+
raw: text,
|
|
352
|
+
reason: "unrecognised version specifier"
|
|
353
|
+
};
|
|
354
|
+
};
|
|
502
355
|
/**
|
|
503
|
-
*
|
|
504
|
-
* them.
|
|
356
|
+
* Parses `npm:<name>[@<range>]`.
|
|
505
357
|
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
358
|
+
* The nested name may itself be scoped, so the `@` split has the same
|
|
359
|
+
* "not at index 0" caveat as top-level spec parsing.
|
|
508
360
|
*/
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (result._tag === "Unverifiable") yield* emit({
|
|
533
|
-
_tag: "Warning",
|
|
534
|
-
message: `${key} could not be verified (${result.reason})`
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
const verifiable = (pkg.manifest.dist.integrity?.length ?? 0) > 0 || (pkg.manifest.dist.shasum?.length ?? 0) > 0;
|
|
538
|
-
const relative = packagePath(pkg.name, pkg.version);
|
|
539
|
-
const target = path.join(destDir, ...relative.split("/"));
|
|
540
|
-
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new OutputError(path.dirname(target), "could not create directory", { cause })));
|
|
541
|
-
yield* fs.writeFile(target, bytes).pipe(Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause })));
|
|
542
|
-
completed += 1;
|
|
543
|
-
yield* emit({
|
|
544
|
-
_tag: "DownloadCompleted",
|
|
545
|
-
name: pkg.name,
|
|
546
|
-
version: pkg.version,
|
|
547
|
-
bytes: bytes.byteLength,
|
|
548
|
-
completedCount: completed,
|
|
549
|
-
totalCount: packages.length
|
|
550
|
-
});
|
|
551
|
-
return {
|
|
552
|
-
key,
|
|
553
|
-
bytes: bytes.byteLength,
|
|
554
|
-
verifiable
|
|
555
|
-
};
|
|
556
|
-
}), { concurrency: options.concurrency });
|
|
557
|
-
for (const result of results) {
|
|
558
|
-
sizes.set(result.key, result.bytes);
|
|
559
|
-
if (!result.verifiable) unverified.push(result.key);
|
|
560
|
-
}
|
|
561
|
-
yield* emit({
|
|
562
|
-
_tag: "PhaseCompleted",
|
|
563
|
-
phase: "download"
|
|
564
|
-
});
|
|
361
|
+
const parseAlias = (key, text) => {
|
|
362
|
+
const body = text.slice(4);
|
|
363
|
+
if (body.length === 0) return {
|
|
364
|
+
_tag: "Unsupported",
|
|
365
|
+
name: key,
|
|
366
|
+
raw: text,
|
|
367
|
+
reason: "empty npm: alias"
|
|
368
|
+
};
|
|
369
|
+
const at = body.lastIndexOf("@");
|
|
370
|
+
const target = at > 0 ? body.slice(0, at) : body;
|
|
371
|
+
const rangeText = at > 0 ? body.slice(at + 1) : "";
|
|
372
|
+
if (target.length === 0) return {
|
|
373
|
+
_tag: "Unsupported",
|
|
374
|
+
name: key,
|
|
375
|
+
raw: text,
|
|
376
|
+
reason: "empty npm: alias target"
|
|
377
|
+
};
|
|
378
|
+
const inner = parseDependencyTarget(target, rangeText);
|
|
379
|
+
if (inner._tag === "Unsupported") return {
|
|
380
|
+
...inner,
|
|
381
|
+
name: key,
|
|
382
|
+
raw: text
|
|
383
|
+
};
|
|
565
384
|
return {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
385
|
+
_tag: "Registry",
|
|
386
|
+
name: target,
|
|
387
|
+
selector: inner.selector,
|
|
388
|
+
aliasOf: key
|
|
569
389
|
};
|
|
570
|
-
}
|
|
390
|
+
};
|
|
571
391
|
|
|
572
392
|
//#endregion
|
|
573
|
-
//#region src/
|
|
574
|
-
var Platform_exports = /* @__PURE__ */ __export({
|
|
575
|
-
allPlatforms: () => allPlatforms,
|
|
576
|
-
currentPlatform: () => currentPlatform,
|
|
577
|
-
formatPlatformFilter: () => formatPlatformFilter,
|
|
578
|
-
isIncluded: () => isIncluded,
|
|
579
|
-
matchesTarget: () => matchesTarget,
|
|
580
|
-
parsePlatformTarget: () => parsePlatformTarget,
|
|
581
|
-
platformTargets: () => platformTargets
|
|
582
|
-
});
|
|
393
|
+
//#region src/platform.ts
|
|
583
394
|
const allPlatforms = { _tag: "All" };
|
|
584
395
|
const platformTargets = (targets) => ({
|
|
585
396
|
_tag: "Targets",
|
|
@@ -598,7 +409,7 @@ const currentPlatform = () => ({
|
|
|
598
409
|
* and installs nothing. `--platform windows` silently matching no binding at
|
|
599
410
|
* all would be precisely that, so it is corrected rather than accepted.
|
|
600
411
|
*/
|
|
601
|
-
const
|
|
412
|
+
const osByAlias = {
|
|
602
413
|
win: "win32",
|
|
603
414
|
windows: "win32",
|
|
604
415
|
mac: "darwin",
|
|
@@ -632,27 +443,24 @@ const LIBC = new Set(["musl", "glibc"]);
|
|
|
632
443
|
* once alongside the list of accepted forms.
|
|
633
444
|
*/
|
|
634
445
|
const parsePlatformTarget = (input) => {
|
|
635
|
-
const parts = input.trim().toLowerCase().split("-").filter((
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const os =
|
|
446
|
+
const parts = input.trim().toLowerCase().split("-").filter((part) => part.length > 0);
|
|
447
|
+
const [rawOs, second, third] = parts;
|
|
448
|
+
if (rawOs === void 0 || parts.length > 3) return null;
|
|
449
|
+
const os = osByAlias[rawOs] ?? rawOs;
|
|
639
450
|
if (!KNOWN_OS.has(os)) return null;
|
|
640
|
-
if (
|
|
641
|
-
if (
|
|
451
|
+
if (second === void 0) return { os };
|
|
452
|
+
if (third === void 0) return LIBC.has(second) ? {
|
|
642
453
|
os,
|
|
643
|
-
libc:
|
|
644
|
-
}
|
|
645
|
-
const cpu = parts[1];
|
|
646
|
-
if (parts.length === 2) return {
|
|
454
|
+
libc: second
|
|
455
|
+
} : {
|
|
647
456
|
os,
|
|
648
|
-
cpu
|
|
457
|
+
cpu: second
|
|
649
458
|
};
|
|
650
|
-
|
|
651
|
-
if (!LIBC.has(libc)) return null;
|
|
459
|
+
if (!LIBC.has(third)) return null;
|
|
652
460
|
return {
|
|
653
461
|
os,
|
|
654
|
-
cpu,
|
|
655
|
-
libc
|
|
462
|
+
cpu: second,
|
|
463
|
+
libc: third
|
|
656
464
|
};
|
|
657
465
|
};
|
|
658
466
|
/**
|
|
@@ -698,168 +506,299 @@ const isIncluded = (constraints, filter) => {
|
|
|
698
506
|
/** Renders a filter for the manifest and for `--dry-run` output. */
|
|
699
507
|
const formatPlatformFilter = (filter) => {
|
|
700
508
|
if (filter._tag === "All") return "all";
|
|
701
|
-
return filter.targets.map((
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
509
|
+
return filter.targets.map((target) => [
|
|
510
|
+
target.os,
|
|
511
|
+
target.cpu,
|
|
512
|
+
target.libc
|
|
705
513
|
].filter((part) => part !== void 0).join("-")).join(",");
|
|
706
514
|
};
|
|
707
515
|
|
|
708
516
|
//#endregion
|
|
709
|
-
//#region src/
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
parseSelector: () => parseSelector,
|
|
715
|
-
parseSpec: () => parseSpec,
|
|
716
|
-
parseSpecs: () => parseSpecs,
|
|
717
|
-
validatePackageName: () => validatePackageName
|
|
718
|
-
});
|
|
719
|
-
/** Renders a spec back into the canonical `name@selector` form. */
|
|
720
|
-
const formatSpec = (spec) => {
|
|
721
|
-
switch (spec.selector._tag) {
|
|
722
|
-
case "Exact": return `${spec.name}@${spec.selector.version}`;
|
|
723
|
-
case "Range": return `${spec.name}@${spec.selector.range}`;
|
|
724
|
-
case "Tag": return `${spec.name}@${spec.selector.tag}`;
|
|
725
|
-
}
|
|
517
|
+
//#region src/options.ts
|
|
518
|
+
const defaultScope = {
|
|
519
|
+
optional: true,
|
|
520
|
+
peer: true,
|
|
521
|
+
platforms: allPlatforms
|
|
726
522
|
};
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
case "Tag": return selector.tag;
|
|
733
|
-
}
|
|
523
|
+
const defaultResolveOptions = {
|
|
524
|
+
scope: defaultScope,
|
|
525
|
+
allVersions: false,
|
|
526
|
+
includePrerelease: false,
|
|
527
|
+
concurrency: 10
|
|
734
528
|
};
|
|
735
|
-
const
|
|
529
|
+
const defaultBundleOptions = {
|
|
530
|
+
...defaultResolveOptions,
|
|
531
|
+
layout: Layout.PerSpec,
|
|
532
|
+
dryRun: false,
|
|
533
|
+
verifyIntegrity: true,
|
|
534
|
+
force: false
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/registry.ts
|
|
736
539
|
/**
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
540
|
+
* Service tag for the registry backend.
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* ```ts
|
|
544
|
+
* import { Effect } from "effect"
|
|
545
|
+
* import { Registry } from "@packall/core"
|
|
546
|
+
*
|
|
547
|
+
* const program = Effect.gen(function*() {
|
|
548
|
+
* const registry = yield* Registry
|
|
549
|
+
* return yield* registry.packument("lodash")
|
|
550
|
+
* })
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
var Registry = class extends Context.Service()("@packall/core/Registry") {};
|
|
554
|
+
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/integrity.ts
|
|
557
|
+
/**
|
|
558
|
+
* The digests npm publishes, mapped to what `Crypto.digest` calls them.
|
|
559
|
+
*
|
|
560
|
+
* Doubles as the supported-algorithm list: an SRI token whose algorithm is not
|
|
561
|
+
* a key here is one we cannot check, and is dropped during parsing.
|
|
562
|
+
*/
|
|
563
|
+
const digestAlgorithmByName = {
|
|
564
|
+
sha512: "SHA-512",
|
|
565
|
+
sha384: "SHA-384",
|
|
566
|
+
sha256: "SHA-256",
|
|
567
|
+
sha1: "SHA-1"
|
|
568
|
+
};
|
|
569
|
+
const isSupported = (algorithm) => Object.hasOwn(digestAlgorithmByName, algorithm);
|
|
570
|
+
/**
|
|
571
|
+
* Parses an SRI string such as `sha512-abc...==`.
|
|
572
|
+
*
|
|
573
|
+
* npm permits several space-separated hashes; we keep every one we can verify
|
|
574
|
+
* and ignore algorithms we cannot.
|
|
575
|
+
*/
|
|
576
|
+
const parseIntegrity = (integrity) => {
|
|
577
|
+
const hashes = [];
|
|
578
|
+
for (const token of integrity.trim().split(/\s+/)) {
|
|
579
|
+
if (token.length === 0) continue;
|
|
580
|
+
const dash = token.indexOf("-");
|
|
581
|
+
if (dash <= 0) continue;
|
|
582
|
+
const algorithm = token.slice(0, dash).toLowerCase();
|
|
583
|
+
const digest = token.slice(dash + 1);
|
|
584
|
+
if (!isSupported(algorithm) || digest.length === 0) continue;
|
|
585
|
+
hashes.push({
|
|
586
|
+
algorithm,
|
|
587
|
+
digest
|
|
588
|
+
});
|
|
758
589
|
}
|
|
759
|
-
|
|
760
|
-
if (!SEGMENT_RE.test(name)) return "name contains illegal characters";
|
|
761
|
-
return null;
|
|
590
|
+
return hashes;
|
|
762
591
|
};
|
|
763
|
-
const
|
|
592
|
+
const digestBytes = (data, algorithm) => Effect.gen(function* () {
|
|
593
|
+
return yield* (yield* Crypto.Crypto).digest(digestAlgorithmByName[algorithm], data);
|
|
594
|
+
});
|
|
595
|
+
/** Computes the base64 digest of some bytes under one algorithm. */
|
|
596
|
+
const digestOf = (data, algorithm) => Effect.map(digestBytes(data, algorithm), Encoding.encodeBase64);
|
|
597
|
+
/** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
|
|
598
|
+
const hexDigestOf = (data, algorithm) => Effect.map(digestBytes(data, algorithm), Encoding.encodeHex);
|
|
764
599
|
/**
|
|
765
|
-
*
|
|
600
|
+
* Constant-time comparison of two digest strings.
|
|
766
601
|
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
602
|
+
* Written by hand rather than with `node:crypto`'s `timingSafeEqual`, which
|
|
603
|
+
* needs `Buffer` and would pin this module to Node. Both digests here are
|
|
604
|
+
* public knowledge, so the timing property is close to theatre — but it costs
|
|
605
|
+
* four lines, and the alternative is explaining in a review why it was dropped.
|
|
769
606
|
*/
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
607
|
+
const digestsEqual = (a, b) => {
|
|
608
|
+
if (a.length !== b.length) return false;
|
|
609
|
+
let difference = 0;
|
|
610
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
611
|
+
return difference === 0;
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Checks bytes against whichever checksums the registry supplied.
|
|
615
|
+
*
|
|
616
|
+
* SRI is preferred; `shasum` is the fallback for versions published before
|
|
617
|
+
* integrity strings existed. If the registry gave us neither, that is reported
|
|
618
|
+
* as `Unverifiable` rather than silently treated as a pass — the caller decides
|
|
619
|
+
* whether to tolerate it.
|
|
620
|
+
*/
|
|
621
|
+
const verify = (data, dist) => Effect.gen(function* () {
|
|
622
|
+
if (dist.integrity !== void 0 && dist.integrity.length > 0) {
|
|
623
|
+
const [best] = parseIntegrity(dist.integrity).toSorted((a, b) => strengthByAlgorithm[b.algorithm] - strengthByAlgorithm[a.algorithm]);
|
|
624
|
+
if (best === void 0) return {
|
|
625
|
+
_tag: "Unverifiable",
|
|
626
|
+
reason: `no supported algorithm in "${dist.integrity}"`
|
|
627
|
+
};
|
|
628
|
+
const actual = yield* digestOf(data, best.algorithm);
|
|
629
|
+
return digestsEqual(actual, best.digest) ? {
|
|
630
|
+
_tag: "Verified",
|
|
631
|
+
using: best.algorithm
|
|
632
|
+
} : {
|
|
633
|
+
_tag: "Mismatch",
|
|
634
|
+
expected: `${best.algorithm}-${best.digest}`,
|
|
635
|
+
actual: `${best.algorithm}-${actual}`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (dist.shasum !== void 0 && dist.shasum.length > 0) {
|
|
639
|
+
const actual = yield* hexDigestOf(data, "sha1");
|
|
640
|
+
return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase()) ? {
|
|
641
|
+
_tag: "Verified",
|
|
642
|
+
using: "sha1"
|
|
643
|
+
} : {
|
|
644
|
+
_tag: "Mismatch",
|
|
645
|
+
expected: `sha1-${dist.shasum}`,
|
|
646
|
+
actual: `sha1-${actual}`
|
|
647
|
+
};
|
|
648
|
+
}
|
|
787
649
|
return {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
raw: input
|
|
650
|
+
_tag: "Unverifiable",
|
|
651
|
+
reason: "registry advertised no integrity or shasum"
|
|
791
652
|
};
|
|
653
|
+
});
|
|
654
|
+
const strengthByAlgorithm = {
|
|
655
|
+
sha512: 4,
|
|
656
|
+
sha384: 3,
|
|
657
|
+
sha256: 2,
|
|
658
|
+
sha1: 1
|
|
792
659
|
};
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
660
|
+
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/layout.ts
|
|
663
|
+
/**
|
|
664
|
+
* The on-disk shape of a bundle.
|
|
665
|
+
*
|
|
666
|
+
* Everything is laid out exactly the way a registry serves it:
|
|
667
|
+
*
|
|
668
|
+
* lodash/-/lodash-4.17.21.tgz
|
|
669
|
+
* @babel/core/-/core-7.24.0.tgz
|
|
670
|
+
*
|
|
671
|
+
* That is not an Artifactory convention — it is the path structure in every
|
|
672
|
+
* `dist.tarball` URL npm publishes. See `notes/requirements.md` § FR-12 for
|
|
673
|
+
* what that buys.
|
|
674
|
+
*
|
|
675
|
+
* The one shape worth noticing before editing anything here: a scoped
|
|
676
|
+
* package's file name drops the scope, so `@babel/core` becomes
|
|
677
|
+
* `core-7.24.0.tgz` under an `@babel/core/-/` directory.
|
|
678
|
+
*/
|
|
679
|
+
/** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
|
|
680
|
+
const splitName = (name) => {
|
|
681
|
+
if (!name.startsWith("@")) return {
|
|
682
|
+
scope: void 0,
|
|
683
|
+
bare: name
|
|
799
684
|
};
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
685
|
+
const slash = name.indexOf("/");
|
|
686
|
+
if (slash === -1) return {
|
|
687
|
+
scope: void 0,
|
|
688
|
+
bare: name
|
|
803
689
|
};
|
|
804
|
-
if (!DIST_TAG_RE.test(text)) throw new InvalidSpecError(raw, `"${text}" is not a valid version, semver range, or dist-tag`);
|
|
805
690
|
return {
|
|
806
|
-
|
|
807
|
-
|
|
691
|
+
scope: name.slice(0, slash),
|
|
692
|
+
bare: name.slice(slash + 1)
|
|
808
693
|
};
|
|
809
694
|
};
|
|
810
|
-
|
|
695
|
+
/** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
|
|
696
|
+
const tarballFileName = (name, version) => {
|
|
697
|
+
const { bare } = splitName(name);
|
|
698
|
+
return `${bare}-${version}.tgz`;
|
|
699
|
+
};
|
|
811
700
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
701
|
+
* The path of one package tarball within a bundle, using `/` separators
|
|
702
|
+
* regardless of host platform — these become tar entry names, and tar entries
|
|
703
|
+
* are always POSIX.
|
|
815
704
|
*/
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
} catch (error) {
|
|
822
|
-
if (error instanceof InvalidSpecError) errors.push(error);
|
|
823
|
-
else throw error;
|
|
824
|
-
}
|
|
825
|
-
return {
|
|
826
|
-
specs,
|
|
827
|
-
errors
|
|
828
|
-
};
|
|
829
|
-
};
|
|
705
|
+
const packagePath = (name, version) => `${name}/-/${tarballFileName(name, version)}`;
|
|
706
|
+
/** File name of the manifest that ships inside every bundle. */
|
|
707
|
+
const MANIFEST_FILE = "bundle-manifest.json";
|
|
708
|
+
/** File name of the short import guide that ships inside every bundle. */
|
|
709
|
+
const README_FILE = "IMPORT.md";
|
|
830
710
|
/**
|
|
831
|
-
*
|
|
711
|
+
* Name of the archive produced for one root spec in `per-spec` layout.
|
|
832
712
|
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
713
|
+
* It is the spec, with the one character a file name cannot hold taken out:
|
|
714
|
+
* `@types/react@19.2.18` becomes `@types-react@19.2.18.tgz`. So the name of the
|
|
715
|
+
* file answers the question somebody has when they are looking at a directory
|
|
716
|
+
* of them — which package, and which version — in the notation they would have
|
|
717
|
+
* typed to ask for it.
|
|
718
|
+
*
|
|
719
|
+
* The version is joined with `@` rather than `-` for the same reason. A
|
|
720
|
+
* hyphen made `foo-bar-1.0.0` ambiguous by eye: package `foo` at `bar-1.0.0`,
|
|
721
|
+
* or `foo-bar` at `1.0.0`? npm's own syntax has no such problem, because a
|
|
722
|
+
* package name may not contain `@` beyond its scope.
|
|
723
|
+
*
|
|
724
|
+
* **The scope's `@` stays**, and it is the difference between a correct bundle
|
|
725
|
+
* and a quietly incomplete one.
|
|
726
|
+
*
|
|
727
|
+
* Dropping it — which this did, matching what `npm pack` produces — meant
|
|
728
|
+
* `@something/somewhat` and `something-somewhat` were both
|
|
729
|
+
* `something-somewhat-1.0.0.tgz`. Two different packages, one path: the second
|
|
730
|
+
* landed on the first, and the run reported two artifacts for the one file that
|
|
731
|
+
* survived. A bundle short a package, called complete, found at the far end of
|
|
732
|
+
* an air gap.
|
|
733
|
+
*
|
|
734
|
+
* Keeping the `@` makes that impossible by construction rather than by a check,
|
|
735
|
+
* because an unscoped npm name may not begin with one. It is also the more
|
|
736
|
+
* honest name: the scope is part of what the package *is*, and a directory of
|
|
737
|
+
* archives that has forgotten which ones were scoped is a directory somebody
|
|
738
|
+
* has to cross-reference against a manifest.
|
|
739
|
+
*
|
|
740
|
+
* It does not make every collision impossible — `@a/b-c` and `@a-b/c` still
|
|
741
|
+
* meet — so `assertDistinctOutputs` still runs. That pair is pathological; the
|
|
742
|
+
* scoped-against-unscoped one is `@babel/core` beside `babel-core`, which is a
|
|
743
|
+
* real migration somebody could be bundling.
|
|
744
|
+
*
|
|
745
|
+
* The leading `@` is safe here in a way it is not inside the archive: tar's
|
|
746
|
+
* splice convention applies to the *file list* it is given, which is what
|
|
747
|
+
* `archive.ts` guards with `./`. This is the output path, not an entry.
|
|
835
748
|
*/
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
for (const spec of specs) {
|
|
840
|
-
const key = formatSpec(spec);
|
|
841
|
-
if (seen.has(key)) continue;
|
|
842
|
-
seen.add(key);
|
|
843
|
-
out.push(spec);
|
|
844
|
-
}
|
|
845
|
-
return out;
|
|
749
|
+
const perSpecArchiveName = (name, version) => {
|
|
750
|
+
const { scope, bare } = splitName(name);
|
|
751
|
+
return `${scope === void 0 ? bare : `${scope}-${bare}`}@${version}.tgz`;
|
|
846
752
|
};
|
|
753
|
+
/** Name of the archive produced in `single` layout. */
|
|
754
|
+
const singleArchiveName = (base = "bundle") => `${base}.tgz`;
|
|
755
|
+
/**
|
|
756
|
+
* The import guide written into each bundle.
|
|
757
|
+
*
|
|
758
|
+
* Kept short and copy-pasteable on purpose: whoever opens this is mid-task on a
|
|
759
|
+
* restricted network and does not want prose.
|
|
760
|
+
*/
|
|
761
|
+
const importGuide = (options) => `# Importing this bundle
|
|
762
|
+
|
|
763
|
+
${options.packageCount} package tarball(s), laid out exactly as an npm registry serves them:
|
|
764
|
+
|
|
765
|
+
<package-name>/-/<file>.tgz
|
|
766
|
+
@<scope>/<name>/-/<file>.tgz
|
|
767
|
+
|
|
768
|
+
Created ${options.createdAt} by packall ${options.toolVersion}.
|
|
769
|
+
See ${MANIFEST_FILE} for the full list with checksums.
|
|
770
|
+
|
|
771
|
+
## JFrog Artifactory
|
|
772
|
+
|
|
773
|
+
Unpack, then upload the tree into a **local npm** repository. The layout already
|
|
774
|
+
matches what Artifactory expects, so no path rewriting is needed:
|
|
775
|
+
|
|
776
|
+
tar -xzf <this-bundle>.tgz -C ./bundle
|
|
777
|
+
jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
|
|
778
|
+
|
|
779
|
+
## Verdaccio / Nexus / any npm registry
|
|
780
|
+
|
|
781
|
+
Publish each tarball individually:
|
|
782
|
+
|
|
783
|
+
find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
|
|
784
|
+
|
|
785
|
+
## Verifying before import
|
|
786
|
+
|
|
787
|
+
node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
|
|
788
|
+
|
|
789
|
+
Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
|
|
790
|
+
advertised, and each tarball was checked against it at download time.
|
|
791
|
+
`;
|
|
847
792
|
|
|
848
793
|
//#endregion
|
|
849
|
-
//#region src/
|
|
850
|
-
var Manifest_exports = /* @__PURE__ */ __export({
|
|
851
|
-
MANIFEST_VERSION: () => MANIFEST_VERSION,
|
|
852
|
-
buildManifest: () => buildManifest,
|
|
853
|
-
formatReasons: () => formatReasons,
|
|
854
|
-
serializeManifest: () => serializeManifest
|
|
855
|
-
});
|
|
794
|
+
//#region src/manifest.ts
|
|
856
795
|
/** Schema version, bumped when the shape changes incompatibly. */
|
|
857
796
|
const MANIFEST_VERSION = 1;
|
|
858
797
|
/** Renders a package's reasons into short, greppable strings. */
|
|
859
798
|
const formatReasons = (pkg) => pkg.reasons.map((reason) => reason._tag === "Root" ? `root:${reason.spec}` : `${reason.kind}:${reason.from}`);
|
|
860
799
|
/** Builds the manifest for a completed (or dry) run. */
|
|
861
800
|
const buildManifest = (input) => {
|
|
862
|
-
const includedNames = new Set(input.included.map((
|
|
801
|
+
const includedNames = new Set(input.included.map((pkg) => `${pkg.name}@${pkg.version}`));
|
|
863
802
|
const packages = input.included.map((pkg) => {
|
|
864
803
|
const key = `${pkg.name}@${pkg.version}`;
|
|
865
804
|
return {
|
|
@@ -909,132 +848,10 @@ const buildManifest = (input) => {
|
|
|
909
848
|
const serializeManifest = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`;
|
|
910
849
|
|
|
911
850
|
//#endregion
|
|
912
|
-
//#region src/
|
|
913
|
-
var DependencyRange_exports = /* @__PURE__ */ __export({ parseDependencyTarget: () => parseDependencyTarget });
|
|
914
|
-
const UNSUPPORTED_PROTOCOLS = [
|
|
915
|
-
["file:", "local file path"],
|
|
916
|
-
["link:", "local link"],
|
|
917
|
-
["workspace:", "workspace protocol"],
|
|
918
|
-
["portal:", "portal protocol"],
|
|
919
|
-
["patch:", "patch protocol"],
|
|
920
|
-
["git:", "git dependency"],
|
|
921
|
-
["git+", "git dependency"],
|
|
922
|
-
["github:", "GitHub shorthand"],
|
|
923
|
-
["gitlab:", "GitLab shorthand"],
|
|
924
|
-
["bitbucket:", "Bitbucket shorthand"],
|
|
925
|
-
["http:", "remote tarball URL"],
|
|
926
|
-
["https:", "remote tarball URL"]
|
|
927
|
-
];
|
|
928
|
-
/**
|
|
929
|
-
* Classifies one `name -> range` entry.
|
|
930
|
-
*
|
|
931
|
-
* An empty range, `*`, and `latest` all mean "any published version"; npm
|
|
932
|
-
* treats them interchangeably and so do we.
|
|
933
|
-
*/
|
|
934
|
-
const parseDependencyTarget = (name, raw) => {
|
|
935
|
-
const text = raw.trim();
|
|
936
|
-
if (text.length === 0 || text === "*" || text === "x" || text === "latest") return {
|
|
937
|
-
_tag: "Registry",
|
|
938
|
-
name,
|
|
939
|
-
selector: {
|
|
940
|
-
_tag: "Range",
|
|
941
|
-
range: "*"
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
if (text.startsWith("npm:")) return parseAlias(name, text);
|
|
945
|
-
for (const [prefix, reason] of UNSUPPORTED_PROTOCOLS) if (text.startsWith(prefix)) return {
|
|
946
|
-
_tag: "Unsupported",
|
|
947
|
-
name,
|
|
948
|
-
raw: text,
|
|
949
|
-
reason
|
|
950
|
-
};
|
|
951
|
-
if (semver.validRange(text, { loose: true }) === null && /^[\w.-]+\/[\w.-]+/.test(text)) return {
|
|
952
|
-
_tag: "Unsupported",
|
|
953
|
-
name,
|
|
954
|
-
raw: text,
|
|
955
|
-
reason: "git shorthand"
|
|
956
|
-
};
|
|
957
|
-
const exact = semver.valid(text, { loose: true });
|
|
958
|
-
if (exact !== null) return {
|
|
959
|
-
_tag: "Registry",
|
|
960
|
-
name,
|
|
961
|
-
selector: {
|
|
962
|
-
_tag: "Exact",
|
|
963
|
-
version: exact
|
|
964
|
-
}
|
|
965
|
-
};
|
|
966
|
-
if (semver.validRange(text, { loose: true }) !== null) return {
|
|
967
|
-
_tag: "Registry",
|
|
968
|
-
name,
|
|
969
|
-
selector: {
|
|
970
|
-
_tag: "Range",
|
|
971
|
-
range: text
|
|
972
|
-
}
|
|
973
|
-
};
|
|
974
|
-
if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(text)) return {
|
|
975
|
-
_tag: "Registry",
|
|
976
|
-
name,
|
|
977
|
-
selector: {
|
|
978
|
-
_tag: "Tag",
|
|
979
|
-
tag: text
|
|
980
|
-
}
|
|
981
|
-
};
|
|
982
|
-
return {
|
|
983
|
-
_tag: "Unsupported",
|
|
984
|
-
name,
|
|
985
|
-
raw: text,
|
|
986
|
-
reason: "unrecognised version specifier"
|
|
987
|
-
};
|
|
988
|
-
};
|
|
989
|
-
/**
|
|
990
|
-
* Parses `npm:<name>[@<range>]`.
|
|
991
|
-
*
|
|
992
|
-
* The nested name may itself be scoped, so the `@` split has the same
|
|
993
|
-
* "not at index 0" caveat as top-level spec parsing.
|
|
994
|
-
*/
|
|
995
|
-
const parseAlias = (key, text) => {
|
|
996
|
-
const body = text.slice(4);
|
|
997
|
-
if (body.length === 0) return {
|
|
998
|
-
_tag: "Unsupported",
|
|
999
|
-
name: key,
|
|
1000
|
-
raw: text,
|
|
1001
|
-
reason: "empty npm: alias"
|
|
1002
|
-
};
|
|
1003
|
-
const at = body.lastIndexOf("@");
|
|
1004
|
-
const target = at > 0 ? body.slice(0, at) : body;
|
|
1005
|
-
const rangeText = at > 0 ? body.slice(at + 1) : "";
|
|
1006
|
-
if (target.length === 0) return {
|
|
1007
|
-
_tag: "Unsupported",
|
|
1008
|
-
name: key,
|
|
1009
|
-
raw: text,
|
|
1010
|
-
reason: "empty npm: alias target"
|
|
1011
|
-
};
|
|
1012
|
-
const inner = parseDependencyTarget(target, rangeText);
|
|
1013
|
-
if (inner._tag === "Unsupported") return {
|
|
1014
|
-
...inner,
|
|
1015
|
-
name: key,
|
|
1016
|
-
raw: text
|
|
1017
|
-
};
|
|
1018
|
-
return {
|
|
1019
|
-
_tag: "Registry",
|
|
1020
|
-
name: target,
|
|
1021
|
-
selector: inner.selector,
|
|
1022
|
-
aliasOf: key
|
|
1023
|
-
};
|
|
1024
|
-
};
|
|
1025
|
-
|
|
1026
|
-
//#endregion
|
|
1027
|
-
//#region src/Resolve.ts
|
|
1028
|
-
var Resolve_exports = /* @__PURE__ */ __export({
|
|
1029
|
-
edgesOf: () => edgesOf,
|
|
1030
|
-
indexPackages: () => indexPackages,
|
|
1031
|
-
packageKey: () => packageKey,
|
|
1032
|
-
resolve: () => resolve,
|
|
1033
|
-
selectVersions: () => selectVersions
|
|
1034
|
-
});
|
|
851
|
+
//#region src/resolve.ts
|
|
1035
852
|
const packageKey = (name, version) => `${name}@${version}`;
|
|
1036
853
|
/** Index of a resolution by `name@version`, for lookups during bundling. */
|
|
1037
|
-
const indexPackages = (resolution) => new Map(resolution.packages.map((
|
|
854
|
+
const indexPackages = (resolution) => new Map(resolution.packages.map((pkg) => [packageKey(pkg.name, pkg.version), pkg]));
|
|
1038
855
|
/**
|
|
1039
856
|
* Turns a selector into the concrete version(s) it names.
|
|
1040
857
|
*
|
|
@@ -1054,17 +871,18 @@ const selectVersions = (packument, selector, options) => {
|
|
|
1054
871
|
return [version];
|
|
1055
872
|
}
|
|
1056
873
|
case "Range": {
|
|
1057
|
-
const satisfying = available.filter((
|
|
874
|
+
const satisfying = available.filter((version) => semver.satisfies(version, selector.range, {
|
|
1058
875
|
loose: true,
|
|
1059
876
|
includePrerelease: options.includePrerelease
|
|
1060
|
-
})).
|
|
1061
|
-
|
|
1062
|
-
if (
|
|
877
|
+
})).toSorted(semver.rcompare);
|
|
878
|
+
const [best] = satisfying;
|
|
879
|
+
if (best === void 0) throw new NoMatchingVersionsError(packument.name, selector.range, sortVersions(available));
|
|
880
|
+
if (!options.all) return [best];
|
|
1063
881
|
return options.maxVersions !== void 0 && options.maxVersions > 0 ? satisfying.slice(0, options.maxVersions) : satisfying;
|
|
1064
882
|
}
|
|
1065
883
|
}
|
|
1066
884
|
};
|
|
1067
|
-
const sortVersions = (versions) =>
|
|
885
|
+
const sortVersions = (versions) => versions.toSorted((a, b) => semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b));
|
|
1068
886
|
const makeWalkState = () => ({
|
|
1069
887
|
resolved: /* @__PURE__ */ new Map(),
|
|
1070
888
|
edges: /* @__PURE__ */ new Map(),
|
|
@@ -1092,8 +910,19 @@ const getPackument = (state, name) => Effect.gen(function* () {
|
|
|
1092
910
|
state.packuments.set(name, packument);
|
|
1093
911
|
return packument;
|
|
1094
912
|
});
|
|
913
|
+
/**
|
|
914
|
+
* Re-fails a thrown selection error into the Effect error channel.
|
|
915
|
+
*
|
|
916
|
+
* `selectVersions` throws `BundlerError` subclasses and nothing else, so this
|
|
917
|
+
* narrows rather than casting; anything unexpected is rethrown as a defect,
|
|
918
|
+
* which is what an unexpected exception actually is.
|
|
919
|
+
*/
|
|
920
|
+
const asBundlerError = (error) => {
|
|
921
|
+
if (error instanceof VersionNotFoundError || error instanceof NoMatchingVersionsError) return error;
|
|
922
|
+
throw error;
|
|
923
|
+
};
|
|
1095
924
|
/** Expands one resolved manifest into the edges that follow from it. */
|
|
1096
|
-
const edgesOf = (manifest, options) => {
|
|
925
|
+
const edgesOf$3 = (manifest, options) => {
|
|
1097
926
|
const from = packageKey(manifest.name, manifest.version);
|
|
1098
927
|
const tasks = [];
|
|
1099
928
|
const warnings = [];
|
|
@@ -1121,11 +950,11 @@ const edgesOf = (manifest, options) => {
|
|
|
1121
950
|
});
|
|
1122
951
|
}
|
|
1123
952
|
};
|
|
1124
|
-
add(manifest.dependencies,
|
|
1125
|
-
if (options.scope.optional) add(manifest.optionalDependencies,
|
|
953
|
+
add(manifest.dependencies, EdgeKind.Prod, false);
|
|
954
|
+
if (options.scope.optional) add(manifest.optionalDependencies, EdgeKind.Optional, true);
|
|
1126
955
|
if (options.scope.peer) {
|
|
1127
956
|
const meta = manifest.peerDependenciesMeta ?? {};
|
|
1128
|
-
add(manifest.peerDependencies,
|
|
957
|
+
add(manifest.peerDependencies, EdgeKind.Peer, false, (name) => meta[name]?.optional === true);
|
|
1129
958
|
}
|
|
1130
959
|
return {
|
|
1131
960
|
tasks,
|
|
@@ -1137,7 +966,7 @@ const edgesFor = (state, manifest, options) => {
|
|
|
1137
966
|
const key = packageKey(manifest.name, manifest.version);
|
|
1138
967
|
const cached = state.edges.get(key);
|
|
1139
968
|
if (cached !== void 0) return cached;
|
|
1140
|
-
const { tasks, warnings } = edgesOf(manifest, options);
|
|
969
|
+
const { tasks, warnings } = edgesOf$3(manifest, options);
|
|
1141
970
|
for (const warning of warnings) addWarning(state, warning);
|
|
1142
971
|
state.edges.set(key, tasks);
|
|
1143
972
|
return tasks;
|
|
@@ -1168,7 +997,8 @@ const walk = (state, seeds, options) => Effect.gen(function* () {
|
|
|
1168
997
|
if (result === null) continue;
|
|
1169
998
|
for (const { manifest, reason } of result) {
|
|
1170
999
|
const key = packageKey(manifest.name, manifest.version);
|
|
1171
|
-
|
|
1000
|
+
const existing = state.resolved.get(key);
|
|
1001
|
+
if (existing === void 0) {
|
|
1172
1002
|
state.resolved.set(key, {
|
|
1173
1003
|
name: manifest.name,
|
|
1174
1004
|
version: manifest.version,
|
|
@@ -1186,13 +1016,10 @@ const walk = (state, seeds, options) => Effect.gen(function* () {
|
|
|
1186
1016
|
from: key,
|
|
1187
1017
|
message: `${key} is deprecated: ${manifest.deprecated}`
|
|
1188
1018
|
});
|
|
1189
|
-
} else {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
reasons: [...existing.reasons, reason]
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1019
|
+
} else if (!hasReason$1(existing.reasons, reason)) state.resolved.set(key, {
|
|
1020
|
+
...existing,
|
|
1021
|
+
reasons: [...existing.reasons, reason]
|
|
1022
|
+
});
|
|
1196
1023
|
if (!discovered.has(key)) {
|
|
1197
1024
|
discovered.add(key);
|
|
1198
1025
|
next.push(...edgesFor(state, manifest, options));
|
|
@@ -1203,7 +1030,7 @@ const walk = (state, seeds, options) => Effect.gen(function* () {
|
|
|
1203
1030
|
}
|
|
1204
1031
|
return [...discovered];
|
|
1205
1032
|
});
|
|
1206
|
-
const hasReason = (reasons, candidate) => reasons.some((reason) => {
|
|
1033
|
+
const hasReason$1 = (reasons, candidate) => reasons.some((reason) => {
|
|
1207
1034
|
if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec;
|
|
1208
1035
|
if (reason._tag === "Edge" && candidate._tag === "Edge") return reason.from === candidate.from && reason.kind === candidate.kind;
|
|
1209
1036
|
return false;
|
|
@@ -1217,24 +1044,21 @@ const hasReason = (reasons, candidate) => reasons.some((reason) => {
|
|
|
1217
1044
|
*/
|
|
1218
1045
|
const resolveTask = (state, task, options) => Effect.gen(function* () {
|
|
1219
1046
|
const packument = yield* getPackument(state, task.name);
|
|
1220
|
-
|
|
1047
|
+
return (yield* Effect.try({
|
|
1221
1048
|
try: () => selectVersions(packument, task.selector, {
|
|
1222
1049
|
all: false,
|
|
1223
1050
|
includePrerelease: options.includePrerelease
|
|
1224
1051
|
}),
|
|
1225
|
-
catch:
|
|
1226
|
-
})
|
|
1227
|
-
const out = [];
|
|
1228
|
-
for (const version of versions) {
|
|
1052
|
+
catch: asBundlerError
|
|
1053
|
+
})).flatMap((version) => {
|
|
1229
1054
|
const manifest = packument.versions[version];
|
|
1230
|
-
if (manifest === void 0)
|
|
1231
|
-
if (task.reason._tag === "Edge" && task.reason.kind ===
|
|
1232
|
-
|
|
1055
|
+
if (manifest === void 0) return [];
|
|
1056
|
+
if (task.reason._tag === "Edge" && task.reason.kind === EdgeKind.Optional && !isIncluded(manifest, options.scope.platforms)) return [];
|
|
1057
|
+
return {
|
|
1233
1058
|
manifest,
|
|
1234
1059
|
reason: task.reason
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
return out;
|
|
1060
|
+
};
|
|
1061
|
+
});
|
|
1238
1062
|
}).pipe(Effect.catch((error) => {
|
|
1239
1063
|
if (!task.tolerateFailure) return Effect.fail(error);
|
|
1240
1064
|
return Effect.sync(() => {
|
|
@@ -1252,76 +1076,1395 @@ const resolveTask = (state, task, options) => Effect.gen(function* () {
|
|
|
1252
1076
|
const resolve = (specs, options) => Effect.gen(function* () {
|
|
1253
1077
|
yield* emit({
|
|
1254
1078
|
_tag: "PhaseStarted",
|
|
1255
|
-
phase:
|
|
1256
|
-
total: specs.length
|
|
1079
|
+
phase: Phase.Resolve,
|
|
1080
|
+
total: specs.length
|
|
1081
|
+
});
|
|
1082
|
+
const state = makeWalkState();
|
|
1083
|
+
const roots = [];
|
|
1084
|
+
for (const spec of specs) {
|
|
1085
|
+
const packument = yield* getPackument(state, spec.name);
|
|
1086
|
+
const versions = yield* Effect.try({
|
|
1087
|
+
try: () => selectVersions(packument, spec.selector, {
|
|
1088
|
+
all: options.allVersions,
|
|
1089
|
+
maxVersions: options.maxVersions,
|
|
1090
|
+
includePrerelease: options.includePrerelease
|
|
1091
|
+
}),
|
|
1092
|
+
catch: asBundlerError
|
|
1093
|
+
});
|
|
1094
|
+
const closures = /* @__PURE__ */ new Map();
|
|
1095
|
+
const union = /* @__PURE__ */ new Set();
|
|
1096
|
+
for (const version of versions) {
|
|
1097
|
+
const closure = yield* walk(state, [{
|
|
1098
|
+
name: spec.name,
|
|
1099
|
+
selector: {
|
|
1100
|
+
_tag: "Exact",
|
|
1101
|
+
version
|
|
1102
|
+
},
|
|
1103
|
+
reason: {
|
|
1104
|
+
_tag: "Root",
|
|
1105
|
+
spec: formatSpec(spec)
|
|
1106
|
+
},
|
|
1107
|
+
tolerateFailure: false
|
|
1108
|
+
}], options);
|
|
1109
|
+
closures.set(version, closure);
|
|
1110
|
+
for (const key of closure) union.add(key);
|
|
1111
|
+
}
|
|
1112
|
+
roots.push({
|
|
1113
|
+
spec,
|
|
1114
|
+
versions,
|
|
1115
|
+
closures,
|
|
1116
|
+
closure: [...union]
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
yield* emit({
|
|
1120
|
+
_tag: "PhaseCompleted",
|
|
1121
|
+
phase: Phase.Resolve
|
|
1122
|
+
});
|
|
1123
|
+
return {
|
|
1124
|
+
roots,
|
|
1125
|
+
packages: [...state.resolved.values()].toSorted(compareResolved$1),
|
|
1126
|
+
warnings: state.warnings
|
|
1127
|
+
};
|
|
1128
|
+
});
|
|
1129
|
+
const compareResolved$1 = (a, b) => {
|
|
1130
|
+
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
|
|
1131
|
+
return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/locked-resolve.ts
|
|
1136
|
+
/** Turns a parsed lockfile into a resolution, pinning every version. */
|
|
1137
|
+
const resolveLocked = (lock, options) => Effect.gen(function* () {
|
|
1138
|
+
const roots = selectRoots(lock, options);
|
|
1139
|
+
yield* emit({
|
|
1140
|
+
_tag: "PhaseStarted",
|
|
1141
|
+
phase: Phase.Resolve,
|
|
1142
|
+
total: roots.length
|
|
1143
|
+
});
|
|
1144
|
+
const gap = findGaps(lock, roots, options.declared);
|
|
1145
|
+
if (gap !== null) return yield* Effect.fail(gap);
|
|
1146
|
+
const manifests = yield* fetchManifests(lock, reachableKeys(lock, roots, options), options);
|
|
1147
|
+
const warnings = lock.warnings.map((message) => ({ message }));
|
|
1148
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1149
|
+
const seenWarnings = /* @__PURE__ */ new Set();
|
|
1150
|
+
const addWarning$1 = (warning) => {
|
|
1151
|
+
const key = `${warning.from ?? ""}|${warning.message}`;
|
|
1152
|
+
if (seenWarnings.has(key)) return;
|
|
1153
|
+
seenWarnings.add(key);
|
|
1154
|
+
warnings.push(warning);
|
|
1155
|
+
};
|
|
1156
|
+
const rootResolutions = [];
|
|
1157
|
+
for (const root of roots) {
|
|
1158
|
+
const key = packageKey(root.name, root.version);
|
|
1159
|
+
if (!manifests.has(key)) continue;
|
|
1160
|
+
const spec = {
|
|
1161
|
+
name: root.name,
|
|
1162
|
+
selector: {
|
|
1163
|
+
_tag: "Exact",
|
|
1164
|
+
version: root.version
|
|
1165
|
+
},
|
|
1166
|
+
raw: `${root.name}@${root.version}`
|
|
1167
|
+
};
|
|
1168
|
+
if (root.kind === LockedRootKind.Optional && !platformAllows(manifests, key, options)) continue;
|
|
1169
|
+
const closure = walkLocked({
|
|
1170
|
+
lock,
|
|
1171
|
+
manifests,
|
|
1172
|
+
options,
|
|
1173
|
+
seed: key,
|
|
1174
|
+
rootSpec: formatSpec(spec),
|
|
1175
|
+
resolved,
|
|
1176
|
+
addWarning: addWarning$1
|
|
1177
|
+
});
|
|
1178
|
+
rootResolutions.push({
|
|
1179
|
+
spec,
|
|
1180
|
+
versions: [root.version],
|
|
1181
|
+
closures: new Map([[root.version, closure]]),
|
|
1182
|
+
closure
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
for (const pkg of resolved.values()) {
|
|
1186
|
+
const deprecated = pkg.manifest.deprecated;
|
|
1187
|
+
if (deprecated !== void 0 && deprecated !== "") addWarning$1({
|
|
1188
|
+
from: packageKey(pkg.name, pkg.version),
|
|
1189
|
+
message: `${packageKey(pkg.name, pkg.version)} is deprecated: ${deprecated}`
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
yield* emit({
|
|
1193
|
+
_tag: "PhaseCompleted",
|
|
1194
|
+
phase: Phase.Resolve
|
|
1195
|
+
});
|
|
1196
|
+
return {
|
|
1197
|
+
roots: rootResolutions,
|
|
1198
|
+
packages: [...resolved.values()].toSorted(compareResolved),
|
|
1199
|
+
warnings
|
|
1200
|
+
};
|
|
1201
|
+
});
|
|
1202
|
+
/** Which of the project's direct dependencies this run should bundle. */
|
|
1203
|
+
const selectRoots = (lock, options) => lock.roots.filter((root) => {
|
|
1204
|
+
if (root.kind === LockedRootKind.Dev) return options.includeDev;
|
|
1205
|
+
if (root.kind === LockedRootKind.Optional) return options.scope.optional;
|
|
1206
|
+
if (root.kind === LockedRootKind.Peer) return options.scope.peer;
|
|
1207
|
+
return true;
|
|
1208
|
+
}).toSorted((a, b) => a.name === b.name ? 0 : a.name < b.name ? -1 : 1);
|
|
1209
|
+
/**
|
|
1210
|
+
* The two ways a lockfile can be partial, checked before anything is fetched.
|
|
1211
|
+
*
|
|
1212
|
+
* Both mean the same thing to whoever is waiting on the bundle — a package they
|
|
1213
|
+
* expected will not be in it — and both are invisible unless something says so,
|
|
1214
|
+
* because the run otherwise succeeds and simply produces less.
|
|
1215
|
+
*/
|
|
1216
|
+
const findGaps = (lock, roots, declared) => {
|
|
1217
|
+
const remedy = `Run \`${lock.format} install\` to bring the lockfile up to date.`;
|
|
1218
|
+
if (declared !== void 0 && declared.length > 0) {
|
|
1219
|
+
const locked = /* @__PURE__ */ new Set();
|
|
1220
|
+
for (const pkg of lock.packages.values()) locked.add(pkg.name);
|
|
1221
|
+
for (const root of roots) locked.add(root.name);
|
|
1222
|
+
const missing = declared.filter((name) => !locked.has(name));
|
|
1223
|
+
if (missing.length > 0) return new LockfileIncompleteError(lock.path, missing, `the package.json declares ${missing.length} dependenc${missing.length === 1 ? "y" : "ies"} it does not pin`, remedy);
|
|
1224
|
+
}
|
|
1225
|
+
if (lock.incomplete.length > 0) return new LockfileIncompleteError(lock.path, lock.incomplete, `${lock.incomplete.length} required dependenc${lock.incomplete.length === 1 ? "y is" : "ies are"} not pinned`, remedy);
|
|
1226
|
+
return null;
|
|
1227
|
+
};
|
|
1228
|
+
/**
|
|
1229
|
+
* Every package the roots can reach through the locked edges.
|
|
1230
|
+
*
|
|
1231
|
+
* Scope filters apply here — `--no-optional` genuinely removes a subtree — but
|
|
1232
|
+
* the platform filter does not, because deciding it needs the `os`/`cpu` fields
|
|
1233
|
+
* that only the registry has.
|
|
1234
|
+
*/
|
|
1235
|
+
const reachableKeys = (lock, roots, options) => {
|
|
1236
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1237
|
+
const queue = roots.map((root) => packageKey(root.name, root.version));
|
|
1238
|
+
while (queue.length > 0) {
|
|
1239
|
+
const key = queue.pop();
|
|
1240
|
+
if (key === void 0 || seen.has(key)) continue;
|
|
1241
|
+
seen.add(key);
|
|
1242
|
+
for (const edge of lock.packages.get(key)?.dependencies ?? []) {
|
|
1243
|
+
if (!followsEdge(edge.kind, options)) continue;
|
|
1244
|
+
queue.push(packageKey(edge.name, edge.version));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return seen;
|
|
1248
|
+
};
|
|
1249
|
+
const followsEdge = (kind, options) => {
|
|
1250
|
+
if (kind === LockedRootKind.Optional) return options.scope.optional;
|
|
1251
|
+
if (kind === LockedRootKind.Peer) return options.scope.peer;
|
|
1252
|
+
return true;
|
|
1253
|
+
};
|
|
1254
|
+
/**
|
|
1255
|
+
* Fetches the published manifest for every pinned version.
|
|
1256
|
+
*
|
|
1257
|
+
* One packument per distinct *name*, not per version — a lockfile holding four
|
|
1258
|
+
* versions of `tslib` costs one request. Every pinned version that the registry
|
|
1259
|
+
* does not serve is collected and reported together, because "refresh your
|
|
1260
|
+
* lockfile" is a single action and finding out about the gaps one run at a time
|
|
1261
|
+
* would be miserable.
|
|
1262
|
+
*/
|
|
1263
|
+
const fetchManifests = (lock, reachable, options) => Effect.gen(function* () {
|
|
1264
|
+
const registry = yield* Registry;
|
|
1265
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
1266
|
+
for (const key of reachable) {
|
|
1267
|
+
const pkg = lock.packages.get(key);
|
|
1268
|
+
if (pkg === void 0) continue;
|
|
1269
|
+
const versions = wanted.get(pkg.name);
|
|
1270
|
+
if (versions === void 0) wanted.set(pkg.name, [pkg.version]);
|
|
1271
|
+
else versions.push(pkg.version);
|
|
1272
|
+
}
|
|
1273
|
+
const manifests = /* @__PURE__ */ new Map();
|
|
1274
|
+
const missing = [];
|
|
1275
|
+
const fetched = yield* Effect.forEach([...wanted.entries()], ([name, versions]) => registry.packument(name).pipe(Effect.map((packument) => ({
|
|
1276
|
+
name,
|
|
1277
|
+
versions,
|
|
1278
|
+
packument
|
|
1279
|
+
})), Effect.catch((error) => error._tag === "PackageNotFoundError" ? Effect.succeed({
|
|
1280
|
+
name,
|
|
1281
|
+
versions,
|
|
1282
|
+
packument: null
|
|
1283
|
+
}) : Effect.fail(error))), { concurrency: options.concurrency });
|
|
1284
|
+
for (const { name, versions, packument } of fetched) for (const version of versions) {
|
|
1285
|
+
if (packument === null) {
|
|
1286
|
+
missing.push({
|
|
1287
|
+
name,
|
|
1288
|
+
version,
|
|
1289
|
+
detail: "package not found"
|
|
1290
|
+
});
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const manifest = packument.versions[version];
|
|
1294
|
+
if (manifest === void 0) {
|
|
1295
|
+
missing.push({
|
|
1296
|
+
name,
|
|
1297
|
+
version,
|
|
1298
|
+
detail: describeAvailable(Object.keys(packument.versions))
|
|
1299
|
+
});
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
manifests.set(packageKey(name, version), manifest);
|
|
1303
|
+
yield* emit({
|
|
1304
|
+
_tag: "PackageResolved",
|
|
1305
|
+
name,
|
|
1306
|
+
version,
|
|
1307
|
+
resolvedCount: manifests.size,
|
|
1308
|
+
pendingCount: reachable.size - manifests.size
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (missing.length > 0) return yield* Effect.fail(new LockfileOutOfDateError(lock.path, registry.registryFor(missing[0]?.name ?? ""), missing));
|
|
1312
|
+
return manifests;
|
|
1313
|
+
});
|
|
1314
|
+
const describeAvailable = (available) => {
|
|
1315
|
+
const recent = available.toSorted((a, b) => semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b)).slice(-3);
|
|
1316
|
+
return recent.length === 0 ? "no published versions" : `no longer published (latest: ${recent.join(", ")})`;
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* Collects one root's closure by following locked edges.
|
|
1320
|
+
*
|
|
1321
|
+
* Pure and synchronous — every manifest is already in hand — so a per-root
|
|
1322
|
+
* closure costs nothing beyond the traversal, which is what makes `per-spec`
|
|
1323
|
+
* layout as cheap here as it is for a range-resolved run.
|
|
1324
|
+
*/
|
|
1325
|
+
const walkLocked = (input) => {
|
|
1326
|
+
const closure = /* @__PURE__ */ new Set();
|
|
1327
|
+
const queue = [{
|
|
1328
|
+
key: input.seed,
|
|
1329
|
+
reason: {
|
|
1330
|
+
_tag: "Root",
|
|
1331
|
+
spec: input.rootSpec
|
|
1332
|
+
}
|
|
1333
|
+
}];
|
|
1334
|
+
while (queue.length > 0) {
|
|
1335
|
+
const next = queue.pop();
|
|
1336
|
+
if (next === void 0) continue;
|
|
1337
|
+
const manifest = input.manifests.get(next.key);
|
|
1338
|
+
const locked = input.lock.packages.get(next.key);
|
|
1339
|
+
if (manifest === void 0 || locked === void 0) continue;
|
|
1340
|
+
record(input.resolved, next.key, manifest, locked, next.reason);
|
|
1341
|
+
if (closure.has(next.key)) continue;
|
|
1342
|
+
closure.add(next.key);
|
|
1343
|
+
for (const edge of locked.dependencies) {
|
|
1344
|
+
if (!followsEdge(edge.kind, input.options)) continue;
|
|
1345
|
+
const target = packageKey(edge.name, edge.version);
|
|
1346
|
+
if (!input.manifests.has(target)) continue;
|
|
1347
|
+
if (edge.kind === LockedRootKind.Optional && !platformAllows(input.manifests, target, input.options)) {
|
|
1348
|
+
input.addWarning({
|
|
1349
|
+
from: next.key,
|
|
1350
|
+
message: `Skipped optional ${target} — it does not build for the selected platforms.`
|
|
1351
|
+
});
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
queue.push({
|
|
1355
|
+
key: target,
|
|
1356
|
+
reason: {
|
|
1357
|
+
_tag: "Edge",
|
|
1358
|
+
from: next.key,
|
|
1359
|
+
kind: edge.kind
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return [...closure];
|
|
1365
|
+
};
|
|
1366
|
+
/** Adds a package, or merges one more justification into an existing entry. */
|
|
1367
|
+
const record = (resolved, key, manifest, locked, reason) => {
|
|
1368
|
+
const existing = resolved.get(key);
|
|
1369
|
+
if (existing === void 0) {
|
|
1370
|
+
resolved.set(key, {
|
|
1371
|
+
name: locked.name,
|
|
1372
|
+
version: locked.version,
|
|
1373
|
+
manifest,
|
|
1374
|
+
reasons: [reason]
|
|
1375
|
+
});
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (hasReason(existing.reasons, reason)) return;
|
|
1379
|
+
resolved.set(key, {
|
|
1380
|
+
...existing,
|
|
1381
|
+
reasons: [...existing.reasons, reason]
|
|
1382
|
+
});
|
|
1383
|
+
};
|
|
1384
|
+
const hasReason = (reasons, candidate) => reasons.some((reason) => {
|
|
1385
|
+
if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec;
|
|
1386
|
+
if (reason._tag === "Edge" && candidate._tag === "Edge") return reason.from === candidate.from && reason.kind === candidate.kind;
|
|
1387
|
+
return false;
|
|
1388
|
+
});
|
|
1389
|
+
const platformAllows = (manifests, key, options) => {
|
|
1390
|
+
const manifest = manifests.get(key);
|
|
1391
|
+
if (manifest === void 0) return false;
|
|
1392
|
+
return isIncluded(manifest, options.scope.platforms);
|
|
1393
|
+
};
|
|
1394
|
+
const compareResolved = (a, b) => {
|
|
1395
|
+
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
|
|
1396
|
+
return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
//#endregion
|
|
1400
|
+
//#region src/lockfile/json.ts
|
|
1401
|
+
const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
|
|
1402
|
+
const decodeStringRecord = Schema.decodeUnknownOption(StringRecordSchema);
|
|
1403
|
+
/** Keeps only the string-valued members of an untyped object. */
|
|
1404
|
+
const stringRecord = (value) => Option.getOrElse(decodeStringRecord(value), () => ({}));
|
|
1405
|
+
const decodeOptionalFlags = Schema.decodeUnknownOption(OptionalFlagRecordSchema);
|
|
1406
|
+
/** Names a package marked its peers optional with. */
|
|
1407
|
+
const optionalPeerNames = (value) => {
|
|
1408
|
+
const flags = Option.getOrElse(decodeOptionalFlags(value), () => ({}));
|
|
1409
|
+
return new Set(Object.entries(flags).flatMap(([name, meta]) => meta.optional ? name : []));
|
|
1410
|
+
};
|
|
1411
|
+
const parseJsonObject = (path, content) => {
|
|
1412
|
+
const parsed = tryParseJsonc(content);
|
|
1413
|
+
if (parsed === void 0) throw new LockfileError(path, "not valid JSON");
|
|
1414
|
+
if (!isRecord(parsed)) throw new LockfileError(path, "top level is not an object");
|
|
1415
|
+
return parsed;
|
|
1416
|
+
};
|
|
1417
|
+
/**
|
|
1418
|
+
* Parses JSON, tolerating the comments and trailing commas bun writes.
|
|
1419
|
+
*
|
|
1420
|
+
* `bun.lock` is JSONC by design — bun puts explanatory comments in it — so
|
|
1421
|
+
* plain `JSON.parse` rejects real lockfiles. Returns `undefined` rather than
|
|
1422
|
+
* throwing so detection can use it as a probe.
|
|
1423
|
+
*/
|
|
1424
|
+
const tryParseJsonc = (content) => {
|
|
1425
|
+
try {
|
|
1426
|
+
return JSON.parse(content);
|
|
1427
|
+
} catch {
|
|
1428
|
+
try {
|
|
1429
|
+
return JSON.parse(stripJsonc(content));
|
|
1430
|
+
} catch {
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
/**
|
|
1436
|
+
* Removes comments and trailing commas.
|
|
1437
|
+
*
|
|
1438
|
+
* String-aware, because a `//` inside a tarball URL is not a comment and an
|
|
1439
|
+
* escaped quote does not end a string. Both appear in every real lockfile.
|
|
1440
|
+
*/
|
|
1441
|
+
const stripJsonc = (content) => {
|
|
1442
|
+
let out = "";
|
|
1443
|
+
let index = 0;
|
|
1444
|
+
let inString = false;
|
|
1445
|
+
while (index < content.length) {
|
|
1446
|
+
const char = content[index];
|
|
1447
|
+
if (inString) {
|
|
1448
|
+
out += char;
|
|
1449
|
+
if (char === "\\") {
|
|
1450
|
+
out += content[index + 1] ?? "";
|
|
1451
|
+
index += 2;
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (char === "\"") inString = false;
|
|
1455
|
+
index += 1;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (char === "\"") {
|
|
1459
|
+
inString = true;
|
|
1460
|
+
out += char;
|
|
1461
|
+
index += 1;
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (char === "/" && content[index + 1] === "/") {
|
|
1465
|
+
const end = content.indexOf("\n", index);
|
|
1466
|
+
index = end === -1 ? content.length : end;
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (char === "/" && content[index + 1] === "*") {
|
|
1470
|
+
const end = content.indexOf("*/", index + 2);
|
|
1471
|
+
index = end === -1 ? content.length : end + 2;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (char === ",") {
|
|
1475
|
+
const next = content.slice(index + 1).search(/\S/);
|
|
1476
|
+
const following = next === -1 ? "" : content[index + 1 + next];
|
|
1477
|
+
if (following === "}" || following === "]") {
|
|
1478
|
+
index += 1;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
out += char;
|
|
1483
|
+
index += 1;
|
|
1484
|
+
}
|
|
1485
|
+
return out;
|
|
1486
|
+
};
|
|
1487
|
+
const describeError$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
1488
|
+
|
|
1489
|
+
//#endregion
|
|
1490
|
+
//#region src/lockfile/detect.ts
|
|
1491
|
+
/**
|
|
1492
|
+
* Deliberately cheap and total: it never throws, so `--file` can try this
|
|
1493
|
+
* first and fall through to its other shapes when the answer is `NotALockfile`.
|
|
1494
|
+
*/
|
|
1495
|
+
const detectLockfile = (content) => {
|
|
1496
|
+
const trimmed = content.trimStart();
|
|
1497
|
+
if (trimmed.startsWith("bun-lockfile-format-v0")) return {
|
|
1498
|
+
_tag: "Unsupported",
|
|
1499
|
+
label: "a binary bun lockfile (bun.lockb)",
|
|
1500
|
+
hint: "Run `bun install --save-text-lockfile` to produce a bun.lock, and pass that instead."
|
|
1501
|
+
};
|
|
1502
|
+
if (/^__metadata:/m.test(trimmed) || /^# yarn lockfile v\d/m.test(trimmed)) return {
|
|
1503
|
+
_tag: "Unsupported",
|
|
1504
|
+
label: "a yarn lockfile",
|
|
1505
|
+
hint: "yarn is not supported yet. Point --file at the package.json instead, or pass --lockfile off."
|
|
1506
|
+
};
|
|
1507
|
+
if (trimmed.startsWith("{")) return detectJsonLockfile(trimmed);
|
|
1508
|
+
if (/^lockfileVersion:/m.test(trimmed) || /^(importers|snapshots):/m.test(trimmed)) return {
|
|
1509
|
+
_tag: "Supported",
|
|
1510
|
+
format: LockfileFormat.Pnpm
|
|
1511
|
+
};
|
|
1512
|
+
return { _tag: "NotALockfile" };
|
|
1513
|
+
};
|
|
1514
|
+
/**
|
|
1515
|
+
* Separates the two JSON lockfiles from each other and from a package.json.
|
|
1516
|
+
*
|
|
1517
|
+
* Both npm and bun write a numeric `lockfileVersion`, so that field alone
|
|
1518
|
+
* cannot decide it. bun is the one that carries a `workspaces` map keyed by the
|
|
1519
|
+
* empty string; npm keeps the root project under `packages[""]` instead.
|
|
1520
|
+
*/
|
|
1521
|
+
const detectJsonLockfile = (trimmed) => {
|
|
1522
|
+
const parsed = tryParseJsonc(trimmed);
|
|
1523
|
+
if (!isRecord(parsed)) return { _tag: "NotALockfile" };
|
|
1524
|
+
const workspaces = parsed["workspaces"];
|
|
1525
|
+
if (isRecord(workspaces) && Object.hasOwn(workspaces, "")) return {
|
|
1526
|
+
_tag: "Supported",
|
|
1527
|
+
format: LockfileFormat.Bun
|
|
1528
|
+
};
|
|
1529
|
+
if (typeof parsed["lockfileVersion"] === "number") return {
|
|
1530
|
+
_tag: "Supported",
|
|
1531
|
+
format: LockfileFormat.Npm
|
|
1532
|
+
};
|
|
1533
|
+
return { _tag: "NotALockfile" };
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
//#endregion
|
|
1537
|
+
//#region src/lockfile/names.ts
|
|
1538
|
+
/**
|
|
1539
|
+
* The file name each supported manager writes, in detection order.
|
|
1540
|
+
*
|
|
1541
|
+
* Ordered: `package-lock.json` before `npm-shrinkwrap.json` before pnpm's and
|
|
1542
|
+
* bun's, so a directory holding several is searched the way npm itself would.
|
|
1543
|
+
*/
|
|
1544
|
+
const formatByLockfileName = {
|
|
1545
|
+
"package-lock.json": LockfileFormat.Npm,
|
|
1546
|
+
"npm-shrinkwrap.json": LockfileFormat.Npm,
|
|
1547
|
+
"pnpm-lock.yaml": LockfileFormat.Pnpm,
|
|
1548
|
+
"bun.lock": LockfileFormat.Bun
|
|
1549
|
+
};
|
|
1550
|
+
/** The names a given manager writes. */
|
|
1551
|
+
const lockfileNamesFor = (format) => Object.entries(formatByLockfileName).flatMap(([name, candidate]) => candidate === format ? name : []);
|
|
1552
|
+
|
|
1553
|
+
//#endregion
|
|
1554
|
+
//#region src/lockfile/tree-builder.ts
|
|
1555
|
+
/**
|
|
1556
|
+
* Which manifest block each root kind is declared in.
|
|
1557
|
+
*
|
|
1558
|
+
* The block names are the same across npm, pnpm and bun; only which of them a
|
|
1559
|
+
* given format writes differs, which is why each parser supplies its own order
|
|
1560
|
+
* rather than iterating this.
|
|
1561
|
+
*/
|
|
1562
|
+
const fieldByRootKind = {
|
|
1563
|
+
[LockedRootKind.Prod]: "dependencies",
|
|
1564
|
+
[LockedRootKind.Optional]: "optionalDependencies",
|
|
1565
|
+
[LockedRootKind.Dev]: "devDependencies",
|
|
1566
|
+
[LockedRootKind.Peer]: "peerDependencies"
|
|
1567
|
+
};
|
|
1568
|
+
var TreeBuilder = class {
|
|
1569
|
+
packages = /* @__PURE__ */ new Map();
|
|
1570
|
+
roots = [];
|
|
1571
|
+
warnings = [];
|
|
1572
|
+
incomplete = [];
|
|
1573
|
+
/** Names dropped on purpose, so an edge into one is explained, not alarming. */
|
|
1574
|
+
skipped = /* @__PURE__ */ new Set();
|
|
1575
|
+
seenWarnings = /* @__PURE__ */ new Set();
|
|
1576
|
+
add(pkg) {
|
|
1577
|
+
const key = packageKey(pkg.name, pkg.version);
|
|
1578
|
+
const existing = this.packages.get(key);
|
|
1579
|
+
if (existing === void 0) {
|
|
1580
|
+
this.packages.set(key, pkg);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
this.packages.set(key, {
|
|
1584
|
+
...existing,
|
|
1585
|
+
dependencies: mergeEdges(existing.dependencies, pkg.dependencies)
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
addRoot(root) {
|
|
1589
|
+
if (this.roots.some((existing) => existing.name === root.name)) return;
|
|
1590
|
+
this.roots.push(root);
|
|
1591
|
+
}
|
|
1592
|
+
warn(message) {
|
|
1593
|
+
if (this.seenWarnings.has(message)) return;
|
|
1594
|
+
this.seenWarnings.add(message);
|
|
1595
|
+
this.warnings.push(message);
|
|
1596
|
+
}
|
|
1597
|
+
/** Notes an entry that is real but cannot come from a registry. */
|
|
1598
|
+
skip(name, spec, reason) {
|
|
1599
|
+
this.skipped.add(name);
|
|
1600
|
+
this.warn(`skipped ${name}@${spec} (${reason}) — not fetchable from a registry`);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Records a required edge that resolved to nothing.
|
|
1604
|
+
*
|
|
1605
|
+
* An edge into something skipped on purpose is expected — you cannot bundle a
|
|
1606
|
+
* `workspace:` sibling from a registry — so it stays a warning. Anything else
|
|
1607
|
+
* means the file does not pin what it claims to.
|
|
1608
|
+
*/
|
|
1609
|
+
dangling(from, name) {
|
|
1610
|
+
if (this.skipped.has(name)) {
|
|
1611
|
+
this.warn(`${from} requires ${name}, which is not fetchable from a registry`);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
this.incomplete.push(`${name} (required by ${from})`);
|
|
1615
|
+
}
|
|
1616
|
+
finish(input) {
|
|
1617
|
+
if (this.packages.size === 0) throw new LockfileError(input.path, "lockfile pins no packages that can be bundled");
|
|
1618
|
+
return {
|
|
1619
|
+
format: input.format,
|
|
1620
|
+
lockfileVersion: input.lockfileVersion,
|
|
1621
|
+
path: input.path,
|
|
1622
|
+
importer: input.importer,
|
|
1623
|
+
packages: this.packages,
|
|
1624
|
+
roots: this.roots,
|
|
1625
|
+
warnings: this.warnings,
|
|
1626
|
+
incomplete: this.incomplete
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
const mergeEdges = (a, b) => {
|
|
1631
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1632
|
+
for (const edge of [...a, ...b]) merged.set(`${edge.name}@${edge.version}|${edge.kind}`, edge);
|
|
1633
|
+
return [...merged.values()];
|
|
1634
|
+
};
|
|
1635
|
+
/**
|
|
1636
|
+
* Reads the right-hand side of a locked entry as an exact version.
|
|
1637
|
+
*
|
|
1638
|
+
* Returns the version when it is one, or the reason it is not — `file:../x`,
|
|
1639
|
+
* `workspace:*`, a git URL. `parseDependencyTarget` already classifies every
|
|
1640
|
+
* one of those for package.json parsing, so the vocabulary of skip reasons
|
|
1641
|
+
* stays identical between the two paths.
|
|
1642
|
+
*/
|
|
1643
|
+
const asExactVersion = (name, raw) => {
|
|
1644
|
+
const exact = semver.valid(raw, { loose: true });
|
|
1645
|
+
if (exact !== null) return {
|
|
1646
|
+
_tag: "Version",
|
|
1647
|
+
version: exact
|
|
1648
|
+
};
|
|
1649
|
+
const target = parseDependencyTarget(name, raw);
|
|
1650
|
+
if (target._tag === "Unsupported") return {
|
|
1651
|
+
_tag: "Unsupported",
|
|
1652
|
+
reason: target.reason
|
|
1653
|
+
};
|
|
1654
|
+
return {
|
|
1655
|
+
_tag: "Unsupported",
|
|
1656
|
+
reason: "not an exact version"
|
|
1657
|
+
};
|
|
1658
|
+
};
|
|
1659
|
+
/**
|
|
1660
|
+
* Raised when a lockfile found by walking up turns out not to cover the
|
|
1661
|
+
* package.json that went looking for it.
|
|
1662
|
+
*
|
|
1663
|
+
* Worth distinguishing from "no lockfile at all": the file exists and is
|
|
1664
|
+
* readable, it simply belongs to a different project, and bundling it would
|
|
1665
|
+
* produce someone else's dependency set.
|
|
1666
|
+
*/
|
|
1667
|
+
const notCovered = (path, importer, known) => {
|
|
1668
|
+
const listed = known.slice(0, 8).map((entry) => ` ${entry}`).join("\n");
|
|
1669
|
+
throw new LockfileError(path, `it does not cover ${importer}.\n` + (known.length === 0 ? " It records no workspace members." : ` Workspace members it does record:\n${listed}` + (known.length > 8 ? `\n … and ${known.length - 8} more` : "")));
|
|
1670
|
+
};
|
|
1671
|
+
/** `""` is how npm and bun spell the project at the lockfile's own level. */
|
|
1672
|
+
const importerKey = (importer) => importer === "." ? "" : importer;
|
|
1673
|
+
|
|
1674
|
+
//#endregion
|
|
1675
|
+
//#region src/lockfile/bun.ts
|
|
1676
|
+
/** Root blocks in bun's precedence order — the first mention of a name wins. */
|
|
1677
|
+
const ROOT_KINDS$2 = [
|
|
1678
|
+
LockedRootKind.Prod,
|
|
1679
|
+
LockedRootKind.Optional,
|
|
1680
|
+
LockedRootKind.Dev,
|
|
1681
|
+
LockedRootKind.Peer
|
|
1682
|
+
];
|
|
1683
|
+
const parseBunLockfile = (path, content, importer) => {
|
|
1684
|
+
const root = parseJsonObject(path, content);
|
|
1685
|
+
const version = String(root["lockfileVersion"] ?? "?");
|
|
1686
|
+
const builder = new TreeBuilder();
|
|
1687
|
+
const packages = root["packages"];
|
|
1688
|
+
const entries = isRecord(packages) ? collectEntries$1(builder, packages) : /* @__PURE__ */ new Map();
|
|
1689
|
+
const keys = new Set(entries.keys());
|
|
1690
|
+
for (const entry of entries.values()) builder.add({
|
|
1691
|
+
name: entry.name,
|
|
1692
|
+
version: entry.version,
|
|
1693
|
+
dependencies: edgesOf$2(builder, entries, keys, entry)
|
|
1694
|
+
});
|
|
1695
|
+
collectRoots$2(builder, entries, keys, root["workspaces"], path, importer);
|
|
1696
|
+
return builder.finish({
|
|
1697
|
+
format: LockfileFormat.Bun,
|
|
1698
|
+
lockfileVersion: version,
|
|
1699
|
+
path,
|
|
1700
|
+
importer
|
|
1701
|
+
});
|
|
1702
|
+
};
|
|
1703
|
+
/**
|
|
1704
|
+
* Narrows bun's tuple entries.
|
|
1705
|
+
*
|
|
1706
|
+
* The value is a positional array whose shape depends on where the package came
|
|
1707
|
+
* from. A registry package is `[id, registry, info, integrity]`; a workspace
|
|
1708
|
+
* member or a git dependency is shorter and its `id` carries the protocol.
|
|
1709
|
+
*/
|
|
1710
|
+
const collectEntries$1 = (builder, packages) => {
|
|
1711
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1712
|
+
for (const [key, raw] of Object.entries(packages)) {
|
|
1713
|
+
if (!Array.isArray(raw)) continue;
|
|
1714
|
+
const [id, , info] = raw;
|
|
1715
|
+
if (typeof id !== "string") continue;
|
|
1716
|
+
const at = id.lastIndexOf("@");
|
|
1717
|
+
if (at <= 0) continue;
|
|
1718
|
+
const name = id.slice(0, at);
|
|
1719
|
+
const spec = id.slice(at + 1);
|
|
1720
|
+
const version = asExactVersion(name, spec);
|
|
1721
|
+
if (version._tag === "Unsupported") {
|
|
1722
|
+
builder.skip(name, spec, version.reason);
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const details = isRecord(info) ? info : {};
|
|
1726
|
+
entries.set(key, {
|
|
1727
|
+
key,
|
|
1728
|
+
name,
|
|
1729
|
+
version: version.version,
|
|
1730
|
+
dependencies: stringRecord(details["dependencies"]),
|
|
1731
|
+
optionalDependencies: stringRecord(details["optionalDependencies"]),
|
|
1732
|
+
peerDependencies: stringRecord(details["peerDependencies"]),
|
|
1733
|
+
optionalPeers: collectOptionalPeers(details)
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
return entries;
|
|
1737
|
+
};
|
|
1738
|
+
/** bun lists optional peers as a name array rather than npm's metadata object. */
|
|
1739
|
+
const collectOptionalPeers = (details) => {
|
|
1740
|
+
const listed = details["optionalPeers"];
|
|
1741
|
+
const names = Array.isArray(listed) ? listed.flatMap((entry) => typeof entry === "string" ? entry : []) : [];
|
|
1742
|
+
return new Set([...names, ...optionalPeerNames(details["peerDependenciesMeta"])]);
|
|
1743
|
+
};
|
|
1744
|
+
const edgesOf$2 = (builder, entries, keys, entry) => {
|
|
1745
|
+
const edges = [];
|
|
1746
|
+
const add = (names, kind) => {
|
|
1747
|
+
for (const name of names) {
|
|
1748
|
+
const target = findEntry(entries, keys, entry.key, name);
|
|
1749
|
+
if (target === void 0) {
|
|
1750
|
+
if (kind === EdgeKind.Prod) builder.dangling(`${entry.name}@${entry.version}`, name);
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
edges.push({
|
|
1754
|
+
name: target.name,
|
|
1755
|
+
version: target.version,
|
|
1756
|
+
kind
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
add(Object.keys(entry.dependencies), EdgeKind.Prod);
|
|
1761
|
+
add(Object.keys(entry.optionalDependencies), EdgeKind.Optional);
|
|
1762
|
+
add(Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)), EdgeKind.Peer);
|
|
1763
|
+
return edges;
|
|
1764
|
+
};
|
|
1765
|
+
const collectRoots$2 = (builder, entries, keys, workspaces, path, member) => {
|
|
1766
|
+
if (!isRecord(workspaces)) return;
|
|
1767
|
+
const selected = member === void 0 ? Object.values(workspaces) : Object.hasOwn(workspaces, importerKey(member)) ? [workspaces[importerKey(member)]] : notCovered(path, member, workspacePaths$1(workspaces));
|
|
1768
|
+
for (const workspace of selected) {
|
|
1769
|
+
if (!isRecord(workspace)) continue;
|
|
1770
|
+
for (const kind of ROOT_KINDS$2) {
|
|
1771
|
+
const declared = stringRecord(workspace[fieldByRootKind[kind]]);
|
|
1772
|
+
for (const [name, specifier] of Object.entries(declared)) {
|
|
1773
|
+
const target = findEntry(entries, keys, "", name);
|
|
1774
|
+
if (target === void 0) continue;
|
|
1775
|
+
builder.addRoot({
|
|
1776
|
+
name: target.name,
|
|
1777
|
+
version: target.version,
|
|
1778
|
+
kind,
|
|
1779
|
+
specifier
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
const workspacePaths$1 = (workspaces) => Object.keys(workspaces).map((key) => key === "" ? "." : key);
|
|
1786
|
+
/**
|
|
1787
|
+
* bun nests with a plain `/`, and package names contain `/` too, so a parent
|
|
1788
|
+
* key cannot be found by string-splitting alone — `@babel/core` would split
|
|
1789
|
+
* into a scope that is not a key. `parentKey` only accepts a prefix that is
|
|
1790
|
+
* itself an entry, which resolves the ambiguity.
|
|
1791
|
+
*/
|
|
1792
|
+
const findEntry = (entries, keys, fromKey, name) => {
|
|
1793
|
+
let scope = fromKey;
|
|
1794
|
+
for (;;) {
|
|
1795
|
+
const found = entries.get(scope === "" ? name : `${scope}/${name}`);
|
|
1796
|
+
if (found !== void 0) return found;
|
|
1797
|
+
if (scope === "") return void 0;
|
|
1798
|
+
scope = parentKey(keys, scope);
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
const parentKey = (keys, key) => {
|
|
1802
|
+
for (let at = key.lastIndexOf("/"); at > 0; at = key.lastIndexOf("/", at - 1)) {
|
|
1803
|
+
const prefix = key.slice(0, at);
|
|
1804
|
+
if (keys.has(prefix)) return prefix;
|
|
1805
|
+
}
|
|
1806
|
+
return "";
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
//#endregion
|
|
1810
|
+
//#region src/lockfile/npm.ts
|
|
1811
|
+
/** Root blocks in npm's precedence order — the first mention of a name wins. */
|
|
1812
|
+
const ROOT_KINDS$1 = [
|
|
1813
|
+
LockedRootKind.Prod,
|
|
1814
|
+
LockedRootKind.Optional,
|
|
1815
|
+
LockedRootKind.Dev,
|
|
1816
|
+
LockedRootKind.Peer
|
|
1817
|
+
];
|
|
1818
|
+
/**
|
|
1819
|
+
* The node resolution walk-up, over a flat map of install paths.
|
|
1820
|
+
*
|
|
1821
|
+
* A package at `node_modules/a/node_modules/b` depending on `c` gets, in order,
|
|
1822
|
+
* `node_modules/a/node_modules/b/node_modules/c`, then
|
|
1823
|
+
* `node_modules/a/node_modules/c`, then `node_modules/c`. This is what makes a
|
|
1824
|
+
* range on an edge unambiguous: the nesting already recorded which copy won.
|
|
1825
|
+
*/
|
|
1826
|
+
const NESTING = "/node_modules/";
|
|
1827
|
+
const parseNpmLockfile = (path, content, importer) => {
|
|
1828
|
+
const root = parseJsonObject(path, content);
|
|
1829
|
+
const version = String(root["lockfileVersion"] ?? "?");
|
|
1830
|
+
const builder = new TreeBuilder();
|
|
1831
|
+
const packages = root["packages"];
|
|
1832
|
+
const entries = isRecord(packages) ? collectEntries(builder, packages) : collectLegacyEntries(builder, root);
|
|
1833
|
+
for (const entry of entries.values()) builder.add({
|
|
1834
|
+
name: entry.realName,
|
|
1835
|
+
version: entry.version,
|
|
1836
|
+
dependencies: edgesOf$1(builder, entries, entry)
|
|
1837
|
+
});
|
|
1838
|
+
collectRoots$1(builder, entries, isRecord(packages) ? packages : root, path, importer);
|
|
1839
|
+
return builder.finish({
|
|
1840
|
+
format: LockfileFormat.Npm,
|
|
1841
|
+
lockfileVersion: version,
|
|
1842
|
+
path,
|
|
1843
|
+
importer
|
|
1844
|
+
});
|
|
1845
|
+
};
|
|
1846
|
+
/** Narrows the v2/v3 `packages` map, dropping what cannot be bundled. */
|
|
1847
|
+
const collectEntries = (builder, packages) => {
|
|
1848
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1849
|
+
for (const [entryPath, raw] of Object.entries(packages)) {
|
|
1850
|
+
const installName = installNameFromPath(entryPath);
|
|
1851
|
+
if (installName === null) continue;
|
|
1852
|
+
if (!isRecord(raw)) continue;
|
|
1853
|
+
if (raw["link"] === true) {
|
|
1854
|
+
builder.skip(installName, stringOr(raw["resolved"], "link"), "workspace link");
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
const rawVersion = raw["version"];
|
|
1858
|
+
if (typeof rawVersion !== "string") {
|
|
1859
|
+
builder.warn(`skipped ${installName} — its lockfile entry records no version, so there is nothing to pin`);
|
|
1860
|
+
continue;
|
|
1861
|
+
}
|
|
1862
|
+
const resolvedName = typeof raw["name"] === "string" ? raw["name"] : installName;
|
|
1863
|
+
const version = asExactVersion(resolvedName, rawVersion);
|
|
1864
|
+
if (version._tag === "Unsupported") {
|
|
1865
|
+
builder.skip(resolvedName, rawVersion, version.reason);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
const resolvedUrl = raw["resolved"];
|
|
1869
|
+
if (typeof resolvedUrl === "string" && !isRegistryUrl(resolvedUrl)) {
|
|
1870
|
+
builder.skip(resolvedName, resolvedUrl, "not a registry tarball");
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
entries.set(entryPath, {
|
|
1874
|
+
path: entryPath,
|
|
1875
|
+
version: version.version,
|
|
1876
|
+
realName: resolvedName,
|
|
1877
|
+
dependencies: stringRecord(raw["dependencies"]),
|
|
1878
|
+
optionalDependencies: stringRecord(raw["optionalDependencies"]),
|
|
1879
|
+
peerDependencies: stringRecord(raw["peerDependencies"]),
|
|
1880
|
+
optionalPeers: optionalPeerNames(raw["peerDependenciesMeta"])
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
return entries;
|
|
1884
|
+
};
|
|
1885
|
+
/**
|
|
1886
|
+
* Flattens a v1 lockfile's nested `dependencies` tree into the same
|
|
1887
|
+
* path-keyed shape v2 uses.
|
|
1888
|
+
*
|
|
1889
|
+
* v1 is old — npm 6 — but it is exactly the vintage still pinned inside a lot
|
|
1890
|
+
* of the locked-down environments this tool exists for, so it is worth the
|
|
1891
|
+
* thirty lines rather than an error telling somebody to upgrade npm.
|
|
1892
|
+
*/
|
|
1893
|
+
const collectLegacyEntries = (builder, root) => {
|
|
1894
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1895
|
+
const visit = (tree, prefix) => {
|
|
1896
|
+
if (!isRecord(tree)) return;
|
|
1897
|
+
for (const [name, raw] of Object.entries(tree)) {
|
|
1898
|
+
if (!isRecord(raw)) continue;
|
|
1899
|
+
const entryPath = prefix === "" ? `node_modules/${name}` : `${prefix}/node_modules/${name}`;
|
|
1900
|
+
const rawVersion = raw["version"];
|
|
1901
|
+
if (typeof rawVersion === "string") {
|
|
1902
|
+
const version = asExactVersion(name, rawVersion);
|
|
1903
|
+
if (version._tag === "Unsupported") builder.skip(name, rawVersion, version.reason);
|
|
1904
|
+
else entries.set(entryPath, {
|
|
1905
|
+
path: entryPath,
|
|
1906
|
+
version: version.version,
|
|
1907
|
+
realName: name,
|
|
1908
|
+
dependencies: stringRecord(raw["requires"]),
|
|
1909
|
+
optionalDependencies: {},
|
|
1910
|
+
peerDependencies: {},
|
|
1911
|
+
optionalPeers: /* @__PURE__ */ new Set()
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
visit(raw["dependencies"], entryPath);
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
visit(root["dependencies"], "");
|
|
1918
|
+
return entries;
|
|
1919
|
+
};
|
|
1920
|
+
/** Turns one entry's ranges into edges, resolved through the directory nesting. */
|
|
1921
|
+
const edgesOf$1 = (builder, entries, entry) => {
|
|
1922
|
+
const edges = [];
|
|
1923
|
+
const add = (names, kind) => {
|
|
1924
|
+
for (const name of names) {
|
|
1925
|
+
const targetPath = resolveNestedEdge(entries, entry.path, name);
|
|
1926
|
+
const target = targetPath === null ? void 0 : entries.get(targetPath);
|
|
1927
|
+
if (target === void 0) {
|
|
1928
|
+
if (kind === EdgeKind.Prod) builder.dangling(`${entry.realName}@${entry.version}`, name);
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
edges.push({
|
|
1932
|
+
name: target.realName,
|
|
1933
|
+
version: target.version,
|
|
1934
|
+
kind
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
const optionalNames = Object.keys(entry.optionalDependencies);
|
|
1939
|
+
const optional = new Set(optionalNames);
|
|
1940
|
+
add(Object.keys(entry.dependencies).filter((name) => !optional.has(name)), EdgeKind.Prod);
|
|
1941
|
+
add(optionalNames, EdgeKind.Optional);
|
|
1942
|
+
add(Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)), EdgeKind.Peer);
|
|
1943
|
+
return edges;
|
|
1944
|
+
};
|
|
1945
|
+
/**
|
|
1946
|
+
* Reads the root project's direct dependencies.
|
|
1947
|
+
*
|
|
1948
|
+
* The lockfile records these under `packages[""]` for v2/v3. v1 has no such
|
|
1949
|
+
* entry, so every top-level `node_modules/x` is treated as direct — which
|
|
1950
|
+
* over-counts roots slightly on v1 and is the best that file can support.
|
|
1951
|
+
*/
|
|
1952
|
+
const collectRoots$1 = (builder, entries, source, path, importer) => {
|
|
1953
|
+
const collectFrom = (from, project) => {
|
|
1954
|
+
for (const kind of ROOT_KINDS$1) {
|
|
1955
|
+
const field = fieldByRootKind[kind];
|
|
1956
|
+
for (const [name, specifier] of Object.entries(stringRecord(project[field]))) {
|
|
1957
|
+
const targetPath = resolveNestedEdge(entries, from, name);
|
|
1958
|
+
const target = targetPath === null ? void 0 : entries.get(targetPath);
|
|
1959
|
+
if (target === void 0) continue;
|
|
1960
|
+
builder.addRoot({
|
|
1961
|
+
name: target.realName,
|
|
1962
|
+
version: target.version,
|
|
1963
|
+
kind,
|
|
1964
|
+
specifier
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
if (importer !== void 0) {
|
|
1970
|
+
const key = importerKey(importer);
|
|
1971
|
+
const project = source[key];
|
|
1972
|
+
if (!isRecord(project)) notCovered(path, importer, workspacePaths(source));
|
|
1973
|
+
else collectFrom(key, project);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
const projects = Object.entries(source).flatMap(([key, value]) => isRecord(value) && !key.includes("node_modules/") ? [[key, value]] : []);
|
|
1977
|
+
if (projects.length > 0) {
|
|
1978
|
+
for (const [key, project] of projects) collectFrom(key, project);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
for (const entry of entries.values()) {
|
|
1982
|
+
if (entry.path.includes(NESTING)) continue;
|
|
1983
|
+
builder.addRoot({
|
|
1984
|
+
name: entry.realName,
|
|
1985
|
+
version: entry.version,
|
|
1986
|
+
kind: LockedRootKind.Prod
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
/** The workspace paths a v2/v3 lockfile records, for a "does not cover" message. */
|
|
1991
|
+
const workspacePaths = (source) => Object.keys(source).flatMap((key) => key === "" ? "." : key.includes("node_modules/") ? [] : key);
|
|
1992
|
+
/**
|
|
1993
|
+
* npm records non-registry sources as a URL in `resolved`. Registry tarballs
|
|
1994
|
+
* are ordinary http(s); anything with a `git+`, `file:` or bare path form is
|
|
1995
|
+
* something we cannot fetch.
|
|
1996
|
+
*/
|
|
1997
|
+
const isRegistryUrl = (url) => url.startsWith("http://") || url.startsWith("https://");
|
|
1998
|
+
const resolveNestedEdge = (entries, fromPath, name) => {
|
|
1999
|
+
let scope = fromPath;
|
|
2000
|
+
for (;;) {
|
|
2001
|
+
const candidate = scope === "" ? `node_modules/${name}` : `${scope}${NESTING}${name}`;
|
|
2002
|
+
if (entries.has(candidate)) return candidate;
|
|
2003
|
+
if (scope === "") return null;
|
|
2004
|
+
const at = scope.lastIndexOf(NESTING);
|
|
2005
|
+
scope = at === -1 ? "" : scope.slice(0, at);
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
/** `node_modules/@babel/core` -> `@babel/core`; a workspace path -> `null`. */
|
|
2009
|
+
const installNameFromPath = (entryPath) => {
|
|
2010
|
+
const at = entryPath.lastIndexOf("node_modules/");
|
|
2011
|
+
if (at === -1) return null;
|
|
2012
|
+
const name = entryPath.slice(at + 13);
|
|
2013
|
+
return name.length === 0 ? null : name;
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
//#endregion
|
|
2017
|
+
//#region src/lockfile/pnpm.ts
|
|
2018
|
+
/** pnpm never records peer dependencies against an importer. */
|
|
2019
|
+
const ROOT_KINDS = [
|
|
2020
|
+
LockedRootKind.Prod,
|
|
2021
|
+
LockedRootKind.Optional,
|
|
2022
|
+
LockedRootKind.Dev
|
|
2023
|
+
];
|
|
2024
|
+
const parsePnpmLockfile = (path, content, importer) => {
|
|
2025
|
+
let root;
|
|
2026
|
+
try {
|
|
2027
|
+
root = parse(content);
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
throw new LockfileError(path, `not valid YAML — ${describeError$1(error)}`, { cause: error });
|
|
2030
|
+
}
|
|
2031
|
+
if (!isRecord(root)) throw new LockfileError(path, "top level is not a mapping");
|
|
2032
|
+
const version = String(root["lockfileVersion"] ?? "?");
|
|
2033
|
+
const builder = new TreeBuilder();
|
|
2034
|
+
const packages = isRecord(root["packages"]) ? root["packages"] : {};
|
|
2035
|
+
const snapshots = isRecord(root["snapshots"]) ? root["snapshots"] : packages;
|
|
2036
|
+
const known = /* @__PURE__ */ new Map();
|
|
2037
|
+
for (const key of Object.keys(packages)) {
|
|
2038
|
+
const parsed = parsePnpmKey(key);
|
|
2039
|
+
if (parsed === null) continue;
|
|
2040
|
+
known.set(key, parsed);
|
|
2041
|
+
}
|
|
2042
|
+
for (const [key, raw] of Object.entries(snapshots)) {
|
|
2043
|
+
const parsed = parsePnpmKey(key) ?? known.get(key);
|
|
2044
|
+
if (parsed === void 0 || parsed === null) {
|
|
2045
|
+
const described = describeKey(key);
|
|
2046
|
+
if (described !== null) builder.skip(described.name, described.spec, described.reason);
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
builder.add({
|
|
2050
|
+
name: parsed.name,
|
|
2051
|
+
version: parsed.version,
|
|
2052
|
+
dependencies: edgesOf(builder, parsed, isRecord(raw) ? raw : {})
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
collectRoots(builder, root, path, importer);
|
|
2056
|
+
return builder.finish({
|
|
2057
|
+
format: LockfileFormat.Pnpm,
|
|
2058
|
+
lockfileVersion: version,
|
|
2059
|
+
path,
|
|
2060
|
+
importer
|
|
2061
|
+
});
|
|
2062
|
+
};
|
|
2063
|
+
const edgesOf = (builder, from, snapshot) => {
|
|
2064
|
+
const edges = [];
|
|
2065
|
+
const add = (field, kind) => {
|
|
2066
|
+
const entries = snapshot[field];
|
|
2067
|
+
if (!isRecord(entries)) return;
|
|
2068
|
+
for (const [name, raw] of Object.entries(entries)) {
|
|
2069
|
+
if (typeof raw !== "string") continue;
|
|
2070
|
+
const target = parsePnpmReference(name, raw);
|
|
2071
|
+
if (target === null) {
|
|
2072
|
+
if (kind === EdgeKind.Prod) builder.dangling(`${from.name}@${from.version}`, name);
|
|
2073
|
+
continue;
|
|
2074
|
+
}
|
|
2075
|
+
edges.push({
|
|
2076
|
+
...target,
|
|
2077
|
+
kind
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
add(fieldByRootKind[LockedRootKind.Prod], EdgeKind.Prod);
|
|
2082
|
+
add(fieldByRootKind[LockedRootKind.Optional], EdgeKind.Optional);
|
|
2083
|
+
add(fieldByRootKind[LockedRootKind.Peer], EdgeKind.Peer);
|
|
2084
|
+
return edges;
|
|
2085
|
+
};
|
|
2086
|
+
/**
|
|
2087
|
+
* The importers' direct dependencies become the roots.
|
|
2088
|
+
*
|
|
2089
|
+
* pnpm is the format that makes this easy: `importers` is already keyed by the
|
|
2090
|
+
* path each package.json sits at, so scoping to one member is a lookup rather
|
|
2091
|
+
* than a reconstruction.
|
|
2092
|
+
*/
|
|
2093
|
+
const collectRoots = (builder, root, path, member) => {
|
|
2094
|
+
const importers = isRecord(root["importers"]) ? root["importers"] : { ".": root };
|
|
2095
|
+
const selected = member === void 0 ? Object.values(importers) : Object.hasOwn(importers, member) ? [importers[member]] : notCovered(path, member, Object.keys(importers));
|
|
2096
|
+
for (const importer of selected) {
|
|
2097
|
+
if (!isRecord(importer)) continue;
|
|
2098
|
+
for (const kind of ROOT_KINDS) {
|
|
2099
|
+
const entries = importer[fieldByRootKind[kind]];
|
|
2100
|
+
if (!isRecord(entries)) continue;
|
|
2101
|
+
for (const [name, raw] of Object.entries(entries)) {
|
|
2102
|
+
const value = typeof raw === "string" ? raw : isRecord(raw) ? raw["version"] : void 0;
|
|
2103
|
+
if (typeof value !== "string") continue;
|
|
2104
|
+
const target = parsePnpmReference(name, value);
|
|
2105
|
+
if (target === null) continue;
|
|
2106
|
+
const specifier = isRecord(raw) && typeof raw["specifier"] === "string" ? raw["specifier"] : void 0;
|
|
2107
|
+
builder.addRoot({
|
|
2108
|
+
...target,
|
|
2109
|
+
kind,
|
|
2110
|
+
specifier
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
/**
|
|
2117
|
+
* Reads a dependency value from a snapshot or importer.
|
|
2118
|
+
*
|
|
2119
|
+
* The value is either a bare version (`1.4.0`) or a full package key
|
|
2120
|
+
* (`/lodash@4.17.21`, used when the install name is an alias of another
|
|
2121
|
+
* package). Both may carry a peer-resolution suffix in parentheses.
|
|
2122
|
+
*/
|
|
2123
|
+
const parsePnpmReference = (name, raw) => {
|
|
2124
|
+
const value = stripSuffix(raw);
|
|
2125
|
+
const exact = semver.valid(value, { loose: true });
|
|
2126
|
+
if (exact !== null) return {
|
|
2127
|
+
name,
|
|
2128
|
+
version: exact
|
|
2129
|
+
};
|
|
2130
|
+
return parsePnpmKey(raw);
|
|
2131
|
+
};
|
|
2132
|
+
/**
|
|
2133
|
+
* Splits a package key into name and version.
|
|
2134
|
+
*
|
|
2135
|
+
* Three encodings across the versions still in the wild:
|
|
2136
|
+
* v5 `/@babel/core/7.0.0`
|
|
2137
|
+
* v6 `/@babel/core@7.0.0`
|
|
2138
|
+
* v9 `@babel/core@7.0.0`
|
|
2139
|
+
*
|
|
2140
|
+
* plus an optional `(peer@1.0.0)` or `(patch_hash=…)` suffix on any of them.
|
|
2141
|
+
*/
|
|
2142
|
+
const parsePnpmKey = (key) => {
|
|
2143
|
+
const body = stripSuffix(key.startsWith("/") ? key.slice(1) : key);
|
|
2144
|
+
const at = body.lastIndexOf("@");
|
|
2145
|
+
const cut = at > 0 ? at : body.lastIndexOf("/");
|
|
2146
|
+
if (cut <= 0) return null;
|
|
2147
|
+
const name = body.slice(0, cut);
|
|
2148
|
+
const version = semver.valid(body.slice(cut + 1), { loose: true });
|
|
2149
|
+
if (version === null || name.length === 0) return null;
|
|
2150
|
+
return {
|
|
2151
|
+
name,
|
|
2152
|
+
version
|
|
2153
|
+
};
|
|
2154
|
+
};
|
|
2155
|
+
/** Describes a key we cannot pin, so the skip warning can say why. */
|
|
2156
|
+
const describeKey = (key) => {
|
|
2157
|
+
const body = key.startsWith("/") ? key.slice(1) : key;
|
|
2158
|
+
const at = body.lastIndexOf("@");
|
|
2159
|
+
if (at <= 0) return null;
|
|
2160
|
+
const name = body.slice(0, at);
|
|
2161
|
+
const spec = body.slice(at + 1);
|
|
2162
|
+
const target = parseDependencyTarget(name, spec);
|
|
2163
|
+
return {
|
|
2164
|
+
name,
|
|
2165
|
+
spec,
|
|
2166
|
+
reason: target._tag === "Unsupported" ? target.reason : "not an exact version"
|
|
2167
|
+
};
|
|
2168
|
+
};
|
|
2169
|
+
/**
|
|
2170
|
+
* Drops the parenthesised peer-resolution suffix.
|
|
2171
|
+
*
|
|
2172
|
+
* pnpm distinguishes the same version installed against different peers —
|
|
2173
|
+
* `react-dom@18.3.1(react@18.3.1)`. Those are the same published tarball, which
|
|
2174
|
+
* is all this tool downloads, so the suffix is noise here.
|
|
2175
|
+
*/
|
|
2176
|
+
const stripSuffix = (value) => {
|
|
2177
|
+
const open = value.indexOf("(");
|
|
2178
|
+
return open === -1 ? value : value.slice(0, open);
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
//#endregion
|
|
2182
|
+
//#region src/lockfile/parse.ts
|
|
2183
|
+
/**
|
|
2184
|
+
* Parses lockfile content into a flat graph.
|
|
2185
|
+
*
|
|
2186
|
+
* Throws `LockfileError`; the caller is expected to be inside an `Effect.try`.
|
|
2187
|
+
*/
|
|
2188
|
+
const parseLockfile = (path, content, format, options = {}) => {
|
|
2189
|
+
const resolved = format ?? resolveFormat(path, content);
|
|
2190
|
+
const importer = options.importer;
|
|
2191
|
+
switch (resolved) {
|
|
2192
|
+
case LockfileFormat.Npm: return parseNpmLockfile(path, content, importer);
|
|
2193
|
+
case LockfileFormat.Pnpm: return parsePnpmLockfile(path, content, importer);
|
|
2194
|
+
case LockfileFormat.Bun: return parseBunLockfile(path, content, importer);
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
const resolveFormat = (path, content) => {
|
|
2198
|
+
const detected = detectLockfile(content);
|
|
2199
|
+
if (detected._tag === "Supported") return detected.format;
|
|
2200
|
+
if (detected._tag === "Unsupported") throw new LockfileError(path, `${detected.label} — ${detected.hint}`);
|
|
2201
|
+
throw new LockfileError(path, "content does not look like a package-lock.json, pnpm-lock.yaml or bun.lock");
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
//#endregion
|
|
2205
|
+
//#region src/schemas/package-json.ts
|
|
2206
|
+
const PackageJsonSchema = Schema.Struct({
|
|
2207
|
+
dependencies: tolerant(StringRecordSchema),
|
|
2208
|
+
devDependencies: tolerant(StringRecordSchema),
|
|
2209
|
+
optionalDependencies: tolerant(StringRecordSchema),
|
|
2210
|
+
peerDependencies: tolerant(StringRecordSchema),
|
|
2211
|
+
peerDependenciesMeta: tolerant(OptionalFlagRecordSchema)
|
|
2212
|
+
});
|
|
2213
|
+
const decodePackageJson = Schema.decodeUnknownOption(PackageJsonSchema);
|
|
2214
|
+
|
|
2215
|
+
//#endregion
|
|
2216
|
+
//#region src/input-file.ts
|
|
2217
|
+
const defaultInputFileOptions = { includeDev: true };
|
|
2218
|
+
/** Reads and parses an input file. */
|
|
2219
|
+
const readInputFile = (filePath, options = defaultInputFileOptions) => Effect.gen(function* () {
|
|
2220
|
+
const fs = yield* FileSystem.FileSystem;
|
|
2221
|
+
if (!(yield* fs.exists(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))))) return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"));
|
|
2222
|
+
const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause })));
|
|
2223
|
+
return yield* Effect.try({
|
|
2224
|
+
try: () => parseInputFile(filePath, content, options),
|
|
2225
|
+
catch: (error) => error instanceof InvalidInputFileError || isLockfileError(error) ? error : new InvalidInputFileError(filePath, describeError(error), { cause: error })
|
|
2226
|
+
});
|
|
2227
|
+
});
|
|
2228
|
+
const isLockfileError = (error) => error instanceof Error && "_tag" in error && error._tag === "LockfileError";
|
|
2229
|
+
/**
|
|
2230
|
+
* Parses file content that has already been read.
|
|
2231
|
+
*
|
|
2232
|
+
* Split out from the IO so the whole of this logic is testable with plain
|
|
2233
|
+
* strings and no file system at all.
|
|
2234
|
+
*/
|
|
2235
|
+
const parseInputFile = (filePath, content, options = defaultInputFileOptions) => {
|
|
2236
|
+
const trimmed = content.trim();
|
|
2237
|
+
if (trimmed.length === 0) throw new InvalidInputFileError(filePath, "file is empty");
|
|
2238
|
+
const detected = detectLockfile(trimmed);
|
|
2239
|
+
if (detected._tag === "Unsupported") throw new InvalidInputFileError(filePath, `${detected.label} — ${detected.hint}`);
|
|
2240
|
+
if (detected._tag === "Supported") return fromLockfile(filePath, content, detected.format, options);
|
|
2241
|
+
return looksLikeJson(trimmed) ? fromPackageJson(filePath, trimmed, options) : fromList(filePath, content);
|
|
2242
|
+
};
|
|
2243
|
+
const looksLikeJson = (trimmed) => trimmed.startsWith("{");
|
|
2244
|
+
const fromLockfile = (filePath, content, format, options) => {
|
|
2245
|
+
const lockfile = parseLockfile(filePath, content, format, { importer: options.importer });
|
|
2246
|
+
const roots = lockfile.roots.filter((root) => options.includeDev || root.kind !== "dev");
|
|
2247
|
+
return {
|
|
2248
|
+
kind: InputFileKind.Lockfile,
|
|
2249
|
+
specs: roots.map((root) => ({
|
|
2250
|
+
name: root.name,
|
|
2251
|
+
selector: {
|
|
2252
|
+
_tag: "Exact",
|
|
2253
|
+
version: root.version
|
|
2254
|
+
},
|
|
2255
|
+
raw: `${root.name}@${root.version}`
|
|
2256
|
+
})),
|
|
2257
|
+
warnings: lockfile.warnings,
|
|
2258
|
+
lockfile
|
|
2259
|
+
};
|
|
2260
|
+
};
|
|
2261
|
+
const fromPackageJson = (filePath, content, options) => {
|
|
2262
|
+
const parsed = parsePackageJson(filePath, content);
|
|
2263
|
+
const specs = [];
|
|
2264
|
+
const warnings = [];
|
|
2265
|
+
const required = [];
|
|
2266
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2267
|
+
const collect = (entries, label, skip) => {
|
|
2268
|
+
if (entries === void 0) return;
|
|
2269
|
+
for (const [name, raw] of Object.entries(entries)) {
|
|
2270
|
+
if (skip?.(name)) continue;
|
|
2271
|
+
const target = parseDependencyTarget(name, raw);
|
|
2272
|
+
if (target._tag === "Unsupported") {
|
|
2273
|
+
warnings.push(`${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`);
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (label === "dependencies" || label === "devDependencies") required.push(target.name);
|
|
2277
|
+
const key = `${target.name}|${JSON.stringify(target.selector)}`;
|
|
2278
|
+
if (seen.has(key)) continue;
|
|
2279
|
+
seen.add(key);
|
|
2280
|
+
specs.push({
|
|
2281
|
+
name: target.name,
|
|
2282
|
+
selector: target.selector,
|
|
2283
|
+
raw: `${target.name}@${raw}`
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
collect(parsed.dependencies, "dependencies");
|
|
2288
|
+
collect(parsed.optionalDependencies, "optionalDependencies");
|
|
2289
|
+
if (options.includeDev) collect(parsed.devDependencies, "devDependencies");
|
|
2290
|
+
const meta = parsed.peerDependenciesMeta ?? {};
|
|
2291
|
+
collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true);
|
|
2292
|
+
if (specs.length === 0) throw new InvalidInputFileError(filePath, options.includeDev ? "package.json declares no dependencies to bundle" : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)");
|
|
2293
|
+
return {
|
|
2294
|
+
kind: InputFileKind.PackageJson,
|
|
2295
|
+
specs,
|
|
2296
|
+
warnings,
|
|
2297
|
+
required
|
|
2298
|
+
};
|
|
2299
|
+
};
|
|
2300
|
+
/**
|
|
2301
|
+
* Reads a package.json, or gives up on its contents rather than on the run.
|
|
2302
|
+
*
|
|
2303
|
+
* Invalid JSON is an error worth raising — somebody pointed `--file` at a
|
|
2304
|
+
* broken file. A well-formed JSON document this schema cannot recognise is
|
|
2305
|
+
* not: it decodes to no blocks, and the caller reports "declares no
|
|
2306
|
+
* dependencies to bundle", which is both true and more useful.
|
|
2307
|
+
*/
|
|
2308
|
+
const parsePackageJson = (filePath, content) => {
|
|
2309
|
+
let parsed;
|
|
2310
|
+
try {
|
|
2311
|
+
parsed = JSON.parse(content);
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
throw new InvalidInputFileError(filePath, `not valid JSON — ${describeError(error)}`, { cause: error });
|
|
2314
|
+
}
|
|
2315
|
+
return Option.getOrElse(decodePackageJson(parsed), () => ({}));
|
|
2316
|
+
};
|
|
2317
|
+
const fromList = (filePath, content) => {
|
|
2318
|
+
const rawLines = content.split(/\r?\n/);
|
|
2319
|
+
const lines = rawLines.flatMap((line) => {
|
|
2320
|
+
const withoutComment = stripComment(line).trim();
|
|
2321
|
+
return withoutComment.length === 0 ? [] : withoutComment;
|
|
2322
|
+
});
|
|
2323
|
+
if (lines.length === 0) throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)");
|
|
2324
|
+
const { specs, errors } = parseSpecs(lines);
|
|
2325
|
+
if (errors.length > 0) throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines));
|
|
2326
|
+
return {
|
|
2327
|
+
kind: InputFileKind.List,
|
|
2328
|
+
specs,
|
|
2329
|
+
warnings: []
|
|
2330
|
+
};
|
|
2331
|
+
};
|
|
2332
|
+
/**
|
|
2333
|
+
* Strips `#` and `//` comments.
|
|
2334
|
+
*
|
|
2335
|
+
* `//` only counts when it is not part of a URL, so a future line containing
|
|
2336
|
+
* `https://…` does not get silently truncated.
|
|
2337
|
+
*/
|
|
2338
|
+
const stripComment = (line) => {
|
|
2339
|
+
const hash = line.indexOf("#");
|
|
2340
|
+
const result = hash === -1 ? line : line.slice(0, hash);
|
|
2341
|
+
const slashes = result.indexOf("//");
|
|
2342
|
+
if (slashes === 0) return "";
|
|
2343
|
+
if (slashes > 0 && result[slashes - 1] !== ":") return result.slice(0, slashes);
|
|
2344
|
+
return result;
|
|
2345
|
+
};
|
|
2346
|
+
const formatSpecErrors = (errors, rawLines) => {
|
|
2347
|
+
const details = errors.map((error) => {
|
|
2348
|
+
const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec);
|
|
2349
|
+
const where = index === -1 ? "" : ` (line ${index + 1})`;
|
|
2350
|
+
return ` ${error.spec}${where}: ${error.reason}`;
|
|
2351
|
+
});
|
|
2352
|
+
return `${errors.length} invalid spec(s):\n${details.join("\n")}`;
|
|
2353
|
+
};
|
|
2354
|
+
const describeError = (error) => error instanceof Error ? error.message : String(error);
|
|
2355
|
+
|
|
2356
|
+
//#endregion
|
|
2357
|
+
//#region src/download.ts
|
|
2358
|
+
/**
|
|
2359
|
+
* Downloads every package into `destDir`, laid out the way a registry serves
|
|
2360
|
+
* them.
|
|
2361
|
+
*
|
|
2362
|
+
* `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
|
|
2363
|
+
* makes it into a corporate registry is far more expensive than a failed run.
|
|
2364
|
+
*/
|
|
2365
|
+
const downloadAll = (packages, destDir, options) => Effect.gen(function* () {
|
|
2366
|
+
const fs = yield* FileSystem.FileSystem;
|
|
2367
|
+
const path = yield* Path.Path;
|
|
2368
|
+
const registry = yield* Registry;
|
|
2369
|
+
yield* emit({
|
|
2370
|
+
_tag: "PhaseStarted",
|
|
2371
|
+
phase: Phase.Download,
|
|
2372
|
+
total: packages.length
|
|
1257
2373
|
});
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}),
|
|
1268
|
-
catch: (error) => error
|
|
2374
|
+
let completed = 0;
|
|
2375
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
2376
|
+
const unverified = [];
|
|
2377
|
+
const results = yield* Effect.forEach(packages, (pkg) => Effect.gen(function* () {
|
|
2378
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
2379
|
+
yield* emit({
|
|
2380
|
+
_tag: "DownloadStarted",
|
|
2381
|
+
name: pkg.name,
|
|
2382
|
+
version: pkg.version
|
|
1269
2383
|
});
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
},
|
|
1279
|
-
reason: {
|
|
1280
|
-
_tag: "Root",
|
|
1281
|
-
spec: formatSpec(spec)
|
|
1282
|
-
},
|
|
1283
|
-
tolerateFailure: false
|
|
1284
|
-
}], options);
|
|
1285
|
-
closures.set(version, closure);
|
|
1286
|
-
for (const key of closure) union.add(key);
|
|
2384
|
+
const bytes = yield* registry.download(pkg.manifest);
|
|
2385
|
+
if (options.verifyIntegrity) {
|
|
2386
|
+
const result = yield* verify(bytes, pkg.manifest.dist);
|
|
2387
|
+
if (result._tag === "Mismatch") return yield* Effect.fail(new IntegrityError(pkg.name, pkg.version, result.expected, result.actual));
|
|
2388
|
+
if (result._tag === "Unverifiable") yield* emit({
|
|
2389
|
+
_tag: "Warning",
|
|
2390
|
+
message: `${key} could not be verified (${result.reason})`
|
|
2391
|
+
});
|
|
1287
2392
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
2393
|
+
const verifiable = (pkg.manifest.dist.integrity?.length ?? 0) > 0 || (pkg.manifest.dist.shasum?.length ?? 0) > 0;
|
|
2394
|
+
const relative = packagePath(pkg.name, pkg.version);
|
|
2395
|
+
const target = path.join(destDir, ...relative.split("/"));
|
|
2396
|
+
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new OutputError(path.dirname(target), "could not create directory", { cause })));
|
|
2397
|
+
yield* fs.writeFile(target, bytes).pipe(Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause })));
|
|
2398
|
+
completed += 1;
|
|
2399
|
+
yield* emit({
|
|
2400
|
+
_tag: "DownloadCompleted",
|
|
2401
|
+
name: pkg.name,
|
|
2402
|
+
version: pkg.version,
|
|
2403
|
+
bytes: bytes.byteLength,
|
|
2404
|
+
completedCount: completed,
|
|
2405
|
+
totalCount: packages.length
|
|
1293
2406
|
});
|
|
2407
|
+
return {
|
|
2408
|
+
key,
|
|
2409
|
+
bytes: bytes.byteLength,
|
|
2410
|
+
verifiable
|
|
2411
|
+
};
|
|
2412
|
+
}), { concurrency: options.concurrency });
|
|
2413
|
+
for (const result of results) {
|
|
2414
|
+
sizes.set(result.key, result.bytes);
|
|
2415
|
+
if (!result.verifiable) unverified.push(result.key);
|
|
1294
2416
|
}
|
|
1295
2417
|
yield* emit({
|
|
1296
2418
|
_tag: "PhaseCompleted",
|
|
1297
|
-
phase:
|
|
2419
|
+
phase: Phase.Download
|
|
1298
2420
|
});
|
|
1299
2421
|
return {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
2422
|
+
sizes,
|
|
2423
|
+
unverified,
|
|
2424
|
+
totalBytes: results.reduce((sum, result) => sum + result.bytes, 0)
|
|
1303
2425
|
};
|
|
1304
2426
|
});
|
|
1305
|
-
const compareResolved = (a, b) => {
|
|
1306
|
-
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
|
|
1307
|
-
return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
|
|
1308
|
-
};
|
|
1309
2427
|
|
|
1310
2428
|
//#endregion
|
|
1311
|
-
//#region src/
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
2429
|
+
//#region src/bundle.ts
|
|
2430
|
+
/**
|
|
2431
|
+
* The archives `per-spec` emits — one per distinct root package version.
|
|
2432
|
+
*
|
|
2433
|
+
* Roots are *specs*, and several specs can name one package: `react`,
|
|
2434
|
+
* `react@latest` and `react@19.2.8` are three ways of asking for the same
|
|
2435
|
+
* thing, and after resolution they are indistinguishable. Without this they
|
|
2436
|
+
* were three archives — the same bytes, under the same file name, written over
|
|
2437
|
+
* each other. The run then reported three artifacts and three times the size
|
|
2438
|
+
* for one file on disk, and the duplication ratio it computes to decide whether
|
|
2439
|
+
* to ask about the layout counted them too, so the tool would offer to
|
|
2440
|
+
* deduplicate a bundle it had duplicated itself.
|
|
2441
|
+
*
|
|
2442
|
+
* Keyed by name and version rather than by the archive's file name. A scoped
|
|
2443
|
+
* name is flattened to make a file name, so `@foo/bar` and `foo-bar` can spell
|
|
2444
|
+
* the same one — and those are two different packages, which should collide
|
|
2445
|
+
* loudly rather than being merged here.
|
|
2446
|
+
*
|
|
2447
|
+
* Roots only. The packages *inside* an archive were deduplicated by resolution
|
|
2448
|
+
* long before this, which is why the same closure fetched from twenty specs is
|
|
2449
|
+
* fetched once.
|
|
2450
|
+
*/
|
|
2451
|
+
const perSpecTargets = (resolution) => {
|
|
2452
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2453
|
+
return resolution.roots.flatMap((root) => root.versions.flatMap((version) => {
|
|
2454
|
+
const key = `${root.spec.name}@${version}`;
|
|
2455
|
+
if (seen.has(key)) return [];
|
|
2456
|
+
seen.add(key);
|
|
2457
|
+
return {
|
|
2458
|
+
root,
|
|
2459
|
+
version
|
|
2460
|
+
};
|
|
2461
|
+
}));
|
|
2462
|
+
};
|
|
1320
2463
|
/** Computes the plan summary for a resolution. */
|
|
1321
2464
|
const summarize = (resolution) => {
|
|
1322
2465
|
let perSpecArchives = 0;
|
|
1323
2466
|
let perSpecEntries = 0;
|
|
1324
|
-
for (const root
|
|
2467
|
+
for (const { root, version } of perSpecTargets(resolution)) {
|
|
1325
2468
|
perSpecArchives += 1;
|
|
1326
2469
|
perSpecEntries += (root.closures.get(version) ?? []).length;
|
|
1327
2470
|
}
|
|
@@ -1345,17 +2488,33 @@ const withLayout = (options, layout) => ({
|
|
|
1345
2488
|
* expected.
|
|
1346
2489
|
*/
|
|
1347
2490
|
const plan = (specs, options) => Effect.gen(function* () {
|
|
2491
|
+
yield* preflight;
|
|
2492
|
+
return yield* resolve(specs, options);
|
|
2493
|
+
});
|
|
2494
|
+
/**
|
|
2495
|
+
* The lockfile counterpart of `plan`.
|
|
2496
|
+
*
|
|
2497
|
+
* Same preflight, same `Resolution` out; the difference is entirely in how the
|
|
2498
|
+
* package set is arrived at. Kept as a separate entry point rather than an
|
|
2499
|
+
* option on `plan` because the two take genuinely different inputs — a set of
|
|
2500
|
+
* specs to satisfy versus a graph to reproduce — and blurring that is how a
|
|
2501
|
+
* "pinned" run quietly starts resolving ranges again.
|
|
2502
|
+
*/
|
|
2503
|
+
const planLocked = (lockfile, options) => Effect.gen(function* () {
|
|
2504
|
+
yield* preflight;
|
|
2505
|
+
return yield* resolveLocked(lockfile, options);
|
|
2506
|
+
});
|
|
2507
|
+
const preflight = Effect.gen(function* () {
|
|
1348
2508
|
const registry = yield* Registry;
|
|
1349
2509
|
yield* emit({
|
|
1350
2510
|
_tag: "PhaseStarted",
|
|
1351
|
-
phase:
|
|
2511
|
+
phase: Phase.Preflight
|
|
1352
2512
|
});
|
|
1353
2513
|
yield* registry.preflight;
|
|
1354
2514
|
yield* emit({
|
|
1355
2515
|
_tag: "PhaseCompleted",
|
|
1356
|
-
phase:
|
|
2516
|
+
phase: Phase.Preflight
|
|
1357
2517
|
});
|
|
1358
|
-
return yield* resolve(specs, options);
|
|
1359
2518
|
});
|
|
1360
2519
|
/**
|
|
1361
2520
|
* Resolves, downloads and packages a set of specs.
|
|
@@ -1367,6 +2526,10 @@ const plan = (specs, options) => Effect.gen(function* () {
|
|
|
1367
2526
|
const bundle = (specs, options) => Effect.gen(function* () {
|
|
1368
2527
|
return yield* bundleResolved(yield* plan(specs, options), options);
|
|
1369
2528
|
});
|
|
2529
|
+
/** `bundle`, pinned to a lockfile. */
|
|
2530
|
+
const bundleLocked = (lockfile, options) => Effect.gen(function* () {
|
|
2531
|
+
return yield* bundleResolved(yield* planLocked(lockfile, options), options);
|
|
2532
|
+
});
|
|
1370
2533
|
/**
|
|
1371
2534
|
* Downloads and packages an already-computed resolution.
|
|
1372
2535
|
*
|
|
@@ -1384,6 +2547,21 @@ const bundleResolved = (resolution, options) => Effect.gen(function* () {
|
|
|
1384
2547
|
};
|
|
1385
2548
|
return yield* Effect.scoped(runBundle(resolution, options));
|
|
1386
2549
|
});
|
|
2550
|
+
/**
|
|
2551
|
+
* The files a run will write.
|
|
2552
|
+
*
|
|
2553
|
+
* Computed before anything is downloaded, so a collision is reported in the
|
|
2554
|
+
* first second rather than after a twenty-minute transfer.
|
|
2555
|
+
*/
|
|
2556
|
+
const plannedOutputs = (resolution, options) => {
|
|
2557
|
+
if (options.layout === Layout.Dir) return [{ file: MANIFEST_FILE }, { file: README_FILE }];
|
|
2558
|
+
if (options.layout === Layout.Single) return [{ file: singleArchiveName(options.archiveName ?? "bundle") }];
|
|
2559
|
+
return perSpecTargets(resolution).map(({ root, version }) => ({
|
|
2560
|
+
file: perSpecArchiveName(root.spec.name, version),
|
|
2561
|
+
name: root.spec.name,
|
|
2562
|
+
version
|
|
2563
|
+
}));
|
|
2564
|
+
};
|
|
1387
2565
|
const runBundle = (resolution, options) => Effect.gen(function* () {
|
|
1388
2566
|
const fs = yield* FileSystem.FileSystem;
|
|
1389
2567
|
const path = yield* Path.Path;
|
|
@@ -1398,42 +2576,25 @@ const runBundle = (resolution, options) => Effect.gen(function* () {
|
|
|
1398
2576
|
});
|
|
1399
2577
|
yield* emit({
|
|
1400
2578
|
_tag: "PhaseStarted",
|
|
1401
|
-
phase:
|
|
2579
|
+
phase: Phase.Archive
|
|
1402
2580
|
});
|
|
1403
|
-
const
|
|
1404
|
-
kind: registry.kind,
|
|
1405
|
-
url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
|
|
1406
|
-
};
|
|
1407
|
-
const index = indexPackages(resolution);
|
|
1408
|
-
const artifacts = options.layout === "single" ? yield* emitSingle({
|
|
1409
|
-
resolution,
|
|
1410
|
-
options,
|
|
1411
|
-
staging,
|
|
1412
|
-
packagesDir,
|
|
1413
|
-
report,
|
|
1414
|
-
registryInfo
|
|
1415
|
-
}) : options.layout === "dir" ? yield* emitDirectory({
|
|
1416
|
-
resolution,
|
|
1417
|
-
options,
|
|
1418
|
-
packagesDir,
|
|
1419
|
-
report,
|
|
1420
|
-
registryInfo
|
|
1421
|
-
}) : yield* emitPerSpec({
|
|
2581
|
+
const artifacts = yield* emit$1({
|
|
1422
2582
|
resolution,
|
|
1423
2583
|
options,
|
|
1424
|
-
staging,
|
|
1425
2584
|
packagesDir,
|
|
1426
2585
|
report,
|
|
1427
|
-
registryInfo
|
|
1428
|
-
|
|
1429
|
-
|
|
2586
|
+
registryInfo: {
|
|
2587
|
+
kind: registry.kind,
|
|
2588
|
+
url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
|
|
2589
|
+
}
|
|
2590
|
+
}, staging);
|
|
1430
2591
|
yield* emit({
|
|
1431
2592
|
_tag: "PhaseCompleted",
|
|
1432
|
-
phase:
|
|
2593
|
+
phase: Phase.Archive
|
|
1433
2594
|
});
|
|
1434
2595
|
yield* emit({
|
|
1435
2596
|
_tag: "PhaseStarted",
|
|
1436
|
-
phase:
|
|
2597
|
+
phase: Phase.Done
|
|
1437
2598
|
});
|
|
1438
2599
|
return {
|
|
1439
2600
|
resolution,
|
|
@@ -1443,6 +2604,13 @@ const runBundle = (resolution, options) => Effect.gen(function* () {
|
|
|
1443
2604
|
dryRun: false
|
|
1444
2605
|
};
|
|
1445
2606
|
});
|
|
2607
|
+
const emit$1 = (input, staging) => {
|
|
2608
|
+
switch (input.options.layout) {
|
|
2609
|
+
case Layout.Single: return emitSingle(input);
|
|
2610
|
+
case Layout.Dir: return emitDirectory(input);
|
|
2611
|
+
case Layout.PerSpec: return emitPerSpec(input, staging);
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
1446
2614
|
/** One tarball containing the deduplicated union of every closure. */
|
|
1447
2615
|
const emitSingle = (input) => Effect.gen(function* () {
|
|
1448
2616
|
const path = yield* Path.Path;
|
|
@@ -1463,7 +2631,7 @@ const emitSingle = (input) => Effect.gen(function* () {
|
|
|
1463
2631
|
outPath
|
|
1464
2632
|
});
|
|
1465
2633
|
return [{
|
|
1466
|
-
kind:
|
|
2634
|
+
kind: ArtifactKind.Archive,
|
|
1467
2635
|
path: result.path,
|
|
1468
2636
|
bytes: result.bytes,
|
|
1469
2637
|
packageCount: input.resolution.packages.length
|
|
@@ -1483,7 +2651,7 @@ const emitDirectory = (input) => Effect.gen(function* () {
|
|
|
1483
2651
|
yield* fs.copy(input.packagesDir, input.options.outDir, { overwrite: true }).pipe(Effect.mapError((cause) => new OutputError(input.options.outDir, "could not write output tree", { cause })));
|
|
1484
2652
|
const bytes = [...input.report.sizes.values()].reduce((a, b) => a + b, 0);
|
|
1485
2653
|
return [{
|
|
1486
|
-
kind:
|
|
2654
|
+
kind: ArtifactKind.Directory,
|
|
1487
2655
|
path: input.options.outDir,
|
|
1488
2656
|
bytes,
|
|
1489
2657
|
packageCount: input.resolution.packages.length
|
|
@@ -1496,15 +2664,16 @@ const emitDirectory = (input) => Effect.gen(function* () {
|
|
|
1496
2664
|
* `react-18.1.0.tgz` and so on, each independently importable — which is the
|
|
1497
2665
|
* whole reason to prefer this layout.
|
|
1498
2666
|
*/
|
|
1499
|
-
const emitPerSpec = (input) => Effect.gen(function* () {
|
|
2667
|
+
const emitPerSpec = (input, staging) => Effect.gen(function* () {
|
|
1500
2668
|
const fs = yield* FileSystem.FileSystem;
|
|
1501
2669
|
const path = yield* Path.Path;
|
|
2670
|
+
const index = indexPackages(input.resolution);
|
|
1502
2671
|
const artifacts = [];
|
|
1503
2672
|
let counter = 0;
|
|
1504
|
-
for (const root of input.resolution
|
|
2673
|
+
for (const { root, version } of perSpecTargets(input.resolution)) {
|
|
1505
2674
|
if (input.options.skipExisting?.has(perSpecArchiveName(root.spec.name, version)) === true) continue;
|
|
1506
|
-
const included = (root.closures.get(version) ?? [])
|
|
1507
|
-
const rootDir = path.join(
|
|
2675
|
+
const included = includedPackages(index, root.closures.get(version) ?? []);
|
|
2676
|
+
const rootDir = path.join(staging, "roots", String(counter++));
|
|
1508
2677
|
yield* fs.makeDirectory(rootDir, { recursive: true });
|
|
1509
2678
|
for (const pkg of included) {
|
|
1510
2679
|
const parts = packagePath(pkg.name, pkg.version).split("/");
|
|
@@ -1534,7 +2703,7 @@ const emitPerSpec = (input) => Effect.gen(function* () {
|
|
|
1534
2703
|
outPath
|
|
1535
2704
|
});
|
|
1536
2705
|
artifacts.push({
|
|
1537
|
-
kind:
|
|
2706
|
+
kind: ArtifactKind.Archive,
|
|
1538
2707
|
path: result.path,
|
|
1539
2708
|
bytes: result.bytes,
|
|
1540
2709
|
packageCount: included.length,
|
|
@@ -1544,6 +2713,7 @@ const emitPerSpec = (input) => Effect.gen(function* () {
|
|
|
1544
2713
|
}
|
|
1545
2714
|
return artifacts;
|
|
1546
2715
|
});
|
|
2716
|
+
const includedPackages = (index, closure) => closure.flatMap((key) => index.get(key) ?? []);
|
|
1547
2717
|
/**
|
|
1548
2718
|
* Hard-links a file, falling back to a copy.
|
|
1549
2719
|
*
|
|
@@ -1578,17 +2748,50 @@ const writeBundleMetadata = (input) => Effect.gen(function* () {
|
|
|
1578
2748
|
});
|
|
1579
2749
|
/** Sorted top-level entries of a directory, used as the tar entry list. */
|
|
1580
2750
|
const topLevelEntries = (dir) => Effect.gen(function* () {
|
|
1581
|
-
return
|
|
2751
|
+
return (yield* (yield* FileSystem.FileSystem).readDirectory(dir)).toSorted();
|
|
2752
|
+
});
|
|
2753
|
+
/**
|
|
2754
|
+
* Two different packages must not want the same file name.
|
|
2755
|
+
*
|
|
2756
|
+
* Most of what this used to catch is now impossible: an archive is named for
|
|
2757
|
+
* the spec, `@types-react@19.2.18.tgz`, and an unscoped npm name may not begin
|
|
2758
|
+
* with `@`, so it cannot spell a scoped one. What survives is **two scoped
|
|
2759
|
+
* packages whose scope boundary falls in a different place**, because that
|
|
2760
|
+
* boundary is the one character the file name drops:
|
|
2761
|
+
*
|
|
2762
|
+
* @a/b-c -> @a-b-c@1.0.0.tgz
|
|
2763
|
+
* @a-b/c -> @a-b-c@1.0.0.tgz
|
|
2764
|
+
*
|
|
2765
|
+
* Pathological rather than plausible — it needs somebody to hold the `@a-b`
|
|
2766
|
+
* scope as well as `@a`, and both packages in one run at one version. It is
|
|
2767
|
+
* kept anyway because of what it costs to be wrong: they are *different
|
|
2768
|
+
* packages*, so deduplicating them would be wrong, and writing both is worse —
|
|
2769
|
+
* the second lands on the first and the run reports two artifacts for the one
|
|
2770
|
+
* file that survived. A bundle short a package, called complete, found at the
|
|
2771
|
+
* far end of an air gap by somebody who cannot go and fetch the missing one.
|
|
2772
|
+
*
|
|
2773
|
+
* Nothing else would catch it. The overwrite check below compares this run's
|
|
2774
|
+
* planned files against what is *already on disk*, and both of these are new.
|
|
2775
|
+
*
|
|
2776
|
+
* So it refuses, and names both specs. Not silently renamed: an archive whose
|
|
2777
|
+
* name nobody chose is a file somebody has to identify later.
|
|
2778
|
+
*
|
|
2779
|
+
* Unconditional, unlike the overwrite check: `--force` is permission to replace
|
|
2780
|
+
* what was already in the directory, not to let one run overwrite itself.
|
|
2781
|
+
*/
|
|
2782
|
+
const assertDistinctOutputs = (resolution, options) => Effect.gen(function* () {
|
|
2783
|
+
const owners = /* @__PURE__ */ new Map();
|
|
2784
|
+
for (const planned of plannedOutputs(resolution, options)) {
|
|
2785
|
+
const claimed = owners.get(planned.file);
|
|
2786
|
+
const spec = planned.name === void 0 ? planned.file : `${planned.name}@${planned.version}`;
|
|
2787
|
+
if (claimed === void 0) owners.set(planned.file, [spec]);
|
|
2788
|
+
else claimed.push(spec);
|
|
2789
|
+
}
|
|
2790
|
+
const collisions = [...owners].filter(([, specs]) => specs.length > 1);
|
|
2791
|
+
if (collisions.length === 0) return;
|
|
2792
|
+
const described = collisions.map(([file, specs]) => `${specs.join(" and ")} would both be ${file}`).join("; ");
|
|
2793
|
+
return yield* Effect.fail(new OutputError(options.outDir, `two packages want the same archive name — ${described}. The \`/\` in a scope becomes a \`-\` to make a file name, so \`@a/b-c\` and \`@a-b/c\` meet. Use --layout single, or bundle them into separate --out directories.`));
|
|
1582
2794
|
});
|
|
1583
|
-
const plannedOutputs = (resolution, options) => {
|
|
1584
|
-
if (options.layout === "dir") return [{ file: MANIFEST_FILE }, { file: README_FILE }];
|
|
1585
|
-
if (options.layout === "single") return [{ file: singleArchiveName(options.archiveName ?? "bundle") }];
|
|
1586
|
-
return resolution.roots.flatMap((root) => root.versions.map((version) => ({
|
|
1587
|
-
file: perSpecArchiveName(root.spec.name, version),
|
|
1588
|
-
name: root.spec.name,
|
|
1589
|
-
version
|
|
1590
|
-
})));
|
|
1591
|
-
};
|
|
1592
2795
|
/**
|
|
1593
2796
|
* Fails if the run would overwrite something, unless `--force`.
|
|
1594
2797
|
*
|
|
@@ -1599,12 +2802,14 @@ const plannedOutputs = (resolution, options) => {
|
|
|
1599
2802
|
* default `.` unusable.
|
|
1600
2803
|
*
|
|
1601
2804
|
* Checked before anything is downloaded. Discovering the collision *after* a
|
|
1602
|
-
* twenty-minute download would be a uniquely irritating way to fail
|
|
2805
|
+
* twenty-minute download would be a uniquely irritating way to fail — which is
|
|
2806
|
+
* also why `assertDistinctOutputs` runs from here rather than at emit time.
|
|
1603
2807
|
*/
|
|
1604
2808
|
const prepareOutputDir = (resolution, options) => Effect.gen(function* () {
|
|
1605
2809
|
const fs = yield* FileSystem.FileSystem;
|
|
1606
2810
|
const path = yield* Path.Path;
|
|
1607
2811
|
const outDir = options.outDir;
|
|
2812
|
+
yield* assertDistinctOutputs(resolution, options);
|
|
1608
2813
|
if (!options.force) {
|
|
1609
2814
|
const clashes = [];
|
|
1610
2815
|
for (const planned of plannedOutputs(resolution, options)) {
|
|
@@ -1618,149 +2823,5 @@ const prepareOutputDir = (resolution, options) => Effect.gen(function* () {
|
|
|
1618
2823
|
});
|
|
1619
2824
|
|
|
1620
2825
|
//#endregion
|
|
1621
|
-
|
|
1622
|
-
var InputFile_exports = /* @__PURE__ */ __export({
|
|
1623
|
-
defaultInputFileOptions: () => defaultInputFileOptions,
|
|
1624
|
-
parseInputFile: () => parseInputFile,
|
|
1625
|
-
readInputFile: () => readInputFile
|
|
1626
|
-
});
|
|
1627
|
-
const defaultInputFileOptions = { includeDev: true };
|
|
1628
|
-
/** Reads and parses an input file. */
|
|
1629
|
-
const readInputFile = (filePath, options = defaultInputFileOptions) => Effect.gen(function* () {
|
|
1630
|
-
const fs = yield* FileSystem.FileSystem;
|
|
1631
|
-
if (!(yield* fs.exists(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))))) return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"));
|
|
1632
|
-
const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause })));
|
|
1633
|
-
return yield* Effect.try({
|
|
1634
|
-
try: () => parseInputFile(filePath, content, options),
|
|
1635
|
-
catch: (error) => error instanceof InvalidInputFileError ? error : new InvalidInputFileError(filePath, describe(error), { cause: error })
|
|
1636
|
-
});
|
|
1637
|
-
});
|
|
1638
|
-
/**
|
|
1639
|
-
* Parses file content that has already been read.
|
|
1640
|
-
*
|
|
1641
|
-
* Split out from the IO so the whole of this logic is testable with plain
|
|
1642
|
-
* strings and no file system at all.
|
|
1643
|
-
*/
|
|
1644
|
-
const parseInputFile = (filePath, content, options = defaultInputFileOptions) => {
|
|
1645
|
-
const trimmed = content.trim();
|
|
1646
|
-
if (trimmed.length === 0) throw new InvalidInputFileError(filePath, "file is empty");
|
|
1647
|
-
return looksLikeJson(trimmed) ? fromPackageJson(filePath, trimmed, options) : fromList(filePath, content);
|
|
1648
|
-
};
|
|
1649
|
-
const looksLikeJson = (trimmed) => trimmed.startsWith("{");
|
|
1650
|
-
const fromPackageJson = (filePath, content, options) => {
|
|
1651
|
-
let parsed;
|
|
1652
|
-
try {
|
|
1653
|
-
parsed = JSON.parse(content);
|
|
1654
|
-
} catch (error) {
|
|
1655
|
-
throw new InvalidInputFileError(filePath, `not valid JSON — ${describe(error)}`, { cause: error });
|
|
1656
|
-
}
|
|
1657
|
-
const specs = [];
|
|
1658
|
-
const warnings = [];
|
|
1659
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1660
|
-
const collect = (entries, label, skip) => {
|
|
1661
|
-
if (entries === void 0) return;
|
|
1662
|
-
for (const [name, raw] of Object.entries(entries)) {
|
|
1663
|
-
if (skip?.(name)) continue;
|
|
1664
|
-
const target = parseDependencyTarget(name, raw);
|
|
1665
|
-
if (target._tag === "Unsupported") {
|
|
1666
|
-
warnings.push(`${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`);
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
const key = `${target.name}|${JSON.stringify(target.selector)}`;
|
|
1670
|
-
if (seen.has(key)) continue;
|
|
1671
|
-
seen.add(key);
|
|
1672
|
-
specs.push({
|
|
1673
|
-
name: target.name,
|
|
1674
|
-
selector: target.selector,
|
|
1675
|
-
raw: `${target.name}@${raw}`
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
};
|
|
1679
|
-
collect(parsed.dependencies, "dependencies");
|
|
1680
|
-
collect(parsed.optionalDependencies, "optionalDependencies");
|
|
1681
|
-
if (options.includeDev) collect(parsed.devDependencies, "devDependencies");
|
|
1682
|
-
const meta = parsed.peerDependenciesMeta ?? {};
|
|
1683
|
-
collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true);
|
|
1684
|
-
if (specs.length === 0) throw new InvalidInputFileError(filePath, options.includeDev ? "package.json declares no dependencies to bundle" : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)");
|
|
1685
|
-
return {
|
|
1686
|
-
kind: "package.json",
|
|
1687
|
-
specs,
|
|
1688
|
-
warnings
|
|
1689
|
-
};
|
|
1690
|
-
};
|
|
1691
|
-
const fromList = (filePath, content) => {
|
|
1692
|
-
const lines = [];
|
|
1693
|
-
const rawLines = content.split(/\r?\n/);
|
|
1694
|
-
for (const line of rawLines) {
|
|
1695
|
-
const withoutComment = stripComment(line).trim();
|
|
1696
|
-
if (withoutComment.length === 0) continue;
|
|
1697
|
-
lines.push(withoutComment);
|
|
1698
|
-
}
|
|
1699
|
-
if (lines.length === 0) throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)");
|
|
1700
|
-
const { specs, errors } = parseSpecs(lines);
|
|
1701
|
-
if (errors.length > 0) throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines));
|
|
1702
|
-
return {
|
|
1703
|
-
kind: "list",
|
|
1704
|
-
specs,
|
|
1705
|
-
warnings: []
|
|
1706
|
-
};
|
|
1707
|
-
};
|
|
1708
|
-
/**
|
|
1709
|
-
* Strips `#` and `//` comments.
|
|
1710
|
-
*
|
|
1711
|
-
* `//` only counts when it is not part of a URL, so a future line containing
|
|
1712
|
-
* `https://…` does not get silently truncated.
|
|
1713
|
-
*/
|
|
1714
|
-
const stripComment = (line) => {
|
|
1715
|
-
const hash = line.indexOf("#");
|
|
1716
|
-
let result = hash === -1 ? line : line.slice(0, hash);
|
|
1717
|
-
const slashes = result.indexOf("//");
|
|
1718
|
-
if (slashes > 0 && result[slashes - 1] !== ":") result = result.slice(0, slashes);
|
|
1719
|
-
else if (slashes === 0) result = "";
|
|
1720
|
-
return result;
|
|
1721
|
-
};
|
|
1722
|
-
const formatSpecErrors = (errors, rawLines) => {
|
|
1723
|
-
const details = errors.map((error) => {
|
|
1724
|
-
const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec);
|
|
1725
|
-
const where = index === -1 ? "" : ` (line ${index + 1})`;
|
|
1726
|
-
return ` ${error.spec}${where}: ${error.reason}`;
|
|
1727
|
-
});
|
|
1728
|
-
return `${errors.length} invalid spec(s):\n${details.join("\n")}`;
|
|
1729
|
-
};
|
|
1730
|
-
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
1731
|
-
|
|
1732
|
-
//#endregion
|
|
1733
|
-
//#region src/Options.ts
|
|
1734
|
-
var Options_exports = /* @__PURE__ */ __export({
|
|
1735
|
-
defaultBundleOptions: () => defaultBundleOptions,
|
|
1736
|
-
defaultResolveOptions: () => defaultResolveOptions,
|
|
1737
|
-
defaultScope: () => defaultScope,
|
|
1738
|
-
layouts: () => layouts
|
|
1739
|
-
});
|
|
1740
|
-
const layouts = [
|
|
1741
|
-
"per-spec",
|
|
1742
|
-
"single",
|
|
1743
|
-
"dir"
|
|
1744
|
-
];
|
|
1745
|
-
const defaultScope = {
|
|
1746
|
-
optional: true,
|
|
1747
|
-
peer: true,
|
|
1748
|
-
platforms: allPlatforms
|
|
1749
|
-
};
|
|
1750
|
-
const defaultResolveOptions = {
|
|
1751
|
-
scope: defaultScope,
|
|
1752
|
-
allVersions: false,
|
|
1753
|
-
includePrerelease: false,
|
|
1754
|
-
concurrency: 10
|
|
1755
|
-
};
|
|
1756
|
-
const defaultBundleOptions = {
|
|
1757
|
-
...defaultResolveOptions,
|
|
1758
|
-
layout: "per-spec",
|
|
1759
|
-
dryRun: false,
|
|
1760
|
-
verifyIntegrity: true,
|
|
1761
|
-
force: false
|
|
1762
|
-
};
|
|
1763
|
-
|
|
1764
|
-
//#endregion
|
|
1765
|
-
export { Archive_exports as Archive, ArchiveError, AuthenticationError, Bundle_exports as Bundle, DependencyRange_exports as DependencyRange, Download_exports as Download, InputFile_exports as InputFile, Integrity_exports as Integrity, IntegrityError, InvalidInputFileError, InvalidSpecError, Layout_exports as Layout, Manifest_exports as Manifest, NoMatchingVersionsError, Options_exports as Options, OutputError, PackageNotFoundError, Platform_exports as Platform, Progress_exports as Progress, Progress as ProgressTag, Registry, RegistryResponseError, RegistryUnreachableError, Resolve_exports as Resolve, Spec_exports as Spec, VersionNotFoundError, allPlatforms, bundle, bundleResolved, currentPlatform, defaultBundleOptions, defaultResolveOptions, defaultScope, layouts, parsePlatformTarget, parseSpec, parseSpecs, plan, plannedOutputs, platformTargets, resolve, summarize, withLayout };
|
|
2826
|
+
export { ArchiveError, Archiver, ArtifactKind, AuthenticationError, DirectOnlyKind, EdgeKind, InputFileKind, IntegrityError, InvalidInputFileError, InvalidSpecError, Layout, Layouts, LockedRootKind, LockfileError, LockfileFormat, LockfileIncompleteError, LockfileOutOfDateError, MANIFEST_FILE, MANIFEST_VERSION, NoMatchingVersionsError, OptionalFlagRecordOrAbsentSchema, OptionalFlagRecordSchema, OptionalStringArraySchema, OptionalStringRecordSchema, OutputError, PackageNotFoundError, Phase, Progress, README_FILE, Registry, RegistryResponseError, RegistryUnreachableError, StringArraySchema, StringRecordSchema, VersionNotFoundError, allPlatforms, buildManifest, bundle, bundleLocked, bundleResolved, createArchive, currentPlatform, dedupeSpecs, defaultBundleOptions, defaultInputFileOptions, defaultResolveOptions, defaultScope, detectLockfile, digestOf, emit as emitProgress, formatByLockfileName, formatPlatformFilter, formatSelector, formatSpec, hexDigestOf, importGuide, indexPackages, isIncluded, isRecord, layerCallback as layerCallbackProgress, layerSilent as layerSilentProgress, lockfileNamesFor, makeCollector as makeProgressCollector, packageKey, packagePath, parseDependencyTarget, parseInputFile, parseIntegrity, parseLockfile, parsePlatformTarget, parseSpec, parseSpecs, perSpecArchiveName, plan, planLocked, plannedOutputs, platformTargets, readInputFile, resolve, resolveLocked, selectVersions, serializeManifest, singleArchiveName, splitName, summarize, tarballFileName, tolerant, verify as verifyIntegrity, withLayout };
|
|
1766
2827
|
//# sourceMappingURL=index.js.map
|