@bun-native/cli 0.0.0-stage → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readdir, realpath, stat } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { isDeepStrictEqual } from 'node:util'
5
+ import toolchain from '../../toolchain.json'
6
+ import {
7
+ dependencyClosure,
8
+ lockedPackages,
9
+ localArchiveLock,
10
+ verifyConsumerLock,
11
+ type DependencyLock,
12
+ } from './package-lock.ts'
13
+ import { inside, packageArchivePath, packagePath } from './paths.ts'
14
+ import { fileDigest, type FileDigest } from './artifact.ts'
15
+ import { verifyPackageArchive } from './package-archive.ts'
16
+
17
+ export const packageNames = [
18
+ '@bun-native/cli',
19
+ '@bun-native/core',
20
+ '@bun-native/protocol',
21
+ '@bun-native/react',
22
+ '@bun-native/renderer',
23
+ '@bun-native/runtime',
24
+ '@bun-native/solid',
25
+ ] as const
26
+
27
+ export type PackageManifest = {
28
+ name: string
29
+ version: string
30
+ private: true
31
+ type: 'module'
32
+ license: 'MIT'
33
+ files: string[]
34
+ engines: { bun: string }
35
+ dependencies?: Record<string, string>
36
+ exports?: string | Record<string, string>
37
+ bin?: Record<string, string>
38
+ }
39
+ export type LocalPackage = {
40
+ manifest: PackageManifest
41
+ archive: FileDigest & { file: string }
42
+ files: Record<string, FileDigest>
43
+ }
44
+ export type Consumer = {
45
+ dependencies: Record<string, string>
46
+ devDependencies: Record<string, string>
47
+ lock: DependencyLock
48
+ }
49
+ export type PackageSet = {
50
+ schemaVersion: 1
51
+ source: { commit: string; tree: string; files: Record<string, FileDigest> }
52
+ toolchain: typeof toolchain
53
+ compiler: { revision: string; os: string; architecture: string; executable: FileDigest }
54
+ packages: Record<string, LocalPackage>
55
+ consumers: { solid: Consumer; react: Consumer }
56
+ }
57
+ export type ReadPackageSet = { directory: string; manifest: PackageSet; text: string }
58
+
59
+ function object(value: unknown): Record<string, unknown> {
60
+ if (!value || typeof value !== 'object' || Array.isArray(value))
61
+ throw new Error('Invalid package-set object')
62
+ return value as Record<string, unknown>
63
+ }
64
+
65
+ function keys(value: Record<string, unknown>, required: string[], optional: string[] = []): void {
66
+ if (
67
+ required.some((key) => !Object.hasOwn(value, key)) ||
68
+ Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))
69
+ )
70
+ throw new Error('Unexpected or missing package-set fields')
71
+ }
72
+
73
+ function digest(value: unknown): FileDigest {
74
+ const fields = object(value)
75
+ if (
76
+ !Number.isSafeInteger(fields['bytes']) ||
77
+ (fields['bytes'] as number) < 0 ||
78
+ typeof fields['sha256'] !== 'string' ||
79
+ !/^[a-f0-9]{64}$/.test(fields['sha256'])
80
+ )
81
+ throw new Error('Invalid package-set file digest')
82
+ return { bytes: fields['bytes'] as number, sha256: fields['sha256'] }
83
+ }
84
+
85
+ function files(value: unknown): Record<string, FileDigest> {
86
+ const result: Record<string, FileDigest> = Object.create(null)
87
+ for (const [path, entry] of Object.entries(object(value))) {
88
+ if (!packagePath(path)) throw new Error(`Invalid package-set file path: ${path}`)
89
+ keys(object(entry), ['bytes', 'sha256'])
90
+ result[path] = digest(entry)
91
+ }
92
+ return result
93
+ }
94
+
95
+ function versions(value: unknown): Record<string, string> {
96
+ const result: Record<string, string> = Object.create(null)
97
+ for (const [name, version] of Object.entries(object(value))) {
98
+ if (
99
+ !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(name) ||
100
+ typeof version !== 'string' ||
101
+ !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
102
+ )
103
+ throw new Error(`Package-set dependency must have an exact version: ${name}`)
104
+ result[name] = version
105
+ }
106
+ return result
107
+ }
108
+
109
+ function exportedPaths(value: unknown): string[] {
110
+ return typeof value === 'string'
111
+ ? [value]
112
+ : Object.values(object(value)).map((entry) => {
113
+ if (typeof entry !== 'string') throw new Error('Invalid package export/bin path')
114
+ return entry
115
+ })
116
+ }
117
+
118
+ function packageManifest(
119
+ value: unknown,
120
+ name: string,
121
+ contents: Record<string, FileDigest>
122
+ ): PackageManifest {
123
+ const manifest = object(value)
124
+ keys(
125
+ manifest,
126
+ ['name', 'version', 'private', 'type', 'license', 'files', 'engines'],
127
+ ['dependencies', 'exports', 'bin']
128
+ )
129
+ if (
130
+ manifest['name'] !== name ||
131
+ manifest['private'] !== true ||
132
+ manifest['type'] !== 'module' ||
133
+ manifest['license'] !== 'MIT' ||
134
+ !isDeepStrictEqual(manifest['engines'], { bun: toolchain.bun.version }) ||
135
+ !Array.isArray(manifest['files']) ||
136
+ manifest['files'].some((path) => !packageArchivePath(path))
137
+ )
138
+ throw new Error(`Invalid materialized package manifest: ${name}`)
139
+ versions({ [name]: manifest['version'] })
140
+ if (manifest['dependencies'] !== undefined) versions(manifest['dependencies'])
141
+ for (const field of ['exports', 'bin']) {
142
+ if (manifest[field] === undefined) continue
143
+ for (const exported of exportedPaths(manifest[field])) {
144
+ const path = exported.replace(/^\.\//, '')
145
+ if (!packagePath(path) || !Object.hasOwn(contents, path))
146
+ throw new Error(`Unpackaged ${field} target: ${name} ${exported}`)
147
+ }
148
+ }
149
+ if (
150
+ !Object.hasOwn(contents, 'LICENSE') ||
151
+ !Object.hasOwn(contents, 'bun-native-source.json') ||
152
+ !Object.hasOwn(contents, 'package.json') ||
153
+ (name === '@bun-native/cli'
154
+ ? !isDeepStrictEqual(manifest['bin'], { 'bun-native': './src/scripts/native.ts' }) ||
155
+ manifest['exports'] !== undefined
156
+ : manifest['exports'] === undefined || manifest['bin'] !== undefined)
157
+ )
158
+ throw new Error(`Missing package entry/license/provenance: ${name}`)
159
+ const text = JSON.stringify(manifest, null, 2) + '\n'
160
+ if (
161
+ !isDeepStrictEqual(contents['package.json'], {
162
+ bytes: Buffer.byteLength(text),
163
+ sha256: createHash('sha256').update(text).digest('hex'),
164
+ })
165
+ )
166
+ throw new Error(`Package manifest digest mismatch: ${name}`)
167
+ return manifest as PackageManifest
168
+ }
169
+
170
+ export function requiredPackages(set: PackageSet, framework: 'solid' | 'react'): string[] {
171
+ const pending = ['@bun-native/cli', '@bun-native/core', `@bun-native/${framework}`]
172
+ const required = new Set<string>()
173
+ for (let index = 0; index < pending.length; index += 1) {
174
+ const name = pending[index]!
175
+ if (required.has(name)) continue
176
+ const pkg = set.packages[name]
177
+ if (!pkg) throw new Error(`Missing local package: ${name}`)
178
+ required.add(name)
179
+ for (const [dependency, version] of Object.entries(pkg.manifest.dependencies ?? {})) {
180
+ if (!dependency.startsWith('@bun-native/')) continue
181
+ if (set.packages[dependency]?.manifest.version !== version)
182
+ throw new Error(`Inconsistent internal dependency: ${name} -> ${dependency}`)
183
+ pending.push(dependency)
184
+ }
185
+ }
186
+ return [...required].sort()
187
+ }
188
+
189
+ export function consumerArchiveLock(
190
+ set: PackageSet,
191
+ framework: 'solid' | 'react',
192
+ archiveDirectory = 'vendor/bun-native'
193
+ ) {
194
+ return localArchiveLock(
195
+ requiredPackages(set, framework).map((name) => {
196
+ const pkg = set.packages[name]!
197
+ return {
198
+ name,
199
+ file: `${archiveDirectory ? archiveDirectory + '/' : ''}${pkg.archive.file}`,
200
+ sha256: pkg.archive.sha256,
201
+ ...(pkg.manifest.dependencies ? { dependencies: pkg.manifest.dependencies } : {}),
202
+ ...(pkg.manifest.bin ? { bin: pkg.manifest.bin } : {}),
203
+ }
204
+ })
205
+ )
206
+ }
207
+
208
+ /** Validates data only; archives are hashed separately before any destination is written. */
209
+ export function parsePackageSet(value: unknown): PackageSet {
210
+ const fields = object(value)
211
+ keys(fields, ['schemaVersion', 'source', 'toolchain', 'compiler', 'packages', 'consumers'])
212
+ if (fields['schemaVersion'] !== 1 || !isDeepStrictEqual(fields['toolchain'], toolchain))
213
+ throw new Error('Package set requires the exact pinned Bun Native toolchain')
214
+ const source = object(fields['source'])
215
+ keys(source, ['commit', 'tree', 'files'])
216
+ if (
217
+ ![source['commit'], source['tree']].every(
218
+ (value) => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value)
219
+ )
220
+ )
221
+ throw new Error('Package set requires exact source commit and tree identities')
222
+ files(source['files'])
223
+ const compiler = object(fields['compiler'])
224
+ keys(compiler, ['revision', 'os', 'architecture', 'executable'])
225
+ if (
226
+ compiler['revision'] !== toolchain.bun.revision ||
227
+ typeof compiler['os'] !== 'string' ||
228
+ typeof compiler['architecture'] !== 'string'
229
+ )
230
+ throw new Error('Invalid package producer compiler identity')
231
+ digest(compiler['executable'])
232
+ const packages = object(fields['packages'])
233
+ keys(packages, [...packageNames])
234
+ const archives = new Set<string>()
235
+ for (const [name, entry] of Object.entries(packages)) {
236
+ const pkg = object(entry)
237
+ keys(pkg, ['manifest', 'archive', 'files'])
238
+ const contents = files(pkg['files'])
239
+ if (Object.keys(contents).some((path) => !packageArchivePath(path)))
240
+ throw new Error(`Source archive contains reserved installer metadata: ${name}`)
241
+ packageManifest(pkg['manifest'], name, contents)
242
+ const archive = object(pkg['archive'])
243
+ keys(archive, ['file', 'bytes', 'sha256'])
244
+ digest(archive)
245
+ if (
246
+ !packagePath(archive['file']) ||
247
+ !/^archives\/[a-z0-9._-]+\.tgz$/.test(archive['file']) ||
248
+ archives.has(archive['file'])
249
+ )
250
+ throw new Error('Package archives must have unique contained relative paths')
251
+ archives.add(archive['file'])
252
+ }
253
+ const consumers = object(fields['consumers'])
254
+ keys(consumers, ['solid', 'react'])
255
+ const set = fields as PackageSet
256
+ for (const framework of ['solid', 'react'] as const) {
257
+ const consumer = object(consumers[framework])
258
+ keys(consumer, ['dependencies', 'devDependencies', 'lock'])
259
+ const runtime = versions(consumer['dependencies'])
260
+ const development = versions(consumer['devDependencies'])
261
+ if (
262
+ !runtime['@bun-native/core'] ||
263
+ !runtime[`@bun-native/${framework}`] ||
264
+ !runtime[framework === 'solid' ? 'solid-js' : 'react'] ||
265
+ !development['@bun-native/cli'] ||
266
+ !development['@types/bun'] ||
267
+ (framework === 'react' &&
268
+ (!development['@types/react'] || !development['@types/react-reconciler']))
269
+ )
270
+ throw new Error(`Missing required consumer dependencies for ${framework}`)
271
+ const dependencies = { ...runtime, ...development }
272
+ const external: Record<string, string> = Object.create(null)
273
+ for (const name of requiredPackages(set, framework)) {
274
+ for (const [dependency, version] of Object.entries(
275
+ set.packages[name]!.manifest.dependencies ?? {}
276
+ )) {
277
+ if (dependencies[dependency] !== undefined && dependencies[dependency] !== version)
278
+ throw new Error(`Inconsistent consumer dependency: ${dependency}`)
279
+ dependencies[dependency] = version
280
+ }
281
+ }
282
+ for (const [name, version] of Object.entries(dependencies)) {
283
+ if (name.startsWith('@bun-native/')) {
284
+ if (set.packages[name]?.manifest.version !== version)
285
+ throw new Error(`Unknown local dependency: ${name}`)
286
+ } else external[name] = version
287
+ }
288
+ const lock = lockedPackages(consumer['lock'])
289
+ if (
290
+ !isDeepStrictEqual(consumer['lock'], lock) ||
291
+ !isDeepStrictEqual(lock, dependencyClosure(lock, external))
292
+ )
293
+ throw new Error(`Unexpected dependency-lock entries for ${framework}`)
294
+ }
295
+ return set
296
+ }
297
+
298
+ async function containedFile(directory: string, path: string): Promise<string> {
299
+ const target = await realpath(join(directory, path))
300
+ if (!inside(directory, target) || !(await stat(target)).isFile())
301
+ throw new Error(`Package file escapes its owned directory or is not a file: ${path}`)
302
+ return target
303
+ }
304
+
305
+ export async function readPackageSet(path: string): Promise<ReadPackageSet> {
306
+ if (Bun.revision !== toolchain.bun.revision)
307
+ throw new Error('Use the pinned Bun for this package set')
308
+ const filename = await realpath(path)
309
+ const directory = dirname(filename)
310
+ const text = await Bun.file(filename).text()
311
+ const manifest = parsePackageSet(JSON.parse(text))
312
+ if (text !== JSON.stringify(manifest, null, 2) + '\n')
313
+ throw new Error('Package-set manifest must use its original canonical JSON encoding')
314
+ for (const [name, pkg] of Object.entries(manifest.packages)) {
315
+ const file = await containedFile(directory, pkg.archive.file)
316
+ if (!isDeepStrictEqual(await fileDigest(file), digest(pkg.archive)))
317
+ throw new Error(`Package archive digest mismatch: ${name}`)
318
+ await verifyPackageArchive(file, pkg.files)
319
+ }
320
+ return { directory, manifest, text }
321
+ }
322
+
323
+ /** Validate the installed CLI too, before its lazy compiler/watch modules are imported. */
324
+ export async function verifyInstalledPackages(
325
+ directory: string,
326
+ set: PackageSet,
327
+ framework: 'solid' | 'react',
328
+ archiveDirectory = 'vendor/bun-native'
329
+ ): Promise<void> {
330
+ let modules: string
331
+ try {
332
+ modules = await realpath(join(directory, 'node_modules'))
333
+ } catch (error) {
334
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
335
+ throw new Error(
336
+ "Standalone dependencies are not installed; follow this project's pinned offline-install instructions"
337
+ )
338
+ throw error
339
+ }
340
+ if (!inside(directory, modules))
341
+ throw new Error('Installed packages must belong to this consumer')
342
+ const required = requiredPackages(set, framework)
343
+ verifyConsumerLock(
344
+ Bun.JSONC.parse(await Bun.file(join(directory, 'bun.lock')).text()),
345
+ set.consumers[framework].lock,
346
+ true,
347
+ consumerArchiveLock(set, framework, archiveDirectory)
348
+ )
349
+ const locations = new Map<string, string>()
350
+ for (const name of required) {
351
+ const pkg = set.packages[name]!
352
+ const root = await realpath(join(modules, name))
353
+ if (!inside(modules, root))
354
+ throw new Error(`Installed package resolves outside this consumer: ${name}`)
355
+ locations.set(name, root)
356
+ if (Object.keys(pkg.files).some((path) => !packageArchivePath(path)))
357
+ throw new Error(`Source archive contains reserved installer metadata: ${name}`)
358
+ // Pinned Integrity::Display uses unpadded base64 followed by "==" for
359
+ // SHA-256. This is its 52-byte installer tag, not the canonical lock SRI.
360
+ const tag = `sha256-${Buffer.from(pkg.archive.sha256, 'hex').toString('base64').replace(/=+$/, '')}==`
361
+ const installedFiles: Record<string, FileDigest> = {
362
+ ...pkg.files,
363
+ '.bun-tag': {
364
+ bytes: Buffer.byteLength(tag),
365
+ sha256: createHash('sha256').update(tag).digest('hex'),
366
+ },
367
+ }
368
+ const expected = Object.keys(installedFiles).sort()
369
+ const folders = new Set(
370
+ expected.flatMap((path) => {
371
+ const parts = path.split('/')
372
+ return parts.slice(1).map((_, index) => parts.slice(0, index + 1).join('/'))
373
+ })
374
+ )
375
+ const inventory: string[] = []
376
+ const visit = async (directory: string, prefix: string): Promise<void> => {
377
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
378
+ const path = prefix + entry.name
379
+ if (entry.isDirectory() && folders.has(path))
380
+ await visit(join(directory, entry.name), path + '/')
381
+ else if (entry.isFile() && Object.hasOwn(installedFiles, path)) inventory.push(path)
382
+ else throw new Error(`Installed package file inventory differs: ${name}/${path}`)
383
+ }
384
+ }
385
+ await visit(root, '')
386
+ if (!isDeepStrictEqual(inventory.sort(), expected))
387
+ throw new Error(`Installed package file inventory differs: ${name}`)
388
+ for (const [path, expected] of Object.entries(installedFiles)) {
389
+ const file = await containedFile(root, path)
390
+ if (!isDeepStrictEqual(await fileDigest(file), expected))
391
+ throw new Error(`Installed package digest mismatch: ${name}/${path}`)
392
+ }
393
+ }
394
+ for (const name of required) {
395
+ const root = locations.get(name)!
396
+ for (const dependency of Object.keys(set.packages[name]!.manifest.dependencies ?? {})) {
397
+ if (!dependency.startsWith('@bun-native/')) continue
398
+ const expected = locations.get(dependency)!
399
+ const resolved = await realpath(Bun.resolveSync(dependency, root))
400
+ const exported = set.packages[dependency]!.manifest.exports!
401
+ const entry = typeof exported === 'string' ? exported : exported['.']!
402
+ if (resolved !== (await realpath(join(expected, entry))))
403
+ throw new Error(
404
+ `Installed dependency resolves to a different package: ${name} -> ${dependency}`
405
+ )
406
+ }
407
+ }
408
+ }
@@ -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,251 @@
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 { readPackageSet, verifyInstalledPackages } from './package-set.ts'
5
+ import { fileDigest, type FileDigest } from './artifact.ts'
6
+ import { copyPackageSet, standaloneFiles } from './standalone.ts'
7
+
8
+ export type Framework = 'solid' | 'react'
9
+ export type Language = 'typescript' | 'javascript'
10
+ export type Project = {
11
+ directory: string
12
+ framework: Framework
13
+ entry: string
14
+ packageSet?: { source: { commit: string; tree: string }; manifest: FileDigest }
15
+ }
16
+
17
+ function template(framework: Framework, language: Language): string {
18
+ const typed = language === 'typescript'
19
+ const solid = framework === 'solid'
20
+ const title = `Bun Native ${solid ? 'Solid' : 'React'}`
21
+ return `${solid ? '' : '/** @jsxImportSource @bun-native/react */\n'}import { createApplication, reportHealthy } from '@bun-native/core'
22
+ import { render${typed ? ', type JSX' : ''} } from '@bun-native/${framework}'
23
+ import { ${solid ? 'createSignal' : 'useState'} } from '${solid ? 'solid-js' : 'react'}'
24
+
25
+ const executable = process.env['BUN_NATIVE_HOST']
26
+ if (!executable)
27
+ throw new Error('Run the dev command with --host pointing to a prebuilt native host')
28
+ const application = await createApplication({ executable })
29
+ ${typed ? `let view: ReturnType<typeof render> | undefined` : '/** @type {ReturnType<typeof render> | undefined} */\nlet view'}
30
+ const stopped = Promise.withResolvers${typed ? '<void>' : ''}()
31
+ const stop = () => stopped.resolve(${typed ? '' : 'undefined'})
32
+ ${typed ? '' : '/** @param {unknown} message */\n'}function stopFromParent(message${typed ? ': unknown' : ''}) {
33
+ if (
34
+ message &&
35
+ typeof message === 'object' &&
36
+ 'type' in message &&
37
+ message.type === 'bun-native:stop'
38
+ )
39
+ stop()
40
+ }
41
+ process.once('SIGINT', stop)
42
+ process.once('SIGTERM', stop)
43
+ process.on('message', stopFromParent)
44
+
45
+ function App()${typed ? ': JSX.Element' : ''} {
46
+ const [clicks, setClicks] = ${solid ? 'createSignal' : 'useState'}(0)
47
+ return (
48
+ <box style={{ direction: 'column', gap: 12, padding: 24 }}>
49
+ <text text="${title}" style={{ font_size: 28 }} />
50
+ <text text={'Clicks: ' + ${solid ? 'clicks()' : 'clicks'}} />
51
+ <button
52
+ text="Increment"
53
+ style={{ width: 160, height: 44 }}
54
+ onClick={() => {
55
+ setClicks((value) => value + 1)
56
+ }}
57
+ />
58
+ </box>
59
+ )
60
+ }
61
+
62
+ try {
63
+ const window = await application.openWindow({
64
+ title: '${title}',
65
+ width: 640,
66
+ height: 480,
67
+ })
68
+ view = render(${solid ? 'App' : '<App />'}, window)
69
+ await view.ready
70
+ // Confirms a pending update when the installed app was started by the verifying launcher.
71
+ await reportHealthy()
72
+ process.send?.({ type: 'bun-native:ready' })
73
+ await Promise.race([window.closed, view.closed, stopped.promise])
74
+ } finally {
75
+ process.off('SIGINT', stop)
76
+ process.off('SIGTERM', stop)
77
+ process.off('message', stopFromParent)
78
+ try {
79
+ await view?.dispose()
80
+ } finally {
81
+ await application.shutdown()
82
+ await application.closed
83
+ process.send?.({
84
+ type: 'bun-native:stopped',
85
+ application: application.diagnostics,
86
+ nodes: view?.diagnostics.nodes ?? 0,
87
+ })
88
+ if (process.connected) process.disconnect?.()
89
+ }
90
+ }
91
+ `
92
+ }
93
+
94
+ function readme(name: string, framework: Framework, guide: string): string {
95
+ const solid = framework === 'solid'
96
+ const rules = solid
97
+ ? `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.`
98
+ : `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.`
99
+ return `# ${name}
100
+
101
+ This project uses the surrounding Bun Native checkout and its pinned dependencies.
102
+ Run \`bun install --frozen-lockfile\` in the checkout first; no Rust compiler is needed when you supply a prebuilt host.
103
+
104
+ - \`bun run check\` checks TypeScript or checked JavaScript.
105
+ - \`bun run format\` applies Bun's built-in formatter; \`bun run format:check\` only reports differences.
106
+ - \`bun run lint\` runs the checkout's pinned oxlint with the checkout's lint rules, or this project's own \`.oxlintrc.json\`.
107
+ - \`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.
108
+ - Add \`--watch\` to remount after source edits with joined teardown. State resets on every successful edit; state-preserving hot reload is not implemented.
109
+ - \`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.
110
+
111
+ ${rules} Build the interface from native elements such as \`box\`, \`text\`, \`button\` and \`input\`; CSS selectors and browser events do not apply.
112
+
113
+ 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.
114
+
115
+ 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.
116
+ `
117
+ }
118
+
119
+ export async function createProject(
120
+ repository: string,
121
+ destination: string,
122
+ framework: Framework,
123
+ language: Language,
124
+ packages?: string
125
+ ): Promise<string> {
126
+ const set = packages === undefined ? undefined : await readPackageSet(packages)
127
+ const root = await realpath(repository)
128
+ const directory = resolve(destination)
129
+ if (!set && !inside(root, directory))
130
+ throw new Error('Create the project inside this Bun Native checkout')
131
+ // Resolve existing parents before writing, so a symlink cannot escape the checkout.
132
+ let ancestor = dirname(directory)
133
+ let resolvedAncestor: string | undefined
134
+ while (resolvedAncestor === undefined) {
135
+ try {
136
+ resolvedAncestor = await realpath(ancestor)
137
+ } catch (error) {
138
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
139
+ const parent = dirname(ancestor)
140
+ if (parent === ancestor) throw error
141
+ ancestor = parent
142
+ }
143
+ }
144
+ if (!set && resolvedAncestor !== root && !inside(root, resolvedAncestor))
145
+ throw new Error('Project parent resolves outside this Bun Native checkout')
146
+ await mkdir(dirname(directory), { recursive: true })
147
+ const parent = await realpath(dirname(directory))
148
+ if (!set && parent !== root && !inside(root, parent))
149
+ throw new Error('Project parent resolves outside this Bun Native checkout')
150
+ await mkdir(directory) // Existing directories, including empty ones, are never overwritten.
151
+ try {
152
+ const entry = language === 'typescript' ? 'main.tsx' : 'main.jsx'
153
+ const cli = relative(directory, join(root, 'scripts/native.ts')).split(sep).join('/')
154
+ const tsconfig = relative(directory, join(root, 'tsconfig.json')).split(sep).join('/')
155
+ const guide = relative(directory, join(root, 'docs/getting-started.md')).split(sep).join('/')
156
+ const files: Record<string, string> = {
157
+ 'bun-native.json': JSON.stringify({ schemaVersion: 1, framework, entry }, null, 2) + '\n',
158
+ 'package.json':
159
+ JSON.stringify(
160
+ {
161
+ name: basename(directory),
162
+ private: true,
163
+ type: 'module',
164
+ scripts: {
165
+ // See docs/developer-tooling.md "Controller memory".
166
+ dev: `MIMALLOC_PURGE_DELAY=0 bun ${JSON.stringify(cli)} dev .`,
167
+ build: `bun ${JSON.stringify(cli)} build .`,
168
+ check: 'bun check --no-script --no-pretty',
169
+ format: `bun ${JSON.stringify(cli)} format .`,
170
+ 'format:check': `bun ${JSON.stringify(cli)} format . --check`,
171
+ lint: `bun ${JSON.stringify(cli)} lint .`,
172
+ },
173
+ },
174
+ null,
175
+ 2
176
+ ) + '\n',
177
+ 'tsconfig.json': `{
178
+ "extends": ${JSON.stringify(tsconfig)},
179
+ "compilerOptions": {
180
+ "jsxImportSource": "@bun-native/${framework}"
181
+ },
182
+ "include": [${JSON.stringify(entry)}],
183
+ "exclude": ["dist", ".bun-native"]
184
+ }
185
+ `,
186
+ '.gitignore': 'dist/\n.bun-native/\n',
187
+ 'README.md': readme(basename(directory), framework, guide),
188
+ [entry]: template(framework, language),
189
+ }
190
+ if (set) {
191
+ Object.assign(files, standaloneFiles(set, framework, entry, basename(directory)))
192
+ await copyPackageSet(set, directory)
193
+ }
194
+ for (const [name, content] of Object.entries(files))
195
+ await writeFile(join(directory, name), content, { flag: 'wx' })
196
+ return directory
197
+ } catch (error) {
198
+ await rm(directory, { recursive: true, force: true })
199
+ throw error
200
+ }
201
+ }
202
+
203
+ export async function readProject(repository: string, path: string): Promise<Project> {
204
+ const root = await realpath(repository)
205
+ const directory = await realpath(path)
206
+ const value: unknown = await Bun.file(join(directory, 'bun-native.json')).json()
207
+ if (!value || typeof value !== 'object' || Array.isArray(value))
208
+ throw new Error('Invalid bun-native.json project configuration')
209
+ const config = value as Record<string, unknown>
210
+ if (
211
+ Object.keys(config).some(
212
+ (key) => !['schemaVersion', 'framework', 'entry', 'packageSet'].includes(key)
213
+ ) ||
214
+ config['schemaVersion'] !== 1 ||
215
+ (config['framework'] !== 'solid' && config['framework'] !== 'react') ||
216
+ typeof config['entry'] !== 'string' ||
217
+ isAbsolute(config['entry']) ||
218
+ !/\.[jt]sx?$/.test(config['entry'])
219
+ )
220
+ throw new Error(
221
+ 'bun-native.json requires schemaVersion 1, framework solid|react and a relative TS/JS entry'
222
+ )
223
+ const entry = await realpath(join(directory, config['entry']))
224
+ if (!inside(directory, entry) || !(await stat(entry)).isFile())
225
+ throw new Error('Project entry must be a source file inside the project')
226
+ if (config['packageSet'] !== undefined) {
227
+ if (typeof config['packageSet'] !== 'string' || isAbsolute(config['packageSet']))
228
+ throw new Error('Project packageSet must be a relative manifest path inside the project')
229
+ const manifestPath = await realpath(join(directory, config['packageSet']))
230
+ if (!inside(directory, manifestPath))
231
+ throw new Error('Project packageSet resolves outside the project')
232
+ const set = await readPackageSet(manifestPath)
233
+ await verifyInstalledPackages(
234
+ directory,
235
+ set.manifest,
236
+ config['framework'],
237
+ relative(directory, set.directory).split(sep).join('/')
238
+ )
239
+ return {
240
+ directory,
241
+ framework: config['framework'],
242
+ entry,
243
+ packageSet: {
244
+ source: { commit: set.manifest.source.commit, tree: set.manifest.source.tree },
245
+ manifest: await fileDigest(manifestPath),
246
+ },
247
+ }
248
+ }
249
+ if (!inside(root, directory)) throw new Error('Project must be inside this Bun Native checkout')
250
+ return { directory, framework: config['framework'], entry }
251
+ }