@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.0

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,7 +4,7 @@ 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
9
  import assert from 'node:assert/strict'
10
10
  import pkg from '../package.json' with { type: 'json' }
@@ -19,10 +19,21 @@ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.
19
19
 
20
20
  function usage(prefix = '') {
21
21
  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 ...
22
+ stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|frozen|ignore)] [--bundle-file=path/to/bundle.br] [--resources-bundle-file=path/to/resources.br] [--dependencies] [--child-process] [--mock] [--fs=(sync|async)] [--resources=ext,ext] path/to/file.js ...
23
+ stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=(path|-)] path/to/file.sol ...
24
+ stasis bundle [--output=(path|-)] path/to/file.php ...
25
+ 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) ...
26
+ stasis bundle --metro --platforms=ios,android [--platforms=web] [--lockfile=path/to/stasis.lock.json] [--output=(path|-)] path/to/file.(js|ts) ...
27
+ stasis bundle [--output=(path|-)] path/to/file.(sh|bash) ...
28
+ stasis bundle [--output=(path|-)] path/to/file.rs ...
29
+ (stasis bundle writes to stasis.code.br by default; --output=- streams to stdout)
30
+ 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]
31
+ stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
32
+ stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
24
33
  stasis prune [path/to/project]
25
34
  stasis audit path/to/file ...
