@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5
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 +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { join, normalize, relative } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import { readJson } from '@exodus/stasis-core/bundle-util'
|
|
5
|
+
import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } from '@exodus/stasis-core/util'
|
|
6
|
+
import { Vfs } from '@preventive/vfs'
|
|
7
|
+
import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
|
|
8
|
+
import { foundrySourceDir } from '../loaders/foundry.js'
|
|
9
|
+
import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
|
|
10
|
+
import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
|
|
11
|
+
|
|
12
|
+
const JS = /\.[cm]?[jt]s$/u
|
|
13
|
+
// Node's own conditions for require() and for import, which the build's are added to.
|
|
14
|
+
const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
|
|
15
|
+
|
|
16
|
+
// The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
|
|
17
|
+
// with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
|
|
18
|
+
// `metro` through the build's field resolver, once per platform of `platforms` under `metro`; what
|
|
19
|
+
// that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
|
|
20
|
+
// that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
|
|
21
|
+
// 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`); and
|
|
23
|
+
// each `bin`. Only JS files in `dir` that are there, named from within it; none without a package.json.
|
|
24
|
+
function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], jsx = false, typescript = false } = {}) {
|
|
25
|
+
const real = host.realpath(dir)
|
|
26
|
+
const manifest = join(real, 'package.json')
|
|
27
|
+
const pkg = readJson(manifest, host)
|
|
28
|
+
if (!isPlainObject(pkg)) return []
|
|
29
|
+
const found = new Set()
|
|
30
|
+
const add = (file) => {
|
|
31
|
+
const rel = file === undefined ? '' : relative(real, file)
|
|
32
|
+
if (rel !== '' && !relativeEscapes(rel) && JS.test(rel)) found.add(rel)
|
|
33
|
+
}
|
|
34
|
+
const passes = metro || mainFields !== undefined
|
|
35
|
+
? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, jsx, typescript, host }))
|
|
36
|
+
: [{ extras: conditions, mainFields: ['main'] }]
|
|
37
|
+
// Node's resolution of `specifier`, else under `typescript` the miss `mapped` as tsc maps it; a
|
|
38
|
+
// real path either way, or undefined.
|
|
39
|
+
const viaNode = (specifier, names, extras, mapped) => {
|
|
40
|
+
const set = new Set([...names, ...extras])
|
|
41
|
+
try {
|
|
42
|
+
return host.resolve(manifest, specifier, set)
|
|
43
|
+
} catch {
|
|
44
|
+
const hit = typescript ? mapped(set) : null
|
|
45
|
+
return hit === null ? undefined : host.realpath(hit)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const { extras, mainFields: fields, resolver } of passes) {
|
|
49
|
+
const entry = fields.map((name) => pkg[name]).find((value) => typeof value === 'string' && value !== '')
|
|
50
|
+
if (entry !== undefined && posixPathEscapes(entry)) continue
|
|
51
|
+
if (resolver === undefined) {
|
|
52
|
+
add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, tsx: jsx, host })))
|
|
53
|
+
} else {
|
|
54
|
+
// A real path, as Node's resolution gives and the scan takes.
|
|
55
|
+
const hit = resolver(manifest, './')
|
|
56
|
+
if (hit?.url !== undefined) add(host.realpath(fileURLToPath(hit.url)))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const { exports, name } = pkg
|
|
60
|
+
const keyed = isPlainObject(exports) && Object.keys(exports).some((key) => key.startsWith('.'))
|
|
61
|
+
const subpaths = keyed ? Object.keys(exports) : exports === undefined || exports === null ? [] : ['.']
|
|
62
|
+
for (const subpath of typeof name === 'string' ? subpaths : []) {
|
|
63
|
+
if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
|
|
64
|
+
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, tsx: jsx, host })))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
|
|
70
|
+
for (const bin of bins) {
|
|
71
|
+
if (typeof bin === 'string' && !posixPathEscapes(bin) && host.stat(join(real, bin))?.isFile()) add(host.realpath(join(real, bin)))
|
|
72
|
+
}
|
|
73
|
+
return [...found]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Directories whose .sol files are none of a project's entry points: its tests, scripts and mocks,
|
|
77
|
+
// what it depends on or builds, and what no walk descends into.
|
|
78
|
+
const SKIPPED_DIRS = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'lib', 'node_modules', 'dependencies', 'out', 'cache', 'artifacts', 'build'])
|
|
79
|
+
const isSkippedDir = (name) => SKIPPED_DIRS.has(name.toLowerCase()) || isAutoExcludedDir(name)
|
|
80
|
+
// A .sol file but a test or script by its name.
|
|
81
|
+
const isEntrySol = (entry) => entry.isFile() && entry.name.endsWith('.sol') && !/\.(?:t|s|test|spec)\.sol$/u.test(entry.name)
|
|
82
|
+
|
|
83
|
+
// The .sol files under the directory `rel` of `real` that are entry points by name, but those in a
|
|
84
|
+
// skipped directory; sorted. Links are not followed.
|
|
85
|
+
function solidityFiles(host, real, rel) {
|
|
86
|
+
const out = []
|
|
87
|
+
const walk = (sub) => {
|
|
88
|
+
for (const entry of host.readdir(join(real, sub))) {
|
|
89
|
+
const path = sub === '.' ? entry.name : `${sub}/${entry.name}`
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!isSkippedDir(entry.name)) walk(path)
|
|
92
|
+
} else if (isEntrySol(entry)) {
|
|
93
|
+
out.push(path)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(rel)
|
|
98
|
+
return out.toSorted()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The entry points of the Soldeer project in `dir`, by name and layout, as paths from `dir`: its
|
|
102
|
+
// .sol files directly in it, then those under contracts/, then those under its source directory
|
|
103
|
+
// (its foundry.toml's default profile's `src`, else `src`); none of its tests, scripts or mocks,
|
|
104
|
+
// nor any in what it depends on or builds. A link to a directory is none.
|
|
105
|
+
function solidityEntries(host, dir) {
|
|
106
|
+
const real = host.realpath(dir)
|
|
107
|
+
const isDirectory = (rel) => !posixPathEscapes(rel) && host.stat(join(real, rel))?.isDirectory() && host.readlink(join(real, rel)) === null
|
|
108
|
+
const under = (rel) => (isDirectory(rel) ? solidityFiles(host, real, rel) : [])
|
|
109
|
+
const src = normalize(foundrySourceDir(real, { host })).replace(/\/+$/u, '')
|
|
110
|
+
const own = host.readdir(real).filter(isEntrySol).map((entry) => entry.name).toSorted()
|
|
111
|
+
return [...new Set([...own, ...under('contracts'), ...(src === 'contracts' ? [] : under(src))])]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// By the kind of bundle a package manager builds: an entry its options are checked over before
|
|
115
|
+
// there are any, the entries the build takes where none are given, and what having none says.
|
|
116
|
+
export const KINDS = {
|
|
117
|
+
js: { standIn: 'index.js', entries: packageEntries, none: 'has no package.json naming a JS entry point there' },
|
|
118
|
+
sol: { standIn: 'index.sol', entries: solidityEntries, none: 'has no .sol entry point directly in it, under contracts/, or under its source directory' },
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const EMPTY = vfsHost(new Vfs())
|
|
122
|
+
|
|
123
|
+
// The build's checks of `options` before anything is fetched, over an empty tree, which never
|
|
124
|
+
// decides them: for the package manager given, else for any; over the entries given, else over one
|
|
125
|
+
// of the kind the default ones are. -> the PACKAGE_MANAGERS entry of the one given
|
|
126
|
+
export function checkAhead(name, packageManager, options) {
|
|
127
|
+
checkVersion(name, { packageManager, ...options })
|
|
128
|
+
const pm = packageManager === undefined ? undefined : packageManagerOf(name, packageManager)
|
|
129
|
+
const refusal = (entries) => {
|
|
130
|
+
try {
|
|
131
|
+
checkKind(name, checkVfsOptions(name, { ...options, entries, cwd: '/', host: EMPTY, fetched: false }), packageManager === undefined ? undefined : [packageManager])
|
|
132
|
+
return null
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const refusals = options.entries === undefined ? (pm === undefined ? Object.values(KINDS) : [KINDS[pm.kind]]).map(({ standIn }) => refusal([standIn])) : [refusal(options.entries)]
|
|
138
|
+
if (!refusals.includes(null)) throw refusals[0]
|
|
139
|
+
return pm
|
|
140
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
4
|
+
import { posixPathEscapes } from '@exodus/stasis-core/util'
|
|
5
|
+
import { decompress } from '@preventive/archive/compression.js'
|
|
6
|
+
import { ArchiveError, unpack } from '@preventive/archive/tar.js'
|
|
7
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
8
|
+
import { vfsFromEntries } from '@preventive/vfs'
|
|
9
|
+
import { buildVfsBundle } from '../cmd/bundle.js'
|
|
10
|
+
import { KINDS, checkAhead } from './entries.js'
|
|
11
|
+
import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfileRoot, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
12
|
+
|
|
13
|
+
// As upstream's tree verification bounds a tarball's unpacked size.
|
|
14
|
+
const MAX_TAR_BYTES = 2 ** 30
|
|
15
|
+
// What a git tree holds (upstream's verification refuses anything else from GitHub).
|
|
16
|
+
const TREE_TYPES = new Set(['file', 'directory', 'symlink'])
|
|
17
|
+
// Lockfile references to a path above the lockfile's directory (conservatively: any importer's):
|
|
18
|
+
// pnpm's and yarn's specs, and package-lock.json's package keys and `resolved` paths, `..` itself
|
|
19
|
+
// (`link:..`, npm's `".."`) or under it.
|
|
20
|
+
const LOCKFILE_ESCAPE = /(?:link:|file:|directory: |["'])\.\.(?![^/"'\s])/u
|
|
21
|
+
const TSCONFIG = /(?:^|\/)[jt]sconfig[^/]*\.json$/u
|
|
22
|
+
|
|
23
|
+
// Whether `target`, a path relative to the file `from`, resolves outside the tree.
|
|
24
|
+
const resolvesOutside = (from, target) => target.startsWith('/') || posixPathEscapes(posix.join(posix.dirname(from), target))
|
|
25
|
+
|
|
26
|
+
// The mode a checkout gives an entry under the usual umask 022. Git keeps only a file's executable
|
|
27
|
+
// bit (upstream verifies that much), and GitHub's tarballs are written with git archive's tar.umask
|
|
28
|
+
// 0002, which makes them 664 and 775 besides.
|
|
29
|
+
const checkoutMode = ({ type, mode }) => (type === 'symlink' ? mode : type === 'directory' || mode & 0o100 ? 0o755 : 0o644)
|
|
30
|
+
|
|
31
|
+
// A GitHub tarball's entries, its one top directory dropped, with the modes a checkout has.
|
|
32
|
+
async function treeEntries(tarball, where) {
|
|
33
|
+
const entries = []
|
|
34
|
+
for (const entry of unpack(await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }))) {
|
|
35
|
+
const name = entry.name.slice(entry.name.indexOf('/') + 1)
|
|
36
|
+
if (!TREE_TYPES.has(entry.type)) throw new Error(`${where}: unexpected ${entry.type} ${JSON.stringify(name)} in the tarball`)
|
|
37
|
+
if (entry.name.includes('/')) entries.push({ ...entry, name, mode: checkoutMode(entry) })
|
|
38
|
+
}
|
|
39
|
+
return entries
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The first symlink pointing outside the tree, which would otherwise resolve within it.
|
|
43
|
+
const escapingLink = (entries) => entries.find((entry) => entry.type === 'symlink' && resolvesOutside(entry.name, entry.linkname))
|
|
44
|
+
|
|
45
|
+
// Whether a subtree's files refer to a path above it: a lockfile link or a [jt]sconfig path.
|
|
46
|
+
function refersAbove(entries, lockfile) {
|
|
47
|
+
const text = (entry) => new TextDecoder().decode(entry.data)
|
|
48
|
+
return entries.some((entry) => entry.type === 'file' && (entry.name === lockfile
|
|
49
|
+
? LOCKFILE_ESCAPE.test(text(entry))
|
|
50
|
+
: TSCONFIG.test(entry.name) && [...text(entry).matchAll(/"(\.\.?\/[^"]*)"/gu)].some(([, path]) => resolvesOutside(entry.name, path))))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The directories above `directory`, the repo's root (undefined) last.
|
|
54
|
+
function ancestorsOf(directory) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (let dir = posix.dirname(directory); dir !== '.'; dir = posix.dirname(dir)) out.push(dir)
|
|
57
|
+
return [...out, undefined]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The files of the subtree `tree`, or null where one is a symlink out of it.
|
|
61
|
+
async function treeFiles(client, { github, tree, where }) {
|
|
62
|
+
let files
|
|
63
|
+
try {
|
|
64
|
+
files = await treeEntries(await client.getRepoTreeTarball({ repo: github, tree }), where)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof ArchiveError) return null // e.g. a symlink out of the subtree
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
return escapingLink(files) ? null : files
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The whole repo's files at `sha`.
|
|
73
|
+
async function repoFiles(client, { github, sha, where }) {
|
|
74
|
+
const files = await treeEntries(await client.getRepoTarball({ repo: github, sha }), where)
|
|
75
|
+
const link = escapingLink(files)
|
|
76
|
+
if (link) throw new Error(`${where}: symlink ${JSON.stringify(link.name)} points outside the repo`)
|
|
77
|
+
return files
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Checked as the Bundle checks them, before anything is fetched; a tag's name is upstream's to check.
|
|
81
|
+
function checkRepo(name, { github, sha, tag, directory }) {
|
|
82
|
+
if (github === undefined) throw new Error(`${name}: github is required`)
|
|
83
|
+
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) throw new TypeError(`${name}: tag must be a non-empty string`)
|
|
84
|
+
if (sha !== undefined && tag !== undefined) throw new Error(`${name}: sha and tag both name the commit: give one`)
|
|
85
|
+
for (const [key, value] of Object.entries({ github, commit: sha, directory: directory || undefined })) {
|
|
86
|
+
if (value !== undefined && !isValidRepoField(key, value)) throw new Error(`${name}: invalid ${key}: ${JSON.stringify(value)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// listRepoDir's listing of a directory of the repo at `sha`, each listed once.
|
|
91
|
+
function lister(client, { github, sha }) {
|
|
92
|
+
const listings = new Map()
|
|
93
|
+
return (directory) => {
|
|
94
|
+
if (!listings.has(directory)) listings.set(directory, client.listRepoDir({ repo: github, sha, directory }))
|
|
95
|
+
return listings.get(directory)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Of no package manager given, the one whose lockfile alone `directory` (or the repo's root) and
|
|
100
|
+
// the directories above it list, which is then the only one that may install it; refused where
|
|
101
|
+
// none does; undefined where more than one does, or the listings can't tell, for the tree to tell.
|
|
102
|
+
async function listedPackageManager(list, { directory, where }) {
|
|
103
|
+
let listings
|
|
104
|
+
try {
|
|
105
|
+
listings = await Promise.all((directory ? [directory, ...ancestorsOf(directory)] : [undefined]).map(list))
|
|
106
|
+
} catch {
|
|
107
|
+
// No plain directory in git (a symlink, or under one); the root's failure is GitHub's.
|
|
108
|
+
await list(undefined)
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
const listed = lockfilesListed(listings)
|
|
112
|
+
if (listed.length === 0) throw noLockfile(where, posix.resolve('/', directory || '.'))
|
|
113
|
+
return listed.length === 1 ? listed[0] : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The files of `directory` alone, when it holds the lockfile, is installed from itself and stands
|
|
117
|
+
// alone; else null.
|
|
118
|
+
async function subtreeEntries(client, list, { github, directory, packageManager, where }) {
|
|
119
|
+
const names = async (path) => (await list(path)).map((entry) => entry.path)
|
|
120
|
+
let listing
|
|
121
|
+
try {
|
|
122
|
+
listing = await list(directory)
|
|
123
|
+
} catch {
|
|
124
|
+
return null // no plain directory in git (a symlink, or under one): the whole repo resolves it
|
|
125
|
+
}
|
|
126
|
+
if (!lockfilesListed([listing]).includes(packageManager)) return null
|
|
127
|
+
const above = ancestorsOf(directory)
|
|
128
|
+
// Installed from a root above it (a workspace's), it is built from there.
|
|
129
|
+
if (!(await installedAlone(packageManager, listing.map((entry) => entry.path), () => Promise.all(above.map(names))))) return null
|
|
130
|
+
// Its tree id, as the listing above it holds it, held to that directory's own.
|
|
131
|
+
const tree = (await list(above[0])).find((entry) => entry.path === posix.basename(directory) && entry.type === 'tree')?.sha
|
|
132
|
+
const files = tree === undefined ? null : await treeFiles(client, { github, tree, where })
|
|
133
|
+
return files === null || refersAbove(files, lockfileOf(packageManager)) ? null : files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The repo as buildGitHubBundle downloads it, for `name`: at `sha`, else the commit `tag` names (an
|
|
137
|
+
// annotated tag followed to it), else the default branch's head; `directory` alone where it stands
|
|
138
|
+
// alone for the package manager installing it, else the whole repo. That package manager is
|
|
139
|
+
// `packageManager` if given, else the one the listings tell, before the tree is fetched, else the
|
|
140
|
+
// tree; `options` are checked for it once it is known.
|
|
141
|
+
// -> { vfs, host, cwd, subtree, sha, where, packageManager, kind }
|
|
142
|
+
async function repoTree(name, { github, sha, tag, directory, client, packageManager }, options) {
|
|
143
|
+
checkAhead(name, packageManager, options)
|
|
144
|
+
checkRepo(name, { github, sha, tag, directory })
|
|
145
|
+
client ??= createClient({ token: null })
|
|
146
|
+
sha ??= tag === undefined ? (await client.getRepoHead({ repo: github })).oid : (await client.getRepoTag({ repo: github, tag })).oid
|
|
147
|
+
const where = `${name}: ${github}@${sha}`
|
|
148
|
+
const list = lister(client, { github, sha })
|
|
149
|
+
let found = packageManager
|
|
150
|
+
if (found === undefined) {
|
|
151
|
+
found = await listedPackageManager(list, { directory, where })
|
|
152
|
+
if (found !== undefined) checkAhead(name, found, options)
|
|
153
|
+
}
|
|
154
|
+
const subtree = directory && found !== undefined ? await subtreeEntries(client, list, { github, directory, packageManager: found, where }) : null
|
|
155
|
+
const vfs = vfsFromEntries(subtree ?? await repoFiles(client, { github, sha, where }))
|
|
156
|
+
const host = vfsHost(vfs)
|
|
157
|
+
const cwd = posix.resolve('/', subtree ? '.' : directory ?? '.')
|
|
158
|
+
if (packageManager === undefined) {
|
|
159
|
+
const detected = detectPackageManager(where, host, cwd, { os: options.os })
|
|
160
|
+
if (detected !== found) checkAhead(name, detected, options)
|
|
161
|
+
found = detected
|
|
162
|
+
}
|
|
163
|
+
return { vfs, host, cwd, subtree, sha, where, packageManager: found, kind: KINDS[packageManagerOf(name, found).kind] }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A GitHub repo at a full commit, or the one `tag` names, the default branch's head without either,
|
|
167
|
+
// as buildVfsBundle builds it: `directory` downloaded alone where it stands alone, else the whole
|
|
168
|
+
// repo, and without a `packageManager`, the one whose lockfile installs it, where only one's does
|
|
169
|
+
// (repoTree). Without `entries`, they are the ones suggestedEntries suggests. `repo` names the
|
|
170
|
+
// commit and where the lockfile is, which the bundle's paths are relative to. Nothing is read from
|
|
171
|
+
// disk: the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree
|
|
172
|
+
// id either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
|
|
173
|
+
export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
|
|
174
|
+
checkTarget('buildGitHubBundle', options)
|
|
175
|
+
const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
|
|
176
|
+
const root = lockfileRoot(tree.host, tree.packageManager, tree.cwd, options.os)
|
|
177
|
+
const at = tree.subtree ? posix.join(directory, (root ?? '/').slice(1)) : (root ?? '/').slice(1)
|
|
178
|
+
const location = at === '' ? { root: true } : isValidRepoField('directory', at) ? { directory: at } : {}
|
|
179
|
+
let { entries } = options
|
|
180
|
+
if (entries === undefined) {
|
|
181
|
+
// As suggestedEntries suggests them, from the tree at hand.
|
|
182
|
+
entries = tree.kind.entries(tree.host, tree.cwd, options)
|
|
183
|
+
if (entries.length === 0) throw new Error(`${tree.where}: no entries given, and ${directory || 'the repo root'} ${tree.kind.none}`)
|
|
184
|
+
}
|
|
185
|
+
return buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, repo: { github, commit: tree.sha, ...location } })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// suggestedEntries of a GitHub repo, `{ github, sha, tag, directory, client, packageManager }`, as
|
|
189
|
+
// buildGitHubBundle downloads it.
|
|
190
|
+
export async function suggestedRepoEntries(repo, resolution) {
|
|
191
|
+
const tree = await repoTree('suggestedEntries', repo, resolution)
|
|
192
|
+
return tree.kind.entries(tree.host, tree.cwd, resolution)
|
|
193
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { isTomlTable, readToml } from '../loaders/toml.js'
|
|
4
|
+
|
|
5
|
+
// What `soldeer install` (0.12.0) writes beside the dependencies folder after installing it
|
|
6
|
+
// (soldeer-core's update_config_libs and edit_remappings with RemappingsAction::Update): the
|
|
7
|
+
// remappings.txt it would write is written into the tree, which then serves it in place of the
|
|
8
|
+
// project's; a foundry.toml it would edit is refused, as is what this can't tell it would leave as
|
|
9
|
+
// it is. `view` is the project's root by paths from `/`, `root` its real path, for messages.
|
|
10
|
+
|
|
11
|
+
const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u
|
|
12
|
+
// A requirement Soldeer takes as exact: a bare version (its `^` made `=`), or one written `=`.
|
|
13
|
+
const EXACT = /^=?\s*(\S+)$/u
|
|
14
|
+
const withoutBuild = (version) => version.replace(/\+.*$/u, '')
|
|
15
|
+
|
|
16
|
+
// A file's text as Soldeer reads it (a byte order mark kept), or undefined.
|
|
17
|
+
function textOf(view, path) {
|
|
18
|
+
try {
|
|
19
|
+
return view.stat(path).type === 'file' ? view.readFile(path).toString('utf8') : undefined
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Rust's `Path::components` of a remapping's target, `.` kept only at the start.
|
|
26
|
+
function components(path) {
|
|
27
|
+
const out = path.startsWith('/') ? ['/'] : []
|
|
28
|
+
for (const [i, part] of path.split('/').entries()) {
|
|
29
|
+
if (part !== '' && (part !== '.' || i === 0)) out.push(part)
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Whether the folder `name` of a remapping's target (its dependencies folder and the one in it) is
|
|
35
|
+
// `dep`'s, as soldeer's path_matches tells, or null where only Rust's semver could tell.
|
|
36
|
+
function folderMatches(dep, name) {
|
|
37
|
+
if (!name.startsWith(`${dep.name}-`)) return false
|
|
38
|
+
const version = name.slice(dep.name.length + 1)
|
|
39
|
+
if (version === dep.version) return true
|
|
40
|
+
if (!SEMVER.test(version)) return version === dep.req
|
|
41
|
+
const exact = EXACT.exec(dep.req.trim())?.[1]
|
|
42
|
+
if (exact !== undefined && SEMVER.test(exact)) return withoutBuild(exact) === withoutBuild(version)
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The remappings Soldeer's update leaves, of `existing` (`[name, target]` pairs) and the config's
|
|
47
|
+
// dependencies, sorted as it sorts them (by their UTF-8 bytes).
|
|
48
|
+
function updatedRemappings(existing, deps, { regenerate, where }) {
|
|
49
|
+
const items = deps.map((dep) => ({ dep, line: `${dep.remapped}=dependencies/${dep.folder}/` }))
|
|
50
|
+
let lines
|
|
51
|
+
if (regenerate || existing.length === 0) {
|
|
52
|
+
lines = items.map(({ line }) => line)
|
|
53
|
+
} else {
|
|
54
|
+
lines = []
|
|
55
|
+
let rest = existing
|
|
56
|
+
for (const { dep, line } of items) {
|
|
57
|
+
let found = false
|
|
58
|
+
rest = rest.filter(([name, target]) => {
|
|
59
|
+
const path = components(target).slice(0, 2)
|
|
60
|
+
const folder = path.at(-1)
|
|
61
|
+
const matches = folder === undefined || ['/', '.', '..'].includes(folder) ? false : folderMatches(dep, folder)
|
|
62
|
+
if (matches === null) throw new Error(`${where}: "${name}=${target}" names ${folder}, which \`soldeer install\` may rewrite to dependencies/${dep.folder}`)
|
|
63
|
+
if (!matches) return true
|
|
64
|
+
lines.push(`${name}=${target.replaceAll(path.join('/').replace(/^\/\//u, '/'), `dependencies/${dep.folder}`)}`)
|
|
65
|
+
found = true
|
|
66
|
+
return false
|
|
67
|
+
})
|
|
68
|
+
if (!found) lines.push(line)
|
|
69
|
+
}
|
|
70
|
+
lines.push(...rest.map(([name, target]) => `${name}=${target}`))
|
|
71
|
+
}
|
|
72
|
+
return lines.toSorted((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `[name, target]` of each remapping in `lines`, as Soldeer reads them: split at the first `=`, any
|
|
76
|
+
// line without one passed over.
|
|
77
|
+
const pairs = (lines) => lines.flatMap((line) => {
|
|
78
|
+
const at = line.indexOf('=')
|
|
79
|
+
return at === -1 ? [] : [[line.slice(0, at), line.slice(at + 1)]]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export function settleSoldeer(view, vfs, root) {
|
|
83
|
+
const foundryText = textOf(view, '/foundry.toml')
|
|
84
|
+
const foundry = foundryText === undefined ? undefined : readToml(foundryText)
|
|
85
|
+
const configFile = isTomlTable(foundry?.dependencies) ? 'foundry.toml' : 'soldeer.toml'
|
|
86
|
+
const config = configFile === 'foundry.toml' ? foundry : readToml(textOf(view, '/soldeer.toml') ?? '')
|
|
87
|
+
const where = (file) => join(root, file)
|
|
88
|
+
|
|
89
|
+
if (configFile === 'foundry.toml') {
|
|
90
|
+
const libs = foundry.profile?.default?.libs
|
|
91
|
+
if (!Array.isArray(libs) || !libs.includes('dependencies')) {
|
|
92
|
+
throw new Error(`${where('foundry.toml')}: [profile.default] libs holds no "dependencies", which \`soldeer install\` adds`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const settings = { remappings_generate: true, remappings_regenerate: false, remappings_version: true, remappings_prefix: '', remappings_location: 'txt', ...config.soldeer }
|
|
97
|
+
if (!settings.remappings_generate) return
|
|
98
|
+
const locked = new Map(readToml(textOf(view, '/soldeer.lock') ?? '').dependencies?.map((entry) => [entry.name, entry.version]) ?? [])
|
|
99
|
+
const deps = Object.entries(config.dependencies ?? {}).map(([name, value]) => {
|
|
100
|
+
const req = typeof value === 'string' ? value : value.version
|
|
101
|
+
const version = locked.get(name)
|
|
102
|
+
const remapped = `${settings.remappings_prefix}${name}${settings.remappings_version ? `-${req.replaceAll('=', '')}` : ''}/`
|
|
103
|
+
return { name, req, version, folder: `${name}-${version}`, remapped }
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const remappingsTxt = textOf(view, '/remappings.txt')
|
|
107
|
+
if (configFile === 'foundry.toml' && settings.remappings_location === 'config') {
|
|
108
|
+
if (remappingsTxt !== undefined) throw new Error(`${where('remappings.txt')}: \`soldeer install\` removes it, as remappings_location is "config"`)
|
|
109
|
+
for (const [name, profile] of Object.entries(isTomlTable(foundry.profile) ? foundry.profile : {})) {
|
|
110
|
+
const current = Array.isArray(profile?.remappings) ? profile.remappings : undefined
|
|
111
|
+
if (current === undefined && name !== 'default') continue
|
|
112
|
+
const updated = updatedRemappings(pairs((current ?? []).filter((r) => typeof r === 'string')), deps, { regenerate: settings.remappings_regenerate, where: where('foundry.toml') })
|
|
113
|
+
if (current === undefined || updated.length !== current.length || updated.some((r, i) => r !== current[i])) {
|
|
114
|
+
throw new Error(`${where('foundry.toml')}: [profile.${name}] remappings are not as \`soldeer install\` leaves them`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = (settings.remappings_regenerate ? '' : remappingsTxt ?? '').split('\n')
|
|
121
|
+
if (lines.at(-1) === '') lines.pop()
|
|
122
|
+
const existing = pairs(lines.map((line) => line.replace(/\r$/u, '')))
|
|
123
|
+
const text = updatedRemappings(existing, deps, { regenerate: settings.remappings_regenerate, where: where('remappings.txt') }).map((line) => `${line}\n`).join('')
|
|
124
|
+
if (text !== remappingsTxt) vfs.writeFile('/remappings.txt', text, { mode: remappingsTxt === undefined ? 0o644 : view.lstat('/remappings.txt').mode })
|
|
125
|
+
}
|