@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

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.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/bin/stasis.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { parseArgs } from 'node:util'
5
4
  import { once } from 'node:events'
6
5
  import { fileURLToPath } from 'node:url'
7
- import { basename, dirname, resolve } from 'node:path'
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
8
7
  import { existsSync, realpathSync } from 'node:fs'
8
+ import { homedir, constants as osConstants } from 'node:os'
9
9
  import assert from 'node:assert/strict'
10
+ import { parseBrotliQuality, parseLeadingOptions, parseResourcesOption } from '@exodus/stasis-core/util'
10
11
  import pkg from '../package.json' with { type: 'json' }
11
12
 
12
13
  const argv = [...process.argv]
@@ -14,27 +15,37 @@ assert(['node', 'node.exe'].includes(basename(argv.shift())))
14
15
  assert(['node', 'node.exe'].includes(basename(process.argv0)))
15
16
 
16
17
  const jsname = argv.shift()
17
- 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)
18
19
  assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
19
20
 
20
- function usage(prefix = '') {
21
- console.error(`${prefix}\nUsage:
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]
21
+ const HELP = `Usage:
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] [--package-json] [--mock] [--import=module ...] [--fs=(sync|async)] [--resources=ext,ext] [--brotli-quality=0..11] path/to/file.js ...
23
+ stasis bundle [--scope=(node_modules|full)] [--conditions=cond1,cond2] [--mainFields=field1,field2] [--metro [--metro-resolver] --platforms=ios,android] [--jsx] [--flow] [--typescript [--tsconfig=path/to/tsconfig.json]] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--brotli-quality=0..11] [--add] [--output=(path|-)] path/to/file.(js|ts|jsx|tsx) ...
24
+ stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--manifests] [--add] [--output=(path|-)] path/to/(file.sol|dir) ...
25
+ stasis bundle [--cargo] [--cargo-features=a,b,pkg/c] [--cargo-no-default-features] [--cargo-all-features] [--cargo-target=(triple|host)] [--cargo-manifests] [--add] [--output=(path|-)] path/to/file.rs ...
26
+ stasis bundle [--add] [--output=(path|-)] path/to/file.(php|sh|bash) ...
27
+ stasis github-bundle --github=owner/name [--sha=commit|--tag=name] [--directory=path] [--package-manager=(pnpm|yarn1|npm|soldeer) [--package-manager-version=version]] [--generate=prisma] [--lockfile=path/to/stasis.lock.json] [--output=(path|-)] [stasis bundle's options for the entries] [path/in/repo/to/(file.(js|ts|jsx|tsx)|file.sol|dir) ...]
28
+ stasis add path/to/(file|dir) ...
29
+ 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]
31
30
  stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
32
31
  stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
33
32
  stasis prune [path/to/project]
34
- stasis audit path/to/file ...
33
+ stasis audit [--why|--why-deep] [--why-full] [--reason=consumer] [--repo-advisories] path/to/(lockfile|bundle) ...
35
34
  stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
36
- (stasis sbom streams to stdout by default; --output=- is explicit stdout)
37
- `.trim())
35
+ stasis --version
36
+
37
+ Each command's options: https://github.com/PreventiveMeasures/stasis/blob/main/doc/<command>.md
38
+ (run, bundle, github-bundle, build, extract, diff, prune, audit, sbom; add is in bundle.md)`
39
+
40
+ // The help, on stderr. Exits 0 when asked for (--help), 1 when printed for want of a command.
41
+ function usage(asked = false) {
42
+ console.error(HELP)
43
+ process.exit(asked ? 0 : 1)
44
+ }
45
+
46
+ // A usage error: the message and where the help is, never the help itself.
47
+ function fail(message) {
48
+ console.error(`${message}\nRun 'stasis --help' for usage.`)
38
49
  process.exit(1)
39
50
  }
40
51
 
@@ -44,18 +55,22 @@ function setEnv(name, value) {
44
55
  process.env[name] = value === undefined ? '' : value
45
56
  }
46
57
 
58
+ // The per-user cache directory, as each platform names it (XDG ignores a relative path).
59
+ function userCacheDir() {
60
+ if (isAbsolute(process.env.XDG_CACHE_HOME ?? '')) return join(process.env.XDG_CACHE_HOME, 'stasis')
61
+ if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'stasis')
62
+ if (process.platform === 'win32') return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'stasis', 'Cache')
63
+ return join(homedir(), '.cache', 'stasis')
64
+ }
65
+
47
66
  const command = argv.shift()
48
67
 
