@exodus/stasis 1.0.0-beta.1 → 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 +386 -213
  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,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
- 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'
9
- import { constants as osConstants } from 'node:os'
8
+ import { homedir, 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,27 +15,37 @@ 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
- function usage(prefix = '') {
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]
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]
32
30
  stasis extract [--output=path/to/dir] path/to/bundle.stasis.code.br
33
31
  stasis diff --stat [--imports] path/to/(lockfile|bundle) path/to/(lockfile|bundle)
34
32
  stasis prune [path/to/project]
35
- stasis audit path/to/file ...
33
+ stasis audit [--why|--why-deep] [--why-full] [--reason=consumer] [--repo-advisories] path/to/(lockfile|bundle) ...
36
34
  stasis sbom --format=(spdx|cyclonedx) [--output=(path|-)] path/to/(lockfile|bundle) ...
37
- (stasis sbom streams to stdout by default; --output=- is explicit stdout)
38
- `.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.`)
39
49
  process.exit(1)
40
50
  }
41
51
 
@@ -45,18 +55,22 @@ function setEnv(name, value) {
45
55
  process.env[name] = value === undefined ? '' : value
46
56
  }
47
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
+
48
66
  const command = argv.shift()
49
67
 
50
68
  if (command === '-v' || command === '--version') {
51
69
  console.log(`v${pkg.version}`)
52
70
  process.exit(0)
71
+ } else if (command === '-h' || command === '--help') {
72
+ usage(true)
53
73
  } 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
74
  const options = {
61
75
  lock: { type: 'string', default: 'none' },
62
76
  bundle: { type: 'string', default: 'none' },
@@ -65,49 +79,65 @@ if (command === '-v' || command === '--version') {
65
79
  debug: { type: 'boolean' },
66
80
  dependencies: { type: 'boolean' },
67
81
  'child-process': { type: 'boolean' },
82
+ 'package-json': { type: 'boolean' },
68
83
  mock: { type: 'boolean' },
84
+ import: { type: 'string', multiple: true },
69
85
  fs: { type: 'string' },
70
86
  resources: { type: 'string' },
87
+ 'brotli-quality': { type: 'string' },
71
88
  }
72
-
73
- let values
74
- try {
75
- ({ values } = parseArgs({ args: flags, options }))
76
- } catch (cause) {
77
- usage(`Error: ${cause.message}`)
78
- }
79
- if (argv.length === 0) usage('Nothing to run: no path to file given')
80
- 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')
81
95
  const lock = values.lock
82
96
  const scope = values.dependencies ? 'node_modules' : 'full'
83
97
  const bundle = values.bundle
84
98
  const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
85
99
  const resourcesBundleFile = values['resources-bundle-file'] ? resolve(values['resources-bundle-file']) : ''
86
100
  const debug = values.debug ? '1' : ''
87
- if (!['none', 'ignore', 'add', 'replace', 'load', 'frozen'].includes(bundle)) usage('Error: invalid --bundle value')
88
- if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|frozen|ignore)')
89
- // Unlike --bundle-file (which is inert-but-harmless under --bundle=ignore, so Config allows
90
- // it), --resources-bundle-file needs an active bundle: Config rejects bundle=none AND ignore
91
- // (resourcesBundleFile requires an active bundle mode). Front-run that with a clean usage error.
92
- if (resourcesBundleFile && (bundle === 'none' || bundle === 'ignore')) usage('Error: --resources-bundle-file requires --bundle=(add|replace|load|frozen)')
93
- if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load is incompatible with --lock=(add|replace)')
94
- if (lock === 'none' && bundle === 'none') usage('Error: stasis needs a lockfile or a bundle: set --lock or --bundle')
95
- if (values.mock && bundle === 'load') usage('Error: --mock is for capturing imports while building a bundle; not compatible with --bundle=load')
96
- // --fs monkey-patches the fs readers (readFileSync/readdirSync + lstatSync/statSync)
97
- // to capture them into the bundle (add|replace) or serve them from it (load); it has
98
- // nothing to record into or read from without one of those bundle modes. The mode
99
- // argument is required: `sync` patches the sync readers, `async` adds their async
100
- // (callback + fs.promises) counterparts on top.
101
- if (values.fs !== undefined && !['sync', 'async'].includes(values.fs)) usage("Error: --fs must be 'sync' or 'async'")
102
- if (values.fs !== undefined && !['add', 'replace', 'load'].includes(bundle)) usage('Error: --fs requires --bundle=(add|replace|load)')
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)')
103
112
  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.
113
+ // --resources: comma-separated extension/filename allowlist for `--fs` captures.
106
114
  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.
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.
109
133
  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 }) })
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 }) })
111
141
  if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
112
142
  setEnv('EXODUS_STASIS_LOCK', lock)
