@exodus/stasis 1.0.0-beta.3 → 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/src/scan.js CHANGED
@@ -1,10 +1,11 @@
1
- import { readFileSync, existsSync } from 'node:fs'
2
1
  import { extname, resolve as resolvePath, relative } from 'node:path'
3
2
  import { fileURLToPath, pathToFileURL } from 'node:url'
4
3
  import { createRequire, isBuiltin } from 'node:module'
5
4
  import assert from 'node:assert/strict'
6
5
  import { packageType } from '@exodus/stasis-core/bundle-util'
7
- import { classifyExtension, classifyFormat } from '@exodus/stasis-core/util'
6
+ import { classifyExtension, classifyFormat, relativeEscapes } from '@exodus/stasis-core/util'
7
+ import { resolveTypescriptFallback } from './resolve-typescript.js'
8
+ import { diskHost } from '@exodus/stasis-core/host'
8
9
 
9
10
  // Static require/import graph walker: parses source, never loads or executes user code.
10
11
  // Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
@@ -54,10 +55,10 @@ function getFlowRemoveTypes() {
54
55
  // classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
55
56
  // returns null for the .js/.ts family whose module system comes from package.json `type` (null =
56
57
  // detect from syntax, signaled up as null).
57
- function formatForFile(file) {
58
+ function formatForFile(file, host) {
58
59
  const format = classifyFormat(file)
59
60
  if (format != null) return format
60
- const type = packageType(file)
61
+ const type = packageType(file, host)
61
62
  if (type === null) return null
62
63
  return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
63
64
  }
@@ -127,15 +128,22 @@ export class Scan {
127
128
  // `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
128
129
  // off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
129
130
  // `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
131
+ // `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
132
+ // `typescriptPaths` (a loadTsconfigPaths matcher) adding tsconfig alias support; only consulted
133
+ // on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling.
130
134
  // `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
131
135
  // are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
132
136
  // that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
133
- constructor({ conditions = [], resolve = null, jsx = false, flow = false, resources = new Set() } = {}) {
137
+ // `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
138
+ constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, resources = new Set(), host = diskHost } = {}) {
134
139
  this.extraConditions = [...conditions]
135
140
  this.customResolve = resolve
136
141
  this.jsx = jsx
137
142
  this.flow = flow
143
+ this.typescript = typescript
144
+ this.typescriptPaths = typescriptPaths
138
145
  this.resources = resources
146
+ this.host = host
139
147
  // --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
140
148
  // parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
141
149
  this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
@@ -154,7 +162,7 @@ export class Scan {
154
162
  const queue = []
155
163
  for (const entry of entries) {
156
164
  const abs = resolvePath(entry)
157
- assert.ok(existsSync(abs), `entry not found: ${abs}`)
165
+ assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
158
166
  const url = pathToFileURL(abs).toString()
159
167
  this.entries.add(url)
160
168
  queue.push(url)
@@ -183,6 +191,28 @@ export class Scan {
183
191
  this.parseErrors.push({ url, format, message, recovered })
184
192
  }
185
193
 
194
+ // --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
195
+ // resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
196
+ // mapping only completes a failed resolution, never rewrites a successful one. The rules
197
+ // (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
198
+ // mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
199
+ // legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
200
+ // (every other recorded edge is a realpath).
201
+ #typescriptResolve(parentFile, spec, conditions) {
202
+ const hit = resolveTypescriptFallback(parentFile, spec, {
203
+ conditions,
204
+ tsx: this.jsx,
205
+ paths: this.typescriptPaths,
206
+ host: this.host,
207
+ })
208
+ if (hit == null) return null
209
+ try {
210
+ return this.host.realpath(hit)
211
+ } catch {
212
+ return null
213
+ }
214
+ }
215
+
186
216
  // Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
187
217
  // missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
188
218
  // so plain-JS files never reach it. Returns the rewritten source, or null when the transform
@@ -219,8 +249,8 @@ export class Scan {
219
249
  }
220
250
 
221
251
  // declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
222
- const declared = formatForFile(file)
223
- const src = readFileSync(file, 'utf8')
252
+ const declared = formatForFile(file, this.host)
253
+ const src = this.host.readFile(file).toString('utf8')
224
254
 
225
255
  // Resolve the parser outside the try: a missing oxc-parser is an env error, not this
226
256
  // file's — it must not be swallowed into a silent zero-edge leaf.
@@ -244,8 +274,9 @@ export class Scan {
244
274
  // `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
245
275
  // file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
246
276
  // so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
247
- let format = declared
248
- ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
277
+ const tsSuffix = ext === '.ts' ? '-typescript' : ''
278
+ const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
279
+ let format = declared ?? detectedFormat(parsed)
249
280
 
250
281
  // oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
251
282
  // Warning/Advice severities aren't real parse errors.
@@ -259,7 +290,7 @@ export class Scan {
259
290
  const asModule = parser.parseSync(file, src, parseOptions('module'))
260
291
  if (nonWarning(asModule).length === 0) {
261
292
  parsed = asModule
262
- format = `module${ext === '.ts' ? '-typescript' : ''}`
293
+ format = `module${tsSuffix}`
263
294
  errors = []
264
295
  }
265
296
  } catch { /* keep the script parse and its recorded errors */ }
@@ -278,7 +309,7 @@ export class Scan {
278
309
  const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
279
310
  if (nonWarning(asFlow).length === 0) {
280
311
  parsed = asFlow
281
- format = declared ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
312
+ format = declared ?? detectedFormat(parsed)
282
313
  errors = []
283
314
  }
284
315
  } catch { /* keep the original parse + its errors */ }
@@ -298,19 +329,21 @@ export class Scan {
298
329
  }
299
330
 
300
331
  const specs = []
301
- // Type-only edges (`import type`, `export type`) are erased before runtime — skip them so the
302
- // bundle matches Node's load graph. Mixed (`{ type A, B }`) and bare side-effect imports are kept.
303
- if (parsed.module?.staticImports) {
304
- for (const imp of parsed.module.staticImports) {
305
- if (imp.entries.length > 0 && imp.entries.every((e) => e.isType)) continue
306
- specs.push({ kind: 'import', spec: imp.moduleRequest.value })
307
- }
308
- }
309
- if (parsed.module?.staticExports) {
310
- for (const exp of parsed.module.staticExports) {
311
- for (const entry of exp.entries) {
312
- if (entry.moduleRequest && !entry.isType) specs.push({ kind: 'export-from', spec: entry.moduleRequest.value })
313
- }
332
+ // STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
333
+ // (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type`
334
+ // statement is erased whole, so no edge; a statement that merely lists inline `type`
335
+ // specifiers (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`,
336
+ // `export {} from`) -- still loads its module, so its edge is real. oxc's module records
337
+ // can't draw that line (`import type { A }` and `import { type A }` yield identical
338
+ // all-isType entries, and `export {} from` yields no entry at all), so the statements are
339
+ // read off the AST, where importKind/exportKind carry it.
340
+ for (const node of parsed.program.body) {
341
+ if (node.type === 'ImportDeclaration') {
342
+ if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
343
+ } else if (node.type === 'ExportNamedDeclaration') {
344
+ if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
345
+ } else if (node.type === 'ExportAllDeclaration') {
346
+ if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
314
347
  }
315
348
  }
316
349
  findCallSpecifiers(parsed.program, (s) => specs.push(s))
@@ -320,12 +353,17 @@ export class Scan {
320
353
  // IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
321
354
  // The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
322
355
  const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
323
- const req = createRequire(file)
324
356
  const edges = []
325
357
  const specMaps = new Map() // condition key -> specifier -> child URL
326
- const record = (key, spec, childURL) => {
358
+ const addChild = (s, key, childURL, childPath) => {
359
+ edges.push({ ...s, child: childURL })
327
360
  if (!specMaps.has(key)) specMaps.set(key, new Map())
328
- specMaps.get(key).set(spec, childURL)
361
+ specMaps.get(key).set(s.spec, childURL)
362
+ if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
363
+ }
364
+ const addUnresolved = (s, reason) => {
365
+ edges.push({ ...s, error: reason })
366
+ this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason })
329
367
  }
330
368
 
331
369
  for (const s of specs) {
@@ -346,13 +384,9 @@ export class Scan {
346
384
  } else if (r?.empty) {
347
385
  edges.push({ ...s, empty: true })
348
386
  } else if (r?.url) {
349
- edges.push({ ...s, child: r.url })
350
- record(key, s.spec, r.url)
351
- const childPath = fileURLToPath(r.url)
352
- if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(r.url)
387
+ addChild(s, key, r.url, fileURLToPath(r.url))
353
388
  } else {
354
- edges.push({ ...s, error: 'MODULE_NOT_FOUND' })
355
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
389
+ addUnresolved(s, 'MODULE_NOT_FOUND')
356
390
  }
357
391
  continue
358
392
  }
@@ -360,17 +394,19 @@ export class Scan {
360
394
  edges.push({ ...s, builtin: true })
361
395
  continue
362
396
  }
397
+ let childPath
363
398
  try {
364
- const childPath = req.resolve(s.spec, { conditions })
365
- const childURL = pathToFileURL(childPath).toString()
366
- edges.push({ ...s, child: childURL })
367
- record(key, s.spec, childURL)
368
- const childExt = extname(childPath)
369
- if (this.resolvableExts.has(childExt) || this.#isResource(childPath)) queue.push(childURL)
399
+ childPath = this.host.resolve(file, s.spec, conditions)
370
400
  } catch (cause) {
371
- edges.push({ ...s, error: cause.code ?? cause.message })
372
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: cause.code ?? cause.message })
401
+ // --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
402
+ // stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
403
+ if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
404
+ if (childPath == null) {
405
+ addUnresolved(s, cause.code ?? cause.message)
406
+ continue
407
+ }
373
408
  }
409
+ addChild(s, key, pathToFileURL(childPath).toString(), childPath)
374
410
  }
375
411
 
376
412
  for (const [key, specMap] of specMaps) {
@@ -384,7 +420,7 @@ export class Scan {
384
420
  const rel = (url) => {
385
421
  const abs = fileURLToPath(url)
386
422
  const r = relative(root, abs)
387
- if (r.startsWith('..')) throw new Error(`Path outside root: ${abs}`)
423
+ if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
388
424
  return r
389
425
  }
390
426
  const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
@@ -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
+ }