49
68
  if (command === '-v' || command === '--version') {
50
69
  console.log(`v${pkg.version}`)
51
70
  process.exit(0)
71
+ } else if (command === '-h' || command === '--help') {
72
+ usage(true)
52
73
  } else if (command === 'run') {
53
- const flags = []
54
- const valueFlags = new Set(['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--resources'])
55
- while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
56
- flags.push(argv.shift())
57
- }
58
-
59
74
  const options = {
60
75
  lock: { type: 'string', default: 'none' },
61
76
  bundle: { type: 'string', default: 'none' },
@@ -64,49 +79,65 @@ if (command === '-v' || command === '--version') {
64
79
  debug: { type: 'boolean' },
65
80
  dependencies: { type: 'boolean' },
66
81
  'child-process': { type: 'boolean' },
82
+ 'package-json': { type: 'boolean' },
67
83
  mock: { type: 'boolean' },
84
+ import: { type: 'string', multiple: true },
68
85
  fs: { type: 'string' },
69
86
  resources: { type: 'string' },
87
+ 'brotli-quality': { type: 'string' },
70
88
  }
71
-
72
- let values
73
- try {
74
- ({ values } = parseArgs({ args: flags, options }))
75
- } catch (cause) {
76
- usage(`Error: ${cause.message}`)
77
- }
78
- if (argv.length === 0) usage('Nothing to run: no path to file given')
79
- if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
89
+ const values = parseLeadingOptions(argv, options, {
90
+ valueFlags: ['--bundle', '--bundle-file', '--resources-bundle-file', '--lock', '--import', '--resources', '--brotli-quality'],
91
+ onError: fail,
92
+ })
93
+ if (argv.length === 0) fail('Nothing to run: no path to file given')
94
+ if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) fail('Error: invalid --lock value')
80
95
  const lock = values.lock
81
96
  const scope = values.dependencies ? 'node_modules' : 'full'
82
97
  const bundle = values.bundle
83
98
  const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
84
99
  const resourcesBundleFile = values['resources-bundle-file'] ? resolve(values['resources-bundle-file']) : ''
85
100
  const debug = values.debug ? '1' : ''
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)')
101
+ if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) fail('Error: invalid --bundle value')
102
+ if (bundleFile && bundle === 'none') fail('Error: --bundle-file requires --bundle=(add|replace|load|frozen|ignore)')
103
+ // --resources-bundle-file needs an active bundle mode (unlike --bundle-file, inert under ignore).
104
+ if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) fail('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
105
+ if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') fail('Error: --bundle=load is incompatible with --lock=(add|replace)')
106
+ if (lock === 'none' && bundle === 'none') fail('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
107
+ if (values.mock && bundle === 'load') fail('Error: --mock is for capturing imports while building a bundle; not compatible with --bundle=load')
108
+ // --fs requires a bundle mode (add|replace|load); `sync` patches the sync fs readers,
109
+ // `async` adds their callback + fs.promises counterparts on top.
110
+ if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) fail("Error: --fs must be 'sync' or 'async'")
111
+ if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) fail('Error: --fs requires --bundle=(add|replace|load)')
102
112
  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.
113
+ // --resources: comma-separated extension/filename allowlist for `--fs` captures.
105
114
  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.
115
+ let brotliQuality
116
+ if (values['brotli-quality'] !== undefined) {
117
+ try {
118
+ brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
119
+ } catch (cause) {
120
+ fail(`Error: ${cause.message}`)
121
+ }
122
+ }
123
+ // --import: extra preload module(s) passed through to the spawned node. Node resolves each
124
+ // against the project cwd; they ride AFTER stasis's own loader import.
125
+ const imports = values.import ?? []
126
+ if (imports.some((s) => s === '')) fail('Error: --import requires a module specifier (e.g. --import=./instrument.mjs)')
127
+ // Not a usage error: plain instrumentation/setup preloads still work under the mock; only ones
128
+ // that need the denied surfaces (spawning helper processes, network) die, and they do so loudly.
129
+ if (values.mock && imports.length > 0) {
130
+ console.warn("[stasis] Warning: --import preloads run under --mock's side-effect denials; a preload that spawns helper processes will fail there")
131
+ }
132
+ // --child-process: forward forked-child (e.g. Metro worker) capture to the root via per-pid shards.
108
133
  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 }) })
134
+ // --package-json: auto-include every bundled module's package.json. Only meaningful while WRITING
135
+ // a bundle (add|replace) -- there's nothing to fold the manifests into otherwise.
136
+ if (values['package-json'] && bundle !== 'add' && bundle !== 'replace') {
137
+ fail('Error: --package-json requires --bundle=(add|replace)')
138
+ }
139
+ const packageJSON = values['package-json'] ? '1' : ''
140
+ console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }), ...(resourcesBundleFile && { resourcesBundleFile }), ...(childProcess && { childProcess: true }), ...(packageJSON && { packageJSON: true }), ...(values.mock && { mock: true }), ...(imports.length > 0 && { import: imports }), ...(values.fs && { fs: values.fs }), ...(resources && { resources }), ...(brotliQuality !== undefined && { brotliQuality }) })
110
141
  if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
111
142
  setEnv('EXODUS_STASIS_LOCK', lock)
112
143
  setEnv('EXODUS_STASIS_SCOPE', scope)
