@exodus/stasis 1.0.0-beta.7 → 1.0.0-beta.9
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/README.md +27 -24
- package/bin/stasis.js +106 -177
- package/package.json +7 -5
- package/src/audit-corrections.js +45 -21
- package/src/cmd/bundle.js +194 -47
- package/src/installed-commits.js +182 -0
- package/src/loaders/php.js +43 -16
- package/src/resolve-fields.js +62 -30
- package/src/resolve-node.js +9 -6
- package/src/scan.js +8 -3
- package/src/vfs-bundle/entries.js +9 -5
- package/src/vfs-bundle/github.js +4 -1
- package/src/vfs-bundle/tree.js +49 -15
- package/src/vfs-bundle.js +13 -8
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { hash } from 'node:crypto'
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { toBase32 } from '@exodus/bytes/base32.js'
|
|
5
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
6
|
+
import { parseGithubRepository, readJson, readText } from '@exodus/stasis-core/bundle-util'
|
|
7
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
8
|
+
import { isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
9
|
+
import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
|
|
10
|
+
import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
|
|
11
|
+
|
|
12
|
+
// The commit each git dependency in a node_modules on disk is installed at, as the package manager
|
|
13
|
+
// that laid it out recorded it there: npm in its hidden lockfile, pnpm in its copy of the lockfile,
|
|
14
|
+
// yarn 1 in yarn.lock, as its .yarn-integrity has it installed. Nothing on disk names a registry
|
|
15
|
+
// package's. A record missing, unreadable, or naming no full commit of a GitHub repository records
|
|
16
|
+
// nothing, and fails nothing.
|
|
17
|
+
|
|
18
|
+
// `{ github, commit }` of `repo`, a git URL, at `commit`, where that is a GitHub repository and a full
|
|
19
|
+
// commit id; else undefined.
|
|
20
|
+
function githubCommit(repo, commit) {
|
|
21
|
+
const github = parseGithubRepository(repo)
|
|
22
|
+
return github !== null && isValidRepoField('commit', commit) ? { github, commit } : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// GitHub's tarball of a commit, as yarn 1 and pnpm fetch a GitHub dependency.
|
|
26
|
+
const CODELOAD = /^https:\/\/codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([^/]+)$/u
|
|
27
|
+
const githubTarball = (url) => {
|
|
28
|
+
const [, github, commit] = CODELOAD.exec(url) ?? []
|
|
29
|
+
return isValidRepoField('github', github) ? githubCommit(github, commit) : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// githubCommit of a git or tarball resolution @preventive/lockfile reads.
|
|
33
|
+
const resolutionCommit = (resolution) => {
|
|
34
|
+
if (resolution?.type === 'git') return githubCommit(resolution.repo, resolution.commit)
|
|
35
|
+
return resolution?.type === 'tarball' ? githubTarball(resolution.tarball ?? '') : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The lookup of a record that finds nothing: one unreadable, or none.
|
|
39
|
+
const NONE = () => undefined
|
|
40
|
+
|
|
41
|
+
// npm's, from npm 7: the hidden lockfile it writes into `dir`'s node_modules as it lays it out, by
|
|
42
|
+
// each package's location from `dir`, where it records the version installed there. A git
|
|
43
|
+
// dependency's `resolved` is the URL npm clones, `#` and the commit.
|
|
44
|
+
function npmRecord(dir, host) {
|
|
45
|
+
const packages = readJson(join(dir, 'node_modules', '.package-lock.json'), host)?.packages
|
|
46
|
+
if (!isPlainObject(packages)) return NONE
|
|
47
|
+
return (location, version) => {
|
|
48
|
+
const entry = Object.hasOwn(packages, location) ? packages[location] : undefined
|
|
49
|
+
if (!isPlainObject(entry) || entry.version !== version || typeof entry.resolved !== 'string') return undefined
|
|
50
|
+
const { resolved } = entry
|
|
51
|
+
if (!/^git(?:\+[\w.-]+)?:/u.test(resolved)) return githubTarball(resolved)
|
|
52
|
+
const at = resolved.lastIndexOf('#')
|
|
53
|
+
return at === -1 ? undefined : githubCommit(resolved.slice(0, at), resolved.slice(at + 1))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// yarn 1's: yarn.lock in `dir`, by the name a package is installed as, the last of its location, and
|
|
58
|
+
// its version, where one entry alone has both. The .yarn-integrity yarn writes into the node_modules
|
|
59
|
+
// it lays out has to hold the entry's resolution for each of its patterns, as yarn.lock's `resolved`
|
|
60
|
+
// spells it: a yarn.lock changed since often moves a git dependency to another commit of one version.
|
|
61
|
+
function yarnRecord(dir, host) {
|
|
62
|
+
const installed = readJson(join(dir, 'node_modules', '.yarn-integrity'), host)?.lockfileEntries
|
|
63
|
+
const text = readText(host, join(dir, 'yarn.lock'))
|
|
64
|
+
if (!isPlainObject(installed) || text === null) return NONE
|
|
65
|
+
let packages
|
|
66
|
+
try {
|
|
67
|
+
packages = parseYarn1Lockfile(text, { checkVersions: false }).packages
|
|
68
|
+
} catch {
|
|
69
|
+
return NONE // whatever the reader refuses
|
|
70
|
+
}
|
|
71
|
+
const byVersion = new Map()
|
|
72
|
+
for (const { patterns, name, version, resolution } of new Set(Object.values(packages))) {
|
|
73
|
+
let commit = resolutionCommit(resolution)
|
|
74
|
+
const resolved = resolution?.type === 'git' ? `${resolution.repo}#${resolution.commit}` : resolution?.sha1 === undefined ? resolution?.tarball : `${resolution.tarball}#${resolution.sha1}`
|
|
75
|
+
if (!patterns.every((pattern) => Object.hasOwn(installed, pattern) && installed[pattern] === resolved)) commit = undefined
|
|
76
|
+
// Of two entries of one name and version, either may be the one installed.
|
|
77
|
+
const id = `${name}@${version}`
|
|
78
|
+
byVersion.set(id, byVersion.has(id) ? undefined : commit)
|
|
79
|
+
}
|
|
80
|
+
return (location, version) => byVersion.get(`${location.slice(location.lastIndexOf('node_modules/') + 'node_modules/'.length)}@${version}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What npm and yarn leave in a node_modules they lay out, and the reader of each one's record.
|
|
84
|
+
const LAYOUTS = [['.package-lock.json', npmRecord], ['.yarn-integrity', yarnRecord]]
|
|
85
|
+
|
|
86
|
+
// The names pnpm 9 to 12 may give snapshot `key`'s directory in node_modules/.pnpm, as its
|
|
87
|
+
// depPathToFilename (@pnpm/dependency-path) spells them: each character a path can't hold made `+`,
|
|
88
|
+
// `#` too from pnpm 10, and a peer's parentheses `_`; where that is too long or has a capital, cut
|
|
89
|
+
// short before `_` and a hash of it, 26 base32 of an MD5 for pnpm 9, 32 hex of a SHA-256 from pnpm 10,
|
|
90
|
+
// of which the hash alone is kept, as where it is cut depends on the install's
|
|
91
|
+
// virtualStoreDirMaxLength.
|
|
92
|
+
const STORE_NAMES = [
|
|
93
|
+
[/[\\/:*?"<>|]/gu, (name) => toBase32(hash('md5', name, 'buffer'), { padding: false }).toLowerCase()],
|
|
94
|
+
[/[\\/:*?"<>|#]/gu, (name) => hash('sha256', name).slice(0, 32)],
|
|
95
|
+
]
|
|
96
|
+
const storeNames = (key) => STORE_NAMES.flatMap(([unsafe, digest]) => {
|
|
97
|
+
let name = key.replace(unsafe, '+')
|
|
98
|
+
if (name.includes('(')) name = name.replace(/\)$/u, '').replace(/\)\(|\(|\)/gu, '_')
|
|
99
|
+
return [name, `_${digest(name)}`]
|
|
100
|
+
})
|
|
101
|
+
const STORE_HASH = /_(?:[\da-f]{32}|[a-z2-7]{26})$/u
|
|
102
|
+
|
|
103
|
+
// pnpm's: lock.yaml in `store`, a node_modules/.pnpm, the lockfile of what pnpm 9 to 12 installed
|
|
104
|
+
// there, by the directory there each snapshot is in. A git dependency's resolution is the repository
|
|
105
|
+
// and commit, or GitHub's tarball of the commit, and the package's `path` in it (`#path:`) where it is
|
|
106
|
+
// not at the root.
|
|
107
|
+
function pnpmRecord(store, host) {
|
|
108
|
+
const text = readText(host, join(store, 'lock.yaml'))
|
|
109
|
+
let packages
|
|
110
|
+
try {
|
|
111
|
+
packages = text === null ? undefined : parsePnpmLockfile(text).lockfile?.packages
|
|
112
|
+
} catch {
|
|
113
|
+
return NONE // whatever the reader refuses
|
|
114
|
+
}
|
|
115
|
+
const byName = new Map()
|
|
116
|
+
for (const [key, { resolution }] of Object.entries(packages ?? {})) {
|
|
117
|
+
const commit = resolutionCommit(resolution)
|
|
118
|
+
if (commit === undefined) continue
|
|
119
|
+
const directory = (resolution.path ?? '').replace(/^\/+|\/+$/gu, '')
|
|
120
|
+
const repo = { github: commit.github, ...(isValidRepoField('directory', directory) && { directory }), commit: commit.commit }
|
|
121
|
+
for (const name of storeNames(key)) byName.set(name, repo)
|
|
122
|
+
}
|
|
123
|
+
return (name) => byName.get(name) ?? byName.get(STORE_HASH.exec(name)?.[0])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A pnpm package's real path, <store>/<dir>/node_modules/<name>, its store a node_modules/.pnpm.
|
|
127
|
+
const PNPM_PACKAGE = /^(.*\/node_modules\/\.pnpm)\/([^/]+)\/node_modules\/(?:@[^/]+\/)?[^/]+$/u
|
|
128
|
+
|
|
129
|
+
// Each npm dependency of `bundle`, its buckets' paths from `root`, that is a git dependency of a GitHub
|
|
130
|
+
// repository, recorded as its package manager's record has it installed: that repository, over the
|
|
131
|
+
// one its package.json names (a fork's, where that names the one forked), at its root (`''`) or the
|
|
132
|
+
// subdirectory pnpm records, at that commit. A dependency is found by its real path, through the link
|
|
133
|
+
// pnpm installs it by: in a node_modules/.pnpm, by its directory there; else by its location from the
|
|
134
|
+
// nearest directory whose node_modules npm or yarn laid out, from the one its outermost node_modules
|
|
135
|
+
// below the bundle root is in up to that root, whatever a directory above the root is named: a
|
|
136
|
+
// record outside the root is another project's. Metadata, as `repo` is: never checked against the
|
|
137
|
+
// files installed.
|
|
138
|
+
export function pinInstalledCommits(bundle, root, host = diskHost) {
|
|
139
|
+
const stores = new Map()
|
|
140
|
+
const layouts = new Map()
|
|
141
|
+
let top // the bundle root's real path, as each package's is compared with it
|
|
142
|
+
// The record of `dir`'s node_modules, or null where neither npm nor yarn laid it out; where both
|
|
143
|
+
// left theirs, neither's, as which was last is not known.
|
|
144
|
+
const layoutAt = (dir) => {
|
|
145
|
+
if (!layouts.has(dir)) {
|
|
146
|
+
const found = LAYOUTS.filter(([file]) => host.stat(join(dir, 'node_modules', file)) !== null)
|
|
147
|
+
layouts.set(dir, found.length === 0 ? null : found.length === 1 ? found[0][1](dir, host) : NONE)
|
|
148
|
+
}
|
|
149
|
+
return layouts.get(dir)
|
|
150
|
+
}
|
|
151
|
+
for (const [dir, info] of bundle.modules) {
|
|
152
|
+
if (info.ecosystem !== 'npm') continue
|
|
153
|
+
let real
|
|
154
|
+
try {
|
|
155
|
+
real = toPosix(host.realpath(resolve(root, dir)))
|
|
156
|
+
} catch {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
let repo
|
|
160
|
+
const pnpm = PNPM_PACKAGE.exec(real)
|
|
161
|
+
if (pnpm !== null) {
|
|
162
|
+
const [, store, name] = pnpm
|
|
163
|
+
if (!stores.has(store)) stores.set(store, pnpmRecord(store, host))
|
|
164
|
+
repo = stores.get(store)(name)
|
|
165
|
+
} else {
|
|
166
|
+
try {
|
|
167
|
+
top ??= resolve(host.realpath(resolve(root)))
|
|
168
|
+
} catch {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const rel = relative(top, real)
|
|
172
|
+
const parts = toPosix(rel).split('/')
|
|
173
|
+
const at = parts.indexOf('node_modules')
|
|
174
|
+
if (relativeEscapes(rel) || at === -1) continue
|
|
175
|
+
let from = resolve(top, ...parts.slice(0, at))
|
|
176
|
+
while (layoutAt(from) === null && from !== top) from = dirname(from)
|
|
177
|
+
const commit = (layoutAt(from) ?? NONE)(toPosix(relative(from, real)), info.version)
|
|
178
|
+
if (commit !== undefined) repo = { github: commit.github, directory: '', commit: commit.commit }
|
|
179
|
+
}
|
|
180
|
+
if (repo !== undefined) bundle.modules.set(dir, { ...info, repo })
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/loaders/php.js
CHANGED
|
@@ -9,6 +9,8 @@ import { readFile } from 'node:fs/promises'
|
|
|
9
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
10
|
import { isDeepStrictEqual } from 'node:util'
|
|
11
11
|
|
|
12
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
13
|
+
import { checkoutCommit, parseGithubRepository } from '@exodus/stasis-core/bundle-util'
|
|
12
14
|
import { assertRealPathWithinBase, isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
13
15
|
import { LockfileError, parseComposerLock } from '@preventive/lockfile/composer.js'
|
|
14
16
|
import { isDir, isFile } from '../resolve-typescript.js'
|
|
@@ -532,20 +534,40 @@ function assertInstalledAsLocked(lockText, installedText, { installedFile, vendo
|
|
|
532
534
|
return installed
|
|
533
535
|
}
|
|
534
536
|
|
|
535
|
-
// The
|
|
537
|
+
// The GitHub repository a Composer package `p` (composer.lock's, or installed.json's) is cloned
|
|
538
|
+
// from, at the commit it is installed at: its git `source` on GitHub, at its `reference` where that
|
|
539
|
+
// is a full commit and what is installed at `at` (its directory, or null for none) is at it.
|
|
540
|
+
// Installed from its source -- as installed.json's `installation-source`, `from`, says, or as it
|
|
541
|
+
// must be with no dist, or as a git checkout there shows -- it is a clone Composer checked out at
|
|
542
|
+
// the reference, which may have been moved since: so only where that checkout's HEAD is it. Else
|
|
543
|
+
// from its dist, which Composer installs by default: only one at the same reference, as one with
|
|
544
|
+
// none, or another, is an archive no commit is known of. Packagist takes a package from the root
|
|
545
|
+
// of its repository, so `directory` is ''. Undefined otherwise.
|
|
546
|
+
function composerRepo({ source, dist }, from, at) {
|
|
547
|
+
if (source?.type !== 'git') return undefined
|
|
548
|
+
const fromSource = from === 'source' || dist == null || (at !== null && existsSync(join(at, '.git')))
|
|
549
|
+
if (fromSource ? at === null || checkoutCommit(at) !== source.reference : dist.reference !== source.reference) return undefined
|
|
550
|
+
const github = parseGithubRepository(source.url)
|
|
551
|
+
return github !== null && isValidRepoField('commit', source.reference) ? { github, directory: '', commit: source.reference } : undefined
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra, repo }]`, `dir` where
|
|
536
555
|
// the package is installed, baseDir-relative (null for nowhere in baseDir, as a metapackage). Of
|
|
537
556
|
// its composer.lock where there is one, read by @preventive/lockfile beside composer.json as
|
|
538
557
|
// `composer install` reads them -- one it refuses stops the build -- each package where Composer
|
|
539
558
|
// installs it (lockedDir), so no install is needed; else of vendor/composer/installed.json, as
|
|
540
559
|
// it is. Where both are, installed.json must be the lockfile's install (assertInstalledAsLocked):
|
|
541
|
-
// its packages, each where installed.json says.
|
|
560
|
+
// its packages, each where installed.json says. `repo` is composerRepo's.
|
|
542
561
|
export function loadComposerPackages(baseDir) {
|
|
543
562
|
const { vendorDir } = readComposerJson(baseDir)
|
|
544
563
|
const lockText = readUtf8OrNull(join(baseDir, 'composer.lock'), 'composer.lock')
|
|
545
564
|
if (lockText === null) {
|
|
546
565
|
return readInstalledPackages(baseDir, vendorDir)
|
|
547
566
|
.filter((p) => p?.name && p.version)
|
|
548
|
-
.map((p) =>
|
|
567
|
+
.map((p) => {
|
|
568
|
+
const dir = installedDir(baseDir, vendorDir, p)
|
|
569
|
+
return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, p['installation-source'], dir == null ? null : join(baseDir, dir)) }
|
|
570
|
+
})
|
|
549
571
|
}
|
|
550
572
|
|
|
551
573
|
const composerJson = readUtf8OrNull(join(baseDir, 'composer.json'), 'composer.json')
|
|
@@ -554,9 +576,13 @@ export function loadComposerPackages(baseDir) {
|
|
|
554
576
|
const installedText = readUtf8OrNull(join(baseDir, installedFile), installedFile)
|
|
555
577
|
const installed = installedText === null ? null : assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir })
|
|
556
578
|
const dirs = installed === null ? null : new Map(installed.packages.map((p) => [p.name, installedDir(baseDir, vendorDir, p)]))
|
|
579
|
+
const from = new Map(installed?.packages.map((p) => [p.name, p['installation-source']]))
|
|
557
580
|
return Object.values(lock.packages)
|
|
558
581
|
.filter((p) => dirs === null || dirs.has(p.name))
|
|
559
|
-
.map((p) =>
|
|
582
|
+
.map((p) => {
|
|
583
|
+
const dir = dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name)
|
|
584
|
+
return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, from.get(p.name), dir == null ? null : join(baseDir, dir)) }
|
|
585
|
+
})
|
|
560
586
|
}
|
|
561
587
|
|
|
562
588
|
// Build the autoload config for `baseDir`, merging the root composer.json
|
|
@@ -667,28 +693,28 @@ function matchingPrefixes(map, name) {
|
|
|
667
693
|
return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
|
|
668
694
|
}
|
|
669
695
|
|
|
670
|
-
// The versions of the Composer `packages` (loadComposerPackages: the authoritative source;
|
|
671
|
-
// package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
696
|
+
// The versions and repos of the Composer `packages` (loadComposerPackages: the authoritative source;
|
|
697
|
+
// a package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
672
698
|
function packageVersions(packages) {
|
|
673
699
|
const byDir = new Map()
|
|
674
700
|
const byName = new Map()
|
|
675
|
-
for (const { name, version, dir } of packages) {
|
|
676
|
-
byName.set(name, version)
|
|
677
|
-
if (dir) byDir.set(dir, version)
|
|
701
|
+
for (const { name, version, dir, repo } of packages) {
|
|
702
|
+
byName.set(name, { version, repo })
|
|
703
|
+
if (dir) byDir.set(dir, { version, repo })
|
|
678
704
|
}
|
|
679
705
|
return { byDir, byName }
|
|
680
706
|
}
|
|
681
707
|
|
|
682
708
|
// Walk up from a bundled file to the nearest named composer.json, returning
|
|
683
|
-
// { pkgDir, name, version } (pkgDir "." for the root; version from composer.lock or
|
|
709
|
+
// { pkgDir, name, version, repo } (pkgDir "." for the root; version and repo from composer.lock or
|
|
684
710
|
// installed.json by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
685
711
|
function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
686
712
|
let dir = dirname(fileRelPath)
|
|
687
713
|
for (;;) {
|
|
688
714
|
const cj = readJsonIfExists(join(baseDir, dir, 'composer.json'))
|
|
689
715
|
if (cj?.name) {
|
|
690
|
-
const
|
|
691
|
-
return { pkgDir: dir, name: cj.name, version }
|
|
716
|
+
const known = installed.byDir.get(dir) ?? installed.byName.get(cj.name)
|
|
717
|
+
return { pkgDir: dir, name: cj.name, version: known?.version ?? cj.version ?? fallbackVersion, repo: known?.repo }
|
|
692
718
|
}
|
|
693
719
|
if (dir === '.' || dir === '/' || dir === '') return null
|
|
694
720
|
const parent = dirname(dir)
|
|
@@ -700,16 +726,17 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
|
700
726
|
// Group bundled PHP sources into per-package buckets keyed by the nearest
|
|
701
727
|
// composer.json's directory (vendor deps get their own; workspace files and
|
|
702
728
|
// orphans -> the root "." bucket with the placeholder identity). Returns a
|
|
703
|
-
// Map<dir, { name, version, files }
|
|
729
|
+
// Map<dir, { name, version, ecosystem?, repo?, files }>, a dependency's `repo` where composerRepo
|
|
730
|
+
// gives one. `packages`: loadComposerPackages(baseDir).
|
|
704
731
|
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion, packages = loadComposerPackages(baseDir)) {
|
|
705
732
|
const installed = packageVersions(packages)
|
|
706
733
|
|
|
707
734
|
const modules = new Map()
|
|
708
|
-
const ensureBucket = (dir, name, version, ecosystem) => {
|
|
735
|
+
const ensureBucket = (dir, name, version, ecosystem, repo) => {
|
|
709
736
|
if (!modules.has(dir)) {
|
|
710
737
|
modules.set(dir, ecosystem === undefined
|
|
711
738
|
? { name, version, files: Object.create(null) }
|
|
712
|
-
: { name, version, ecosystem, files: Object.create(null) })
|
|
739
|
+
: { name, version, ecosystem, ...(repo === undefined ? {} : { repo }), files: Object.create(null) })
|
|
713
740
|
}
|
|
714
741
|
return modules.get(dir)
|
|
715
742
|
}
|
|
@@ -721,7 +748,7 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
721
748
|
// Below the root package = an installed Composer dependency, tagged
|
|
722
749
|
// `composer`; the root "." bucket is workspace code and stays ecosystem-less.
|
|
723
750
|
const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
|
|
724
|
-
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
|
|
751
|
+
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem, pkg.repo).files[rel] = content
|
|
725
752
|
} else {
|
|
726
753
|
ensureBucket('.', fallbackName, fallbackVersion).files[path] = content
|
|
727
754
|
}
|
package/src/resolve-fields.js
CHANGED
|
@@ -14,17 +14,25 @@ import {
|
|
|
14
14
|
typescriptSiblings,
|
|
15
15
|
} from './resolve-typescript.js'
|
|
16
16
|
import { diskHost } from '@exodus/stasis-core/host'
|
|
17
|
+
import { createNodeResolver } from './resolve-node.js'
|
|
17
18
|
|
|
18
19
|
// Static module resolver for legacy package fields (`react-native`/`browser`/`main` + browser-spec
|
|
19
20
|
// redirect maps) and platform suffixes (`.ios`/`.android`/`.native`), reproducing Metro/React-Native
|
|
20
21
|
// resolution that Node's own resolver (used by scan.js) can't. `exports`-bearing packages are
|
|
21
|
-
// delegated to Node
|
|
22
|
+
// delegated to Node's algorithm, under a bundler's conditions rather than Node's (see
|
|
23
|
+
// resolveConditions). No user code is executed (only package.json is stat/read).
|
|
22
24
|
//
|
|
23
25
|
// Returns, for each specifier:
|
|
24
|
-
// { url } resolved to a real
|
|
26
|
+
// { url } resolved to a file, by its real path (file: URL string; see fileResolution)
|
|
25
27
|
// { empty } a browser/react-native field mapped it to `false` (empty module)
|
|
26
|
-
// { builtin } a Node builtin
|
|
28
|
+
// { builtin } a Node builtin: a `node:` specifier, or a builtin name no installed package resolves
|
|
27
29
|
// null unresolved
|
|
30
|
+
//
|
|
31
|
+
// Browser and React Native targets have no Node builtins, so a bare builtin name (`buffer`,
|
|
32
|
+
// `events`, `util`, `process`, ...) resolves to the installed npm package of that name, as esbuild
|
|
33
|
+
// (platform browser), webpack 5 (target web) and Metro bundle it. Where nothing is installed it
|
|
34
|
+
// stays a builtin, as the --metro-resolver adapter reports Metro's miss on one (metro-resolver.js),
|
|
35
|
+
// rather than an unresolved edge: Metro and webpack fail the build there.
|
|
28
36
|
|
|
29
37
|
// Metro divergence toggle. When a package's browser map maps its OWN entry to `false` (under
|
|
30
38
|
// Metro's matching rules, which include bare keys like {"buf": false} for main "./buf.js"),
|
|
@@ -106,7 +114,21 @@ function resolveEntryThroughMap(map, main, opts) {
|
|
|
106
114
|
return { entry: main }
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
// A resolved file by its REAL path (through `host`, so a Vfs host's links are followed too), as Node,
|
|
118
|
+
// Metro and esbuild record one: under pnpm's isolated layout `node_modules/debug` links into
|
|
119
|
+
// `node_modules/.pnpm/debug@x/node_modules/debug`, whose own imports (a sibling `ms`) resolve only
|
|
120
|
+
// from where it really lies. Probing stays on the path as reached, so a candidate and the package
|
|
121
|
+
// scope read from it (resolveSourceFile's metro redirect) are one spelling; only the hit is
|
|
122
|
+
// realpathed. null where the file has no real path (gone since it was probed).
|
|
123
|
+
function fileResolution(path, host) {
|
|
124
|
+
let real
|
|
125
|
+
try {
|
|
126
|
+
real = host.realpath(path)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
return { url: pathToFileURL(real).toString() }
|
|
131
|
+
}
|
|
110
132
|
|
|
111
133
|
// Match a package-relative path (`./x`) against the redirect map, trying `.js`/`.json` appended.
|
|
112
134
|
function matchRedirect(map, relPath) {
|
|
@@ -199,7 +221,7 @@ function resolveSourceFile(base, opts) {
|
|
|
199
221
|
// bare package-name imports, not to a path landing on a directory.
|
|
200
222
|
function resolveFileOrDir(base, opts) {
|
|
201
223
|
// resolveSourceFile yields a path, { empty: true } (a metro candidate-redirect hit), or null.
|
|
202
|
-
const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit) : hit)
|
|
224
|
+
const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit, opts.host) : hit)
|
|
203
225
|
const file = asResolution(resolveSourceFile(base, opts))
|
|
204
226
|
if (file) return file
|
|
205
227
|
if (isDir(base, opts.host)) {
|
|
@@ -219,8 +241,9 @@ function resolveFileOrDir(base, opts) {
|
|
|
219
241
|
return null
|
|
220
242
|
}
|
|
221
243
|
|
|
222
|
-
// Build a resolver bound to options: `conditions` gates `exports`
|
|
223
|
-
// `mainFields`/`platform`/`preferNative`/`sourceExts`
|
|
244
|
+
// Build a resolver bound to options: `conditions` (a resolveConditions set) gates `exports`
|
|
245
|
+
// packages (delegated to Node's algorithm), `mainFields`/`platform`/`preferNative`/`sourceExts`
|
|
246
|
+
// drive the legacy-field + suffix probing.
|
|
224
247
|
// `metro` opts into Metro's package-entry + candidate-redirect semantics (see
|
|
225
248
|
// resolveEntryThroughMap and resolveSourceFile); leave it off for the esbuild-parity
|
|
226
249
|
// `--mainFields` path. `typescript` adds tsc's extension substitution (a missing `x.js` probes
|
|
@@ -244,6 +267,9 @@ export function createFieldResolver({
|
|
|
244
267
|
const opts = { platform, preferNative, sourceExts, mainFields, metro, typescript, metroKeepEntryOnBrowserFalse, host }
|
|
245
268
|
// An importer in node_modules gets no --typescript mapping (inNodeModules), wherever its import lands.
|
|
246
269
|
const fromNodeModules = { ...opts, typescript: false }
|
|
270
|
+
// Node answers a builtin name with the builtin itself, so an `exports`-bearing package named like
|
|
271
|
+
// one (see the header) resolves through this instead, with builtins off; built on first use.
|
|
272
|
+
let packageResolver
|
|
247
273
|
// `callConditions` (from scan) is the parent's format-driven condition set, so `exports`
|
|
248
274
|
// delegation matches Node resolving from THAT file; falls back to configured `conditions`.
|
|
249
275
|
const resolve = function resolve(parentFile, specifier, callConditions) {
|
|
@@ -251,11 +277,28 @@ export function createFieldResolver({
|
|
|
251
277
|
const conds = new Set(callConditions ?? conditions)
|
|
252
278
|
const viaNode = (spec) => {
|
|
253
279
|
try {
|
|
254
|
-
return fileResolution(host.resolve(parentFile, spec, conds))
|
|
280
|
+
if (!isBuiltin(spec)) return fileResolution(host.resolve(parentFile, spec, conds), host)
|
|
281
|
+
packageResolver ??= createNodeResolver(host)
|
|
282
|
+
return fileResolution(packageResolver.resolve(parentFile, spec, conds, { builtins: false }), host)
|
|
255
283
|
} catch {
|
|
256
284
|
return null
|
|
257
285
|
}
|
|
258
286
|
}
|
|
287
|
+
// A bare specifier (`mod`, `mod/sub`) through node_modules.
|
|
288
|
+
const viaPackage = (spec) => {
|
|
289
|
+
const loc = locatePackage(dirname(parentFile), spec, host)
|
|
290
|
+
if (!loc) return null
|
|
291
|
+
const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
|
|
292
|
+
// `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
|
|
293
|
+
if (pkg.exports != null) return viaNode(spec)
|
|
294
|
+
// A bare package import resolves its directory via the same dir algorithm as any other.
|
|
295
|
+
if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
|
|
296
|
+
const sub = `./${loc.subpath}`
|
|
297
|
+
const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
|
|
298
|
+
if (r === false) return { empty: true }
|
|
299
|
+
const target = typeof r === 'string' ? r : sub
|
|
300
|
+
return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
|
|
301
|
+
}
|
|
259
302
|
// `#name` subpath imports use the `imports` field + conditions; Node's algorithm handles them.
|
|
260
303
|
if (specifier.startsWith('#')) return viaNode(specifier)
|
|
261
304
|
|
|
@@ -283,26 +326,15 @@ export function createFieldResolver({
|
|
|
283
326
|
}
|
|
284
327
|
}
|
|
285
328
|
|
|
286
|
-
// A builtin the browser map did not remap resolves to the builtin itself.
|
|
287
|
-
if (isBuiltin(spec)) return { builtin: true }
|
|
288
|
-
|
|
289
329
|
if (spec.startsWith('.') || isAbsolute(spec)) {
|
|
290
330
|
const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
|
|
291
331
|
return resolveFileOrDir(base, fileOpts)
|
|
292
332
|
}
|
|
293
333
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (pkg.exports != null) return viaNode(spec)
|
|
299
|
-
// A bare package import resolves its directory via the same dir algorithm as any other.
|
|
300
|
-
if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
|
|
301
|
-
const sub = `./${loc.subpath}`
|
|
302
|
-
const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
|
|
303
|
-
if (r === false) return { empty: true }
|
|
304
|
-
const target = typeof r === 'string' ? r : sub
|
|
305
|
-
return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
|
|
334
|
+
// A builtin the browser map did not remap: its `node:` form names the builtin itself; a bare
|
|
335
|
+
// name is the installed package of that name (see the header), the builtin where none resolves.
|
|
336
|
+
if (isBuiltin(spec)) return spec.startsWith('node:') ? { builtin: true } : (viaPackage(spec) ?? { builtin: true })
|
|
337
|
+
return viaPackage(spec)
|
|
306
338
|
}
|
|
307
339
|
if (!typescript) return resolve
|
|
308
340
|
// --typescript: when the whole field flow leaves the specifier unresolved, give tsc's mapping
|
|
@@ -319,7 +351,7 @@ export function createFieldResolver({
|
|
|
319
351
|
paths: typescriptPaths,
|
|
320
352
|
host,
|
|
321
353
|
})
|
|
322
|
-
return hit == null ? null : fileResolution(hit)
|
|
354
|
+
return hit == null ? null : fileResolution(hit, host)
|
|
323
355
|
}
|
|
324
356
|
}
|
|
325
357
|
|
|
@@ -328,11 +360,11 @@ function isModuleFormat(format) {
|
|
|
328
360
|
return format === 'module' || format === 'module-typescript'
|
|
329
361
|
}
|
|
330
362
|
|
|
331
|
-
// Condition set for a parent of the given format
|
|
332
|
-
//
|
|
363
|
+
// Condition set for a parent of the given format, as scan's #conditionSet gives a custom resolver:
|
|
364
|
+
// `import` or `require` by format, `default` (which Node's resolver matches anyway), plus extras.
|
|
365
|
+
// A bundler's base, not Node's: esbuild, webpack and Metro assert `import`/`require` and
|
|
366
|
+
// `default`, never `node`, so an `exports` map listing `node` first can't win over the extras.
|
|
367
|
+
// Order is irrelevant (Node membership-tests against exports key order).
|
|
333
368
|
export function resolveConditions(format, extras = []) {
|
|
334
|
-
|
|
335
|
-
? ['node', 'import', 'module-sync', 'node-addons']
|
|
336
|
-
: ['node', 'require', 'module-sync', 'node-addons']
|
|
337
|
-
return [...new Set([...base, ...extras])]
|
|
369
|
+
return [...new Set([isModuleFormat(format) ? 'import' : 'require', 'default', ...extras])]
|
|
338
370
|
}
|
package/src/resolve-node.js
CHANGED
|
@@ -385,9 +385,10 @@ export function createNodeResolver(host) {
|
|
|
385
385
|
|
|
386
386
|
const notFound = (request, parentFile) => Object.assign(codedError('MODULE_NOT_FOUND', `Cannot find module '${request}'\nRequire stack:\n- ${parentFile}`), { requireStack: [parentFile] })
|
|
387
387
|
|
|
388
|
-
// Module._resolveFilename: -> the file's real path, or a builtin's id.
|
|
389
|
-
|
|
390
|
-
|
|
388
|
+
// Module._resolveFilename: -> the file's real path, or a builtin's id. Without `builtins`, a
|
|
389
|
+
// request naming a builtin is looked up like any bare name (see resolve).
|
|
390
|
+
const resolveFilename = (parentFile, request, conditions, builtins) => {
|
|
391
|
+
if (builtins && isBuiltin(request)) return request
|
|
391
392
|
if (request === '') throw notFound(request, parentFile)
|
|
392
393
|
if (request[0] === '#') {
|
|
393
394
|
const pkg = readPackageScope(parentFile)
|
|
@@ -404,12 +405,14 @@ export function createNodeResolver(host) {
|
|
|
404
405
|
|
|
405
406
|
return {
|
|
406
407
|
// `conditions`: `require.resolve`'s own by default. A builtin returns its id, as it does.
|
|
407
|
-
|
|
408
|
+
// `builtins: false` resolves for a target with no Node builtins: a bare builtin name (`buffer`)
|
|
409
|
+
// is the installed package of that name, through its `exports` like any other.
|
|
410
|
+
resolve(parentFile, request, conditions = REQUIRE_CONDITIONS, { builtins = true } = {}) {
|
|
408
411
|
parentFile = resolve(parentFile)
|
|
409
412
|
if (!(conditions instanceof Set)) conditions = new Set(conditions)
|
|
410
|
-
const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}`
|
|
413
|
+
const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}${builtins ? '' : '\0nobuiltins'}`
|
|
411
414
|
let hit = memo.get(key)
|
|
412
|
-
if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions))
|
|
415
|
+
if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions, builtins))
|
|
413
416
|
return hit
|
|
414
417
|
},
|
|
415
418
|
}
|
package/src/scan.js
CHANGED
|
@@ -237,10 +237,15 @@ export class Scan {
|
|
|
237
237
|
return this
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
//
|
|
241
|
-
// packages to a different file) AND its order: the recorded key must equal the
|
|
242
|
-
// hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
240
|
+
// Node's resolver: must match the runtime resolver's condition set (omitting one resolves
|
|
241
|
+
// `exports`-gated packages to a different file) AND its order: the recorded key must equal the
|
|
242
|
+
// runtime hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
243
|
+
// A custom resolver (--mainFields/--metro) resolves for a bundler instead, so it gets what
|
|
244
|
+
// esbuild, webpack and Metro all assert: `import`/`require`, `default` (which Node's resolver
|
|
245
|
+
// matches anyway) and the extras, never `node`, `node-addons` or `module-sync`; the same set as
|
|
246
|
+
// resolve-fields.js's resolveConditions.
|
|
243
247
|
#conditionSet(context) {
|
|
248
|
+
if (this.customResolve) return new Set([context, 'default', ...this.extraConditions])
|
|
244
249
|
return context === 'import'
|
|
245
250
|
? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
|
|
246
251
|
: new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
|
|
@@ -6,12 +6,15 @@ import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } f
|
|
|
6
6
|
import { Vfs } from '@preventive/vfs'
|
|
7
7
|
import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
|
|
8
8
|
import { foundrySourceDir } from '../loaders/foundry.js'
|
|
9
|
+
import { resolveConditions } from '../resolve-fields.js'
|
|
9
10
|
import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
|
|
10
11
|
import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
|
|
11
12
|
|
|
12
13
|
const JS = /\.(?:[cm]?[jt]s|[jt]sx)$/u
|
|
13
|
-
// Node's own conditions for require() and for import, which the build's are added to
|
|
14
|
+
// Node's own conditions for require() and for import, which the build's are added to; with
|
|
15
|
+
// `mainFields` or `metro`, the field resolver's bundler ones instead (no `node`).
|
|
14
16
|
const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
|
|
17
|
+
const FIELD_CONDITIONS = ['commonjs', 'module'].map((format) => resolveConditions(format))
|
|
15
18
|
|
|
16
19
|
// The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
|
|
17
20
|
// with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
|
|
@@ -19,8 +22,9 @@ const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['im
|
|
|
19
22
|
// that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
|
|
20
23
|
// that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
|
|
21
24
|
// fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
|
|
22
|
-
// for require() and for import, with the conditions the build adds (the RN ones under `metro`)
|
|
23
|
-
//
|
|
25
|
+
// for require() and for import, with the conditions the build adds (the RN ones under `metro`) to
|
|
26
|
+
// Node's, or with `mainFields` or `metro` to the field resolver's; and each `bin`. Only JS files in
|
|
27
|
+
// `dir` that are there, named from within it; none without a package.json.
|
|
24
28
|
function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], typescript = false } = {}) {
|
|
25
29
|
const real = host.realpath(dir)
|
|
26
30
|
const manifest = join(real, 'package.json')
|
|
@@ -62,8 +66,8 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
|
|
|
62
66
|
for (const subpath of typeof name === 'string' ? subpaths : []) {
|
|
63
67
|
if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
|
|
64
68
|
const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
|
|
65
|
-
for (const { extras } of passes) {
|
|
66
|
-
for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
|
|
69
|
+
for (const { extras, resolver } of passes) {
|
|
70
|
+
for (const names of resolver === undefined ? NODE_CONDITIONS : FIELD_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
|
package/src/vfs-bundle/github.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
8
8
|
import { vfsFromEntries } from '@preventive/vfs'
|
|
9
9
|
import { buildVfsBundle } from '../cmd/bundle.js'
|
|
10
10
|
import { KINDS, checkAhead } from './entries.js'
|
|
11
|
-
import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
11
|
+
import { checkCache, checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
12
12
|
|
|
13
13
|
// As upstream's tree verification bounds a tarball's unpacked size.
|
|
14
14
|
const MAX_TAR_BYTES = 2 ** 30
|
|
@@ -172,8 +172,11 @@ async function repoTree(name, { github, sha, tag, directory, client, packageMana
|
|
|
172
172
|
// one is outside the others; `repo` names that directory and the commit. Nothing is read from disk:
|
|
173
173
|
// the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree id
|
|
174
174
|
// either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
|
|
175
|
+
// `cache` is buildVfsBundle's, for the npm packages the tree installs: the repo's tree is cached
|
|
176
|
+
// where setCacheDir says, whatever it is.
|
|
175
177
|
export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
|
|
176
178
|
checkTarget('buildGitHubBundle', options)
|
|
179
|
+
checkCache('buildGitHubBundle', options.cache)
|
|
177
180
|
// Its own, for a Soldeer git dependency too.
|
|
178
181
|
client ??= createClient({ token: null })
|
|
179
182
|
const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
|