@packall/core 0.0.1

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 (51) hide show
  1. package/dist/Archive.d.ts +32 -0
  2. package/dist/Archive.d.ts.map +1 -0
  3. package/dist/Bundle.d.ts +123 -0
  4. package/dist/Bundle.d.ts.map +1 -0
  5. package/dist/DependencyRange.d.ts +26 -0
  6. package/dist/DependencyRange.d.ts.map +1 -0
  7. package/dist/Download.d.ts +35 -0
  8. package/dist/Download.d.ts.map +1 -0
  9. package/dist/Errors.d.ts +124 -0
  10. package/dist/Errors.d.ts.map +1 -0
  11. package/dist/InputFile.d.ts +46 -0
  12. package/dist/InputFile.d.ts.map +1 -0
  13. package/dist/Integrity.d.ts +48 -0
  14. package/dist/Integrity.d.ts.map +1 -0
  15. package/dist/Layout.d.ts +53 -0
  16. package/dist/Layout.d.ts.map +1 -0
  17. package/dist/Manifest.d.ts +82 -0
  18. package/dist/Manifest.d.ts.map +1 -0
  19. package/dist/Options.d.ts +100 -0
  20. package/dist/Options.d.ts.map +1 -0
  21. package/dist/Platform.d.ts +69 -0
  22. package/dist/Platform.d.ts.map +1 -0
  23. package/dist/Progress.d.ts +107 -0
  24. package/dist/Progress.d.ts.map +1 -0
  25. package/dist/Registry.d.ts +106 -0
  26. package/dist/Registry.d.ts.map +1 -0
  27. package/dist/Resolve.d.ts +105 -0
  28. package/dist/Resolve.d.ts.map +1 -0
  29. package/dist/Spec.d.ts +62 -0
  30. package/dist/Spec.d.ts.map +1 -0
  31. package/dist/index.d.ts +36 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1766 -0
  34. package/dist/index.js.map +1 -0
  35. package/package.json +38 -0
  36. package/src/Archive.ts +92 -0
  37. package/src/Bundle.ts +570 -0
  38. package/src/DependencyRange.ts +115 -0
  39. package/src/Download.ts +132 -0
  40. package/src/Errors.ts +216 -0
  41. package/src/InputFile.ts +229 -0
  42. package/src/Integrity.ts +122 -0
  43. package/src/Layout.ts +103 -0
  44. package/src/Manifest.ts +133 -0
  45. package/src/Options.ts +123 -0
  46. package/src/Platform.ts +182 -0
  47. package/src/Progress.ts +106 -0
  48. package/src/Registry.ts +121 -0
  49. package/src/Resolve.ts +528 -0
  50. package/src/Spec.ts +186 -0
  51. package/src/index.ts +44 -0