113
143
  setEnv('EXODUS_STASIS_SCOPE', scope)
@@ -116,211 +146,371 @@ if (command === '-v' || command === '--version') {
116
146
  setEnv('EXODUS_STASIS_RESOURCES_BUNDLE_FILE', resourcesBundleFile)
117
147
  setEnv('EXODUS_STASIS_DEBUG', debug)
118
148
  setEnv('EXODUS_STASIS_CHILD_PROCESS', childProcess)
149
+ setEnv('EXODUS_STASIS_PACKAGE_JSON', packageJSON)
119
150
  setEnv('EXODUS_STASIS_FS', captureFs)
120
151
  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).
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.
135
158
  // Node 24 dropped comma-separated --allow-fs-write; repeat the flag instead.
136
159
  const nodeArgs = []
137
160
  if (values.mock) {
138
161
  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.)
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.
145
164
  for (const [flag, file] of [['--bundle-file', bundleFile], ['--resources-bundle-file', resourcesBundleFile]]) {
146
165
  if (!file || file.startsWith(`${process.cwd()}/`)) continue
147
166
  let p = dirname(file)
148
167
  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.
168
+ // Refuse write access to the filesystem root -- it would disable the --permission layer everywhere.
152
169
  if (p === dirname(p)) {
153
- usage(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
170
+ fail(`Error: no existing parent directory for ${flag}=${file}; create one first or choose a path under an existing directory`)
154
171
  }
155
- if (!writeAllow.includes(p)) writeAllow.push(p) // both targets often share a parent dir
172
+ if (!writeAllow.includes(p)) writeAllow.push(p)
156
173
  }
157
174
  nodeArgs.push('--permission', '--allow-fs-read=*')
158
175
  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).
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).
164
178
  nodeArgs.push('--conditions=node-addons')
165
179
  }
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).
180
+ // --mock uses stasis's loader-mock entry (composes the mock before installing the hooks).
168
181
  const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader'
169
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)
170
187
  const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' })
171
188
  const [code, signal] = await once(child, 'close')
172
189
  // code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0
173
190
  process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0)
174
191
  } 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
192
  const options = {
181
193
  mapping: { type: 'string' },
194
+ manifests: { type: 'boolean' },
182
195
  output: { type: 'string', short: 'o' },
183
196
  scope: { type: 'string' },
184
197
  lockfile: { type: 'string' },
185
198
  conditions: { type: 'string' },
186
199
  mainFields: { type: 'string' },
187
200
  metro: { type: 'boolean' },
201
+ 'metro-resolver': { type: 'boolean' },
188
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' },
189
217
  }
190
- let values
191
- try {
192
- ({ values } = parseArgs({ args: flags, options }))
193
- } catch (cause) {
194
- usage(`Error: ${cause.message}`)
195
- }
196
- if (argv.length === 0) usage('Nothing to bundle: no entry file given')
197
- 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))
198
229
  const allPhp = argv.every((f) => f.endsWith('.php'))
199
- 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))
200
231
  const allBash = argv.every((f) => /\.(?:sh|bash)$/u.test(f))
201
232
  const allRust = argv.every((f) => f.endsWith('.rs'))
202
233
  if (!allSol && !allPhp && !allJs && !allBash && !allRust) {
203
- usage('Error: bundle entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts, all be .sh/.bash, or all be .rs')
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')
204
235
  }
205
- if (values.mapping && !allSol) usage('Error: --mapping is only valid for .sol bundles')
206
- if (values.scope && !allJs) usage('Error: --scope is only valid for JS bundles')
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')
207
267
  if (values.scope && !['node_modules', 'full'].includes(values.scope)) {
208
- usage('Error: --scope must be node_modules or full')
268
+ fail('Error: --scope must be node_modules or full')
209
269
  }
210
- if (values.lockfile && !allJs) usage('Error: --lockfile is only valid for JS bundles')
211
- // --scope selects a bundle subset; the field resolver (--mainFields/--metro) always
212
- // emits a full-scope bundle, so the two together would silently ignore --scope.
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.
213
272
  if (values.scope !== undefined && (values.mainFields !== undefined || values.metro)) {
214
- usage('Error: --scope is not supported with --mainFields or --metro')
273
+ fail('Error: --scope is not supported with --mainFields or --metro')
215
274
  }
216
- // --conditions: comma-separated extra `exports`/`imports` resolution conditions
217
- // (e.g. react-native,browser) merged on top of the conditions Node always asserts.
218
- // They steer which branch of a package's conditional `exports` the static scan
219
- // follows. NOTE: conditions alone do not honour legacy package `mainFields` or
220
- // platform-specific file suffixes -- see --mainFields.
221
- if (values.conditions !== undefined && !allJs) usage('Error: --conditions is only valid for JS bundles')
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')
222
278
  const conditions = values.conditions === undefined
