@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

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 (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  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 +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
package/src/cmd/build.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import { dirname, extname, join, relative, resolve, sep } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
@@ -7,42 +7,30 @@ import { spawnSync } from 'node:child_process'
7
7
  import { Bundle } from '@exodus/stasis-core/bundle'
8
8
  import { State } from '@exodus/stasis-core/state'
9
9
  import { StasisEsbuild } from '@exodus/stasis-plugins/esbuild'
10
+ import { HERMES_UNSUPPORTED, createBabelConfigTransform, createHermesTransform } from '../babel.js'
10
11
  import { bundleFromLockfile } from '../bundle-from-lockfile.js'
11
12
  import { parseFileWithKind } from '../parse.js'
13
+ import { writeFile } from './output.js'
12
14
 
13
- // Re-exported so `@exodus/stasis/cmd/build` keeps surfacing the lockfile->bundle
14
- // reconstruction alongside the command; the implementation lives in its own module.
15
15
  export { bundleFromLockfile }
16
16
 
17
- // JS/TS entry extensions esbuild can build. esbuild handles JSX/TSX natively (loader
18
- // 'jsx'/'tsx', derived from the extension by the load plugin), and stasis-core already
19
- // classifies .jsx/.tsx as code (CODE_EXTENSIONS), so a bundle/lockfile captured by the
20
- // esbuild/webpack plugins can carry them as entries.
21
17
  const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
22
18
 
23
- // Allowlist (NOT a blocklist) of per-file formats `stasis build` accepts in an artifact:
24
- // the JS/TS/JSON code formats esbuild builds, plus the non-code formats -- resource/
25
- // directory payloads and the payload-free stat records (`--fs` lstat/stat captures) --
26
- // that are inert unless the SELECTED entry imports them. An allowlist
27
- // so a new language format added to `stasis bundle` later (e.g. a future `go`) is rejected
28
- // here by default, rather than slipping through to a cryptic esbuild failure in the build.
19
+ // Allowlist (not blocklist) of per-file formats `stasis build` accepts, so a bundle
20
+ // format added later fails closed here rather than in a cryptic esbuild error.
29
21
  const BUILDABLE_FORMATS = new Set([
30
22
  'module', 'commonjs', 'json', 'module-typescript', 'commonjs-typescript',
31
23
  'resource', 'resource:base64', 'directory', 'stat:file', 'stat:directory',
32
24
  ])
33
25
 
34
- // esbuild is an OPTIONAL peer dependency: `stasis build` is the only command that
35
- // needs a bundler, so we don't force every stasis install to pull one in. Resolve it
36
- // from the local install first (a peer dep hoisted into the project's node_modules, or
37
- // an npx-provided copy), then fall back to a global install, with an actionable error
38
- // if neither is present.
26
+ // esbuild is an optional peer dep; resolve it from the local install, else fall back
27
+ // to a global one.
39
28
  async function importEsbuild() {
40
29
  let mod
41
30
  try {
42
31
  mod = await import('esbuild')
43
32
  } catch (cause) {
44
- // Only a genuine "not installed" miss falls through to the global lookup; a
45
- // different failure (a corrupt install, a syntax error in esbuild) is surfaced.
33
+ // Only a genuine "not installed" miss falls through to the global lookup; surface other failures.
46
34
  if (cause?.code !== 'ERR_MODULE_NOT_FOUND' && cause?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
47
35
  throw cause
48
36
  }
@@ -57,14 +45,10 @@ async function importEsbuild() {
57
45
  }
58
46
  mod = await import(pathToFileURL(globalMain).href)
59
47
  }
60
- // esbuild ships a CJS module exposing named exports (build, context, ...); Node's
61
- // interop surfaces them directly. A globally-imported CJS copy may instead land its
62
- // exports on `default` -- accept both shapes.
48
+ // A globally-imported CJS copy may land its exports on `default` -- accept both shapes.
63
49
  return typeof mod.build === 'function' ? mod : mod.default
64
50
  }
65
51
 
66
- // Locate a globally-installed esbuild via the package managers' "global root" commands.
67
- // Returns the absolute path to esbuild's entry, or undefined when none is installed.
68
52
  function resolveGlobalEsbuild() {
69
53
  const probes = [
70
54
  ['npm', ['root', '-g']],
@@ -76,7 +60,7 @@ function resolveGlobalEsbuild() {
76
60
  try {
77
61
  out = spawnSync(cmd, args, { encoding: 'utf8' })
78
62
  } catch {
79
- continue // package manager not installed
63
+ continue
80
64
  }
81
65
  if (!out || out.status !== 0 || !out.stdout) continue
82
66
  const base = out.stdout.trim()
@@ -88,61 +72,77 @@ function resolveGlobalEsbuild() {
88
72
  try {
89
73
  return createRequire(join(nm, 'noop.js')).resolve('esbuild')
90
74
  } catch {
91
- // keep probing
92
75
  }
93
76
  }
94
77
  }
95
78
  return undefined
96
79
  }
97
80
 
98
- // Validate that an artifact is something esbuild can build: full scope (node_modules
99
- // scope omits the entry and workspace code, so it can't be built on its own), at least
100
- // one entry, all entries JS/TS, and only buildable per-file formats (see BUILDABLE_FORMATS).
101
- function assertBuildable(artifact, label) {
81
+ // Preconditions independent of which entry we build. Per-entry language checks are deferred
82
+ // to assertBuildableEntry, once selectEntry has picked one.
83
+ function assertBuildableArtifact(artifact, label) {
102
84
  if (artifact.config.scope !== 'full') {
103
85
  throw new Error(
104
86
  `stasis build requires a full-scope artifact; ${label} is scope='${artifact.config.scope}'. ` +
105
87
  `node_modules-scope artifacts omit the entry and workspace code, so there is nothing to build.`
106
88
  )
107
89
  }
108
- const entries = [...artifact.entries]
109
- if (entries.length === 0) {
90
+ if (artifact.entries.size === 0) {
110
91
  throw new Error(`stasis build: ${label} declares no entries to build`)
111
92
  }
112
- for (const entry of entries) {
113
- if (!JS_EXTS.has(extname(entry))) {
114
- throw new Error(`stasis build only builds JavaScript/TypeScript entries (esbuild); got non-JS entry '${entry}' in ${label}`)
93
+ }
94
+
95
+ // The entry plus its transitive imports in the recorded graph -- the exact set esbuild loads
96
+ // (all conditions buckets; a --metro edge's per-platform targets included).
97
+ function reachableFiles(entry, imports) {
98
+ const seen = new Set([entry])
99
+ const stack = [entry]
100
+ while (stack.length > 0) {
101
+ const parent = stack.pop()
102
+ for (const [, byParent] of imports) {
103
+ const specifiers = byParent.get(parent)
104
+ if (specifiers === undefined) continue
105
+ for (const [, target] of specifiers) {
106
+ // A plain edge is a file string; a --metro edge is a {platform: file} Map.
107
+ const targets = typeof target === 'string' ? [target] : [...target.values()]
108
+ for (const file of targets) {
109
+ if (!seen.has(file)) {
110
+ seen.add(file)
111
+ stack.push(file)
112
+ }
113
+ }
114
+ }
115
115
  }
116
116
  }
117
- // artifact.formats is a Map (Bundle) or may be null on a legacy lockfile (an absent map
118
- // is fine -- nothing to check). Reject any format outside the allowlist: the non-JS
119
- // LANGUAGE bundlers' formats (Solidity/PHP/Bash/Rust) and anything added later.
120
- //
121
- // NOTE: resource formats are IN the allowlist -- deliberately NOT rejected here. A resource
122
- // is recorded as a FILE but never as an import EDGE, so whether a build hits one depends on
123
- // the SELECTED entry's own source, not the artifact as a whole -- a code-only entry must
124
- // build even when the artifact carries assets for OTHER entries. An entry that does import
125
- // an asset fails at build time (esbuild can't resolve the un-graphed resource).
117
+ return seen
118
+ }
119
+
120
+ // Language checks scoped to the built entry: it must be JS/TS, and every file reachable from
121
+ // it must be buildable. A non-JS file elsewhere (another entry, or a `stasis add` attachment)
122
+ // isn't reached, so it doesn't block the build. imports/formats are null on a legacy lockfile.
123
+ function assertBuildableEntry(artifact, entry, label) {
124
+ if (!JS_EXTS.has(extname(entry))) {
125
+ throw new Error(`stasis build only builds JavaScript/TypeScript entries (esbuild); got non-JS entry '${entry}' in ${label}`)
126
+ }
127
+ const reachable = reachableFiles(entry, artifact.imports ?? new Map())
128
+ // A reachable resource (plugin captures DO record asset edges) stays allowed via BUILDABLE_FORMATS;
129
+ // an entry that imports an unbuildable asset fails later at esbuild, not here.
126
130
  for (const [file, format] of artifact.formats ?? new Map()) {
131
+ if (!reachable.has(file)) continue
127
132
  if (!BUILDABLE_FORMATS.has(format)) {
128
133
  throw new Error(`stasis build only supports JavaScript/TypeScript bundles (esbuild); ${label} carries a '${format}' file (${file})`)
129
134
  }
130
135
  }
131
136
  }
132
137
 
133
- // Pick which recorded entry to build. The build produces exactly one entry's bundle;
134
- // `entryArg` selects it. With a single recorded entry the selector is optional (that
135
- // entry is used); with several it is required, and an absent or unknown one is rejected
136
- // with the available list. The selector is matched against the artifact's recorded
137
- // (project-relative) entries -- only those are attested as graph roots, and the load
138
- // plugin's assertEntry rejects anything else -- accepting the recorded path verbatim, a
139
- // leading `./` stripped, or a cwd-relative path normalized into the project root.
138
+ // Pick which recorded entry to build; required only when several are recorded. Matched
139
+ // against the recorded project-relative entries (verbatim, `./`-stripped, or cwd-relative).
140
140
  function selectEntry(entryArg, entries, { cwd, root, label }) {
141
141
  const recorded = [...entries]
142
142
  const list = recorded.map((e) => ` ${e}`).join('\n')
143
143
  if (entryArg === undefined) {
144
144
  if (recorded.length === 1) return recorded[0]
145
- // length === 0 is impossible here (assertBuildable required >= 1 entry).
145
+ // length === 0 is impossible here (assertBuildableArtifact required >= 1 entry).
146
146
  throw new Error(`stasis build: ${label} has ${recorded.length} entry points; pass one as the second argument:\n${list}`)
147
147
  }
148
148
  const candidates = new Set([
@@ -157,13 +157,8 @@ function selectEntry(entryArg, entries, { cwd, root, label }) {
157
157
  return match
158
158
  }
159
159
 
160
- // Build a bundle-load State that serves an in-memory Bundle -- the same shape StasisEsbuild's
161
- // load path reads from (state.sources / state.resources / state.formats / state.imports /
162
- // state.entries). skipDiscovery anchors the State at `root` and does NO filesystem discovery:
163
- // no package.json walk, no on-disk config, no stray stasis.code.br read. bundle=load +
164
- // lock=ignore serve the bundle's own bytes (self-attesting -- the lockfile path already
165
- // SHA-512-verified them in bundleFromLockfile). We mirror State's on-disk load (split
166
- // sources/resources by format) and fill the maps from `bundle`.
160
+ // Build a bundle-load State that serves an in-memory Bundle. skipDiscovery does NO
161
+ // filesystem discovery (no package.json walk, no on-disk config, no stray stasis.code.br).
167
162
  function loadStateForBundle(root, bundle) {
168
163
  const state = new State(root, { bundle: 'load', lock: 'ignore', scope: bundle.config.scope, skipDiscovery: true })
169
164
  const sources = new Map()
@@ -177,50 +172,23 @@ function loadStateForBundle(root, bundle) {
177
172
  state.formats = bundle.formats
178
173
  state.imports = bundle.imports
179
174
  state.entries = new Set(bundle.entries)
180
- // Not state.modules: the load path (getImport/getFile/getFormat/assertEntry) never reads
181
- // it -- it's only written by the capture paths. The State's empty-Map default stands.
175
+ // Not state.executable either: like state.modules below, the load path never reads it, and every
176
+ // consumer narrows against state.modules -- which is empty here -- so copying it would be inert.
177
+ // Not state.modules: the load path never reads it (only the capture paths write it).
182
178
  return state
183
179
  }
184
180
 
185
- // Run the `stasis build` CLI command end-to-end: take a stasis artifact (a `stasis.code.br`
186
- // bundle, or a `stasis.lock.json` lockfile whose attested files are read + verified from
187
- // disk) and an entry point, and run esbuild over that one entry, following the artifact's
188
- // recorded import graph EXACTLY rather than esbuild's own resolver.
189
- //
190
- // `entry` selects which recorded entry to build (see selectEntry): optional when the
191
- // artifact records one, required when it records several. `output` is required: a path
192
- // ending in `.js`/`.cjs`/`.mjs` writes a single file, anything else a directory.
193
- //
194
- // The build runs entirely in memory off the artifact -- no temp files, and no package.json
195
- // required. The artifact's relative paths anchor at the artifact's OWN directory: a bundle is
196
- // self-contained (root is just a keying prefix; nothing is read from disk), and a lockfile
197
- // records its paths relative to where it sits (its project root), so its sources are read +
198
- // verified from there. A project stasis.config.json is not consulted -- the build is driven
199
- // by the artifact, not by `stasis run` config.
200
- //
201
- // The recorded graph is forced via the stasis esbuild plugin in bundle-load mode: each import
202
- // resolves to the edge stasis recorded (a specifier the artifact doesn't record is handed back
203
- // to esbuild, which externalizes it like a built-in/`external`), and each module's bytes come
204
- // from the artifact -- so the output reflects what stasis attested, not what's on disk.
205
- //
206
- // `format` (esm/cjs/iife, default esm), `platform` (node/browser/neutral, default node),
207
- // `minify`, `sourcemap`, `define` (an esbuild define map), `external` (an array of esbuild
208
- // external patterns), and `loader` (an esbuild extension->loader map, e.g. { '.js': 'jsx' }
209
- // to parse JSX in .js files) are forwarded to esbuild; the import graph is fixed by the artifact.
210
- //
211
- // `external` is esbuild's native option: it only steers specifiers esbuild itself would
212
- // resolve -- i.e. the ones the bundle does NOT record (handed back to esbuild as a miss,
213
- // typically the externals that existed at capture time). It re-declares those so esbuild
214
- // leaves them as runtime imports instead of trying (and failing) to resolve them on disk.
215
- // A specifier the artifact DOES record is still followed from the artifact -- the recorded
216
- // graph wins, so `external` never silently un-inlines an attested edge.
181
+ // Run `stasis build`: run esbuild over one recorded entry, following the artifact's
182
+ // recorded import graph EXACTLY (not esbuild's resolver). A specifier the artifact records
183
+ // is always followed from the artifact; `external` only steers the specifiers it doesn't.
217
184
  export async function buildCommand({
218
185
  cwd = process.cwd(),
219
186
  artifact,
220
187
  entry,
221
188
  output,
222
- format = 'esm',
189
+ format,
223
190
  platform = 'node',
191
+ babel = false,
224
192
  minify = false,
225
193
  sourcemap = false,
226
194
  define = {},
@@ -230,36 +198,43 @@ export async function buildCommand({
230
198
  if (!artifact) throw new Error('stasis build: an artifact (bundle or lockfile) is required')
231
199
  if (!output) throw new Error('stasis build: --output is required (a .js/.cjs/.mjs file, or a directory)')
232
200
  if (!Array.isArray(external)) throw new Error('stasis build: external must be an array of esbuild patterns')
201
+ // --platform=hermes is a stasis-level target, not an esbuild platform (esbuild gets
202
+ // 'neutral'): every code file is downleveled to Hermes' dialect before the final bundle,
203
+ // and the bundle itself must avoid ESM -- Hermes has no import/export.
204
+ const hermes = platform === 'hermes'
205
+ format ??= hermes ? 'iife' : 'esm'
206
+ if (hermes && format === 'esm') {
207
+ throw new Error('stasis build: --platform=hermes cannot emit ESM (Hermes has no import/export); use --format=iife (the hermes default) or cjs')
208
+ }
233
209
  const artifactAbs = resolve(cwd, artifact)
234
210
  const { kind, artifact: parsed } = parseFileWithKind(artifactAbs)
235
- assertBuildable(parsed, artifact)
211
+ assertBuildableArtifact(parsed, artifact)
236
212
 
237
- // Anchor the artifact's relative paths at the artifact's OWN directory. A bundle is
238
- // self-contained (root is just a keying prefix; nothing is read from disk), and a lockfile
239
- // records its paths relative to where it sits -- its project root -- so its sources are
240
- // read + verified from there. No package.json discovery, no project stasis.config.json.
213
+ // Anchor artifact-relative paths at the artifact's own directory; a lockfile's sources
214
+ // are read + verified from there.
241
215
  const root = dirname(artifactAbs)
242
216
  const selectedEntry = selectEntry(entry, parsed.entries, { cwd, root, label: artifact })
217
+ assertBuildableEntry(parsed, selectedEntry, artifact)
243
218
 
244
- // The Bundle the load State serves, entirely in memory. A bundle artifact is already a
245
- // parsed Bundle; a lockfile is reconstructed into one by reading + SHA-512-verifying its
246
- // attested files from disk (bundleFromLockfile). The plugin drives off the in-memory State.
247
219
  const bundle = kind === 'lockfile' ? bundleFromLockfile(parsed, { root }) : parsed
248
220
  const state = loadStateForBundle(root, bundle)
249
- // The selected entry is project-relative in the artifact; make it absolute against the
250
- // State's root so esbuild's entry resolution is independent of the process cwd.
221
+ // Make the entry absolute against the State's root so resolution is independent of process cwd.
251
222
  const entryPoint = resolve(state.root, selectedEntry)
252
223
 
253
224
  const esbuild = await importEsbuild()
225
+ // Per-file code rewrites the plugin applies before esbuild parses a served file:
226
+ // --babel runs the project's own babel.config.* over the original source (no esbuild
227
+ // pre-transform), and otherwise --platform=hermes uses the built-in downlevel pipeline.
228
+ // Both resolve Babel from the project (cwd), never from stasis's own dependencies.
229
+ let transform
230
+ if (babel) transform = createBabelConfigTransform({ projectDir: cwd })
231
+ else if (hermes) transform = createHermesTransform({ projectDir: cwd, esbuild })
232
+
254
233
  const outAbs = resolve(cwd, output)
255
- // A `.js`/`.cjs`/`.mjs` --output names a single output file; anything else is a directory.
256
234
  const asFile = /\.[cm]?js$/u.test(output)
257
- // Absolute paths the artifact attests -- esbuild must NEVER overwrite one. A stray
258
- // `--output=src/x.js` (or `--output=src`/`.` whose computed output basename collides with a
259
- // recorded source) would otherwise destroy an attested file: esbuild's own "refusing to
260
- // overwrite input file" check fires only AFTER it has written, and for a lockfile that
261
- // permanently breaks the file's hash. So we build with write:false and write the outputs
262
- // ourselves, refusing -- before touching disk -- to clobber any recorded file.
235
+ // Never overwrite a file the artifact attests: esbuild's own overwrite check fires only
236
+ // after writing (corrupting a lockfile's hash), so we build write:false and refuse to
237
+ // clobber any recorded file before touching disk.
263
238
  const attested = new Set([...state.sources.keys(), ...state.resources.keys()].map((f) => resolve(state.root, f)))
264
239
 
265
240
  let result
@@ -269,24 +244,21 @@ export async function buildCommand({
269
244
  absWorkingDir: state.root,
270
245
  bundle: true,
271
246
  write: false,
272
- platform,
247
+ platform: hermes ? 'neutral' : platform,
273
248
  format,
274
249
  logLevel: 'silent',
250
+ ...(hermes ? { supported: HERMES_UNSUPPORTED } : {}),
275
251
  ...(minify ? { minify: true } : {}),
276
252
  ...(sourcemap ? { sourcemap: true } : {}),
277
253
  ...(external.length > 0 ? { external } : {}),
278
254
  ...(Object.keys(define).length > 0 ? { define } : {}),
279
255
  ...(asFile ? { outfile: outAbs } : { outdir: outAbs }),
280
- // Per-extension loader overrides drive the stasis plugin's load-mode loader choice
281
- // (the plugin serves and labels every in-bundle file, so esbuild's own `loader`
282
- // option never sees them). e.g. { '.js': 'jsx' } to parse JSX in .js files.
283
- plugins: [new StasisEsbuild(state, { loaders: new Map(Object.entries(loader)) })],
256
+ // Loader overrides go through the plugin: it serves every in-bundle file, so esbuild's
257
+ // own `loader` option never sees them. e.g. { '.js': 'jsx' } to parse JSX in .js files.
258
+ plugins: [new StasisEsbuild(state, { loaders: new Map(Object.entries(loader)), transform })],
284
259
  })
285
260
  } catch (err) {
286
- // esbuild rejects with a BuildFailure carrying a formatted `.errors` array under
287
- // logLevel:'silent'. Surface those (rather than the bare wrapper) so the user sees the
288
- // actual resolve/load failure -- including an entry that imports an un-buildable asset
289
- // -- then fail the command.
261
+ // Under logLevel:'silent' esbuild puts the real errors on err.errors; surface them, then fail.
290
262
  if (Array.isArray(err?.errors) && err.errors.length > 0) {
291
263
  const formatted = await esbuild.formatMessages(err.errors, { kind: 'error', color: false })
292
264
  for (const line of formatted) process.stderr.write(line)
@@ -295,9 +267,7 @@ export async function buildCommand({
295
267
  throw err
296
268
  }
297
269
 
298
- // The build succeeded. Refuse to clobber an attested source -- checked against esbuild's
299
- // ACTUAL output paths (so it covers both `--output` file and directory), BEFORE any write,
300
- // so a rejected build leaves every recorded file intact.
270
+ // Refuse to clobber an attested source, checked against esbuild's actual output paths before any write.
301
271
  for (const file of result.outputFiles) {
302
272
  if (attested.has(file.path)) {
303
273
  throw new Error(
@@ -312,10 +282,7 @@ export async function buildCommand({
312
282
  for (const line of formatted) process.stderr.write(line)
313
283
  }
314
284
 
315
- for (const file of result.outputFiles) {
316
- mkdirSync(dirname(file.path), { recursive: true })
317
- writeFileSync(file.path, file.contents)
318
- }
285
+ for (const file of result.outputFiles) writeFile(file.path, file.contents)
319
286
 
320
287
  console.warn(`[stasis] Built ${selectedEntry} from ${kind} ${artifact} to ${output}`)
321
288
  return { entry: selectedEntry, dir: asFile ? undefined : outAbs, outfile: asFile ? outAbs : undefined }