@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 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 + per-file lockfiles
3
+ *Analyzeable source code bundles with resolutions + fine-grained lockfiles.*
4
4
 
5
- ## Bundles
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
- ## Lockfiles
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] [--full] path/to/file.js ...
23
- stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=path/to/out.stasis.code.br] path/to/file.sol ...
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
- full: { type: 'boolean' },
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
- const { values } = parseArgs({ args: flags, options })
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.full ? 'full' : 'node_modules'
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
- if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load requires --lock=(frozen|none|ignore)')
67
- if (lock === 'none' && bundle === 'none') usage('Error: --lock=none requires --bundle=(add|replace|load|ignore)')
68
- console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }) })
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
- const child = spawn('node', ['--import', import.meta.resolve('../src/loader.js'), ...argv], { stdio: 'inherit' })
76
- const [code] = await once(child, 'close')
77
- process.exitCode = code
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 .sol file given')
95
- if (!argv.every((f) => f.endsWith('.sol'))) usage('Error: bundle only accepts .sol files')
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('../src/prune.js')
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="a" x1="0" x2="1" y1="0" y2="1">
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="rgba(150,150,150,0.09)" rx="28" ry="28"/>
10
- <circle cx="64" cy="64" r="28.16" fill="url(#a)"/>
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-alpha.3",
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/config.js",
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/state.js",
30
- "src/state.util.js",
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
- "devDependencies": {
56
- "@exodus/bytes": "^1.15.0",
57
- "esbuild": "^0.27.3",
58
- "oxlint": "^1.52.0",
59
- "string-replace-loader": "^2.3.0",
60
- "webpack": "^4.47.0"
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
- "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
78
+ "peerDependenciesMeta": {
79
+ "esbuild": {
80
+ "optional": true
81
+ }
82
+ }
63
83
  }