@usehyper/cli 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/package.json +40 -0
  4. package/registry-sources/agent-rules/README.md +12 -0
  5. package/registry-sources/agent-rules/files/.cursor/rules/hyper.md +178 -0
  6. package/registry-sources/agent-rules/files/AGENTS.md +64 -0
  7. package/registry-sources/agent-rules/manifest.json +15 -0
  8. package/src/__tests__/add.test.ts +125 -0
  9. package/src/__tests__/cli.test.ts +50 -0
  10. package/src/__tests__/security.test.ts +101 -0
  11. package/src/args.ts +38 -0
  12. package/src/bin.ts +77 -0
  13. package/src/commands/add.ts +232 -0
  14. package/src/commands/bench.ts +185 -0
  15. package/src/commands/build.ts +146 -0
  16. package/src/commands/client.ts +78 -0
  17. package/src/commands/dev.ts +53 -0
  18. package/src/commands/diff.ts +80 -0
  19. package/src/commands/env.ts +92 -0
  20. package/src/commands/help.ts +42 -0
  21. package/src/commands/init.ts +119 -0
  22. package/src/commands/list.ts +46 -0
  23. package/src/commands/mcp.ts +51 -0
  24. package/src/commands/openapi.ts +50 -0
  25. package/src/commands/routes.ts +45 -0
  26. package/src/commands/security.ts +233 -0
  27. package/src/commands/test.ts +191 -0
  28. package/src/commands/typecheck.ts +19 -0
  29. package/src/commands/update.ts +91 -0
  30. package/src/commands/version.ts +16 -0
  31. package/src/config/index.ts +30 -0
  32. package/src/config/io.ts +112 -0
  33. package/src/config/tsconfig.ts +138 -0
  34. package/src/config/types.ts +63 -0
  35. package/src/entry.ts +42 -0
  36. package/src/index.ts +57 -0
  37. package/src/load-app.ts +89 -0
  38. package/src/registry/__tests__/env-writer.test.ts +83 -0
  39. package/src/registry/apply.ts +268 -0
  40. package/src/registry/client.ts +127 -0
  41. package/src/registry/env-writer.ts +135 -0
  42. package/src/registry/index.ts +18 -0
  43. package/src/registry/rewrite.ts +177 -0
  44. package/src/registry/snapshot.ts +1018 -0
  45. package/src/registry/types.ts +62 -0
  46. package/src/templates.ts +141 -0