223
279
  ? []
224
280
  : values.conditions.split(',').map((s) => s.trim()).filter(Boolean)
225
281
  if (values.conditions !== undefined && conditions.length === 0) {
226
- 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)')
227
283
  }
228
- // --mainFields: comma-separated legacy package entry fields (e.g.
229
- // react-native,browser,main). Drives the legacy-field resolver for non-`exports`
230
- // packages -- the entry it picks plus the browser-field-spec object redirection.
231
- if (values.mainFields !== undefined && !allJs) usage('Error: --mainFields is only valid for JS bundles')
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')
232
296
  const mainFields = values.mainFields === undefined
233
297
  ? undefined
234
298
  : values.mainFields.split(',').map((s) => s.trim()).filter(Boolean)
235
299
  if (values.mainFields !== undefined && mainFields.length === 0) {
236
- 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)')
237
301
  }
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.
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.
242
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'])
243
308
  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).
309
+ // A platform name becomes an edge key: reject '/' (parsers reject it) and '*' (reserved placeholder).
247
310
  for (const p of platforms) {
248
- 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}`)
249
337
  }
250
- if ((metro || platforms.length > 0) && !allJs) usage('Error: --metro is only valid for JS bundles')
251
338
  if (metro) {
252
- if (conditions.length > 0) usage("Error: --conditions can't be combined with --metro (it sets its own conditions)")
253
- if (mainFields !== undefined) usage("Error: --mainFields can't be combined with --metro (it sets its own mainFields)")
254
- if (platforms.length === 0) usage('Error: --metro requires --platforms (e.g. --platforms=ios,android)')
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)')
255
342
  } else if (platforms.length > 0) {
256
- usage('Error: --platforms is only valid with --metro')
343
+ fail('Error: --platforms is only valid with --metro')
257
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=-')
258
360
  const { bundleCommand } = await import('../src/cmd/bundle.js')
259
361
  await bundleCommand({
260
362
  cwd: process.cwd(),
261
363
  entries: argv,
262
364
  mappingFile: values.mapping,
365
+ manifests,
263
366
  output: values.output,
264
367
  scope: values.scope,
265
368
  lockfile: values.lockfile,
266
369
  conditions,
267
370
  mainFields,
268
371
  metro,
372
+ metroResolver,
269
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,
270
388
  })
271
- } else if (command === 'build') {
272
- const flags = []
273
- const valueFlags = new Set(['--output', '-o', '--format', '--platform', '--define', '--external', '--loader'])
274
- while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
275
- flags.push(argv.shift())
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
+ }
276
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') {
277
469
  const options = {
278
470
  output: { type: 'string', short: 'o' },
279
471
  format: { type: 'string' },
280
472
  platform: { type: 'string' },
473
+ babel: { type: 'boolean' },
281
474
  minify: { type: 'boolean' },
282
475
  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.
476
+ // Repeatable esbuild passthroughs (--define=K=V, --external=PATTERN, --loader=.ext:name).
286
477
  define: { type: 'string', multiple: true },
287
478
  external: { type: 'string', multiple: true },
288
479
  loader: { type: 'string', multiple: true },
289
480
  }
290
- let values
291
- try {
292
- ({ values } = parseArgs({ args: flags, options }))
293
- } catch (cause) {
294
- usage(`Error: ${cause.message}`)
295
- }
296
- if (argv.length === 0) usage('Nothing to build: no bundle or lockfile given')
297
- if (argv.length > 2) usage('Error: build takes a bundle or lockfile and an optional entry point')
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')
298
487
  // esbuild output knobs; the import graph itself is fixed by the artifact, not these.
299
488
  if (values.format !== undefined && !['esm', 'cjs', 'iife'].includes(values.format)) {
300
- usage('Error: --format must be esm, cjs, or iife')
489
+ fail('Error: --format must be esm, cjs, or iife')
301
490
  }
302
- if (values.platform !== undefined && !['node', 'browser', 'neutral'].includes(values.platform)) {
303
- 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')
304
493
  }
305
- if (!values.output) usage('Error: --output is required (a .js/.cjs/.mjs file, or a directory)')
306
- // `-` is the stdout sentinel for `bundle`/`sbom`, but `build` writes via esbuild and can't
307
- // stream; without this it would silently create a directory literally named `-`.
308
- if (values.output === '-') usage('Error: stasis build cannot stream to stdout; --output must be a .js/.cjs/.mjs file or a directory')
309
- // --define=KEY=VALUE -> esbuild's define map. The value is forwarded to esbuild verbatim,
310
- // so it must be valid JS the same way esbuild's CLI requires (a JSON literal, an
311
- // identifier, ...): e.g. --define=process.env.NODE_ENV='"production"' or --define=DEBUG=false.
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.
312
503
  const define = {}
313
504
  for (const entry of values.define ?? []) {
314
505
  const eq = entry.indexOf('=')
315
- 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}')`)
316
507
  define[entry.slice(0, eq)] = entry.slice(eq + 1)
