brustjs 0.1.10-alpha → 0.1.12-alpha

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/README.md CHANGED
@@ -73,6 +73,7 @@ route apiece.
73
73
  ```
74
74
  brustjs dev <entry> # dev mode: watcher + WS reload + browser auto-reload
75
75
  brustjs build <entry> --out-dir D # prebuilt ./dist/ — run from the project (bun run dist/index.js)
76
+ --target <auto|all|TARGET[,…]> # which native binary to bundle (default: auto = host platform)
76
77
  brustjs new <name> # scaffold a project (partial — see Status)
77
78
  ```
78
79
 
@@ -84,13 +85,18 @@ brustjs new <name> # scaffold a project (partial — see Status)
84
85
  `renderToString` runs once per key, then serves a frozen pair from Rust.
85
86
  - **`native: true` routes** — JSX compiled to a jinja template at build time and
86
87
  rendered Rust-side (`minijinja`), skipping React on the server entirely.
87
- - **Server actions** (`"use server"`) per-action endpoints; client helper rewrites
88
- form/`fetch` targets.
88
+ - **Typed actions** `defineActions().get/post/put/patch/delete/head(path, ctx => R, { body, query })`
89
+ on the server; `client<typeof actions>()` is an Eden-Treaty-style proxy that
90
+ infers the whole API from the server types (no codegen) and returns
91
+ `{ data, error, status, headers }` (never throws). Standard Schema (zod)
92
+ validation, JSON / urlencoded / multipart bodies.
89
93
  - **SSE & WebSockets** as first-class route shapes.
90
94
  - Nested routes + dynamic params, per-route typed loaders, request-scoped middleware,
91
- forms/multipart, SPA-style navigation, in-process LRU response cache + island ISR cache, Tailwind v4 + CSS Modules.
92
- - **Agent-first** — server fns and routes expose MCP tool/resource schemas at
93
- `/_brust/mcp` so agents drive the app without scraping.
95
+ SPA-style navigation, in-process LRU response cache + island ISR cache, Tailwind v4 + CSS Modules.
96
+ - **Agent-first** — `defineActions` endpoints become MCP **tools** and route
97
+ loaders become **resources** at `/_brust/mcp`; `tools/call` runs through the
98
+ same validation + middleware as an HTTP request, so agents drive the app
99
+ without scraping.
94
100
 
95
101
  ## Performance
96
102
 
@@ -119,9 +125,9 @@ bench/ · docs/ · architecture.md
119
125
  ## Status
120
126
 
121
127
  Alpha, solo-developed. Linux is tier-1 (io_uring; glibc + musl, 6 prebuilt platform
122
- binaries). Known partials: `brustjs dev` TS reload is a full worker-respawn reload
123
- (not state-preserving HMR), islands + `.module.css`. Tailwind
124
- is opt-in — the scaffold adds it as a project dependency; `@import "tailwindcss"`
128
+ binaries). Known partials: `brustjs dev` reload is a full worker-respawn (not
129
+ state-preserving HMR) — TS, islands, and `.module.css` all reload that way.
130
+ Tailwind is opt-in — the scaffold adds it as a project dependency; `@import "tailwindcss"`
125
131
  resolves from your own `node_modules`. Deployment note: the io_uring server needs `io_uring_*`
126
132
  syscalls permitted — a default-seccomp container (Docker/k8s) must allow them or run
127
133
  `--security-opt seccomp=unconfined`. Roadmap and limitations in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.10-alpha",
3
+ "version": "0.1.12-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,12 +40,12 @@
40
40
  "typescript": "^6.0.3"
41
41
  },
