@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-6sqTn1C4.js +274 -0
  4. package/dist/archive-6sqTn1C4.js.map +1 -0
  5. package/dist/archive.d.ts +64 -0
  6. package/dist/archive.d.ts.map +1 -0
  7. package/dist/{Bundle.d.ts → bundle.d.ts} +56 -38
  8. package/dist/bundle.d.ts.map +1 -0
  9. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  10. package/dist/dependency-range.d.ts.map +1 -0
  11. package/dist/{Download.d.ts → download.d.ts} +9 -8
  12. package/dist/download.d.ts.map +1 -0
  13. package/dist/enums/artifact-kind.d.ts +8 -0
  14. package/dist/enums/artifact-kind.d.ts.map +1 -0
  15. package/dist/enums/edge-kind.d.ts +29 -0
  16. package/dist/enums/edge-kind.d.ts.map +1 -0
  17. package/dist/enums/input-file-kind.d.ts +12 -0
  18. package/dist/enums/input-file-kind.d.ts.map +1 -0
  19. package/dist/enums/layout.d.ts +23 -0
  20. package/dist/enums/layout.d.ts.map +1 -0
  21. package/dist/enums/lockfile-format.d.ts +9 -0
  22. package/dist/enums/lockfile-format.d.ts.map +1 -0
  23. package/dist/enums/phase.d.ts +11 -0
  24. package/dist/enums/phase.d.ts.map +1 -0
  25. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/index.d.ts +46 -28
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2121 -1060
  30. package/dist/index.js.map +1 -1
  31. package/dist/input-file.d.ts +77 -0
  32. package/dist/input-file.d.ts.map +1 -0
  33. package/dist/integrity.d.ts +80 -0
  34. package/dist/integrity.d.ts.map +1 -0
  35. package/dist/layout.d.ts +87 -0
  36. package/dist/layout.d.ts.map +1 -0
  37. package/dist/locked-resolve.d.ts +47 -0
  38. package/dist/locked-resolve.d.ts.map +1 -0
  39. package/dist/lockfile/bun.d.ts +3 -0
  40. package/dist/lockfile/bun.d.ts.map +1 -0
  41. package/dist/lockfile/detect.d.ts +31 -0
  42. package/dist/lockfile/detect.d.ts.map +1 -0
  43. package/dist/lockfile/json.d.ts +23 -0
  44. package/dist/lockfile/json.d.ts.map +1 -0
  45. package/dist/lockfile/names.d.ts +16 -0
  46. package/dist/lockfile/names.d.ts.map +1 -0
  47. package/dist/lockfile/npm.d.ts +3 -0
  48. package/dist/lockfile/npm.d.ts.map +1 -0
  49. package/dist/lockfile/parse.d.ts +23 -0
  50. package/dist/lockfile/parse.d.ts.map +1 -0
  51. package/dist/lockfile/pnpm.d.ts +20 -0
  52. package/dist/lockfile/pnpm.d.ts.map +1 -0
  53. package/dist/lockfile/tree-builder.d.ts +70 -0
  54. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  55. package/dist/lockfile/types.d.ts +70 -0
  56. package/dist/lockfile/types.d.ts.map +1 -0
  57. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  58. package/dist/manifest.d.ts.map +1 -0
  59. package/dist/node/archiver.d.ts +5 -0
  60. package/dist/node/archiver.d.ts.map +1 -0
  61. package/dist/node/index.d.ts +13 -0
  62. package/dist/node/index.d.ts.map +1 -0
  63. package/dist/node/index.js +55 -0
  64. package/dist/node/index.js.map +1 -0
  65. package/dist/{Options.d.ts → options.d.ts} +12 -30
  66. package/dist/options.d.ts.map +1 -0
  67. package/dist/{Platform.d.ts → platform.d.ts} +14 -19
  68. package/dist/platform.d.ts.map +1 -0
  69. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  70. package/dist/progress.d.ts.map +1 -0
  71. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  72. package/dist/registry.d.ts.map +1 -0
  73. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  74. package/dist/resolve.d.ts.map +1 -0
  75. package/dist/schemas/lenient.d.ts +38 -0
  76. package/dist/schemas/lenient.d.ts.map +1 -0
  77. package/dist/schemas/package-json.d.ts +44 -0
  78. package/dist/schemas/package-json.d.ts.map +1 -0
  79. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  80. package/dist/spec.d.ts.map +1 -0
  81. package/dist/types/value-of.d.ts +9 -0
  82. package/dist/types/value-of.d.ts.map +1 -0
  83. package/dist/utils/is-record.d.ts +8 -0
  84. package/dist/utils/is-record.d.ts.map +1 -0
  85. package/package.json +13 -11
  86. package/src/archive.ts +95 -0
  87. package/src/bundle.ts +736 -0
  88. package/src/dependency-range.ts +121 -0
  89. package/src/download.ts +155 -0
  90. package/src/enums/artifact-kind.ts +9 -0
  91. package/src/enums/edge-kind.ts +32 -0
  92. package/src/enums/input-file-kind.ts +13 -0
  93. package/src/enums/layout.ts +25 -0
  94. package/src/enums/lockfile-format.ts +10 -0
  95. package/src/enums/phase.ts +12 -0
  96. package/src/errors.ts +300 -0
  97. package/src/index.ts +146 -35
  98. package/src/input-file.ts +324 -0
  99. package/src/integrity.ts +165 -0
  100. package/src/layout.ts +137 -0
  101. package/src/locked-resolve.ts +471 -0
  102. package/src/lockfile/bun.ts +214 -0
  103. package/src/lockfile/detect.ts +80 -0
  104. package/src/lockfile/json.ts +131 -0
  105. package/src/lockfile/names.ts +20 -0
  106. package/src/lockfile/npm.ts +328 -0
  107. package/src/lockfile/parse.ts +56 -0
  108. package/src/lockfile/pnpm.ts +230 -0
  109. package/src/lockfile/tree-builder.ts +166 -0
  110. package/src/lockfile/types.ts +74 -0
  111. package/src/manifest.ts +129 -0
  112. package/src/node/archiver.ts +85 -0
  113. package/src/node/index.ts +13 -0
  114. package/src/options.ts +103 -0
  115. package/src/platform.ts +178 -0
  116. package/src/progress.ts +105 -0
  117. package/src/registry.ts +116 -0
  118. package/src/resolve.ts +560 -0
  119. package/src/schemas/lenient.ts +130 -0
  120. package/src/schemas/package-json.ts +28 -0
  121. package/src/spec.ts +182 -0
  122. package/src/types/value-of.ts +8 -0
  123. package/src/utils/is-record.ts +8 -0
  124. package/dist/Archive.d.ts +0 -32
  125. package/dist/Archive.d.ts.map +0 -1
  126. package/dist/Bundle.d.ts.map +0 -1
  127. package/dist/DependencyRange.d.ts.map +0 -1
  128. package/dist/Download.d.ts.map +0 -1
  129. package/dist/Errors.d.ts.map +0 -1
  130. package/dist/InputFile.d.ts +0 -46
  131. package/dist/InputFile.d.ts.map +0 -1
  132. package/dist/Integrity.d.ts +0 -48
  133. package/dist/Integrity.d.ts.map +0 -1
  134. package/dist/Layout.d.ts +0 -53
  135. package/dist/Layout.d.ts.map +0 -1
  136. package/dist/Manifest.d.ts.map +0 -1
  137. package/dist/Options.d.ts.map +0 -1
  138. package/dist/Platform.d.ts.map +0 -1
  139. package/dist/Progress.d.ts.map +0 -1
  140. package/dist/Registry.d.ts.map +0 -1
  141. package/dist/Resolve.d.ts.map +0 -1
  142. package/dist/Spec.d.ts.map +0 -1
  143. package/src/Archive.ts +0 -92
  144. package/src/Bundle.ts +0 -570
  145. package/src/DependencyRange.ts +0 -115
  146. package/src/Download.ts +0 -132
  147. package/src/Errors.ts +0 -216
  148. package/src/InputFile.ts +0 -229
  149. package/src/Integrity.ts +0 -122
  150. package/src/Layout.ts +0 -103
  151. package/src/Manifest.ts +0 -133
  152. package/src/Options.ts +0 -123
  153. package/src/Platform.ts +0 -182
  154. package/src/Progress.ts +0 -106
  155. package/src/Registry.ts +0 -121
  156. package/src/Resolve.ts +0 -528
  157. package/src/Spec.ts +0 -186