package/src/bin.ts ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from "./args.ts"
3
+ import { runAdd } from "./commands/add.ts"
4
+ import { runBench } from "./commands/bench.ts"
5
+ import { runBuild } from "./commands/build.ts"
6
+ import { runClient } from "./commands/client.ts"
7
+ import { runDev } from "./commands/dev.ts"
8
+ import { runDiff } from "./commands/diff.ts"
9
+ import { runEnvCheck } from "./commands/env.ts"
10
+ import { HELP_TEXT, runHelp } from "./commands/help.ts"
11
+ import { runInit } from "./commands/init.ts"
12
+ import { runList } from "./commands/list.ts"
13
+ import { runMcp } from "./commands/mcp.ts"
14
+ import { runOpenapi } from "./commands/openapi.ts"
15
+ import { runRoutes } from "./commands/routes.ts"
16
+ import { runSecurity } from "./commands/security.ts"
17
+ import { runTest } from "./commands/test.ts"
18
+ import { runTypecheck } from "./commands/typecheck.ts"
19
+ import { runUpdate } from "./commands/update.ts"
20
+ import { runVersion } from "./commands/version.ts"
21
+
22
+ async function main(): Promise<number> {
23
+ const args = parseArgs(process.argv.slice(2))
24
+ if (args.flags.help === true || args.flags.h === true || !args.command) {
25
+ return runHelp()
26
+ }
27
+ switch (args.command) {
28
+ case "init":
29
+ return runInit(args)
30
+ case "dev":
31
+ return runDev(args)
32
+ case "build":
33
+ return runBuild(args)
34
+ case "test":
35
+ return runTest(args)
36
+ case "typecheck":
37
+ return runTypecheck(args)
38
+ case "env":
39
+ return runEnvCheck(args)
40
+ case "routes":
41
+ return runRoutes(args)
42
+ case "client":
43
+ return runClient(args)
44
+ case "mcp":
45
+ return runMcp(args)
46
+ case "openapi":
47
+ return runOpenapi(args)
48
+ case "add":
49
+ return runAdd(args)
50
+ case "diff":
51
+ return runDiff(args)
52
+ case "update":
53
+ return runUpdate(args)
54
+ case "list":
55
+ case "search":
56
+ case "ls":
57
+ return runList(args)
58
+ case "bench":
59
+ return runBench(args)
60
+ case "security":
61
+ return runSecurity(args)
62
+ case "version":
63
+ case "--version":
64
+ case "-v":
65
+ return runVersion(args)
66
+ case "help":
67
+ return runHelp()
68
+ default:
69
+ console.error(`unknown command: ${args.command}\n`)
70
+ console.error(HELP_TEXT)
71
+ return 2
72
+ }
73
+ }
74
+
75
+ main().then((code) => {
76
+ process.exit(code)
77
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `hyper add <component>...` — copy component source into the user's repo.
3
+ *
4
+ * hyper add cors # install cors + transitively required deps
5
+ * hyper add auth-jwt session # install multiple
6
+ * hyper add cors --info # print readme + file list, install nothing
7
+ * hyper add cors --force # overwrite local edits
8
+ * hyper add cors --dry-run # preview without writing
9
+ * hyper add list # list all available components
10
+ *
11
+ * Files are content-hash verified: a locally-edited file (sha differs from
12
+ * lockfile + differs from registry) refuses to overwrite without --force.
13
+ * If the local sha matches the lockfile but differs from the registry, that
14
+ * counts as a clean update and is applied.
15
+ */
16
+
17
+ import { type ParsedArgs, isJson } from "../args.ts"
18
+ import { readConfig, readLock, writeLock } from "../config/index.ts"
19
+ import { applyComponents, createRegistryClient } from "../registry/index.ts"
20
+
21
+ export async function runAdd(args: ParsedArgs): Promise<number> {
22
+ const positional = args.positional
23
+ if (positional.length === 0) {
24
+ console.error("usage: hyper add <component>... [--force] [--dry-run] [--info] [--list]")
25
+ return 2
26
+ }
27
+
28
+ const config = await readConfig()
29
+ const client = createRegistryClient({ url: config.registryUrl })
30
+
31
+ if (positional[0] === "list" || args.flags.list === true) {
32
+ return await listComponents(client, args)
33
+ }
34
+
35
+ if (args.flags.info === true) {
36
+ return await infoComponent(positional, client, args)
37
+ }
38
+
39
+ const lock = await readLock()
40
+ const force = args.flags.force === true
41
+ const dryRun = args.flags["dry-run"] === true || args.flags.n === true
42
+
43
+ let outcome: Awaited<ReturnType<typeof applyComponents>>
44
+ try {
45
+ outcome = await applyComponents(positional, {
46
+ cwd: process.cwd(),
47
+ config,
48
+ client,
49
+ lock,
50
+ force,
51
+ dryRun,
52
+ })
53
+ } catch (err) {
54
+ console.error(`error: ${(err as Error).message}`)
55
+ return 1
56
+ }
57
+
58
+ if (outcome.conflicts.length > 0) {
59
+ console.error("conflicts (use --force to overwrite, or `hyper diff <component>` to inspect):")
60
+ for (const c of outcome.conflicts) console.error(` ${c.path} (${c.component})`)
61
+ return 1
62
+ }
63
+
64
+ if (!dryRun && outcome.written.length > 0) {
65
+ await writeLock(outcome.lock)
66
+ }
67
+
68
+ if (isJson(args.flags)) {
69
+ const docsByComponent = collectDocs(outcome)
70
+ console.log(
71
+ JSON.stringify(
72
+ {
73
+ components: outcome.components.map((c) => ({
74
+ name: c.name,
75
+ version: c.version,
76
+ ...(c.title !== undefined && { title: c.title }),
77
+ })),
78
+ written: outcome.written,
79
+ unchanged: outcome.unchanged,
80
+ peerDeps: outcome.peerDeps,
81
+ optionalPeerDeps: outcome.optionalPeerDeps,
82
+ envVars: outcome.envVars,
83
+ docs: docsByComponent,
84
+ warnings: outcome.warnings,
85
+ dryRun,
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ )
91
+ return 0
92
+ }
93
+
94
+ printOutcome(outcome, dryRun, config.alias)
95
+ return 0
96
+ }
97
+
98
+ /** Map component name -> docs blurb, only for components that wrote files. */
99
+ function collectDocs(outcome: Awaited<ReturnType<typeof applyComponents>>): Record<string, string> {
100
+ const touched = new Set(outcome.written.map((w) => w.component))
101
+ const docs: Record<string, string> = {}
102
+ for (const c of outcome.components) {
103
+ if (c.docs && touched.has(c.name)) docs[c.name] = c.docs
104
+ }
105
+ return docs
106
+ }
107
+
108
+ async function listComponents(
109
+ client: ReturnType<typeof createRegistryClient>,
110
+ args: ParsedArgs,
111
+ ): Promise<number> {
112
+ const all = await client.listComponents()
113
+ if (isJson(args.flags)) {
114
+ console.log(JSON.stringify(all, null, 2))
115
+ return 0
116
+ }
117
+ const w = Math.max(8, ...all.map((c) => c.name.length))
118
+ console.log(`${"name".padEnd(w)} version description`)
119
+ console.log(`${"".padEnd(w, "-")} ------- -----------`)
120
+ for (const c of all) {
121
+ const summary = c.title ? `${c.title} — ${c.description}` : c.description
122
+ console.log(`${c.name.padEnd(w)} ${c.version.padEnd(7)} ${summary}`)
123
+ }
124
+ return 0
125
+ }
126
+
127
+ async function infoComponent(
128
+ names: readonly string[],
129
+ client: ReturnType<typeof createRegistryClient>,
130
+ args: ParsedArgs,
131
+ ): Promise<number> {
132
+ for (const name of names) {
133
+ const c = await client.getComponent(name).catch((err) => {
134
+ console.error(`error: ${(err as Error).message}`)
135
+ return null
136
+ })
137
+ if (!c) return 2
138
+ if (isJson(args.flags)) {
139
+ console.log(JSON.stringify(c, null, 2))
140
+ continue
141
+ }
142
+ console.log(`# ${c.name}@${c.version}`)
143
+ console.log(c.description)
144
+ if (c.registryDeps.length > 0) console.log(`\nneeds: ${c.registryDeps.join(", ")}`)
145
+ if (Object.keys(c.peerDeps).length > 0) {
146
+ console.log(
147
+ `peers: ${Object.entries(c.peerDeps)
148
+ .map(([k, v]) => `${k}@${v}`)
149
+ .join(", ")}`,
150
+ )
151
+ }
152
+ if (Object.keys(c.optionalPeerDeps).length > 0) {
153
+ console.log(
154
+ `optional peers: ${Object.entries(c.optionalPeerDeps)
155
+ .map(([k, v]) => `${k}@${v}`)
156
+ .join(", ")}`,
157
+ )
158
+ }
159
+ if (c.envVars && Object.keys(c.envVars).length > 0) {
160
+ console.log("\nenv vars (written to .env.local on install):")
161
+ for (const [k, v] of Object.entries(c.envVars)) console.log(` ${k}=${v}`)
162
+ }
163
+ console.log(`\nfiles (${c.files.length}):`)
164
+ for (const f of c.files) console.log(` ${f.path}`)
165
+ if (c.docs) {
166
+ console.log("\n# Setup notes\n")
167
+ console.log(c.docs)
168
+ }
169
+ if (c.readme) {
170
+ console.log(`\n${"-".repeat(60)}\n`)
171
+ console.log(c.readme)
172
+ }
173
+ }
174
+ return 0
175
+ }
176
+
177
+ function printOutcome(
178
+ outcome: Awaited<ReturnType<typeof applyComponents>>,
179
+ dryRun: boolean,
180
+ alias: string,
181
+ ): void {
182
+ const tag = dryRun ? "(dry-run) " : ""
183
+ const componentNames = outcome.components.map((c) => c.name).join(", ")
184
+ console.log(`${tag}installed: ${componentNames}`)
185
+ const newCount = outcome.written.filter((w) => w.reason === "new").length
186
+ const updatedCount = outcome.written.filter((w) => w.reason === "updated").length
187
+ const upToDateCount = outcome.unchanged.length
188
+ console.log(
189
+ `${tag}${newCount} new, ${updatedCount} updated, ${upToDateCount} up-to-date — alias: ${alias}`,
190
+ )
191
+ for (const w of outcome.written.slice(0, 30)) {
192
+ console.log(` ${w.reason === "new" ? "+" : "~"} ${w.path}`)
193
+ }
194
+ if (outcome.written.length > 30) console.log(` ... (${outcome.written.length - 30} more)`)
195
+
196
+ const peers = Object.entries(outcome.peerDeps)
197
+ const optionalPeers = Object.entries(outcome.optionalPeerDeps)
198
+ if (peers.length > 0) {
199
+ console.log(`\nrun: bun add ${peers.map(([k, v]) => `${k}@${v}`).join(" ")}`)
200
+ }
201
+ if (optionalPeers.length > 0) {
202
+ console.log(
203
+ `optional peers: ${optionalPeers.map(([k]) => k).join(", ")} (install only if you use them)`,
204
+ )
205
+ }
206
+
207
+ if (outcome.envVars.added.length > 0) {
208
+ console.log(`\nenv: ${outcome.envVars.added.length} key(s) added to ${outcome.envVars.path}`)
209
+ for (const k of outcome.envVars.added) console.log(` + ${k}`)
210
+ }
211
+ if (outcome.envVars.preserved.length > 0) {
212
+ console.log(
213
+ `env: preserved existing ${outcome.envVars.preserved.length} key(s) in ${outcome.envVars.path}`,
214
+ )
215
+ }
216
+
217
+ if (outcome.warnings.length > 0) {
218
+ console.log("\nwarnings:")
219
+ for (const w of outcome.warnings) console.log(` ! ${w}`)
220
+ }
221
+
222
+ // Per-component setup notes — only for newly-installed components, so
223
+ // re-running `hyper add` doesn't re-spam the user.
224
+ const touched = new Set(outcome.written.map((w) => w.component))
225
+ for (const c of outcome.components) {
226
+ if (!c.docs || !touched.has(c.name)) continue
227
+ console.log(`\n${"-".repeat(60)}`)
228
+ console.log(`# ${c.title ?? c.name}`)
229
+ console.log()
230
+ console.log(c.docs)
231
+ }
232
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `hyper bench` — minimal, reproducible latency benchmark.
3
+ *
4
+ * hyper bench → bench/<entry> and print stats
5
+ * hyper bench --json → emit JSON (CI perf gate)
6
+ * hyper bench --path /todos → pick a route to hammer
7
+ *
8
+ * We run everything in-process via app.fetch — no sockets, no port
9
+ * contention, no kernel scheduler noise. That gives us a stable signal
10
+ * for regression detection. The runner is warmup-aware and records
11
+ * allocation deltas so we can spot memory regressions.
12
+ */
13
+
14
+ import { type ParsedArgs, isJson } from "../args.ts"
15
+ import { resolveEntry } from "../entry.ts"
16
+ import { loadApp } from "../load-app.ts"
17
+
18
+ export interface BenchReport {
19
+ readonly path: string
20
+ readonly method: string
21
+ readonly iterations: number
22
+ readonly warmup: number
23
+ readonly p50_us: number
24
+ readonly p95_us: number
25
+ readonly p99_us: number
26
+ readonly rps: number
27
+ readonly heapUsedDeltaMb: number
28
+ readonly targetP50Us: number
29
+ readonly targetP95Us: number
30
+ readonly passed: boolean
31
+ }
32
+
33
+ export async function runBench(args: ParsedArgs): Promise<number> {
34
+ const entry = await resolveEntry(args.positional)
35
+ if (!entry) {
36
+ console.error("error: no entry file found")
37
+ return 2
38
+ }
39
+ const app = await loadApp(entry)
40
+ if (!app) {
41
+ console.error("error: entry did not export a Hyper app")
42
+ return 2
43
+ }
44
+ const iterations = readNumber(args.flags.n, 20_000)
45
+ const warmup = readNumber(args.flags.warmup, 2_000)
46
+ const targetP50 = readNumber(args.flags.p50, 250)
47
+ const targetP95 = readNumber(args.flags.p95, 800)
48
+
49
+ if (args.flags.tests === true) {
50
+ const reports: BenchReport[] = []
51
+ for (const r of app.routeList) {
52
+ if (r.path.includes(":")) continue
53
+ const report = await benchOne(app, r.path, r.method, {
54
+ iterations: Math.max(1_000, Math.floor(iterations / Math.max(1, app.routeList.length))),
55
+ warmup: Math.max(100, Math.floor(warmup / 2)),
56
+ targetP50,
57
+ targetP95,
58
+ })
59
+ reports.push(report)
60
+ }
61
+ const passed = reports.every((r) => r.passed)
62
+ if (isJson(args.flags)) {
63
+ console.log(JSON.stringify({ passed, routes: reports }))
64
+ } else {
65
+ console.log(`bench --tests ${reports.length} route(s) ${passed ? "PASS" : "FAIL"}`)
66
+ for (const r of reports) {
67
+ console.log(
68
+ ` ${r.passed ? "PASS" : "FAIL"} ${r.method.padEnd(6)} ${r.path.padEnd(30)} p50=${r.p50_us}µs p95=${r.p95_us}µs rps=${r.rps}`,
69
+ )
70
+ }
71
+ }
72
+ return passed ? 0 : 1
73
+ }
74
+
75
+ const path = typeof args.flags.path === "string" ? args.flags.path : "/"
76
+ const method = typeof args.flags.method === "string" ? args.flags.method.toUpperCase() : "GET"
77
+ const url = `http://local${path}`
78
+
79
+ // Warmup.
80
+ for (let i = 0; i < warmup; i++) await app.fetch(new Request(url, { method }))
81
+
82
+ const samples = new Float64Array(iterations)
83
+ const start = process.memoryUsage().heapUsed
84
+ const t0 = Bun.nanoseconds()
85
+ for (let i = 0; i < iterations; i++) {
86
+ const s = Bun.nanoseconds()
87
+ await app.fetch(new Request(url, { method }))
88
+ samples[i] = (Bun.nanoseconds() - s) / 1000 // µs
89
+ }
90
+ const t1 = Bun.nanoseconds()
91
+ const heapUsedDelta = process.memoryUsage().heapUsed - start
92
+ const sorted = samples.slice().sort()
93
+ const p50 = percentile(sorted, 0.5)
94
+ const p95 = percentile(sorted, 0.95)
95
+ const p99 = percentile(sorted, 0.99)
96
+ const rps = iterations / ((t1 - t0) / 1_000_000_000)
97
+ const passed = p50 <= targetP50 * 1.05 && p95 <= targetP95 * 1.05
98
+ const report: BenchReport = {
99
+ path,
100
+ method,
101
+ iterations,
102
+ warmup,
103
+ p50_us: round(p50),
104
+ p95_us: round(p95),
105
+ p99_us: round(p99),
106
+ rps: round(rps),
107
+ heapUsedDeltaMb: round(heapUsedDelta / 1024 / 1024),
108
+ targetP50Us: targetP50,
109
+ targetP95Us: targetP95,
110
+ passed,
111
+ }
112
+ if (isJson(args.flags)) {
113
+ console.log(JSON.stringify(report))
114
+ } else {
115
+ console.log(`route ${method} ${path}`)
116
+ console.log(`iters ${iterations} (warmup ${warmup})`)
117
+ console.log(`p50/p95/p99 ${report.p50_us}µs / ${report.p95_us}µs / ${report.p99_us}µs`)
118
+ console.log(`rps ${report.rps}`)
119
+ console.log(`heapΔ ${report.heapUsedDeltaMb} MB`)
120
+ console.log(`target p50<=${targetP50}µs p95<=${targetP95}µs (5% slack)`)
121
+ console.log(`status ${passed ? "PASS" : "FAIL"}`)
122
+ }
123
+ return passed ? 0 : 1
124
+ }
125
+
126
+ function percentile(sorted: Float64Array, p: number): number {
127
+ if (sorted.length === 0) return 0
128
+ const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length))
129
+ return sorted[idx] ?? 0
130
+ }
131
+ function round(n: number): number {
132
+ return Math.round(n * 100) / 100
133
+ }
134
+ function readNumber(flag: string | boolean | undefined, fallback: number): number {
135
+ if (typeof flag === "string") {
136
+ const n = Number.parseInt(flag, 10)
137
+ return Number.isFinite(n) && n > 0 ? n : fallback
138
+ }
139
+ return fallback
140
+ }
141
+
142
+ async function benchOne(
143
+ app: NonNullable<Awaited<ReturnType<typeof loadApp>>>,
144
+ path: string,
145
+ method: string,
146
+ opts: {
147
+ readonly iterations: number
148
+ readonly warmup: number
149
+ readonly targetP50: number
150
+ readonly targetP95: number
151
+ },
152
+ ): Promise<BenchReport> {
153
+ const url = `http://local${path}`
154
+ for (let i = 0; i < opts.warmup; i++) await app.fetch(new Request(url, { method }))
155
+ const samples = new Float64Array(opts.iterations)
156
+ const start = process.memoryUsage().heapUsed
157
+ const t0 = Bun.nanoseconds()
158
+ for (let i = 0; i < opts.iterations; i++) {
159
+ const s = Bun.nanoseconds()
160
+ await app.fetch(new Request(url, { method }))
161
+ samples[i] = (Bun.nanoseconds() - s) / 1000
162
+ }
163
+ const t1 = Bun.nanoseconds()
164
+ const heapUsedDelta = process.memoryUsage().heapUsed - start
165
+ const sorted = samples.slice().sort()
166
+ const p50 = percentile(sorted, 0.5)
167
+ const p95 = percentile(sorted, 0.95)
168
+ const p99 = percentile(sorted, 0.99)
169
+ const rps = opts.iterations / ((t1 - t0) / 1_000_000_000)
170
+ const passed = p50 <= opts.targetP50 * 1.05 && p95 <= opts.targetP95 * 1.05
171
+ return {
172
+ path,
173
+ method,
174
+ iterations: opts.iterations,
175
+ warmup: opts.warmup,
176
+ p50_us: round(p50),
177
+ p95_us: round(p95),
178
+ p99_us: round(p99),
179
+ rps: round(rps),
180
+ heapUsedDeltaMb: round(heapUsedDelta / 1024 / 1024),
181
+ targetP50Us: opts.targetP50,
182
+ targetP95Us: opts.targetP95,
183
+ passed,
184
+ }
185
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import { type ParsedArgs, isJson } from "../args.ts"
5
+ import { resolveEntry } from "../entry.ts"
6
+ import { loadApp } from "../load-app.ts"
7
+
8
+ /**
9
+ * `hyper build` — bundle via Bun.build and emit the artifact manifest.
10
+ *
11
+ * v0.3 additions:
12
+ * - content-hash cache: skips bundling when entry+tsconfig+deps hash
13
+ * matches the previous build. Typical cache hit: <50ms incremental.
14
+ * - route graph: includes projection hints (mcp, subscription, action,
15
+ * deprecated, version) so downstream codegen can consume it directly.
16
+ * - per-route monomorphic hint: routes without dynamic segments are
17
+ * flagged `nativeEligible: true`, letting the adapter mount them on
18
+ * `Bun.serve({ routes: ... })` for faster dispatch.
19
+ * - .d.ts emission: when `--dts` is passed, emit isolated declaration
20
+ * files alongside the bundle via `tsgo` (falls back to `tsc`).
21
+ */
22
+ export async function runBuild(args: ParsedArgs): Promise<number> {
23
+ const entry = await resolveEntry(args.positional)
24
+ if (!entry) {
25
+ console.error("error: no entry file found")
26
+ return 2
27
+ }
28
+ const outDir = typeof args.flags.outDir === "string" ? args.flags.outDir : "dist"
29
+ const absOut = resolve(process.cwd(), outDir)
30
+ await mkdir(absOut, { recursive: true })
31
+
32
+ if (typeof Bun === "undefined") {
33
+ console.error("error: hyper build requires Bun")
34
+ return 2
35
+ }
36
+
37
+ const cacheKey = await computeCacheKey(entry)
38
+ const cacheFile = join(absOut, ".hyper-cache.json")
39
+ const cached = await readJsonOrNull<{ key: string; summary: unknown }>(cacheFile)
40
+ const bypassCache = args.flags.force === true
41
+ if (!bypassCache && cached?.key === cacheKey) {
42
+ if (isJson(args.flags)) console.log(JSON.stringify(cached.summary))
43
+ else console.log(`build cached -> ${absOut} (key ${cacheKey.slice(0, 8)})`)
44
+ return 0
45
+ }
46
+
47
+ const result = await Bun.build({
48
+ entrypoints: [entry],
49
+ outdir: absOut,
50
+ target: "bun",
51
+ format: "esm",
52
+ sourcemap: "linked",
53
+ minify: args.flags.minify === true || args.flags.minify === "true",
54
+ })
55
+ if (!result.success) {
56
+ for (const log of result.logs) console.error(log)
57
+ return 1
58
+ }
59
+
60
+ const app = await loadApp(entry)
61
+ if (app) {
62
+ const graph = {
63
+ routes: app.routeList.map((r) => ({
64
+ method: r.method,
65
+ path: r.path,
66
+ meta: r.meta,
67
+ kind: r.kind,
68
+ nativeEligible: !r.path.includes(":"),
69
+ staticResponse: r.kind === "static",
70
+ })),
71
+ summary: {
72
+ total: app.routeList.length,
73
+ staticCount: app.routeList.filter((r) => r.kind === "static").length,
74
+ nativeEligibleCount: app.routeList.filter((r) => !r.path.includes(":")).length,
75
+ },
76
+ }
77
+ const graphPath = resolve(absOut, "route-graph.json")
78
+ await mkdir(dirname(graphPath), { recursive: true })
79
+ await writeFile(graphPath, JSON.stringify(graph, null, 2))
80
+ }
81
+
82
+ if (args.flags.dts === true) {
83
+ await emitDts(absOut)
84
+ }
85
+
86
+ const summary = {
87
+ entry,
88
+ outDir: absOut,
89
+ artifacts: result.outputs.map((o) => o.path),
90
+ routeCount: app?.routeList.length ?? 0,
91
+ cacheKey,
92
+ }
93
+ await writeFile(cacheFile, JSON.stringify({ key: cacheKey, summary }))
94
+ if (isJson(args.flags)) {
95
+ console.log(JSON.stringify(summary))
96
+ } else {
97
+ console.log(`build ok -> ${absOut}`)
98
+ console.log(` ${summary.artifacts.length} artifact(s), ${summary.routeCount} route(s)`)
99
+ }
100
+ return 0
101
+ }
102
+
103
+ async function computeCacheKey(entry: string): Promise<string> {
104
+ const h = createHash("sha256")
105
+ h.update(entry)
106
+ await hashFile(h, entry)
107
+ await hashFile(h, resolve(process.cwd(), "tsconfig.json"))
108
+ await hashFile(h, resolve(process.cwd(), "package.json"))
109
+ await hashFile(h, resolve(process.cwd(), "bun.lockb"))
110
+ return h.digest("hex")
111
+ }
112
+
113
+ async function hashFile(h: import("node:crypto").Hash, path: string): Promise<void> {
114
+ try {
115
+ const s = await stat(path)
116
+ h.update(path)
117
+ h.update(s.mtimeMs.toString())
118
+ if (s.size < 1024 * 1024) {
119
+ const buf = await readFile(path)
120
+ h.update(buf)
121
+ }
122
+ } catch {
123
+ // Missing file — include absence in the key.
124
+ h.update(`missing:${path}`)
125
+ }
126
+ }
127
+
128
+ async function readJsonOrNull<T>(path: string): Promise<T | null> {
129
+ try {
130
+ const buf = await readFile(path, "utf8")
131
+ return JSON.parse(buf) as T
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ async function emitDts(outDir: string): Promise<void> {
138
+ const { spawn } = await import("node:child_process")
139
+ await new Promise<void>((res) => {
140
+ const child = spawn("tsgo", ["--declaration", "--emitDeclarationOnly", "--outDir", outDir], {
141
+ stdio: "inherit",
142
+ })
143
+ child.on("exit", () => res())
144
+ child.on("error", () => res())
145
+ })
146
+ }