42
42
  "optionalDependencies": {
43
- "brustjs-darwin-x64": "0.1.10-alpha",
44
- "brustjs-darwin-arm64": "0.1.10-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.10-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.10-alpha",
47
- "brustjs-linux-x64-musl": "0.1.10-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.10-alpha"
43
+ "brustjs-darwin-x64": "0.1.12-alpha",
44
+ "brustjs-darwin-arm64": "0.1.12-alpha",
45
+ "brustjs-linux-x64-gnu": "0.1.12-alpha",
46
+ "brustjs-linux-arm64-gnu": "0.1.12-alpha",
47
+ "brustjs-linux-x64-musl": "0.1.12-alpha",
48
+ "brustjs-linux-arm64-musl": "0.1.12-alpha"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.2.6",
@@ -59,7 +59,8 @@
59
59
  "happy-dom": "^20.9.0",
60
60
  "react": "^19.2.6",
61
61
  "react-dom": "^19.2.6",
62
- "typescript": "^6.0.3"
62
+ "typescript": "^6.0.3",
63
+ "zod": "^4.4.3"
63
64
  },
64
65
  "type": "module",
65
66
  "types": "./runtime/index.d.ts",
@@ -1,65 +1,7 @@
1
- import type { BrustRequest, Middleware } from './routes.ts'
2
-
3
- /** Server-side action handler. First arg is ALWAYS BrustRequest; the client
4
- * stub strips it from the call site. Subsequent args are JSON-decoded from
5
- * the request body (which MUST be a JSON array). */
6
- export type ActionFn<Args extends unknown[] = unknown[], R = unknown> = (
7
- req: BrustRequest,
8
- ...args: Args
9
- ) => Promise<R>
10
-
11
- /** Registration shape passed to brust.registerActions. */
12
- export interface ActionDef<F extends ActionFn = ActionFn> {
13
- /** Stable id; must match the id used by `action<F>(id)` on the client.
14
- * Charset: [A-Za-z0-9_-]+ (enforced both in TS and in Rust). */
15
- id: string
16
- /** Handler. Receives req + JSON-decoded args. */
17
- fn: F
18
- /** Per-action middleware chain. Same Middleware type used by routes. */
19
- middleware?: Middleware[]
20
- }
21
-
22
- /** Mirrors is_safe_action_id in src/lib.rs and src/server.rs.
23
- * Allowed: [A-Za-z0-9_-]+ only, max 128 chars. */
24
- export function isValidActionId(id: string): boolean {
25
- if (id.length === 0 || id.length > 128) return false
26
- return /^[A-Za-z0-9_-]+$/.test(id)
27
- }
28
-
29
- const BRUST_MW_KEY = '__brustMiddleware'
30
-
31
- /** Attach a middleware chain to a server-action function. The function is
32
- * returned unchanged so the TypeScript signature is preserved verbatim —
33
- * callers can still write `typeof srv.deleteNote` on the client.
34
- *
35
- * Throws TypeError if `mws` isn't an array of functions.
36
- * Throws Error if called twice on the same fn (compose mws in one call).
37
- */
38
- export function withMiddleware<F extends (...args: never[]) => unknown>(
39
- mws: readonly Middleware[],
40
- fn: F,
41
- ): F {
42
- if (!Array.isArray(mws) || mws.some((m) => typeof m !== 'function')) {
43
- throw new TypeError('withMiddleware expects an array of middleware functions')
44
- }
45
- if ((fn as { [BRUST_MW_KEY]?: unknown })[BRUST_MW_KEY] !== undefined) {
46
- throw new Error(
47
- 'withMiddleware called twice on the same function — compose middleware in a single call instead',
48
- )
49
- }
50
- Object.defineProperty(fn, BRUST_MW_KEY, {
51
- value: Object.freeze([...mws]),
52
- enumerable: false,
53
- writable: false,
54
- configurable: false,
55
- })
56
- return fn
57
- }
58
-
59
- /** Read the middleware metadata installed by withMiddleware. Used by the
60
- * scanner to populate ActionDef.middleware. Returns undefined for plain
61
- * (un-wrapped) functions. */
62
- export function getActionMiddleware(fn: unknown): Middleware[] | undefined {
63
- if (typeof fn !== 'function') return undefined
64
- return (fn as { [BRUST_MW_KEY]?: Middleware[] })[BRUST_MW_KEY]
65
- }
1
+ export type {
2
+ EndpointDef,
3
+ ActionContext,
4
+ EndpointOptions,
5
+ ActionsBuilder,
6
+ } from './define-actions.ts'
7
+ export { defineActions, isValidEndpointPath } from './define-actions.ts'
@@ -1,11 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'
2
+ import { copyFile, cp, mkdir, readdir, rm } from 'node:fs/promises'
3
3
  import { createRequire } from 'node:module'
