@bun-native/cli 0.0.0-bootstrap.0 → 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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/bun-native-source.json +131 -0
- package/package.json +35 -1
- package/src/.oxfmtrc.json +20 -0
- package/src/.oxlintrc.json +12 -0
- package/src/experiments/aot-gpui/compile.ts +41 -0
- package/src/experiments/aot-gpui/inspect-runtime.ts +303 -0
- package/src/experiments/aot-gpui/inspect.ts +65 -0
- package/src/scripts/native/artifact.ts +32 -0
- package/src/scripts/native/build.ts +250 -0
- package/src/scripts/native/doctor.ts +68 -0
- package/src/scripts/native/host-download.ts +139 -0
- package/src/scripts/native/host-header.ts +33 -0
- package/src/scripts/native/host.ts +22 -0
- package/src/scripts/native/inputs.ts +65 -0
- package/src/scripts/native/lint.ts +43 -0
- package/src/scripts/native/package-archive.ts +103 -0
- package/src/scripts/native/package-lock.ts +220 -0
- package/src/scripts/native/package-set.ts +408 -0
- package/src/scripts/native/paths.ts +20 -0
- package/src/scripts/native/project.ts +251 -0
- package/src/scripts/native/quality.ts +61 -0
- package/src/scripts/native/session.ts +296 -0
- package/src/scripts/native/standalone.ts +83 -0
- package/src/scripts/native/watch.ts +104 -0
- package/src/scripts/native.ts +219 -0
- package/src/scripts/solid/transform.ts +16 -0
- package/src/scripts/solid-build.ts +43 -0
- package/src/toolchain.json +64 -0
- package/src/tsconfig.json +45 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|