@@ -115,210 +146,371 @@ if (command === '-v' || command === '--version') {
115
146
  setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
116
147
  setEnv('EXODUS_STASIS_DEBUG', debug)
117
148
  setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
149
+ setEnv('EXODUS_STASIS_PACKAGE_JSON', packageJSON)
118
150
  setEnv('EXODUS_STASIS_FS', captureFs)
119
151
  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).
152
+ // Only set when given; an unconditional setEnv('') would conflict with an ambient value.
153
+ if (brotliQuality !== undefined) setEnv('EXODUS_STASIS_BROTLI_QUALITY', String(brotliQuality))
154
+ // --mock: capture imports by running user code with side-effects denied (fail-closed).
155
+ // --permission blocks fs writes/child procs/workers/addons/inspector at the kernel level;
156
+ // src/mock.js neutralizes network + timers in JS (no --allow-* for those). Reads stay open
157
+ // so node_modules resolution works.
134
158
  // Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
135
159
  const nodeArgs = []
136
160
  if (values.mock) {
137
161
  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.)
162
+ // --allow-fs-write paths must already exist on disk, so for each out-of-cwd bundle target
163
+ // walk up to the nearest existing ancestor and grant write there.
144
164
  for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
145
165
  if (!file || file.startsWith(`${process.cwd()}/`)) continue
146
166
  let p = dirname(file)
147
167
  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.