package/src/Layout.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The on-disk shape of a bundle.
3
+ *
4
+ * Everything is laid out exactly the way a registry serves it:
5
+ *
6
+ * lodash/-/lodash-4.17.21.tgz
7
+ * @babel/core/-/core-7.24.0.tgz
8
+ *
9
+ * That is not an Artifactory convention — it is the path structure in every
10
+ * `dist.tarball` URL npm publishes, which is why the same tree imports into
11
+ * Artifactory, Nexus and Verdaccio without translation. Note that a scoped
12
+ * package's file name drops the scope: `@babel/core` becomes `core-7.24.0.tgz`
13
+ * under an `@babel/core/-/` directory.
14
+ */
15
+
16
+ /** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
17
+ export const splitName = (
18
+ name: string
19
+ ): { readonly scope: string | undefined; readonly bare: string } => {
20
+ if (!name.startsWith("@")) return { scope: undefined, bare: name }
21
+ const slash = name.indexOf("/")
22
+ if (slash === -1) return { scope: undefined, bare: name }
23
+ return { scope: name.slice(0, slash), bare: name.slice(slash + 1) }
24
+ }
25
+
26
+ /** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
27
+ export const tarballFileName = (name: string, version: string): string => {
28
+ const { bare } = splitName(name)
29
+ return `${bare}-${version}.tgz`
30
+ }
31
+
32
+ /**
33
+ * The path of one package tarball within a bundle, using `/` separators
34
+ * regardless of host platform — these become tar entry names, and tar entries
35
+ * are always POSIX.
36
+ */
37
+ export const packagePath = (name: string, version: string): string =>
38
+ `${name}/-/${tarballFileName(name, version)}`
39
+
40
+ /** File name of the manifest that ships inside every bundle. */
41
+ export const MANIFEST_FILE = "bundle-manifest.json"
42
+
43
+ /** File name of the short import guide that ships inside every bundle. */
44
+ export const README_FILE = "IMPORT.md"
45
+
46
+ /**
47
+ * Name of the archive produced for one root spec in `per-spec` layout.
48
+ *
49
+ * Scoped names are flattened (`@babel/core` -> `babel-core`) because a `/` in a
50
+ * file name is not a thing, and `@` confuses enough shells and web UIs to be
51
+ * worth avoiding.
52
+ */
53
+ export const perSpecArchiveName = (name: string, version: string): string => {
54
+ const { scope, bare } = splitName(name)
55
+ const prefix = scope === undefined ? bare : `${scope.slice(1)}-${bare}`
56
+ return `${prefix}-${version}.tgz`
57
+ }
58
+
59
+ /** Name of the archive produced in `single` layout. */
60
+ export const singleArchiveName = (base = "bundle"): string => `${base}.tgz`
61
+
62
+ /**
63
+ * The import guide written into each bundle.
64
+ *
65
+ * Kept short and copy-pasteable on purpose: whoever opens this is mid-task on a
66
+ * restricted network and does not want prose.
67
+ */
68
+ export const importGuide = (options: {
69
+ readonly packageCount: number
70
+ readonly createdAt: string
71
+ readonly toolVersion: string
72
+ }): string =>
73
+ `# Importing this bundle
74
+
75
+ ${options.packageCount} package tarball(s), laid out exactly as an npm registry serves them:
76
+
77
+ <package-name>/-/<file>.tgz
78
+ @<scope>/<name>/-/<file>.tgz
79
+
80
+ Created ${options.createdAt} by packall ${options.toolVersion}.
81
+ See ${MANIFEST_FILE} for the full list with checksums.
82
+
83
+ ## JFrog Artifactory
84
+
85
+ Unpack, then upload the tree into a **local npm** repository. The layout already
86
+ matches what Artifactory expects, so no path rewriting is needed:
87
+
88
+ tar -xzf <this-bundle>.tgz -C ./bundle
89
+ jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
90
+
91
+ ## Verdaccio / Nexus / any npm registry
92
+
93
+ Publish each tarball individually:
94
+
95
+ find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
96
+
97
+ ## Verifying before import
98
+
99
+ node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
100
+
101
+ Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
102
+ advertised, and each tarball was checked against it at download time.
103
+ `
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The manifest written into every bundle.
3
+ *
4
+ * This is the audit trail. Before anything is imported into a corporate
5
+ * registry somebody is usually required to answer "what exactly is in here and
6
+ * where did it come from" — this file answers that without unpacking a single
7
+ * tarball.
8
+ */
9
+ import type { Resolution, ResolvedPackage } from "./Resolve.js"
10
+ import { packagePath } from "./Layout.js"
11
+ import type { BundleOptions } from "./Options.js"
12
+ import { formatPlatformFilter } from "./Platform.js"
13
+ import { formatSpec } from "./Spec.js"
14
+
15
+ /** Schema version, bumped when the shape changes incompatibly. */
16
+ export const MANIFEST_VERSION = 1
17
+
18
+ export interface ManifestEntry {
19
+ readonly name: string
20
+ readonly version: string
21
+ /** Path of this tarball inside the bundle. */
22
+ readonly path: string
23
+ /** The URL it was downloaded from. */
24
+ readonly tarball: string
25
+ /** SRI integrity string as advertised by the source registry, when present. */
26
+ readonly integrity?: string | undefined
27
+ /** Legacy sha1, present on older published versions. */
28
+ readonly shasum?: string | undefined
29
+ /** Actual size on disk, in bytes. */
30
+ readonly bytes?: number | undefined
31
+ /** Human-readable justifications: `root:react@18.2.0`, `prod:scheduler@0.23.0`. */
32
+ readonly reasons: ReadonlyArray<string>
33
+ }
34
+
35
+ export interface BundleManifest {
36
+ readonly manifestVersion: number
37
+ readonly tool: { readonly name: string; readonly version: string }
38
+ readonly createdAt: string
39
+ readonly registry: { readonly kind: string; readonly url: string }
40
+ /** What the user asked for, verbatim. */
41
+ readonly requested: ReadonlyArray<string>
42
+ /** What each request expanded to. */
43
+ readonly roots: ReadonlyArray<{
44
+ readonly spec: string
45
+ readonly versions: ReadonlyArray<string>
46
+ readonly packageCount: number
47
+ }>
48
+ readonly options: {
49
+ readonly layout: string
50
+ readonly optionalDependencies: boolean
51
+ readonly peerDependencies: boolean
52
+ readonly platforms: string
53
+ readonly allVersions: boolean
54
+ readonly includePrerelease: boolean
55
+ readonly integrityVerified: boolean
56
+ }
57
+ readonly packages: ReadonlyArray<ManifestEntry>
58
+ readonly warnings: ReadonlyArray<string>
59
+ readonly totals: {
60
+ readonly packages: number
61
+ readonly bytes: number
62
+ }
63
+ }
64
+
65
+ /** Renders a package's reasons into short, greppable strings. */
66
+ export const formatReasons = (pkg: ResolvedPackage): ReadonlyArray<string> =>
67
+ pkg.reasons.map((reason) =>
68
+ reason._tag === "Root" ? `root:${reason.spec}` : `${reason.kind}:${reason.from}`
69
+ )
70
+
71
+ /** Builds the manifest for a completed (or dry) run. */
72
+ export const buildManifest = (input: {
73
+ readonly resolution: Resolution
74
+ /** Only the packages actually included in *this* archive. */
75
+ readonly included: ReadonlyArray<ResolvedPackage>
76
+ readonly options: BundleOptions
77
+ readonly registry: { readonly kind: string; readonly url: string }
78
+ readonly toolVersion: string
79
+ readonly sizes: ReadonlyMap<string, number>
80
+ readonly createdAt?: Date | undefined
81
+ }): BundleManifest => {
82
+ const includedNames = new Set(input.included.map((p) => `${p.name}@${p.version}`))
83
+
84
+ const packages: ReadonlyArray<ManifestEntry> = input.included.map((pkg) => {
85
+ const key = `${pkg.name}@${pkg.version}`
86
+ return {
87
+ name: pkg.name,
88
+ version: pkg.version,
89
+ path: packagePath(pkg.name, pkg.version),
90
+ tarball: pkg.manifest.dist.tarball,
91
+ integrity: pkg.manifest.dist.integrity,
92
+ shasum: pkg.manifest.dist.shasum,
93
+ bytes: input.sizes.get(key),
94
+ reasons: formatReasons(pkg)
95
+ }
96
+ })
97
+
98
+ const totalBytes = packages.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0)
99
+
100
+ return {
101
+ manifestVersion: MANIFEST_VERSION,
102
+ tool: { name: "packall", version: input.toolVersion },
103
+ createdAt: (input.createdAt ?? new Date()).toISOString(),
104
+ registry: input.registry,
105
+ requested: input.resolution.roots.map((root) => formatSpec(root.spec)),
106
+ roots: input.resolution.roots.map((root) => ({
107
+ spec: formatSpec(root.spec),
108
+ versions: root.versions,
109
+ packageCount: root.closure.filter((key) => includedNames.has(key)).length
110
+ })),
111
+ options: {
112
+ layout: input.options.layout,
113
+ optionalDependencies: input.options.scope.optional,
114
+ peerDependencies: input.options.scope.peer,
115
+ platforms: formatPlatformFilter(input.options.scope.platforms),
116
+ allVersions: input.options.allVersions,
117
+ includePrerelease: input.options.includePrerelease,
118
+ integrityVerified: input.options.verifyIntegrity
119
+ },
120
+ packages,
121
+ warnings: input.resolution.warnings.map((warning) =>
122
+ warning.from === undefined ? warning.message : `${warning.from}: ${warning.message}`
123
+ ),
124
+ totals: {
125
+ packages: packages.length,
126
+ bytes: totalBytes
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Serialises a manifest with stable key order and a trailing newline. */
132
+ export const serializeManifest = (manifest: BundleManifest): string =>
133
+ `${JSON.stringify(manifest, null, 2)}\n`
package/src/Options.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Option types shared by the resolver and the bundler, plus their defaults.
3
+ *
4
+ * Defaults are chosen so that the *unflagged* command produces a bundle that
5
+ * actually installs offline on a machine that is not the one you built it on.
6
+ * Every switch that makes the bundle smaller is opt-in.
7
+ */
8
+ import type { PlatformFilter } from "./Platform.js"
9
+ import { allPlatforms } from "./Platform.js"
10
+
11
+ /** How the resulting tarball(s) are shaped. */
12
+ export type Layout =
13
+ /**
14
+ * One tarball per requested spec, each carrying its own complete dependency
15
+ * closure. Shared dependencies repeat across tarballs, so the total is
16
+ * larger — but each one imports, rolls back and hands off independently.
17
+ */
18
+ | "per-spec"
19
+ /**
20
+ * One tarball for everything, with the union of all closures deduplicated.
21
+ * Smallest output and a single import step; all-or-nothing to roll back.
22
+ */
23
+ | "single"
24
+ /**
25
+ * No archive at all — just the npm-layout directory tree, ready for
26
+ * `jf rt upload` or rsync.
27
+ */
28
+ | "dir"
29
+
30
+ export const layouts: ReadonlyArray<Layout> = ["per-spec", "single", "dir"]
31
+
32
+ /** Which dependency edges the resolver follows. */
33
+ export interface DependencyScope {
34
+ /**
35
+ * Follow `optionalDependencies`. On by default — this is where per-platform
36
+ * native binaries live, and omitting them is the most common reason an
37
+ * offline install fails on a machine other than the one that built it.
38
+ */
39
+ readonly optional: boolean
40
+ /**
41
+ * Follow non-optional `peerDependencies`. On by default, because npm 7+
42
+ * auto-installs peers and will reach for the network if they are absent.
43
+ */
44
+ readonly peer: boolean
45
+ /** Which platforms optional dependencies are kept for. */
46
+ readonly platforms: PlatformFilter
47
+ }
48
+
49
+ export const defaultScope: DependencyScope = {
50
+ optional: true,
51
+ peer: true,
52
+ platforms: allPlatforms
53
+ }
54
+
55
+ /** Options governing version selection and the dependency walk. */
56
+ export interface ResolveOptions {
57
+ readonly scope: DependencyScope
58
+ /**
59
+ * When a root spec is a range, take *every* published version that satisfies
60
+ * it rather than only the best match.
61
+ *
62
+ * `npmb react@^18` bundles one version; `npmb --all-versions react@^18`
63
+ * bundles all of them.
64
+ */
65
+ readonly allVersions: boolean
66
+ /** With `allVersions`, keep at most this many (newest first). */
67
+ readonly maxVersions?: number | undefined
68
+ /** Let prereleases satisfy ranges that would not normally admit them. */
69
+ readonly includePrerelease: boolean
70
+ /** How many registry requests to have in flight at once. */
71
+ readonly concurrency: number
72
+ }
73
+
74
+ export const defaultResolveOptions: ResolveOptions = {
75
+ scope: defaultScope,
76
+ allVersions: false,
77
+ includePrerelease: false,
78
+ concurrency: 10
79
+ }
80
+
81
+ /** Options for a full bundle run. */
82
+ export interface BundleOptions extends ResolveOptions {
83
+ readonly layout: Layout
84
+ /** Directory the finished artifacts are written to. */
85
+ readonly outDir: string
86
+ /** Base name for the archive in `single` layout. Defaults to `bundle`. */
87
+ readonly archiveName?: string | undefined
88
+ /** Resolve and report, but download nothing and write nothing. */
89
+ readonly dryRun: boolean
90
+ /**
91
+ * Verify every tarball against the checksum the registry advertised.
92
+ *
93
+ * On by default and there is essentially no good reason to turn it off; the
94
+ * escape hatch exists only for registries that serve broken metadata.
95
+ */
96
+ readonly verifyIntegrity: boolean
97
+ /** Overwrite existing files in `outDir` instead of refusing. */
98
+ readonly force: boolean
99
+ /**
100
+ * Planned output files to leave untouched, by file name.
101
+ *
102
+ * The engine never asks anything; deciding is the caller's job. The CLI's
103
+ * `--force prompt` resolves its per-file answers into this set, so "keep the
104
+ * existing one" means that artifact is simply not produced, and the rest of
105
+ * the run continues.
106
+ */
107
+ readonly skipExisting?: ReadonlySet<string> | undefined
108
+ /**
109
+ * Planned output files that may be replaced, by file name.
110
+ *
111
+ * A narrower `force`: `--force tsdown` says "replace tsdown's bundle, and
112
+ * still ask about anything else".
113
+ */
114
+ readonly overwrite?: ReadonlySet<string> | undefined
115
+ }
116
+
117
+ export const defaultBundleOptions: Omit<BundleOptions, "outDir"> = {
118
+ ...defaultResolveOptions,
119
+ layout: "per-spec",
120
+ dryRun: false,
121
+ verifyIntegrity: true,
122
+ force: false
123
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Platform matching for optional dependencies.
3
+ *
4
+ * This module exists because of one specific, very annoying failure mode.
5
+ *
6
+ * Packages with native code — esbuild, rollup, sharp, swc, lightningcss,
7
+ * `@parcel/watcher` — publish their binary as a *set* of sibling packages, one
8
+ * per platform, and list all of them in `optionalDependencies`. npm installs
9
+ * only the one matching the current machine and silently skips the rest.
10
+ *
11
+ * So if you bundle on a Mac and skip the non-matching optional deps, the
12
+ * resulting bundle installs fine on your Mac and fails on the Linux CI box
13
+ * inside the firewall — which is the only place it actually matters. Hence the
14
+ * default is "take every platform" and narrowing is opt-in.
15
+ */
16
+
17
+ /** A target to keep, e.g. `linux-x64`, `linux-x64-musl`, or just `linux`. */
18
+ export interface PlatformTarget {
19
+ readonly os: string
20
+ /** Undefined means "every architecture for this OS". */
21
+ readonly cpu?: string | undefined
22
+ /** `glibc` / `musl`. Undefined means "don't care". */
23
+ readonly libc?: string | undefined
24
+ }
25
+
26
+ /**
27
+ * Which platforms optional dependencies should be resolved for.
28
+ *
29
+ * - `All` — keep everything, regardless of `os`/`cpu`/`libc` (the default).
30
+ * - `Targets` — keep only packages that could install on one of these.
31
+ */
32
+ export type PlatformFilter =
33
+ | { readonly _tag: "All" }
34
+ | { readonly _tag: "Targets"; readonly targets: ReadonlyArray<PlatformTarget> }
35
+
36
+ export const allPlatforms: PlatformFilter = { _tag: "All" }
37
+
38
+ export const platformTargets = (targets: ReadonlyArray<PlatformTarget>): PlatformFilter => ({
39
+ _tag: "Targets",
40
+ targets
41
+ })
42
+
43
+ /** The machine we are currently running on. */
44
+ export const currentPlatform = (): PlatformTarget => ({
45
+ os: process.platform,
46
+ cpu: process.arch
47
+ })
48
+
49
+ /**
50
+ * npm's documented `os` values, plus the names people actually type.
51
+ *
52
+ * An OS on its own is a valid target, so a typo has to be caught here — the
53
+ * whole failure mode this tool exists to prevent is a bundle that looks fine
54
+ * and installs nothing. `--platform windows` silently matching no binding at
55
+ * all would be precisely that, so it is corrected rather than accepted.
56
+ */
57
+ const OS_ALIASES: Readonly<Record<string, string>> = {
58
+ win: "win32",
59
+ windows: "win32",
60
+ mac: "darwin",
61
+ macos: "darwin",
62
+ osx: "darwin"
63
+ }
64
+
65
+ const KNOWN_OS: ReadonlySet<string> = new Set([
66
+ "aix",
67
+ "android",
68
+ "cygwin",
69
+ "darwin",
70
+ "freebsd",
71
+ "haiku",
72
+ "linux",
73
+ "netbsd",
74
+ "openbsd",
75
+ "sunos",
76
+ "win32"
77
+ ])
78
+
79
+ const LIBC = new Set(["musl", "glibc"])
80
+
81
+ /**
82
+ * Parses `linux`, `linux-x64`, `darwin-arm64`, `win32-x64`, `linux-x64-musl`,
83
+ * `linux-musl`.
84
+ *
85
+ * An OS on its own means every architecture for that OS, which is almost always
86
+ * what you want: "the Windows and Linux bindings" is a far more natural way to
87
+ * describe a target set than enumerating four `os-arch` pairs, and it keeps
88
+ * working when a package adds an arm64 build.
89
+ *
90
+ * Returns `null` rather than throwing so the CLI can report every bad value at
91
+ * once alongside the list of accepted forms.
92
+ */
93
+ export const parsePlatformTarget = (input: string): PlatformTarget | null => {
94
+ const parts = input.trim().toLowerCase().split("-").filter((p) => p.length > 0)
95
+ if (parts.length === 0 || parts.length > 3) return null
96
+
97
+ const rawOs = parts[0]!
98
+ const os = OS_ALIASES[rawOs] ?? rawOs
99
+ if (!KNOWN_OS.has(os)) return null
100
+
101
+ if (parts.length === 1) return { os }
102
+
103
+ // `linux-musl` is an OS plus a libc, not an OS plus an architecture.
104
+ if (parts.length === 2 && LIBC.has(parts[1]!)) return { os, libc: parts[1]! }
105
+
106
+ const cpu = parts[1]!
107
+ if (parts.length === 2) return { os, cpu }
108
+
109
+ const libc = parts[2]!
110
+ if (!LIBC.has(libc)) return null
111
+ return { os, cpu, libc }
112
+ }
113
+
114
+ /**
115
+ * npm's `os`/`cpu`/`libc` fields use a list of allowed values, where a leading
116
+ * `!` negates. An empty or absent list means "no constraint".
117
+ *
118
+ * The semantics npm implements: if any negated entry matches, reject. Otherwise
119
+ * if there are any positive entries, at least one must match.
120
+ */
121
+ const listAllows = (list: ReadonlyArray<string> | undefined, value: string): boolean => {
122
+ if (list === undefined || list.length === 0) return true
123
+
124
+ let hasPositive = false
125
+ let positiveMatched = false
126
+
127
+ for (const entry of list) {
128
+ const normalized = entry.trim().toLowerCase()
129
+ if (normalized.length === 0) continue
130
+ if (normalized === "any" || normalized === "*") return true
131
+
132
+ if (normalized.startsWith("!")) {
133
+ if (normalized.slice(1) === value) return false
134
+ } else {
135
+ hasPositive = true
136
+ if (normalized === value) positiveMatched = true
137
+ }
138
+ }
139
+
140
+ return hasPositive ? positiveMatched : true
141
+ }
142
+
143
+ /** Constraints declared by a package, as they appear in its manifest. */
144
+ export interface PlatformConstraints {
145
+ readonly os?: ReadonlyArray<string> | undefined
146
+ readonly cpu?: ReadonlyArray<string> | undefined
147
+ readonly libc?: ReadonlyArray<string> | undefined
148
+ }
149
+
150
+ /** Would this package install on this specific target? */
151
+ export const matchesTarget = (
152
+ constraints: PlatformConstraints,
153
+ target: PlatformTarget
154
+ ): boolean => {
155
+ if (!listAllows(constraints.os, target.os)) return false
156
+ // An undefined cpu means the target is the whole OS, so any architecture the
157
+ // package declares qualifies.
158
+ if (target.cpu !== undefined && !listAllows(constraints.cpu, target.cpu)) return false
159
+ if (target.libc !== undefined && !listAllows(constraints.libc, target.libc)) return false
160
+ return true
161
+ }
162
+
163
+ /**
164
+ * Should this package be included, given the active filter?
165
+ *
166
+ * Under `All` this is unconditionally `true` — that is the whole point.
167
+ */
168
+ export const isIncluded = (
169
+ constraints: PlatformConstraints,
170
+ filter: PlatformFilter
171
+ ): boolean => {
172
+ if (filter._tag === "All") return true
173
+ return filter.targets.some((target) => matchesTarget(constraints, target))
174
+ }
175
+
176
+ /** Renders a filter for the manifest and for `--dry-run` output. */
177
+ export const formatPlatformFilter = (filter: PlatformFilter): string => {
178
+ if (filter._tag === "All") return "all"
179
+ return filter.targets
180
+ .map((t) => [t.os, t.cpu, t.libc].filter((part) => part !== undefined).join("-"))
181
+ .join(",")
182
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Progress reporting.
3
+ *
4
+ * The engine emits structured events; it never writes to a terminal. That
5
+ * separation is what lets the CLI draw a live TTY view, CI print plain lines,
6
+ * and the test suite assert on an array of events without any of them
7
+ * interfering with each other.
8
+ */
9
+ import * as Context from "effect/Context"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Layer from "effect/Layer"
12
+ import * as Ref from "effect/Ref"
13
+
14
+ /** The phases a bundle run moves through, in order. */
15
+ export type Phase = "preflight" | "resolve" | "download" | "archive" | "done"
16
+
17
+ /** A structured progress event. */
18
+ export type ProgressEvent =
19
+ /** A phase began. `total` is set only when it is known up front. */
20
+ | { readonly _tag: "PhaseStarted"; readonly phase: Phase; readonly total?: number | undefined }
21
+ /** A phase finished. */
22
+ | { readonly _tag: "PhaseCompleted"; readonly phase: Phase }
23
+ /** The resolver discovered a package that has to be included. */
24
+ | {
25
+ readonly _tag: "PackageResolved"
26
+ readonly name: string
27
+ readonly version: string
28
+ /** Running count of resolved packages, for a live counter. */
29
+ readonly resolvedCount: number
30
+ /** Packages still queued for resolution. */
31
+ readonly pendingCount: number
32
+ }
33
+ /** A tarball download started. */
34
+ | { readonly _tag: "DownloadStarted"; readonly name: string; readonly version: string }
35
+ /** A tarball download finished. */
36
+ | {
37
+ readonly _tag: "DownloadCompleted"
38
+ readonly name: string
39
+ readonly version: string
40
+ readonly bytes: number
41
+ readonly completedCount: number
42
+ readonly totalCount: number
43
+ }
44
+ /** A download failed and will be retried. */
45
+ | {
46
+ readonly _tag: "DownloadRetrying"
47
+ readonly name: string
48
+ readonly version: string
49
+ readonly attempt: number
50
+ readonly reason: string
51
+ }
52
+ /** An archive is being written. */
53
+ | { readonly _tag: "ArchiveStarted"; readonly path: string; readonly entryCount: number }
54
+ /** An archive finished. */
55
+ | { readonly _tag: "ArchiveCompleted"; readonly path: string; readonly bytes: number }
56
+ /** Something noteworthy but non-fatal, e.g. a deprecated package. */
57
+ | { readonly _tag: "Warning"; readonly message: string }
58
+
59
+ /** The reporting sink. */
60
+ export interface ProgressService {
61
+ readonly emit: (event: ProgressEvent) => Effect.Effect<void>
62
+ }
63
+
64
+ /** Service tag for progress reporting. */
65
+ export class Progress extends Context.Service<Progress, ProgressService>()(
66
+ "@packall/core/Progress"
67
+ ) {}
68
+
69
+ /** Emits an event to whichever reporter is installed. */
70
+ export const emit = (event: ProgressEvent): Effect.Effect<void, never, Progress> =>
71
+ Effect.gen(function*() {
72
+ const progress = yield* Progress
73
+ yield* progress.emit(event)
74
+ })
75
+
76
+ /**
77
+ * Discards every event.
78
+ *
79
+ * The default for library consumers and for tests that do not care about
80
+ * progress — silence should never require ceremony.
81
+ */
82
+ export const layerSilent: Layer.Layer<Progress> = Layer.succeed(Progress)({
83
+ emit: () => Effect.void
84
+ })
85
+
86
+ /** Sends every event to a callback. Used by the CLI renderer. */
87
+ export const layerCallback = (
88
+ onEvent: (event: ProgressEvent) => void
89
+ ): Layer.Layer<Progress> =>
90
+ Layer.succeed(Progress)({
91
+ emit: (event) => Effect.sync(() => onEvent(event))
92
+ })
93
+
94
+ /**
95
+ * Accumulates every event into a `Ref`, for assertions.
96
+ *
97
+ * Returned as `[layer, ref]` so a test can provide the layer and then read the
98
+ * transcript afterwards.
99
+ */
100
+ export const makeCollector = Effect.gen(function*() {
101
+ const ref = yield* Ref.make<ReadonlyArray<ProgressEvent>>([])
102
+ const layer = Layer.succeed(Progress)({
103
+ emit: (event: ProgressEvent) => Ref.update(ref, (events) => [...events, event])
104
+ })
105
+ return { layer, events: Ref.get(ref) } as const
106
+ })