@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3

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
@@ -2,6 +2,11 @@
2
2
 
3
3
  *Analyzeable source code bundles with resolutions + fine-grained lockfiles.*
4
4
 
5
+ [![Node.js](https://img.shields.io/badge/Node.js-338750?style=for-the-badge&logo=Node.js&logoColor=FFF)](https://nodejs.org/)
6
+ [![esbuild](https://img.shields.io/badge/esbuild-191919?style=for-the-badge&logo=esbuild)](https://esbuild.github.io/)
7
+ [![metro](https://img.shields.io/badge/metro-FFF?style=for-the-badge&logo=Metro&logoColor=Ef4242)](https://metrobundler.dev/)
8
+ [![webpack](https://img.shields.io/badge/WebPack-2B3A42?style=for-the-badge&logo=WebPack)](https://webpack.js.org/)
9
+
5
10
  Both lockfiles and bundles only include what is actually _used_.\
6
11
  Generally about 10x smaller than production-focused `node_modules` install.
7
12
 
@@ -53,9 +58,14 @@ _Lockfiles (npm/pnpm/etc) not mentioned: they are like the "tarball" column, but
53
58
  | `stasis run --lock=add --child-process app.js` | also attest modules loaded in forked child processes (e.g. Metro transform workers) |
54
59
  | `stasis run --bundle=add --fs=sync app.js` | build a bundle that also captures sync `fs.readFileSync`/`readdirSync` reads |
55
60
  | `stasis bundle src/index.js` | build a bundle statically, without executing it |
61
+ | `stasis bundle --add src/worker.js` | merge more entries (and their import graph) into an existing `stasis.code.br` instead of replacing it |
62
+ | `stasis add a.js icon.svg` | add the listed files to the project's bundles (config-driven), with no dependency resolution |
63
+ | `stasis add src assets` | same, expanding each directory to every file under it, minus an auto-excluded set unless explicitly named |
56
64
  | `stasis bundle --conditions=react-native,browser app.js` | statically bundle, asserting extra `exports`/`imports` resolution conditions |
57
65
  | `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 |
66
+ | `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, plus each bundled native dependency's `ios/`/`android/` sources + podspec |
67
+ | `stasis bundle --metro --metro-resolver --platforms=ios,android app.js` | same, but resolve through the project's own `metro-resolver` (byte-for-byte Metro fidelity) instead of the built-in approximation; requires `metro-resolver` installed (it ships with `react-native`/`metro`) |
68
+ | `stasis bundle --flow app.js` | statically bundle Flow-typed sources: when oxc can't parse a `.js`/`.cjs`/`.mjs` file, strip its Flow syntax and retry so the import graph resolves (needs the optional `flow-remove-types` dependency; the stored source stays the original bytes). Combines with `--jsx`/`--conditions`/`--mainFields`/`--metro` |
59
69
  | `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
70
  | `stasis build --output=out.js stasis.lock.json [entry]` | same, reading + verifying sources from disk against the lockfile |
61
71
  | `stasis extract app.stasis.code.br` | unpack a bundle back to sources + a `stasis.lock.json` |
@@ -63,12 +73,16 @@ _Lockfiles (npm/pnpm/etc) not mentioned: they are like the "tarball" column, but
63
73
  | `stasis prune` | trim `node_modules` to the lockfile, verifying the rest |
64
74
  | `stasis audit stasis.lock.json` | report npm advisories for a lockfile's dependencies |
65
75
  | `stasis audit app.stasis.code.br` | report npm advisories for a bundle's dependencies |
76
+ | `stasis audit --why app.stasis.code.br` | same, with the cross-module import paths that pull each flagged package in (`run: a -> b -> c`), prefixed by the consumer that imports each chain at the top level; a chain is skipped when its full tail is already listed as its own chain |
77
+ | `stasis audit --why-deep app.stasis.code.br` | like `--why`, but keep those longer chains too (every path, shared tails collapsed to `a -> b -> ... -> d`) |
78
+ | `stasis audit --why-full app.stasis.code.br` | like `--why`, but spell every chain out with no `...` collapse (combine with `--why-deep` for the complete raw listing) |
79
+ | `stasis audit --reason=run app.stasis.code.br` | show only advisories related to one consumer (`run`); with `--why`, keep only that consumer's chains |
66
80
  | `stasis sbom --format=spdx stasis.lock.json` | export an SPDX SBOM for a lockfile or bundle |
67
81
  | `stasis sbom --format=cyclonedx app.stasis.code.br` | export a CycloneDX SBOM for a lockfile or bundle |
68
82
 
69
83
  ## Runtime
70
84
 
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).
85
+ The zero-dependency [`@exodus/stasis-core`](../stasis-core) CLI provides `run`, `prune`, and `add` commands only; the bundler plugins live in [`@exodus/stasis-plugins`](../stasis-plugins).
72
86
 
73
87
  ## License
74
88
 
package/bin/stasis.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { parseArgs } from 'node:util'
5
4
  import { once } from 'node:events'
6
5
  import { fileURLToPath } from 'node:url'
7
6
  import { basename, dirname, resolve } from 'node:path'
8
7
  import { existsSync, realpathSync } from 'node:fs'
9
8
  import { constants as osConstants } from 'node:os'
10
9
  import assert from 'node:assert/strict'
10
+ import { parseBrotliQuality, parseLeadingOptions, parseResourcesOption } from '@exodus/stasis-core/util'
11
11
  import pkg from '../package.json' with { type: 'json' }
12
12
 
13
13
  const argv = [...process.argv]
@@ -15,26 +15,42 @@ assert(['node', 'node.exe'].includes(basename(argv.shift())))
15
15
  assert(['node', 'node.exe'].includes(basename(process.argv0)))
16
16
 
17
17
  const jsname = argv.shift()
18
- const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
18
+ const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b)
19
19
  assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
20
20
 
21
21
  function usage(prefix = '') {
22
22
  console.error(`${prefix}\nUsage:
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]
23
+ stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|frozen|ignore)] [--bundle-file=path/to/bundle.br] [--resources-bundle-file=path/to/resources.br] [--dependencies] [--child-process] [--package-json] [--mock] [--fs=(sync|async)] [--resources=ext,ext] [--brotli-quality=0..11] path/to/file.js ...
24
+ stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--add] [--output=(path|-)] path/to/file.sol ...
25
+ stasis bundle [--add] [--output=(path|-)] path/to/file.php ...
26
+ stasis bundle [--scope=(node_modules|full)] [--conditions=cond1,cond2] [--mainFields=field1,field2] [--jsx] [--flow] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--add] [--output=(path|-)] path/to/file.(js|ts) ...
27
+ stasis bundle --metro [--metro-resolver] --platforms=ios,android [--platforms=web] [--jsx] [--flow] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--add] [--output=(path|-)] path/to/file.(js|ts) ...
28
+ stasis bundle [--add] [--output=(path|-)] path/to/file.(sh|bash) ...
29
+ stasis bundle [--add] [--output=(path|-)] path/to/file.rs ...
30
+ (writes to stasis.code.br by default; --output=- streams to stdout; --add merges into an
31
+ existing bundle instead of replacing it (not with --output=-); --brotli-quality=0..11, default 9;
32
+ --jsx parses JSX in .js/.cjs/.mjs files, e.g. React Native source (put JSX-in-TS in a .tsx file);
33
+ --flow strips Flow types from .js/.cjs/.mjs sources oxc can't parse (needs the optional flow-remove-types dep);
34
+ --resources carries reached assets (e.g. --resources=png,svg) as resources instead of failing to bundle them;
35
+ --package-json auto-includes each bundled module's package.json, even ones the scan never reached))
36
+ stasis add path/to/(file|dir) ...
37
+ (adds the listed files to the project's bundle(s) with no dependency resolution;
38
+ a directory expands to its files. Requires a stasis.config.json (all fields optional).)
39
+ stasis build --output=(dir|file.js) [--format=(esm|cjs|iife)] [--platform=(node|browser|neutral|hermes)] [--babel] [--minify] [--sourcemap] [--define=K=V ...] [--external=pkg ...] [--loader=.ext:name ...] path/to/(stasis.code.br|stasis.lock.json) [entry]
40
+ (--platform=hermes downlevels every file for Hermes (classes, let/const, arrows, ...; via esbuild + Babel)
41
+ and defaults --format to iife; --babel instead transforms every file with the project's own
42
+ babel.config.(js|cjs|mjs|json); both resolve @babel/core and plugins from the project's dev deps)
32
43
  stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
33
44
  stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
34
45
  stasis prune [path/to/project]
35
- stasis audit path/to/file ...
46
+ stasis audit [--why|--why-deep] [--why-full] [--reason=consumer] path/to/file ...
47
+ (--why lists, per advisory, the cross-module import paths that pull the package in,
48
+ prefixed by the bundle consumer that imports each chain at the top level, e.g. "run: a -> b -> c";
49
+ it skips chains whose full tail is already listed as its own chain -- --why-deep keeps them all;
50
+ --why-full spells every chain out instead of collapsing repeated tails to "a -> b -> ... -> d";
51
+ --reason=consumer shows only advisories related to that consumer, and with --why only its chains)
36
52
  stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
37
- (stasis sbom streams to stdout by default; --output=- is explicit stdout)
53
+ (streams to stdout by default; --output=- is explicit stdout)
38
54
  `.trim())
39
55
  process.exit(1)
40
56
  }
@@ -51,12 +67,6 @@ if (command === '-v' || command === '--version') {
51
67
  console.log(`v${pkg.version}`)
52
68
  process.exit(0)
53
69
  } else if (command === 'run') {
54
- const flags = []
55
- const valueFlags = new Set(['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources'])
56
- while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
57
- flags.push(argv.shift())
58
- }
59
-
60
70
  const options = {
61
71
  lock: { type: 'string', default: 'none' },
62
72
  bundle: { type: 'string', default: 'none' },
@@ -65,17 +75,16 @@ if (command === '-v' || command === '--version') {
65
75
  debug: { type: 'boolean' },
66
76
  dependencies: { type: 'boolean' },
67
77
  'child-process': { type: 'boolean' },
78
+ 'package-json': { type: 'boolean' },
68
79
  mock: { type: 'boolean' },
69
80
  fs: { type: 'string' },
70
81
  resources: { type: 'string' },
82
+ 'brotli-quality': { type: 'string' },
71
83
  }
72
-
73
- let values
74
- try {
75
- ({ values } = parseArgs({ args: flags, options }))
76
- } catch (cause) {
77
- usage(`Error: ${cause.message}`)
78
- }
84
+ const values = parseLeadingOptions(argv, options, {
85
+ valueFlags: ['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources', '--brotli-quality'],
86
+ onError: usage,
87
+ })
79
88
  if (argv.length === 0) usage('Nothing to run: no path to file given')
80
89
  if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
81
90
  const lock = values.lock
@@ -86,28 +95,35 @@ if (command === '-v' || command === '--version') {
86
95
  const debug = values.debug ? '1' : ''
87
96
  if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) usage('Error: invalid --bundle value')
88
97
  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.
98
+ // --resources-bundle-file needs an active bundle mode (unlike --bundle-file, inert under ignore).
92
99
  if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) usage('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
93
100
  if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load is incompatible with --lock=(add|replace)')
94
101
  if (lock === 'none' && bundle === 'none') usage('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
95
102
  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.
103
+ // --fs requires a bundle mode (add|replace|load); `sync` patches the sync fs readers,
104
+ // `async` adds their callback + fs.promises counterparts on top.
101
105
  if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) usage("Error: --fs must be 'sync' or 'async'")
102
106
  if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) usage('Error: --fs requires --bundle=(add|replace|load)')
103
107
  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.
108
+ // --resources: comma-separated extension/filename allowlist for `--fs` captures.
106
109
  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.
110
+ let brotliQuality
111
+ if (values['brotli-quality'] !== undefined) {
112
+ try {
113
+ brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
114
+ } catch (cause) {
115
+ usage(`Error: ${cause.message}`)
116
+ }
117
+ }
118
+ // --child-process: forward forked-child (e.g. Metro worker) capture to the root via per-pid shards.
109
119
  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 }) })
120
+ // --package-json: auto-include every bundled module's package.json. Only meaningful while WRITING
121
+ // a bundle (add|replace) -- there's nothing to fold the manifests into otherwise.
122
+ if (values['package-json'] && bundle !== 'add' && bundle !== 'replace') {
123
+ usage('Error: --package-json requires --bundle=(add|replace)')
124
+ }
125
+ const packageJSON = values['package-json'] ? '1' : ''
126
+ console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }), ...(resourcesBundleFile && { resourcesBundleFile }), ...(childProcess && { childProcess: true }), ...(packageJSON && { packageJSON: true }), ...(values.mock && { mock: true }), ...(values.fs && { fs: values.fs }), ...(resources && { resources }), ...(brotliQuality !== undefined && { brotliQuality }) })
111
127
  if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
112
128
  setEnv('EXODUS_STASIS_LOCK', lock)
113
129
  setEnv('EXODUS_STASIS_SCOPE', scope)
@@ -116,55 +132,38 @@ if (command === '-v' || command === '--version') {
116
132
  setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
117
133
  setEnv('EXODUS_STASIS_DEBUG', debug)
118
134
  setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
135
+ setEnv('EXODUS_STASIS_PACKAGE_JSON', packageJSON)
119
136
  setEnv('EXODUS_STASIS_FS', captureFs)
120
137
  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).
138
+ // Only set when given; an unconditional setEnv('') would conflict with an ambient value.
139
+ if (brotliQuality !== undefined) setEnv('EXODUS_STASIS_BROTLI_QUALITY', String(brotliQuality))
140
+ // --mock: capture imports by running user code with side-effects denied (fail-closed).
141
+ // --permission blocks fs writes/child procs/workers/addons/inspector at the kernel level;
142
+ // src/mock.js neutralizes network + timers in JS (no --allow-* for those). Reads stay open
143
+ // so node_modules resolution works.
135
144
  // Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
136
145
  const nodeArgs = []
137
146
  if (values.mock) {
138
147
  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.)
148
+ // --allow-fs-write paths must already exist on disk, so for each out-of-cwd bundle target
149
+ // walk up to the nearest existing ancestor and grant write there.
145
150
  for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
146
151
  if (!file || file.startsWith(`${process.cwd()}/`)) continue
147
152
  let p = dirname(file)
148
153
  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.
154
+ // Refuse write access to the filesystem root -- it would disable the --permission layer everywhere.
152
155
  if (p === dirname(p)) {
153
156
  usage(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
154
157
  }
155
- if (!writeAllow.includes(p)) writeAllow.push(p) // both targets often share a parent dir
158
+ if (!writeAllow.includes(p)) writeAllow.push(p)
156
159
  }
157
160
  nodeArgs.push('--permission', '--allow-fs-read=*')
158
161
  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).
162
+ // Restore the node-addons resolution condition (dropped by --permission) so the captured
163
+ // import map matches a non-mock run; native addons still can't load (--allow-addons off).
164
164
  nodeArgs.push('--conditions=node-addons')
165
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).
166
+ // --mock uses stasis's loader-mock entry (composes the mock before installing the hooks).
168
167
  const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
169
168
  nodeArgs.push('--import', import.meta.resolve(loaderEntry))
170
169
  const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
@@ -172,11 +171,6 @@ if (command === '-v' || command === '--version') {
172
171
  // code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0
173
172
  process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0)
174
173
  } else if (command === 'bundle') {
175
- const flags = []
176
- const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o'])
177
- while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
178
- flags.push(argv.shift())
179
- }
180
174
  const options = {
181
175
  mapping: { type: 'string' },
182
176
  output: { type: 'string', short: 'o' },
@@ -185,14 +179,19 @@ if (command === '-v' || command === '--version') {
185
179
  conditions: { type: 'string' },
186
180
  mainFields: { type: 'string' },
187
181
  metro: { type: 'boolean' },
182
+ 'metro-resolver': { type: 'boolean' },
188
183
  platforms: { type: 'string', multiple: true },
184
+ jsx: { type: 'boolean' },
185
+ flow: { type: 'boolean' },
186
+ resources: { type: 'string' },
187
+ 'package-json': { type: 'boolean' },
188
+ 'brotli-quality': { type: 'string' },
189
+ add: { type: 'boolean' },
189
190
  }
190
- let values
191
- try {
192
- ({ values } = parseArgs({ args: flags, options }))
193
- } catch (cause) {
194
- usage(`Error: ${cause.message}`)
195
- }
191
+ const values = parseLeadingOptions(argv, options, {
192
+ valueFlags: ['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '--resources', '--brotli-quality', '-o'],
193
+ onError: usage,
194
+ })
196
195
  if (argv.length === 0) usage('Nothing to bundle: no entry file given')
197
196
  const allSol = argv.every((f) => f.endsWith('.sol'))
198
197
  const allPhp = argv.every((f) => f.endsWith('.php'))
@@ -208,16 +207,12 @@ if (command === '-v' || command === '--version') {
208
207
  usage('Error: --scope must be node_modules or full')
209
208
  }
210
209
  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.
210
+ // --mainFields/--metro always emit a full-scope bundle, so they'd silently ignore --scope.
213
211
  if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
214
212
  usage('Error: --scope is not supported with --mainFields or --metro')
215
213
  }
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.
214
+ // --conditions: extra exports/imports resolution conditions merged onto Node's defaults;
215
+ // they don't honour legacy `mainFields` or platform suffixes (see --mainFields).
221
216
  if (values.conditions !== undefined && !allJs) usage('Error: --conditions is only valid for JS bundles')
222
217
  const conditions = values.conditions === undefined
223
218
  ? []
@@ -225,9 +220,10 @@ if (command === '-v' || command === '--version') {
225
220
  if (values.conditions !== undefined && conditions.length === 0) {
226
221
  usage('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
227
222
  }
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.
223
+ // --flow: strip Flow type syntax (via the optional flow-remove-types dep) before parsing; JS-only.
224
+ if (values.flow && !allJs) usage('Error: --flow is only valid for JS bundles')
225
+ const flow = Boolean(values.flow)
226
+ // --mainFields: legacy package entry fields (e.g. react-native,browser,main) for the non-exports resolver.
231
227
  if (values.mainFields !== undefined && !allJs) usage('Error: --mainFields is only valid for JS bundles')
232
228
  const mainFields = values.mainFields === undefined
233
229
  ? undefined
@@ -235,19 +231,42 @@ if (command === '-v' || command === '--version') {
235
231
  if (values.mainFields !== undefined && mainFields.length === 0) {
236
232
  usage('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
237
233
  }
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.
234
+ // --metro: resolve like Metro/RN (its own conditions + mainFields, platform suffixes per
235
+ // --platforms target). --platforms is repeatable and/or comma-separated; the values union.
242
236
  const metro = Boolean(values.metro)
237
+ // --metro-resolver: resolve through the project's own metro-resolver instead of the built-in
238
+ // approximation. Only meaningful alongside --metro (enforced below and in classifyEntries).
239
+ const metroResolver = Boolean(values['metro-resolver'])
243
240
  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).
241
+ // A platform name becomes an edge key: reject '/' (parsers reject it) and '*' (reserved placeholder).
247
242
  for (const p of platforms) {
248
243
  if (p === '*' || p.includes('/')) usage(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
249
244
  }
250
245
  if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
246
+ // --jsx: parse React Native's JSX-in-.js source in the static scanner (off by default; oxc,
247
+ // like tsc, only auto-enables JSX for .jsx/.tsx). Orthogonal to the resolver, so it pairs with
248
+ // plain/--mainFields/--metro alike -- JS-only, since no other entry language is scanned.
249
+ const jsx = Boolean(values.jsx)
250
+ if (jsx && !allJs) usage('Error: --jsx is only valid for JS bundles')
251
+ // --package-json: fold each bundled module's package.json into the bundle. JS-only (only JS
252
+ // modules carry an npm package.json; the sol/php/bash/rust bucketizers have no such manifest).
253
+ const packageJSON = Boolean(values['package-json'])
254
+ if (packageJSON && !allJs) usage('Error: --package-json is only valid for JS bundles')
255
+ // --resources: comma-separated extension/filename allowlist for assets reached through the graph
256
+ // (e.g. --resources=png,svg). They're carried as resources instead of failing "can't carry" --
257
+ // for graphs that aren't fully loadable in JS (Metro consumes such assets). JS-only.
258
+ if (values.resources !== undefined && !allJs) usage('Error: --resources is only valid for JS bundles')
259
+ const resources = values.resources === undefined
260
+ ? []
261
+ : values.resources.split(',').map((s) => s.trim()).filter(Boolean)
262
+ if (values.resources !== undefined && resources.length === 0) {
263
+ usage('Error: --resources must list at least one extension or filename (e.g. --resources=png,svg)')
264
+ }
265
+ try {
266
+ parseResourcesOption('--resources', resources) // validate early for a clean usage error (code exts / bad chars rejected)
267
+ } catch (cause) {
268
+ usage(`Error: ${cause.message}`)
269
+ }
251
270
  if (metro) {
252
271
  if (conditions.length > 0) usage("Error: --conditions can't be combined with --metro (it sets its own conditions)")
253
272
  if (mainFields !== undefined) usage("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
@@ -255,6 +274,19 @@ if (command === '-v' || command === '--version') {
255
274
  } else if (platforms.length > 0) {
256
275
  usage('Error: --platforms is only valid with --metro')
257
276
  }
277
+ if (metroResolver && !metro) usage('Error: --metro-resolver is only valid with --metro')
278
+ // --brotli-quality: valid for every entry language (no allJs gate).
279
+ let brotliQuality
280
+ if (values['brotli-quality'] !== undefined) {
281
+ try {
282
+ brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
283
+ } catch (cause) {
284
+ usage(`Error: ${cause.message}`)
285
+ }
286
+ }
287
+ // --add merges into the existing bundle at --output, so it can't target write-only stdout.
288
+ const add = Boolean(values.add)
289
+ if (add && values.output === '-') usage('Error: --add cannot be combined with --output=-')
258
290
  const { bundleCommand } = await import('../src/cmd/bundle.js')
259
291
  await bundleCommand({
260
292
  cwd: process.cwd(),
@@ -266,57 +298,65 @@ if (command === '-v' || command === '--version') {
266
298
  conditions,
267
299
  mainFields,
268
300
  metro,
301
+ metroResolver,
269
302
  platforms,
303
+ jsx,
304
+ flow,
305
+ resources,
306
+ packageJSON,
307
+ brotliQuality,
308
+ add,
270
309
  })
310
+ } else if (command === 'add') {
311
+ // add packs the listed files (no resolver), taking split targets + resource allowlist from
312
+ // stasis.config.json; it takes no flags.
313
+ if (argv.length === 0) usage('Nothing to add: no file given')
314
+ if (argv.some((a) => a.startsWith('-'))) usage('Error: add takes no options; its targets and resource allowlist come from stasis.config.json')
315
+ const { addCommand } = await import('@exodus/stasis-core/add')
316
+ // Let addCommand's runtime errors propagate as-is; wrapping in usage() would bury the cause.
317
+ addCommand({ cwd: process.cwd(), entries: argv, logLabel: 'stasis' })
271
318
  } 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
319
  const options = {
278
320
  output: { type: 'string', short: 'o' },
279
321
  format: { type: 'string' },
280
322
  platform: { type: 'string' },
323
+ babel: { type: 'boolean' },
281
324
  minify: { type: 'boolean' },
282
325
  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.
326
+ // Repeatable esbuild passthroughs (--define=K=V, --external=PATTERN, --loader=.ext:name).
286
327
  define: { type: 'string', multiple: true },
287
328
  external: { type: 'string', multiple: true },
288
329
  loader: { type: 'string', multiple: true },
289
330
  }
290
- let values
291
- try {
292
- ({ values } = parseArgs({ args: flags, options }))
293
- } catch (cause) {
294
- usage(`Error: ${cause.message}`)
295
- }
331
+ const values = parseLeadingOptions(argv, options, {
332
+ valueFlags: ['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'],
333
+ onError: usage,
334
+ })
296
335
  if (argv.length === 0) usage('Nothing to build: no bundle or lockfile given')
297
336
  if (argv.length > 2) usage('Error: build takes a bundle or lockfile and an optional entry point')
298
337
  // esbuild output knobs; the import graph itself is fixed by the artifact, not these.
299
338
  if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
300
339
  usage('Error: --format must be esm, cjs, or iife')
301
340
  }
302
- if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
303
- usage('Error: --platform must be node, browser, or neutral')
341
+ if (values.platform !== undefined && !['node', 'browser', 'neutral', 'hermes'].includes(values.platform)) {
342
+ usage('Error: --platform must be node, browser, neutral, or hermes')
343
+ }
344
+ // Hermes has no import/export; --format defaults to iife under --platform=hermes (see buildCommand).
345
+ if (values.platform === 'hermes' && values.format === 'esm') {
346
+ usage('Error: --platform=hermes cannot emit ESM; use --format=iife (the hermes default) or cjs')
304
347
  }
305
348
  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 `-`.
349
+ // build can't stream to stdout; without this, --output=- would create a dir literally named `-`.
308
350
  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.
351
+ // --define=KEY=VALUE -> esbuild define map; VALUE is forwarded verbatim and must be valid JS
352
+ // (a JSON literal or an identifier), e.g. --define=DEBUG=false.
312
353
  const define = {}
313
354
  for (const entry of values.define ?? []) {
314
355
  const eq = entry.indexOf('=')
315
356
  if (eq <= 0) usage(`Error: --define must be KEY=VALUE (got '${entry}')`)
316
357
  define[entry.slice(0, eq)] = entry.slice(eq + 1)
317
358
  }
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.
359
+ // --loader=.ext:NAME -> esbuild loader override for that extension (e.g. --loader=.js:jsx).
320
360
  const loader = {}
321
361
  for (const entry of values.loader ?? []) {
322
362
  const colon = entry.indexOf(':')
@@ -331,6 +371,7 @@ if (command === '-v' || command === '--version') {
331
371
  output: values.output,
332
372
  format: values.format,
333
373
  platform: values.platform,
374
+ babel: Boolean(values.babel),
334
375
  minify: values.minify,
335
376
  sourcemap: values.sourcemap,
336
377
  define,
@@ -338,48 +379,28 @@ if (command === '-v' || command === '--version') {
338
379
  loader,
339
380
  })
340
381
  } 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
382
  const options = {
347
383
  output: { type: 'string', short: 'o' },
348
384
  }
349
- let values
350
- try {
351
- ({ values } = parseArgs({ args: flags, options }))
352
- } catch (cause) {
353
- usage(`Error: ${cause.message}`)
354
- }
385
+ const values = parseLeadingOptions(argv, options, { valueFlags: ['--output', '-o'], onError: usage })
355
386
  if (argv.length === 0) usage('Nothing to extract: no bundle file given')
356
387
  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 })
388
+ const { extractCommand } = await import('@exodus/stasis-core/extract')
389
+ extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output, logLabel: 'stasis' })
359
390
  } else if (command === 'diff') {
360
- const flags = []
361
- while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
362
391
  const options = {
363
392
  stat: { type: 'boolean' },
364
393
  imports: { type: 'boolean' },
365
394
  }
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.
395
+ const values = parseLeadingOptions(argv, options, { onError: usage })
396
+ // --stat is required for now (it reports what changed, not the content); bare `stasis diff`
397
+ // is reserved for a future content-level diff.
375
398
  if (!values.stat) usage('Error: stasis diff currently requires --stat')
376
399
  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.
400
+ // --imports also diffs the resolution graphs (added/removed/redirected edges); off by default (verbose).
379
401
  const { diffCommand } = await import('../src/cmd/diff.js')
380
402
  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).
403
+ // Exit non-zero when the artifacts differ, so it composes in CI.
383
404
  process.exitCode = differences ? 1 : 0
384
405
  } else if (command === 'prune') {
385
406
  if (argv.length > 1) usage('Error: prune takes at most one path argument')
@@ -388,28 +409,19 @@ if (command === '-v' || command === '--version') {
388
409
  const { removed, validated } = prune({ root })
389
410
  console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
390
411
  } else if (command === 'audit') {
412
+ const values = parseLeadingOptions(argv, { why: { type: 'boolean' }, 'why-deep': { type: 'boolean' }, 'why-full': { type: 'boolean' }, reason: { type: 'string' } }, { valueFlags: ['--reason'], onError: usage })
391
413
  if (argv.length === 0) usage('Nothing to audit: no path to file given')
392
414
  const { audit, printAuditReport } = await import('../src/audit.js')
393
415
  const files = argv.map((f) => resolve(f))
394
- const report = await audit(files)
416
+ const report = await audit(files, { why: Boolean(values.why), whyDeep: Boolean(values['why-deep']), whyFull: Boolean(values['why-full']), reason: values.reason })
395
417
  printAuditReport(report)
396
418
  process.exitCode = report.rows.length === 0 ? 0 : 1
397
419
  } 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
420
  const options = {
404
421
  format: { type: 'string' },
405
422
  output: { type: 'string', short: 'o' },
406
423
  }
407
- let values
408
- try {
409
- ({ values } = parseArgs({ args: flags, options }))
410
- } catch (cause) {
411
- usage(`Error: ${cause.message}`)
412
- }
424
+ const values = parseLeadingOptions(argv, options, { valueFlags: ['--format', '--output', '-o'], onError: usage })
413
425
  if (!values.format) usage('Error: stasis sbom requires --format=(spdx|cyclonedx)')
414
426
  if (!['spdx', 'cyclonedx'].includes(values.format)) usage('Error: --format must be spdx or cyclonedx')
415
427
  if (argv.length === 0) usage('Nothing to export: no lockfile or bundle given')