168
+ // Refuse write access to the filesystem root -- it would disable the --permission layer everywhere.
151
169
  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`)
170
+ fail(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
153
171
  }
154
- if (!writeAllow.includes(p)) writeAllow.push(p) // both targets often share a parent dir
172
+ if (!writeAllow.includes(p)) writeAllow.push(p)
155
173
  }
156
174
  nodeArgs.push('--permission', '--allow-fs-read=*')
157
175
  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).
176
+ // Restore the node-addons resolution condition (dropped by --permission) so the captured
177
+ // import map matches a non-mock run; native addons still can't load (--allow-addons off).
163
178
  nodeArgs.push('--conditions=node-addons')
164
179
  }
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).
180
+ // --mock uses stasis's loader-mock entry (composes the mock before installing the hooks).
167
181
  const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
168
182
  nodeArgs.push('--import', import.meta.resolve(loaderEntry))
183
+ // Passthrough preloads ride after the loader (and after --mock's denials), so they evaluate
184
+ // under its hooks; their module graphs pass through uncaptured unless the app graph reaches
185
+ // them -- then they're promoted into the capture (see core hooks.js).
186
+ for (const specifier of imports) nodeArgs.push('--import', specifier)
169
187
  const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
170
- const [code] = await once(child, 'close')
171
- process.exitCode = code
188
+ const [code, signal] = await once(child, 'close')
189
+ // code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0
190
+ process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0)
172
191
  } else if (command === 'bundle') {
173
- const flags = []
174
- const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o'])
175
- while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
176
- flags.push(argv.shift())
177
- }
178
192
  const options = {
179
193
  mapping: { type: 'string' },
194
+ manifests: { type: 'boolean' },
180
195
  output: { type: 'string', short: 'o' },
181
196
  scope: { type: 'string' },
182
197
  lockfile: { type: 'string' },
183
198
  conditions: { type: 'string' },
184
199
  mainFields: { type: 'string' },
185
200
  metro: { type: 'boolean' },
201
+ 'metro-resolver': { type: 'boolean' },
186
202
  platforms: { type: 'string', multiple: true },
203
+ jsx: { type: 'boolean' },
204
+ flow: { type: 'boolean' },
205
+ typescript: { type: 'boolean' },
206
+ tsconfig: { type: 'string' },
207
+ resources: { type: 'string' },
208
+ 'package-json': { type: 'boolean' },
209
+ cargo: { type: 'boolean' },
210
+ 'cargo-features': { type: 'string', multiple: true },
211
+ 'cargo-no-default-features': { type: 'boolean' },
212
+ 'cargo-all-features': { type: 'boolean' },
213
+ 'cargo-target': { type: 'string' },
214
+ 'cargo-manifests': { type: 'boolean' },
215
+ 'brotli-quality': { type: 'string' },
216
+ add: { type: 'boolean' },
187
217
  }
188
- let values
189
- try {
190
- ({ values } = parseArgs({ args: flags, options }))
191
- } catch (cause) {
192
- usage(`Error: ${cause.message}`)
193
- }
194
- if (argv.length === 0) usage('Nothing to bundle: no entry file given')
195
- const allSol = argv.every((f) => f.endsWith('.sol'))
218
+ const values = parseLeadingOptions(argv, options, {
219
+ valueFlags: ['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '--resources', '--tsconfig', '--cargo-features', '--cargo-target', '--brotli-quality', '-o'],
220
+ onError: fail,
221
+ })
222
+ if (argv.length === 0) fail('Nothing to bundle: no entry file given')
223
+ // A directory entry stands for the .sol files under it (Solidity only); an extensionless path
224
+ // that doesn't exist is a missing one (skipped with a warning).
225
+ const { directoryEntryError, isSolidityEntry } = await import('../src/cmd/bundle.js')
226
+ const dirError = directoryEntryError(argv)
227
+ if (dirError !== null) fail(`Error: ${dirError}`)
228
+ const allSol = argv.every((f) => isSolidityEntry(f))
196
229
  const allPhp = argv.every((f) => f.endsWith('.php'))
197
- const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(f))
230
+ const allJs = argv.every((f) => /\.(?:js|cjs|mjs|ts|cts|mts|jsx|tsx)$/u.test(f))
198
231
  const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f))
199
232
  const allRust = argv.every((f) => f.endsWith('.rs'))
200
233
  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')
234
+ fail('Error: bundle entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx, all be .sh/.bash, or all be .rs')
202
235
  }
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')
236
+ if (values.mapping && !allSol) fail('Error: --mapping is only valid for .sol bundles')
237
+ // --manifests: carry the Solidity build's description files (foundry.toml, remappings.txt, ...).
238
+ const manifests = Boolean(values.manifests)
239
+ if (manifests && !allSol) fail('Error: --manifests is only valid for .sol bundles')
240
+ // --cargo: take the Rust feature/dependency resolution from `cargo metadata` (runs cargo; opt-in).
241
+ const cargo = Boolean(values.cargo)
242
+ if (cargo && !allRust) fail('Error: --cargo is only valid for Rust bundles')
243
+ // --cargo-features / --cargo-no-default-features / --cargo-all-features: cargo's own feature flags
244
+ // for the entries' packages (`pkg/feat` targets one, or a dependency). --cargo-features is
245
+ // repeatable and/or comma-separated; parseFeatureList is the one splitter (the resolver reuses it).
246
+ const { parseFeatureList } = await import('../src/loaders/cargo.js')
247
+ const cargoFeatures = parseFeatureList(values['cargo-features'] ?? [])
248
+ if (values['cargo-features'] !== undefined && cargoFeatures.length === 0) {
249
+ fail('Error: --cargo-features must list at least one feature (e.g. --cargo-features=serde,app/tls)')
250
+ }
251
+ const cargoNoDefaultFeatures = Boolean(values['cargo-no-default-features'])
252
+ const cargoAllFeatures = Boolean(values['cargo-all-features'])
253
+ // --cargo-target=<triple|host>: the build's target, whose cfgs (from `rustc --print cfg`) decide
254
+ // `#[cfg(unix)]`-style code. A triple is letters, digits, `-`, `_` and `.` (`x86_64-unknown-linux-gnu`).
255
+ const cargoTarget = values['cargo-target'] ?? null
256
+ if (cargoTarget !== null && !/^[\w.-]+$/u.test(cargoTarget)) {
257
+ fail('Error: --cargo-target must be a target triple or "host" (e.g. --cargo-target=aarch64-apple-darwin)')
258
+ }
259
+ // --cargo-manifests: carry each bundled package's Cargo.toml and build script, the workspace
260
+ // Cargo.toml, Cargo.lock and .cargo/config.toml too (the Rust counterpart of --package-json).
261
+ const cargoManifests = Boolean(values['cargo-manifests'])
262
+ if (!allRust) {
263
+ const given = { 'cargo-features': cargoFeatures.length > 0, 'cargo-no-default-features': cargoNoDefaultFeatures, 'cargo-all-features': cargoAllFeatures, 'cargo-target': cargoTarget !== null, 'cargo-manifests': cargoManifests }
264
+ for (const [flag, on] of Object.entries(given)) if (on) fail(`Error: --${flag} is only valid for Rust bundles`)
265
+ }
266
+ if (values.scope && !allJs) fail('Error: --scope is only valid for JS bundles')
205
267
  if (values.scope && !['node_modules', 'full'].includes(values.scope)) {
206
- usage('Error: --scope must be node_modules or full')
268
+ fail('Error: --scope must be node_modules or full')
207
269
  }
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.
270
+ if (values.lockfile && !allJs) fail('Error: --lockfile is only valid for JS bundles')
271
+ // --mainFields/--metro always emit a full-scope bundle, so they'd silently ignore --scope.
211
272
  if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
212
- usage('Error: --scope is not supported with --mainFields or --metro')
273
+ fail('Error: --scope is not supported with --mainFields or --metro')
213
274
  }
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')
275
+ // --conditions: extra exports/imports resolution conditions merged onto Node's defaults;
276
+ // they don't honour legacy `mainFields` or platform suffixes (see --mainFields).
277
+ if (values.conditions !== undefined && !allJs) fail('Error: --conditions is only valid for JS bundles')
220
278
  const conditions = values.conditions === undefined
221
279
  ? []
222
280
  : values.conditions.split(',').map((s) => s.trim()).filter(Boolean)
