@exodus/stasis 1.0.0-beta.1 → 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.
- package/README.md +32 -8
- package/bin/stasis.js +386 -213
- package/package.json +52 -12
- package/src/audit-corrections.js +96 -0
- package/src/audit.js +289 -64
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +14 -22
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +96 -128
- package/src/cmd/bundle.js +1121 -654
- 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 +7 -19
- package/src/diff.js +80 -129
- package/src/installed-commits.js +182 -0
- 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 +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +375 -402
- 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/metro-transformer.js +3 -2
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +370 -0
- package/src/resolve-node.js +419 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +120 -97
- package/src/scan.js +348 -180
- package/src/vfs-bundle/entries.js +144 -0
- package/src/vfs-bundle/github.js +202 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +647 -0
- package/src/vfs-bundle.js +80 -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
package/bin/stasis.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { parseArgs } from 'node:util'
|
|
5
4
|
import { once } from 'node:events'
|
|
6
5
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { basename, dirname, resolve } from 'node:path'
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
8
7
|
import { existsSync, realpathSync } from 'node:fs'
|
|
9
|
-
import { constants as osConstants } from 'node:os'
|
|
8
|
+
import { homedir, constants as osConstants } from 'node:os'
|
|
10
9
|
import assert from 'node:assert/strict'
|
|
10
|
+
import { parseBrotliQuality, parseLeadingOptions, parseResourcesOption } from '@exodus/stasis-core/util'
|
|
11
11
|
import pkg from '../package.json' with { type: 'json' }
|
|
12
12
|
|
|
13
13
|
const argv = [...process.argv]
|
|
@@ -15,27 +15,37 @@ assert(['node', 'node.exe'].includes(basename(argv.shift())))
|
|
|
15
15
|
assert(['node', 'node.exe'].includes(basename(process.argv0)))
|
|
16
16
|
|
|
17
17
|
const jsname = argv.shift()
|
|
18
|
-
const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b)
|
|
18
|
+
const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b)
|
|
19
19
|
assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
stasis
|
|
24
|
-
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=(path|-)] path/to/file.sol ...
|
|
25
|
-
stasis bundle [--output=(path|-)] path/to/file.
|
|
26
|
-
stasis bundle [--
|
|
27
|
-
stasis bundle --
|
|
28
|
-
stasis
|
|
29
|
-
stasis
|
|
30
|
-
(stasis bundle writes to stasis.code.br by default; --output=- streams to stdout)
|
|
31
|
-
stasis build --output=(dir|file.js) [--format=(esm|cjs|iife)] [--platform=(node|browser|neutral)] [--minify] [--sourcemap] [--define=K=V ...] [--external=pkg ...] [--loader=.ext:name ...] path/to/(stasis.code.br|stasis.lock.json) [entry]
|
|
21
|
+
const HELP = `Usage:
|
|
22
|
+
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|frozen|ignore)] [--bundle-file=path/to/bundle.br] [--resources-bundle-file=path/to/resources.br] [--dependencies] [--child-process] [--package-json] [--mock] [--import=module ...] [--fs=(sync|async)] [--resources=ext,ext] [--brotli-quality=0..11] path/to/file.js ...
|
|
23
|
+
stasis bundle [--scope=(node_modules|full)] [--conditions=cond1,cond2] [--mainFields=field1,field2] [--metro [--metro-resolver] --platforms=ios,android] [--jsx] [--flow] [--typescript [--tsconfig=path/to/tsconfig.json]] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--brotli-quality=0..11] [--add] [--output=(path|-)] path/to/file.(js|ts|jsx|tsx) ...
|
|
24
|
+
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--manifests] [--add] [--output=(path|-)] path/to/(file.sol|dir) ...
|
|
25
|
+
stasis bundle [--cargo] [--cargo-features=a,b,pkg/c] [--cargo-no-default-features] [--cargo-all-features] [--cargo-target=(triple|host)] [--cargo-manifests] [--add] [--output=(path|-)] path/to/file.rs ...
|
|
26
|
+
stasis bundle [--add] [--output=(path|-)] path/to/file.(php|sh|bash) ...
|
|
27
|
+
stasis github-bundle --github=owner/name [--sha=commit|--tag=name] [--directory=path] [--package-manager=(pnpm|yarn1|npm|soldeer) [--package-manager-version=version]] [--generate=prisma] [--lockfile=path/to/stasis.lock.json] [--output=(path|-)] [stasis bundle's options for the entries] [path/in/repo/to/(file.(js|ts|jsx|tsx)|file.sol|dir) ...]
|
|
28
|
+
stasis add path/to/(file|dir) ...
|
|
29
|
+
stasis build --output=(dir|file.js) [--format=(esm|cjs|iife)] [--platform=(node|browser|neutral|hermes)] [--babel] [--minify] [--sourcemap] [--define=K=V ...] [--external=pkg ...] [--loader=.ext:name ...] path/to/(stasis.code.br|stasis.lock.json) [entry]
|
|
32
30
|
stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
|
|
33
31
|
stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
|
|
34
32
|
stasis prune [path/to/project]
|
|
35
|
-
stasis audit path/to/
|
|
33
|
+
stasis audit [--why|--why-deep] [--why-full] [--reason=consumer] [--repo-advisories] path/to/(lockfile|bundle) ...
|
|
36
34
|
stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
stasis --version
|
|
36
|
+
|
|
37
|
+
Each command's options: https://github.com/PreventiveMeasures/stasis/blob/main/doc/<command>.md
|
|
38
|
+
(run, bundle, github-bundle, build, extract, diff, prune, audit, sbom; add is in bundle.md)`
|
|
39
|
+
|
|
40
|
+
// The help, on stderr. Exits 0 when asked for (--help), 1 when printed for want of a command.
|
|
41
|
+
function usage(asked = false) {
|
|
42
|
+
console.error(HELP)
|
|
43
|
+
process.exit(asked ? 0 : 1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A usage error: the message and where the help is, never the help itself.
|
|
47
|
+
function fail(message) {
|
|
48
|
+
console.error(`${message}\nRun 'stasis --help' for usage.`)
|
|
39
49
|
process.exit(1)
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -45,18 +55,22 @@ function setEnv(name, value) {
|
|
|
45
55
|
process.env[name] = value === undefined ? '' : value
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
// The per-user cache directory, as each platform names it (XDG ignores a relative path).
|
|
59
|
+
function userCacheDir() {
|
|
60
|
+
if (isAbsolute(process.env.XDG_CACHE_HOME ?? '')) return join(process.env.XDG_CACHE_HOME, 'stasis')
|
|
61
|
+
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'stasis')
|
|
62
|
+
if (process.platform === 'win32') return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'stasis', 'Cache')
|
|
63
|
+
return join(homedir(), '.cache', 'stasis')
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
const command = argv.shift()
|
|
49
67
|
|
|
50
68
|
if (command === '-v' || command === '--version') {
|
|
51
69
|
console.log(`v${pkg.version}`)
|
|
52
70
|
process.exit(0)
|
|
71
|
+
} else if (command === '-h' || command === '--help') {
|
|
72
|
+
usage(true)
|
|
53
73
|
} else if (command === 'run') {
|
|
54
|
-
const flags = []
|
|
55
|
-
const valueFlags = new Set(['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources'])
|
|
56
|
-
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
57
|
-
flags.push(argv.shift())
|
|
58
|
-
}
|
|
59
|
-
|
|
60
74
|
const options = {
|
|
61
75
|
lock: { type: 'string', default: 'none' },
|
|
62
76
|
bundle: { type: 'string', default: 'none' },
|
|
@@ -65,49 +79,65 @@ if (command === '-v' || command === '--version') {
|
|
|
65
79
|
debug: { type: 'boolean' },
|
|
66
80
|
dependencies: { type: 'boolean' },
|
|
67
81
|
'child-process': { type: 'boolean' },
|
|
82
|
+
'package-json': { type: 'boolean' },
|
|
68
83
|
mock: { type: 'boolean' },
|
|
84
|
+
import: { type: 'string', multiple: true },
|
|
69
85
|
fs: { type: 'string' },
|
|
70
86
|
resources: { type: 'string' },
|
|
87
|
+
'brotli-quality': { type: 'string' },
|
|
71
88
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
if (argv.length === 0) usage('Nothing to run: no path to file given')
|
|
80
|
-
if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
|
|
89
|
+
const values = parseLeadingOptions(argv, options, {
|
|
90
|
+
valueFlags: ['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--import', '--resources', '--brotli-quality'],
|
|
91
|
+
onError: fail,
|
|
92
|
+
})
|
|
93
|
+
if (argv.length === 0) fail('Nothing to run: no path to file given')
|
|
94
|
+
if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) fail('Error: invalid --lock value')
|
|
81
95
|
const lock = values.lock
|
|
82
96
|
const scope = values.dependencies ? 'node_modules' : 'full'
|
|
83
97
|
const bundle = values.bundle
|
|
84
98
|
const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
|
|
85
99
|
const resourcesBundleFile = values['resources-bundle-file'] ? resolve(values['resources-bundle-file']) : ''
|
|
86
100
|
const debug = values.debug ? '1' : ''
|
|
87
|
-
if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle))
|
|
88
|
-
if (bundleFile && bundle === 'none')
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
if (bundle === 'load'
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// nothing to record into or read from without one of those bundle modes. The mode
|
|
99
|
-
// argument is required: `sync` patches the sync readers, `async` adds their async
|
|
100
|
-
// (callback + fs.promises) counterparts on top.
|
|
101
|
-
if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) usage("Error: --fs must be 'sync' or 'async'")
|
|
102
|
-
if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) usage('Error: --fs requires --bundle=(add|replace|load)')
|
|
101
|
+
if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) fail('Error: invalid --bundle value')
|
|
102
|
+
if (bundleFile && bundle === 'none') fail('Error: --bundle-file requires --bundle=(add|replace|load|frozen|ignore)')
|
|
103
|
+
// --resources-bundle-file needs an active bundle mode (unlike --bundle-file, inert under ignore).
|
|
104
|
+
if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) fail('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
|
|
105
|
+
if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') fail('Error: --bundle=load is incompatible with --lock=(add|replace)')
|
|
106
|
+
if (lock === 'none' && bundle === 'none') fail('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
|
|
107
|
+
if (values.mock && bundle === 'load') fail('Error: --mock is for capturing imports while building a bundle; not compatible with --bundle=load')
|
|
108
|
+
// --fs requires a bundle mode (add|replace|load); `sync` patches the sync fs readers,
|
|
109
|
+
// `async` adds their callback + fs.promises counterparts on top.
|
|
110
|
+
if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) fail("Error: --fs must be 'sync' or 'async'")
|
|
111
|
+
if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) fail('Error: --fs requires --bundle=(add|replace|load)')
|
|
103
112
|
const captureFs = values.fs ?? ''
|
|
104
|
-
// --resources: comma-separated extension/filename allowlist for `--fs`
|
|
105
|
-
// (e.g. png,svg,LICENSE). The child's Config validates each entry via parseResourcesOption.
|
|
113
|
+
// --resources: comma-separated extension/filename allowlist for `--fs` captures.
|
|
106
114
|
const resources = values.resources ?? ''
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
let brotliQuality
|
|
116
|
+
if (values['brotli-quality'] !== undefined) {
|
|
117
|
+
try {
|
|
118
|
+
brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
fail(`Error: ${cause.message}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// --import: extra preload module(s) passed through to the spawned node. Node resolves each
|
|
124
|
+
// against the project cwd; they ride AFTER stasis's own loader import.
|
|
125
|
+
const imports = values.import ?? []
|
|
126
|
+
if (imports.some((s) => s === '')) fail('Error: --import requires a module specifier (e.g. --import=./instrument.mjs)')
|
|
127
|
+
// Not a usage error: plain instrumentation/setup preloads still work under the mock; only ones
|
|
128
|
+
// that need the denied surfaces (spawning helper processes, network) die, and they do so loudly.
|
|
129
|
+
if (values.mock && imports.length > 0) {
|
|
130
|
+
console.warn("[stasis] Warning: --import preloads run under --mock's side-effect denials; a preload that spawns helper processes will fail there")
|
|
131
|
+
}
|
|
132
|
+
// --child-process: forward forked-child (e.g. Metro worker) capture to the root via per-pid shards.
|
|
109
133
|
const childProcess = values['child-process'] ? '1' : ''
|
|
110
|
-
|
|
134
|
+
// --package-json: auto-include every bundled module's package.json. Only meaningful while WRITING
|
|
135
|
+
// a bundle (add|replace) -- there's nothing to fold the manifests into otherwise.
|
|
136
|
+
if (values['package-json'] && bundle !== 'add' && bundle !== 'replace') {
|
|
137
|
+
fail('Error: --package-json requires --bundle=(add|replace)')
|
|
138
|
+
}
|
|
139
|
+
const packageJSON = values['package-json'] ? '1' : ''
|
|
140
|
+
console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }), ...(resourcesBundleFile && { resourcesBundleFile }), ...(childProcess && { childProcess: true }), ...(packageJSON && { packageJSON: true }), ...(values.mock && { mock: true }), ...(imports.length > 0 && { import: imports }), ...(values.fs && { fs: values.fs }), ...(resources && { resources }), ...(brotliQuality !== undefined && { brotliQuality }) })
|
|
111
141
|
if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
|
|
112
142
|
setEnv('EXODUS_STASIS_LOCK', lock)
|
|
113
143
|
setEnv('EXODUS_STASIS_SCOPE', scope)
|
|
@@ -116,211 +146,371 @@ if (command === '-v' || command === '--version') {
|
|
|
116
146
|
setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
|
|
117
147
|
setEnv('EXODUS_STASIS_DEBUG', debug)
|
|
118
148
|
setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
|
|
149
|
+
setEnv('EXODUS_STASIS_PACKAGE_JSON', packageJSON)
|
|
119
150
|
setEnv('EXODUS_STASIS_FS', captureFs)
|
|
120
151
|
setEnv('EXODUS_STASIS_RESOURCES', resources)
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// hooks lib first -- snapshotting stasis's real fs bindings -- then src/mock.js
|
|
128
|
-
// (whose syncBuiltinESMExports() refresh reaches user-code ESM imports), then
|
|
129
|
-
// installs the hooks. So the mock runs after stasis's snapshots and before
|
|
130
|
-
// registration, by static import order and with no env var. Reads stay open
|
|
131
|
-
// (--allow-fs-read=*) so node_modules resolution works; reads aren't a side
|
|
132
|
-
// effect. Writes are still scoped at the kernel level as defense in depth (the
|
|
133
|
-
// JS layer covers user code, --permission covers anything that bypasses JS,
|
|
134
|
-
// e.g. process.binding or a future leak).
|
|
152
|
+
// Only set when given; an unconditional setEnv('') would conflict with an ambient value.
|
|
153
|
+
if (brotliQuality !== undefined) setEnv('EXODUS_STASIS_BROTLI_QUALITY', String(brotliQuality))
|
|
154
|
+
// --mock: capture imports by running user code with side-effects denied (fail-closed).
|
|
155
|
+
// --permission blocks fs writes/child procs/workers/addons/inspector at the kernel level;
|
|
156
|
+
// src/mock.js neutralizes network + timers in JS (no --allow-* for those). Reads stay open
|
|
157
|
+
// so node_modules resolution works.
|
|
135
158
|
// Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
|
|
136
159
|
const nodeArgs = []
|
|
137
160
|
if (values.mock) {
|
|
138
161
|
const writeAllow = [process.cwd()]
|
|
139
|
-
// --allow-fs-write paths must already exist on disk
|
|
140
|
-
//
|
|
141
|
-
// bundle target (code and/or split resources), walk up to the nearest existing ancestor and
|
|
142
|
-
// grant write there -- broader than ideal, but the user explicitly chose these locations.
|
|
143
|
-
// (Note: the cwd check uses '/' as a separator and is therefore unix-only; that matches the
|
|
144
|
-
// rest of the project's path handling.)
|
|
162
|
+
// --allow-fs-write paths must already exist on disk, so for each out-of-cwd bundle target
|
|
163
|
+
// walk up to the nearest existing ancestor and grant write there.
|
|
145
164
|
for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
|
|
146
165
|
if (!file || file.startsWith(`${process.cwd()}/`)) continue
|
|
147
166
|
let p = dirname(file)
|
|
148
167
|
while (!existsSync(p) && dirname(p) !== p) p = dirname(p)
|
|
149
|
-
// Refuse
|
|
150
|
-
// --permission layer everywhere, and reaching it means none of the path's ancestors exist --
|
|
151
|
-
// the user almost certainly didn't intend this. Ask them to create a parent.
|
|
168
|
+
// Refuse write access to the filesystem root -- it would disable the --permission layer everywhere.
|
|
152
169
|
if (p === dirname(p)) {
|
|
153
|
-
|
|
170
|
+
fail(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
|
|
154
171
|
}
|
|
155
|
-
if (!writeAllow.includes(p)) writeAllow.push(p)
|
|
172
|
+
if (!writeAllow.includes(p)) writeAllow.push(p)
|
|
156
173
|
}
|
|
157
174
|
nodeArgs.push('--permission', '--allow-fs-read=*')
|
|
158
175
|
for (const p of writeAllow) nodeArgs.push(`--allow-fs-write=${p}`)
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// resolve and make the captured import map incompatible with a normal
|
|
162
|
-
// replay. Restore the condition so resolution matches a non-mock run; this
|
|
163
|
-
// doesn't allow native addons to actually load (--allow-addons still off).
|
|
176
|
+
// Restore the node-addons resolution condition (dropped by --permission) so the captured
|
|
177
|
+
// import map matches a non-mock run; native addons still can't load (--allow-addons off).
|
|
164
178
|
nodeArgs.push('--conditions=node-addons')
|
|
165
179
|
}
|
|
166
|
-
//
|
|
167
|
-
// entry, which composes the mock into the same hooks lib (see comment above).
|
|
180
|
+
// --mock uses stasis's loader-mock entry (composes the mock before installing the hooks).
|
|
168
181
|
const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
|
|
169
182
|
nodeArgs.push('--import', import.meta.resolve(loaderEntry))
|
|
183
|
+
// Passthrough preloads ride after the loader (and after --mock's denials), so they evaluate
|
|
184
|
+
// under its hooks; their module graphs pass through uncaptured unless the app graph reaches
|
|
185
|
+
// them -- then they're promoted into the capture (see core hooks.js).
|
|
186
|
+
for (const specifier of imports) nodeArgs.push('--import', specifier)
|
|
170
187
|
const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
|
|
171
188
|
const [code, signal] = await once(child, 'close')
|
|
172
189
|
// code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0
|
|
173
190
|
process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0)
|
|
174
191
|
} else if (command === 'bundle') {
|
|
175
|
-
const flags = []
|
|
176
|
-
const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o'])
|
|
177
|
-
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
178
|
-
flags.push(argv.shift())
|
|
179
|
-
}
|
|
180
192
|
const options = {
|
|
181
193
|
mapping: { type: 'string' },
|
|
194
|
+
manifests: { type: 'boolean' },
|
|
182
195
|
output: { type: 'string', short: 'o' },
|
|
183
196
|
scope: { type: 'string' },
|
|
184
197
|
lockfile: { type: 'string' },
|
|
185
198
|
conditions: { type: 'string' },
|
|
186
199
|
mainFields: { type: 'string' },
|
|
187
200
|
metro: { type: 'boolean' },
|
|
201
|
+
'metro-resolver': { type: 'boolean' },
|
|
188
202
|
platforms: { type: 'string', multiple: true },
|
|
203
|
+
jsx: { type: 'boolean' },
|
|
204
|
+
flow: { type: 'boolean' },
|
|
205
|
+
typescript: { type: 'boolean' },
|
|
206
|
+
tsconfig: { type: 'string' },
|
|
207
|
+
resources: { type: 'string' },
|
|
208
|
+
'package-json': { type: 'boolean' },
|
|
209
|
+
cargo: { type: 'boolean' },
|
|
210
|
+
'cargo-features': { type: 'string', multiple: true },
|
|
211
|
+
'cargo-no-default-features': { type: 'boolean' },
|
|
212
|
+
'cargo-all-features': { type: 'boolean' },
|
|
213
|
+
'cargo-target': { type: 'string' },
|
|
214
|
+
'cargo-manifests': { type: 'boolean' },
|
|
215
|
+
'brotli-quality': { type: 'string' },
|
|
216
|
+
add: { type: 'boolean' },
|
|
189
217
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
218
|
+
const values = parseLeadingOptions(argv, options, {
|
|
219
|
+
valueFlags: ['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '--resources', '--tsconfig', '--cargo-features', '--cargo-target', '--brotli-quality', '-o'],
|
|
220
|
+
onError: fail,
|
|
221
|
+
})
|
|
222
|
+
if (argv.length === 0) fail('Nothing to bundle: no entry file given')
|
|
223
|
+
// A directory entry stands for the .sol files under it (Solidity only); an extensionless path
|
|
224
|
+
// that doesn't exist is a missing one (skipped with a warning).
|
|
225
|
+
const { directoryEntryError, isSolidityEntry } = await import('../src/cmd/bundle.js')
|
|
226
|
+
const dirError = directoryEntryError(argv)
|
|
227
|
+
if (dirError !== null) fail(`Error: ${dirError}`)
|
|
228
|
+
const allSol = argv.every((f) => isSolidityEntry(f))
|
|
198
229
|
const allPhp = argv.every((f) => f.endsWith('.php'))
|
|
199
|
-
const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f))
|
|
230
|
+
const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts|jsx|tsx)$/u.test(f))
|
|
200
231
|
const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f))
|
|
201
232
|
const allRust = argv.every((f) => f.endsWith('.rs'))
|
|
202
233
|
if (!allSol && !allPhp && !allJs && !allBash && !allRust) {
|
|
203
|
-
|
|
234
|
+
fail('Error: bundle entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx, all be .sh/.bash, or all be .rs')
|
|
204
235
|
}
|
|
205
|
-
if (values.mapping && !allSol)
|
|
206
|
-
|
|
236
|
+
if (values.mapping && !allSol) fail('Error: --mapping is only valid for .sol bundles')
|
|
237
|
+
// --manifests: carry the Solidity build's description files (foundry.toml, remappings.txt, ...).
|
|
238
|
+
const manifests = Boolean(values.manifests)
|
|
239
|
+
if (manifests && !allSol) fail('Error: --manifests is only valid for .sol bundles')
|
|
240
|
+
// --cargo: take the Rust feature/dependency resolution from `cargo metadata` (runs cargo; opt-in).
|
|
241
|
+
const cargo = Boolean(values.cargo)
|
|
242
|
+
if (cargo && !allRust) fail('Error: --cargo is only valid for Rust bundles')
|
|
243
|
+
// --cargo-features / --cargo-no-default-features / --cargo-all-features: cargo's own feature flags
|
|
244
|
+
// for the entries' packages (`pkg/feat` targets one, or a dependency). --cargo-features is
|
|
245
|
+
// repeatable and/or comma-separated; parseFeatureList is the one splitter (the resolver reuses it).
|
|
246
|
+
const { parseFeatureList } = await import('../src/loaders/cargo.js')
|
|
247
|
+
const cargoFeatures = parseFeatureList(values['cargo-features'] ?? [])
|
|
248
|
+
if (values['cargo-features'] !== undefined && cargoFeatures.length === 0) {
|
|
249
|
+
fail('Error: --cargo-features must list at least one feature (e.g. --cargo-features=serde,app/tls)')
|
|
250
|
+
}
|
|
251
|
+
const cargoNoDefaultFeatures = Boolean(values['cargo-no-default-features'])
|
|
252
|
+
const cargoAllFeatures = Boolean(values['cargo-all-features'])
|
|
253
|
+
// --cargo-target=<triple|host>: the build's target, whose cfgs (from `rustc --print cfg`) decide
|
|
254
|
+
// `#[cfg(unix)]`-style code. A triple is letters, digits, `-`, `_` and `.` (`x86_64-unknown-linux-gnu`).
|
|
255
|
+
const cargoTarget = values['cargo-target'] ?? null
|
|
256
|
+
if (cargoTarget !== null && !/^[\w.-]+$/u.test(cargoTarget)) {
|
|
257
|
+
fail('Error: --cargo-target must be a target triple or "host" (e.g. --cargo-target=aarch64-apple-darwin)')
|
|
258
|
+
}
|
|
259
|
+
// --cargo-manifests: carry each bundled package's Cargo.toml and build script, the workspace
|
|
260
|
+
// Cargo.toml, Cargo.lock and .cargo/config.toml too (the Rust counterpart of --package-json).
|
|
261
|
+
const cargoManifests = Boolean(values['cargo-manifests'])
|
|
262
|
+
if (!allRust) {
|
|
263
|
+
const given = { 'cargo-features': cargoFeatures.length > 0, 'cargo-no-default-features': cargoNoDefaultFeatures, 'cargo-all-features': cargoAllFeatures, 'cargo-target': cargoTarget !== null, 'cargo-manifests': cargoManifests }
|
|
264
|
+
for (const [flag, on] of Object.entries(given)) if (on) fail(`Error: --${flag} is only valid for Rust bundles`)
|
|
265
|
+
}
|
|
266
|
+
if (values.scope && !allJs) fail('Error: --scope is only valid for JS bundles')
|
|
207
267
|
if (values.scope && !['node_modules', 'full'].includes(values.scope)) {
|
|
208
|
-
|
|
268
|
+
fail('Error: --scope must be node_modules or full')
|
|
209
269
|
}
|
|
210
|
-
if (values.lockfile && !allJs)
|
|
211
|
-
// --
|
|
212
|
-
// emits a full-scope bundle, so the two together would silently ignore --scope.
|
|
270
|
+
if (values.lockfile && !allJs) fail('Error: --lockfile is only valid for JS bundles')
|
|
271
|
+
// --mainFields/--metro always emit a full-scope bundle, so they'd silently ignore --scope.
|
|
213
272
|
if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
|
|
214
|
-
|
|
273
|
+
fail('Error: --scope is not supported with --mainFields or --metro')
|
|
215
274
|
}
|
|
216
|
-
// --conditions:
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
// follows. NOTE: conditions alone do not honour legacy package `mainFields` or
|
|
220
|
-
// platform-specific file suffixes -- see --mainFields.
|
|
221
|
-
if (values.conditions !== undefined && !allJs) usage('Error: --conditions is only valid for JS bundles')
|
|
275
|
+
// --conditions: extra exports/imports resolution conditions merged onto Node's defaults;
|
|
276
|
+
// they don't honour legacy `mainFields` or platform suffixes (see --mainFields).
|
|
277
|
+
if (values.conditions !== undefined && !allJs) fail('Error: --conditions is only valid for JS bundles')
|
|
222
278
|
const conditions = values.conditions === undefined
|
|
223
279
|
? []
|
|
224
280
|
: values.conditions.split(',').map((s) => s.trim()).filter(Boolean)
|
|
225
281
|
if (values.conditions !== undefined && conditions.length === 0) {
|
|
226
|
-
|
|
282
|
+
fail('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
|
|
227
283
|
}
|
|
228
|
-
// --
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
284
|
+
// --flow: strip Flow type syntax (via the optional flow-remove-types dep) before parsing; JS-only.
|
|
285
|
+
if (values.flow && !allJs) fail('Error: --flow is only valid for JS bundles')
|
|
286
|
+
const flow = Boolean(values.flow)
|
|
287
|
+
// --typescript: resolve like tsc -- a specifier naming a missing JS output (./x.js, .mjs, .cjs,
|
|
288
|
+
// extensionless) lands on its on-disk TS source (./x.ts, .mts, .cts); an existing .js always wins.
|
|
289
|
+
if (values.typescript && !allJs) fail('Error: --typescript is only valid for JS bundles')
|
|
290
|
+
const typescript = Boolean(values.typescript)
|
|
291
|
+
// --tsconfig: the config whose compilerOptions.paths aliases --typescript honours (default: the
|
|
292
|
+
// tsconfig.json beside each importing file's package.json). Meaningless without --typescript.
|
|
293
|
+
if (values.tsconfig !== undefined && !typescript) fail('Error: --tsconfig is only valid with --typescript')
|
|
294
|
+
// --mainFields: legacy package entry fields (e.g. react-native,browser,main) for the non-exports resolver.
|
|
295
|
+
if (values.mainFields !== undefined && !allJs) fail('Error: --mainFields is only valid for JS bundles')
|
|
232
296
|
const mainFields = values.mainFields === undefined
|
|
233
297
|
? undefined
|
|
234
298
|
: values.mainFields.split(',').map((s) => s.trim()).filter(Boolean)
|
|
235
299
|
if (values.mainFields !== undefined && mainFields.length === 0) {
|
|
236
|
-
|
|
300
|
+
fail('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
|
|
237
301
|
}
|
|
238
|
-
// --metro: resolve like Metro/
|
|
239
|
-
//
|
|
240
|
-
// (.ios/.android/.native) for each --platforms target, bundling all of them at once.
|
|
241
|
-
// --platforms is repeatable and/or comma-separated; the values union.
|
|
302
|
+
// --metro: resolve like Metro/RN (its own conditions + mainFields, platform suffixes per
|
|
303
|
+
// --platforms target). --platforms is repeatable and/or comma-separated; the values union.
|
|
242
304
|
const metro = Boolean(values.metro)
|
|
305
|
+
// --metro-resolver: resolve through the project's own metro-resolver instead of the built-in
|
|
306
|
+
// approximation. Only meaningful alongside --metro (enforced below and in classifyEntries).
|
|
307
|
+
const metroResolver = Boolean(values['metro-resolver'])
|
|
243
308
|
const platforms = [...new Set((values.platforms ?? []).flatMap((p) => p.split(',')).map((s) => s.trim()).filter(Boolean))]
|
|
244
|
-
// A platform name becomes
|
|
245
|
-
// contains '/'; '*' is the reserved private placeholder. Reject both here so we never
|
|
246
|
-
// emit a bundle the readers can't parse (the divergent-edge case is data-dependent).
|
|
309
|
+
// A platform name becomes an edge key: reject '/' (parsers reject it) and '*' (reserved placeholder).
|
|
247
310
|
for (const p of platforms) {
|
|
248
|
-
if (p === '*' || p.includes('/'))
|
|
311
|
+
if (p === '*' || p.includes('/')) fail(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
|
|
312
|
+
}
|
|
313
|
+
if ((metro || platforms.length > 0) && !allJs) fail('Error: --metro is only valid for JS bundles')
|
|
314
|
+
// --jsx: parse React Native's JSX-in-.js source in the static scanner (off by default; oxc,
|
|
315
|
+
// like tsc, auto-enables JSX for .jsx/.tsx only). Orthogonal to the resolver, so it pairs with
|
|
316
|
+
// plain/--mainFields/--metro alike -- JS-only, since no other entry language is scanned.
|
|
317
|
+
const jsx = Boolean(values.jsx)
|
|
318
|
+
if (jsx && !allJs) fail('Error: --jsx is only valid for JS bundles')
|
|
319
|
+
// --package-json: fold each bundled module's package.json into the bundle. JS-only (only JS
|
|
320
|
+
// modules carry an npm package.json; the sol/php/bash/rust bucketizers have no such manifest).
|
|
321
|
+
const packageJSON = Boolean(values['package-json'])
|
|
322
|
+
if (packageJSON && !allJs) fail('Error: --package-json is only valid for JS bundles')
|
|
323
|
+
// --resources: comma-separated extension/filename allowlist for assets reached through the graph
|
|
324
|
+
// (e.g. --resources=png,svg). They're carried as resources instead of failing "can't carry" --
|
|
325
|
+
// for graphs that aren't fully loadable in JS (Metro consumes such assets). JS-only.
|
|
326
|
+
if (values.resources !== undefined && !allJs) fail('Error: --resources is only valid for JS bundles')
|
|
327
|
+
const resources = values.resources === undefined
|
|
328
|
+
? []
|
|
329
|
+
: values.resources.split(',').map((s) => s.trim()).filter(Boolean)
|
|
330
|
+
if (values.resources !== undefined && resources.length === 0) {
|
|
331
|
+
fail('Error: --resources must list at least one extension or filename (e.g. --resources=png,svg)')
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
parseResourcesOption('--resources', resources) // validate early for a clean usage error (code exts / bad chars rejected)
|
|
335
|
+
} catch (cause) {
|
|
336
|
+
fail(`Error: ${cause.message}`)
|
|
249
337
|
}
|
|
250
|
-
if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
|
|
251
338
|
if (metro) {
|
|
252
|
-
if (conditions.length > 0)
|
|
253
|
-
if (mainFields !== undefined)
|
|
254
|
-
if (platforms.length === 0)
|
|
339
|
+
if (conditions.length > 0) fail("Error: --conditions can't be combined with --metro (it sets its own conditions)")
|
|
340
|
+
if (mainFields !== undefined) fail("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
|
|
341
|
+
if (platforms.length === 0) fail('Error: --metro requires --platforms (e.g. --platforms=ios,android)')
|
|
255
342
|
} else if (platforms.length > 0) {
|
|
256
|
-
|
|
343
|
+
fail('Error: --platforms is only valid with --metro')
|
|
257
344
|
}
|
|
345
|
+
if (metroResolver && !metro) fail('Error: --metro-resolver is only valid with --metro')
|
|
346
|
+
// metro-resolver can't substitute .js -> .ts, so --typescript would silently not apply -- reject it.
|
|
347
|
+
if (typescript && metroResolver) fail("Error: --typescript is not supported with --metro-resolver (the project's metro-resolver doesn't substitute .js -> .ts)")
|
|
348
|
+
// --brotli-quality: valid for every entry language (no allJs gate).
|
|
349
|
+
let brotliQuality
|
|
350
|
+
if (values['brotli-quality'] !== undefined) {
|
|
351
|
+
try {
|
|
352
|
+
brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
fail(`Error: ${cause.message}`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// --add merges into the existing bundle at --output, so it can't target write-only stdout.
|
|
358
|
+
const add = Boolean(values.add)
|
|
359
|
+
if (add && values.output === '-') fail('Error: --add cannot be combined with --output=-')
|
|
258
360
|
const { bundleCommand } = await import('../src/cmd/bundle.js')
|
|
259
361
|
await bundleCommand({
|
|
260
362
|
cwd: process.cwd(),
|
|
261
363
|
entries: argv,
|
|
262
364
|
mappingFile: values.mapping,
|
|
365
|
+
manifests,
|
|
263
366
|
output: values.output,
|
|
264
367
|
scope: values.scope,
|
|
265
368
|
lockfile: values.lockfile,
|
|
266
369
|
conditions,
|
|
267
370
|
mainFields,
|
|
268
371
|
metro,
|
|
372
|
+
metroResolver,
|
|
269
373
|
platforms,
|
|
374
|
+
jsx,
|
|
375
|
+
flow,
|
|
376
|
+
typescript,
|
|
377
|
+
tsconfig: values.tsconfig,
|
|
378
|
+
resources,
|
|
379
|
+
packageJSON,
|
|
380
|
+
cargo,
|
|
381
|
+
cargoFeatures,
|
|
382
|
+
cargoNoDefaultFeatures,
|
|
383
|
+
cargoAllFeatures,
|
|
384
|
+
cargoTarget,
|
|
385
|
+
cargoManifests,
|
|
386
|
+
brotliQuality,
|
|
387
|
+
add,
|
|
270
388
|
})
|
|
271
|
-
} else if (command === '
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
389
|
+
} else if (command === 'github-bundle') {
|
|
390
|
+
const values = parseLeadingOptions(argv, {
|
|
391
|
+
github: { type: 'string' },
|
|
392
|
+
sha: { type: 'string' },
|
|
393
|
+
tag: { type: 'string' },
|
|
394
|
+
directory: { type: 'string' },
|
|
395
|
+
'package-manager': { type: 'string' },
|
|
396
|
+
'package-manager-version': { type: 'string' },
|
|
397
|
+
mapping: { type: 'string' },
|
|
398
|
+
manifests: { type: 'boolean' },
|
|
399
|
+
output: { type: 'string', short: 'o' },
|
|
400
|
+
scope: { type: 'string' },
|
|
401
|
+
lockfile: { type: 'string' },
|
|
402
|
+
conditions: { type: 'string' },
|
|
403
|
+
mainFields: { type: 'string' },
|
|
404
|
+
metro: { type: 'boolean' },
|
|
405
|
+
platforms: { type: 'string', multiple: true },
|
|
406
|
+
jsx: { type: 'boolean' },
|
|
407
|
+
flow: { type: 'boolean' },
|
|
408
|
+
typescript: { type: 'boolean' },
|
|
409
|
+
tsconfig: { type: 'string' },
|
|
410
|
+
resources: { type: 'string' },
|
|
411
|
+
'package-json': { type: 'boolean' },
|
|
412
|
+
'brotli-quality': { type: 'string' },
|
|
413
|
+
generate: { type: 'string', multiple: true },
|
|
414
|
+
}, {
|
|
415
|
+
valueFlags: ['--github', '--sha', '--tag', '--directory', '--package-manager', '--package-manager-version', '--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '--tsconfig', '--resources', '--brotli-quality', '--generate', '-o'],
|
|
416
|
+
onError: fail,
|
|
417
|
+
})
|
|
418
|
+
if (values.github === undefined) fail('Error: github-bundle requires --github=owner/name, the repo to bundle')
|
|
419
|
+
if (values.sha !== undefined && values.tag !== undefined) fail('Error: github-bundle takes --sha or --tag, not both')
|
|
420
|
+
// Whether each option applies to the entries is buildGitHubBundle's to say.
|
|
421
|
+
const list = (value) => [value ?? []].flat().flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean)
|
|
422
|
+
let brotliQuality
|
|
423
|
+
if (values['brotli-quality'] !== undefined) {
|
|
424
|
+
try {
|
|
425
|
+
brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
|
|
426
|
+
} catch (cause) {
|
|
427
|
+
fail(`Error: ${cause.message}`)
|
|
428
|
+
}
|
|
276
429
|
}
|
|
430
|
+
// Tarballs, zips and GitHub trees are cached where `stasis audit` caches.
|
|
431
|
+
const { setCacheDir } = await import('../src/vfs-bundle.js')
|
|
432
|
+
setCacheDir(userCacheDir())
|
|
433
|
+
const { githubBundleCommand } = await import('../src/cmd/github-bundle.js')
|
|
434
|
+
await githubBundleCommand({
|
|
435
|
+
github: values.github,
|
|
436
|
+
sha: values.sha,
|
|
437
|
+
tag: values.tag,
|
|
438
|
+
directory: values.directory,
|
|
439
|
+
packageManager: values['package-manager'],
|
|
440
|
+
packageManagerVersion: values['package-manager-version'],
|
|
441
|
+
entries: argv.length === 0 ? undefined : argv,
|
|
442
|
+
mappingFile: values.mapping,
|
|
443
|
+
manifests: Boolean(values.manifests),
|
|
444
|
+
output: values.output,
|
|
445
|
+
lockfile: values.lockfile,
|
|
446
|
+
scope: values.scope,
|
|
447
|
+
conditions: list(values.conditions),
|
|
448
|
+
mainFields: values.mainFields === undefined ? undefined : list(values.mainFields),
|
|
449
|
+
metro: Boolean(values.metro),
|
|
450
|
+
platforms: list(values.platforms),
|
|
451
|
+
jsx: Boolean(values.jsx),
|
|
452
|
+
flow: Boolean(values.flow),
|
|
453
|
+
typescript: Boolean(values.typescript),
|
|
454
|
+
tsconfig: values.tsconfig,
|
|
455
|
+
resources: list(values.resources),
|
|
456
|
+
packageJSON: Boolean(values['package-json']),
|
|
457
|
+
generate: list(values.generate),
|
|
458
|
+
brotliQuality,
|
|
459
|
+
})
|
|
460
|
+
} else if (command === 'add') {
|
|
461
|
+
// add packs the listed files (no resolver), taking split targets + resource allowlist from
|
|
462
|
+
// stasis.config.json; it takes no flags.
|
|
463
|
+
if (argv.length === 0) fail('Nothing to add: no file given')
|
|
464
|
+
if (argv.some((a) => a.startsWith('-'))) fail('Error: add takes no options; its targets and resource allowlist come from stasis.config.json')
|
|
465
|
+
const { addCommand } = await import('@exodus/stasis-core/add')
|
|
466
|
+
// Let addCommand's runtime errors propagate as-is; wrapping in fail() would bury the cause.
|
|
467
|
+
addCommand({ cwd: process.cwd(), entries: argv, logLabel: 'stasis' })
|
|
468
|
+
} else if (command === 'build') {
|
|
277
469
|
const options = {
|
|
278
470
|
output: { type: 'string', short: 'o' },
|
|
279
471
|
format: { type: 'string' },
|
|
280
472
|
platform: { type: 'string' },
|
|
473
|
+
babel: { type: 'boolean' },
|
|
281
474
|
minify: { type: 'boolean' },
|
|
282
475
|
sourcemap: { type: 'boolean' },
|
|
283
|
-
// Repeatable esbuild passthroughs
|
|
284
|
-
// --external=PATTERN (one external each), and --loader=.ext:name (one extension
|
|
285
|
-
// loader override each). Each collects into an array.
|
|
476
|
+
// Repeatable esbuild passthroughs (--define=K=V, --external=PATTERN, --loader=.ext:name).
|
|
286
477
|
define: { type: 'string', multiple: true },
|
|
287
478
|
external: { type: 'string', multiple: true },
|
|
288
479
|
loader: { type: 'string', multiple: true },
|
|
289
480
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (argv.length === 0) usage('Nothing to build: no bundle or lockfile given')
|
|
297
|
-
if (argv.length > 2) usage('Error: build takes a bundle or lockfile and an optional entry point')
|
|
481
|
+
const values = parseLeadingOptions(argv, options, {
|
|
482
|
+
valueFlags: ['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'],
|
|
483
|
+
onError: fail,
|
|
484
|
+
})
|
|
485
|
+
if (argv.length === 0) fail('Nothing to build: no bundle or lockfile given')
|
|
486
|
+
if (argv.length > 2) fail('Error: build takes a bundle or lockfile and an optional entry point')
|
|
298
487
|
// esbuild output knobs; the import graph itself is fixed by the artifact, not these.
|
|
299
488
|
if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
|
|
300
|
-
|
|
489
|
+
fail('Error: --format must be esm, cjs, or iife')
|
|
301
490
|
}
|
|
302
|
-
if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
|
|
303
|
-
|
|
491
|
+
if (values.platform !== undefined && !['node', 'browser', 'neutral', 'hermes'].includes(values.platform)) {
|
|
492
|
+
fail('Error: --platform must be node, browser, neutral, or hermes')
|
|
304
493
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
494
|
+
// Hermes has no import/export; --format defaults to iife under --platform=hermes (see buildCommand).
|
|
495
|
+
if (values.platform === 'hermes' && values.format === 'esm') {
|
|
496
|
+
fail('Error: --platform=hermes cannot emit ESM; use --format=iife (the hermes default) or cjs')
|
|
497
|
+
}
|
|
498
|
+
if (!values.output) fail('Error: --output is required (a .js/.cjs/.mjs file, or a directory)')
|
|
499
|
+
// build can't stream to stdout; without this, --output=- would create a dir literally named `-`.
|
|
500
|
+
if (values.output === '-') fail('Error: stasis build cannot stream to stdout; --output must be a .js/.cjs/.mjs file or a directory')
|
|
501
|
+
// --define=KEY=VALUE -> esbuild define map; VALUE is forwarded verbatim and must be valid JS
|
|
502
|
+
// (a JSON literal or an identifier), e.g. --define=DEBUG=false.
|
|
312
503
|
const define = {}
|
|
313
504
|
for (const entry of values.define ?? []) {
|
|
314
505
|
const eq = entry.indexOf('=')
|
|
315
|
-
if (eq <= 0)
|
|
506
|
+
if (eq <= 0) fail(`Error: --define must be KEY=VALUE (got '${entry}')`)
|
|
316
507
|
define[entry.slice(0, eq)] = entry.slice(eq + 1)
|
|
317
508
|
}
|
|
318
|
-
// --loader=.ext:NAME -> esbuild loader override for that extension
|
|
319
|
-
// --loader=.js:jsx so a .js file carrying JSX (the React Native convention) parses.
|
|
509
|
+
// --loader=.ext:NAME -> esbuild loader override for that extension (e.g. --loader=.js:jsx).
|
|
320
510
|
const loader = {}
|
|
321
511
|
for (const entry of values.loader ?? []) {
|
|
322
512
|
const colon = entry.indexOf(':')
|
|
323
|
-
if (colon <= 0 || !entry.startsWith('.'))
|
|
513
|
+
if (colon <= 0 || !entry.startsWith('.')) fail(`Error: --loader must be .EXT:LOADER (got '${entry}')`)
|
|
324
514
|
loader[entry.slice(0, colon)] = entry.slice(colon + 1)
|
|
325
515
|
}
|
|
326
516
|
const { buildCommand } = await import('../src/cmd/build.js')
|
|
@@ -331,6 +521,7 @@ if (command === '-v' || command === '--version') {
|
|
|
331
521
|
output: values.output,
|
|
332
522
|
format: values.format,
|
|
333
523
|
platform: values.platform,
|
|
524
|
+
babel: Boolean(values.babel),
|
|
334
525
|
minify: values.minify,
|
|
335
526
|
sourcemap: values.sourcemap,
|
|
336
527
|
define,
|
|
@@ -338,81 +529,61 @@ if (command === '-v' || command === '--version') {
|
|
|
338
529
|
loader,
|
|
339
530
|
})
|
|
340
531
|
} else if (command === 'extract') {
|
|
341
|
-
const flags = []
|
|
342
|
-
const valueFlags = new Set(['--output', '-o'])
|
|
343
|
-
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
344
|
-
flags.push(argv.shift())
|
|
345
|
-
}
|
|
346
532
|
const options = {
|
|
347
533
|
output: { type: 'string', short: 'o' },
|
|
348
534
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
if (argv.length === 0) usage('Nothing to extract: no bundle file given')
|
|
356
|
-
if (argv.length > 1) usage('Error: extract takes exactly one bundle file')
|
|
357
|
-
const { extractCommand } = await import('../src/cmd/extract.js')
|
|
358
|
-
extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output })
|
|
535
|
+
const values = parseLeadingOptions(argv, options, { valueFlags: ['--output', '-o'], onError: fail })
|
|
536
|
+
if (argv.length === 0) fail('Nothing to extract: no bundle file given')
|
|
537
|
+
if (argv.length > 1) fail('Error: extract takes exactly one bundle file')
|
|
538
|
+
const { extractCommand } = await import('@exodus/stasis-core/extract')
|
|
539
|
+
extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output, logLabel: 'stasis' })
|
|
359
540
|
} else if (command === 'diff') {
|
|
360
|
-
const flags = []
|
|
361
|
-
while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
|
|
362
541
|
const options = {
|
|
363
542
|
stat: { type: 'boolean' },
|
|
364
543
|
imports: { type: 'boolean' },
|
|
365
544
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
// --stat is required until full text diffs land: it reports what changed
|
|
373
|
-
// (modules/files), not the differing content. Reserving bare `stasis diff`
|
|
374
|
-
// keeps the door open for a future content-level diff without a breaking flag.
|
|
375
|
-
if (!values.stat) usage('Error: stasis diff currently requires --stat')
|
|
376
|
-
if (argv.length !== 2) usage('Error: stasis diff takes exactly two files (lockfile or bundle)')
|
|
377
|
-
// --imports additionally diffs the resolution graphs (which import edges were
|
|
378
|
-
// added/removed/redirected); off by default since the edge list is verbose.
|
|
545
|
+
const values = parseLeadingOptions(argv, options, { onError: fail })
|
|
546
|
+
// --stat is required for now (it reports what changed, not the content); bare `stasis diff`
|
|
547
|
+
// is reserved for a future content-level diff.
|
|
548
|
+
if (!values.stat) fail('Error: stasis diff currently requires --stat')
|
|
549
|
+
if (argv.length !== 2) fail('Error: stasis diff takes exactly two files (lockfile or bundle)')
|
|
550
|
+
// --imports also diffs the resolution graphs (added/removed/redirected edges); off by default (verbose).
|
|
379
551
|
const { diffCommand } = await import('../src/cmd/diff.js')
|
|
380
552
|
const { differences } = diffCommand({ cwd: process.cwd(), left: argv[0], right: argv[1], stat: true, imports: values.imports })
|
|
381
|
-
// Exit non-zero when the artifacts differ, so
|
|
382
|
-
// (e.g. fail a build when a bundle drifts from its lockfile).
|
|
553
|
+
// Exit non-zero when the artifacts differ, so it composes in CI.
|
|
383
554
|
process.exitCode = differences ? 1 : 0
|
|
384
555
|
} else if (command === 'prune') {
|
|
385
|
-
if (argv.length > 1)
|
|
556
|
+
if (argv.length > 1) fail('Error: prune takes at most one path argument')
|
|
386
557
|
const root = argv[0] ? resolve(argv[0]) : process.cwd()
|
|
387
558
|
const { prune } = await import('@exodus/stasis-core/prune')
|
|
388
559
|
const { removed, validated } = prune({ root })
|
|
389
560
|
console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
|
|
390
561
|
} else if (command === 'audit') {
|
|
391
|
-
|
|
562
|
+
const values = parseLeadingOptions(argv, { why: { type: 'boolean' }, 'why-deep': { type: 'boolean' }, 'why-full': { type: 'boolean' }, reason: { type: 'string' }, 'repo-advisories': { type: 'boolean' } }, { valueFlags: ['--reason'], onError: fail })
|
|
563
|
+
if (argv.length === 0) fail('Nothing to audit: no path to file given')
|
|
564
|
+
const repoAdvisories = Boolean(values['repo-advisories'])
|
|
565
|
+
if (repoAdvisories && !process.env.GITHUB_TOKEN) fail('Error: --repo-advisories requires a GitHub token in GITHUB_TOKEN')
|
|
566
|
+
// A Soldeer package's and a GitHub repo's advisories are their repository's, asked of GitHub with
|
|
567
|
+
// GITHUB_TOKEN where it is set; the client asks nothing for any other package by itself.
|
|
568
|
+
const github = (await import('@preventive/upstream/github.js')).createClient({ token: process.env.GITHUB_TOKEN || null })
|
|
569
|
+
// Upstream caches what it looks up (a package's GitHub repo, a month each) only where told to, and
|
|
570
|
+
// answers from it without asking: a per-user directory, never one shared with less trusted jobs.
|
|
571
|
+
const { setCacheDir } = await import('@preventive/upstream/npm.js')
|
|
572
|
+
setCacheDir(userCacheDir())
|
|
392
573
|
const { audit, printAuditReport } = await import('../src/audit.js')
|
|
393
574
|
const files = argv.map((f) => resolve(f))
|
|
394
|
-
const report = await audit(files)
|
|
575
|
+
const report = await audit(files, { why: Boolean(values.why), whyDeep: Boolean(values['why-deep']), whyFull: Boolean(values['why-full']), reason: values.reason, repoAdvisories, github })
|
|
395
576
|
printAuditReport(report)
|
|
396
577
|
process.exitCode = report.rows.length === 0 ? 0 : 1
|
|
397
578
|
} else if (command === 'sbom') {
|
|
398
|
-
const flags = []
|
|
399
|
-
const valueFlags = new Set(['--format', '--output', '-o'])
|
|
400
|
-
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
401
|
-
flags.push(argv.shift())
|
|
402
|
-
}
|
|
403
579
|
const options = {
|
|
404
580
|
format: { type: 'string' },
|
|
405
581
|
output: { type: 'string', short: 'o' },
|
|
406
582
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
usage(`Error: ${cause.message}`)
|
|
412
|
-
}
|
|
413
|
-
if (!values.format) usage('Error: stasis sbom requires --format=(spdx|cyclonedx)')
|
|
414
|
-
if (!['spdx', 'cyclonedx'].includes(values.format)) usage('Error: --format must be spdx or cyclonedx')
|
|
415
|
-
if (argv.length === 0) usage('Nothing to export: no lockfile or bundle given')
|
|
583
|
+
const values = parseLeadingOptions(argv, options, { valueFlags: ['--format', '--output', '-o'], onError: fail })
|
|
584
|
+
if (!values.format) fail('Error: stasis sbom requires --format=(spdx|cyclonedx)')
|
|
585
|
+
if (!['spdx', 'cyclonedx'].includes(values.format)) fail('Error: --format must be spdx or cyclonedx')
|
|
586
|
+
if (argv.length === 0) fail('Nothing to export: no lockfile or bundle given')
|
|
416
587
|
const { sbomCommand } = await import('../src/cmd/sbom.js')
|
|
417
588
|
sbomCommand({
|
|
418
589
|
cwd: process.cwd(),
|
|
@@ -420,6 +591,8 @@ if (command === '-v' || command === '--version') {
|
|
|
420
591
|
format: values.format,
|
|
421
592
|
output: values.output,
|
|
422
593
|
})
|
|
423
|
-
} else {
|
|
594
|
+
} else if (command === undefined) {
|
|
424
595
|
usage()
|
|
596
|
+
} else {
|
|
597
|
+
fail(`Error: unknown command '${command}'`)
|
|
425
598
|
}
|