@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,144 @@
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 { resolveConditions } from '../resolve-fields.js'
10
+ import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
11
+ import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
12
+
13
+ const JS = /\.(?:[cm]?[jt]s|[jt]sx)$/u
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`).
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))
18
+
19
+ // The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
20
+ // with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
21
+ // `metro` through the build's field resolver, once per platform of `platforms` under `metro`; what
22
+ // that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
23
+ // that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
24
+ // fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
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.
28
+ function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], typescript = false } = {}) {
29
+ const real = host.realpath(dir)
30
+ const manifest = join(real, 'package.json')
31
+ const pkg = readJson(manifest, host)
32
+ if (!isPlainObject(pkg)) return []
33
+ const found = new Set()
34
+ const add = (file) => {
35
+ const rel = file === undefined ? '' : relative(real, file)
36
+ if (rel !== '' && !relativeEscapes(rel) && JS.test(rel)) found.add(rel)
37
+ }
38
+ const passes = metro || mainFields !== undefined
39
+ ? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, typescript, host }))
40
+ : [{ extras: conditions, mainFields: ['main'] }]
41
+ // Node's resolution of `specifier`, else under `typescript` the miss `mapped` as tsc maps it; a
42
+ // real path either way, or undefined.
43
+ const viaNode = (specifier, names, extras, mapped) => {
44
+ const set = new Set([...names, ...extras])
45
+ try {
46
+ return host.resolve(manifest, specifier, set)
47
+ } catch {
48
+ const hit = typescript ? mapped(set) : null
49
+ return hit === null ? undefined : host.realpath(hit)
50
+ }
51
+ }
52
+ for (const { extras, mainFields: fields, resolver } of passes) {
53
+ const entry = fields.map((name) => pkg[name]).find((value) => typeof value === 'string' && value !== '')
54
+ if (entry !== undefined && posixPathEscapes(entry)) continue
55
+ if (resolver === undefined) {
56
+ add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, host })))
57
+ } else {
58
+ // A real path, as Node's resolution gives and the scan takes.
59
+ const hit = resolver(manifest, './')
60
+ if (hit?.url !== undefined) add(host.realpath(fileURLToPath(hit.url)))
61
+ }
62
+ }
63
+ const { exports, name } = pkg
64
+ const keyed = isPlainObject(exports) && Object.keys(exports).some((key) => key.startsWith('.'))
65
+ const subpaths = keyed ? Object.keys(exports) : exports === undefined || exports === null ? [] : ['.']
66
+ for (const subpath of typeof name === 'string' ? subpaths : []) {
67
+ if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
68
+ const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
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 })))
71
+ }
72
+ }
73
+ const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
74
+ for (const bin of bins) {
75
+ if (typeof bin === 'string' && !posixPathEscapes(bin) && host.stat(join(real, bin))?.isFile()) add(host.realpath(join(real, bin)))
76
+ }
77
+ return [...found]
78
+ }
79
+
80
+ // Directories whose .sol files are none of a project's entry points: its tests, scripts and mocks,
81
+ // what it depends on or builds, and what no walk descends into.
82
+ const SKIPPED_DIRS = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'lib', 'node_modules', 'dependencies', 'out', 'cache', 'artifacts', 'build'])
83
+ const isSkippedDir = (name) => SKIPPED_DIRS.has(name.toLowerCase()) || isAutoExcludedDir(name)
84
+ // A .sol file but a test or script by its name.
85
+ const isEntrySol = (entry) => entry.isFile() && entry.name.endsWith('.sol') && !/\.(?:t|s|test|spec)\.sol$/u.test(entry.name)
86
+
87
+ // The .sol files under the directory `rel` of `real` that are entry points by name, but those in a
88
+ // skipped directory; sorted. Links are not followed.
89
+ function solidityFiles(host, real, rel) {
90
+ const out = []
91
+ const walk = (sub) => {
92
+ for (const entry of host.readdir(join(real, sub))) {
93
+ const path = sub === '.' ? entry.name : `${sub}/${entry.name}`
94
+ if (entry.isDirectory()) {
95
+ if (!isSkippedDir(entry.name)) walk(path)
96
+ } else if (isEntrySol(entry)) {
97
+ out.push(path)
98
+ }
99
+ }
100
+ }
101
+ walk(rel)
102
+ return out.toSorted()
103
+ }
104
+
105
+ // The entry points of the Soldeer project in `dir`, by name and layout, as paths from `dir`: its
106
+ // .sol files directly in it, then those under contracts/, then those under its source directory
107
+ // (its foundry.toml's default profile's `src`, else `src`); none of its tests, scripts or mocks,
108
+ // nor any in what it depends on or builds. A link to a directory is none.
109
+ function solidityEntries(host, dir) {
110
+ const real = host.realpath(dir)
111
+ const isDirectory = (rel) => !posixPathEscapes(rel) && host.stat(join(real, rel))?.isDirectory() && host.readlink(join(real, rel)) === null
112
+ const under = (rel) => (isDirectory(rel) ? solidityFiles(host, real, rel) : [])
113
+ const src = normalize(foundrySourceDir(real, { host })).replace(/\/+$/u, '')
114
+ const own = host.readdir(real).filter(isEntrySol).map((entry) => entry.name).toSorted()
115
+ return [...new Set([...own, ...under('contracts'), ...(src === 'contracts' ? [] : under(src))])]
116
+ }
117
+
118
+ // By the kind of bundle a package manager builds: an entry its options are checked over before
119
+ // there are any, the entries the build takes where none are given, and what having none says.
120
+ export const KINDS = {
121
+ js: { standIn: 'index.js', entries: packageEntries, none: 'has no package.json naming a JS entry point there' },
122
+ sol: { standIn: 'index.sol', entries: solidityEntries, none: 'has no .sol entry point directly in it, under contracts/, or under its source directory' },
123
+ }
124
+
125
+ const EMPTY = vfsHost(new Vfs())
126
+
127
+ // The build's checks of `options` before anything is fetched, over an empty tree, which never
128
+ // decides them: for the package manager given, else for any; over the entries given, else over one
129
+ // of the kind the default ones are. -> the PACKAGE_MANAGERS entry of the one given
130
+ export function checkAhead(name, packageManager, options) {
131
+ checkVersion(name, { packageManager, ...options })
132
+ const pm = packageManager === undefined ? undefined : packageManagerOf(name, packageManager)
133
+ const refusal = (entries) => {
134
+ try {
135
+ checkKind(name, checkVfsOptions(name, { ...options, entries, cwd: '/', host: EMPTY, fetched: false }), packageManager === undefined ? undefined : [packageManager])
136
+ return null
137
+ } catch (error) {
138
+ return error
139
+ }
140
+ }
141
+ const refusals = options.entries === undefined ? (pm === undefined ? Object.values(KINDS) : [KINDS[pm.kind]]).map(({ standIn }) => refusal([standIn])) : [refusal(options.entries)]
142
+ if (!refusals.includes(null)) throw refusals[0]
143
+ return pm
144
+ }
@@ -0,0 +1,202 @@
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 { checkCache, checkTarget, detectPackageManager, installedAlone, lockfileOf, 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. The bundle's paths are
170
+ // relative to `directory`, or for a JS bundle, to the innermost package directory at or above it
171
+ // holding every file it bundles (buildJsBundle's innermostRoot), which is the project's root where
172
+ // one is outside the others; `repo` names that directory and the commit. Nothing is read from disk:
173
+ // the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree id
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.
177
+ export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
178
+ checkTarget('buildGitHubBundle', options)
179
+ checkCache('buildGitHubBundle', options.cache)
180
+ // Its own, for a Soldeer git dependency too.
181
+ client ??= createClient({ token: null })
182
+ const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
183
+ let { entries } = options
184
+ if (entries === undefined) {
185
+ // As suggestedEntries suggests them, from the tree at hand.
186
+ entries = tree.kind.entries(tree.host, tree.cwd, options)
187
+ if (entries.length === 0) throw new Error(`${tree.where}: no entries given, and ${directory || 'the repo root'} ${tree.kind.none}`)
188
+ }
189
+ const repo = { github, commit: tree.sha }
190
+ const built = await buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, innermostRoot: true, repo, client })
191
+ // The Vfs is the repo's tree, or the subtree at `directory`: a layout known, so its root is `''`.
192
+ const at = posix.join(tree.subtree ? directory : '.', posix.relative('/', built.root)).replace(/^\.$/u, '')
193
+ built.bundle.repo = { ...repo, ...(isValidRepoField('directory', at) ? { directory: at } : {}) }
194
+ return built
195
+ }
196
+
197
+ // suggestedEntries of a GitHub repo, `{ github, sha, tag, directory, client, packageManager }`, as
198
+ // buildGitHubBundle downloads it.
199
+ export async function suggestedRepoEntries(repo, resolution) {
200
+ const tree = await repoTree('suggestedEntries', repo, resolution)
201
+ return tree.kind.entries(tree.host, tree.cwd, resolution)
202
+ }