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