@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,209 @@
|
|
|
1
|
+
import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
4
|
+
import toolchain from '../../toolchain.json'
|
|
5
|
+
import { sha256, type FileDigest } from './artifact.ts'
|
|
6
|
+
import { userCache } from './context.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A verified standalone package set, as `readProject` reports it.
|
|
10
|
+
*
|
|
11
|
+
* Full verification hashes every archive, gunzips and hashes each member, checks
|
|
12
|
+
* the consumer lock, and walks and hashes every installed file. `build`, `create`
|
|
13
|
+
* and `doctor` always do that. The light commands (`check`, `format`, `lint`,
|
|
14
|
+
* `dev` and each watch remount) may instead accept a stamp that the last full
|
|
15
|
+
* verification left in `.bun-native/verified.json`. The stamp records the
|
|
16
|
+
* manifest digest and the lstat identity (device, inode, mode, size, mtime and
|
|
17
|
+
* ctime in nanoseconds) of every path that verification read: the manifest,
|
|
18
|
+
* each archive, `bun.lock`, `node_modules`, each required package root, each of
|
|
19
|
+
* its directories and installed files, and the verifier's own modules. Any
|
|
20
|
+
* write, replacement, added or removed entry changes one of those identities,
|
|
21
|
+
* and a miss falls back to full verification. A stamp hit trusts file system
|
|
22
|
+
* metadata; it is a cache for local edit loops, not a security boundary.
|
|
23
|
+
*/
|
|
24
|
+
export type VerifiedPackageSet = {
|
|
25
|
+
source: { commit: string; tree: string }
|
|
26
|
+
manifest: FileDigest
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Stamp = {
|
|
30
|
+
schemaVersion: 1
|
|
31
|
+
revision: string
|
|
32
|
+
framework: 'solid' | 'react'
|
|
33
|
+
manifestPath: string
|
|
34
|
+
archiveDirectory: string
|
|
35
|
+
manifest: FileDigest
|
|
36
|
+
source: VerifiedPackageSet['source']
|
|
37
|
+
entries: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One stamp per project directory, in the user cache so the project stays clean. */
|
|
41
|
+
export const stampFile = (
|
|
42
|
+
directory: string,
|
|
43
|
+
environment: Record<string, string | undefined> = process.env
|
|
44
|
+
) => join(userCache(environment), 'verified', `${sha256(resolve(directory)).slice(0, 32)}.json`)
|
|
45
|
+
|
|
46
|
+
// The modules whose rules decide verification; changing them invalidates every stamp.
|
|
47
|
+
const verifier = [
|
|
48
|
+
'artifact.ts',
|
|
49
|
+
'package-archive.ts',
|
|
50
|
+
'package-lock.ts',
|
|
51
|
+
'package-set.ts',
|
|
52
|
+
'package-stamp.ts',
|
|
53
|
+
'paths.ts',
|
|
54
|
+
].map((name) => join(import.meta.dir, name))
|
|
55
|
+
|
|
56
|
+
/** One path's identity, or undefined for a symlink or other entry the stamp will not trust. */
|
|
57
|
+
async function identity(path: string): Promise<string | undefined> {
|
|
58
|
+
const stats = await lstat(path, { bigint: true })
|
|
59
|
+
if (!stats.isFile() && !stats.isDirectory()) return undefined
|
|
60
|
+
return [stats.dev, stats.ino, stats.mode, stats.size, stats.mtimeNs, stats.ctimeNs].join(':')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function identities(
|
|
64
|
+
directory: string,
|
|
65
|
+
paths: string[]
|
|
66
|
+
): Promise<Record<string, string> | undefined> {
|
|
67
|
+
const values = await Promise.all(
|
|
68
|
+
paths.map((path) => identity(resolve(directory, path)).catch(() => undefined))
|
|
69
|
+
)
|
|
70
|
+
if (values.some((value) => value === undefined)) return undefined
|
|
71
|
+
return Object.fromEntries(paths.map((path, index) => [path, values[index]!]))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const portable = (path: string) => path.split(sep).join('/')
|
|
75
|
+
|
|
76
|
+
async function manifestDigest(path: string): Promise<{ text: string; digest: FileDigest }> {
|
|
77
|
+
const bytes = await Bun.file(path).bytes()
|
|
78
|
+
return {
|
|
79
|
+
text: new TextDecoder().decode(bytes),
|
|
80
|
+
digest: { bytes: bytes.byteLength, sha256: sha256(bytes) },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The stamped result when every recorded identity still matches, else undefined. */
|
|
85
|
+
async function stamped(
|
|
86
|
+
directory: string,
|
|
87
|
+
manifestPath: string,
|
|
88
|
+
framework: 'solid' | 'react',
|
|
89
|
+
archiveDirectory: string
|
|
90
|
+
): Promise<VerifiedPackageSet | undefined> {
|
|
91
|
+
if (Bun.revision !== toolchain.bun.revision) return undefined
|
|
92
|
+
let stamp: Stamp
|
|
93
|
+
try {
|
|
94
|
+
stamp = await Bun.file(stampFile(directory)).json()
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
stamp?.schemaVersion !== 1 ||
|
|
100
|
+
stamp.revision !== Bun.revision ||
|
|
101
|
+
stamp.framework !== framework ||
|
|
102
|
+
stamp.manifestPath !== manifestPath ||
|
|
103
|
+
stamp.archiveDirectory !== archiveDirectory ||
|
|
104
|
+
typeof stamp.entries !== 'object' ||
|
|
105
|
+
stamp.entries === null
|
|
106
|
+
)
|
|
107
|
+
return undefined
|
|
108
|
+
const [current, manifest] = await Promise.all([
|
|
109
|
+
identities(directory, Object.keys(stamp.entries)),
|
|
110
|
+
manifestDigest(manifestPath),
|
|
111
|
+
])
|
|
112
|
+
if (!current || !isDeepStrictEqual(current, stamp.entries)) return undefined
|
|
113
|
+
if (!isDeepStrictEqual(manifest.digest, stamp.manifest)) return undefined
|
|
114
|
+
return { source: stamp.source, manifest: manifest.digest }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every path full verification reads, relative to the project or absolute for the verifier. */
|
|
118
|
+
async function verifiedPaths(
|
|
119
|
+
directory: string,
|
|
120
|
+
manifestPath: string,
|
|
121
|
+
manifestText: string,
|
|
122
|
+
framework: 'solid' | 'react'
|
|
123
|
+
): Promise<string[]> {
|
|
124
|
+
const { parsePackageSet, requiredPackages } = await import('./package-set.ts')
|
|
125
|
+
const set = parsePackageSet(JSON.parse(manifestText))
|
|
126
|
+
const setDirectory = portable(relative(directory, dirname(manifestPath)))
|
|
127
|
+
const paths = new Set<string>([
|
|
128
|
+
portable(relative(directory, manifestPath)),
|
|
129
|
+
'bun.lock',
|
|
130
|
+
'node_modules',
|
|
131
|
+
...verifier,
|
|
132
|
+
])
|
|
133
|
+
for (const pkg of Object.values(set.packages)) paths.add(`${setDirectory}/${pkg.archive.file}`)
|
|
134
|
+
for (const name of requiredPackages(set, framework)) {
|
|
135
|
+
const root = `node_modules/${name}`
|
|
136
|
+
const parts = root.split('/')
|
|
137
|
+
for (let index = 2; index <= parts.length; index += 1)
|
|
138
|
+
paths.add(parts.slice(0, index).join('/'))
|
|
139
|
+
for (const file of [...Object.keys(set.packages[name]!.files), '.bun-tag']) {
|
|
140
|
+
const segments = file.split('/')
|
|
141
|
+
for (let index = 1; index <= segments.length; index += 1)
|
|
142
|
+
paths.add(`${root}/${segments.slice(0, index).join('/')}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...paths].sort()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function writeStamp(directory: string, stamp: Stamp): Promise<void> {
|
|
149
|
+
const file = stampFile(directory)
|
|
150
|
+
const partial = `${file}.${process.pid}.partial`
|
|
151
|
+
try {
|
|
152
|
+
await mkdir(dirname(file), { recursive: true })
|
|
153
|
+
await writeFile(partial, JSON.stringify(stamp, null, 2) + '\n')
|
|
154
|
+
await rename(partial, file)
|
|
155
|
+
} catch {
|
|
156
|
+
// An unwritable user cache still verifies; it only loses the stamp.
|
|
157
|
+
await rm(partial, { force: true }).catch(() => {})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify a standalone project's package set and installed packages. With `reuse`,
|
|
163
|
+
* a matching stamp skips the archive and installed-file hashing, and a full
|
|
164
|
+
* verification refreshes the stamp when nothing changed while it ran.
|
|
165
|
+
*/
|
|
166
|
+
export async function verifyStandalone(
|
|
167
|
+
directory: string,
|
|
168
|
+
manifestPath: string,
|
|
169
|
+
framework: 'solid' | 'react',
|
|
170
|
+
reuse: boolean
|
|
171
|
+
): Promise<VerifiedPackageSet> {
|
|
172
|
+
const archiveDirectory = portable(relative(directory, dirname(manifestPath)))
|
|
173
|
+
if (reuse) {
|
|
174
|
+
const hit = await stamped(directory, manifestPath, framework, archiveDirectory)
|
|
175
|
+
if (hit) return hit
|
|
176
|
+
}
|
|
177
|
+
const { readPackageSet, verifyInstalledPackages } = await import('./package-set.ts')
|
|
178
|
+
// Record identities before hashing, so a change during verification cannot be stamped.
|
|
179
|
+
const before = reuse ? await manifestDigest(manifestPath) : undefined
|
|
180
|
+
const paths = before
|
|
181
|
+
? await verifiedPaths(directory, manifestPath, before.text, framework).catch(() => undefined)
|
|
182
|
+
: undefined
|
|
183
|
+
const recorded = paths ? await identities(directory, paths) : undefined
|
|
184
|
+
const set = await readPackageSet(manifestPath)
|
|
185
|
+
await verifyInstalledPackages(directory, set.manifest, framework, archiveDirectory)
|
|
186
|
+
const manifest =
|
|
187
|
+
before?.text === set.text ? before.digest : (await manifestDigest(manifestPath)).digest
|
|
188
|
+
const result = {
|
|
189
|
+
source: { commit: set.manifest.source.commit, tree: set.manifest.source.tree },
|
|
190
|
+
manifest,
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
paths &&
|
|
194
|
+
recorded &&
|
|
195
|
+
before?.text === set.text &&
|
|
196
|
+
isDeepStrictEqual(recorded, await identities(directory, paths))
|
|
197
|
+
)
|
|
198
|
+
await writeStamp(directory, {
|
|
199
|
+
schemaVersion: 1,
|
|
200
|
+
revision: Bun.revision,
|
|
201
|
+
framework,
|
|
202
|
+
manifestPath,
|
|
203
|
+
archiveDirectory,
|
|
204
|
+
manifest,
|
|
205
|
+
source: result.source,
|
|
206
|
+
entries: recorded,
|
|
207
|
+
})
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export function inside(parent: string, child: string): boolean {
|
|
4
|
+
const path = relative(parent, child)
|
|
5
|
+
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Package-set filenames are portable archive paths, never platform-specific paths. */
|
|
9
|
+
export function packagePath(value: unknown): value is string {
|
|
10
|
+
return (
|
|
11
|
+
typeof value === 'string' &&
|
|
12
|
+
/^[A-Za-z0-9_@./-]+$/.test(value) &&
|
|
13
|
+
value.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Installer-owned metadata must not originate in a source archive at any depth. */
|
|
18
|
+
export function packageArchivePath(value: unknown): value is string {
|
|
19
|
+
return packagePath(value) && !value.split('/').includes('.bun-tag')
|
|
20
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { inside } from './paths.ts'
|
|
4
|
+
import type { FileDigest } from './artifact.ts'
|
|
5
|
+
import { cliContext } from './context.ts'
|
|
6
|
+
import type { RegistryTemplate } from './registry-project.ts'
|
|
7
|
+
// Package-set modules (archive parsing, gzip, lock checks) load only for standalone
|
|
8
|
+
// projects; checkout projects never need them.
|
|
9
|
+
|
|
10
|
+
export type Framework = 'solid' | 'react'
|
|
11
|
+
export type Language = 'typescript' | 'javascript'
|
|
12
|
+
export type Project = {
|
|
13
|
+
directory: string
|
|
14
|
+
framework: Framework
|
|
15
|
+
entry: string
|
|
16
|
+
packageSet?: { source: { commit: string; tree: string }; manifest: FileDigest }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function template(framework: Framework, language: Language): string {
|
|
20
|
+
const typed = language === 'typescript'
|
|
21
|
+
const solid = framework === 'solid'
|
|
22
|
+
const title = `Bun Native ${solid ? 'Solid' : 'React'}`
|
|
23
|
+
return `${solid ? '' : '/** @jsxImportSource @bun-native/react */\n'}import { createApplication, reportHealthy } from '@bun-native/core'
|
|
24
|
+
import { render${typed ? ', type JSX' : ''} } from '@bun-native/${framework}'
|
|
25
|
+
import { ${solid ? 'createSignal' : 'useState'} } from '${solid ? 'solid-js' : 'react'}'
|
|
26
|
+
|
|
27
|
+
const executable = process.env['BUN_NATIVE_HOST']
|
|
28
|
+
if (!executable)
|
|
29
|
+
throw new Error('Run the dev command with --host pointing to a prebuilt native host')
|
|
30
|
+
const application = await createApplication({ executable })
|
|
31
|
+
${typed ? `let view: ReturnType<typeof render> | undefined` : '/** @type {ReturnType<typeof render> | undefined} */\nlet view'}
|
|
32
|
+
const stopped = Promise.withResolvers${typed ? '<void>' : ''}()
|
|
33
|
+
const stop = () => stopped.resolve(${typed ? '' : 'undefined'})
|
|
34
|
+
${typed ? '' : '/** @param {unknown} message */\n'}function stopFromParent(message${typed ? ': unknown' : ''}) {
|
|
35
|
+
if (
|
|
36
|
+
message &&
|
|
37
|
+
typeof message === 'object' &&
|
|
38
|
+
'type' in message &&
|
|
39
|
+
message.type === 'bun-native:stop'
|
|
40
|
+
)
|
|
41
|
+
stop()
|
|
42
|
+
}
|
|
43
|
+
process.once('SIGINT', stop)
|
|
44
|
+
process.once('SIGTERM', stop)
|
|
45
|
+
process.on('message', stopFromParent)
|
|
46
|
+
|
|
47
|
+
function App()${typed ? ': JSX.Element' : ''} {
|
|
48
|
+
const [clicks, setClicks] = ${solid ? 'createSignal' : 'useState'}(0)
|
|
49
|
+
return (
|
|
50
|
+
<box style={{ direction: 'column', gap: 12, padding: 24 }}>
|
|
51
|
+
<text text="${title}" style={{ font_size: 28 }} />
|
|
52
|
+
<text text={'Clicks: ' + ${solid ? 'clicks()' : 'clicks'}} />
|
|
53
|
+
<button
|
|
54
|
+
text="Increment"
|
|
55
|
+
style={{ width: 160, height: 44 }}
|
|
56
|
+
onClick={() => {
|
|
57
|
+
setClicks((value) => value + 1)
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
</box>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const window = await application.openWindow({
|
|
66
|
+
title: '${title}',
|
|
67
|
+
width: 640,
|
|
68
|
+
height: 480,
|
|
69
|
+
})
|
|
70
|
+
view = render(${solid ? 'App' : '<App />'}, window)
|
|
71
|
+
await view.ready
|
|
72
|
+
// Confirms a pending update when the installed app was started by the verifying launcher.
|
|
73
|
+
await reportHealthy()
|
|
74
|
+
process.send?.({ type: 'bun-native:ready' })
|
|
75
|
+
await Promise.race([window.closed, view.closed, stopped.promise])
|
|
76
|
+
} finally {
|
|
77
|
+
process.off('SIGINT', stop)
|
|
78
|
+
process.off('SIGTERM', stop)
|
|
79
|
+
process.off('message', stopFromParent)
|
|
80
|
+
try {
|
|
81
|
+
await view?.dispose()
|
|
82
|
+
} finally {
|
|
83
|
+
await application.shutdown()
|
|
84
|
+
await application.closed
|
|
85
|
+
process.send?.({
|
|
86
|
+
type: 'bun-native:stopped',
|
|
87
|
+
application: application.diagnostics,
|
|
88
|
+
nodes: view?.diagnostics.nodes ?? 0,
|
|
89
|
+
})
|
|
90
|
+
if (process.connected) process.disconnect?.()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readme(name: string, framework: Framework, guide: string): string {
|
|
97
|
+
const solid = framework === 'solid'
|
|
98
|
+
const rules = solid
|
|
99
|
+
? `This app uses the Solid adapter (\`@bun-native/solid\`). Components run once and signals update only the native nodes that read them. Pass the component itself to \`render(App, window)\`. \`solid-js/web\` and DOM components are not available.`
|
|
100
|
+
: `This app uses the React adapter (\`@bun-native/react\`). Hooks, refs, effect cleanup and error boundaries work as in React. Pass an element to \`render(<App />, window)\`. \`react-dom\` and DOM components are not available.`
|
|
101
|
+
return `# ${name}
|
|
102
|
+
|
|
103
|
+
This project uses the surrounding Bun Native checkout and its pinned dependencies.
|
|
104
|
+
Run \`bun install --frozen-lockfile\` in the checkout first; no Rust compiler is needed when you supply a prebuilt host.
|
|
105
|
+
|
|
106
|
+
- \`bun run check\` checks TypeScript or checked JavaScript.
|
|
107
|
+
- \`bun run format\` applies Bun's built-in formatter; \`bun run format:check\` only reports differences.
|
|
108
|
+
- \`bun run lint\` runs the checkout's pinned oxlint with the checkout's lint rules, or this project's own \`.oxlintrc.json\`.
|
|
109
|
+
- \`bun run dev --host /absolute/path/to/bun-native-host\` bundles and opens the app once. Close the window or press Ctrl-C to end it.
|
|
110
|
+
- Add \`--watch\` to remount after source edits with joined teardown. State resets on every successful edit; state-preserving hot reload is not implemented.
|
|
111
|
+
- \`bun run build --host /absolute/path/to/bun-native-host --host-profile release\` creates an unsigned same-platform AOT app in \`dist\`. The output directory must be empty.
|
|
112
|
+
|
|
113
|
+
${rules} Build the interface from native elements such as \`box\`, \`text\`, \`button\` and \`input\`; CSS selectors and browser events do not apply.
|
|
114
|
+
|
|
115
|
+
Keep the template's optional development IPC hooks: they report readiness and clean up the renderer and owned host before a replacement starts. The host is supplied explicitly and never downloaded or rebuilt.
|
|
116
|
+
|
|
117
|
+
See the checkout's [getting-started guide](${guide}) for the ${solid ? 'SolidJS' : 'React'} walkthrough, debugging and upgrade steps, and \`docs/developer-tooling.md\` for requirements and limitations.
|
|
118
|
+
`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function createProject(
|
|
122
|
+
repository: string,
|
|
123
|
+
destination: string,
|
|
124
|
+
framework: Framework,
|
|
125
|
+
language: Language,
|
|
126
|
+
packages?: string,
|
|
127
|
+
registry?: RegistryTemplate
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
const set =
|
|
130
|
+
packages === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: await (await import('./package-set.ts')).readPackageSet(packages)
|
|
133
|
+
// A project with installed packages (a local set or the registry) lives anywhere.
|
|
134
|
+
const installed = set !== undefined || registry !== undefined
|
|
135
|
+
const root = await realpath(repository)
|
|
136
|
+
const directory = resolve(destination)
|
|
137
|
+
if (!installed && !inside(root, directory))
|
|
138
|
+
throw new Error('Create the project inside this Bun Native checkout')
|
|
139
|
+
// Resolve existing parents before writing, so a symlink cannot escape the checkout.
|
|
140
|
+
let ancestor = dirname(directory)
|
|
141
|
+
let resolvedAncestor: string | undefined
|
|
142
|
+
while (resolvedAncestor === undefined) {
|
|
143
|
+
try {
|
|
144
|
+
resolvedAncestor = await realpath(ancestor)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
|
|
147
|
+
const parent = dirname(ancestor)
|
|
148
|
+
if (parent === ancestor) throw error
|
|
149
|
+
ancestor = parent
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!installed && resolvedAncestor !== root && !inside(root, resolvedAncestor))
|
|
153
|
+
throw new Error('Project parent resolves outside this Bun Native checkout')
|
|
154
|
+
await mkdir(dirname(directory), { recursive: true })
|
|
155
|
+
const parent = await realpath(dirname(directory))
|
|
156
|
+
if (!installed && parent !== root && !inside(root, parent))
|
|
157
|
+
throw new Error('Project parent resolves outside this Bun Native checkout')
|
|
158
|
+
await mkdir(directory) // Existing directories, including empty ones, are never overwritten.
|
|
159
|
+
try {
|
|
160
|
+
const entry = language === 'typescript' ? 'main.tsx' : 'main.jsx'
|
|
161
|
+
const cli = relative(directory, join(root, 'scripts/native.ts')).split(sep).join('/')
|
|
162
|
+
const tsconfig = relative(directory, join(root, 'tsconfig.json')).split(sep).join('/')
|
|
163
|
+
const guide = relative(directory, join(root, 'docs/getting-started.md')).split(sep).join('/')
|
|
164
|
+
const files: Record<string, string> = {
|
|
165
|
+
'bun-native.json': JSON.stringify({ schemaVersion: 1, framework, entry }, null, 2) + '\n',
|
|
166
|
+
'package.json':
|
|
167
|
+
JSON.stringify(
|
|
168
|
+
{
|
|
169
|
+
name: basename(directory),
|
|
170
|
+
private: true,
|
|
171
|
+
type: 'module',
|
|
172
|
+
scripts: {
|
|
173
|
+
// See docs/developer-tooling.md "Controller memory".
|
|
174
|
+
dev: `MIMALLOC_PURGE_DELAY=0 bun ${JSON.stringify(cli)} dev .`,
|
|
175
|
+
build: `bun ${JSON.stringify(cli)} build .`,
|
|
176
|
+
check: 'bun check --no-script --no-pretty',
|
|
177
|
+
format: `bun ${JSON.stringify(cli)} format .`,
|
|
178
|
+
'format:check': `bun ${JSON.stringify(cli)} format . --check`,
|
|
179
|
+
lint: `bun ${JSON.stringify(cli)} lint .`,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
null,
|
|
183
|
+
2
|
|
184
|
+
) + '\n',
|
|
185
|
+
'tsconfig.json': `{
|
|
186
|
+
"extends": ${JSON.stringify(tsconfig)},
|
|
187
|
+
"compilerOptions": {
|
|
188
|
+
"jsxImportSource": "@bun-native/${framework}"
|
|
189
|
+
},
|
|
190
|
+
"include": [${JSON.stringify(entry)}],
|
|
191
|
+
"exclude": ["dist", ".bun-native"]
|
|
192
|
+
}
|
|
193
|
+
`,
|
|
194
|
+
'.gitignore': 'dist/\n.bun-native/\n',
|
|
195
|
+
'README.md': readme(basename(directory), framework, guide),
|
|
196
|
+
[entry]: template(framework, language),
|
|
197
|
+
}
|
|
198
|
+
if (registry) {
|
|
199
|
+
const { registryFiles } = await import('./registry-project.ts')
|
|
200
|
+
Object.assign(files, registryFiles(registry, framework, entry, basename(directory)))
|
|
201
|
+
} else if (set) {
|
|
202
|
+
const { copyPackageSet, standaloneFiles } = await import('./standalone.ts')
|
|
203
|
+
Object.assign(files, standaloneFiles(set, framework, entry, basename(directory)))
|
|
204
|
+
await copyPackageSet(set, directory)
|
|
205
|
+
}
|
|
206
|
+
for (const [name, content] of Object.entries(files))
|
|
207
|
+
await writeFile(join(directory, name), content, { flag: 'wx' })
|
|
208
|
+
return directory
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await rm(directory, { recursive: true, force: true })
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type ReadProjectOptions = {
|
|
216
|
+
/**
|
|
217
|
+
* `full` (the default) re-verifies a standalone package set completely. `stamp`
|
|
218
|
+
* accepts the last full verification while no verified path changed; see
|
|
219
|
+
* package-stamp.ts. Use it only for commands that never ship an artifact.
|
|
220
|
+
*/
|
|
221
|
+
verification?: 'full' | 'stamp'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function readProject(
|
|
225
|
+
repository: string,
|
|
226
|
+
path: string,
|
|
227
|
+
options: ReadProjectOptions = {}
|
|
228
|
+
): Promise<Project> {
|
|
229
|
+
const root = await realpath(repository)
|
|
230
|
+
const directory = await realpath(path)
|
|
231
|
+
const value: unknown = await Bun.file(join(directory, 'bun-native.json')).json()
|
|
232
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
233
|
+
throw new Error('Invalid bun-native.json project configuration')
|
|
234
|
+
const config = value as Record<string, unknown>
|
|
235
|
+
if (
|
|
236
|
+
Object.keys(config).some(
|
|
237
|
+
(key) => !['schemaVersion', 'framework', 'entry', 'packageSet'].includes(key)
|
|
238
|
+
) ||
|
|
239
|
+
config['schemaVersion'] !== 1 ||
|
|
240
|
+
(config['framework'] !== 'solid' && config['framework'] !== 'react') ||
|
|
241
|
+
typeof config['entry'] !== 'string' ||
|
|
242
|
+
isAbsolute(config['entry']) ||
|
|
243
|
+
!/\.[jt]sx?$/.test(config['entry'])
|
|
244
|
+
)
|
|
245
|
+
throw new Error(
|
|
246
|
+
'bun-native.json requires schemaVersion 1, framework solid|react and a relative TS/JS entry'
|
|
247
|
+
)
|
|
248
|
+
const entry = await realpath(join(directory, config['entry']))
|
|
249
|
+
if (!inside(directory, entry) || !(await stat(entry)).isFile())
|
|
250
|
+
throw new Error('Project entry must be a source file inside the project')
|
|
251
|
+
if (config['packageSet'] !== undefined) {
|
|
252
|
+
if (typeof config['packageSet'] !== 'string' || isAbsolute(config['packageSet']))
|
|
253
|
+
throw new Error('Project packageSet must be a relative manifest path inside the project')
|
|
254
|
+
const manifestPath = await realpath(join(directory, config['packageSet']))
|
|
255
|
+
if (!inside(directory, manifestPath))
|
|
256
|
+
throw new Error('Project packageSet resolves outside the project')
|
|
257
|
+
const { verifyStandalone } = await import('./package-stamp.ts')
|
|
258
|
+
const packageSet = await verifyStandalone(
|
|
259
|
+
directory,
|
|
260
|
+
manifestPath,
|
|
261
|
+
config['framework'],
|
|
262
|
+
options.verification === 'stamp'
|
|
263
|
+
)
|
|
264
|
+
return { directory, framework: config['framework'], entry, packageSet }
|
|
265
|
+
}
|
|
266
|
+
// Without a package set, a project outside the checkout installs its packages from
|
|
267
|
+
// the registry; only an installed CLI (not a checkout's) serves such a project.
|
|
268
|
+
if (!inside(root, directory) && (await cliContext(root)).mode !== 'package')
|
|
269
|
+
throw new Error('Project must be inside this Bun Native checkout')
|
|
270
|
+
return { directory, framework: config['framework'], entry }
|
|
271
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { join, relative, sep } from 'node:path'
|
|
2
|
+
// Package the template style with the CLI so standalone projects without their own config use it.
|
|
3
|
+
import '../../.oxfmtrc.json'
|
|
4
|
+
import type { Project } from './project.ts'
|
|
5
|
+
|
|
6
|
+
/** The style the generated templates use; projects with a discoverable config keep theirs. */
|
|
7
|
+
export const templateFormatterConfig = join(import.meta.dir, '../../.oxfmtrc.json')
|
|
8
|
+
|
|
9
|
+
export type QualityStep = {
|
|
10
|
+
name: 'typecheck' | 'format' | 'lint'
|
|
11
|
+
command: string[]
|
|
12
|
+
exitCode: number
|
|
13
|
+
output: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A quality step that runs longer than this is killed and reported as failed. */
|
|
17
|
+
const STEP_TIMEOUT_MS = 10 * 60_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs the pinned Bun with `args` in the project directory and captures its combined
|
|
21
|
+
* output. Asynchronous, so `check` runs its steps concurrently; callers print the
|
|
22
|
+
* results in a fixed order.
|
|
23
|
+
*/
|
|
24
|
+
export async function runQualityStep(
|
|
25
|
+
project: Project,
|
|
26
|
+
args: string[]
|
|
27
|
+
): Promise<Omit<QualityStep, 'name'>> {
|
|
28
|
+
const command = [process.execPath, ...args]
|
|
29
|
+
const child = Bun.spawn(command, {
|
|
30
|
+
cwd: project.directory,
|
|
31
|
+
stdout: 'pipe',
|
|
32
|
+
stderr: 'pipe',
|
|
33
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
34
|
+
timeout: STEP_TIMEOUT_MS,
|
|
35
|
+
})
|
|
36
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
37
|
+
new Response(child.stdout).text(),
|
|
38
|
+
new Response(child.stderr).text(),
|
|
39
|
+
child.exited,
|
|
40
|
+
])
|
|
41
|
+
return {
|
|
42
|
+
command,
|
|
43
|
+
exitCode: child.signalCode ? 1 : exitCode,
|
|
44
|
+
output: (stdout + stderr).trimEnd(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Arguments for the built-in formatter, never a same-named package script. */
|
|
49
|
+
export async function formatArguments(project: Project, check: boolean): Promise<string[]> {
|
|
50
|
+
const entry = relative(project.directory, project.entry).split(sep).join('/')
|
|
51
|
+
const discovered = await Bun.spawn(
|
|
52
|
+
[process.execPath, 'fmt', '--no-script', '--find-config-path', entry],
|
|
53
|
+
{ cwd: project.directory, stdout: 'ignore', stderr: 'ignore', timeout: STEP_TIMEOUT_MS }
|
|
54
|
+
).exited
|
|
55
|
+
const args = ['--no-install', 'fmt', '--no-script', '--no-color']
|
|
56
|
+
if (check) args.push('--check')
|
|
57
|
+
if (discovered !== 0) args.push('--config', templateFormatterConfig)
|
|
58
|
+
// Package archives and their canonical manifest are verified by digest, never reformatted.
|
|
59
|
+
if (project.packageSet) args.push('--ignore-pattern', 'vendor/**')
|
|
60
|
+
args.push('.')
|
|
61
|
+
return args
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function formatProject(project: Project, check: boolean): Promise<QualityStep> {
|
|
65
|
+
return {
|
|
66
|
+
name: 'format',
|
|
67
|
+
...(await runQualityStep(project, await formatArguments(project, check))),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Built-in type checking and the formatter check, run together; one report covers both. */
|
|
72
|
+
export async function checkProject(project: Project): Promise<QualityStep[]> {
|
|
73
|
+
const typecheck = runQualityStep(project, ['--no-install', 'check', '--no-script', '--no-pretty'])
|
|
74
|
+
const format = formatProject(project, true)
|
|
75
|
+
return [{ name: 'typecheck', ...(await typecheck) }, await format]
|
|
76
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import toolchain from '../../toolchain.json'
|
|
3
|
+
import { consumerScripts, consumerTsconfig, installedCli, json } from './consumer-files.ts'
|
|
4
|
+
import type { Framework } from './bundle.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The dependencies of a project created by the npm-installed CLI: every
|
|
8
|
+
* `@bun-native/*` package at the CLI's own release version, plus the framework and
|
|
9
|
+
* tool pins the release was qualified with. `scripts/registry-packages.ts` derives it
|
|
10
|
+
* from the package set's consumer definitions and publishes it in the CLI's
|
|
11
|
+
* `package.json` under `bunNative.templates`.
|
|
12
|
+
*/
|
|
13
|
+
export type RegistryTemplate = {
|
|
14
|
+
dependencies: Record<string, string>
|
|
15
|
+
devDependencies: Record<string, string>
|
|
16
|
+
}
|
|
17
|
+
export type RegistryTemplates = Record<Framework, RegistryTemplate>
|
|
18
|
+
|
|
19
|
+
type ConsumerPins = Record<Framework, RegistryTemplate>
|
|
20
|
+
|
|
21
|
+
/** The published templates for `version`: local `@bun-native/*` versions become the release version. */
|
|
22
|
+
export function registryTemplates(consumers: ConsumerPins, version: string): RegistryTemplates {
|
|
23
|
+
const release = (pins: Record<string, string>) =>
|
|
24
|
+
Object.fromEntries(
|
|
25
|
+
Object.entries(pins).map(([name, pin]) => [
|
|
26
|
+
name,
|
|
27
|
+
name.startsWith('@bun-native/') ? version : pin,
|
|
28
|
+
])
|
|
29
|
+
)
|
|
30
|
+
const template = (framework: Framework): RegistryTemplate => ({
|
|
31
|
+
dependencies: release(consumers[framework].dependencies),
|
|
32
|
+
devDependencies: release(consumers[framework].devDependencies),
|
|
33
|
+
})
|
|
34
|
+
return { solid: template('solid'), react: template('react') }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The templates an npm-installed CLI carries, read from its own `package.json`
|
|
39
|
+
* (`root` is the package's `src`). A CLI from a local package set has none.
|
|
40
|
+
*/
|
|
41
|
+
export async function installedTemplates(root: string): Promise<RegistryTemplates> {
|
|
42
|
+
const manifest: { bunNative?: { templates?: RegistryTemplates } } = await Bun.file(
|
|
43
|
+
join(dirname(root), 'package.json')
|
|
44
|
+
).json()
|
|
45
|
+
const templates = manifest.bunNative?.templates
|
|
46
|
+
if (!templates?.solid || !templates.react)
|
|
47
|
+
throw new Error(
|
|
48
|
+
'This CLI was not installed from the npm registry; create the project with --packages /path/to/package-set/manifest.json'
|
|
49
|
+
)
|
|
50
|
+
return templates
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function registryFiles(
|
|
54
|
+
template: RegistryTemplate,
|
|
55
|
+
framework: Framework,
|
|
56
|
+
entry: string,
|
|
57
|
+
name: string
|
|
58
|
+
): Record<string, string> {
|
|
59
|
+
const version = template.dependencies['@bun-native/core']
|
|
60
|
+
return {
|
|
61
|
+
'bun-native.json': json({ schemaVersion: 1, framework, entry }),
|
|
62
|
+
'package.json': json({
|
|
63
|
+
name,
|
|
64
|
+
private: true,
|
|
65
|
+
type: 'module',
|
|
66
|
+
packageManager: `bun@${toolchain.bun.version}`,
|
|
67
|
+
engines: { bun: toolchain.bun.version },
|
|
68
|
+
scripts: consumerScripts(),
|
|
69
|
+
dependencies: template.dependencies,
|
|
70
|
+
devDependencies: template.devDependencies,
|
|
71
|
+
}),
|
|
72
|
+
'tsconfig.json': consumerTsconfig(framework, entry, ['dist', 'node_modules', '.bun-native']),
|
|
73
|
+
'.gitignore': 'dist/\n.bun-native/\nnode_modules/\n',
|
|
74
|
+
'README.md': `# ${name}
|
|
75
|
+
|
|
76
|
+
This project depends on the Bun Native ${version} packages from the npm registry. Use the Bun version in \`package.json\` \`engines\` (${toolchain.bun.cliRevision}); \`bun run build\` refuses any other compiler.
|
|
77
|
+
|
|
78
|
+
1. \`bun install\` installs the pinned dependencies, including this project's own \`@bun-native/cli\`.
|
|
79
|
+
2. \`${installedCli} host install\` downloads the released native host for this version into \`.bun-native/hosts\` and prints its path. Released hosts exist for linux-x64, linux-arm64, darwin-arm64 and darwin-x64.
|
|
80
|
+
|
|
81
|
+
- \`bun run check\` checks TypeScript or checked JavaScript.
|
|
82
|
+
- \`bun run format\` applies Bun's built-in formatter in the template style unless the project supplies its own formatter config; \`bun run format:check\` only reports differences.
|
|
83
|
+
- \`bun run lint\` runs the CLI's pinned \`oxlint\` with the template rules unless the project supplies its own \`.oxlintrc.json\`.
|
|
84
|
+
- \`bun run dev --host auto\` bundles and opens the app once with the released host (or pass \`--host /absolute/path/to/bun-native-host\`). Add \`--watch\` to remount after source edits; state resets on every edit.
|
|
85
|
+
- \`bun run build --host auto --host-profile release\` creates an unsigned same-platform AOT app in \`dist\`. The output directory must be empty.
|
|
86
|
+
|
|
87
|
+
Keep the template's optional development IPC hooks: they report readiness and clean up the renderer and owned host before a replacement starts. The host is unsigned; see the Bun Native [getting-started guide](https://github.com/0xPlayerOne/bun-native/blob/v${version}/docs/getting-started.md) and [developer tooling](https://github.com/0xPlayerOne/bun-native/blob/v${version}/docs/developer-tooling.md).
|
|
88
|
+
`,
|
|
89
|
+
}
|
|
90
|
+
}
|