4
4
  import path, { isAbsolute, resolve } from 'node:path'
5
- import {
6
- actionsPrebuiltPlugin,
7
- writePrebuiltActionsFileWithMap,
8
- } from './actions-prebuilt-plugin.ts'
9
5
  import { emitNativeTemplates } from './native-routes-emit.ts'
10
6
  import { nativeShimPlugin } from './native-shim-plugin.ts'
11
7
 
@@ -59,14 +55,98 @@ async function collectNativeBinaries(): Promise<string[]> {
59
55
  return out
60
56
  }
61
57
 
58
+ /** The 6 published platform targets (root package.json optionalDependencies),
59
+ * keyed by the napi binary infix `<platform>-<arch>[-<libc>]`. */
60
+ export const VALID_TARGETS = [
61
+ 'darwin-x64',
62
+ 'darwin-arm64',
63
+ 'linux-x64-gnu',
64
+ 'linux-arm64-gnu',
65
+ 'linux-x64-musl',
66
+ 'linux-arm64-musl',
67
+ ] as const
68
+
69
+ /** Host target infix — reuses the same detection as platformPackageName by
70
+ * stripping the `brustjs-` prefix. */
71
+ export function hostTargetInfix(): string {
72
+ return platformPackageName().replace(/^brustjs-/, '')
73
+ }
74
+
75
+ function basenameTarget(absPath: string): string | null {
76
+ const b = absPath.replace(/^.*[/\\]/, '') // basename
77
+ const m = /^brust\.(.+)\.node$/.exec(b)
78
+ return m ? m[1] : null
79
+ }
80
+
81
+ /** Select which collected `brust.*.node` paths to copy for `target`.
82
+ * Pure: takes the collected absolute paths, returns selected paths + errors. */
83
+ export function selectNativeBinaries(
84
+ collected: string[],
85
+ target: string,
86
+ ): { selected: string[]; errors: string[] } {
87
+ // Dedupe tokens here so `selected` never contains duplicate paths — the
88
+ // function's contract is a clean selection, independent of any caller-side
89
+ // copy-dedup (`--target darwin-arm64,darwin-arm64` must select once).
90
+ const tokens = [
91
+ ...new Set(
92
+ target
93
+ .split(',')
94
+ .map((t) => t.trim().toLowerCase())
95
+ .filter(Boolean),
96
+ ),
97
+ ]
98
+ const byTarget = new Map<string, string>() // infix → first abs path (dedupe)
99
+ for (const p of collected) {
100
+ const t = basenameTarget(p)
101
+ if (t && !byTarget.has(t)) byTarget.set(t, p)
102
+ }
103
+
104
+ if (tokens.length === 0) return { selected: [], errors: ['brust build: empty --target'] }
105
+
106
+ const hasAuto = tokens.includes('auto')
107
+ const hasAll = tokens.includes('all')
108
+ if ((hasAuto || hasAll) && tokens.length > 1) {
109
+ return {
110
+ selected: [],
111
+ errors: [
112
+ `brust build: --target "${target}" — auto/all cannot be combined with other targets`,
113
+ ],
114
+ }
115
+ }
116
+ if (hasAll) return { selected: [...byTarget.values()], errors: [] }
117
+
118
+ const wanted = hasAuto ? [hostTargetInfix()] : tokens
119
+ const selected: string[] = []
120
+ const errors: string[] = []
121
+ for (const t of wanted) {
122
+ if (!hasAuto && !VALID_TARGETS.includes(t as (typeof VALID_TARGETS)[number])) {
123
+ errors.push(
124
+ `brust build: unknown target "${t}" (valid: ${VALID_TARGETS.join(', ')}, or auto/all)`,
125
+ )
126
+ continue
127
+ }
128
+ const p = byTarget.get(t)
129
+ if (!p) {
130
+ errors.push(
131
+ `brust build: no native binary for target "${t}" — install brustjs-${t} or build it (bun --filter runtime run build)`,
132
+ )
133
+ continue
134
+ }
135
+ selected.push(p)
136
+ }
137
+ return { selected, errors }
138
+ }
139
+
62
140
  interface ParsedArgs {
63
141
  entry: string // absolute path to the entry file
64
142
  outDir: string // absolute path to the output dir
143
+ target: string // --target value (default 'auto')
65
144
  }
