@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.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/package.json +40 -0
- package/registry-sources/agent-rules/README.md +12 -0
- package/registry-sources/agent-rules/files/.cursor/rules/hyper.md +178 -0
- package/registry-sources/agent-rules/files/AGENTS.md +64 -0
- package/registry-sources/agent-rules/manifest.json +15 -0
- package/src/__tests__/add.test.ts +125 -0
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/security.test.ts +101 -0
- package/src/args.ts +38 -0
- package/src/bin.ts +77 -0
- package/src/commands/add.ts +232 -0
- package/src/commands/bench.ts +185 -0
- package/src/commands/build.ts +146 -0
- package/src/commands/client.ts +78 -0
- package/src/commands/dev.ts +53 -0
- package/src/commands/diff.ts +80 -0
- package/src/commands/env.ts +92 -0
- package/src/commands/help.ts +42 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/list.ts +46 -0
- package/src/commands/mcp.ts +51 -0
- package/src/commands/openapi.ts +50 -0
- package/src/commands/routes.ts +45 -0
- package/src/commands/security.ts +233 -0
- package/src/commands/test.ts +191 -0
- package/src/commands/typecheck.ts +19 -0
- package/src/commands/update.ts +91 -0
- package/src/commands/version.ts +16 -0
- package/src/config/index.ts +30 -0
- package/src/config/io.ts +112 -0
- package/src/config/tsconfig.ts +138 -0
- package/src/config/types.ts +63 -0
- package/src/entry.ts +42 -0
- package/src/index.ts +57 -0
- package/src/load-app.ts +89 -0
- package/src/registry/__tests__/env-writer.test.ts +83 -0
- package/src/registry/apply.ts +268 -0
- package/src/registry/client.ts +127 -0
- package/src/registry/env-writer.ts +135 -0
- package/src/registry/index.ts +18 -0
- package/src/registry/rewrite.ts +177 -0
- package/src/registry/snapshot.ts +1018 -0
- package/src/registry/types.ts +62 -0
- 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, "&")
|
|
188
|
+
.replace(/</g, "<")
|
|
189
|
+
.replace(/>/g, ">")
|
|
190
|
+
.replace(/"/g, """)
|
|
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"
|
package/src/config/io.ts
ADDED
|
@@ -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
|
+
}
|