package/src/index.ts CHANGED
@@ -4,41 +4,152 @@
4
4
  * Nothing in here knows about npm specifically. Point it at any implementation
5
5
  * of the `Registry` service and it will resolve, download, verify and package
6
6
  * a dependency closure.
7
+ *
8
+ * This is the package's whole public API. Everything not named here is an
9
+ * implementation detail, including modules that happen to be reachable.
7
10
  */
8
11
 
9
- export * as Archive from "./Archive.js"
10
- export * as Bundle from "./Bundle.js"
11
- export * as DependencyRange from "./DependencyRange.js"
12
- export * as Download from "./Download.js"
13
- export * as Integrity from "./Integrity.js"
14
- export * as InputFile from "./InputFile.js"
15
- export * as Layout from "./Layout.js"
16
- export * as Manifest from "./Manifest.js"
17
- export * as Options from "./Options.js"
18
- export * as Platform from "./Platform.js"
19
- export * as Progress from "./Progress.js"
20
- export * as Resolve from "./Resolve.js"
21
- export * as Spec from "./Spec.js"
22
-
23
- // Flat re-exports of the things a consumer reaches for constantly.
24
- export { bundle, bundleResolved, plan, plannedOutputs, summarize, withLayout } from "./Bundle.js"
25
- export type { BundleArtifact, BundleContext, BundleResult, PlanSummary } from "./Bundle.js"
26
- export { Registry } from "./Registry.js"
27
- export type { Packument, PackageDist, PackageManifest, RegistryService } from "./Registry.js"
28
- export { Progress as ProgressTag } from "./Progress.js"
29
- export type { ProgressEvent } from "./Progress.js"
30
- export { resolve } from "./Resolve.js"
31
- export type { Resolution, ResolvedPackage, RootResolution } from "./Resolve.js"
32
- export { parseSpec, parseSpecs } from "./Spec.js"
33
- export type { PackageSpec, Selector } from "./Spec.js"
34
- export type { BundleOptions, DependencyScope, Layout as LayoutKind, ResolveOptions } from "./Options.js"
12
+ export type * from "./errors.js";
13
+ export {
14
+ ArchiveError,
15
+ AuthenticationError,
16
+ IntegrityError,
17
+ InvalidInputFileError,
18
+ InvalidSpecError,
19
+ LockfileError,
20
+ LockfileIncompleteError,
21
+ LockfileOutOfDateError,
22
+ NoMatchingVersionsError,
23
+ OutputError,
24
+ PackageNotFoundError,
25
+ RegistryResponseError,
26
+ RegistryUnreachableError,
27
+ VersionNotFoundError,
28
+ } from "./errors.js";
29
+
30
+ export { ArtifactKind } from "./enums/artifact-kind.js";
31
+ export { DirectOnlyKind, EdgeKind, LockedRootKind } from "./enums/edge-kind.js";
32
+ export { InputFileKind } from "./enums/input-file-kind.js";
33
+ export { Layout, Layouts } from "./enums/layout.js";
34
+ export { LockfileFormat } from "./enums/lockfile-format.js";
35
+ export { Phase } from "./enums/phase.js";
36
+
37
+ export type { ValueOf } from "./types/value-of.js";
38
+ export { isRecord } from "./utils/is-record.js";
39
+
40
+ // The tolerant decoders every boundary that reads untrusted JSON is built from
41
+ // — registry responses here, package.json and lockfiles inside the engine.
42
+ export {
43
+ OptionalFlagRecordOrAbsentSchema,
44
+ OptionalFlagRecordSchema,
45
+ OptionalStringArraySchema,
46
+ OptionalStringRecordSchema,
47
+ StringArraySchema,
48
+ StringRecordSchema,
49
+ tolerant,
50
+ } from "./schemas/lenient.js";
51
+
52
+ export type { PackageSpec, Selector } from "./spec.js";
53
+ export { dedupeSpecs, formatSelector, formatSpec, parseSpec, parseSpecs } from "./spec.js";
54
+
55
+ export type { DependencyTarget } from "./dependency-range.js";
56
+ export { parseDependencyTarget } from "./dependency-range.js";
57
+
58
+ export type { PlatformConstraints, PlatformFilter, PlatformTarget } from "./platform.js";
59
+ export {
60
+ allPlatforms,
61
+ currentPlatform,
62
+ formatPlatformFilter,
63
+ isIncluded,
64
+ parsePlatformTarget,
65
+ platformTargets,
66
+ } from "./platform.js";
67
+
68
+ export type { BundleOptions, DependencyScope, ResolveOptions } from "./options.js";
69
+ export { defaultBundleOptions, defaultResolveOptions, defaultScope } from "./options.js";
70
+
71
+ export type { Packument, PackageDist, PackageManifest } from "./registry.js";
72
+ // `Registry.Service` and `Progress.Service` travel with their class through
73
+ // declaration merging, so the one name carries the tag and its shape.
74
+ export { Registry } from "./registry.js";
75
+
76
+ export type { ProgressEvent } from "./progress.js";
77
+ // Aliased: `emit` and `layerSilent` say what they do inside `progress.ts`, and
78
+ // nothing at all on a package's public surface.
79
+ export {
80
+ emit as emitProgress,
81
+ layerCallback as layerCallbackProgress,
82
+ layerSilent as layerSilentProgress,
83
+ makeCollector as makeProgressCollector,
84
+ Progress,
85
+ } from "./progress.js";
86
+
87
+ export type { IntegrityAlgorithm, IntegrityHash, VerificationResult } from "./integrity.js";
88
+ export { digestOf, hexDigestOf, parseIntegrity, verify as verifyIntegrity } from "./integrity.js";
89
+
90
+ // The packing backend. The Node implementation is deliberately *not* re-exported
91
+ // here — it lives behind `@packall/core/node`, because it pulls in `node-tar` and
92
+ // reaching it has to be an explicit act so importing the engine stays safe in a
93
+ // browser.
94
+ export type { ArchiveResult, CreateArchiveOptions } from "./archive.js";
95
+ export { Archiver, createArchive } from "./archive.js";
96
+
97
+ export {
98
+ importGuide,
99
+ MANIFEST_FILE,
100
+ packagePath,
101
+ perSpecArchiveName,
102
+ README_FILE,
103
+ singleArchiveName,
104
+ splitName,
105
+ tarballFileName,
106
+ } from "./layout.js";
107
+
108
+ export type { BundleManifest, ManifestEntry } from "./manifest.js";
109
+ export { buildManifest, MANIFEST_VERSION, serializeManifest } from "./manifest.js";
110
+
111
+ export type {
112
+ PackageKey,
113
+ Reason,
114
+ Resolution,
115
+ ResolutionWarning,
116
+ ResolvedPackage,
117
+ RootResolution,
118
+ } from "./resolve.js";
119
+ export { indexPackages, packageKey, resolve, selectVersions } from "./resolve.js";
120
+
121
+ export type { LockedResolveOptions } from "./locked-resolve.js";
122
+ export { resolveLocked } from "./locked-resolve.js";
123
+
124
+ export type { LockfileDetection } from "./lockfile/detect.js";
125
+ export { detectLockfile } from "./lockfile/detect.js";
126
+ export type {
127
+ LockedEdge,
128
+ LockedPackage,
129
+ LockedRoot,
130
+ LockedTree,
131
+ LockfileParseOptions,
132
+ } from "./lockfile/types.js";
133
+ export { formatByLockfileName, lockfileNamesFor } from "./lockfile/names.js";
134
+ export { parseLockfile } from "./lockfile/parse.js";
135
+
136
+ export type { InputFileOptions, InputFileResult } from "./input-file.js";
137
+ export { defaultInputFileOptions, parseInputFile, readInputFile } from "./input-file.js";
138
+
139
+ export type {
140
+ BundleArtifact,
141
+ BundleContext,
142
+ BundleResult,
143
+ PlannedOutput,
144
+ PlanSummary,
145
+ } from "./bundle.js";
35
146
  export {
36
- defaultBundleOptions,
37
- defaultResolveOptions,
38
- defaultScope,
39
- layouts
40
- } from "./Options.js"
41
- export type { PlatformFilter, PlatformTarget } from "./Platform.js"
42
- export { allPlatforms, currentPlatform, parsePlatformTarget, platformTargets } from "./Platform.js"
43
-
44
- export * from "./Errors.js"
147
+ bundle,
148
+ bundleLocked,
149
+ bundleResolved,
150
+ plan,
151
+ planLocked,
152
+ plannedOutputs,
153
+ summarize,
154
+ withLayout,
155
+ } from "./bundle.js";
@@ -0,0 +1,324 @@
1
+ /**
2
+ * `--file` handling.
3
+ *
4
+ * One flag, three accepted shapes, detected from the content rather than the
5
+ * extension so that `--file deps.json` and `--file my-packages` both do the
6
+ * obvious thing:
7
+ *
8
+ * - a **package.json** — bundle everything it depends on
9
+ * - a **lockfile** — npm, pnpm or bun; bundle the exact versions it pins
10
+ * - a **newline-delimited list** — one spec per line, `#` and `//` comments
11
+ * allowed, blank lines ignored
12
+ *
13
+ * A lockfile is checked for first, because `package-lock.json` and
14
+ * `package.json` are both JSON objects and only the content tells them apart.
15
+ */
16
+ import * as Effect from "effect/Effect";
17
+ import * as FileSystem from "effect/FileSystem";
18
+ import * as Option from "effect/Option";
19
+
20
+ import { parseDependencyTarget } from "./dependency-range.js";
21
+ import { InputFileKind } from "./enums/input-file-kind.js";
22
+ import type { LockfileFormat } from "./enums/lockfile-format.js";
23
+ import type { InvalidSpecError, LockfileError } from "./errors.js";
24
+ import { InvalidInputFileError } from "./errors.js";
25
+ import { detectLockfile } from "./lockfile/detect.js";
26
+ import { parseLockfile } from "./lockfile/parse.js";
27
+ import type { LockedTree } from "./lockfile/types.js";
28
+ import type { PackageJson } from "./schemas/package-json.js";
29
+ import { decodePackageJson } from "./schemas/package-json.js";
30
+ import type { PackageSpec } from "./spec.js";
31
+ import { parseSpecs } from "./spec.js";
32
+
33
+ export type InputFileResult = {
34
+ readonly kind: InputFileKind;
35
+ readonly specs: ReadonlyArray<PackageSpec>;
36
+ /** Entries that were recognised but cannot be bundled from a registry. */
37
+ readonly warnings: ReadonlyArray<string>;
38
+ /**
39
+ * The pinned graph, set only for a lockfile.
40
+ *
41
+ * `specs` carries the same roots for display, but a lockfile run must resolve
42
+ * from this — see `locked-resolve` for why re-resolving the roots' ranges
43
+ * would quietly produce a different bundle.
44
+ */
45
+ readonly lockfile?: LockedTree | undefined;
46
+ /**
47
+ * Names from the blocks a package.json always installs — `dependencies` and,
48
+ * unless `--prod`, `devDependencies`.
49
+ *
50
+ * Used to check a lockfile against the manifest beside it. Deliberately
51
+ * excludes optional and peer entries: both are legitimately absent from a
52
+ * lockfile (an optional dependency that failed to build, a peer the consumer
53
+ * is expected to supply), so including them would turn a useful staleness
54
+ * check into a false alarm.
55
+ */
56
+ readonly required?: ReadonlyArray<string> | undefined;
57
+ };
58
+
59
+ /** Which dependency blocks of a package.json become root specs. */
60
+ export type InputFileOptions = {
61
+ /**
62
+ * Include `devDependencies`.
63
+ *
64
+ * On by default: if you are pointing this tool at a package.json, you are
65
+ * almost certainly trying to make that project installable behind the
66
+ * firewall, and a project without its dev tooling does not build. `--prod`
67
+ * turns it off.
68
+ */
69
+ readonly includeDev: boolean;
70
+ /**
71
+ * For a lockfile, which workspace member's dependencies are the roots.
72
+ *
73
+ * Set when a package.json found its lockfile by walking up: the file then
74
+ * covers the whole repository, and only one member of it was asked for.
75
+ */
76
+ readonly importer?: string | undefined;
77
+ };
78
+
79
+ export const defaultInputFileOptions: InputFileOptions = { includeDev: true };
80
+
81
+ /** Reads and parses an input file. */
82
+ export const readInputFile = (
83
+ filePath: string,
84
+ options: InputFileOptions = defaultInputFileOptions,
85
+ ): Effect.Effect<InputFileResult, InvalidInputFileError | LockfileError, FileSystem.FileSystem> =>
86
+ Effect.gen(function* () {
87
+ const fs = yield* FileSystem.FileSystem;
88
+
89
+ const exists = yield* fs
90
+ .exists(filePath)
91
+ .pipe(
92
+ Effect.mapError(
93
+ (cause) => new InvalidInputFileError(filePath, "could not be read", { cause }),
94
+ ),
95
+ );
96
+ if (!exists) {
97
+ return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"));
98
+ }
99
+
100
+ const content = yield* fs
101
+ .readFileString(filePath)
102
+ .pipe(
103
+ Effect.mapError(
104
+ (cause) => new InvalidInputFileError(filePath, "could not be read", { cause }),
105
+ ),
106
+ );
107
+
108
+ return yield* Effect.try({
109
+ try: () => parseInputFile(filePath, content, options),
110
+ catch: (error) =>
111
+ // A LockfileError already names the file and says what is wrong with
112
+ // it; wrapping it would only stutter.
113
+ error instanceof InvalidInputFileError || isLockfileError(error)
114
+ ? error
115
+ : new InvalidInputFileError(filePath, describeError(error), { cause: error }),
116
+ });
117
+ });
118
+
119
+ const isLockfileError = (error: unknown): error is LockfileError =>
120
+ error instanceof Error && "_tag" in error && error._tag === "LockfileError";
121
+
122
+ /**
123
+ * Parses file content that has already been read.
124
+ *
125
+ * Split out from the IO so the whole of this logic is testable with plain
126
+ * strings and no file system at all.
127
+ */
128
+ export const parseInputFile = (
129
+ filePath: string,
130
+ content: string,
131
+ options: InputFileOptions = defaultInputFileOptions,
132
+ ): InputFileResult => {
133
+ const trimmed = content.trim();
134
+ if (trimmed.length === 0) {
135
+ throw new InvalidInputFileError(filePath, "file is empty");
136
+ }
137
+
138
+ // Checked first: a package-lock.json is also a JSON object whose top level
139
+ // looks a lot like a package.json, and reading one as the other would produce
140
+ // a bundle with none of the pinning that was asked for.
141
+ const detected = detectLockfile(trimmed);
142
+ if (detected._tag === "Unsupported") {
143
+ throw new InvalidInputFileError(filePath, `${detected.label} — ${detected.hint}`);
144
+ }
145
+ if (detected._tag === "Supported") {
146
+ return fromLockfile(filePath, content, detected.format, options);
147
+ }
148
+
149
+ return looksLikeJson(trimmed)
150
+ ? fromPackageJson(filePath, trimmed, options)
151
+ : fromList(filePath, content);
152
+ };
153
+
154
+ const looksLikeJson = (trimmed: string): boolean => trimmed.startsWith("{");
155
+
156
+ /* -------------------------------------------------------------------------- */
157
+ /* Lockfile */
158
+ /* -------------------------------------------------------------------------- */
159
+
160
+ const fromLockfile = (
161
+ filePath: string,
162
+ content: string,
163
+ format: LockfileFormat,
164
+ options: InputFileOptions,
165
+ ): InputFileResult => {
166
+ const lockfile = parseLockfile(filePath, content, format, { importer: options.importer });
167
+
168
+ // The same devDependency rule as a package.json, applied to the pinned roots
169
+ // so that `specs` shows exactly what the run will bundle.
170
+ const roots = lockfile.roots.filter((root) => options.includeDev || root.kind !== "dev");
171
+
172
+ return {
173
+ kind: InputFileKind.Lockfile,
174
+ specs: roots.map((root) => ({
175
+ name: root.name,
176
+ selector: { _tag: "Exact", version: root.version },
177
+ raw: `${root.name}@${root.version}`,
178
+ })),
179
+ warnings: lockfile.warnings,
180
+ lockfile,
181
+ };
182
+ };
183
+
184
+ /* -------------------------------------------------------------------------- */
185
+ /* package.json */
186
+ /* -------------------------------------------------------------------------- */
187
+
188
+ const fromPackageJson = (
189
+ filePath: string,
190
+ content: string,
191
+ options: InputFileOptions,
192
+ ): InputFileResult => {
193
+ const parsed = parsePackageJson(filePath, content);
194
+
195
+ const specs: Array<PackageSpec> = [];
196
+ const warnings: Array<string> = [];
197
+ const required: Array<string> = [];
198
+ const seen = new Set<string>();
199
+
200
+ const collect = (
201
+ entries: Record<string, string> | undefined,
202
+ label: string,
203
+ skip?: (name: string) => boolean,
204
+ ): void => {
205
+ if (entries === undefined) return;
206
+ for (const [name, raw] of Object.entries(entries)) {
207
+ if (skip?.(name)) continue;
208
+ const target = parseDependencyTarget(name, raw);
209
+ if (target._tag === "Unsupported") {
210
+ warnings.push(
211
+ `${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`,
212
+ );
213
+ continue;
214
+ }
215
+ // `target.name` rather than the key, so an `npm:` alias is recorded under
216
+ // the name a lockfile would actually pin it as.
217
+ if (label === "dependencies" || label === "devDependencies") required.push(target.name);
218
+ const key = `${target.name}|${JSON.stringify(target.selector)}`;
219
+ if (seen.has(key)) continue;
220
+ seen.add(key);
221
+ specs.push({
222
+ name: target.name,
223
+ selector: target.selector,
224
+ raw: `${target.name}@${raw}`,
225
+ });
226
+ }
227
+ };
228
+
229
+ collect(parsed.dependencies, "dependencies");
230
+ collect(parsed.optionalDependencies, "optionalDependencies");
231
+ if (options.includeDev) {
232
+ collect(parsed.devDependencies, "devDependencies");
233
+ }
234
+ const meta = parsed.peerDependenciesMeta ?? {};
235
+ collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true);
236
+
237
+ if (specs.length === 0) {
238
+ throw new InvalidInputFileError(
239
+ filePath,
240
+ options.includeDev
241
+ ? "package.json declares no dependencies to bundle"
242
+ : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)",
243
+ );
244
+ }
245
+
246
+ return { kind: InputFileKind.PackageJson, specs, warnings, required };
247
+ };
248
+
249
+ /**
250
+ * Reads a package.json, or gives up on its contents rather than on the run.
251
+ *
252
+ * Invalid JSON is an error worth raising — somebody pointed `--file` at a
253
+ * broken file. A well-formed JSON document this schema cannot recognise is
254
+ * not: it decodes to no blocks, and the caller reports "declares no
255
+ * dependencies to bundle", which is both true and more useful.
256
+ */
257
+ const parsePackageJson = (filePath: string, content: string): PackageJson => {
258
+ let parsed: unknown;
259
+ try {
260
+ parsed = JSON.parse(content);
261
+ } catch (error) {
262
+ throw new InvalidInputFileError(filePath, `not valid JSON — ${describeError(error)}`, {
263
+ cause: error,
264
+ });
265
+ }
266
+
267
+ return Option.getOrElse(decodePackageJson(parsed), (): PackageJson => ({}));
268
+ };
269
+
270
+ /* -------------------------------------------------------------------------- */
271
+ /* Spec list */
272
+ /* -------------------------------------------------------------------------- */
273
+
274
+ const fromList = (filePath: string, content: string): InputFileResult => {
275
+ const rawLines = content.split(/\r?\n/);
276
+ const lines = rawLines.flatMap((line) => {
277
+ const withoutComment = stripComment(line).trim();
278
+ return withoutComment.length === 0 ? [] : withoutComment;
279
+ });
280
+
281
+ if (lines.length === 0) {
282
+ throw new InvalidInputFileError(
283
+ filePath,
284
+ "no package specs found (only blank lines and comments)",
285
+ );
286
+ }
287
+
288
+ const { specs, errors } = parseSpecs(lines);
289
+ if (errors.length > 0) {
290
+ throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines));
291
+ }
292
+
293
+ return { kind: InputFileKind.List, specs, warnings: [] };
294
+ };
295
+
296
+ /**
297
+ * Strips `#` and `//` comments.
298
+ *
299
+ * `//` only counts when it is not part of a URL, so a future line containing
300
+ * `https://…` does not get silently truncated.
301
+ */
302
+ const stripComment = (line: string): string => {
303
+ const hash = line.indexOf("#");
304
+ const result = hash === -1 ? line : line.slice(0, hash);
305
+ const slashes = result.indexOf("//");
306
+ if (slashes === 0) return "";
307
+ if (slashes > 0 && result[slashes - 1] !== ":") return result.slice(0, slashes);
308
+ return result;
309
+ };
310
+
311
+ const formatSpecErrors = (
312
+ errors: ReadonlyArray<InvalidSpecError>,
313
+ rawLines: ReadonlyArray<string>,
314
+ ): string => {
315
+ const details = errors.map((error) => {
316
+ const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec);
317
+ const where = index === -1 ? "" : ` (line ${index + 1})`;
318
+ return ` ${error.spec}${where}: ${error.reason}`;
319
+ });
320
+ return `${errors.length} invalid spec(s):\n${details.join("\n")}`;
321
+ };
322
+
323
+ const describeError = (error: unknown): string =>
324
+ error instanceof Error ? error.message : String(error);
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Checksum verification for downloaded tarballs.
3
+ *
4
+ * A failed check discards the download rather than warning and carrying on —
5
+ * see `notes/requirements.md` § FR-9 for why that strictness is a requirement
6
+ * rather than a preference.
7
+ *
8
+ * Built on Effect's `Crypto` service rather than `node:crypto` directly. The
9
+ * four digests `Crypto` offers — SHA-1, SHA-256, SHA-384, SHA-512 — are exactly
10
+ * the four npm publishes, so nothing is given up, and the same verification
11
+ * then runs unchanged against WebCrypto in a browser. Parsing stays here
12
+ * because the subset of Subresource Integrity npm actually emits is small and
13
+ * entirely mechanical; `ssri` would be a dependency for a regex.
14
+ */
15
+ import * as Crypto from "effect/Crypto";
16
+ import * as Effect from "effect/Effect";
17
+ import * as Encoding from "effect/Encoding";
18
+ import type { PlatformError } from "effect/PlatformError";
19
+
20
+ /**
21
+ * The digests npm publishes, mapped to what `Crypto.digest` calls them.
22
+ *
23
+ * Doubles as the supported-algorithm list: an SRI token whose algorithm is not
24
+ * a key here is one we cannot check, and is dropped during parsing.
25
+ */
26
+ const digestAlgorithmByName = {
27
+ sha512: "SHA-512",
28
+ sha384: "SHA-384",
29
+ sha256: "SHA-256",
30
+ sha1: "SHA-1",
31
+ } as const satisfies Record<string, Crypto.DigestAlgorithm>;
32
+
33
+ /** An algorithm this module can verify against. */
34
+ export type IntegrityAlgorithm = keyof typeof digestAlgorithmByName;
35
+
36
+ const isSupported = (algorithm: string): algorithm is IntegrityAlgorithm =>
37
+ Object.hasOwn(digestAlgorithmByName, algorithm);
38
+
39
+ /** One parsed SRI hash. */
40
+ export type IntegrityHash = {
41
+ readonly algorithm: IntegrityAlgorithm;
42
+ /** Base64 digest, exactly as written in the SRI string. */
43
+ readonly digest: string;
44
+ };
45
+
46
+ /**
47
+ * Parses an SRI string such as `sha512-abc...==`.
48
+ *
49
+ * npm permits several space-separated hashes; we keep every one we can verify
50
+ * and ignore algorithms we cannot.
51
+ */
52
+ export const parseIntegrity = (integrity: string): ReadonlyArray<IntegrityHash> => {
53
+ const hashes: Array<IntegrityHash> = [];
54
+ for (const token of integrity.trim().split(/\s+/)) {
55
+ if (token.length === 0) continue;
56
+ const dash = token.indexOf("-");
57
+ if (dash <= 0) continue;
58
+ const algorithm = token.slice(0, dash).toLowerCase();
59
+ const digest = token.slice(dash + 1);
60
+ if (!isSupported(algorithm) || digest.length === 0) continue;
61
+ hashes.push({ algorithm, digest });
62
+ }
63
+ return hashes;
64
+ };
65
+
66
+ const digestBytes = (
67
+ data: Uint8Array,
68
+ algorithm: IntegrityAlgorithm,
69
+ ): Effect.Effect<Uint8Array, PlatformError, Crypto.Crypto> =>
70
+ Effect.gen(function* () {
71
+ const crypto = yield* Crypto.Crypto;
72
+ return yield* crypto.digest(digestAlgorithmByName[algorithm], data);
73
+ });
74
+
75
+ /** Computes the base64 digest of some bytes under one algorithm. */
76
+ export const digestOf = (
77
+ data: Uint8Array,
78
+ algorithm: IntegrityAlgorithm,
79
+ ): Effect.Effect<string, PlatformError, Crypto.Crypto> =>
80
+ Effect.map(digestBytes(data, algorithm), Encoding.encodeBase64);
81
+
82
+ /** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
83
+ export const hexDigestOf = (
84
+ data: Uint8Array,
85
+ algorithm: IntegrityAlgorithm,
86
+ ): Effect.Effect<string, PlatformError, Crypto.Crypto> =>
87
+ Effect.map(digestBytes(data, algorithm), Encoding.encodeHex);
88
+
89
+ /** Result of checking a download. */
90
+ export type VerificationResult =
91
+ /** Verified against at least one checksum. */
92
+ | { readonly _tag: "Verified"; readonly using: string }
93
+ /** The registry advertised no checksum we could use. */
94
+ | { readonly _tag: "Unverifiable"; readonly reason: string }
95
+ /** A checksum was present and did not match. */
96
+ | { readonly _tag: "Mismatch"; readonly expected: string; readonly actual: string };
97
+
98
+ /**
99
+ * Constant-time comparison of two digest strings.
100
+ *
101
+ * Written by hand rather than with `node:crypto`'s `timingSafeEqual`, which
102
+ * needs `Buffer` and would pin this module to Node. Both digests here are
103
+ * public knowledge, so the timing property is close to theatre — but it costs
104
+ * four lines, and the alternative is explaining in a review why it was dropped.
105
+ */
106
+ const digestsEqual = (a: string, b: string): boolean => {
107
+ if (a.length !== b.length) return false;
108
+ let difference = 0;
109
+ for (let index = 0; index < a.length; index++) {
110
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
111
+ }
112
+ return difference === 0;
113
+ };
114
+
115
+ /**
116
+ * Checks bytes against whichever checksums the registry supplied.
117
+ *
118
+ * SRI is preferred; `shasum` is the fallback for versions published before
119
+ * integrity strings existed. If the registry gave us neither, that is reported
120
+ * as `Unverifiable` rather than silently treated as a pass — the caller decides
121
+ * whether to tolerate it.
122
+ */
123
+ export const verify = (
124
+ data: Uint8Array,
125
+ dist: { readonly integrity?: string | undefined; readonly shasum?: string | undefined },
126
+ ): Effect.Effect<VerificationResult, PlatformError, Crypto.Crypto> =>
127
+ Effect.gen(function* () {
128
+ if (dist.integrity !== undefined && dist.integrity.length > 0) {
129
+ // Strongest first, so a mismatch is reported against the best hash present.
130
+ const [best] = parseIntegrity(dist.integrity).toSorted(
131
+ (a, b) => strengthByAlgorithm[b.algorithm] - strengthByAlgorithm[a.algorithm],
132
+ );
133
+ if (best === undefined) {
134
+ return {
135
+ _tag: "Unverifiable",
136
+ reason: `no supported algorithm in "${dist.integrity}"`,
137
+ };
138
+ }
139
+
140
+ const actual = yield* digestOf(data, best.algorithm);
141
+ return digestsEqual(actual, best.digest)
142
+ ? { _tag: "Verified", using: best.algorithm }
143
+ : {
144
+ _tag: "Mismatch",
145
+ expected: `${best.algorithm}-${best.digest}`,
146
+ actual: `${best.algorithm}-${actual}`,
147
+ };
148
+ }
149
+
150
+ if (dist.shasum !== undefined && dist.shasum.length > 0) {
151
+ const actual = yield* hexDigestOf(data, "sha1");
152
+ return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase())
153
+ ? { _tag: "Verified", using: "sha1" }
154
+ : { _tag: "Mismatch", expected: `sha1-${dist.shasum}`, actual: `sha1-${actual}` };
155
+ }
156
+
157
+ return { _tag: "Unverifiable", reason: "registry advertised no integrity or shasum" };
158
+ });
159
+
160
+ const strengthByAlgorithm = {
161
+ sha512: 4,
162
+ sha384: 3,
163
+ sha256: 2,
164
+ sha1: 1,
165
+ } as const satisfies Record<IntegrityAlgorithm, number>;