@exodus/stasis 1.0.0-beta.8 → 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 +1 -0
- package/bin/stasis.js +106 -177
- package/package.json +6 -5
- 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
package/src/cmd/bundle.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { isUtf8 } from 'node:buffer'
|
|
2
2
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
3
|
-
import { dirname, extname, join, posix, relative, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, extname, join, posix, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { brotliDecompressSync } from 'node:zlib'
|
|
6
6
|
|
|
7
|
-
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
7
|
+
import { Bundle, isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
8
8
|
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
9
9
|
import { importsTypescriptByOutputName, scan } from '../scan.js'
|
|
10
10
|
import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
|
|
11
11
|
import { discoverTsconfig, isDir, loadTsconfigPaths, packageTsconfigPaths } from '../resolve-typescript.js'
|
|
12
12
|
import { createMetroResolver } from '../metro-resolver.js'
|
|
13
|
+
import { pinInstalledCommits } from '../installed-commits.js'
|
|
13
14
|
import { State } from '@exodus/stasis-core/state'
|
|
14
15
|
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
15
|
-
import { detectRepo, findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
|
|
16
|
-
import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, hasNodeModulesSegment, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPathWithin, isPodspec, isSkippedNativeWalkDir, moduleFileKey, moduleInfo, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix } from '@exodus/stasis-core/util'
|
|
16
|
+
import { checkoutCommit, detectRepo, findPackageMetadata, normalizeEntries, packageType, parseGithubRepository, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
|
|
17
|
+
import { EMPTY_MODULE_PATH, RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, hasNodeModulesSegment, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPathWithin, isPodspec, isSkippedNativeWalkDir, moduleFileKey, moduleInfo, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix } from '@exodus/stasis-core/util'
|
|
17
18
|
import { diskHost } from '@exodus/stasis-core/host'
|
|
19
|
+
import { parseSoldeerLockfile } from '@preventive/lockfile/soldeer.js'
|
|
18
20
|
import {
|
|
19
21
|
SOLIDITY_PACKAGE_MANIFESTS,
|
|
20
22
|
SOLIDITY_ROOT_MANIFESTS,
|
|
@@ -82,42 +84,105 @@ function parseSoldeerDir(seg) {
|
|
|
82
84
|
return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
87
|
+
// A url as git fetches from a host: scp's `[user@]host:path`, with no scheme and a colon before its
|
|
88
|
+
// first slash, or a URL of a transport git has itself (`http`, `https`, `git`, `ssh` and its
|
|
89
|
+
// `git+ssh`/`ssh+git` aliases, lowercase, as git spells them), its authority, `[userinfo@]host[:port]`,
|
|
90
|
+
// ending at the first slash after it. Not, then, what a package.json spells GitHub's that git reads
|
|
91
|
+
// another way or not at all: `ssh://git@github.com:owner/name`, whose host is `github.com:owner` to
|
|
92
|
+
// git; `git+https://` or `HTTPS://`, transports git doesn't have; nor `owner/name`, a path, as is any
|
|
93
|
+
// url with no colon before its first slash.
|
|
94
|
+
const GIT_URL = /^(?:[^/]*:(?!\/\/)|(?:https?|git|ssh|git\+ssh|ssh\+git):\/\/(?:[^/@]*@)?[^/:@]*(?::\d*)?\/)/u
|
|
95
|
+
|
|
96
|
+
// The GitHub repository, `owner/name`, a .gitmodules `url` names (parseGithubRepository), else null:
|
|
97
|
+
// none for a host merely holding `github.com` (`https://notgithub.com/o/n`), nor for a url git
|
|
98
|
+
// doesn't fetch as one (GIT_URL), nor for npm's `github:owner/name`, the host `github`'s path to git,
|
|
99
|
+
// whatever an ssh alias or `insteadOf` makes of it, nor one with a `#`, which git keeps in the path
|
|
100
|
+
// it asks for (`owner/name.git#v1`) where npm takes a committish. git's `ssh+git://`, which a
|
|
101
|
+
// package.json doesn't spell, is its `ssh://`.
|
|
102
|
+
const submoduleGithub = (url) => (GIT_URL.test(url) && !/^github:/iu.test(url) && !url.includes('#') ? parseGithubRepository(url.replace(/^ssh\+git:/u, 'ssh:')) : null)
|
|
103
|
+
|
|
104
|
+
// The GitHub ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
|
|
92
105
|
function githubSubmodules(submodules) {
|
|
93
106
|
const byPath = new Map()
|
|
94
107
|
for (const { path, url, branch } of submodules) {
|
|
95
|
-
const name = url &&
|
|
108
|
+
const name = url && submoduleGithub(url)
|
|
96
109
|
if (name) byPath.set(path, { name, branch })
|
|
97
110
|
}
|
|
98
111
|
return byPath
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
// The root of the GitHub repository `github` at `commit`, where both are valid; else undefined.
|
|
115
|
+
const repoRootAt = (github, commit) => (isValidRepoField('github', github) && isValidRepoField('commit', commit) ? { github, directory: '', commit } : undefined)
|
|
116
|
+
|
|
117
|
+
// The text of `rel`, a file of the project root, read as `ownership` vouches for it; null where it
|
|
118
|
+
// refuses it, nothing is there, or it isn't UTF-8.
|
|
119
|
+
function ownedText(baseDir, rel, ownership, host) {
|
|
120
|
+
const { reason, real, outside } = ownership.of(rel)
|
|
121
|
+
if (reason || real === null || outside) return null
|
|
122
|
+
try {
|
|
123
|
+
const buf = readRegularFileOrNull(join(host.realpath(baseDir), real), rel, host)
|
|
124
|
+
return buf === null ? null : decodeUtf8(buf, rel)
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Each git dependency the root's soldeer.lock installs from GitHub, by its folder under
|
|
131
|
+
// `dependencies/` (`<name>-<version>`, as Soldeer names it): the repository's root at the lockfile's
|
|
132
|
+
// `rev`, which Soldeer checks out there. None where the lockfile can't be read or parsed: it is
|
|
133
|
+
// metadata, which never stops a build.
|
|
134
|
+
function soldeerGitRepos(baseDir, ownership, host) {
|
|
135
|
+
const text = ownedText(baseDir, 'soldeer.lock', ownership, host)
|
|
136
|
+
let lock = null
|
|
137
|
+
try {
|
|
138
|
+
lock = text === null ? null : parseSoldeerLockfile(text)
|
|
139
|
+
} catch {
|
|
140
|
+
// Not one Soldeer writes: nothing is recorded from it.
|
|
141
|
+
}
|
|
142
|
+
const repos = new Map()
|
|
143
|
+
for (const dep of Object.values(lock?.dependencies ?? {})) {
|
|
144
|
+
const repo = dep.type === 'git' ? repoRootAt(parseGithubRepository(dep.git), dep.rev) : undefined
|
|
145
|
+
if (repo !== undefined) repos.set(`${dep.name}-${dep.version}`, repo)
|
|
146
|
+
}
|
|
147
|
+
return repos
|
|
148
|
+
}
|
|
149
|
+
|
|
101
150
|
// Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
|
|
102
151
|
// github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the
|
|
103
152
|
// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read
|
|
104
|
-
// through `host`.
|
|
153
|
+
// through `host`. A bucket's `repo` is the GitHub repository it is checked out from, at its root, at
|
|
154
|
+
// the commit its checkout's HEAD is at, as the bundle's own `repo` reads one: a submodule's, from
|
|
155
|
+
// the GitHub repository its .gitmodules url names; a Soldeer git dependency's, where it is its
|
|
156
|
+
// soldeer.lock `rev` (soldeerGitRepos), as Soldeer holds a checkout to the lockfile before taking it
|
|
157
|
+
// for installed: a folder left at another commit since the lockfile changed, or no checkout, records
|
|
158
|
+
// none.
|
|
105
159
|
function makeSolidityClassifier(baseDir, ownership, host) {
|
|
106
160
|
const submodules = githubSubmodules(ownership.submodules)
|
|
107
161
|
const check = ownership.assert
|
|
108
162
|
const versions = new Map() // a submodule's package.json version, read once
|
|
163
|
+
const commits = new Map() // a bucket's checkout's commit, read once
|
|
164
|
+
const commitOf = (dir) => {
|
|
165
|
+
if (!commits.has(dir)) commits.set(dir, checkoutCommit(join(baseDir, dir), host))
|
|
166
|
+
return commits.get(dir)
|
|
167
|
+
}
|
|
168
|
+
let soldeer // soldeerGitRepos, read at the first Soldeer file
|
|
109
169
|
return (path) => {
|
|
110
170
|
if (path.startsWith('dependencies/')) {
|
|
111
171
|
const seg = path.slice('dependencies/'.length).split('/')[0]
|
|
112
172
|
if (seg) {
|
|
113
173
|
const { name, version } = parseSoldeerDir(seg)
|
|
114
|
-
|
|
174
|
+
const bucketDir = `dependencies/${seg}`
|
|
175
|
+
soldeer ??= soldeerGitRepos(baseDir, ownership, host)
|
|
176
|
+
const locked = soldeer.get(seg)
|
|
177
|
+
const repo = locked !== undefined && commitOf(bucketDir) === locked.commit ? locked : undefined
|
|
178
|
+
return { bucketDir, name, version, ecosystem: 'soldeer', ...(repo === undefined ? {} : { repo }) }
|
|
115
179
|
}
|
|
116
180
|
}
|
|
117
181
|
for (const [sub, { name, branch }] of submodules) {
|
|
118
182
|
if (path === sub || path.startsWith(`${sub}/`)) {
|
|
119
183
|
if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check, host })?.version)
|
|
120
|
-
|
|
184
|
+
const repo = repoRootAt(name, commitOf(sub))
|
|
185
|
+
return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github', ...(repo === undefined ? {} : { repo }) }
|
|
121
186
|
}
|
|
122
187
|
}
|
|
123
188
|
return null
|
|
@@ -801,9 +866,11 @@ function innermostRootOf(baseDir, files, root, host) {
|
|
|
801
866
|
const SOURCE_EXTS = ['js', 'jsx', 'json', 'ts', 'tsx']
|
|
802
867
|
// React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
|
|
803
868
|
const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
869
|
+
// What esbuild and webpack 5 both assert on every platform besides `import`/`require` and
|
|
870
|
+
// `default`, and so `--mainFields` too: `module`, the bundler-only condition a package's ESM build
|
|
871
|
+
// sits under, from an import and a require alike. (esbuild drops it when given conditions of its
|
|
872
|
+
// own; webpack keeps it, as `--conditions` adds to it here.) Node and Metro never assert it.
|
|
873
|
+
const BUNDLER_CONDITIONS = ['module']
|
|
807
874
|
|
|
808
875
|
// Recursively collect the files under `dirAbs` whose name `keep` takes, skipping build output,
|
|
809
876
|
// symlinks (cycle/escape hazard) and, directly in `dirAbs`, the dirs `skipAtRoot` names. Absolute
|
|
@@ -848,10 +915,11 @@ function nativeModuleFiles(pkgAbs, host) {
|
|
|
848
915
|
|
|
849
916
|
// The built-in field/suffix resolver (resolve-fields.js) the legacy-field build resolves with on
|
|
850
917
|
// `platform` (null for --mainFields), its `mainFields` (Metro's under --metro), and the conditions
|
|
851
|
-
// it adds to
|
|
852
|
-
//
|
|
918
|
+
// it adds to the bundler base (`import`/`require` + `default`, never Node's `node`; see
|
|
919
|
+
// resolveConditions): --metro asserts the RN conditions (+ browser on web), Metro's own set;
|
|
920
|
+
// --mainFields asserts esbuild's and webpack's `module` (BUNDLER_CONDITIONS) and the user's --conditions.
|
|
853
921
|
export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], typescript = false, typescriptPaths = null, host = diskHost }) {
|
|
854
|
-
const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : conditions
|
|
922
|
+
const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : [...new Set([...BUNDLER_CONDITIONS, ...conditions])]
|
|
855
923
|
const fields = metro ? METRO_MAIN_FIELDS : mainFields
|
|
856
924
|
const resolver = createFieldResolver({
|
|
857
925
|
mainFields: fields,
|
|
@@ -879,8 +947,17 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
|
|
|
879
947
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
880
948
|
const normalized = normalizeEntries(entries, cwd)
|
|
881
949
|
|
|
950
|
+
// A resolved file arrives by its real path, an entry as spelled: under a cwd named through a link,
|
|
951
|
+
// the files the scan reaches lie below the root's real path, not below `baseDir` as spelled. Either
|
|
952
|
+
// is the root; a file below neither is outside it. (Realpathed only when needed, so a missing cwd
|
|
953
|
+
// is still the scan's missing entry.)
|
|
954
|
+
let realRoot
|
|
882
955
|
const toRel = (abs) => {
|
|
883
|
-
|
|
956
|
+
let rel = toPosix(relative(baseDir, abs))
|
|
957
|
+
if (relativeEscapes(rel)) {
|
|
958
|
+
realRoot ??= host.realpath(baseDir)
|
|
959
|
+
rel = toPosix(relative(realRoot, abs))
|
|
960
|
+
}
|
|
884
961
|
if (relativeEscapes(rel)) {
|
|
885
962
|
throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
|
|
886
963
|
}
|
|
@@ -958,8 +1035,10 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
|
|
|
958
1035
|
const realBase = host.realpath(baseDir)
|
|
959
1036
|
for (const abs of reached) {
|
|
960
1037
|
const rel = toRel(abs)
|
|
961
|
-
// Security:
|
|
962
|
-
//
|
|
1038
|
+
// Security: a resolved file arrives by its real path, which toRel already holds to the root,
|
|
1039
|
+
// but an entry is taken as spelled (and --metro-resolver keeps a path it can't realpath), so an
|
|
1040
|
+
// in-tree-named symlink escaping the root would slip past toRel's textual check -- realpath and
|
|
1041
|
+
// fail closed.
|
|
963
1042
|
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
964
1043
|
const buf = host.readFile(abs)
|
|
965
1044
|
if (resourceRels.has(rel)) {
|
|
@@ -978,25 +1057,89 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
|
|
|
978
1057
|
put(EMPTY_MODULE_PATH, '', 'commonjs', Buffer.alloc(0))
|
|
979
1058
|
}
|
|
980
1059
|
|
|
1060
|
+
// The package a bundled file is in (findPackageMetadata: its pkgDir and identity, null where no
|
|
1061
|
+
// manifest names one), memoized per directory so a package's many files don't each re-walk to it.
|
|
1062
|
+
const packageOf = packageLookup(baseDir, { host })
|
|
1063
|
+
|
|
981
1064
|
// --metro also carries each bundled dependency's native build-input surface (ios/android
|
|
982
|
-
// sources + podspecs), scoped to the
|
|
983
|
-
// source is stored as code under a language tag; other assets as
|
|
1065
|
+
// sources + podspecs), scoped to the packages in the bundle installed or linked into
|
|
1066
|
+
// node_modules. Native source is stored as code under a language tag; other assets as
|
|
1067
|
+
// 'resource'/'resource:base64'.
|
|
984
1068
|
if (metro) {
|
|
985
|
-
const pkgDirs = new
|
|
1069
|
+
const pkgDirs = new Map() // package dir -> its package's name
|
|
1070
|
+
// Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
|
|
1071
|
+
// not a linked native dependency, so it must not drag in its ios/android surface.
|
|
1072
|
+
const isCode = (rel) => !resourceRels.has(rel)
|
|
986
1073
|
for (const abs of reached) {
|
|
987
|
-
// Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
|
|
988
|
-
// not a linked native dependency, so it must not drag in its ios/android surface.
|
|
989
1074
|
const rel = toRel(abs)
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1075
|
+
const nm = isCode(rel) && splitNodeModulesPath(rel)
|
|
1076
|
+
if (nm) pkgDirs.set(nm.dir, nm.name)
|
|
1077
|
+
}
|
|
1078
|
+
// A package linked into node_modules from out of it (a workspace package) is reached by its real
|
|
1079
|
+
// path, out of node_modules, but is a dependency all the same, however an import reaches it (by
|
|
1080
|
+
// name or alias, through a browser/react-native map, by a relative path): one linked, under any
|
|
1081
|
+
// name, into a node_modules its importer finds packages in (the directories the resolver looks
|
|
1082
|
+
// in). One linked nowhere there is the project's own source.
|
|
1083
|
+
const linksIn = new Map() // directory -> Set of the real package dirs linked into a node_modules it finds packages in
|
|
1084
|
+
const linkedFrom = (dir) => {
|
|
1085
|
+
let links = linksIn.get(dir)
|
|
1086
|
+
if (links !== undefined) return links
|
|
1087
|
+
const parent = dirname(dir)
|
|
1088
|
+
links = new Set(parent === dir ? [] : linkedFrom(parent))
|
|
1089
|
+
const link = (abs) => {
|
|
1090
|
+
try {
|
|
1091
|
+
const rel = toRel(host.realpath(abs))
|
|
1092
|
+
if (!splitNodeModulesPath(rel)) links.add(rel)
|
|
1093
|
+
} catch { /* a dangling link, or one out of the root, whose files the scan pass refused already */ }
|
|
1094
|
+
}
|
|
1095
|
+
const listing = (abs) => {
|
|
1096
|
+
try {
|
|
1097
|
+
return host.readdir(abs)
|
|
1098
|
+
} catch {
|
|
1099
|
+
return []
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// As locatePackage: a directory named node_modules holds no node_modules of its own to look in.
|
|
1103
|
+
if (basename(dir) !== 'node_modules') {
|
|
1104
|
+
const nodeModules = join(dir, 'node_modules')
|
|
1105
|
+
for (const ent of listing(nodeModules)) {
|
|
1106
|
+
const abs = join(nodeModules, ent.name)
|
|
1107
|
+
if (ent.name.startsWith('@')) {
|
|
1108
|
+
// A scope holds packages, a directory or a link to one (a whole scope linked, where
|
|
1109
|
+
// every package in it is reached through the link).
|
|
1110
|
+
for (const scoped of listing(abs)) {
|
|
1111
|
+
if (ent.isSymbolicLink() || scoped.isSymbolicLink()) link(join(abs, scoped.name))
|
|
1112
|
+
}
|
|
1113
|
+
} else if (ent.isSymbolicLink()) {
|
|
1114
|
+
link(abs)
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
linksIn.set(dir, links)
|
|
1119
|
+
return links
|
|
993
1120
|
}
|
|
994
|
-
for (const
|
|
1121
|
+
for (const [parent, bySpec] of edges) {
|
|
1122
|
+
for (const byPlatform of bySpec.values()) {
|
|
1123
|
+
for (const target of byPlatform.values()) {
|
|
1124
|
+
if (!isCode(target) || splitNodeModulesPath(target)) continue
|
|
1125
|
+
const links = linkedFrom(dirname(join(baseDir, parent)))
|
|
1126
|
+
// The linked root holding the target, nearest first: its entry may lie below a manifest of
|
|
1127
|
+
// its own (a named dist/package.json), as a node_modules path names the package it's in.
|
|
1128
|
+
let root = posix.dirname(target)
|
|
1129
|
+
while (root !== '.' && !links.has(root)) root = posix.dirname(root)
|
|
1130
|
+
// The project's own source isn't a dependency, nor is an import within the package itself.
|
|
1131
|
+
if (root === '.' || parent.startsWith(`${root}/`) || pkgDirs.has(root)) continue
|
|
1132
|
+
// Named by its own manifest, whatever name it's linked under.
|
|
1133
|
+
pkgDirs.set(root, readPackageJson(baseDir, `${root}/package.json`, { host })?.name)
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
for (const [pkgDir, name] of [...pkgDirs].toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
995
1138
|
const pkgAbs = join(baseDir, pkgDir)
|
|
996
1139
|
// react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
|
|
997
1140
|
// so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
|
|
998
1141
|
// way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
|
|
999
|
-
const isRnCore =
|
|
1142
|
+
const isRnCore = name === 'react-native'
|
|
1000
1143
|
const files = isRnCore ? [] : nativeModuleFiles(pkgAbs, host)
|
|
1001
1144
|
if (isRnCore) walkNative(pkgAbs, files, host, isNativeSource)
|
|
1002
1145
|
for (const abs of files) {
|
|
@@ -1036,12 +1179,9 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
|
|
|
1036
1179
|
|
|
1037
1180
|
// --package-json: fold each bundled module's package.json into `sources` (and its integrity into
|
|
1038
1181
|
// the companion lockfile) even when the scan never reached it. Buckets are the ones
|
|
1039
|
-
// assembleCodeBundle derives, from the same `packageOf` (
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
// with the State path (containment, UTF-8-aborts); no identity check here -- these buckets are
|
|
1043
|
-
// all fresh from disk.
|
|
1044
|
-
const packageOf = packageLookup(baseDir, { host })
|
|
1182
|
+
// assembleCodeBundle derives, from the same `packageOf` (its pkgDir, else the '.' workspace
|
|
1183
|
+
// bucket). readModuleManifest applies the read/validate rules shared with the State path
|
|
1184
|
+
// (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
|
|
1045
1185
|
if (packageJSON) {
|
|
1046
1186
|
const pkgDirs = new Set()
|
|
1047
1187
|
for (const abs of reached) {
|
|
@@ -1236,14 +1376,17 @@ async function buildJs({ mainFields, platforms, metro, metroResolver, innermostR
|
|
|
1236
1376
|
return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true, root: state.root }
|
|
1237
1377
|
}
|
|
1238
1378
|
|
|
1239
|
-
// The bundle of `kind` (classifyEntries') built from buildBundle's options
|
|
1379
|
+
// The bundle of `kind` (classifyEntries') built from buildBundle's options, from disk: a JS bundle's
|
|
1380
|
+
// git dependencies at the commits their package managers' records name (pinInstalledCommits).
|
|
1240
1381
|
// -> { bundle, lockfile: () => Lockfile, stateBuilt } (the last two of a JS bundle alone)
|
|
1241
1382
|
async function buildOfKind(kind, { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) {
|
|
1242
1383
|
if (kind === 'sol') return { bundle: await buildSolidityBundle({ cwd, env, entries, mappingFile, manifests }) }
|
|
1243
1384
|
if (kind === 'php') return { bundle: await buildPhpBundle({ cwd, entries }) }
|
|
1244
1385
|
if (kind === 'bash') return { bundle: await buildBashBundle({ cwd, entries }) }
|
|
1245
1386
|
if (kind === 'rust') return { bundle: await buildRustBundle({ cwd, entries, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) }
|
|
1246
|
-
|
|
1387
|
+
const built = await buildJs({ cwd, env, entries, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON })
|
|
1388
|
+
pinInstalledCommits(built.bundle, built.root)
|
|
1389
|
+
return built
|
|
1247
1390
|
}
|
|
1248
1391
|
|
|
1249
1392
|
// buildVfsBundle's checks of `options`, which hold before anything is fetched (`host` the
|
|
@@ -1272,13 +1415,16 @@ export function checkVfsOptions(name, options) {
|
|
|
1272
1415
|
// tree before the scan, leaving `vfs` as it is (vfs-bundle/prisma.js). `client`, a
|
|
1273
1416
|
// @preventive/upstream/github.js client, is what a Soldeer git dependency is fetched through; without
|
|
1274
1417
|
// one, a soldeer.lock holding one is refused, naming deptree's `github`, before anything is fetched.
|
|
1275
|
-
// `
|
|
1276
|
-
//
|
|
1418
|
+
// `cache` is where npm packages' tarballs and version documents are kept (checkCache). Each
|
|
1419
|
+
// dependency is recorded at the commit the tree installs it from, where deptree names one
|
|
1420
|
+
// (pinCommits). `root` is the directory in the Vfs the bundle's paths are relative to: cwd, or for a
|
|
1421
|
+
// JS bundle built through a State, the State's root, which is at or above it.
|
|
1277
1422
|
// -> { bundle: Bundle, lockfile: Lockfile (of a JS bundle), stats, packageManager, root }
|
|
1278
|
-
export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, innermostRoot = false, generate = [], client, ...options } = {}) {
|
|
1279
|
-
const { checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, treeHost, vfsHost } = await import('../vfs-bundle/tree.js')
|
|
1423
|
+
export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, innermostRoot = false, generate = [], client, cache, ...options } = {}) {
|
|
1424
|
+
const { checkCache, checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, pinCommits, treeHost, vfsHost } = await import('../vfs-bundle/tree.js')
|
|
1280
1425
|
checkVfs('buildVfsBundle', vfs)
|
|
1281
1426
|
checkTarget('buildVfsBundle', { os, cpu, libc })
|
|
1427
|
+
checkCache('buildVfsBundle', cache)
|
|
1282
1428
|
// Checked as the Bundle checks it, before anything is fetched.
|
|
1283
1429
|
if (repo !== undefined) repo = new Bundle({ repo }).repo
|
|
1284
1430
|
const project = vfsHost(vfs)
|
|
@@ -1294,7 +1440,7 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
|
|
|
1294
1440
|
if (pm.kind === 'sol' && !abs.endsWith('.sol')) continue
|
|
1295
1441
|
if (!posix.relative(cwd, abs).split('/').includes(pm.installs) && project.stat(abs) === null) throw new Error(`entry not found: ${abs}`)
|
|
1296
1442
|
}
|
|
1297
|
-
const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client })
|
|
1443
|
+
const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache })
|
|
1298
1444
|
let { host } = tree
|
|
1299
1445
|
if (generate.includes('prisma')) {
|
|
1300
1446
|
const { generatePrismaClients, withPrismaClients } = await import('../vfs-bundle/prisma.js')
|
|
@@ -1306,6 +1452,7 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
|
|
|
1306
1452
|
if (repo !== undefined) bundle.repo = repo
|
|
1307
1453
|
// Rooted at cwd, where `stasis bundle` detects its repo.
|
|
1308
1454
|
else if (!stateBuilt) bundle.repo ??= detectRepo(cwd, host)
|
|
1455
|
+
pinCommits(bundle, root, tree)
|
|
1309
1456
|
return { bundle, lockfile: lockfile?.(), stats: tree.stats, packageManager, root }
|
|
1310
1457
|
}
|
|
1311
1458
|
|
|
@@ -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
|
+
}
|