@bun-native/cli 0.0.0-stage → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ import { constants } from 'node:fs'
2
+ import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
4
+ import { compileApplication } from '../../experiments/aot-gpui/compile.ts'
5
+ import type { Project } from './project.ts'
6
+ import { fileDigest } from './artifact.ts'
7
+ import toolchain from '../../toolchain.json'
8
+
9
+ /**
10
+ * Bun.build names each module in its `// path` comments relative to the process
11
+ * working directory, so bundling from another cwd changed the module bytes. Anchor
12
+ * it to the project for the bundle's duration; every path used here is absolute.
13
+ */
14
+ async function inProjectDirectory<T>(project: Project, bundle: () => Promise<T>): Promise<T> {
15
+ const previous = process.cwd()
16
+ process.chdir(project.directory)
17
+ try {
18
+ return await bundle()
19
+ } finally {
20
+ process.chdir(previous)
21
+ }
22
+ }
23
+
24
+ export async function bundleProject(project: Project, outdir: string): Promise<string> {
25
+ const destination = resolve(outdir)
26
+ return inProjectDirectory(project, () => bundleFromProject(project, destination))
27
+ }
28
+
29
+ async function bundleFromProject(project: Project, outdir: string): Promise<string> {
30
+ if (project.framework === 'solid') {
31
+ // Only Solid needs the Babel universal transform; React builds skip loading it.
32
+ const { buildSolid } = await import('../solid-build.ts')
33
+ return buildSolid(project.entry, outdir)
34
+ }
35
+ await mkdir(outdir, { recursive: true })
36
+ const result = await Bun.build({
37
+ entrypoints: [project.entry],
38
+ outdir,
39
+ naming: 'application.js',
40
+ target: 'bun',
41
+ format: 'esm',
42
+ jsx: { runtime: 'automatic', importSource: '@bun-native/react' },
43
+ define: { 'process.env.NODE_ENV': '"production"' },
44
+ sourcemap: 'external',
45
+ })
46
+ if (!result.success) throw new AggregateError(result.logs, 'React native build failed')
47
+ return join(outdir, 'application.js')
48
+ }
49
+
50
+ /** The application bundle that holds the host in a macOS build. */
51
+ export const MACOS_HOST_BUNDLE = 'BunNativeHost.app'
52
+ const BUNDLE_IDENTIFIER = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
53
+
54
+ const xml = (text: string) =>
55
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
56
+
57
+ /** The Info.plist that makes the macOS host an application bundle with an identifier. */
58
+ export function hostInfoPlist(identifier: string, name: string, executable: string): string {
59
+ if (!BUNDLE_IDENTIFIER.test(identifier))
60
+ throw new Error(`Bundle identifier ${JSON.stringify(identifier)} is not reverse-DNS text`)
61
+ const entry = (key: string, value: string) =>
62
+ ` <key>${key}</key>\n <string>${xml(value)}</string>`
63
+ return [
64
+ '<?xml version="1.0" encoding="UTF-8"?>',
65
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
66
+ '<plist version="1.0">',
67
+ '<dict>',
68
+ entry('CFBundleIdentifier', identifier),
69
+ entry('CFBundleExecutable', executable),
70
+ entry('CFBundleName', name),
71
+ entry('CFBundleDisplayName', name),
72
+ entry('CFBundlePackageType', 'APPL'),
73
+ entry('CFBundleInfoDictionaryVersion', '6.0'),
74
+ entry('CFBundleShortVersionString', '1.0'),
75
+ entry('CFBundleVersion', '1'),
76
+ ' <key>NSHighResolutionCapable</key>\n <true/>',
77
+ '</dict>',
78
+ '</plist>',
79
+ '',
80
+ ].join('\n')
81
+ }
82
+
83
+ /**
84
+ * Where the host goes in a build. macOS user notifications serve only a process
85
+ * that runs from an application bundle with an identifier
86
+ * (docs/decisions/0009-native-notifications.md), so a macOS build puts the host in
87
+ * `BunNativeHost.app/Contents/MacOS` next to its Info.plist; every other platform
88
+ * keeps it adjacent to the application.
89
+ */
90
+ export function hostLayout(
91
+ platform: NodeJS.Platform,
92
+ hostFile: string
93
+ ): { file: string; infoPlist?: string } {
94
+ if (platform !== 'darwin') return { file: hostFile }
95
+ return {
96
+ file: `${MACOS_HOST_BUNDLE}/Contents/MacOS/${hostFile}`,
97
+ infoPlist: `${MACOS_HOST_BUNDLE}/Contents/Info.plist`,
98
+ }
99
+ }
100
+
101
+ /** The default bundle identity: the project's package name under `dev.bun-native.app`. */
102
+ export async function defaultBundleIdentity(
103
+ project: Project
104
+ ): Promise<{ identifier: string; name: string }> {
105
+ let name = 'application'
106
+ try {
107
+ const value = JSON.parse(await readFile(join(project.directory, 'package.json'), 'utf8')) as {
108
+ name?: unknown
109
+ }
110
+ if (typeof value.name === 'string' && value.name) name = value.name
111
+ } catch {}
112
+ const slug =
113
+ name
114
+ .replace(/^@/, '')
115
+ .replace(/[^A-Za-z0-9-]+/g, '-')
116
+ .replace(/^-+|-+$/g, '') || 'application'
117
+ return { identifier: `dev.bun-native.app.${slug}`, name }
118
+ }
119
+
120
+ export async function buildProject(
121
+ project: Project,
122
+ host: string,
123
+ destination: string,
124
+ hostProfile: 'dev' | 'release',
125
+ options: { bundleIdentifier?: string } = {}
126
+ ) {
127
+ if (Bun.revision !== toolchain.bun.revision) throw new Error('Use the pinned Bun AOT compiler')
128
+ const outdir = resolve(destination)
129
+ await mkdir(outdir, { recursive: true })
130
+ if ((await readdir(outdir)).length !== 0) throw new Error('Output directory must be empty')
131
+ const suffix = process.platform === 'win32' ? '.exe' : ''
132
+ const layout = hostLayout(process.platform, `bun-native-host${suffix}`)
133
+ const hostFile = layout.file
134
+ const identity = await defaultBundleIdentity(project)
135
+ if (options.bundleIdentifier !== undefined) identity.identifier = options.bundleIdentifier
136
+ // Validate before any output exists.
137
+ const infoPlist = layout.infoPlist
138
+ ? hostInfoPlist(identity.identifier, identity.name, `bun-native-host${suffix}`)
139
+ : undefined
140
+ const applicationFile = `application${suffix}`
141
+ const originalHost = await fileDigest(host)
142
+ const module = await bundleProject(project, join(outdir, 'development'))
143
+ const developmentModule = await fileDigest(module)
144
+ // Resolve the bundled host relative to the executable, independent of launch cwd.
145
+ const launcher = join(outdir, 'launcher.ts')
146
+ await writeFile(
147
+ launcher,
148
+ `import { dirname, join } from 'node:path'\nprocess.env['BUN_NATIVE_HOST'] ??= join(dirname(process.execPath), ${JSON.stringify(hostFile)})\nawait import('./development/application.js')\n`,
149
+ { flag: 'wx' }
150
+ )
151
+ const compiled = compileApplication(launcher, join(outdir, applicationFile), project.directory)
152
+ if (layout.infoPlist && infoPlist) {
153
+ await mkdir(dirname(join(outdir, hostFile)), { recursive: true })
154
+ await writeFile(join(outdir, layout.infoPlist), infoPlist, { flag: 'wx' })
155
+ }
156
+ await copyFile(host, join(outdir, hostFile), constants.COPYFILE_EXCL)
157
+ if (process.platform !== 'win32') await chmod(join(outdir, hostFile), 0o755)
158
+ const packagedHost = await fileDigest(join(outdir, hostFile))
159
+ if (
160
+ originalHost.sha256 !== packagedHost.sha256 ||
161
+ originalHost.sha256 !== (await fileDigest(host)).sha256
162
+ )
163
+ throw new Error('Prebuilt host changed while packaging; retry with a stable input')
164
+ if (developmentModule.sha256 !== (await fileDigest(module)).sha256)
165
+ throw new Error('Application module changed during AOT compilation')
166
+ // Keep machine-local paths and wall-clock timing out of the manifest so equal
167
+ // inputs produce an identical manifest; timing goes to a separate diagnostic.
168
+ const { compileCommand, buildMs, ...inspected } = compiled
169
+ await writeFile(
170
+ join(outdir, 'build-metrics.json'),
171
+ JSON.stringify({ schemaVersion: 1, compileMs: buildMs }, null, 2) + '\n',
172
+ { flag: 'wx' }
173
+ )
174
+ const manifest = {
175
+ schemaVersion: 1,
176
+ ...(project.packageSet ? { packageSet: project.packageSet } : {}),
177
+ framework: project.framework,
178
+ entrypoint: relative(project.directory, project.entry),
179
+ target: { os: process.platform, architecture: process.arch },
180
+ toolchain,
181
+ application: {
182
+ file: applicationFile,
183
+ ...(await fileDigest(join(outdir, applicationFile))),
184
+ // Absolute arguments are recorded relative to this artifact directory; the
185
+ // compiler is identified by its digest instead of its install path.
186
+ compileCommand: compileCommand.map((argument, index) =>
187
+ index === 0 ? 'bun' : isAbsolute(argument) ? relative(outdir, argument) : argument
188
+ ),
189
+ compiler: { revision: Bun.revision, executable: await fileDigest(process.execPath) },
190
+ ...inspected,
191
+ },
192
+ developmentModule: { file: 'development/application.js', ...developmentModule },
193
+ sourceMap: {
194
+ file: 'development/application.js.map',
195
+ ...(await fileDigest(join(outdir, 'development/application.js.map'))),
196
+ },
197
+ launcher: { file: 'launcher.ts', ...(await fileDigest(launcher)) },
198
+ reproducibility: {
199
+ deterministic: [
200
+ applicationFile,
201
+ hostFile,
202
+ ...(layout.infoPlist ? [layout.infoPlist] : []),
203
+ 'development/application.js',
204
+ 'development/application.js.map',
205
+ 'launcher.ts',
206
+ 'manifest.json',
207
+ ],
208
+ diagnostics: ['build-metrics.json', 'compile-aot.stdout.log', 'compile-aot.stderr.log'],
209
+ },
210
+ host: {
211
+ file: hostFile,
212
+ profile: hostProfile,
213
+ ...packagedHost,
214
+ ...(layout.infoPlist
215
+ ? {
216
+ bundle: {
217
+ directory: MACOS_HOST_BUNDLE,
218
+ identifier: identity.identifier,
219
+ infoPlist: {
220
+ file: layout.infoPlist,
221
+ ...(await fileDigest(join(outdir, layout.infoPlist))),
222
+ },
223
+ },
224
+ }
225
+ : {}),
226
+ provenance:
227
+ 'Caller-supplied prebuilt executable. Profile is caller-declared; match the hash to native build evidence.',
228
+ },
229
+ launch: {
230
+ executable: applicationFile,
231
+ host:
232
+ process.platform === 'darwin'
233
+ ? 'Bundled host inside BunNativeHost.app, so macOS serves it user notifications; BUN_NATIVE_HOST can explicitly override it'
234
+ : 'Adjacent bundled host; BUN_NATIVE_HOST can explicitly override it',
235
+ },
236
+ verification: {
237
+ aotCompiledProgram: 'passed',
238
+ jitOmission: 'passed',
239
+ nativeRendering: 'not-run',
240
+ osInput: 'not-run',
241
+ teardown: 'not-run',
242
+ signing: 'unsigned',
243
+ installation: 'not-tested',
244
+ },
245
+ }
246
+ await writeFile(join(outdir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', {
247
+ flag: 'wx',
248
+ })
249
+ return manifest
250
+ }
@@ -0,0 +1,68 @@
1
+ import toolchain from '../../toolchain.json'
2
+ import { prebuiltHost } from './host.ts'
3
+ import { readProject } from './project.ts'
4
+
5
+ type Diagnosis = { name: string; status: 'ok' | 'failed' | 'skipped'; detail: string }
6
+
7
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
8
+
9
+ /**
10
+ * Report toolchain, platform, host and project compatibility without launching anything.
11
+ * A matching host header is not proof of a Bun Native host; the runtime handshake decides that.
12
+ */
13
+ export async function diagnose(
14
+ repository: string,
15
+ options: { host?: string | undefined; project?: string | undefined }
16
+ ): Promise<Diagnosis[]> {
17
+ const results: Diagnosis[] = []
18
+ results.push(
19
+ Bun.revision === toolchain.bun.revision
20
+ ? { name: 'bun', status: 'ok', detail: `pinned revision ${toolchain.bun.cliRevision}` }
21
+ : {
22
+ name: 'bun',
23
+ status: 'failed',
24
+ detail: `running ${Bun.version} (${Bun.revision}); dev, build and package sets require ${toolchain.bun.cliRevision} from toolchain.json`,
25
+ }
26
+ )
27
+ const target = `${process.platform}-${process.arch}`
28
+ results.push(
29
+ target in toolchain.bun.packages
30
+ ? { name: 'platform', status: 'ok', detail: `${target} has a pinned Bun distribution` }
31
+ : {
32
+ name: 'platform',
33
+ status: 'failed',
34
+ detail: `${target} has no pinned Bun distribution; supported: ${Object.keys(toolchain.bun.packages).join(', ')}`,
35
+ }
36
+ )
37
+ if (options.host === undefined)
38
+ results.push({
39
+ name: 'host',
40
+ status: 'skipped',
41
+ detail: 'pass --host /absolute/path/to/bun-native-host to check a prebuilt host',
42
+ })
43
+ else
44
+ try {
45
+ const host = await prebuiltHost(options.host)
46
+ results.push({
47
+ name: 'host',
48
+ status: 'ok',
49
+ detail: `${host} is a readable ${process.platform}/${process.arch} executable (protocol handshake not run)`,
50
+ })
51
+ } catch (error) {
52
+ results.push({ name: 'host', status: 'failed', detail: message(error) })
53
+ }
54
+ if (options.project === undefined)
55
+ results.push({ name: 'project', status: 'skipped', detail: 'pass a project directory' })
56
+ else
57
+ try {
58
+ const project = await readProject(repository, options.project)
59
+ results.push({
60
+ name: 'project',
61
+ status: 'ok',
62
+ detail: `${project.framework} project ${project.directory}${project.packageSet ? ' with verified installed package set' : ' in this checkout'}`,
63
+ })
64
+ } catch (error) {
65
+ results.push({ name: 'project', status: 'failed', detail: message(error) })
66
+ }
67
+ return results
68
+ }
@@ -0,0 +1,139 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { gunzipSync } from 'node:zlib'
5
+ import { prebuiltHost } from './host.ts'
6
+
7
+ /** The platforms whose release host archives the release workflow attaches (ADR 0021). */
8
+ export const releaseHostTargets = [
9
+ 'linux-x64',
10
+ 'linux-arm64',
11
+ 'darwin-arm64',
12
+ 'darwin-x64',
13
+ ] as const
14
+ export type ReleaseHostTarget = (typeof releaseHostTargets)[number]
15
+
16
+ const releases = 'https://github.com/0xPlayerOne/bun-native/releases/download'
17
+ const maximumArchiveBytes = 512 * 1024 * 1024
18
+ const maximumChecksumBytes = 64 * 1024
19
+
20
+ export function releaseHostTarget(
21
+ platform: string = process.platform,
22
+ arch: string = process.arch
23
+ ): ReleaseHostTarget {
24
+ const target = `${platform}-${arch}`
25
+ if (!(releaseHostTargets as readonly string[]).includes(target))
26
+ throw new Error(
27
+ `No released native host for ${target}; supply --host with a host built from source (released: ${releaseHostTargets.join(', ')})`
28
+ )
29
+ return target as ReleaseHostTarget
30
+ }
31
+
32
+ export function releaseHostAsset(target: ReleaseHostTarget): string {
33
+ return `bun-native-host-${target}.tar.gz`
34
+ }
35
+
36
+ /** The expected digest of `asset` from a release's `sha256sum`-format SHA256SUMS. */
37
+ export function checksumFor(sums: string, asset: string): string {
38
+ const matches = sums
39
+ .split('\n')
40
+ .map((line) => /^([0-9a-f]{64}) [ *]([A-Za-z0-9._-]+)$/.exec(line.trimEnd()))
41
+ .filter((match): match is RegExpExecArray => match !== null && match[2] === asset)
42
+ if (matches.length !== 1)
43
+ throw new Error(`SHA256SUMS must list ${asset} exactly once (found ${matches.length})`)
44
+ return matches[0]![1]!
45
+ }
46
+
47
+ function octal(header: Buffer, offset: number, length: number): number {
48
+ const text = header.toString('latin1', offset, offset + length).replace(/[\0 ]+$/, '')
49
+ if (!/^[0-7]+$/.test(text)) throw new Error('Host archive has an invalid USTAR number')
50
+ return Number.parseInt(text, 8)
51
+ }
52
+
53
+ /**
54
+ * The single `bun-native-host` member of a host archive made by host-archive.ts:
55
+ * a gzip-compressed USTAR stream with exactly one regular file, mode 0755.
56
+ */
57
+ export function hostFromArchive(archive: Uint8Array): Buffer {
58
+ const tar = gunzipSync(archive, { maxOutputLength: maximumArchiveBytes })
59
+ if (tar.length < 1024 || tar.length % 512 !== 0) throw new Error('Host archive is truncated')
60
+ const header = tar.subarray(0, 512)
61
+ const name = header.toString('latin1', 0, 100).replace(/\0+$/, '')
62
+ if (name !== 'bun-native-host')
63
+ throw new Error(`Host archive must hold only bun-native-host, not ${name || 'nothing'}`)
64
+ if (header[156] !== 0x30 && header[156] !== 0)
65
+ throw new Error('Host member is not a regular file')
66
+ if (header.toString('latin1', 257, 262) !== 'ustar') throw new Error('Host archive is not USTAR')
67
+ let checksum = 0
68
+ for (let index = 0; index < 512; index++)
69
+ checksum += index >= 148 && index < 156 ? 0x20 : header[index]!
70
+ if (checksum !== octal(header, 148, 8)) throw new Error('Host archive header checksum mismatch')
71
+ const size = octal(header, 124, 12)
72
+ const end = 512 + Math.ceil(size / 512) * 512
73
+ if (size === 0 || end + 1024 > tar.length) throw new Error('Host archive is truncated')
74
+ if (tar.subarray(end).some((byte) => byte !== 0))
75
+ throw new Error('Host archive must hold exactly one member')
76
+ return Buffer.from(tar.subarray(512, 512 + size))
77
+ }
78
+
79
+ async function download(url: string, limit: number): Promise<Uint8Array> {
80
+ const response = await fetch(url, { redirect: 'follow' })
81
+ if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`)
82
+ const declared = Number(response.headers.get('content-length') ?? '0')
83
+ if (declared > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
84
+ const bytes = new Uint8Array(await response.arrayBuffer())
85
+ if (bytes.length > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
86
+ return bytes
87
+ }
88
+
89
+ export type HostInstallOptions = {
90
+ version: string
91
+ /** Release download base; the tag directory is appended. Defaults to GitHub releases. */
92
+ baseUrl?: string
93
+ /** Directory that holds one host per version and target. */
94
+ cache: string
95
+ force?: boolean
96
+ target?: ReleaseHostTarget
97
+ }
98
+
99
+ export function installedHostPath(options: HostInstallOptions): string {
100
+ const target = options.target ?? releaseHostTarget()
101
+ return join(options.cache, `v${options.version}`, target, 'bun-native-host')
102
+ }
103
+
104
+ /**
105
+ * Download this version's release host for the current OS and CPU, check the
106
+ * archive against the release's SHA256SUMS, unpack its one member and check its
107
+ * executable header. A cached host from an earlier install is reused unless
108
+ * `force` is set. The host is unsigned; the checksum proves only that the bytes
109
+ * match what the release workflow attached (docs/developer-tooling.md).
110
+ */
111
+ export async function installReleaseHost(options: HostInstallOptions): Promise<string> {
112
+ if (!/^\d+\.\d+\.\d+$/.test(options.version))
113
+ throw new Error(`Release host version must be x.y.z, not ${options.version}`)
114
+ const target = options.target ?? releaseHostTarget()
115
+ const destination = installedHostPath({ ...options, target })
116
+ if (!options.force && (await stat(destination).catch(() => undefined))?.isFile())
117
+ return prebuiltHost(destination)
118
+ const base = `${(options.baseUrl ?? process.env['BUN_NATIVE_HOST_RELEASE_URL'] ?? releases).replace(/\/+$/, '')}/v${options.version}`
119
+ const asset = releaseHostAsset(target)
120
+ const sums = new TextDecoder().decode(await download(`${base}/SHA256SUMS`, maximumChecksumBytes))
121
+ const expected = checksumFor(sums, asset)
122
+ const archive = await download(`${base}/${asset}`, maximumArchiveBytes)
123
+ const actual = createHash('sha256').update(archive).digest('hex')
124
+ if (actual !== expected)
125
+ throw new Error(`${asset} digest ${actual} does not match SHA256SUMS ${expected}`)
126
+ const host = hostFromArchive(archive)
127
+ await mkdir(dirname(destination), { recursive: true })
128
+ const partial = `${destination}.${process.pid}.partial`
129
+ try {
130
+ await writeFile(partial, host, { mode: 0o755 })
131
+ await chmod(partial, 0o755)
132
+ await prebuiltHost(partial)
133
+ await rename(partial, destination)
134
+ } finally {
135
+ await rm(partial, { force: true })
136
+ }
137
+ await writeFile(`${destination}.archive.sha256`, `${actual} ${asset}\n`)
138
+ return prebuiltHost(destination)
139
+ }
@@ -0,0 +1,33 @@
1
+ import type { FileHandle } from 'node:fs/promises'
2
+
3
+ /** Inspect the existing bounded header fields; the caller retains the read handle. */
4
+ export async function hasExecutableHeader(
5
+ file: Pick<FileHandle, 'read'>,
6
+ target: { platform: NodeJS.Platform; arch: NodeJS.Architecture }
7
+ ): Promise<boolean> {
8
+ const header = Buffer.alloc(64)
9
+ const { bytesRead } = await file.read(header, 0, header.length, 0)
10
+ if (bytesRead !== header.length) return false
11
+ if (target.platform === 'linux')
12
+ return (
13
+ header.readUInt32LE(0) === 0x464c457f &&
14
+ header[4] === 2 &&
15
+ header[5] === 1 &&
16
+ header.readUInt16LE(18) === (target.arch === 'arm64' ? 183 : 62)
17
+ )
18
+ if (target.platform === 'darwin')
19
+ return (
20
+ header.readUInt32LE(0) === 0xfeedfacf &&
21
+ header.readUInt32LE(4) === (target.arch === 'arm64' ? 0x0100000c : 0x01000007)
22
+ )
23
+ if (target.platform === 'win32' && header.subarray(0, 2).toString() === 'MZ') {
24
+ const pe = Buffer.alloc(6)
25
+ const read = await file.read(pe, 0, pe.length, header.readUInt32LE(60))
26
+ return (
27
+ read.bytesRead === pe.length &&
28
+ pe.readUInt32LE(0) === 0x00004550 &&
29
+ pe.readUInt16LE(4) === (target.arch === 'arm64' ? 0xaa64 : 0x8664)
30
+ )
31
+ }
32
+ return false
33
+ }
@@ -0,0 +1,22 @@
1
+ import { constants } from 'node:fs'
2
+ import { access, open, realpath, stat } from 'node:fs/promises'
3
+ import { isAbsolute } from 'node:path'
4
+ import { hasExecutableHeader } from './host-header.ts'
5
+
6
+ export async function prebuiltHost(path: string | undefined): Promise<string> {
7
+ if (!path || !isAbsolute(path))
8
+ throw new Error(
9
+ '--host must be an absolute path to an existing prebuilt native host, or auto for the released host; this command never runs Cargo'
10
+ )
11
+ const host = await realpath(path)
12
+ if (!(await stat(host)).isFile()) throw new Error('The prebuilt native host must be a file')
13
+ await access(host, constants.R_OK | (process.platform === 'win32' ? 0 : constants.X_OK))
14
+ const file = await open(host, 'r')
15
+ try {
16
+ if (!(await hasExecutableHeader(file, { platform: process.platform, arch: process.arch })))
17
+ throw new Error(`Prebuilt host must be a ${process.platform}/${process.arch} executable`)
18
+ } finally {
19
+ await file.close()
20
+ }
21
+ return host
22
+ }
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import { readdir, realpath, stat } from 'node:fs/promises'
4
+ import { join, relative } from 'node:path'
5
+
6
+ const ignored = new Set(['node_modules', '.git', '.bun-native', 'dist', 'build'])
7
+
8
+ export function rescanInput(path: string | Buffer | null | undefined): boolean {
9
+ // Missing names request a content rescan; they do not establish a source edit.
10
+ if (path === null || path === undefined) return true
11
+ const name = path.toString().replaceAll('\\', '/')
12
+ // A name without an extension may be a directory being replaced or removed.
13
+ // The snapshot decides whether relevant files actually changed.
14
+ return !name.split('/').some((part) => ignored.has(part))
15
+ }
16
+
17
+ function disappeared(error: unknown): boolean {
18
+ return (
19
+ error instanceof Error &&
20
+ 'code' in error &&
21
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
22
+ )
23
+ }
24
+
25
+ /** One content digest, with no retained file contents or earlier generations. */
26
+ export async function projectFingerprint(directory: string, signal?: AbortSignal): Promise<string> {
27
+ const fingerprint = createHash('sha256')
28
+ const visit = async (folder: string, prefix: string): Promise<void> => {
29
+ signal?.throwIfAborted()
30
+ let entries
31
+ try {
32
+ entries = await readdir(folder, { withFileTypes: true })
33
+ } catch (error) {
34
+ if (disappeared(error)) return
35
+ throw error
36
+ }
37
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
38
+ for (const entry of entries) {
39
+ signal?.throwIfAborted()
40
+ if (ignored.has(entry.name)) continue
41
+ const path = join(folder, entry.name)
42
+ const name = prefix + entry.name
43
+ if (entry.isDirectory()) await visit(path, name + '/')
44
+ else if (/\.(?:[cm]?[jt]s|[jt]sx|jsonc?|toml)$/.test(name)) {
45
+ try {
46
+ // File links are hashed through their targets. Do not follow directory
47
+ // links: recursive aliases can escape the project or introduce cycles.
48
+ if (!entry.isFile() && !(entry.isSymbolicLink() && (await stat(path)).isFile())) continue
49
+ const target = entry.isSymbolicLink() ? relative(directory, await realpath(path)) : null
50
+ const contents = createHash('sha256')
51
+ for await (const chunk of createReadStream(path, { signal })) contents.update(chunk)
52
+ signal?.throwIfAborted()
53
+ fingerprint.update(JSON.stringify([name, target, contents.digest('hex')]))
54
+ } catch (error) {
55
+ // Deletion/atomic replacement during a scan is an absent input; a
56
+ // queued create/change event requests another scan. Other IO fails.
57
+ if (!disappeared(error)) throw error
58
+ }
59
+ }
60
+ }
61
+ }
62
+ await visit(directory, '')
63
+ signal?.throwIfAborted()
64
+ return fingerprint.digest('hex')
65
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ // Package the template lint rules with the CLI so projects without their own config use them.
4
+ import '../../.oxlintrc.json'
5
+ // Importing the manifest makes oxlint a pinned dependency of the packaged CLI.
6
+ import oxlint from 'oxlint/package.json'
7
+ import type { Project } from './project.ts'
8
+ import { runQualityStep, type QualityStep } from './quality.ts'
9
+
10
+ /** The rules the generated templates satisfy; a project's own `.oxlintrc.json` replaces them. */
11
+ export const templateLinterConfig = join(import.meta.dir, '../../.oxlintrc.json')
12
+
13
+ /** Generated build output and development bundles are never linted. */
14
+ const generatedOutput = ['dist/**', '.bun-native/**', 'node_modules/**']
15
+
16
+ /** The CLI's own pinned oxlint, resolved from the CLI location rather than the project. */
17
+ function linterEntry(): string {
18
+ const manifest = Bun.resolveSync('oxlint/package.json', import.meta.dir)
19
+ return join(dirname(manifest), oxlint.bin.oxlint)
20
+ }
21
+
22
+ /** Arguments for the pinned linter with deterministic, uncoloured line output. */
23
+ export function lintArguments(project: Project): string[] {
24
+ const own = join(project.directory, '.oxlintrc.json')
25
+ const args = [
26
+ '--no-install',
27
+ linterEntry(),
28
+ '--config',
29
+ existsSync(own) ? own : templateLinterConfig,
30
+ '--deny-warnings',
31
+ '--format',
32
+ 'unix',
33
+ ]
34
+ // Package archives and their canonical manifest are verified by digest, never linted.
35
+ const ignored = project.packageSet ? [...generatedOutput, 'vendor/**'] : generatedOutput
36
+ for (const pattern of ignored) args.push('--ignore-pattern', pattern)
37
+ args.push('.')
38
+ return args
39
+ }
40
+
41
+ export function lintProject(project: Project): QualityStep {
42
+ return { name: 'lint', ...runQualityStep(project, lintArguments(project)) }
43
+ }