@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bun-native-source.json +151 -0
  4. package/package.json +63 -4
  5. package/src/.oxfmtrc.json +20 -0
  6. package/src/.oxlintrc.json +12 -0
  7. package/src/experiments/aot-gpui/compile.ts +44 -0
  8. package/src/experiments/aot-gpui/inspect-runtime.ts +304 -0
  9. package/src/experiments/aot-gpui/inspect.ts +66 -0
  10. package/src/scripts/native/artifact.ts +73 -0
  11. package/src/scripts/native/build.ts +231 -0
  12. package/src/scripts/native/bundle.ts +121 -0
  13. package/src/scripts/native/consumer-files.ts +46 -0
  14. package/src/scripts/native/context.ts +95 -0
  15. package/src/scripts/native/doctor.ts +69 -0
  16. package/src/scripts/native/host-download.ts +179 -0
  17. package/src/scripts/native/host-header.ts +33 -0
  18. package/src/scripts/native/host.ts +22 -0
  19. package/src/scripts/native/inputs.ts +95 -0
  20. package/src/scripts/native/lint.ts +43 -0
  21. package/src/scripts/native/package-archive.ts +94 -0
  22. package/src/scripts/native/package-lock.ts +220 -0
  23. package/src/scripts/native/package-set.ts +417 -0
  24. package/src/scripts/native/package-stamp.ts +209 -0
  25. package/src/scripts/native/paths.ts +20 -0
  26. package/src/scripts/native/project.ts +271 -0
  27. package/src/scripts/native/quality.ts +76 -0
  28. package/src/scripts/native/registry-project.ts +90 -0
  29. package/src/scripts/native/session.ts +299 -0
  30. package/src/scripts/native/standalone.ts +64 -0
  31. package/src/scripts/native/ustar.ts +29 -0
  32. package/src/scripts/native/watch.ts +104 -0
  33. package/src/scripts/native.ts +237 -0
  34. package/src/scripts/solid/transform.ts +16 -0
  35. package/src/toolchain.json +74 -0
  36. 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
+ }