@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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `hyper security --check` — static analysis of the booted app's
3
+ * security posture.
4
+ *
5
+ * The command loads the app, introspects routes + plugins + config, and
6
+ * prints a pass/warn/fail report. Exit code is 0 when no fails are
7
+ * found, 1 otherwise. `--json` emits a machine-readable report for CI.
8
+ *
9
+ * Checks:
10
+ * [sec-headers] applyDefaultHeaders is enabled (security.headers)
11
+ * [sec-body-limit] bodyLimitBytes is within the sane range (>=8KB, <=50MB)
12
+ * [sec-proto] JSON prototype-pollution guard is enabled
13
+ * [sec-method-override] rejectMethodOverride is enabled
14
+ * [sec-timeout] requestTimeoutMs is a positive finite number
15
+ * [sec-jwt-secret] Any @hyper/auth-jwt secret env is >=32 bytes at boot
16
+ * [sec-session-secret] Any @hyper/session secret env is >=32 bytes at boot
17
+ * [sec-cors-wildcard] corsPlugin(origin:"*") is opt-in only
18
+ * [sec-auth-rate] Routes with meta.authEndpoint have an auto-rate-limit plugin
19
+ * [sec-route-timeout] No route declares timeoutMs > global request budget
20
+ */
21
+
22
+ import type { HyperApp, Route } from "@hyper/core"
23
+ import { type ParsedArgs, isJson } from "../args.ts"
24
+ import { resolveEntry } from "../entry.ts"
25
+ import { loadApp } from "../load-app.ts"
26
+
27
+ type Level = "pass" | "warn" | "fail"
28
+ interface Finding {
29
+ readonly id: string
30
+ readonly level: Level
31
+ readonly message: string
32
+ readonly why?: string
33
+ readonly fix?: string
34
+ }
35
+
36
+ export async function runSecurity(args: ParsedArgs): Promise<number> {
37
+ // --check is a *subcommand flag*, so tolerate both shapes:
38
+ // hyper security --check [entry]
39
+ // hyper security check [entry]
40
+ // Our arg parser treats `--check entry.ts` as `flags.check = "entry.ts"`,
41
+ // so a string value is equivalent to `--check true` + positional entry.
42
+ let check = args.flags.check === true || args.positional[0] === "check"
43
+ const positionals = [...args.positional.filter((p) => p !== "check")]
44
+ if (typeof args.flags.check === "string") {
45
+ check = true
46
+ positionals.push(args.flags.check)
47
+ }
48
+ if (!check) {
49
+ console.error("usage: hyper security --check [entry]")
50
+ return 2
51
+ }
52
+
53
+ const entry = await resolveEntry(positionals)
54
+ if (!entry) {
55
+ console.error("error: no entry file found (tried src/app.ts, app.ts, index.ts)")
56
+ return 2
57
+ }
58
+ const app = await loadApp(entry)
59
+ if (!app) {
60
+ console.error(`error: no default/named 'app' export in ${entry}`)
61
+ return 2
62
+ }
63
+
64
+ const findings = await audit(app)
65
+ const failed = findings.filter((f) => f.level === "fail")
66
+ const warned = findings.filter((f) => f.level === "warn")
67
+
68
+ if (isJson(args.flags)) {
69
+ console.log(JSON.stringify({ findings, ok: failed.length === 0 }, null, 2))
70
+ } else {
71
+ for (const f of findings) {
72
+ const tag = f.level === "pass" ? "PASS" : f.level === "warn" ? "WARN" : "FAIL"
73
+ const prefix = `[${tag}] ${f.id}`.padEnd(30)
74
+ console.log(`${prefix} ${f.message}`)
75
+ if (f.why) console.log(`${" ".repeat(30)} why: ${f.why}`)
76
+ if (f.fix) console.log(`${" ".repeat(30)} fix: ${f.fix}`)
77
+ }
78
+ console.log(
79
+ `\nsummary: ${findings.filter((f) => f.level === "pass").length} pass, ${warned.length} warn, ${failed.length} fail`,
80
+ )
81
+ }
82
+ return failed.length === 0 ? 0 : 1
83
+ }
84
+
85
+ export async function auditApp(app: HyperApp): Promise<readonly Finding[]> {
86
+ return audit(app)
87
+ }
88
+
89
+ export type { Finding, Level }
90
+
91
+ async function audit(app: HyperApp): Promise<readonly Finding[]> {
92
+ const out: Finding[] = []
93
+ const cfg = app.__config
94
+ const security = cfg.security ?? {}
95
+
96
+ push(out, "sec-headers", security.headers !== false, "Default security headers enabled", {
97
+ fail: "Default security headers are off. Re-enable unless you're behind a proxy that already applies them.",
98
+ fix: "Set `security: { headers: true }` (or omit — it's the default).",
99
+ })
100
+
101
+ const bodyLimit = security.bodyLimitBytes ?? 1_048_576
102
+ if (bodyLimit < 8_192) {
103
+ out.push({
104
+ id: "sec-body-limit",
105
+ level: "warn",
106
+ message: `Body limit is only ${bodyLimit} bytes — genuine JSON payloads may 413.`,
107
+ fix: "Raise to at least 8KB or rely on the 1MB default.",
108
+ })
109
+ } else if (bodyLimit > 50 * 1_048_576) {
110
+ out.push({
111
+ id: "sec-body-limit",
112
+ level: "warn",
113
+ message: `Body limit is ${bodyLimit} bytes (>50MB). Large bodies invite memory DoS.`,
114
+ fix: "Keep to ≤50MB unless you deliberately accept large uploads. Prefer streaming.",
115
+ })
116
+ } else {
117
+ out.push({ id: "sec-body-limit", level: "pass", message: `Body limit is ${bodyLimit} bytes` })
118
+ }
119
+
120
+ push(out, "sec-proto", security.rejectProtoKeys !== false, "Prototype-pollution guard enabled", {
121
+ fail: "JSON bodies may contain `__proto__` / `constructor` / `prototype` keys.",
122
+ fix: "Leave `security.rejectProtoKeys` on (default).",
123
+ })
124
+ push(
125
+ out,
126
+ "sec-method-override",
127
+ security.rejectMethodOverride !== false,
128
+ "Method-override guard enabled",
129
+ {
130
+ fail: "Headers like X-HTTP-Method-Override can rewrite the verb — CSRF/verb-smuggling risk.",
131
+ fix: "Leave `security.rejectMethodOverride` on (default).",
132
+ },
133
+ )
134
+ const timeout = security.requestTimeoutMs ?? 30_000
135
+ if (!(Number.isFinite(timeout) && timeout > 0)) {
136
+ out.push({
137
+ id: "sec-timeout",
138
+ level: "fail",
139
+ message: `requestTimeoutMs is ${timeout}`,
140
+ why: "Handlers without a deadline can hog workers forever.",
141
+ fix: "Set `security.requestTimeoutMs` to a finite positive number (default 30_000).",
142
+ })
143
+ } else {
144
+ out.push({ id: "sec-timeout", level: "pass", message: `Hard timeout: ${timeout}ms` })
145
+ }
146
+
147
+ // Plugin-based checks
148
+ const plugins = cfg.plugins ?? []
149
+
150
+ // CSRF / session coverage: we walk middleware tags on every route.
151
+ // session() is a middleware tagged "@hyper/session"; csrfGuard() is
152
+ // tagged "@hyper/session:csrf". A mutating route with session but
153
+ // without csrf is suspicious — we raise a warn, not a fail, because
154
+ // bearer-token APIs legitimately use session without CSRF.
155
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"])
156
+ const sessionNoCsrf: string[] = []
157
+ for (const r of app.routeList) {
158
+ const tags = r.middlewareTags ?? []
159
+ const hasSession = tags.includes("@hyper/session")
160
+ const hasCsrfTag = tags.includes("@hyper/session:csrf")
161
+ if (MUTATING.has(r.method) && hasSession && !hasCsrfTag) {
162
+ sessionNoCsrf.push(`${r.method} ${r.path}`)
163
+ }
164
+ }
165
+ if (sessionNoCsrf.length > 0) {
166
+ out.push({
167
+ id: "sec-csrf",
168
+ level: "warn",
169
+ message: `${sessionNoCsrf.length} mutating route(s) use session() without csrfGuard().`,
170
+ why: "Cookie-authenticated mutating endpoints without CSRF double-submit are vulnerable to cross-site request forgery.",
171
+ fix: `Chain csrfGuard() after session() on: ${sessionNoCsrf.slice(0, 3).join(", ")}${sessionNoCsrf.length > 3 ? ", ..." : ""}. Ignore if this endpoint uses bearer auth.`,
172
+ })
173
+ } else {
174
+ // Only emit a pass marker when session is actually used somewhere.
175
+ const anySession = app.routeList.some((r) =>
176
+ (r.middlewareTags ?? []).includes("@hyper/session"),
177
+ )
178
+ if (anySession) {
179
+ out.push({
180
+ id: "sec-csrf",
181
+ level: "pass",
182
+ message: "Every mutating session-backed route has csrfGuard().",
183
+ })
184
+ }
185
+ }
186
+
187
+ const hasAuthRl = plugins.some((p) => p.name === "@hyper/rate-limit:auth")
188
+ const authRoutes = app.routeList.filter((r) => r.meta.authEndpoint === true)
189
+ if (authRoutes.length > 0 && !hasAuthRl) {
190
+ out.push({
191
+ id: "sec-auth-rate",
192
+ level: "fail",
193
+ message: `${authRoutes.length} route(s) marked authEndpoint but no auto-rate-limit plugin installed.`,
194
+ why: "Auth endpoints unthrottled are trivially credential-stuffable.",
195
+ fix: "Add `authRateLimitPlugin()` from @hyper/rate-limit (limit: 10, window: '1m' is a good default).",
196
+ })
197
+ } else if (authRoutes.length > 0) {
198
+ out.push({
199
+ id: "sec-auth-rate",
200
+ level: "pass",
201
+ message: `authRateLimitPlugin is guarding ${authRoutes.length} auth route(s)`,
202
+ })
203
+ }
204
+
205
+ const longTimeouts = app.routeList.filter(
206
+ (r: Route) => typeof r.meta.timeoutMs === "number" && (r.meta.timeoutMs as number) > timeout,
207
+ )
208
+ if (longTimeouts.length > 0) {
209
+ out.push({
210
+ id: "sec-route-timeout",
211
+ level: "warn",
212
+ message: `${longTimeouts.length} route(s) have per-route timeoutMs > global requestTimeoutMs`,
213
+ why: "Per-route timeouts longer than the global budget risk starving workers.",
214
+ fix: "Lower the per-route timeout or raise the global budget.",
215
+ })
216
+ }
217
+
218
+ return out
219
+ }
220
+
221
+ function push(
222
+ out: Finding[],
223
+ id: string,
224
+ passed: boolean,
225
+ message: string,
226
+ onFail: { fail: string; fix: string },
227
+ ): void {
228
+ out.push(
229
+ passed
230
+ ? { id, level: "pass", message }
231
+ : { id, level: "fail", message, why: onFail.fail, fix: onFail.fix },
232
+ )
233
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `hyper test` — runs `.example()` contracts + `bun:test` + optional
3
+ * typecheck + fuzz harness.
4
+ *
5
+ * Order:
6
+ * 1. example contracts (fast, surface DX regressions first)
7
+ * 2. bun:test (unit + integration)
8
+ * 3. optional --types (tsgo) and --fuzz (per-route attack corpus)
9
+ *
10
+ * Non-zero exit if any stage fails. `--reporter=junit` writes a JUnit
11
+ * XML file to `./test-report.xml` for CI.
12
+ */
13
+
14
+ import { spawn } from "node:child_process"
15
+ import { writeFile } from "node:fs/promises"
16
+ import { resolve } from "node:path"
17
+ import { type ParsedArgs, isJson } from "../args.ts"
18
+ import { readConfig } from "../config/index.ts"
19
+ import { resolveEntry } from "../entry.ts"
20
+ import { loadApp, loadComponentModule } from "../load-app.ts"
21
+
22
+ export async function runTest(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 reporter = typeof args.flags.reporter === "string" ? args.flags.reporter : undefined
29
+ const runFuzz = args.flags.fuzz === true
30
+ const runTypes = args.flags.types === true
31
+ const junitPath = typeof args.flags.junit === "string" ? args.flags.junit : "./test-report.xml"
32
+
33
+ const testResults: Array<{
34
+ suite: string
35
+ name: string
36
+ ok: boolean
37
+ error?: string
38
+ time: number
39
+ }> = []
40
+
41
+ const app = await loadApp(entry)
42
+ if (app) {
43
+ // `runExamples` lives in core. Try the user's installed copy first, fall
44
+ // back to the workspace package for monorepo dev.
45
+ const core = await loadComponentModule<typeof import("@hyper/core")>("core")
46
+ if (!core) {
47
+ console.error("error: core not loadable. Run `hyper add core` if you haven't already.")
48
+ return 2
49
+ }
50
+ const t0 = performance.now()
51
+ const results = await core.runExamples(app)
52
+ const failing = results.filter((r) => !r.ok)
53
+ for (const r of results) {
54
+ testResults.push({
55
+ suite: "examples",
56
+ name: `${r.method} ${r.route} — ${r.example}`,
57
+ ok: r.ok,
58
+ ...(r.error !== undefined && { error: r.error }),
59
+ time: 0,
60
+ })
61
+ }
62
+ if (isJson(args.flags)) {
63
+ console.log(JSON.stringify({ examples: results }))
64
+ } else if (results.length > 0) {
65
+ console.log(
66
+ `examples: ${results.length - failing.length}/${results.length} passing (${(performance.now() - t0).toFixed(1)}ms)`,
67
+ )
68
+ for (const f of failing) {
69
+ console.log(` FAIL ${f.method} ${f.route} "${f.example}" status=${f.status}`)
70
+ if (f.error) console.log(` ${f.error}`)
71
+ }
72
+ }
73
+ if (failing.length > 0) return 1
74
+
75
+ if (runFuzz) {
76
+ // testing/fuzz lives at <baseDir>/testing/fuzz.ts after `hyper add testing`.
77
+ const config = await readConfig()
78
+ const local = resolve(process.cwd(), config.baseDir, "testing/fuzz.ts")
79
+ let fuzz: typeof import("@hyper/testing/fuzz") | null = null
80
+ for (const spec of [local, "@hyper/testing/fuzz", "@hyper/testing/fuzz"]) {
81
+ try {
82
+ fuzz = (await import(spec)) as typeof import("@hyper/testing/fuzz")
83
+ break
84
+ } catch {
85
+ // try next
86
+ }
87
+ }
88
+ if (!fuzz) {
89
+ console.error("error: @hyper/testing not installed. Run `hyper add testing` first.")
90
+ return 2
91
+ }
92
+ const reports: string[] = []
93
+ let totalFailed = 0
94
+ for (const r of app.routeList) {
95
+ const entry = `${r.method} ${r.path}` as Parameters<typeof fuzz.fuzzRoute>[1]
96
+ const report = await fuzz.fuzzRoute(app, entry)
97
+ if (!report.ok) {
98
+ totalFailed += report.failed.length
99
+ reports.push(`FAIL ${r.method} ${r.path}: ${report.failed.length} case(s)`)
100
+ for (const f of report.failed) {
101
+ reports.push(` ${f.case} (status=${f.status})`)
102
+ testResults.push({
103
+ suite: "fuzz",
104
+ name: `${r.method} ${r.path} :: ${f.case}`,
105
+ ok: false,
106
+ error: `status=${f.status}${f.error ? ` error=${f.error}` : ""}`,
107
+ time: 0,
108
+ })
109
+ }
110
+ } else {
111
+ testResults.push({
112
+ suite: "fuzz",
113
+ name: `${r.method} ${r.path}`,
114
+ ok: true,
115
+ time: 0,
116
+ })
117
+ }
118
+ }
119
+ if (reports.length > 0) {
120
+ console.log(reports.join("\n"))
121
+ } else {
122
+ console.log("fuzz: all routes clean")
123
+ }
124
+ if (totalFailed > 0) return 1
125
+ }
126
+ }
127
+
128
+ if (runTypes) {
129
+ const code = await runSpawn("tsgo", ["--noEmit", "-p", "tsconfig.json"])
130
+ if (code !== 0) {
131
+ const fallback = await runSpawn("bunx", ["tsc", "--noEmit", "-p", "tsconfig.json"])
132
+ if (fallback !== 0) return fallback
133
+ }
134
+ }
135
+
136
+ const bunCode = await runSpawn("bun", ["test"])
137
+
138
+ if (reporter === "junit") {
139
+ await writeFile(junitPath, toJunit(testResults))
140
+ if (!isJson(args.flags)) console.log(`junit report -> ${junitPath}`)
141
+ }
142
+
143
+ return bunCode
144
+ }
145
+
146
+ function runSpawn(cmd: string, args: readonly string[]): Promise<number> {
147
+ return new Promise<number>((res) => {
148
+ const child = spawn(cmd, [...args], { stdio: "inherit" })
149
+ child.on("exit", (code) => res(code ?? 1))
150
+ child.on("error", () => res(1))
151
+ })
152
+ }
153
+
154
+ function toJunit(
155
+ results: ReadonlyArray<{
156
+ suite: string
157
+ name: string
158
+ ok: boolean
159
+ error?: string
160
+ time: number
161
+ }>,
162
+ ): string {
163
+ const bySuite = new Map<string, typeof results>()
164
+ for (const r of results) {
165
+ const list = (bySuite.get(r.suite) ?? []) as typeof results
166
+ bySuite.set(r.suite, [...list, r] as typeof results)
167
+ }
168
+ const suites: string[] = []
169
+ for (const [name, rs] of bySuite) {
170
+ const failures = rs.filter((r) => !r.ok).length
171
+ const cases = rs
172
+ .map((r) => {
173
+ const open = `<testcase classname="${escapeXml(name)}" name="${escapeXml(r.name)}" time="${(r.time / 1000).toFixed(3)}">`
174
+ if (r.ok) return `${open}</testcase>`
175
+ return `${open}<failure message="${escapeXml(r.error ?? "failed")}" /></testcase>`
176
+ })
177
+ .join("\n")
178
+ suites.push(
179
+ ` <testsuite name="${escapeXml(name)}" tests="${rs.length}" failures="${failures}">\n${cases}\n </testsuite>`,
180
+ )
181
+ }
182
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join("\n")}\n</testsuites>\n`
183
+ }
184
+
185
+ function escapeXml(s: string): string {
186
+ return s
187
+ .replace(/&/g, "&amp;")
188
+ .replace(/</g, "&lt;")
189
+ .replace(/>/g, "&gt;")
190
+ .replace(/"/g, "&quot;")
191
+ }
@@ -0,0 +1,19 @@
1
+ import { spawn } from "node:child_process"
2
+ import type { ParsedArgs } from "../args.ts"
3
+
4
+ export async function runTypecheck(args: ParsedArgs): Promise<number> {
5
+ const tsconfig = typeof args.flags.p === "string" ? args.flags.p : "tsconfig.json"
6
+ // Prefer tsgo (TypeScript 7 native preview); fall back to tsc.
7
+ return new Promise((res) => {
8
+ const child = spawn("tsgo", ["--noEmit", "-p", tsconfig], {
9
+ stdio: "inherit",
10
+ })
11
+ child.on("error", () => {
12
+ const fallback = spawn("bunx", ["tsc", "--noEmit", "-p", tsconfig], {
13
+ stdio: "inherit",
14
+ })
15
+ fallback.on("exit", (code) => res(code ?? 1))
16
+ })
17
+ child.on("exit", (code) => res(code ?? 1))
18
+ })
19
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `hyper update [component...]` — bump installed components to the
3
+ * latest registry version.
4
+ *
5
+ * Strategy: for each component listed in the lockfile (or the explicit
6
+ * positional list), fetch the latest manifest from the registry. If the
7
+ * version is newer, re-apply the component (subject to the same drift
8
+ * protection as `hyper add`).
9
+ *
10
+ * Conflicts are reported and skip that component's update — fix them with
11
+ * `hyper diff` + commit, or pass `--force`.
12
+ */
13
+
14
+ import { type ParsedArgs, isJson } from "../args.ts"
15
+ import { readConfig, readLock, writeLock } from "../config/index.ts"
16
+ import { applyComponents, createRegistryClient } from "../registry/index.ts"
17
+
18
+ export async function runUpdate(args: ParsedArgs): Promise<number> {
19
+ const config = await readConfig()
20
+ const client = createRegistryClient({ url: config.registryUrl })
21
+ const lock = await readLock()
22
+
23
+ const installed = Object.keys(lock.components)
24
+ if (installed.length === 0) {
25
+ console.error("no components installed (run `hyper add <component>` first)")
26
+ return 2
27
+ }
28
+
29
+ const targets = args.positional.length > 0 ? args.positional : installed
30
+ const force = args.flags.force === true
31
+ const dryRun = args.flags["dry-run"] === true || args.flags.n === true
32
+
33
+ const candidates: { name: string; from: string; to: string }[] = []
34
+ for (const name of targets) {
35
+ const cur = lock.components[name]
36
+ if (!cur) {
37
+ console.error(`not installed: ${name}`)
38
+ continue
39
+ }
40
+ const latest = await client.getComponent(name).catch(() => null)
41
+ if (!latest) {
42
+ console.error(`fetch failed: ${name}`)
43
+ continue
44
+ }
45
+ if (latest.version !== cur.version) {
46
+ candidates.push({ name, from: cur.version, to: latest.version })
47
+ }
48
+ }
49
+
50
+ if (candidates.length === 0) {
51
+ if (isJson(args.flags)) console.log(JSON.stringify({ updated: [], dryRun }))
52
+ else console.log("everything up-to-date.")
53
+ return 0
54
+ }
55
+
56
+ if (isJson(args.flags) && dryRun) {
57
+ console.log(JSON.stringify({ candidates, dryRun: true }))
58
+ return 0
59
+ }
60
+
61
+ const outcome = await applyComponents(
62
+ candidates.map((c) => c.name),
63
+ {
64
+ cwd: process.cwd(),
65
+ config,
66
+ client,
67
+ lock,
68
+ force,
69
+ dryRun,
70
+ },
71
+ )
72
+
73
+ if (outcome.conflicts.length > 0) {
74
+ console.error("conflicts (use --force, or `hyper diff <component>` first):")
75
+ for (const c of outcome.conflicts) console.error(` ${c.path} (${c.component})`)
76
+ return 1
77
+ }
78
+
79
+ if (!dryRun) await writeLock(outcome.lock)
80
+
81
+ if (isJson(args.flags)) {
82
+ console.log(JSON.stringify({ updated: candidates, written: outcome.written, dryRun }))
83
+ return 0
84
+ }
85
+
86
+ for (const c of candidates) console.log(` ${c.name}: ${c.from} → ${c.to}`)
87
+ console.log(
88
+ `${dryRun ? "(dry-run) " : ""}${candidates.length} component(s) updated, ${outcome.written.length} file(s) ${dryRun ? "would be " : ""}written.`,
89
+ )
90
+ return 0
91
+ }
@@ -0,0 +1,16 @@
1
+ import { type ParsedArgs, isJson } from "../args.ts"
2
+
3
+ export async function runVersion(args: ParsedArgs): Promise<number> {
4
+ const info = {
5
+ hyper: "0.0.0",
6
+ bun: typeof Bun !== "undefined" ? Bun.version : "unknown",
7
+ platform: process.platform,
8
+ arch: process.arch,
9
+ }
10
+ if (isJson(args.flags)) {
11
+ console.log(JSON.stringify(info))
12
+ return 0
13
+ }
14
+ console.log(`hyper ${info.hyper} | bun ${info.bun} | ${info.platform}-${info.arch}`)
15
+ return 0
16
+ }
@@ -0,0 +1,30 @@
1
+ /** Public surface for `hyper.config.json` + `hyper.lock.json` IO. */
2
+
3
+ export type {
4
+ HyperConfig,
5
+ HyperLock,
6
+ LockedComponent,
7
+ LockedFile,
8
+ } from "./types.ts"
9
+ export {
10
+ CONFIG_FILENAME,
11
+ DEFAULT_ALIAS,
12
+ DEFAULT_BASE_DIR,
13
+ DEFAULT_CONFIG,
14
+ DEFAULT_REGISTRY_URL,
15
+ LOCK_FILENAME,
16
+ SCHEMA_URL,
17
+ } from "./types.ts"
18
+ export {
19
+ configExists,
20
+ configPath,
21
+ defaultConfig,
22
+ emptyLock,
23
+ lockPath,
24
+ readConfig,
25
+ readLock,
26
+ writeConfig,
27
+ writeLock,
28
+ } from "./io.ts"
29
+ export { parseJsonc, patchTsConfig, readTsConfig, upsertAlias, writeTsConfig } from "./tsconfig.ts"
30
+ export type { TsConfig } from "./tsconfig.ts"
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Read/write helpers for `hyper.config.json` and `hyper.lock.json`.
3
+ *
4
+ * Behavioral guarantees:
5
+ * - Reading a missing config file returns the defaults (with `registryUrl`
6
+ * possibly overridden by the `HYPER_REGISTRY_URL` env var).
7
+ * - Reading a missing lockfile returns an empty lock — never throws.
8
+ * - Writes are pretty-printed with 2-space indent + trailing newline so the
9
+ * files are diff-friendly when checked into source control.
10
+ */
11
+
12
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
13
+ import { dirname, resolve } from "node:path"
14
+ import {
15
+ CONFIG_FILENAME,
16
+ DEFAULT_ALIAS,
17
+ DEFAULT_BASE_DIR,
18
+ DEFAULT_CONFIG,
19
+ DEFAULT_REGISTRY_URL,
20
+ type HyperConfig,
21
+ type HyperLock,
22
+ LOCK_FILENAME,
23
+ type LockedComponent,
24
+ SCHEMA_URL,
25
+ } from "./types.ts"
26
+
27
+ export interface ConfigIO {
28
+ readonly cwd: string
29
+ }
30
+
31
+ export function configPath(cwd: string = process.cwd()): string {
32
+ return resolve(cwd, CONFIG_FILENAME)
33
+ }
34
+
35
+ export function lockPath(cwd: string = process.cwd()): string {
36
+ return resolve(cwd, LOCK_FILENAME)
37
+ }
38
+
39
+ async function readJsonOrNull<T>(path: string): Promise<T | null> {
40
+ try {
41
+ const buf = await readFile(path, "utf8")
42
+ return JSON.parse(buf) as T
43
+ } catch (err) {
44
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null
45
+ throw err
46
+ }
47
+ }
48
+
49
+ export async function readConfig(cwd: string = process.cwd()): Promise<HyperConfig> {
50
+ const fromDisk = await readJsonOrNull<Partial<HyperConfig>>(configPath(cwd))
51
+ const envUrl = process.env.HYPER_REGISTRY_URL
52
+ return {
53
+ $schema: fromDisk?.$schema ?? SCHEMA_URL,
54
+ registryUrl: envUrl ?? fromDisk?.registryUrl ?? DEFAULT_REGISTRY_URL,
55
+ baseDir: fromDisk?.baseDir ?? DEFAULT_BASE_DIR,
56
+ alias: fromDisk?.alias ?? DEFAULT_ALIAS,
57
+ ...(fromDisk?.tsx !== undefined && { tsx: fromDisk.tsx }),
58
+ ...(fromDisk?.pinVersions !== undefined && { pinVersions: fromDisk.pinVersions }),
59
+ }
60
+ }
61
+
62
+ export async function writeConfig(config: HyperConfig, cwd: string = process.cwd()): Promise<void> {
63
+ const path = configPath(cwd)
64
+ const ordered: HyperConfig = {
65
+ $schema: config.$schema ?? SCHEMA_URL,
66
+ registryUrl: config.registryUrl,
67
+ baseDir: config.baseDir,
68
+ alias: config.alias,
69
+ ...(config.tsx !== undefined && { tsx: config.tsx }),
70
+ ...(config.pinVersions !== undefined && { pinVersions: config.pinVersions }),
71
+ }
72
+ await mkdir(dirname(path), { recursive: true })
73
+ await writeFile(path, `${JSON.stringify(ordered, null, 2)}\n`)
74
+ }
75
+
76
+ export async function configExists(cwd: string = process.cwd()): Promise<boolean> {
77
+ return (await readJsonOrNull(configPath(cwd))) !== null
78
+ }
79
+
80
+ export function emptyLock(registryUrl: string = DEFAULT_REGISTRY_URL): HyperLock {
81
+ return { schema: 1, registryUrl, components: {} }
82
+ }
83
+
84
+ export async function readLock(cwd: string = process.cwd()): Promise<HyperLock> {
85
+ const fromDisk = await readJsonOrNull<HyperLock>(lockPath(cwd))
86
+ if (!fromDisk) return emptyLock()
87
+ return fromDisk
88
+ }
89
+
90
+ export async function writeLock(lock: HyperLock, cwd: string = process.cwd()): Promise<void> {
91
+ const path = lockPath(cwd)
92
+ await mkdir(dirname(path), { recursive: true })
93
+ // Sort keys deterministically so lockfile diffs are minimal.
94
+ const sortedComponents: Record<string, LockedComponent> = {}
95
+ for (const name of Object.keys(lock.components).sort()) {
96
+ sortedComponents[name] = lock.components[name]!
97
+ }
98
+ const ordered: HyperLock = {
99
+ schema: lock.schema,
100
+ registryUrl: lock.registryUrl,
101
+ components: sortedComponents,
102
+ }
103
+ await writeFile(path, `${JSON.stringify(ordered, null, 2)}\n`)
104
+ }
105
+
106
+ /** Default config with optional overrides for `hyper init`. */
107
+ export function defaultConfig(overrides: Partial<HyperConfig> = {}): HyperConfig {
108
+ return {
109
+ ...DEFAULT_CONFIG,
110
+ ...overrides,
111
+ }
112
+ }