223
281
  if (values.conditions !== undefined && conditions.length === 0) {
224
- usage('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
282
+ fail('Error: --conditions must list at least one condition name (e.g. --conditions=react-native,browser)')
225
283
  }
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')
284
+ // --flow: strip Flow type syntax (via the optional flow-remove-types dep) before parsing; JS-only.
285
+ if (values.flow && !allJs) fail('Error: --flow is only valid for JS bundles')
286
+ const flow = Boolean(values.flow)
287
+ // --typescript: resolve like tsc -- a specifier naming a missing JS output (./x.js, .mjs, .cjs,
288
+ // extensionless) lands on its on-disk TS source (./x.ts, .mts, .cts); an existing .js always wins.
289
+ if (values.typescript && !allJs) fail('Error: --typescript is only valid for JS bundles')
290
+ const typescript = Boolean(values.typescript)
291
+ // --tsconfig: the config whose compilerOptions.paths aliases --typescript honours (default: the
292
+ // tsconfig.json beside each importing file's package.json). Meaningless without --typescript.
293
+ if (values.tsconfig !== undefined && !typescript) fail('Error: --tsconfig is only valid with --typescript')
294
+ // --mainFields: legacy package entry fields (e.g. react-native,browser,main) for the non-exports resolver.
295
+ if (values.mainFields !== undefined && !allJs) fail('Error: --mainFields is only valid for JS bundles')
230
296
  const mainFields = values.mainFields === undefined
231
297
  ? undefined
232
298
  : values.mainFields.split(',').map((s) => s.trim()).filter(Boolean)
233
299
  if (values.mainFields !== undefined && mainFields.length === 0) {
234
- usage('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
300
+ fail('Error: --mainFields must list at least one field (e.g. --mainFields=react-native,browser,main)')
235
301
  }
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.
302
+ // --metro: resolve like Metro/RN (its own conditions + mainFields, platform suffixes per
303
+ // --platforms target). --platforms is repeatable and/or comma-separated; the values union.
240
304
  const metro = Boolean(values.metro)
305
+ // --metro-resolver: resolve through the project's own metro-resolver instead of the built-in
306
+ // approximation. Only meaningful alongside --metro (enforced below and in classifyEntries).
307
+ const metroResolver = Boolean(values['metro-resolver'])
241
308
  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).
309
+ // A platform name becomes an edge key: reject '/' (parsers reject it) and '*' (reserved placeholder).
245
310
  for (const p of platforms) {
246
- if (p === '*' || p.includes('/')) usage(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
311
+ if (p === '*' || p.includes('/')) fail(`Error: invalid --platforms value '${p}' (a platform name can't contain '/' or be '*')`)
312
+ }
313
+ if ((metro || platforms.length > 0) && !allJs) fail('Error: --metro is only valid for JS bundles')
314
+ // --jsx: parse React Native's JSX-in-.js source in the static scanner (off by default; oxc,
315
+ // like tsc, auto-enables JSX for .jsx/.tsx only). Orthogonal to the resolver, so it pairs with
316
+ // plain/--mainFields/--metro alike -- JS-only, since no other entry language is scanned.
317
+ const jsx = Boolean(values.jsx)
318
+ if (jsx && !allJs) fail('Error: --jsx is only valid for JS bundles')
319
+ // --package-json: fold each bundled module's package.json into the bundle. JS-only (only JS
320
+ // modules carry an npm package.json; the sol/php/bash/rust bucketizers have no such manifest).
321
+ const packageJSON = Boolean(values['package-json'])
322
+ if (packageJSON && !allJs) fail('Error: --package-json is only valid for JS bundles')
323
+ // --resources: comma-separated extension/filename allowlist for assets reached through the graph
324
+ // (e.g. --resources=png,svg). They're carried as resources instead of failing "can't carry" --
325
+ // for graphs that aren't fully loadable in JS (Metro consumes such assets). JS-only.
326
+ if (values.resources !== undefined && !allJs) fail('Error: --resources is only valid for JS bundles')
327
+ const resources = values.resources === undefined
328
+ ? []
329
+ : values.resources.split(',').map((s) => s.trim()).filter(Boolean)
330
+ if (values.resources !== undefined && resources.length === 0) {
331
+ fail('Error: --resources must list at least one extension or filename (e.g. --resources=png,svg)')
332
+ }
333
+ try {
334
+ parseResourcesOption('--resources', resources) // validate early for a clean usage error (code exts / bad chars rejected)
335
+ } catch (cause) {
336
+ fail(`Error: ${cause.message}`)
247
337
  }
248
- if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
249
338
  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)')
339
+ if (conditions.length > 0) fail("Error: --conditions can't be combined with --metro (it sets its own conditions)")
340
+ if (mainFields !== undefined) fail("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
341
+ if (platforms.length === 0) fail('Error: --metro requires --platforms (e.g. --platforms=ios,android)')
253
342
  } else if (platforms.length > 0) {
254
- usage('Error: --platforms is only valid with --metro')
343
+ fail('Error: --platforms is only valid with --metro')
255
344
  }
345
+ if (metroResolver && !metro) fail('Error: --metro-resolver is only valid with --metro')
346
+ // metro-resolver can't substitute .js -> .ts, so --typescript would silently not apply -- reject it.
347
+ if (typescript && metroResolver) fail("Error: --typescript is not supported with --metro-resolver (the project's metro-resolver doesn't substitute .js -> .ts)")
348
+ // --brotli-quality: valid for every entry language (no allJs gate).
349
+ let brotliQuality
350
+ if (values['brotli-quality'] !== undefined) {
351
+ try {
352
+ brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
353
+ } catch (cause) {
354
+ fail(`Error: ${cause.message}`)
355
+ }
356
+ }
357
+ // --add merges into the existing bundle at --output, so it can't target write-only stdout.
358
+ const add = Boolean(values.add)
359
+ if (add && values.output === '-') fail('Error: --add cannot be combined with --output=-')
256
360
  const { bundleCommand } = await import('../src/cmd/bundle.js')
257
361
  await bundleCommand({
258
362
  cwd: process.cwd(),
259
363
  entries: argv,
260
364
  mappingFile: values.mapping,
365
+ manifests,
261
366
  output: values.output,
262
367
  scope: values.scope,
263
368
  lockfile: values.lockfile,
264
369
  conditions,
265
370
  mainFields,
266
371
  metro,
372
+ metroResolver,
267
373
  platforms,
374
+ jsx,
375
+ flow,
376
+ typescript,
377
+ tsconfig: values.tsconfig,
378
+ resources,
379
+ packageJSON,
380
+ cargo,
381
+ cargoFeatures,
382
+ cargoNoDefaultFeatures,
383
+ cargoAllFeatures,
384
+ cargoTarget,
385
+ cargoManifests,
386
+ brotliQuality,
387
+ add,
268
388
  })
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())
389
+ } else if (command === 'github-bundle') {
390
+ const values = parseLeadingOptions(argv, {
391
+ github: { type: 'string' },
392
+ sha: { type: 'string' },
393
+ tag: { type: 'string' },
394
+ directory: { type: 'string' },
395
+ 'package-manager': { type: 'string' },
396
+ 'package-manager-version': { type: 'string' },
397
+ mapping: { type: 'string' },
398
+ manifests: { type: 'boolean' },
399
+ output: { type: 'string', short: 'o' },
400
+ scope: { type: 'string' },
401
+ lockfile: { type: 'string' },
402
+ conditions: { type: 'string' },
403
+ mainFields: { type: 'string' },
404
+ metro: { type: 'boolean' },
405
+ platforms: { type: 'string', multiple: true },
406
+ jsx: { type: 'boolean' },
407
+ flow: { type: 'boolean' },
408
+ typescript: { type: 'boolean' },
409
+ tsconfig: { type: 'string' },
410
+ resources: { type: 'string' },
411
+ 'package-json': { type: 'boolean' },
412
+ 'brotli-quality': { type: 'string' },
413
+ generate: { type: 'string', multiple: true },
414
+ }, {
415
+ valueFlags: ['--github', '--sha', '--tag', '--directory', '--package-manager', '--package-manager-version', '--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '--tsconfig', '--resources', '--brotli-quality', '--generate', '-o'],
416
+ onError: fail,
417
+ })
418
+ if (values.github === undefined) fail('Error: github-bundle requires --github=owner/name, the repo to bundle')
419
+ if (values.sha !== undefined && values.tag !== undefined) fail('Error: github-bundle takes --sha or --tag, not both')
420
+ // Whether each option applies to the entries is buildGitHubBundle's to say.
421
+ const list = (value) => [value ?? []].flat().flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean)
422
+ let brotliQuality
423
+ if (values['brotli-quality'] !== undefined) {
424
+ try {
425
+ brotliQuality = parseBrotliQuality('--brotli-quality', values['brotli-quality'])
426
+ } catch (cause) {
427
+ fail(`Error: ${cause.message}`)
428
+ }
274
429
  }
430
+ // Tarballs, zips and GitHub trees are cached where `stasis audit` caches.
431
+ const { setCacheDir } = await import('../src/vfs-bundle.js')
432
+ setCacheDir(userCacheDir())
433
+ const { githubBundleCommand } = await import('../src/cmd/github-bundle.js')
434
+ await githubBundleCommand({
435
+ github: values.github,
436
+ sha: values.sha,
437
+ tag: values.tag,
438
+ directory: values.directory,
439
+ packageManager: values['package-manager'],
440
+ packageManagerVersion: values['package-manager-version'],
441
+ entries: argv.length === 0 ? undefined : argv,
442
+ mappingFile: values.mapping,
443
+ manifests: Boolean(values.manifests),
444
+ output: values.output,
445
+ lockfile: values.lockfile,
446
+ scope: values.scope,
447
+ conditions: list(values.conditions),
448
+ mainFields: values.mainFields === undefined ? undefined : list(values.mainFields),
449
+ metro: Boolean(values.metro),
450
+ platforms: list(values.platforms),
451
+ jsx: Boolean(values.jsx),
452
+ flow: Boolean(values.flow),
453
+ typescript: Boolean(values.typescript),
454
+ tsconfig: values.tsconfig,
455
+ resources: list(values.resources),
456
+ packageJSON: Boolean(values['package-json']),
457
+ generate: list(values.generate),
458
+ brotliQuality,
459
+ })
460
+ } else if (command === 'add') {
461
+ // add packs the listed files (no resolver), taking split targets + resource allowlist from
462
+ // stasis.config.json; it takes no flags.
463
+ if (argv.length === 0) fail('Nothing to add: no file given')
464
+ if (argv.some((a) => a.startsWith('-'))) fail('Error: add takes no options; its targets and resource allowlist come from stasis.config.json')
465
+ const { addCommand } = await import('@exodus/stasis-core/add')
466
+ // Let addCommand's runtime errors propagate as-is; wrapping in fail() would bury the cause.
467
+ addCommand({ cwd: process.cwd(), entries: argv, logLabel: 'stasis' })
468
+ } else if (command === 'build') {
275
469
  const options = {
276
470
  output: { type: 'string', short: 'o' },
277
471
  format: { type: 'string' },
278
472
  platform: { type: 'string' },
473
+ babel: { type: 'boolean' },
279
474
  minify: { type: 'boolean' },
280
475
  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.
476
+ // Repeatable esbuild passthroughs (--define=K=V, --external=PATTERN, --loader=.ext:name).
284
477
  define: { type: 'string', multiple: true },
285
478
  external: { type: 'string', multiple: true },
286
479
  loader: { type: 'string', multiple: true },
287
480
  }
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')
481
+ const values = parseLeadingOptions(argv, options, {
482
+ valueFlags: ['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'],
483
+ onError: fail,
484
+ })
485
+ if (argv.length === 0) fail('Nothing to build: no bundle or lockfile given')
486
+ if (argv.length > 2) fail('Error: build takes a bundle or lockfile and an optional entry point')
296
487
  // esbuild output knobs; the import graph itself is fixed by the artifact, not these.
297
488
  if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
298
- usage('Error: --format must be esm, cjs, or iife')
489
+ fail('Error: --format must be esm, cjs, or iife')
299
490
  }
