@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/Download.ts DELETED
@@ -1,132 +0,0 @@
1
- /**
2
- * Fetching tarballs into the staging tree.
3
- *
4
- * Downloads run concurrently, every one is checksum-verified before it is
5
- * written, and the destination is always the scoped temp directory — nothing
6
- * lands next to the user's own files.
7
- */
8
- import * as Effect from "effect/Effect"
9
- import * as FileSystem from "effect/FileSystem"
10
- import * as Path from "effect/Path"
11
- import type { PlatformError } from "effect/PlatformError"
12
- import type { BundlerError } from "./Errors.js"
13
- import { IntegrityError, OutputError } from "./Errors.js"
14
- import * as Integrity from "./Integrity.js"
15
- import { packagePath } from "./Layout.js"
16
- import * as Progress from "./Progress.js"
17
- import { Registry } from "./Registry.js"
18
- import type { ResolvedPackage } from "./Resolve.js"
19
-
20
- /** What a completed download run produced. */
21
- export interface DownloadReport {
22
- /** `name@version` -> bytes written. */
23
- readonly sizes: ReadonlyMap<string, number>
24
- /** Packages the registry advertised no usable checksum for. */
25
- readonly unverified: ReadonlyArray<string>
26
- readonly totalBytes: number
27
- }
28
-
29
- /**
30
- * Downloads every package into `destDir`, laid out the way a registry serves
31
- * them.
32
- *
33
- * `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
34
- * makes it into a corporate registry is far more expensive than a failed run.
35
- */
36
- export const downloadAll = (
37
- packages: ReadonlyArray<ResolvedPackage>,
38
- destDir: string,
39
- options: { readonly concurrency: number; readonly verifyIntegrity: boolean }
40
- ): Effect.Effect<
41
- DownloadReport,
42
- BundlerError | PlatformError,
43
- Registry | Progress.Progress | FileSystem.FileSystem | Path.Path
44
- > =>
45
- Effect.gen(function*() {
46
- const fs = yield* FileSystem.FileSystem
47
- const path = yield* Path.Path
48
- const registry = yield* Registry
49
-
50
- yield* Progress.emit({ _tag: "PhaseStarted", phase: "download", total: packages.length })
51
-
52
- let completed = 0
53
- const sizes = new Map<string, number>()
54
- const unverified: Array<string> = []
55
-
56
- const results = yield* Effect.forEach(
57
- packages,
58
- (pkg) =>
59
- Effect.gen(function*() {
60
- const key = `${pkg.name}@${pkg.version}`
61
-
62
- yield* Progress.emit({
63
- _tag: "DownloadStarted",
64
- name: pkg.name,
65
- version: pkg.version
66
- })
67
-
68
- const bytes = yield* registry.download(pkg.manifest)
69
-
70
- if (options.verifyIntegrity) {
71
- const result = Integrity.verify(bytes, pkg.manifest.dist)
72
- if (result._tag === "Mismatch") {
73
- return yield* Effect.fail(
74
- new IntegrityError(pkg.name, pkg.version, result.expected, result.actual)
75
- )
76
- }
77
- if (result._tag === "Unverifiable") {
78
- yield* Progress.emit({
79
- _tag: "Warning",
80
- message: `${key} could not be verified (${result.reason})`
81
- })
82
- }
83
- }
84
-
85
- const verifiable =
86
- (pkg.manifest.dist.integrity?.length ?? 0) > 0 ||
87
- (pkg.manifest.dist.shasum?.length ?? 0) > 0
88
-
89
- const relative = packagePath(pkg.name, pkg.version)
90
- const target = path.join(destDir, ...relative.split("/"))
91
-
92
- yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(
93
- Effect.mapError(
94
- (cause) =>
95
- new OutputError(path.dirname(target), "could not create directory", { cause })
96
- )
97
- )
98
- yield* fs.writeFile(target, bytes).pipe(
99
- Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause }))
100
- )
101
-
102
- // Counting here rather than inside the concurrent body would be
103
- // wrong; each fiber increments as it finishes, which is what makes
104
- // the progress counter monotonic.
105
- completed += 1
106
- yield* Progress.emit({
107
- _tag: "DownloadCompleted",
108
- name: pkg.name,
109
- version: pkg.version,
110
- bytes: bytes.byteLength,
111
- completedCount: completed,
112
- totalCount: packages.length
113
- })
114
-
115
- return { key, bytes: bytes.byteLength, verifiable } as const
116
- }),
117
- { concurrency: options.concurrency }
118
- )
119
-
120
- for (const result of results) {
121
- sizes.set(result.key, result.bytes)
122
- if (!result.verifiable) unverified.push(result.key)
123
- }
124
-
125
- yield* Progress.emit({ _tag: "PhaseCompleted", phase: "download" })
126
-
127
- return {
128
- sizes,
129
- unverified,
130
- totalBytes: results.reduce((sum, r) => sum + r.bytes, 0)
131
- }
132
- })
package/src/Errors.ts DELETED
@@ -1,216 +0,0 @@
1
- /**
2
- * Failure types for the bundler.
3
- *
4
- * These are deliberately plain classes carrying a literal `_tag` rather than
5
- * `Data.TaggedError`. They work with `Effect.catchTag` / `Effect.catchTags`
6
- * exactly the same way, and they keep the public error surface independent of
7
- * any single Effect release — useful while Effect v4 is still in beta.
8
- *
9
- * Every error carries enough context to be actionable without a stack trace,
10
- * because the primary consumer is somebody staring at a terminal on a
11
- * locked-down network trying to work out why a download did not happen.
12
- */
13
-
14
- /** Discriminant union of every failure the bundler can produce. */
15
- export type BundlerError =
16
- | InvalidSpecError
17
- | InvalidInputFileError
18
- | RegistryUnreachableError
19
- | PackageNotFoundError
20
- | VersionNotFoundError
21
- | NoMatchingVersionsError
22
- | RegistryResponseError
23
- | AuthenticationError
24
- | IntegrityError
25
- | ArchiveError
26
- | OutputError
27
-
28
- abstract class BundlerErrorBase extends Error {
29
- abstract readonly _tag: string
30
- constructor(message: string, options?: { cause?: unknown }) {
31
- super(message, options as ErrorOptions)
32
- this.name = new.target.name
33
- }
34
- }
35
-
36
- /** A package spec on the command line or in an input file could not be parsed. */
37
- export class InvalidSpecError extends BundlerErrorBase {
38
- readonly _tag = "InvalidSpecError" as const
39
- readonly spec: string
40
- readonly reason: string
41
- constructor(spec: string, reason: string) {
42
- super(`Invalid package spec ${JSON.stringify(spec)}: ${reason}`)
43
- this.spec = spec
44
- this.reason = reason
45
- }
46
- }
47
-
48
- /** `--file` pointed at something that is neither a usable package.json nor a spec list. */
49
- export class InvalidInputFileError extends BundlerErrorBase {
50
- readonly _tag = "InvalidInputFileError" as const
51
- readonly path: string
52
- readonly reason: string
53
- constructor(path: string, reason: string, options?: { cause?: unknown }) {
54
- super(`Cannot read specs from ${path}: ${reason}`, options)
55
- this.path = path
56
- this.reason = reason
57
- }
58
- }
59
-
60
- /**
61
- * The registry could not be reached at all — DNS failure, refused connection,
62
- * proxy blackhole, or a preflight that timed out.
63
- *
64
- * This is the error that fixes "hangs forever with no network": we surface it
65
- * quickly and say which host we could not reach.
66
- */
67
- export class RegistryUnreachableError extends BundlerErrorBase {
68
- readonly _tag = "RegistryUnreachableError" as const
69
- readonly registry: string
70
- readonly timeoutMillis: number | undefined
71
- constructor(
72
- registry: string,
73
- detail: string,
74
- options?: { cause?: unknown; timeoutMillis?: number }
75
- ) {
76
- super(
77
- `Cannot reach registry ${registry}: ${detail}.\n` +
78
- ` Check your network connection, VPN, and the proxy settings in your .npmrc.`,
79
- options
80
- )
81
- this.registry = registry
82
- this.timeoutMillis = options?.timeoutMillis
83
- }
84
- }
85
-
86
- /** The registry answered, but has never heard of this package. */
87
- export class PackageNotFoundError extends BundlerErrorBase {
88
- readonly _tag = "PackageNotFoundError" as const
89
- readonly packageName: string
90
- readonly registry: string
91
- constructor(packageName: string, registry: string) {
92
- super(`Package "${packageName}" was not found on ${registry}`)
93
- this.packageName = packageName
94
- this.registry = registry
95
- }
96
- }
97
-
98
- /** The package exists but the exact version requested does not. */
99
- export class VersionNotFoundError extends BundlerErrorBase {
100
- readonly _tag = "VersionNotFoundError" as const
101
- readonly packageName: string
102
- readonly version: string
103
- readonly available: ReadonlyArray<string>
104
- constructor(packageName: string, version: string, available: ReadonlyArray<string>) {
105
- const tail = available.slice(-5).join(", ")
106
- super(
107
- `${packageName}@${version} does not exist.` +
108
- (tail.length > 0 ? ` Most recent published versions: ${tail}` : "")
109
- )
110
- this.packageName = packageName
111
- this.version = version
112
- this.available = available
113
- }
114
- }
115
-
116
- /** A range (or dist-tag) matched nothing that is actually published. */
117
- export class NoMatchingVersionsError extends BundlerErrorBase {
118
- readonly _tag = "NoMatchingVersionsError" as const
119
- readonly packageName: string
120
- readonly selector: string
121
- readonly available: ReadonlyArray<string>
122
- constructor(packageName: string, selector: string, available: ReadonlyArray<string>) {
123
- const tail = available.slice(-5).join(", ")
124
- super(
125
- `No published version of ${packageName} satisfies "${selector}".` +
126
- (tail.length > 0 ? ` Most recent published versions: ${tail}` : "")
127
- )
128
- this.packageName = packageName
129
- this.selector = selector
130
- this.available = available
131
- }
132
- }
133
-
134
- /** The registry responded, but with something we cannot use. */
135
- export class RegistryResponseError extends BundlerErrorBase {
136
- readonly _tag = "RegistryResponseError" as const
137
- readonly url: string
138
- readonly status: number | undefined
139
- constructor(url: string, detail: string, options?: { cause?: unknown; status?: number }) {
140
- super(`Unexpected response from ${url}: ${detail}`, options)
141
- this.url = url
142
- this.status = options?.status
143
- }
144
- }
145
-
146
- /** 401/403 — almost always a missing or stale token in `.npmrc`. */
147
- export class AuthenticationError extends BundlerErrorBase {
148
- readonly _tag = "AuthenticationError" as const
149
- readonly registry: string
150
- readonly status: number
151
- constructor(registry: string, status: number, packageName?: string) {
152
- super(
153
- `Registry ${registry} rejected the request with HTTP ${status}` +
154
- (packageName ? ` while fetching "${packageName}"` : "") +
155
- `.\n Add credentials to your .npmrc, e.g.\n` +
156
- ` //${safeHost(registry)}/:_authToken=\${NPM_TOKEN}`
157
- )
158
- this.registry = registry
159
- this.status = status
160
- }
161
- }
162
-
163
- /**
164
- * A downloaded tarball did not match the checksum the registry advertised.
165
- *
166
- * Never soft-fail this: a bundle is a supply-chain artifact and a corrupt or
167
- * substituted tarball is exactly what integrity checking exists to catch.
168
- */
169
- export class IntegrityError extends BundlerErrorBase {
170
- readonly _tag = "IntegrityError" as const
171
- readonly packageName: string
172
- readonly version: string
173
- readonly expected: string
174
- readonly actual: string
175
- constructor(packageName: string, version: string, expected: string, actual: string) {
176
- super(
177
- `Integrity check failed for ${packageName}@${version}.\n` +
178
- ` expected: ${expected}\n` +
179
- ` actual: ${actual}\n` +
180
- ` The download was discarded. This is either corruption in transit or a tampered artifact.`
181
- )
182
- this.packageName = packageName
183
- this.version = version
184
- this.expected = expected
185
- this.actual = actual
186
- }
187
- }
188
-
189
- /** Something went wrong writing the `.tgz`. */
190
- export class ArchiveError extends BundlerErrorBase {
191
- readonly _tag = "ArchiveError" as const
192
- readonly path: string
193
- constructor(path: string, detail: string, options?: { cause?: unknown }) {
194
- super(`Failed to create archive ${path}: ${detail}`, options)
195
- this.path = path
196
- }
197
- }
198
-
199
- /** Something went wrong preparing or writing to the output directory. */
200
- export class OutputError extends BundlerErrorBase {
201
- readonly _tag = "OutputError" as const
202
- readonly path: string
203
- constructor(path: string, detail: string, options?: { cause?: unknown }) {
204
- super(`Output error at ${path}: ${detail}`, options)
205
- this.path = path
206
- }
207
- }
208
-
209
- const safeHost = (registry: string): string => {
210
- try {
211
- const url = new URL(registry)
212
- return url.host + url.pathname.replace(/\/+$/, "")
213
- } catch {
214
- return registry
215
- }
216
- }
package/src/InputFile.ts DELETED
@@ -1,229 +0,0 @@
1
- /**
2
- * `--file` handling.
3
- *
4
- * One flag, two 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 **newline-delimited list** — one spec per line, `#` and `//` comments
10
- * allowed, blank lines ignored
11
- */
12
- import * as Effect from "effect/Effect"
13
- import * as FileSystem from "effect/FileSystem"
14
- import { parseDependencyTarget } from "./DependencyRange.js"
15
- import { InvalidInputFileError } from "./Errors.js"
16
- import type { InvalidSpecError } from "./Errors.js"
17
- import type { PackageSpec } from "./Spec.js"
18
- import { parseSpecs } from "./Spec.js"
19
-
20
- /** Which of the two shapes a file turned out to be. */
21
- export type InputFileKind = "package.json" | "list"
22
-
23
- export interface InputFileResult {
24
- readonly kind: InputFileKind
25
- readonly specs: ReadonlyArray<PackageSpec>
26
- /** Entries that were recognised but cannot be bundled from a registry. */
27
- readonly warnings: ReadonlyArray<string>
28
- }
29
-
30
- /** Which dependency blocks of a package.json become root specs. */
31
- export interface InputFileOptions {
32
- /**
33
- * Include `devDependencies`.
34
- *
35
- * On by default: if you are pointing this tool at a package.json, you are
36
- * almost certainly trying to make that project installable behind the
37
- * firewall, and a project without its dev tooling does not build. `--prod`
38
- * turns it off.
39
- */
40
- readonly includeDev: boolean
41
- }
42
-
43
- export const defaultInputFileOptions: InputFileOptions = { includeDev: true }
44
-
45
- /** Reads and parses an input file. */
46
- export const readInputFile = (
47
- filePath: string,
48
- options: InputFileOptions = defaultInputFileOptions
49
- ): Effect.Effect<InputFileResult, InvalidInputFileError, FileSystem.FileSystem> =>
50
- Effect.gen(function*() {
51
- const fs = yield* FileSystem.FileSystem
52
-
53
- const exists = yield* fs.exists(filePath).pipe(
54
- Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))
55
- )
56
- if (!exists) {
57
- return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"))
58
- }
59
-
60
- const content = yield* fs.readFileString(filePath).pipe(
61
- Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))
62
- )
63
-
64
- return yield* Effect.try({
65
- try: () => parseInputFile(filePath, content, options),
66
- catch: (error) =>
67
- error instanceof InvalidInputFileError
68
- ? error
69
- : new InvalidInputFileError(filePath, describe(error), { cause: error })
70
- })
71
- })
72
-
73
- /**
74
- * Parses file content that has already been read.
75
- *
76
- * Split out from the IO so the whole of this logic is testable with plain
77
- * strings and no file system at all.
78
- */
79
- export const parseInputFile = (
80
- filePath: string,
81
- content: string,
82
- options: InputFileOptions = defaultInputFileOptions
83
- ): InputFileResult => {
84
- const trimmed = content.trim()
85
- if (trimmed.length === 0) {
86
- throw new InvalidInputFileError(filePath, "file is empty")
87
- }
88
-
89
- return looksLikeJson(trimmed)
90
- ? fromPackageJson(filePath, trimmed, options)
91
- : fromList(filePath, content)
92
- }
93
-
94
- const looksLikeJson = (trimmed: string): boolean => trimmed.startsWith("{")
95
-
96
- /* -------------------------------------------------------------------------- */
97
- /* package.json */
98
- /* -------------------------------------------------------------------------- */
99
-
100
- interface PackageJsonShape {
101
- readonly dependencies?: Record<string, string>
102
- readonly devDependencies?: Record<string, string>
103
- readonly optionalDependencies?: Record<string, string>
104
- readonly peerDependencies?: Record<string, string>
105
- readonly peerDependenciesMeta?: Record<string, { optional?: boolean }>
106
- }
107
-
108
- const fromPackageJson = (
109
- filePath: string,
110
- content: string,
111
- options: InputFileOptions
112
- ): InputFileResult => {
113
- let parsed: PackageJsonShape
114
- try {
115
- parsed = JSON.parse(content) as PackageJsonShape
116
- } catch (error) {
117
- throw new InvalidInputFileError(filePath, `not valid JSON — ${describe(error)}`, {
118
- cause: error
119
- })
120
- }
121
-
122
- const specs: Array<PackageSpec> = []
123
- const warnings: Array<string> = []
124
- const seen = new Set<string>()
125
-
126
- const collect = (
127
- entries: Record<string, string> | undefined,
128
- label: string,
129
- skip?: (name: string) => boolean
130
- ): void => {
131
- if (entries === undefined) return
132
- for (const [name, raw] of Object.entries(entries)) {
133
- if (skip?.(name)) continue
134
- const target = parseDependencyTarget(name, raw)
135
- if (target._tag === "Unsupported") {
136
- warnings.push(
137
- `${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`
138
- )
139
- continue
140
- }
141
- const key = `${target.name}|${JSON.stringify(target.selector)}`
142
- if (seen.has(key)) continue
143
- seen.add(key)
144
- specs.push({
145
- name: target.name,
146
- selector: target.selector,
147
- raw: `${target.name}@${raw}`
148
- })
149
- }
150
- }
151
-
152
- collect(parsed.dependencies, "dependencies")
153
- collect(parsed.optionalDependencies, "optionalDependencies")
154
- if (options.includeDev) {
155
- collect(parsed.devDependencies, "devDependencies")
156
- }
157
- const meta = parsed.peerDependenciesMeta ?? {}
158
- collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true)
159
-
160
- if (specs.length === 0) {
161
- throw new InvalidInputFileError(
162
- filePath,
163
- options.includeDev
164
- ? "package.json declares no dependencies to bundle"
165
- : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)"
166
- )
167
- }
168
-
169
- return { kind: "package.json", specs, warnings }
170
- }
171
-
172
- /* -------------------------------------------------------------------------- */
173
- /* Spec list */
174
- /* -------------------------------------------------------------------------- */
175
-
176
- const fromList = (filePath: string, content: string): InputFileResult => {
177
- const lines: Array<string> = []
178
- const rawLines = content.split(/\r?\n/)
179
-
180
- for (const line of rawLines) {
181
- const withoutComment = stripComment(line).trim()
182
- if (withoutComment.length === 0) continue
183
- lines.push(withoutComment)
184
- }
185
-
186
- if (lines.length === 0) {
187
- throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)")
188
- }
189
-
190
- const { specs, errors } = parseSpecs(lines)
191
- if (errors.length > 0) {
192
- throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines))
193
- }
194
-
195
- return { kind: "list", specs, warnings: [] }
196
- }
197
-
198
- /**
199
- * Strips `#` and `//` comments.
200
- *
201
- * `//` only counts when it is not part of a URL, so a future line containing
202
- * `https://…` does not get silently truncated.
203
- */
204
- const stripComment = (line: string): string => {
205
- const hash = line.indexOf("#")
206
- let result = hash === -1 ? line : line.slice(0, hash)
207
- const slashes = result.indexOf("//")
208
- if (slashes > 0 && result[slashes - 1] !== ":") {
209
- result = result.slice(0, slashes)
210
- } else if (slashes === 0) {
211
- result = ""
212
- }
213
- return result
214
- }
215
-
216
- const formatSpecErrors = (
217
- errors: ReadonlyArray<InvalidSpecError>,
218
- rawLines: ReadonlyArray<string>
219
- ): string => {
220
- const details = errors.map((error) => {
221
- const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec)
222
- const where = index === -1 ? "" : ` (line ${index + 1})`
223
- return ` ${error.spec}${where}: ${error.reason}`
224
- })
225
- return `${errors.length} invalid spec(s):\n${details.join("\n")}`
226
- }
227
-
228
- const describe = (error: unknown): string =>
229
- error instanceof Error ? error.message : String(error)
package/src/Integrity.ts DELETED
@@ -1,122 +0,0 @@
1
- /**
2
- * Checksum verification for downloaded tarballs.
3
- *
4
- * A bundle is a supply-chain artifact that gets imported wholesale into a
5
- * corporate registry, so "the bytes I got are the bytes the registry promised"
6
- * is the one property worth being strict about. A failed check discards the
7
- * download rather than warning and carrying on.
8
- *
9
- * Implemented directly against `node:crypto` rather than pulling in `ssri`,
10
- * because the subset of Subresource Integrity that npm actually publishes is
11
- * small and entirely mechanical.
12
- */
13
- import { createHash, timingSafeEqual } from "node:crypto"
14
-
15
- /** One parsed SRI hash. */
16
- export interface IntegrityHash {
17
- readonly algorithm: string
18
- /** Base64 digest, exactly as written in the SRI string. */
19
- readonly digest: string
20
- }
21
-
22
- const SUPPORTED = new Set(["sha512", "sha384", "sha256", "sha1"])
23
-
24
- /**
25
- * Parses an SRI string such as `sha512-abc...==`.
26
- *
27
- * npm permits several space-separated hashes; we keep every one we can verify
28
- * and ignore algorithms Node does not implement.
29
- */
30
- export const parseIntegrity = (integrity: string): ReadonlyArray<IntegrityHash> => {
31
- const hashes: Array<IntegrityHash> = []
32
- for (const token of integrity.trim().split(/\s+/)) {
33
- if (token.length === 0) continue
34
- const dash = token.indexOf("-")
35
- if (dash <= 0) continue
36
- const algorithm = token.slice(0, dash).toLowerCase()
37
- const digest = token.slice(dash + 1)
38
- if (!SUPPORTED.has(algorithm) || digest.length === 0) continue
39
- hashes.push({ algorithm, digest })
40
- }
41
- return hashes
42
- }
43
-
44
- /** Computes the base64 digest of some bytes under one algorithm. */
45
- export const digestOf = (data: Uint8Array, algorithm: string): string =>
46
- createHash(algorithm).update(data).digest("base64")
47
-
48
- /** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
49
- export const hexDigestOf = (data: Uint8Array, algorithm: string): string =>
50
- createHash(algorithm).update(data).digest("hex")
51
-
52
- /** Result of checking a download. */
53
- export type VerificationResult =
54
- /** Verified against at least one checksum. */
55
- | { readonly _tag: "Verified"; readonly using: string }
56
- /** The registry advertised no checksum we could use. */
57
- | { readonly _tag: "Unverifiable"; readonly reason: string }
58
- /** A checksum was present and did not match. */
59
- | { readonly _tag: "Mismatch"; readonly expected: string; readonly actual: string }
60
-
61
- /** Constant-time comparison of two digest strings of equal length. */
62
- const digestsEqual = (a: string, b: string): boolean => {
63
- const left = Buffer.from(a)
64
- const right = Buffer.from(b)
65
- // `timingSafeEqual` throws on unequal lengths, which is itself a mismatch.
66
- return left.length === right.length && timingSafeEqual(left, right)
67
- }
68
-
69
- /**
70
- * Checks bytes against whichever checksums the registry supplied.
71
- *
72
- * SRI is preferred; `shasum` is the fallback for versions published before
73
- * integrity strings existed. If the registry gave us neither, that is reported
74
- * as `Unverifiable` rather than silently treated as a pass — the caller decides
75
- * whether to tolerate it.
76
- */
77
- export const verify = (
78
- data: Uint8Array,
79
- dist: { readonly integrity?: string | undefined; readonly shasum?: string | undefined }
80
- ): VerificationResult => {
81
- if (dist.integrity !== undefined && dist.integrity.length > 0) {
82
- const hashes = parseIntegrity(dist.integrity)
83
- if (hashes.length === 0) {
84
- return { _tag: "Unverifiable", reason: `no supported algorithm in "${dist.integrity}"` }
85
- }
86
- // Strongest first, so a mismatch is reported against the best hash present.
87
- const ordered = [...hashes].sort(
88
- (a, b) => strength(b.algorithm) - strength(a.algorithm)
89
- )
90
- const best = ordered[0]!
91
- const actual = digestOf(data, best.algorithm)
92
- return digestsEqual(actual, best.digest)
93
- ? { _tag: "Verified", using: best.algorithm }
94
- : {
95
- _tag: "Mismatch",
96
- expected: `${best.algorithm}-${best.digest}`,
97
- actual: `${best.algorithm}-${actual}`
98
- }
99
- }
100
-
101
- if (dist.shasum !== undefined && dist.shasum.length > 0) {
102
- const actual = hexDigestOf(data, "sha1")
103
- return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase())
104
- ? { _tag: "Verified", using: "sha1" }
105
- : { _tag: "Mismatch", expected: `sha1-${dist.shasum}`, actual: `sha1-${actual}` }
106
- }
107
-
108
- return { _tag: "Unverifiable", reason: "registry advertised no integrity or shasum" }
109
- }
110
-
111
- const strength = (algorithm: string): number => {
112
- switch (algorithm) {
113
- case "sha512":
114
- return 4
115
- case "sha384":
116
- return 3
117
- case "sha256":
118
- return 2
119
- default:
120
- return 1
121
- }
122
- }