@bun-native/cli 0.0.0-stage → 0.33.1
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 +4 -2
- package/bun-native-source.json +151 -0
- package/package.json +63 -4
- package/src/.oxfmtrc.json +20 -0
- package/src/.oxlintrc.json +12 -0
- package/src/experiments/aot-gpui/compile.ts +44 -0
- package/src/experiments/aot-gpui/inspect-runtime.ts +304 -0
- package/src/experiments/aot-gpui/inspect.ts +66 -0
- package/src/scripts/native/artifact.ts +73 -0
- package/src/scripts/native/build.ts +231 -0
- package/src/scripts/native/bundle.ts +121 -0
- package/src/scripts/native/consumer-files.ts +46 -0
- package/src/scripts/native/context.ts +95 -0
- package/src/scripts/native/doctor.ts +69 -0
- package/src/scripts/native/host-download.ts +179 -0
- package/src/scripts/native/host-header.ts +33 -0
- package/src/scripts/native/host.ts +22 -0
- package/src/scripts/native/inputs.ts +95 -0
- package/src/scripts/native/lint.ts +43 -0
- package/src/scripts/native/package-archive.ts +94 -0
- package/src/scripts/native/package-lock.ts +220 -0
- package/src/scripts/native/package-set.ts +417 -0
- package/src/scripts/native/package-stamp.ts +209 -0
- package/src/scripts/native/paths.ts +20 -0
- package/src/scripts/native/project.ts +271 -0
- package/src/scripts/native/quality.ts +76 -0
- package/src/scripts/native/registry-project.ts +90 -0
- package/src/scripts/native/session.ts +299 -0
- package/src/scripts/native/standalone.ts +64 -0
- package/src/scripts/native/ustar.ts +29 -0
- package/src/scripts/native/watch.ts +104 -0
- package/src/scripts/native.ts +237 -0
- package/src/scripts/solid/transform.ts +16 -0
- package/src/toolchain.json +74 -0
- package/src/tsconfig.json +45 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import toolchain from '../../toolchain.json'
|
|
2
|
+
import { cliContext } from './context.ts'
|
|
3
|
+
import { prebuiltHost } from './host.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 { readProject } = await import('./project.ts')
|
|
59
|
+
const project = await readProject(repository, options.project)
|
|
60
|
+
results.push({
|
|
61
|
+
name: 'project',
|
|
62
|
+
status: 'ok',
|
|
63
|
+
detail: `${project.framework} project ${project.directory}${project.packageSet ? ' with verified installed package set' : (await cliContext(repository)).mode === 'package' ? ' with registry packages' : ' in this checkout'}`,
|
|
64
|
+
})
|
|
65
|
+
} catch (error) {
|
|
66
|
+
results.push({ name: 'project', status: 'failed', detail: message(error) })
|
|
67
|
+
}
|
|
68
|
+
return results
|
|
69
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
4
|
+
import { sha256 } from './artifact.ts'
|
|
5
|
+
import { prebuiltHost } from './host.ts'
|
|
6
|
+
import { ustarChecksumValid, ustarNumber } from './ustar.ts'
|
|
7
|
+
|
|
8
|
+
/** The platforms whose release host archives the release workflow attaches (ADR 0021). */
|
|
9
|
+
export const releaseHostTargets = [
|
|
10
|
+
'linux-x64',
|
|
11
|
+
'linux-arm64',
|
|
12
|
+
'darwin-arm64',
|
|
13
|
+
'darwin-x64',
|
|
14
|
+
] as const
|
|
15
|
+
export type ReleaseHostTarget = (typeof releaseHostTargets)[number]
|
|
16
|
+
|
|
17
|
+
const releases = 'https://github.com/0xPlayerOne/bun-native/releases/download'
|
|
18
|
+
const maximumArchiveBytes = 512 * 1024 * 1024
|
|
19
|
+
const maximumChecksumBytes = 64 * 1024
|
|
20
|
+
const defaultTimeoutMs = 5 * 60_000
|
|
21
|
+
|
|
22
|
+
export function releaseHostTarget(
|
|
23
|
+
platform: string = process.platform,
|
|
24
|
+
arch: string = process.arch
|
|
25
|
+
): ReleaseHostTarget {
|
|
26
|
+
const target = `${platform}-${arch}`
|
|
27
|
+
if (!(releaseHostTargets as readonly string[]).includes(target))
|
|
28
|
+
throw new Error(
|
|
29
|
+
`No released native host for ${target}; supply --host with a host built from source (released: ${releaseHostTargets.join(', ')})`
|
|
30
|
+
)
|
|
31
|
+
return target as ReleaseHostTarget
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function releaseHostAsset(target: ReleaseHostTarget): string {
|
|
35
|
+
return `bun-native-host-${target}.tar.gz`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The expected digest of `asset` from a release's `sha256sum`-format SHA256SUMS. */
|
|
39
|
+
export function checksumFor(sums: string, asset: string): string {
|
|
40
|
+
const matches = sums
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((line) => /^([0-9a-f]{64}) [ *]([A-Za-z0-9._-]+)$/.exec(line.trimEnd()))
|
|
43
|
+
.filter((match): match is RegExpExecArray => match !== null && match[2] === asset)
|
|
44
|
+
if (matches.length !== 1)
|
|
45
|
+
throw new Error(`SHA256SUMS must list ${asset} exactly once (found ${matches.length})`)
|
|
46
|
+
return matches[0]![1]!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The single `bun-native-host` member of a host archive made by host-archive.ts:
|
|
51
|
+
* a gzip-compressed USTAR stream with exactly one regular file, mode 0755.
|
|
52
|
+
*/
|
|
53
|
+
export function hostFromArchive(archive: Uint8Array): Buffer {
|
|
54
|
+
const tar = gunzipSync(archive, { maxOutputLength: maximumArchiveBytes })
|
|
55
|
+
if (tar.length < 1024 || tar.length % 512 !== 0) throw new Error('Host archive is truncated')
|
|
56
|
+
const header = tar.subarray(0, 512)
|
|
57
|
+
const name = header.toString('latin1', 0, 100).replace(/\0+$/, '')
|
|
58
|
+
if (name !== 'bun-native-host')
|
|
59
|
+
throw new Error(`Host archive must hold only bun-native-host, not ${name || 'nothing'}`)
|
|
60
|
+
if (header[156] !== 0x30 && header[156] !== 0)
|
|
61
|
+
throw new Error('Host member is not a regular file')
|
|
62
|
+
if (header.toString('latin1', 257, 262) !== 'ustar') throw new Error('Host archive is not USTAR')
|
|
63
|
+
if (!ustarChecksumValid(header)) throw new Error('Host archive header checksum mismatch')
|
|
64
|
+
const size = ustarNumber(header.subarray(124, 136))
|
|
65
|
+
if (size === undefined) throw new Error('Host archive has an invalid USTAR number')
|
|
66
|
+
const end = 512 + Math.ceil(size / 512) * 512
|
|
67
|
+
if (size === 0 || end + 1024 > tar.length) throw new Error('Host archive is truncated')
|
|
68
|
+
if (tar.subarray(end).some((byte) => byte !== 0))
|
|
69
|
+
throw new Error('Host archive must hold exactly one member')
|
|
70
|
+
return Buffer.from(tar.subarray(512, 512 + size))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One bounded download: the same deadline covers the request and the whole body,
|
|
75
|
+
* which is streamed into a byte-limited buffer (the install-bun/download.mjs pattern).
|
|
76
|
+
* A stalled release server fails the command instead of hanging it.
|
|
77
|
+
*/
|
|
78
|
+
async function download(url: string, limit: number, timeoutMs: number): Promise<Uint8Array> {
|
|
79
|
+
const controller = new AbortController()
|
|
80
|
+
const expired = () => new Error(`Download exceeded ${timeoutMs} ms: ${url}`)
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
82
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
controller.abort()
|
|
85
|
+
reject(expired())
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
})
|
|
88
|
+
const operation = (async () => {
|
|
89
|
+
let response: Response
|
|
90
|
+
try {
|
|
91
|
+
response = await fetch(url, { redirect: 'follow', signal: controller.signal })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (controller.signal.aborted) throw expired()
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`)
|
|
98
|
+
const declared = Number(response.headers.get('content-length') ?? '0')
|
|
99
|
+
if (declared > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
|
|
100
|
+
const parts: Uint8Array[] = []
|
|
101
|
+
let total = 0
|
|
102
|
+
for await (const part of response.body ?? []) {
|
|
103
|
+
total += part.byteLength
|
|
104
|
+
if (total > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
|
|
105
|
+
parts.push(part)
|
|
106
|
+
}
|
|
107
|
+
return Buffer.concat(parts, total)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (controller.signal.aborted) throw expired()
|
|
110
|
+
throw error
|
|
111
|
+
} finally {
|
|
112
|
+
// Release the connection on every early exit; a completed body is a no-op.
|
|
113
|
+
controller.abort()
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([operation, deadline])
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
operation.catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type HostInstallOptions = {
|
|
125
|
+
version: string
|
|
126
|
+
/** Release download base; the tag directory is appended. Defaults to GitHub releases. */
|
|
127
|
+
baseUrl?: string
|
|
128
|
+
/** Directory that holds one host per version and target. */
|
|
129
|
+
cache: string
|
|
130
|
+
force?: boolean
|
|
131
|
+
target?: ReleaseHostTarget
|
|
132
|
+
/** Deadline for each download, request and body together. Defaults to 5 minutes. */
|
|
133
|
+
timeoutMs?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function installedHostPath(options: HostInstallOptions): string {
|
|
137
|
+
const target = options.target ?? releaseHostTarget()
|
|
138
|
+
return join(options.cache, `v${options.version}`, target, 'bun-native-host')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Download this version's release host for the current OS and CPU, check the
|
|
143
|
+
* archive against the release's SHA256SUMS, unpack its one member and check its
|
|
144
|
+
* executable header. A cached host from an earlier install is reused unless
|
|
145
|
+
* `force` is set. The host is unsigned; the checksum proves only that the bytes
|
|
146
|
+
* match what the release workflow attached (docs/developer-tooling.md).
|
|
147
|
+
*/
|
|
148
|
+
export async function installReleaseHost(options: HostInstallOptions): Promise<string> {
|
|
149
|
+
if (!/^\d+\.\d+\.\d+$/.test(options.version))
|
|
150
|
+
throw new Error(`Release host version must be x.y.z, not ${options.version}`)
|
|
151
|
+
const target = options.target ?? releaseHostTarget()
|
|
152
|
+
const destination = installedHostPath({ ...options, target })
|
|
153
|
+
if (!options.force && (await stat(destination).catch(() => undefined))?.isFile())
|
|
154
|
+
return prebuiltHost(destination)
|
|
155
|
+
const base = `${(options.baseUrl ?? process.env['BUN_NATIVE_HOST_RELEASE_URL'] ?? releases).replace(/\/+$/, '')}/v${options.version}`
|
|
156
|
+
const asset = releaseHostAsset(target)
|
|
157
|
+
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs
|
|
158
|
+
const sums = new TextDecoder().decode(
|
|
159
|
+
await download(`${base}/SHA256SUMS`, maximumChecksumBytes, timeoutMs)
|
|
160
|
+
)
|
|
161
|
+
const expected = checksumFor(sums, asset)
|
|
162
|
+
const archive = await download(`${base}/${asset}`, maximumArchiveBytes, timeoutMs)
|
|
163
|
+
const actual = sha256(archive)
|
|
164
|
+
if (actual !== expected)
|
|
165
|
+
throw new Error(`${asset} digest ${actual} does not match SHA256SUMS ${expected}`)
|
|
166
|
+
const host = hostFromArchive(archive)
|
|
167
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
168
|
+
const partial = `${destination}.${process.pid}.partial`
|
|
169
|
+
try {
|
|
170
|
+
await writeFile(partial, host, { mode: 0o755 })
|
|
171
|
+
await chmod(partial, 0o755)
|
|
172
|
+
await prebuiltHost(partial)
|
|
173
|
+
await rename(partial, destination)
|
|
174
|
+
} finally {
|
|
175
|
+
await rm(partial, { force: true })
|
|
176
|
+
}
|
|
177
|
+
await writeFile(`${destination}.archive.sha256`, `${actual} ${asset}\n`)
|
|
178
|
+
return prebuiltHost(destination)
|
|
179
|
+
}
|
|
@@ -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,95 @@
|
|
|
1
|
+
import { readdir, realpath, stat } from 'node:fs/promises'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const ignored = new Set(['node_modules', '.git', '.bun-native', 'dist', 'build'])
|
|
5
|
+
|
|
6
|
+
export function rescanInput(path: string | Buffer | null | undefined): boolean {
|
|
7
|
+
// Missing names request a content rescan; they do not establish a source edit.
|
|
8
|
+
if (path === null || path === undefined) return true
|
|
9
|
+
const name = path.toString().replaceAll('\\', '/')
|
|
10
|
+
// A name without an extension may be a directory being replaced or removed.
|
|
11
|
+
// The snapshot decides whether relevant files actually changed.
|
|
12
|
+
return !name.split('/').some((part) => ignored.has(part))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function disappeared(error: unknown): boolean {
|
|
16
|
+
return (
|
|
17
|
+
error instanceof Error &&
|
|
18
|
+
'code' in error &&
|
|
19
|
+
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Input = { name: string; path: string; link: boolean }
|
|
24
|
+
|
|
25
|
+
/** The digest entry for one input, or undefined when it vanished or is not a file. */
|
|
26
|
+
async function inputDigest(
|
|
27
|
+
directory: string,
|
|
28
|
+
input: Input,
|
|
29
|
+
signal?: AbortSignal
|
|
30
|
+
): Promise<string | undefined> {
|
|
31
|
+
try {
|
|
32
|
+
// File links are hashed through their targets. Do not follow directory
|
|
33
|
+
// links: recursive aliases can escape the project or introduce cycles.
|
|
34
|
+
if (input.link && !(await stat(input.path)).isFile()) return undefined
|
|
35
|
+
const target = input.link ? relative(directory, await realpath(input.path)) : null
|
|
36
|
+
const contents = await Bun.file(input.path).bytes()
|
|
37
|
+
signal?.throwIfAborted()
|
|
38
|
+
return JSON.stringify([
|
|
39
|
+
input.name,
|
|
40
|
+
target,
|
|
41
|
+
new Bun.CryptoHasher('sha256').update(contents).digest('hex'),
|
|
42
|
+
])
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Deletion/atomic replacement during a scan is an absent input; a
|
|
45
|
+
// queued create/change event requests another scan. Other IO fails.
|
|
46
|
+
if (!disappeared(error)) throw error
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Enough concurrent reads to overlap file system latency without exhausting descriptors.
|
|
52
|
+
const READERS = 16
|
|
53
|
+
|
|
54
|
+
/** One content digest, with no retained file contents or earlier generations. */
|
|
55
|
+
export async function projectFingerprint(directory: string, signal?: AbortSignal): Promise<string> {
|
|
56
|
+
const inputs: Input[] = []
|
|
57
|
+
const visit = async (folder: string, prefix: string): Promise<void> => {
|
|
58
|
+
signal?.throwIfAborted()
|
|
59
|
+
let entries
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(folder, { withFileTypes: true })
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (disappeared(error)) return
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (ignored.has(entry.name)) continue
|
|
69
|
+
const path = join(folder, entry.name)
|
|
70
|
+
const name = prefix + entry.name
|
|
71
|
+
if (entry.isDirectory()) await visit(path, name + '/')
|
|
72
|
+
else if (
|
|
73
|
+
/\.(?:[cm]?[jt]s|[jt]sx|jsonc?|toml)$/.test(name) &&
|
|
74
|
+
(entry.isFile() || entry.isSymbolicLink())
|
|
75
|
+
)
|
|
76
|
+
inputs.push({ name, path, link: entry.isSymbolicLink() })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(directory, '')
|
|
80
|
+
// Read and hash files concurrently, then fold them in the sorted traversal order,
|
|
81
|
+
// so the digest is identical to a serial scan.
|
|
82
|
+
const digests: (string | undefined)[] = Array.from({ length: inputs.length })
|
|
83
|
+
let next = 0
|
|
84
|
+
const reader = async () => {
|
|
85
|
+
for (let index = next++; index < inputs.length; index = next++) {
|
|
86
|
+
signal?.throwIfAborted()
|
|
87
|
+
digests[index] = await inputDigest(directory, inputs[index]!, signal)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await Promise.all(Array.from({ length: Math.min(READERS, inputs.length) }, reader))
|
|
91
|
+
signal?.throwIfAborted()
|
|
92
|
+
const fingerprint = new Bun.CryptoHasher('sha256')
|
|
93
|
+
for (const digest of digests) if (digest !== undefined) fingerprint.update(digest)
|
|
94
|
+
return fingerprint.digest('hex')
|
|
95
|
+
}
|
|
@@ -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 async function lintProject(project: Project): Promise<QualityStep> {
|
|
42
|
+
return { name: 'lint', ...(await runQualityStep(project, lintArguments(project))) }
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { gunzipSync } from 'node:zlib'
|
|
2
|
+
import { sha256, type FileDigest } from './artifact.ts'
|
|
3
|
+
import { packageArchivePath } from './paths.ts'
|
|
4
|
+
import { USTAR_BLOCK as block, ustarChecksumValid, ustarNumber } from './ustar.ts'
|
|
5
|
+
|
|
6
|
+
const maximumBytes = 64 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
function text(field: Buffer): string {
|
|
9
|
+
const end = field.indexOf(0)
|
|
10
|
+
if (end !== -1 && field.subarray(end).some((byte) => byte !== 0))
|
|
11
|
+
throw new Error('Invalid package tar text padding')
|
|
12
|
+
return field.subarray(0, end === -1 ? undefined : end).toString('latin1')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function octal(field: Buffer): number {
|
|
16
|
+
const number = ustarNumber(field)
|
|
17
|
+
if (number === undefined) throw new Error('Invalid package tar numeric field')
|
|
18
|
+
return number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Inspect original USTAR members before any map can erase duplicates or link metadata. */
|
|
22
|
+
export async function verifyPackageArchive(
|
|
23
|
+
path: string,
|
|
24
|
+
expected: Record<string, FileDigest>
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const files = new Map<string, FileDigest>()
|
|
27
|
+
const directories = new Set<string>()
|
|
28
|
+
let paddedContents = 0
|
|
29
|
+
for (const [name, digest] of Object.entries(expected)) {
|
|
30
|
+
if (!packageArchivePath(name) || !Number.isSafeInteger(digest.bytes) || digest.bytes < 0)
|
|
31
|
+
throw new Error('Invalid expected package archive inventory')
|
|
32
|
+
const full = `package/${name}`
|
|
33
|
+
files.set(full, digest)
|
|
34
|
+
const parts = full.split('/')
|
|
35
|
+
for (let index = 1; index < parts.length; index += 1)
|
|
36
|
+
directories.add(parts.slice(0, index).join('/'))
|
|
37
|
+
paddedContents += Math.ceil(digest.bytes / block) * block
|
|
38
|
+
}
|
|
39
|
+
// Permit the declared files, directory headers, end blocks and one conventional
|
|
40
|
+
// 10 KiB blocking pad. Bound decompression before parsing any member contents.
|
|
41
|
+
const limit = paddedContents + (files.size + directories.size + 2) * block + 10_240
|
|
42
|
+
if (!Number.isSafeInteger(limit) || limit > maximumBytes || Bun.file(path).size > maximumBytes)
|
|
43
|
+
throw new Error('Package archive exceeds the bounded source-package inspection limit')
|
|
44
|
+
const data = gunzipSync(await Bun.file(path).bytes(), { maxOutputLength: limit })
|
|
45
|
+
if (data.length % block !== 0) throw new Error('Truncated package tar blocks')
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
const regular = new Set<string>()
|
|
48
|
+
let ended = false
|
|
49
|
+
for (let offset = 0; offset + block <= data.length;) {
|
|
50
|
+
const header = data.subarray(offset, offset + block)
|
|
51
|
+
if (header.every((byte) => byte === 0)) {
|
|
52
|
+
if (data.length - offset < block * 2 || data.subarray(offset).some((byte) => byte !== 0))
|
|
53
|
+
throw new Error('Invalid package tar end records or trailing data')
|
|
54
|
+
ended = true
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
octal(header.subarray(148, 156))
|
|
58
|
+
if (!ustarChecksumValid(header)) throw new Error('Invalid package tar header checksum')
|
|
59
|
+
if (!['ustar\0', 'ustar '].includes(header.subarray(257, 263).toString('latin1')))
|
|
60
|
+
throw new Error('Package archive must use the supported USTAR format')
|
|
61
|
+
const kind = header[156] === 0 ? '0' : String.fromCharCode(header[156]!)
|
|
62
|
+
if (!['0', '5'].includes(kind) || text(header.subarray(157, 257)) !== '')
|
|
63
|
+
throw new Error(`Unsupported package tar member type or link: ${kind}`)
|
|
64
|
+
const size = octal(header.subarray(124, 136))
|
|
65
|
+
const prefix = text(header.subarray(345, 500))
|
|
66
|
+
const raw = (prefix ? prefix + '/' : '') + text(header.subarray(0, 100))
|
|
67
|
+
const name = kind === '5' ? raw.replace(/\/$/, '') : raw
|
|
68
|
+
if (!packageArchivePath(name))
|
|
69
|
+
throw new Error(`Package archive inventory contains an invalid path: ${raw}`)
|
|
70
|
+
if (seen.has(name)) throw new Error(`Duplicate package tar member: ${name}`)
|
|
71
|
+
seen.add(name)
|
|
72
|
+
const start = offset + block
|
|
73
|
+
const end = start + size
|
|
74
|
+
const next = start + Math.ceil(size / block) * block
|
|
75
|
+
if (!Number.isSafeInteger(next) || next > data.length)
|
|
76
|
+
throw new Error('Truncated package tar member')
|
|
77
|
+
if (data.subarray(end, next).some((byte) => byte !== 0))
|
|
78
|
+
throw new Error('Invalid package tar member padding')
|
|
79
|
+
if (kind === '5') {
|
|
80
|
+
if (size !== 0 || !directories.has(name))
|
|
81
|
+
throw new Error(`Unexpected package tar directory: ${name}`)
|
|
82
|
+
} else {
|
|
83
|
+
const expected = files.get(name)
|
|
84
|
+
if (!expected) throw new Error(`Packed archive file inventory differs: ${name}`)
|
|
85
|
+
if (size !== expected.bytes || sha256(data.subarray(start, end)) !== expected.sha256)
|
|
86
|
+
throw new Error(`Packed archive digest mismatch: ${name}`)
|
|
87
|
+
regular.add(name)
|
|
88
|
+
}
|
|
89
|
+
offset = next
|
|
90
|
+
}
|
|
91
|
+
if (!ended) throw new Error('Package tar has no end records')
|
|
92
|
+
if (regular.size !== files.size)
|
|
93
|
+
throw new Error('Packed archive file inventory differs from the staged package')
|
|
94
|
+
}
|