35
+ stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
36
+ (stasis sbom streams to stdout by default; --output=- is explicit stdout)
26
37
  `.trim())
27
38
  process.exit(1)
28
39
  }
@@ -40,7 +51,7 @@ if (command === '-v' || command === '--version') {
40
51
  process.exit(0)
41
52
  } else if (command === 'run') {
42
53
  const flags = []
43
- const valueFlags = new Set(['--bundle', '--bundle-file', '--lock'])
54
+ const valueFlags = new Set(['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources'])
44
55
  while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
45
56
  flags.push(argv.shift())
46
57
  }
@@ -49,41 +60,130 @@ if (command === '-v' || command === '--version') {
49
60
  lock: { type: 'string', default: 'none' },
50
61
  bundle: { type: 'string', default: 'none' },
51
62
  'bundle-file': { type: 'string' },
63
+ 'resources-bundle-file': { type: 'string' },
52
64
  debug: { type: 'boolean' },
53
- full: { type: 'boolean' },
65
+ dependencies: { type: 'boolean' },
66
+ 'child-process': { type: 'boolean' },
67
+ mock: { type: 'boolean' },
68
+ fs: { type: 'string' },
69
+ resources: { type: 'string' },
54
70
  }
55
71
 
56
- const { values } = parseArgs({ args: flags, options })
72
+ let values
73
+ try {
74
+ ({ values } = parseArgs({ args: flags, options }))
75
+ } catch (cause) {
76
+ usage(`Error: ${cause.message}`)
77
+ }
57
78
  if (argv.length === 0) usage('Nothing to run: no path to file given')
58
79
  if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
59
80
  const lock = values.lock
60
- const scope = values.full ? 'full' : 'node_modules'
81
+ const scope = values.dependencies ? 'node_modules' : 'full'
61
82
  const bundle = values.bundle
62
83
  const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
84
+ const resourcesBundleFile = values['resources-bundle-file'] ? resolve(values['resources-bundle-file']) : ''
63
85
  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 }) })
86
+ if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) usage('Error: invalid --bundle value')
87
+ if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|frozen|ignore)')
88
+ // Unlike --bundle-file (which is inert-but-harmless under --bundle=ignore, so Config allows
89
+ // it), --resources-bundle-file needs an active bundle: Config rejects bundle=none AND ignore
90
+ // (resourcesBundleFile requires an active bundle mode). Front-run that with a clean usage error.
91
+ if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) usage('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
92
+ if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load is incompatible with --lock=(add|replace)')
93
+ if (lock === 'none' && bundle === 'none') usage('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
94
+ if (values.mock && bundle === 'load') usage('Error: --mock is for capturing imports while building a bundle; not compatible with --bundle=load')
95
+ // --fs monkey-patches the fs readers (readFileSync/readdirSync + lstatSync/statSync)
96
+ // to capture them into the bundle (add|replace) or serve them from it (load); it has
97
+ // nothing to record into or read from without one of those bundle modes. The mode
98
+ // argument is required: `sync` patches the sync readers, `async` adds their async
99
+ // (callback + fs.promises) counterparts on top.
100
+ if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) usage("Error: --fs must be 'sync' or 'async'")
101
+ if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) usage('Error: --fs requires --bundle=(add|replace|load)')
102
+ const captureFs = values.fs ?? ''
103
+ // --resources: comma-separated extension/filename allowlist for `--fs` resource captures
104
+ // (e.g. png,svg,LICENSE). The child's Config validates each entry via parseResourcesOption.
105
+ const resources = values.resources ?? ''
106
+ // --child-process: forward forked-child (e.g. Metro transform worker) capture to the root
107
+ // via per-pid shards. Opt-in -- it stands up a process-coordination channel, off by default.
108
+ const childProcess = values['child-process'] ? '1' : ''
109
+ 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
110
  if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
70
111
  setEnv('EXODUS_STASIS_LOCK', lock)
71
112
  setEnv('EXODUS_STASIS_SCOPE', scope)
72
113
  setEnv('EXODUS_STASIS_BUNDLE', bundle)
73
114
  setEnv('EXODUS_STASIS_BUNDLE_FILE', bundleFile)
115
+ setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
74
116
  setEnv('EXODUS_STASIS_DEBUG', debug)
75
- const child = spawn('node', ['--import', import.meta.resolve('../src/loader.js'), ...argv], { stdio: 'inherit' })
117
+ setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
118
+ setEnv('EXODUS_STASIS_FS', captureFs)
119
+ setEnv('EXODUS_STASIS_RESOURCES', resources)
120
+ // --mock: capture imports by running user code with side-effects denied,
121
+ // fail-closed. Node's permission system blocks fs writes, child processes,
122
+ // worker threads, native addons (no --allow-addons -- addons would bypass
123
+ // the whole model), and the inspector. Network and timers have no
124
+ // --allow-* counterparts, so src/mock.js neutralizes them in JS. We --import
125
+ // src/loader-mock.js (instead of core's plain loader): it evaluates the core
126
+ // hooks lib first -- snapshotting stasis's real fs bindings -- then src/mock.js
127
+ // (whose syncBuiltinESMExports() refresh reaches user-code ESM imports), then
128
+ // installs the hooks. So the mock runs after stasis's snapshots and before
129
+ // registration, by static import order and with no env var. Reads stay open
130
+ // (--allow-fs-read=*) so node_modules resolution works; reads aren't a side
131
+ // effect. Writes are still scoped at the kernel level as defense in depth (the
132
+ // JS layer covers user code, --permission covers anything that bypasses JS,
133
+ // e.g. process.binding or a future leak).
134
+ // Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
135
+ const nodeArgs = []
136
+ if (values.mock) {
137
+ const writeAllow = [process.cwd()]
138
+ // --allow-fs-write paths must already exist on disk; granting a bundle file's own path is not
139
+ // enough when state.write()'s mkdirSync has to create the parent chain. For each out-of-cwd
140
+ // bundle target (code and/or split resources), walk up to the nearest existing ancestor and
141
+ // grant write there -- broader than ideal, but the user explicitly chose these locations.
142
+ // (Note: the cwd check uses '/' as a separator and is therefore unix-only; that matches the
143
+ // rest of the project's path handling.)
144
+ for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
145
+ if (!file || file.startsWith(`${process.cwd()}/`)) continue
146
+ let p = dirname(file)
147
+ while (!existsSync(p) && dirname(p) !== p) p = dirname(p)
148
+ // Refuse to grant write access to the filesystem root: it would effectively disable the
149
+ // --permission layer everywhere, and reaching it means none of the path's ancestors exist --
150
+ // the user almost certainly didn't intend this. Ask them to create a parent.
151
+ if (p === dirname(p)) {
152
+ usage(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
153
+ }
154
+ if (!writeAllow.includes(p)) writeAllow.push(p) // both targets often share a parent dir
155
+ }
156
+ nodeArgs.push('--permission', '--allow-fs-read=*')
157
+ for (const p of writeAllow) nodeArgs.push(`--allow-fs-write=${p}`)
158
+ // --permission without --allow-addons removes "node-addons" from resolution
159
+ // conditions, which would change how packages with conditional exports
160
+ // resolve and make the captured import map incompatible with a normal
161
+ // replay. Restore the condition so resolution matches a non-mock run; this
162
+ // doesn't allow native addons to actually load (--allow-addons still off).
163
+ nodeArgs.push('--conditions=node-addons')
164
+ }
165
+ // Non-mock runs use core's loader directly; --mock uses stasis's loader-mock
166
+ // entry, which composes the mock into the same hooks lib (see comment above).
167
+ const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
168
+ nodeArgs.push('--import', import.meta.resolve(loaderEntry))
169
+ const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
76
170
  const [code] = await once(child, 'close')
77
171
  process.exitCode = code
78
172
  } else if (command === 'bundle') {
79
173
  const flags = []
80
- const valueFlags = new Set(['--mapping', '--output', '-o'])
174
+ const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o'])
81
175
  while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
82
176
  flags.push(argv.shift())
83
177
  }
84
178
  const options = {
85
179
  mapping: { type: 'string' },
86
180
  output: { type: 'string', short: 'o' },
181
+ scope: { type: 'string' },
182
+ lockfile: { type: 'string' },
183
+ conditions: { type: 'string' },
184
+ mainFields: { type: 'string' },
185
+ metro: { type: 'boolean' },
186
+ platforms: { type: 'string', multiple: true },
87
187
  }
88
188
  let values
89
189
  try {
@@ -91,19 +191,198 @@ if (command === '-v' || command === '--version') {
91
191
  } catch (cause) {
92
192
  usage(`Error: ${cause.message}`)
93
193
  }
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')
194
+ if (argv.length === 0) usage('Nothing to bundle: no entry file given')
195
+ const allSol = argv.every((f) => f.endsWith('.sol'))
196
+ const allPhp = argv.every((f) => f.endsWith('.php'))
197
+ const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f))
198
+ const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f))
199
+ const allRust = argv.every((f) => f.endsWith('.rs'))
200
+ if (!allSol && !allPhp && !allJs && !allBash && !allRust) {
201
+ 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')
202
+ }
203
+ if (values.mapping && !allSol) usage('Error: --mapping is only valid for .sol bundles')
204
+ if (values.scope && !allJs) usage('Error: --scope is only valid for JS bundles')
205
+ if (values.scope && !['node_modules', 'full'].includes(values.scope)) {
206
+ usage('Error: --scope must be node_modules or full')
207
+ }
208
+ if (values.lockfile && !allJs) usage('Error: --lockfile is only valid for JS bundles')
209
+ // --scope selects a bundle subset; the field resolver (--mainFields/--metro) always
210
+ // emits a full-scope bundle, so the two together would silently ignore --scope.
211
+ if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
212
+ usage('Error: --scope is not supported with --mainFields or --metro')
213
+ }
214
+ // --conditions: comma-separated extra `exports`/`imports` resolution conditions
215
+ // (e.g. react-native,browser) merged on top of the conditions Node always asserts.
216
+ // They steer which branch of a package's conditional `exports` the static scan
217
+ // follows. NOTE: conditions alone do not honour legacy package `mainFields` or
218
+ // platform-specific file suffixes -- see --mainFields.
219
+ if (values.conditions !== undefined && !allJs) usage('Error: --conditions is only valid for JS bundles')
220
+ const conditions = values.conditions === undefined
221
+ ? []
222
+ : values.conditions.split(',').map((s) => s.trim()).filter(Boolean)
223
+ if (values.conditions !== undefined && conditions.length === 0) {
224
+ usage('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
225
+ }
226
+ // --mainFields: comma-separated legacy package entry fields (e.g.
227
+ // react-native,browser,main). Drives the legacy-field resolver for non-`exports`
228
+ // packages -- the entry it picks plus the browser-field-spec object redirection.
229
+ if (values.mainFields !== undefined && !allJs) usage('Error: --mainFields is only valid for JS bundles')
230
+ const mainFields = values.mainFields === undefined
231
+ ? undefined
232
+ : values.mainFields.split(',').map((s) => s.trim()).filter(Boolean)
233
+ if (values.mainFields !== undefined && mainFields.length === 0) {
234
+ usage('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
235
+ }
236
+ // --metro: resolve like Metro/React Native -- it sets its own conditions
237
+ // (react-native, +browser for web) and mainFields, and resolves platform suffixes
238
+ // (.ios/.android/.native) for each --platforms target, bundling all of them at once.
239
+ // --platforms is repeatable and/or comma-separated; the values union.
240
+ const metro = Boolean(values.metro)
241
+ const platforms = [...new Set((values.platforms ?? []).flatMap((p) => p.split(',')).map((s) => s.trim()).filter(Boolean))]
242
+ // A platform name becomes a bundle/lockfile edge key, which the parsers reject if it
243
+ // contains '/'; '*' is the reserved private placeholder. Reject both here so we never
244
+ // emit a bundle the readers can't parse (the divergent-edge case is data-dependent).
245
+ for (const p of platforms) {
246
+ if (p === '*' || p.includes('/')) usage(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
247
+ }
248
+ if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
249
+ if (metro) {
250
+ if (conditions.length > 0) usage("Error: --conditions can't be combined with --metro (it sets its own conditions)")
251
+ if (mainFields !== undefined) usage("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
252
+ if (platforms.length === 0) usage('Error: --metro requires --platforms (e.g. --platforms=ios,android)')
253
+ } else if (platforms.length > 0) {
254
+ usage('Error: --platforms is only valid with --metro')
255
+ }
96
256
  const { bundleCommand } = await import('../src/cmd/bundle.js')
97
257
  await bundleCommand({
98
258
  cwd: process.cwd(),
99
259
  entries: argv,
100
260
  mappingFile: values.mapping,
101
261
  output: values.output,
262
+ scope: values.scope,
263
+ lockfile: values.lockfile,
264
+ conditions,
265
+ mainFields,
266
+ metro,
267
+ platforms,
268
+ })
269
+ } else if (command === 'build') {
270
+ const flags = []
271
+ const valueFlags = new Set(['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'])
272
+ while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
273
+ flags.push(argv.shift())
274
+ }
275
+ const options = {
276
+ output: { type: 'string', short: 'o' },
277
+ format: { type: 'string' },
278
+ platform: { type: 'string' },
279
+ minify: { type: 'boolean' },
280
+ sourcemap: { type: 'boolean' },
281
+ // Repeatable esbuild passthroughs: --define=K=V (one substitution each),
282
+ // --external=PATTERN (one external each), and --loader=.ext:name (one extension
283
+ // loader override each). Each collects into an array.
284
+ define: { type: 'string', multiple: true },
285
+ external: { type: 'string', multiple: true },
286
+ loader: { type: 'string', multiple: true },
287
+ }
288
+ let values
289
+ try {
290
+ ({ values } = parseArgs({ args: flags, options }))
291
+ } catch (cause) {
292
+ usage(`Error: ${cause.message}`)
293
+ }
294
+ if (argv.length === 0) usage('Nothing to build: no bundle or lockfile given')
295
+ if (argv.length > 2) usage('Error: build takes a bundle or lockfile and an optional entry point')
296
+ // esbuild output knobs; the import graph itself is fixed by the artifact, not these.
297
+ if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
298
+ usage('Error: --format must be esm, cjs, or iife')
299
+ }
300
+ if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
301
+ usage('Error: --platform must be node, browser, or neutral')
302
+ }
303
+ if (!values.output) usage('Error: --output is required (a .js/.cjs/.mjs file, or a directory)')
304
+ // `-` is the stdout sentinel for `bundle`/`sbom`, but `build` writes via esbuild and can't
305
+ // stream; without this it would silently create a directory literally named `-`.
306
+ if (values.output === '-') usage('Error: stasis build cannot stream to stdout; --output must be a .js/.cjs/.mjs file or a directory')
307
+ // --define=KEY=VALUE -> esbuild's define map. The value is forwarded to esbuild verbatim,
308
+ // so it must be valid JS the same way esbuild's CLI requires (a JSON literal, an
309
+ // identifier, ...): e.g. --define=process.env.NODE_ENV='"production"' or --define=DEBUG=false.
310
+ const define = {}
311
+ for (const entry of values.define ?? []) {
312
+ const eq = entry.indexOf('=')
313
+ if (eq <= 0) usage(`Error: --define must be KEY=VALUE (got '${entry}')`)
314
+ define[entry.slice(0, eq)] = entry.slice(eq + 1)
315
+ }
316
+ // --loader=.ext:NAME -> esbuild loader override for that extension, e.g.
317
+ // --loader=.js:jsx so a .js file carrying JSX (the React Native convention) parses.
318
+ const loader = {}
319
+ for (const entry of values.loader ?? []) {
320
+ const colon = entry.indexOf(':')
321
+ if (colon <= 0 || !entry.startsWith('.')) usage(`Error: --loader must be .EXT:LOADER (got '${entry}')`)
322
+ loader[entry.slice(0, colon)] = entry.slice(colon + 1)
323
+ }
324
+ const { buildCommand } = await import('../src/cmd/build.js')
325
+ await buildCommand({
326
+ cwd: process.cwd(),
327
+ artifact: argv[0],
328
+ entry: argv[1],
329
+ output: values.output,
330
+ format: values.format,
331
+ platform: values.platform,
332
+ minify: values.minify,
333
+ sourcemap: values.sourcemap,
334
+ define,
335
+ external: values.external ?? [],
336
+ loader,
102
337
  })
338
+ } else if (command === 'extract') {
339
+ const flags = []
340
+ const valueFlags = new Set(['--output', '-o'])
341
+ while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
342
+ flags.push(argv.shift())
343
+ }
344
+ const options = {
345
+ output: { type: 'string', short: 'o' },
346
+ }
347
+ let values
348
+ try {
349
+ ({ values } = parseArgs({ args: flags, options }))
350
+ } catch (cause) {
351
+ usage(`Error: ${cause.message}`)
352
+ }
353
+ if (argv.length === 0) usage('Nothing to extract: no bundle file given')
354
+ if (argv.length > 1) usage('Error: extract takes exactly one bundle file')
355
+ const { extractCommand } = await import('../src/cmd/extract.js')
356
+ extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output })
357
+ } else if (command === 'diff') {
358
+ const flags = []
359
+ while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
360
+ const options = {
361
+ stat: { type: 'boolean' },
362
+ imports: { type: 'boolean' },
363
+ }
364
+ let values
365
+ try {
366
+ ({ values } = parseArgs({ args: flags, options }))
367
+ } catch (cause) {
368
+ usage(`Error: ${cause.message}`)
369
+ }
370
+ // --stat is required until full text diffs land: it reports what changed
371
+ // (modules/files), not the differing content. Reserving bare `stasis diff`
372
+ // keeps the door open for a future content-level diff without a breaking flag.
373
+ if (!values.stat) usage('Error: stasis diff currently requires --stat')
374
+ if (argv.length !== 2) usage('Error: stasis diff takes exactly two files (lockfile or bundle)')
375
+ // --imports additionally diffs the resolution graphs (which import edges were
376
+ // added/removed/redirected); off by default since the edge list is verbose.
377
+ const { diffCommand } = await import('../src/cmd/diff.js')
378
+ const { differences } = diffCommand({ cwd: process.cwd(), left: argv[0], right: argv[1], stat: true, imports: values.imports })
379
+ // Exit non-zero when the artifacts differ, so the command composes in CI
380
+ // (e.g. fail a build when a bundle drifts from its lockfile).
381
+ process.exitCode = differences ? 1 : 0
103
382
  } else if (command === 'prune') {
104
383
  if (argv.length > 1) usage('Error: prune takes at most one path argument')
105
384
  const root = argv[0] ? resolve(argv[0]) : process.cwd()
106
- const { prune } = await import('../src/prune.js')
385
+ const { prune } = await import('@exodus/stasis-core/prune')
107
386
  const { removed, validated } = prune({ root })
108
387
  console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
109
388
  } else if (command === 'audit') {
@@ -113,6 +392,32 @@ if (command === '-v' || command === '--version') {
113
392
  const report = await audit(files)
114
393
  printAuditReport(report)
115
394
  process.exitCode = report.rows.length === 0 ? 0 : 1
395
+ } else if (command === 'sbom') {
396
+ const flags = []
397
+ const valueFlags = new Set(['--format', '--output', '-o'])
398
+ while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
399
+ flags.push(argv.shift())
400
+ }
401
+ const options = {
402
+ format: { type: 'string' },
403
+ output: { type: 'string', short: 'o' },
404
+ }
405
+ let values
406
+ try {
407
+ ({ values } = parseArgs({ args: flags, options }))
408
+ } catch (cause) {
409
+ usage(`Error: ${cause.message}`)
410
+ }
411
+ if (!values.format) usage('Error: stasis sbom requires --format=(spdx|cyclonedx)')
412
+ if (!['spdx', 'cyclonedx'].includes(values.format)) usage('Error: --format must be spdx or cyclonedx')
413
+ if (argv.length === 0) usage('Nothing to export: no lockfile or bundle given')
414
+ const { sbomCommand } = await import('../src/cmd/sbom.js')
415
+ sbomCommand({
416
+ cwd: process.cwd(),
417
+ files: argv,
418
+ format: values.format,
419
+ output: values.output,
420
+ })
116
421
  } else {
117
422
  usage()
118
423
  }
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.0",
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.0",
72
+ "@exodus/stasis-plugins": "1.0.0-beta.0",
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
  }