300
- if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
301
- usage('Error: --platform must be node, browser, or neutral')
491
+ if (values.platform !== undefined && !['node', 'browser', 'neutral', 'hermes'].includes(values.platform)) {
492
+ fail('Error: --platform must be node, browser, neutral, or hermes')
302
493
  }
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.
494
+ // Hermes has no import/export; --format defaults to iife under --platform=hermes (see buildCommand).
495
+ if (values.platform === 'hermes' && values.format === 'esm') {
496
+ fail('Error: --platform=hermes cannot emit ESM; use --format=iife (the hermes default) or cjs')
497
+ }
498
+ if (!values.output) fail('Error: --output is required (a .js/.cjs/.mjs file, or a directory)')
499
+ // build can't stream to stdout; without this, --output=- would create a dir literally named `-`.
500
+ if (values.output === '-') fail('Error: stasis build cannot stream to stdout; --output must be a .js/.cjs/.mjs file or a directory')
501
+ // --define=KEY=VALUE -> esbuild define map; VALUE is forwarded verbatim and must be valid JS
502
+ // (a JSON literal or an identifier), e.g. --define=DEBUG=false.
310
503
  const define = {}
311
504
  for (const entry of values.define ?? []) {
312
505
  const eq = entry.indexOf('=')
313
- if (eq <= 0) usage(`Error: --define must be KEY=VALUE (got '${entry}')`)
506
+ if (eq <= 0) fail(`Error: --define must be KEY=VALUE (got '${entry}')`)
314
507
  define[entry.slice(0, eq)] = entry.slice(eq + 1)
315
508
  }
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.
509
+ // --loader=.ext:NAME -> esbuild loader override for that extension (e.g. --loader=.js:jsx).
318
510
  const loader = {}