317
508
  }
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.
509
+ // --loader=.ext:NAME -> esbuild loader override for that extension (e.g. --loader=.js:jsx).
320
510
  const loader = {}
321
511
  for (const entry of values.loader ?? []) {
322
512
  const colon = entry.indexOf(':')
323
- 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}')`)
324
514
  loader[entry.slice(0, colon)] = entry.slice(colon + 1)
325
515
  }
326
516
  const { buildCommand } = await import('../src/cmd/build.js')
@@ -331,6 +521,7 @@ if (command === '-v' || command === '--version') {
331
521
  output: values.output,
332
522
  format: values.format,
333
523
  platform: values.platform,
524
+ babel: Boolean(values.babel),
334
525
  minify: values.minify,
335
526
  sourcemap: values.sourcemap,
336
527
  define,
@@ -338,81 +529,61 @@ if (command === '-v' || command === '--version') {
338
529
  loader,
339
530
  })
340
531
  } 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
532
  const options = {
347
533
  output: { type: 'string', short: 'o' },
348
534
  }
349
- let values
350
- try {
351
- ({ values } = parseArgs({ args: flags, options }))
352
- } catch (cause) {
353
- usage(`Error: ${cause.message}`)
354
- }
355
- if (argv.length === 0) usage('Nothing to extract: no bundle file given')
356
- if (argv.length > 1) usage('Error: extract takes exactly one bundle file')
357
- const { extractCommand } = await import('../src/cmd/extract.js')
358
- extractCommand({ cwd: process.cwd(), bundleFile: argv[0], output: values.output })
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' })
359
540
  } else if (command === 'diff') {
360
- const flags = []
361
- while (argv.length > 0 && argv[0].startsWith('-')) flags.push(argv.shift())
362
541
  const options = {
363
542
  stat: { type: 'boolean' },
364
543
  imports: { type: 'boolean' },
365
544
  }
366
- let values
367
- try {
368
- ({ values } = parseArgs({ args: flags, options }))
369
- } catch (cause) {
370
- usage(`Error: ${cause.message}`)
371
- }
372
- // --stat is required until full text diffs land: it reports what changed
373
- // (modules/files), not the differing content. Reserving bare `stasis diff`
374
- // keeps the door open for a future content-level diff without a breaking flag.
375
- if (!values.stat) usage('Error: stasis diff currently requires --stat')
376
- if (argv.length !== 2) usage('Error: stasis diff takes exactly two files (lockfile or bundle)')
377
- // --imports additionally diffs the resolution graphs (which import edges were
378
- // added/removed/redirected); off by default since the edge list is verbose.
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).
379
551
  const { diffCommand } = await import('../src/cmd/diff.js')
380
552
  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).
553
+ // Exit non-zero when the artifacts differ, so it composes in CI.
383
554
  process.exitCode = differences ? 1 : 0
384
555
  } else if (command === 'prune') {
385
- 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')
386
557
  const root = argv[0] ? resolve(argv[0]) : process.cwd()
387
558
  const { prune } = await import('@exodus/stasis-core/prune')
388
559
  const { removed, validated } = prune({ root })
389
560
  console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
390
561
  } else if (command === 'audit') {
391
- 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())
392
573
  const { audit, printAuditReport } = await import('../src/audit.js')
393
574
  const files = argv.map((f) => resolve(f))
394
- 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 })
395
576
  printAuditReport(report)
396
577
  process.exitCode = report.rows.length === 0 ? 0 : 1
397
578
  } 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
579
  const options = {
404
580
  format: { type: 'string' },
405
581
  output: { type: 'string', short: 'o' },
406
582
  }
407
- let values
408
- try {
409
- ({ values } = parseArgs({ args: flags, options }))
410
- } catch (cause) {
411
- usage(`Error: ${cause.message}`)
412
- }
413
- if (!values.format) usage('Error: stasis sbom requires --format=(spdx|cyclonedx)')
414
- if (!['spdx', 'cyclonedx'].includes(values.format)) usage('Error: --format must be spdx or cyclonedx')
415
- if (argv.length === 0) usage('Nothing to export: no lockfile or bundle given')
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')
416
587
  const { sbomCommand } = await import('../src/cmd/sbom.js')
417
588
  sbomCommand({
418
589
  cwd: process.cwd(),
@@ -420,6 +591,8 @@ if (command === '-v' || command === '--version') {
420
591
  format: values.format,
421
592
  output: values.output,
422
593
  })
423
- } else {
594
+ } else if (command === undefined) {
424
595
  usage()
596
+ } else {
597
+ fail(`Error: unknown command '${command}'`)
425
598
  }