@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.1
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 +67 -3
- package/bin/stasis.js +326 -19
- package/logo.svg +12 -3
- package/package.json +38 -18
- package/src/audit.js +1 -38
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +949 -58
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +26 -1
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
package/README.md
CHANGED
|
@@ -1,10 +1,74 @@
|
|
|
1
1
|
# <img src="./logo.svg" alt="" width="39" height="39" valign="bottom" /> `@exodus/stasis`
|
|
2
2
|
|
|
3
|
-
Analyzeable source code bundles with resolutions +
|
|
3
|
+
*Analyzeable source code bundles with resolutions + fine-grained lockfiles.*
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Both lockfiles and bundles only include what is actually _used_.\
|
|
6
|
+
Generally about 10x smaller than production-focused `node_modules` install.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Enforcing these lets you focus automated code scanning and alerts only on code that matters,
|
|
9
|
+
and makes the analysis import graph aware.
|
|
10
|
+
|
|
11
|
+
Ideal workflow: create bundle -> analyze -> ship _that exact bundle_ to production.
|
|
12
|
+
|
|
13
|
+
Lockfile can attest the bundle in a readable form, but that's not required - bundle itself can be an attestation.
|
|
14
|
+
|
|
15
|
+
| Lockfile | Bundle |
|
|
16
|
+
| -------- | ------ |
|
|
17
|
+
| `stasis.lock.json` | `stasis.code.br` |
|
|
18
|
+
| Asserts per-file content integrity and the import graph | Contains per-file content (source) and the import graph |
|
|
19
|
+
| Human-readable | Compressed |
|
|
20
|
+
| Asserts only, files are read from disk | Loads or asserts (can be used as a lockfile)|
|
|
21
|
+
| Modes: `add`, `replace`, `frozen`, `ignore` | Modes: `load`, `add`, `replace`, `frozen`, `ignore` |
|
|
22
|
+
|
|
23
|
+
The main difference between lockfiles and bundles is that lockfiles contain integrities, and bundles contain full content.\
|
|
24
|
+
See [file formats](https://github.com/ExodusOSS/stasis/blob/main/doc/file-formats.md).
|
|
25
|
+
|
|
26
|
+
Both can be run in full scope (default) or just in `node_modules` scope.
|
|
27
|
+
|
|
28
|
+
## Why a separate bundle format?
|
|
29
|
+
|
|
30
|
+
| | Stasis | JS bundlers | Sourcemaps | SEA | source + deps tarball | Containers |
|
|
31
|
+
| - | - | - | - | - | - | - |
|
|
32
|
+
| Runnable | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
|
|
33
|
+
| Contains original sources | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
|
34
|
+
| Sources match runtime | ✅ | ➖ | ❌ Deps can misreport | ➖ | ✅ | ✅ |
|
|
35
|
+
| Dependency versions | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
|
36
|
+
| Import edges | ✅ | ❌ | ❌ | ❌ | 🔍 Implicit | 🔍 Implicit |
|
|
37
|
+
| Constrained to related code | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
|
38
|
+
|
|
39
|
+
_Lockfiles (npm/pnpm/etc) not mentioned: they are like the "tarball" column, but also require network, install step and are not self-contained._
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | What it does |
|
|
44
|
+
| - | - |
|
|
45
|
+
| `stasis run --lock=add app.js` | build just a lockfile |
|
|
46
|
+
| `stasis run --lock=frozen app.js` | verify disk against the lockfile |
|
|
47
|
+
| `stasis run --bundle=add app.js` | build just a bundle |
|
|
48
|
+
| `stasis run --bundle=frozen app.js` | verify disk against the bundle alone (self-attesting) |
|
|
49
|
+
| `stasis run --bundle=load app.js` | run from the bundle alone |
|
|
50
|
+
| `stasis run --lock=add --bundle=add app.js` | build a lockfile and bundle together |
|
|
51
|
+
| `stasis run --lock=frozen --bundle=load app.js` | run from the bundle, verified against the lockfile |
|
|
52
|
+
| `stasis run --lock=add --bundle=add --mock app.js` | build without the app's side effects (network, fs writes) |
|
|
53
|
+
| `stasis run --lock=add --child-process app.js` | also attest modules loaded in forked child processes (e.g. Metro transform workers) |
|
|
54
|
+
| `stasis run --bundle=add --fs=sync app.js` | build a bundle that also captures sync `fs.readFileSync`/`readdirSync` reads |
|
|
55
|
+
| `stasis bundle src/index.js` | build a bundle statically, without executing it |
|
|
56
|
+
| `stasis bundle --conditions=react-native,browser app.js` | statically bundle, asserting extra `exports`/`imports` resolution conditions |
|
|
57
|
+
| `stasis bundle --mainFields=react-native,browser,main app.js` | statically bundle, honoring legacy package `mainFields` (incl. browser-field object redirection) |
|
|
58
|
+
| `stasis bundle --metro --platforms=ios,android app.js` | statically bundle the way Metro resolves: RN conditions + mainFields + `.ios`/`.android`/`.native` suffixes, all platforms at once |
|
|
59
|
+
| `stasis build --output=out.js app.stasis.code.br [entry]` | rebuild a runnable JS bundle with esbuild, following the bundle's recorded import graph exactly (entry optional when the bundle has one) |
|
|
60
|
+
| `stasis build --output=out.js stasis.lock.json [entry]` | same, reading + verifying sources from disk against the lockfile |
|
|
61
|
+
| `stasis extract app.stasis.code.br` | unpack a bundle back to sources + a `stasis.lock.json` |
|
|
62
|
+
| `stasis diff --stat a.lock.json b.stasis.code.br` | summarize module/file differences between two lockfiles/bundles |
|
|
63
|
+
| `stasis prune` | trim `node_modules` to the lockfile, verifying the rest |
|
|
64
|
+
| `stasis audit stasis.lock.json` | report npm advisories for a lockfile's dependencies |
|
|
65
|
+
| `stasis audit app.stasis.code.br` | report npm advisories for a bundle's dependencies |
|
|
66
|
+
| `stasis sbom --format=spdx stasis.lock.json` | export an SPDX SBOM for a lockfile or bundle |
|
|
67
|
+
| `stasis sbom --format=cyclonedx app.stasis.code.br` | export a CycloneDX SBOM for a lockfile or bundle |
|
|
68
|
+
|
|
69
|
+
## Runtime
|
|
70
|
+
|
|
71
|
+
The zero-dependency [`@exodus/stasis-core`](../stasis-core) CLI provides `run` and `prune` commands only; the bundler plugins live in [`@exodus/stasis-plugins`](../stasis-plugins).
|
|
8
72
|
|
|
9
73
|
## License
|
|
10
74
|
|
package/bin/stasis.js
CHANGED
|
@@ -4,8 +4,9 @@ import { spawn } from 'node:child_process'
|
|
|
4
4
|
import { parseArgs } from 'node:util'
|
|
5
5
|
import { once } from 'node:events'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { basename, resolve } from 'node:path'
|
|
7
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
8
8
|
import { existsSync, realpathSync } from 'node:fs'
|
|
9
|
+
import { constants as osConstants } from 'node:os'
|
|
9
10
|
import assert from 'node:assert/strict'
|
|
10
11
|
import pkg from '../package.json' with { type: 'json' }
|
|
11
12
|
|
|
@@ -19,10 +20,21 @@ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.
|
|
|
19
20
|
|
|
20
21
|
function usage(prefix = '') {
|
|
21
22
|
console.error(`${prefix}\nUsage:
|
|
22
|
-
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--
|
|
23
|
-
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=path
|
|
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] [--mock] [--fs=(sync|async)] [--resources=ext,ext] path/to/file.js ...
|
|
24
|
+
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=(path|-)] path/to/file.sol ...
|
|
25
|
+
stasis bundle [--output=(path|-)] path/to/file.php ...
|
|
26
|
+
stasis bundle [--scope=(node_modules|full)] [--conditions=cond1,cond2] [--mainFields=field1,field2] [--lockfile=path/to/stasis.lock.json] [--output=(path|-)] path/to/file.(js|ts) ...
|
|
27
|
+
stasis bundle --metro --platforms=ios,android [--platforms=web] [--lockfile=path/to/stasis.lock.json] [--output=(path|-)] path/to/file.(js|ts) ...
|
|
28
|
+
stasis bundle [--output=(path|-)] path/to/file.(sh|bash) ...
|
|
29
|
+
stasis bundle [--output=(path|-)] path/to/file.rs ...
|
|
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]
|
|
32
|
+
stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
|
|
33
|
+
stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
|
|
24
34
|
stasis prune [path/to/project]
|
|
25
35
|
stasis audit path/to/file ...
|
|
36
|
+
stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
|
|
37
|
+
(stasis sbom streams to stdout by default; --output=- is explicit stdout)
|
|
26
38
|
`.trim())
|
|
27
39
|
process.exit(1)
|
|
28
40
|
}
|
|
@@ -40,7 +52,7 @@ if (command === '-v' || command === '--version') {
|
|
|
40
52
|
process.exit(0)
|
|
41
53
|
} else if (command === 'run') {
|
|
42
54
|
const flags = []
|
|
43
|
-
const valueFlags = new Set(['--bundle', '--bundle-file', '--lock'])
|
|
55
|
+
const valueFlags = new Set(['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources'])
|
|
44
56
|
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
45
57
|
flags.push(argv.shift())
|
|
46
58
|
}
|
|
@@ -49,41 +61,131 @@ if (command === '-v' || command === '--version') {
|
|
|
49
61
|
lock: { type: 'string', default: 'none' },
|
|
50
62
|
bundle: { type: 'string', default: 'none' },
|
|
51
63
|
'bundle-file': { type: 'string' },
|
|
64
|
+
'resources-bundle-file': { type: 'string' },
|
|
52
65
|
debug: { type: 'boolean' },
|
|
53
|
-
|
|
66
|
+
dependencies: { type: 'boolean' },
|
|
67
|
+
'child-process': { type: 'boolean' },
|
|
68
|
+
mock: { type: 'boolean' },
|
|
69
|
+
fs: { type: 'string' },
|
|
70
|
+
resources: { type: 'string' },
|
|
54
71
|
}
|
|
55
72
|
|
|
56
|
-
|
|
73
|
+
let values
|
|
74
|
+
try {
|
|
75
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
usage(`Error: ${cause.message}`)
|
|
78
|
+
}
|
|
57
79
|
if (argv.length === 0) usage('Nothing to run: no path to file given')
|
|
58
80
|
if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
|
|
59
81
|
const lock = values.lock
|
|
60
|
-
const scope = values.
|
|
82
|
+
const scope = values.dependencies ? 'node_modules' : 'full'
|
|
61
83
|
const bundle = values.bundle
|
|
62
84
|
const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
|
|
85
|
+
const resourcesBundleFile = values['resources-bundle-file'] ? resolve(values['resources-bundle-file']) : ''
|
|
63
86
|
const debug = values.debug ? '1' : ''
|
|
64
|
-
if (!['none', 'ignore', 'add', 'replace', 'load'].includes(bundle)) usage('Error: invalid --bundle value')
|
|
65
|
-
if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|ignore)')
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) usage('Error: invalid --bundle value')
|
|
88
|
+
if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|frozen|ignore)')
|
|
89
|
+
// Unlike --bundle-file (which is inert-but-harmless under --bundle=ignore, so Config allows
|
|
90
|
+
// it), --resources-bundle-file needs an active bundle: Config rejects bundle=none AND ignore
|
|
91
|
+
// (resourcesBundleFile requires an active bundle mode). Front-run that with a clean usage error.
|
|
92
|
+
if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) usage('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
|
|
93
|
+
if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load is incompatible with --lock=(add|replace)')
|
|
94
|
+
if (lock === 'none' && bundle === 'none') usage('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
|
|
95
|
+
if (values.mock && bundle === 'load') usage('Error: --mock is for capturing imports while building a bundle; not compatible with --bundle=load')
|
|
96
|
+
// --fs monkey-patches the fs readers (readFileSync/readdirSync + lstatSync/statSync)
|
|
97
|
+
// to capture them into the bundle (add|replace) or serve them from it (load); it has
|
|
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)')
|
|
103
|
+
const captureFs = values.fs ?? ''
|
|
104
|
+
// --resources: comma-separated extension/filename allowlist for `--fs` resource captures
|
|
105
|
+
// (e.g. png,svg,LICENSE). The child's Config validates each entry via parseResourcesOption.
|
|
106
|
+
const resources = values.resources ?? ''
|
|
107
|
+
// --child-process: forward forked-child (e.g. Metro transform worker) capture to the root
|
|
108
|
+
// via per-pid shards. Opt-in -- it stands up a process-coordination channel, off by default.
|
|
109
|
+
const childProcess = values['child-process'] ? '1' : ''
|
|
110
|
+
console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }), ...(resourcesBundleFile && { resourcesBundleFile }), ...(childProcess && { childProcess: true }), ...(values.mock && { mock: true }), ...(values.fs && { fs: values.fs }), ...(resources && { resources }) })
|
|
69
111
|
if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
|
|
70
112
|
setEnv('EXODUS_STASIS_LOCK', lock)
|
|
71
113
|
setEnv('EXODUS_STASIS_SCOPE', scope)
|
|
72
114
|
setEnv('EXODUS_STASIS_BUNDLE', bundle)
|
|
73
115
|
setEnv('EXODUS_STASIS_BUNDLE_FILE', bundleFile)
|
|
116
|
+
setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
|
|
74
117
|
setEnv('EXODUS_STASIS_DEBUG', debug)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
118
|
+
setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
|
|
119
|
+
setEnv('EXODUS_STASIS_FS', captureFs)
|
|
120
|
+
setEnv('EXODUS_STASIS_RESOURCES', resources)
|
|
121
|
+
// --mock: capture imports by running user code with side-effects denied,
|
|
122
|
+
// fail-closed. Node's permission system blocks fs writes, child processes,
|
|
123
|
+
// worker threads, native addons (no --allow-addons -- addons would bypass
|
|
124
|
+
// the whole model), and the inspector. Network and timers have no
|
|
125
|
+
// --allow-* counterparts, so src/mock.js neutralizes them in JS. We --import
|
|
126
|
+
// src/loader-mock.js (instead of core's plain loader): it evaluates the core
|
|
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).
|
|
135
|
+
// Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
|
|
136
|
+
const nodeArgs = []
|
|
137
|
+
if (values.mock) {
|
|
138
|
+
const writeAllow = [process.cwd()]
|
|
139
|
+
// --allow-fs-write paths must already exist on disk; granting a bundle file's own path is not
|
|
140
|
+
// enough when state.write()'s mkdirSync has to create the parent chain. For each out-of-cwd
|
|
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.)
|
|
145
|
+
for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
|
|
146
|
+
if (!file || file.startsWith(`${process.cwd()}/`)) continue
|
|
147
|
+
let p = dirname(file)
|
|
148
|
+
while (!existsSync(p) && dirname(p) !== p) p = dirname(p)
|
|
149
|
+
// Refuse to grant write access to the filesystem root: it would effectively disable the
|
|
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.
|
|
152
|
+
if (p === dirname(p)) {
|
|
153
|
+
usage(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
|
|
154
|
+
}
|
|
155
|
+
if (!writeAllow.includes(p)) writeAllow.push(p) // both targets often share a parent dir
|
|
156
|
+
}
|
|
157
|
+
nodeArgs.push('--permission', '--allow-fs-read=*')
|
|
158
|
+
for (const p of writeAllow) nodeArgs.push(`--allow-fs-write=${p}`)
|
|
159
|
+
// --permission without --allow-addons removes "node-addons" from resolution
|
|
160
|
+
// conditions, which would change how packages with conditional exports
|
|
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).
|
|
164
|
+
nodeArgs.push('--conditions=node-addons')
|
|
165
|
+
}
|
|
166
|
+
// Non-mock runs use core's loader directly; --mock uses stasis's loader-mock
|
|
167
|
+
// entry, which composes the mock into the same hooks lib (see comment above).
|
|
168
|
+
const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
|
|
169
|
+
nodeArgs.push('--import', import.meta.resolve(loaderEntry))
|
|
170
|
+
const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
|
|
171
|
+
const [code, signal] = await once(child, 'close')
|
|
172
|
+
// code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0
|
|
173
|
+
process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0)
|
|
78
174
|
} else if (command === 'bundle') {
|
|
79
175
|
const flags = []
|
|
80
|
-
const valueFlags = new Set(['--mapping', '--output', '-o'])
|
|
176
|
+
const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o'])
|
|
81
177
|
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
82
178
|
flags.push(argv.shift())
|
|
83
179
|
}
|
|
84
180
|
const options = {
|
|
85
181
|
mapping: { type: 'string' },
|
|
86
182
|
output: { type: 'string', short: 'o' },
|
|
183
|
+
scope: { type: 'string' },
|
|
184
|
+
lockfile: { type: 'string' },
|
|
185
|
+
conditions: { type: 'string' },
|
|
186
|
+
mainFields: { type: 'string' },
|
|
187
|
+
metro: { type: 'boolean' },
|
|
188
|
+
platforms: { type: 'string', multiple: true },
|
|
87
189
|
}
|
|
88
190
|
let values
|
|
89
191
|
try {
|
|
@@ -91,19 +193,198 @@ if (command === '-v' || command === '--version') {
|
|
|
91
193
|
} catch (cause) {
|
|
92
194
|
usage(`Error: ${cause.message}`)
|
|
93
195
|
}
|
|
94
|
-
if (argv.length === 0) usage('Nothing to bundle: no
|
|
95
|
-
|
|
196
|
+
if (argv.length === 0) usage('Nothing to bundle: no entry file given')
|
|
197
|
+
const allSol = argv.every((f) => f.endsWith('.sol'))
|
|
198
|
+
const allPhp = argv.every((f) => f.endsWith('.php'))
|
|
199
|
+
const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f))
|
|
200
|
+
const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f))
|
|
201
|
+
const allRust = argv.every((f) => f.endsWith('.rs'))
|
|
202
|
+
if (!allSol && !allPhp && !allJs && !allBash && !allRust) {
|
|
203
|
+
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')
|
|
204
|
+
}
|
|
205
|
+
if (values.mapping && !allSol) usage('Error: --mapping is only valid for .sol bundles')
|
|
206
|
+
if (values.scope && !allJs) usage('Error: --scope is only valid for JS bundles')
|
|
207
|
+
if (values.scope && !['node_modules', 'full'].includes(values.scope)) {
|
|
208
|
+
usage('Error: --scope must be node_modules or full')
|
|
209
|
+
}
|
|
210
|
+
if (values.lockfile && !allJs) usage('Error: --lockfile is only valid for JS bundles')
|
|
211
|
+
// --scope selects a bundle subset; the field resolver (--mainFields/--metro) always
|
|
212
|
+
// emits a full-scope bundle, so the two together would silently ignore --scope.
|
|
213
|
+
if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
|
|
214
|
+
usage('Error: --scope is not supported with --mainFields or --metro')
|
|
215
|
+
}
|
|
216
|
+
// --conditions: comma-separated extra `exports`/`imports` resolution conditions
|
|
217
|
+
// (e.g. react-native,browser) merged on top of the conditions Node always asserts.
|
|
218
|
+
// They steer which branch of a package's conditional `exports` the static scan
|
|
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')
|
|
222
|
+
const conditions = values.conditions === undefined
|
|
223
|
+
? []
|
|
224
|
+
: values.conditions.split(',').map((s) => s.trim()).filter(Boolean)
|
|
225
|
+
if (values.conditions !== undefined && conditions.length === 0) {
|
|
226
|
+
usage('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
|
|
227
|
+
}
|
|
228
|
+
// --mainFields: comma-separated legacy package entry fields (e.g.
|
|
229
|
+
// react-native,browser,main). Drives the legacy-field resolver for non-`exports`
|
|
230
|
+
// packages -- the entry it picks plus the browser-field-spec object redirection.
|
|
231
|
+
if (values.mainFields !== undefined && !allJs) usage('Error: --mainFields is only valid for JS bundles')
|
|
232
|
+
const mainFields = values.mainFields === undefined
|
|
233
|
+
? undefined
|
|
234
|
+
: values.mainFields.split(',').map((s) => s.trim()).filter(Boolean)
|
|
235
|
+
if (values.mainFields !== undefined && mainFields.length === 0) {
|
|
236
|
+
usage('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
|
|
237
|
+
}
|
|
238
|
+
// --metro: resolve like Metro/React Native -- it sets its own conditions
|
|
239
|
+
// (react-native, +browser for web) and mainFields, and resolves platform suffixes
|
|
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.
|
|
242
|
+
const metro = Boolean(values.metro)
|
|
243
|
+
const platforms = [...new Set((values.platforms ?? []).flatMap((p) => p.split(',')).map((s) => s.trim()).filter(Boolean))]
|
|
244
|
+
// A platform name becomes a bundle/lockfile edge key, which the parsers reject if it
|
|
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).
|
|
247
|
+
for (const p of platforms) {
|
|
248
|
+
if (p === '*' || p.includes('/')) usage(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
|
|
249
|
+
}
|
|
250
|
+
if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
|
|
251
|
+
if (metro) {
|
|
252
|
+
if (conditions.length > 0) usage("Error: --conditions can't be combined with --metro (it sets its own conditions)")
|
|
253
|
+
if (mainFields !== undefined) usage("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
|
|
254
|
+
if (platforms.length === 0) usage('Error: --metro requires --platforms (e.g. --platforms=ios,android)')
|
|
255
|
+
} else if (platforms.length > 0) {
|
|
256
|
+
usage('Error: --platforms is only valid with --metro')
|
|
257
|
+
}
|
|
96
258
|
const { bundleCommand } = await import('../src/cmd/bundle.js')
|
|
97
259
|
await bundleCommand({
|
|
98
260
|
cwd: process.cwd(),
|
|
99
261
|
entries: argv,
|
|
100
262
|
mappingFile: values.mapping,
|
|
101
263
|
output: values.output,
|
|
264
|
+
scope: values.scope,
|
|
265
|
+
lockfile: values.lockfile,
|
|
266
|
+
conditions,
|
|
267
|
+
mainFields,
|
|
268
|
+
metro,
|
|
269
|
+
platforms,
|
|
270
|
+
})
|
|
271
|
+
} else if (command === 'build') {
|
|
272
|
+
const flags = []
|
|
273
|
+
const valueFlags = new Set(['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'])
|
|
274
|
+
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
275
|
+
flags.push(argv.shift())
|
|
276
|
+
}
|
|
277
|
+
const options = {
|
|
278
|
+
output: { type: 'string', short: 'o' },
|
|
279
|
+
format: { type: 'string' },
|
|
280
|
+
platform: { type: 'string' },
|
|
281
|
+
minify: { type: 'boolean' },
|
|
282
|
+
sourcemap: { type: 'boolean' },
|
|
283
|
+
// Repeatable esbuild passthroughs: --define=K=V (one substitution each),
|
|
284
|
+
// --external=PATTERN (one external each), and --loader=.ext:name (one extension
|
|
285
|
+
// loader override each). Each collects into an array.
|
|
286
|
+
define: { type: 'string', multiple: true },
|
|
287
|
+
external: { type: 'string', multiple: true },
|
|
288
|
+
loader: { type: 'string', multiple: true },
|
|
289
|
+
}
|
|
290
|
+
let values
|
|
291
|
+
try {
|
|
292
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
293
|
+
} catch (cause) {
|
|
294
|
+
usage(`Error: ${cause.message}`)
|
|
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')
|
|
298
|
+
// esbuild output knobs; the import graph itself is fixed by the artifact, not these.
|
|
299
|
+
if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
|
|
300
|
+
usage('Error: --format must be esm, cjs, or iife')
|
|
301
|
+
}
|
|
302
|
+
if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
|
|
303
|
+
usage('Error: --platform must be node, browser, or neutral')
|
|
304
|
+
}
|
|
305
|
+
if (!values.output) usage('Error: --output is required (a .js/.cjs/.mjs file, or a directory)')
|
|
306
|
+
// `-` is the stdout sentinel for `bundle`/`sbom`, but `build` writes via esbuild and can't
|
|
307
|
+
// stream; without this it would silently create a directory literally named `-`.
|
|
308
|
+
if (values.output === '-') usage('Error: stasis build cannot stream to stdout; --output must be a .js/.cjs/.mjs file or a directory')
|
|
309
|
+
// --define=KEY=VALUE -> esbuild's define map. The value is forwarded to esbuild verbatim,
|
|
310
|
+
// so it must be valid JS the same way esbuild's CLI requires (a JSON literal, an
|
|
311
|
+
// identifier, ...): e.g. --define=process.env.NODE_ENV='"production"' or --define=DEBUG=false.
|
|
312
|
+
const define = {}
|
|
313
|
+
for (const entry of values.define ?? []) {
|
|
314
|
+
const eq = entry.indexOf('=')
|
|
315
|
+
if (eq <= 0) usage(`Error: --define must be KEY=VALUE (got '${entry}')`)
|
|
316
|
+
define[entry.slice(0, eq)] = entry.slice(eq + 1)
|
|
317
|
+
}
|
|
318
|
+
// --loader=.ext:NAME -> esbuild loader override for that extension, e.g.
|
|
319
|
+
// --loader=.js:jsx so a .js file carrying JSX (the React Native convention) parses.
|
|
320
|
+
const loader = {}
|
|
321
|
+
for (const entry of values.loader ?? []) {
|
|
322
|
+
const colon = entry.indexOf(':')
|
|
323
|
+
if (colon <= 0 || !entry.startsWith('.')) usage(`Error: --loader must be .EXT:LOADER (got '${entry}')`)
|
|
324
|
+
loader[entry.slice(0, colon)] = entry.slice(colon + 1)
|
|
325
|
+
}
|
|
326
|
+
const { buildCommand } = await import('../src/cmd/build.js')
|
|
327
|
+
await buildCommand({
|
|
328
|
+
cwd: process.cwd(),
|
|
329
|
+
artifact: argv[0],
|
|
330
|
+
entry: argv[1],
|
|
331
|
+
output: values.output,
|
|
332
|
+
format: values.format,
|
|
333
|
+
platform: values.platform,
|
|
334
|
+
minify: values.minify,
|
|
335
|
+
sourcemap: values.sourcemap,
|
|
336
|
+
define,
|
|
337
|
+
external: values.external ?? [],
|
|
338
|
+
loader,
|
|
102
339
|
})
|
|
340
|
+
} 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
|
+
const options = {
|
|
347
|
+
output: { type: 'string', short: 'o' },
|
|
348
|
+
}
|
|
349
|
+
let values
|
|
350
|
+
try {
|
|
351
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
352
|
+
} catch (cause) {
|
|
353
|
+
usage(`Error: ${cause.message}`)
|
|
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 })
|
|
359
|
+
} else if (command === 'diff') {
|
|
360
|
+
const flags = []
|
|
361
|
+
while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
|
|
362
|
+
const options = {
|
|
363
|
+
stat: { type: 'boolean' },
|
|
364
|
+
imports: { type: 'boolean' },
|
|
365
|
+
}
|
|
366
|
+
let values
|
|
367
|
+
try {
|
|
368
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
369
|
+
} catch (cause) {
|
|
370
|
+
usage(`Error: ${cause.message}`)
|
|
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.
|
|
379
|
+
const { diffCommand } = await import('../src/cmd/diff.js')
|
|
380
|
+
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 the command composes in CI
|
|
382
|
+
// (e.g. fail a build when a bundle drifts from its lockfile).
|
|
383
|
+
process.exitCode = differences ? 1 : 0
|
|
103
384
|
} else if (command === 'prune') {
|
|
104
385
|
if (argv.length > 1) usage('Error: prune takes at most one path argument')
|
|
105
386
|
const root = argv[0] ? resolve(argv[0]) : process.cwd()
|
|
106
|
-
const { prune } = await import('
|
|
387
|
+
const { prune } = await import('@exodus/stasis-core/prune')
|
|
107
388
|
const { removed, validated } = prune({ root })
|
|
108
389
|
console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
|
|
109
390
|
} else if (command === 'audit') {
|
|
@@ -113,6 +394,32 @@ if (command === '-v' || command === '--version') {
|
|
|
113
394
|
const report = await audit(files)
|
|
114
395
|
printAuditReport(report)
|
|
115
396
|
process.exitCode = report.rows.length === 0 ? 0 : 1
|
|
397
|
+
} 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
|
+
const options = {
|
|
404
|
+
format: { type: 'string' },
|
|
405
|
+
output: { type: 'string', short: 'o' },
|
|
406
|
+
}
|
|
407
|
+
let values
|
|
408
|
+
try {
|
|
409
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
410
|
+
} catch (cause) {
|
|
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')
|
|
416
|
+
const { sbomCommand } = await import('../src/cmd/sbom.js')
|
|
417
|
+
sbomCommand({
|
|
418
|
+
cwd: process.cwd(),
|
|
419
|
+
files: argv,
|
|
420
|
+
format: values.format,
|
|
421
|
+
output: values.output,
|
|
422
|
+
})
|
|
116
423
|
} else {
|
|
117
424
|
usage()
|
|
118
425
|
}
|
package/logo.svg
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Stasis" viewBox="0 0 128 128">
|
|
2
2
|
<defs>
|
|
3
|
-
<linearGradient id="
|
|
3
|
+
<linearGradient id="b" x1="0" x2="1" y1="0" y2="1">
|
|
4
4
|
<stop offset="0%" stop-color="#1f9cff"/>
|
|
5
5
|
<stop offset="60%" stop-color="#8c5bfe"/>
|
|
6
6
|
<stop offset="100%" stop-color="#b85bfe"/>
|
|
7
7
|
</linearGradient>
|
|
8
|
+
<clipPath id="a">
|
|
9
|
+
<rect width="126.5" height="126.5" x=".75" y=".75" rx="27.25" ry="27.25"/>
|
|
10
|
+
</clipPath>
|
|
8
11
|
</defs>
|
|
9
|
-
<rect width="128" height="128" fill="
|
|
10
|
-
<
|
|
12
|
+
<rect width="128" height="128" fill="#969696" fill-opacity=".09" rx="28" ry="28"/>
|
|
13
|
+
<g fill="none" stroke="#8c8c8c" stroke-width="1.5" opacity=".42">
|
|
14
|
+
<g clip-path="url(#a)">
|
|
15
|
+
<path d="M0 12 12 0M0 26.5 26.5 0M0 41 41 0M0 55.5 55.5 0M0 70 70 0M0 84.5 84.5 0M0 99 99 0M0 113.5 113.5 0M0 128 128 0M14.5 128 128 14.5M29 128l99-99m-84.5 99L128 43.5M58 128l70-70m-55.5 70L128 72.5M87 128l41-41m-26.5 41 26.5-26.5M116 128l12-12"/>
|
|
16
|
+
</g>
|
|
17
|
+
<rect width="126.5" height="126.5" x=".75" y=".75" rx="27.25" ry="27.25"/>
|
|
18
|
+
</g>
|
|
19
|
+
<circle cx="64" cy="64" r="28.16" fill="url(#b)"/>
|
|
11
20
|
</svg>
|
package/package.json
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
4
|
"description": "File-level source bundles and lockfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./esbuild": "./src/esbuild.js",
|
|
8
8
|
"./webpack": "./src/webpack.js",
|
|
9
|
+
"./metro": "./src/metro.js",
|
|
10
|
+
"./metro-transformer": "./src/metro-transformer.js",
|
|
9
11
|
"./loader": "./src/loader.js",
|
|
12
|
+
"./mock": "./src/mock.js",
|
|
10
13
|
"./bundle": "./src/bundle.js",
|
|
14
|
+
"./cmd/bundle": "./src/cmd/bundle.js",
|
|
15
|
+
"./cmd/build": "./src/cmd/build.js",
|
|
16
|
+
"./diff": "./src/diff.js",
|
|
11
17
|
"./lockfile": "./src/lockfile.js",
|
|
12
18
|
"./prune": "./src/prune.js",
|
|
19
|
+
"./sbom": "./src/sbom.js",
|
|
13
20
|
"./logo.svg": "./logo.svg"
|
|
14
21
|
},
|
|
15
22
|
"files": [
|
|
@@ -19,16 +26,28 @@
|
|
|
19
26
|
"src/apis/npm/semver.cjs",
|
|
20
27
|
"src/audit.js",
|
|
21
28
|
"src/bundle.js",
|
|
29
|
+
"src/bundle-from-lockfile.js",
|
|
22
30
|
"src/cmd/bundle.js",
|
|
23
|
-
"src/
|
|
31
|
+
"src/cmd/build.js",
|
|
32
|
+
"src/cmd/diff.js",
|
|
33
|
+
"src/cmd/extract.js",
|
|
34
|
+
"src/cmd/sbom.js",
|
|
35
|
+
"src/diff.js",
|
|
24
36
|
"src/esbuild.js",
|
|
25
37
|
"src/loader.js",
|
|
38
|
+
"src/loader-mock.js",
|
|
39
|
+
"src/loaders/bash.js",
|
|
40
|
+
"src/loaders/rust.js",
|
|
26
41
|
"src/loaders/solidity.js",
|
|
42
|
+
"src/loaders/php.js",
|
|
27
43
|
"src/lockfile.js",
|
|
44
|
+
"src/metro.js",
|
|
45
|
+
"src/metro-transformer.js",
|
|
46
|
+
"src/mock.js",
|
|
47
|
+
"src/parse.js",
|
|
28
48
|
"src/prune.js",
|
|
29
|
-
"src/
|
|
30
|
-
"src/
|
|
31
|
-
"src/util.js",
|
|
49
|
+
"src/sbom.js",
|
|
50
|
+
"src/scan.js",
|
|
32
51
|
"src/webpack.js"
|
|
33
52
|
],
|
|
34
53
|
"engines": {
|
|
@@ -37,14 +56,10 @@
|
|
|
37
56
|
"bin": {
|
|
38
57
|
"stasis": "bin/stasis.js"
|
|
39
58
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"lint": "oxlint",
|
|
42
|
-
"test": "node --run test:coherence",
|
|
43
|
-
"test:coherence": "node --test"
|
|
44
|
-
},
|
|
45
59
|
"repository": {
|
|
46
60
|
"type": "git",
|
|
47
|
-
"url": "git+https://github.com/ExodusOSS/stasis.git"
|
|
61
|
+
"url": "git+https://github.com/ExodusOSS/stasis.git",
|
|
62
|
+
"directory": "stasis"
|
|
48
63
|
},
|
|
49
64
|
"author": "Exodus Movement, Inc.",
|
|
50
65
|
"license": "MIT",
|
|
@@ -52,12 +67,17 @@
|
|
|
52
67
|
"url": "https://github.com/ExodusOSS/stasis/issues"
|
|
53
68
|
},
|
|
54
69
|
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
55
|
-
"
|
|
56
|
-
"@exodus/
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@exodus/stasis-core": "1.0.0-beta.1",
|
|
72
|
+
"@exodus/stasis-plugins": "1.0.0-beta.1",
|
|
73
|
+
"oxc-parser": "^0.94.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"esbuild": "*"
|
|
61
77
|
},
|
|
62
|
-
"
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"esbuild": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
63
83
|
}
|