319
511
  for (const entry of values.loader ?? []) {
320
512
  const colon = entry.indexOf(':')
321
- if (colon <= 0 || !entry.startsWith('.')) usage(`Error: --loader must be .EXT:LOADER (got '${entry}')`)
513
+ if (colon <= 0 || !entry.startsWith('.')) fail(`Error: --loader must be .EXT:LOADER (got '${entry}')`)
322
514
  loader[entry.slice(0, colon)] = entry.slice(colon + 1)
323
515
  }
324
516
  const { buildCommand } = await import('../src/cmd/build.js')
@@ -329,6 +521,7 @@ if (command === '-v' || command === '--version') {
329
521
  output: values.output,
330
522
  format: values.format,
331
523
  platform: values.platform,
524
+ babel: Boolean(values.babel),
332
525
  minify: values.minify,
333
526
  sourcemap: values.sourcemap,
334
527
  define,
@@ -336,81 +529,61 @@ if (command === '-v' || command === '--version') {
336
529
  loader,
337
530
  })
338
531
  } 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
532
  const options = {
345
533
  output: { type: 'string', short: 'o' },
346
534
  }
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 })
535
+ const values = parseLeadingOptions(argv, options, { valueFlags: ['--output', '-o'], onError: fail })
536
+ if (argv.length === 0) fail('Nothing to extract: no bundle file given')
537
+ if (argv.length > 1) fail('Error: extract takes exactly one bundle file')
538
+ const { extractCommand } = await import('@exodus/stasis-core/extract')
539
+ extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output, logLabel: 'stasis' })
357
540
  } else if (command === 'diff') {
358
- const flags = []
359
- while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
360
541
  const options = {
361
542
  stat: { type: 'boolean' },
362
543
  imports: { type: 'boolean' },
363
544
  }
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.
545
+ const values = parseLeadingOptions(argv, options, { onError: fail })
546
+ // --stat is required for now (it reports what changed, not the content); bare `stasis diff`
547
+ // is reserved for a future content-level diff.
548
+ if (!values.stat) fail('Error: stasis diff currently requires --stat')
549
+ if (argv.length !== 2) fail('Error: stasis diff takes exactly two files (lockfile or bundle)')
550
+ // --imports also diffs the resolution graphs (added/removed/redirected edges); off by default (verbose).
377
551
  const { diffCommand } = await import('../src/cmd/diff.js')
378
552
  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).
