@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.
- package/dist/Archive.d.ts +32 -0
- package/dist/Archive.d.ts.map +1 -0
- package/dist/Bundle.d.ts +123 -0
- package/dist/Bundle.d.ts.map +1 -0
- package/dist/DependencyRange.d.ts +26 -0
- package/dist/DependencyRange.d.ts.map +1 -0
- package/dist/Download.d.ts +35 -0
- package/dist/Download.d.ts.map +1 -0
- package/dist/Errors.d.ts +124 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/InputFile.d.ts +46 -0
- package/dist/InputFile.d.ts.map +1 -0
- package/dist/Integrity.d.ts +48 -0
- package/dist/Integrity.d.ts.map +1 -0
- package/dist/Layout.d.ts +53 -0
- package/dist/Layout.d.ts.map +1 -0
- package/dist/Manifest.d.ts +82 -0
- package/dist/Manifest.d.ts.map +1 -0
- package/dist/Options.d.ts +100 -0
- package/dist/Options.d.ts.map +1 -0
- package/dist/Platform.d.ts +69 -0
- package/dist/Platform.d.ts.map +1 -0
- package/dist/Progress.d.ts +107 -0
- package/dist/Progress.d.ts.map +1 -0
- package/dist/Registry.d.ts +106 -0
- package/dist/Registry.d.ts.map +1 -0
- package/dist/Resolve.d.ts +105 -0
- package/dist/Resolve.d.ts.map +1 -0
- package/dist/Spec.d.ts +62 -0
- package/dist/Spec.d.ts.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1766 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/Archive.ts +92 -0
- package/src/Bundle.ts +570 -0
- package/src/DependencyRange.ts +115 -0
- package/src/Download.ts +132 -0
- package/src/Errors.ts +216 -0
- package/src/InputFile.ts +229 -0
- package/src/Integrity.ts +122 -0
- package/src/Layout.ts +103 -0
- package/src/Manifest.ts +133 -0
- package/src/Options.ts +123 -0
- package/src/Platform.ts +182 -0
- package/src/Progress.ts +106 -0
- package/src/Registry.ts +121 -0
- package/src/Resolve.ts +528 -0
- package/src/Spec.ts +186 -0
- 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
|
+
`
|
package/src/Manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/Platform.ts
ADDED
|
@@ -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
|
+
}
|
package/src/Progress.ts
ADDED
|
@@ -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
|
+
})
|