66
145
 
67
146
  function parseArgs(args: string[]): ParsedArgs {
68
147
  let entry: string | undefined
69
148
  let outDir: string | undefined
149
+ let target = 'auto'
70
150
 
71
151
  for (let i = 0; i < args.length; i++) {
72
152
  const a = args[i]
@@ -78,6 +158,18 @@ function parseArgs(args: string[]): ParsedArgs {
78
158
  }
79
159
  } else if (a.startsWith('--out-dir=')) {
80
160
  outDir = a.slice('--out-dir='.length)
161
+ } else if (a === '--target') {
162
+ target = args[++i]
163
+ if (!target) {
164
+ console.error('brust build: --target requires a value')
165
+ process.exit(1)
166
+ }
167
+ } else if (a.startsWith('--target=')) {
168
+ target = a.slice('--target='.length)
169
+ if (!target) {
170
+ console.error('brust build: --target= requires a value')
171
+ process.exit(1)
172
+ }
81
173
  } else if (a.startsWith('-')) {
82
174
  console.error(`brust build: unknown flag "${a}"`)
83
175
  process.exit(1)
@@ -107,11 +199,11 @@ function parseArgs(args: string[]): ParsedArgs {
107
199
  : resolve(cwd, outDir)
108
200
  : resolve(cwd, 'dist')
109
201
 
110
- return { entry: entryPath, outDir: outPath }
202
+ return { entry: entryPath, outDir: outPath, target }
111
203
  }
112
204
 
113
205
  export async function runBuild(args: string[]): Promise<void> {
114
- const { entry, outDir } = parseArgs(args)
206
+ const { entry, outDir, target } = parseArgs(args)
115
207
  const entryDir = path.dirname(entry)
116
208
 
117
209
  console.log(`[brust build] entry: ${entry}`)
@@ -121,18 +213,11 @@ export async function runBuild(args: string[]): Promise<void> {
121
213
  await rm(outDir, { recursive: true, force: true })
122
214
  await mkdir(outDir, { recursive: true })
123
215
 
124
- // 2. Scan actions + rediscover id→source mapping for the prebuilt plugin.
125
- const { scanActions, collectExports } = await import('../scan-actions.ts')
126
- const scan = await scanActions({ roots: [entryDir] })
127
- console.log(
128
- `[brust build] actions: discovered ${scan.actions.length} (${scan.actions.map((a) => a.id).join(', ') || '(none)'})`,
129
- )
130
-
131
- const idToSource = new Map<string, string>()
132
- for (const file of scan.sourceFiles) {
133
- const defs = await collectExports(file)
134
- for (const def of defs) idToSource.set(def.id, file)
135
- }
216
+ // 2. Actions need no build-time codegen. With `defineActions(...)`, actions
217
+ // are an EXPLICIT module the app entry imports and passes to
218
+ // `brust.run({ actions })` the dist boot registers them through the normal
219
+ // `import { actions } from './actions'` path, no scan and no `_actions-prebuilt`
220
+ // file. (The old `'use server'` filesystem scanner is gone.)
136
221
 
137
222
  // routes.tsx is the scan target for both islands (§3) and the MCP manifest
138
223
  // (§4). Computed once here and reused below.
@@ -147,6 +232,18 @@ export async function runBuild(args: string[]): Promise<void> {
147
232
  const islandsOutDir = path.join(outDir, 'islands')
148
233
  const result = await buildIslands(islandMap, { outDir: islandsOutDir })
149
234
  console.log(`[brust build] islands: ${result.islandCount} chunk(s) → ${islandsOutDir}`)
235
+
236
+ // Mirror into cwd/.brust/islands so the NON-prebuilt source runtime
237
+ // (`bun run <entry>` directly, and `bun run dev`) — which reads
238
+ // cwd/.brust/islands (see runtime/islands/build.ts default outDir) — finds
239
+ // the same chunks after a build. The prebuilt dist reads <distDir>/islands;
240
+ // this keeps the dev/source path working without a separate bundle step.
241
+ // Mirrors the jinja mirror below. `.brust/` is a gitignored cache dir.
242
+ const localIslandsDir = path.join(process.cwd(), '.brust', 'islands')
243
+ if (path.resolve(localIslandsDir) !== path.resolve(islandsOutDir)) {
244
+ await rm(localIslandsDir, { recursive: true, force: true })
245
+ await cp(islandsOutDir, localIslandsDir, { recursive: true })
246
+ }
150
247
  } else {
151
248
  console.log('[brust build] islands: skipped (no <Island> usage)')
152
249
  }
@@ -157,11 +254,11 @@ export async function runBuild(args: string[]): Promise<void> {
157
254
  const { extractMcpManifest } = await import('../mcp/extractor.ts')
158
255
  const { routes } = await import(routesFile)
159
256
  loadedRoutes = routes
257
+ const actionsFile = path.join(entryDir, 'actions.ts')
160
258
  const manifest = await extractMcpManifest({
161
- serverFiles: scan.sourceFiles,
259
+ actionsFile: existsSync(actionsFile) ? actionsFile : undefined,
162
260
  routesFile,
163
261
  sourceRoots: [entryDir],
164
- actions: scan.actions,
165
262
  routes,
166
263
  })
167
264
  const manifestPath = path.join(outDir, 'mcp-manifest.json')
@@ -177,12 +274,11 @@ export async function runBuild(args: string[]): Promise<void> {
177
274
  // native: true route. Pipeline runs even if no native routes exist (writes
178
275
  // an empty manifest) so consumers can rely on the output dir's presence.
179
276
  {
180
- // outDir must align with the runtime's loadJinjaOnce which reads from
181
- // `process.cwd() + '.brust/jinja'`. Existing CSS pipeline uses cwd too
182
- // (see boot log: "built CSS <cwd>/.brust/css/app.css"). entryDir
183
- // diverges when user runs `bun run dev <entry>` from a different dir;
184
- // cwd is the single source of truth for both pipelines.
185
- const jinjaDir = path.join(process.cwd(), '.brust/jinja')
277
+ // Emit jinja templates INTO the build output dir (`<outDir>/jinja`), next to
278
+ // the other pre-built artifacts (islands, css, mcp-manifest), so a dist-only
279
+ // deploy ships the templates. The prebuilt runtime reads them from
280
+ // `<BRUST_DIST_DIR>/jinja` (see index.ts loadJinjaOnce / configureJinjaDir).
281
+ const jinjaDir = path.join(outDir, 'jinja')
186
282
  // Spec §7 Component-source resolution: scan the routes module's source for
187
283
  // ImportDeclarations, NOT the app entry's. The app entry only imports the
188
284
  // routes module + brust; the page components are imported by routes.tsx.
@@ -197,6 +293,17 @@ export async function runBuild(args: string[]): Promise<void> {
197
293
  })
198
294
  const nativeCount = (loadedRoutes ?? []).filter((r: any) => r?.nativeTemplate).length
199
295
  console.log(`[brust build] jinja: ${nativeCount} template(s) → ${jinjaDir}`)
296
+
297
+ // Also mirror into cwd/.brust/jinja so the NON-prebuilt source runtime
298
+ // (`bun run <entry>` directly, and `bun run dev`) — which reads
299
+ // cwd/.brust/jinja — finds the same templates after a build. The prebuilt
300
+ // dist reads <distDir>/jinja (above); this keeps the dev/source path working
301
+ // without a separate compile step. `.brust/` is a gitignored cache dir.
302
+ const localJinjaDir = path.join(process.cwd(), '.brust', 'jinja')
303
+ if (path.resolve(localJinjaDir) !== path.resolve(jinjaDir)) {
304
+ await rm(localJinjaDir, { recursive: true, force: true })
305
+ await cp(jinjaDir, localJinjaDir, { recursive: true })
306
+ }
200
307
  }
201
308
 
202
309
  // 4.5. CSS — Tailwind v4 if app.css is present.
@@ -244,11 +351,10 @@ export async function runBuild(args: string[]): Promise<void> {
244
351
  }
245
352
  }
246
353
 
247
- // 5. Generate the prebuilt-actions file (always empty list if no actions).
248
- const prebuiltActionsPath = path.join(outDir, '_actions-prebuilt.ts')
249
- await writePrebuiltActionsFileWithMap(prebuiltActionsPath, idToSource, REPO_ROOT)
250
-
251
- // 6. Bun.build the server bundle with both plugins + banner.
354
+ // 5. Bun.build the server bundle with the native shim plugin + banner. No
355
+ // actions codegen: `defineActions(...)` actions register via the app entry's
356
+ // `import { actions } from './actions'` → `brust.run({ actions })` path, which
357
+ // the bundled entry already carries.
252
358
  const banner =
253
359
  `process.env.BRUST_PREBUILT = '1';\n` + `process.env.BRUST_DIST_DIR = import.meta.dir;\n`
254
360
 
@@ -274,7 +380,7 @@ export async function runBuild(args: string[]): Promise<void> {
274
380
  // point at non-existent files. Whitespace + syntax minification still apply.
275
381
  minify: { whitespace: true, syntax: true, identifiers: false },
276
382
  banner,
277
- plugins: [nativeShimPlugin(REPO_ROOT), actionsPrebuiltPlugin(prebuiltActionsPath, REPO_ROOT)],
383
+ plugins: [nativeShimPlugin(REPO_ROOT)],
278
384
  })
279
385
 
280
386
  if (!result.success) {
@@ -284,26 +390,30 @@ export async function runBuild(args: string[]): Promise<void> {
284
390
  }
285
391
  console.log(`[brust build] bundle: ${path.join(outDir, 'index.js')}`)
286
392
 
287
- // 7. Copy the current-platform native binary.
393
+ // 7. Copy the selected-platform native binary(ies).
288
394
  const nativeDir = path.join(outDir, 'native')
289
395
  await mkdir(nativeDir, { recursive: true })
290
396
 
291
397
  // napi-rs names the binary `brust.<platform>-<arch>[-libc].node` (binaryName
292
398
  // "brust", from the root napi config). Source/CI builds leave it in runtime/;
293
- // an installed project carries it in the brustjs-<platform> package. Copy
294
- // every match from both multi-platform (CI matrix) and single-platform
295
- // (local/installed) layouts both Just Work.
399
+ // an installed project carries it in the brustjs-<platform> package. The
400
+ // --target flag (default "auto" = host platform) selects which to copy.
296
401
  const nativeBinaries = await collectNativeBinaries()
297
- if (nativeBinaries.length === 0) {
402
+ const { selected, errors } = selectNativeBinaries(nativeBinaries, target)
403
+ if (errors.length > 0) {
404
+ for (const e of errors) console.error(e)
405
+ process.exit(1)
406
+ }
407
+ if (selected.length === 0) {
298
408
  console.error(
299
- `brust build: no native binary found. Looked in ${path.join(REPO_ROOT, 'runtime')} and ` +
300
- `the ${platformPackageName()} package. From source run \`bun --filter runtime run build\` ` +
301
- `(or :debug) first; in an installed project ensure the platform package is present.`,
409
+ `brust build: no native binary found for target "${target}". Looked in ` +
410
+ `${path.join(REPO_ROOT, 'runtime')} and the ${platformPackageName()} package. ` +
411
+ `From source run \`bun --filter runtime run build\` first.`,
302
412
  )
303
413
  process.exit(1)
304
414
  }
305
415
  const seen = new Set<string>()
306
- for (const src of nativeBinaries) {
416
+ for (const src of selected) {
307
417
  const name = path.basename(src)
308
418
  if (seen.has(name)) continue
309
419
  seen.add(name)
@@ -0,0 +1,125 @@
1
+ // Dep-free CLI help/version rendering + a tiny ANSI color util. All functions
2
+ // return strings (no console writes) so they're unit-testable. index.ts owns
3
+ // the actual stdout/stderr + exit codes.
4
+ import { readFileSync } from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ /** Read the brustjs package.json version. `help.ts` lives at
8
+ * <root>/runtime/cli/, so ../../package.json is <root>/package.json in both the
9
+ * source tree and an installed node_modules/brustjs layout. Never throws —
10
+ * returns "unknown" on any failure (version must not crash the CLI). */
11
+ export function readVersion(): string {
12
+ try {
13
+ const p = path.join(import.meta.dir, '..', '..', 'package.json')
14
+ const pkg = JSON.parse(readFileSync(p, 'utf8')) as { version?: string }
15
+ return pkg.version ?? 'unknown'
16
+ } catch {
17
+ return 'unknown'
18
+ }
19
+ }
20
+
21
+ function useColor(): boolean {
22
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
23
+ }
24
+ function wrap(open: string, s: string): string {
25
+ return useColor() ? `\x1b[${open}m${s}\x1b[0m` : s
26
+ }
27
+ export const style = {
28
+ bold: (s: string) => wrap('1', s),
29
+ dim: (s: string) => wrap('2', s),
30
+ cyan: (s: string) => wrap('36', s),
31
+ green: (s: string) => wrap('32', s),
32
+ red: (s: string) => wrap('31', s),
33
+ }
34
+
35
+ interface CommandDef {
36
+ name: string
37
+ summary: string
38
+ usage: string
39
+ flags: { flag: string; desc: string }[]
40
+ }
41
+
42
+ export const COMMANDS: CommandDef[] = [
43
+ {
44
+ name: 'build',
45
+ summary: 'Compile a brust app to a self-contained dist/',
46
+ usage: 'brust build [entry] [options]',
47
+ flags: [
48
+ { flag: '[entry]', desc: 'Entry file (default ./index.ts)' },
49
+ { flag: '--out-dir <dir>', desc: 'Output directory (default ./dist)' },
50
+ {
51
+ flag: '--target <t>',
52
+ desc: 'Native target(s): auto | all | <platform>-<arch>[-<libc>][,…] (default auto)',
53
+ },
54
+ ],
55
+ },
56
+ {
57
+ name: 'dev',
58
+ summary: 'Run the dev server with hot reload',
59
+ usage: 'brust dev [entry] [options]',
60
+ flags: [
61
+ { flag: '[entry]', desc: 'Entry file (default ./index.ts)' },
62
+ { flag: '--port <n>', desc: 'Port to listen on' },
63
+ ],
64
+ },
65
+ {
66
+ name: 'new',
67
+ summary: 'Scaffold a new brust project',
68
+ usage: 'brust new <name> [options]',
69
+ flags: [
70
+ { flag: '<name>', desc: 'Project name (lowercase letters, digits, - _)' },
71
+ { flag: '--dir <path>', desc: 'Target directory (default ./<name>)' },
72
+ ],
73
+ },
74
+ ]
75
+
76
+ function pad(s: string, w: number): string {
77
+ return s + ' '.repeat(Math.max(0, w - s.length))
78
+ }
79
+
80
+ export function renderVersion(): string {
81
+ return `brustjs ${readVersion()}`
82
+ }
83
+
84
+ export function renderRootHelp(): string {
85
+ const lines: string[] = []
86
+ lines.push(`${style.bold('brust')} ${style.dim(readVersion())} — the brust framework CLI`)
87
+ lines.push('')
88
+ lines.push(`${style.bold('Usage:')} brust <command> [options]`)
89
+ lines.push('')
90
+ const globals = [
91
+ { label: '-h, --help', desc: 'Show help (brust help <command> for details)' },
92
+ { label: '-v, --version', desc: 'Show the brustjs version' },
93
+ ]
94
+ // One shared column width across commands AND global flags so every
95
+ // description lines up in a single column.
96
+ const w = Math.max(...COMMANDS.map((c) => c.name.length), ...globals.map((g) => g.label.length))
97
+ lines.push(style.bold('Commands:'))
98
+ for (const c of COMMANDS) {
99
+ lines.push(` ${style.cyan(pad(c.name, w))} ${c.summary}`)
100
+ }
101
+ lines.push('')
102
+ lines.push(style.bold('Global:'))
103
+ for (const g of globals) {
104
+ lines.push(` ${style.cyan(pad(g.label, w))} ${g.desc}`)
105
+ }
106
+ lines.push('')
107
+ lines.push(style.dim('Run `brust help <command>` for command-specific options.'))
108
+ return lines.join('\n')
109
+ }
110
+
111
+ export function renderCommandHelp(name: string): string | null {
112
+ const c = COMMANDS.find((x) => x.name === name)
113
+ if (!c) return null
114
+ const lines: string[] = []
115
+ lines.push(`${style.bold('Usage:')} ${c.usage}`)
116
+ lines.push('')
117
+ lines.push(c.summary)
118
+ lines.push('')
119
+ lines.push(style.bold('Options:'))
120
+ const w = Math.max(...c.flags.map((f) => f.flag.length))
121
+ for (const f of c.flags) {
122
+ lines.push(` ${style.cyan(pad(f.flag, w))} ${f.desc}`)
123
+ }
124
+ return lines.join('\n')
125
+ }
@@ -1,30 +1,65 @@
1
1
  #!/usr/bin/env bun
2
- const [, , subcommand, ...rest] = process.argv
2
+ import { COMMANDS, renderCommandHelp, renderRootHelp, renderVersion } from './help.ts'
3
3
 
4
- switch (subcommand) {
5
- case 'build': {
6
- const { runBuild } = await import('./build.ts')
7
- await runBuild(rest)
8
- break
4
+ const argv = process.argv.slice(2)
5
+ const first = argv[0]
6
+ const second = argv[1]
7
+
8
+ // Derive from COMMANDS so the dispatch set and the help registry can never
9
+ // drift (a name in KNOWN but absent from COMMANDS would print `null` as help).
10
+ const KNOWN = new Set(COMMANDS.map((c) => c.name))
11
+
12
+ function hasHelpFlag(args: string[]): boolean {
13
+ return args.includes('--help') || args.includes('-h')
14
+ }
15
+
16
+ if (first === '--version' || first === '-v' || first === 'version') {
17
+ console.log(renderVersion())
18
+ process.exit(0)
19
+ }
20
+
21
+ if (first === '--help' || first === '-h' || first === 'help') {
22
+ if (second && KNOWN.has(second)) {
23
+ console.log(renderCommandHelp(second))
24
+ process.exit(0)
9
25
  }
10
- case 'dev': {
11
- const { runDev } = await import('./dev.ts')
12
- await runDev(rest)
13
- break
26
+ if (second) {
27
+ console.error(`brust: unknown command "${second}".`)
28
+ console.error(renderRootHelp())
29
+ process.exit(1)
14
30
  }
15
- case 'new': {
16
- const { runNew } = await import('./new.ts')
17
- await runNew(rest)
18
- break
31
+ console.log(renderRootHelp())
32
+ process.exit(0)
33
+ }
34
+
35
+ if (first && KNOWN.has(first)) {
36
+ const rest = argv.slice(1)
37
+ if (hasHelpFlag(rest)) {
38
+ console.log(renderCommandHelp(first))
39
+ process.exit(0)
19
40
  }
20
- default: {
21
- if (!subcommand) {
22
- console.error('brust: missing subcommand. Try: brust build | brust dev | brust new')
23
- } else {
24
- console.error(
25
- `brust: unknown subcommand "${subcommand}". Try: brust build | brust dev | brust new`,
26
- )
41
+ switch (first) {
42
+ case 'build': {
43
+ const { runBuild } = await import('./build.ts')
44
+ await runBuild(rest)
45
+ break
46
+ }
47
+ case 'dev': {
48
+ const { runDev } = await import('./dev.ts')
49
+ await runDev(rest)
50
+ break
51
+ }
52
+ case 'new': {
53
+ const { runNew } = await import('./new.ts')
54
+ await runNew(rest)
55
+ break
27
56
  }
28
- process.exit(1)
29
57
  }
58
+ } else if (!first) {
59
+ console.error(renderRootHelp())
60
+ process.exit(1)
61
+ } else {
62
+ console.error(`brust: unknown command "${first}".`)
63
+ console.error('Run `brust --help` to see available commands.')
64
+ process.exit(1)
30
65
  }