553
+ // Exit non-zero when the artifacts differ, so it composes in CI.
381
554
  process.exitCode = differences ? 1 : 0
382
555
  } else if (command === 'prune') {
383
- if (argv.length > 1) usage('Error: prune takes at most one path argument')
556
+ if (argv.length > 1) fail('Error: prune takes at most one path argument')
384
557
  const root = argv[0] ? resolve(argv[0]) : process.cwd()
385
558
  const { prune } = await import('@exodus/stasis-core/prune')
386
559
  const { removed, validated } = prune({ root })
387
560
  console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
388
561
  } else if (command === 'audit') {
389
- if (argv.length === 0) usage('Nothing to audit: no path to file given')
562
+ const values = parseLeadingOptions(argv, { why: { type: 'boolean' }, 'why-deep': { type: 'boolean' }, 'why-full': { type: 'boolean' }, reason: { type: 'string' }, 'repo-advisories': { type: 'boolean' } }, { valueFlags: ['--reason'], onError: fail })
563
+ if (argv.length === 0) fail('Nothing to audit: no path to file given')
564
+ const repoAdvisories = Boolean(values['repo-advisories'])
565
+ if (repoAdvisories && !process.env.GITHUB_TOKEN) fail('Error: --repo-advisories requires a GitHub token in GITHUB_TOKEN')
566
+ // A Soldeer package's and a GitHub repo's advisories are their repository's, asked of GitHub with
567
+ // GITHUB_TOKEN where it is set; the client asks nothing for any other package by itself.
568
+ const github = (await import('@preventive/upstream/github.js')).createClient({ token: process.env.GITHUB_TOKEN || null })
569
+ // Upstream caches what it looks up (a package's GitHub repo, a month each) only where told to, and
570
+ // answers from it without asking: a per-user directory, never one shared with less trusted jobs.
571
+ const { setCacheDir } = await import('@preventive/upstream/npm.js')
572
+ setCacheDir(userCacheDir())
390
573
  const { audit, printAuditReport } = await import('../src/audit.js')
391
574
  const files = argv.map((f) => resolve(f))
392
- const report = await audit(files)
575
+ const report = await audit(files, { why: Boolean(values.why), whyDeep: Boolean(values['why-deep']), whyFull: Boolean(values['why-full']), reason: values.reason, repoAdvisories, github })
393
576
  printAuditReport(report)
394
577
  process.exitCode = report.rows.length === 0 ? 0 : 1
395
578
  } 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
579
  const options = {
402
580
  format: { type: 'string' },
403
581
  output: { type: 'string', short: 'o' },
404
582
  }
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')
583
+ const values = parseLeadingOptions(argv, options, { valueFlags: ['--format', '--output', '-o'], onError: fail })
584
+ if (!values.format) fail('Error: stasis sbom requires --format=(spdx|cyclonedx)')
585
+ if (!['spdx', 'cyclonedx'].includes(values.format)) fail('Error: --format must be spdx or cyclonedx')
586
+ if (argv.length === 0) fail('Nothing to export: no lockfile or bundle given')
414
587
  const { sbomCommand } = await import('../src/cmd/sbom.js')
415
588
  sbomCommand({
416
589
  cwd: process.cwd(),
@@ -418,6 +591,8 @@ if (command === '-v' || command === '--version') {
418
591
  format: values.format,
419
592
  output: values.output,
420
593
  })
421
- } else {
594
+ } else if (command === undefined) {
422
595
  usage()
596
+ } else {
597
+ fail(`Error: unknown command '${command}'`)
423
598
  }