@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,138 @@
1
+ /**
2
+ * Minimal `tsconfig.json` reader/writer aware of JSONC (line + block comments
3
+ * and trailing commas) — many bun/node projects use a JSONC tsconfig.
4
+ *
5
+ * We only need to:
6
+ * - read `compilerOptions.paths`
7
+ * - upsert `<alias>/*` mappings under `paths`
8
+ * - write back with comments and trailing commas preserved
9
+ *
10
+ * Strategy: read as JSONC into a JS object, mutate, write back with
11
+ * `JSON.stringify(_, null, 2)`. Comments/trailing commas are stripped on
12
+ * write — that's a deliberate trade-off (the user can re-add them) since
13
+ * preserving them losslessly would require a full CST parser.
14
+ */
15
+
16
+ import { readFile, writeFile } from "node:fs/promises"
17
+ import { resolve } from "node:path"
18
+
19
+ export interface TsConfig {
20
+ extends?: string
21
+ compilerOptions?: {
22
+ baseUrl?: string
23
+ paths?: Record<string, string[]>
24
+ [k: string]: unknown
25
+ }
26
+ include?: string[]
27
+ exclude?: string[]
28
+ files?: string[]
29
+ [k: string]: unknown
30
+ }
31
+
32
+ /** Parse JSONC by stripping `//` line comments, `/* *\/` block comments, and trailing commas. */
33
+ export function parseJsonc(input: string): unknown {
34
+ let s = input
35
+ // Strip line comments. Beware of "//" inside strings; we handle that by not
36
+ // touching characters inside double-quoted spans.
37
+ let out = ""
38
+ let i = 0
39
+ while (i < s.length) {
40
+ const c = s[i]
41
+ if (c === '"') {
42
+ // Copy entire string literal (incl. escape sequences) verbatim.
43
+ const start = i
44
+ i++
45
+ while (i < s.length) {
46
+ if (s[i] === "\\") {
47
+ i += 2
48
+ continue
49
+ }
50
+ if (s[i] === '"') {
51
+ i++
52
+ break
53
+ }
54
+ i++
55
+ }
56
+ out += s.slice(start, i)
57
+ continue
58
+ }
59
+ if (c === "/" && s[i + 1] === "/") {
60
+ while (i < s.length && s[i] !== "\n") i++
61
+ continue
62
+ }
63
+ if (c === "/" && s[i + 1] === "*") {
64
+ i += 2
65
+ while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) i++
66
+ i += 2
67
+ continue
68
+ }
69
+ out += c
70
+ i++
71
+ }
72
+ s = out
73
+ // Strip trailing commas before `}` or `]`.
74
+ s = s.replace(/,(\s*[}\]])/g, "$1")
75
+ return JSON.parse(s) as unknown
76
+ }
77
+
78
+ export async function readTsConfig(cwd: string = process.cwd()): Promise<{
79
+ raw: string
80
+ parsed: TsConfig
81
+ } | null> {
82
+ const path = resolve(cwd, "tsconfig.json")
83
+ let raw: string
84
+ try {
85
+ raw = await readFile(path, "utf8")
86
+ } catch (err) {
87
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null
88
+ throw err
89
+ }
90
+ return { raw, parsed: parseJsonc(raw) as TsConfig }
91
+ }
92
+
93
+ export async function writeTsConfig(config: TsConfig, cwd: string = process.cwd()): Promise<void> {
94
+ const path = resolve(cwd, "tsconfig.json")
95
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`)
96
+ }
97
+
98
+ /**
99
+ * Add `"<alias>/*": ["./<baseDir>/*", "./<baseDir>/*\/index.ts"]` to
100
+ * `compilerOptions.paths`. Idempotent: if the mapping already exists with
101
+ * the same value, returns the same config unchanged.
102
+ */
103
+ export function upsertAlias(
104
+ config: TsConfig,
105
+ alias: string,
106
+ baseDir: string,
107
+ ): { config: TsConfig; changed: boolean } {
108
+ if (alias === "relative") return { config, changed: false }
109
+ const key = `${alias}/*`
110
+ const dir = baseDir.replace(/\/+$/, "")
111
+ const value = [`./${dir}/*`, `./${dir}/*/index.ts`]
112
+ const existing = config.compilerOptions?.paths?.[key]
113
+ if (existing && JSON.stringify(existing) === JSON.stringify(value)) {
114
+ return { config, changed: false }
115
+ }
116
+ const next: TsConfig = {
117
+ ...config,
118
+ compilerOptions: {
119
+ ...config.compilerOptions,
120
+ paths: { ...config.compilerOptions?.paths, [key]: value },
121
+ },
122
+ }
123
+ return { config: next, changed: true }
124
+ }
125
+
126
+ /** Convenience: read → upsert → write, only writing if something changed. */
127
+ export async function patchTsConfig(
128
+ alias: string,
129
+ baseDir: string,
130
+ cwd: string = process.cwd(),
131
+ ): Promise<"missing" | "unchanged" | "patched"> {
132
+ const cur = await readTsConfig(cwd)
133
+ if (!cur) return "missing"
134
+ const { config: next, changed } = upsertAlias(cur.parsed, alias, baseDir)
135
+ if (!changed) return "unchanged"
136
+ await writeTsConfig(next, cwd)
137
+ return "patched"
138
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shapes for `hyper.config.json` (project-level config) and
3
+ * `hyper.lock.json` (per-component install state).
4
+ *
5
+ * Both files live at the project root next to `package.json`.
6
+ */
7
+
8
+ export interface HyperConfig {
9
+ /** JSON Schema URL — auto-injected, makes editor tooling work. */
10
+ readonly $schema?: string
11
+ /** Base URL of the registry. Default: `https://hyperjs.ai`. */
12
+ readonly registryUrl: string
13
+ /** Where components are written, relative to project root. Default: `src/hyper`. */
14
+ readonly baseDir: string
15
+ /**
16
+ * Import alias for installed components. The string `"relative"` means
17
+ * the CLI will rewrite all `@hyper/*` imports to relative paths instead.
18
+ * Default: `@hyper`.
19
+ */
20
+ readonly alias: string
21
+ /** Reserved for future TSX/JSX-aware components. */
22
+ readonly tsx?: boolean
23
+ /** Optional: pin every install to this exact registry version (CI use case). */
24
+ readonly pinVersions?: boolean
25
+ }
26
+
27
+ export interface LockedFile {
28
+ /** Path relative to project root (already resolved through `baseDir`). */
29
+ readonly path: string
30
+ /** SHA-256 of file contents AS WRITTEN (post import-rewrite). */
31
+ readonly sha256: string
32
+ }
33
+
34
+ export interface LockedComponent {
35
+ readonly version: string
36
+ /** When this component was installed/updated (ISO-8601). */
37
+ readonly installedAt: string
38
+ /** Which alias was used at install time (may differ from current config). */
39
+ readonly alias: string
40
+ /** Files installed by this component, in stable sorted order. */
41
+ readonly files: readonly LockedFile[]
42
+ }
43
+
44
+ export interface HyperLock {
45
+ readonly schema: 1
46
+ readonly registryUrl: string
47
+ readonly components: Readonly<Record<string, LockedComponent>>
48
+ }
49
+
50
+ export const CONFIG_FILENAME = "hyper.config.json"
51
+ export const LOCK_FILENAME = "hyper.lock.json"
52
+
53
+ export const DEFAULT_REGISTRY_URL = "https://hyperjs.ai"
54
+ export const DEFAULT_BASE_DIR = "src/hyper"
55
+ export const DEFAULT_ALIAS = "@hyper"
56
+ export const SCHEMA_URL = "https://hyperjs.ai/schema.json"
57
+
58
+ export const DEFAULT_CONFIG: HyperConfig = {
59
+ $schema: SCHEMA_URL,
60
+ registryUrl: DEFAULT_REGISTRY_URL,
61
+ baseDir: DEFAULT_BASE_DIR,
62
+ alias: DEFAULT_ALIAS,
63
+ }
package/src/entry.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Entry-point resolution. Default order:
3
+ * 1) The positional arg, if provided.
4
+ * 2) src/app.ts
5
+ * 3) app.ts
6
+ * 4) src/index.ts
7
+ * 5) index.ts
8
+ */
9
+
10
+ import { resolve } from "node:path"
11
+
12
+ const CANDIDATES = ["src/app.ts", "app.ts", "src/index.ts", "index.ts"]
13
+
14
+ export async function resolveEntry(
15
+ positional: readonly string[],
16
+ cwd: string = process.cwd(),
17
+ ): Promise<string | null> {
18
+ const override = positional[0]
19
+ if (override) {
20
+ const p = resolve(cwd, override)
21
+ if (await exists(p)) return p
22
+ return null
23
+ }
24
+ for (const c of CANDIDATES) {
25
+ const p = resolve(cwd, c)
26
+ if (await exists(p)) return p
27
+ }
28
+ return null
29
+ }
30
+
31
+ async function exists(path: string): Promise<boolean> {
32
+ if (typeof Bun !== "undefined") {
33
+ return Bun.file(path).exists()
34
+ }
35
+ try {
36
+ const { access } = await import("node:fs/promises")
37
+ await access(path)
38
+ return true
39
+ } catch {
40
+ return false
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @usehyper/cli — programmatic entry for embedding the CLI in scripts/CI.
3
+ *
4
+ * The CLI is also installable as a binary via `bun add -D @usehyper/cli`
5
+ * (exposes the `hyper` executable). Most users won't import this module
6
+ * directly.
7
+ */
8
+
9
+ export { parseArgs } from "./args.ts"
10
+ export type { ParsedArgs } from "./args.ts"
11
+
12
+ export { runAdd } from "./commands/add.ts"
13
+ export { runBuild } from "./commands/build.ts"
14
+ export { runDev } from "./commands/dev.ts"
15
+ export { runDiff } from "./commands/diff.ts"
16
+ export { runEnvCheck } from "./commands/env.ts"
17
+ export { runHelp } from "./commands/help.ts"
18
+ export { runInit } from "./commands/init.ts"
19
+ export { runList } from "./commands/list.ts"
20
+ export { runRoutes } from "./commands/routes.ts"
21
+ export { runTypecheck } from "./commands/typecheck.ts"
22
+ export { runUpdate } from "./commands/update.ts"
23
+ export { runVersion } from "./commands/version.ts"
24
+
25
+ export { resolveEntry } from "./entry.ts"
26
+ export { TEMPLATES } from "./templates.ts"
27
+
28
+ // Config
29
+ export type { HyperConfig, HyperLock } from "./config/index.ts"
30
+ export {
31
+ CONFIG_FILENAME,
32
+ DEFAULT_ALIAS,
33
+ DEFAULT_BASE_DIR,
34
+ DEFAULT_REGISTRY_URL,
35
+ LOCK_FILENAME,
36
+ defaultConfig,
37
+ readConfig,
38
+ readLock,
39
+ writeConfig,
40
+ writeLock,
41
+ } from "./config/index.ts"
42
+
43
+ // Registry
44
+ export {
45
+ applyComponents,
46
+ createRegistryClient,
47
+ RegistryError,
48
+ resolveDeps,
49
+ } from "./registry/index.ts"
50
+ export type {
51
+ ApplyOptions,
52
+ ApplyOutcome,
53
+ RegistryClient,
54
+ RegistryComponent,
55
+ RegistryFile,
56
+ RegistryIndex,
57
+ } from "./registry/index.ts"
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Load a built HyperApp from a source path.
3
+ *
4
+ * Before importing we set `HYPER_SKIP_LISTEN=1` so user modules that call
5
+ * `.listen()` on their default export don't actually boot a socket during
6
+ * CLI introspection. The chain still runs through `.build()` so everything
7
+ * downstream (openapi, routes, mcp, bench) works.
8
+ *
9
+ * The user module can export any of:
10
+ * - a `Hyper` instance (preferred — lowered via `.build()`)
11
+ * - a `HyperApp` (the legacy `app({...})` shape)
12
+ * - a `default` or named `app` export of either shape
13
+ *
14
+ * We deliberately duck-type rather than `import { Hyper }` so the CLI has
15
+ * no hard runtime dependency on `@hyper/core` — the user's local copy
16
+ * lives at `<baseDir>/core/` and is reached via tsconfig path resolution
17
+ * from their entry file.
18
+ */
19
+
20
+ import { resolve } from "node:path"
21
+ import { readConfig } from "./config/index.ts"
22
+
23
+ /**
24
+ * Type-only stand-in. The real shape comes from the user's `@hyper/core`.
25
+ * We keep the import as `import type` so it's erased by Bun at runtime.
26
+ */
27
+ import type { HyperApp } from "@hyper/core"
28
+
29
+ interface DuckHyper {
30
+ build(): HyperApp
31
+ }
32
+
33
+ function isDuckHyper(x: unknown): x is DuckHyper {
34
+ return (
35
+ typeof x === "object" &&
36
+ x !== null &&
37
+ "build" in x &&
38
+ typeof (x as { build: unknown }).build === "function"
39
+ )
40
+ }
41
+
42
+ function isDuckHyperApp(x: unknown): x is HyperApp {
43
+ return (
44
+ typeof x === "object" &&
45
+ x !== null &&
46
+ "fetch" in x &&
47
+ typeof (x as { fetch: unknown }).fetch === "function" &&
48
+ "routeList" in x &&
49
+ Array.isArray((x as { routeList: unknown }).routeList)
50
+ )
51
+ }
52
+
53
+ export async function loadApp(entry: string): Promise<HyperApp | null> {
54
+ process.env.HYPER_SKIP_LISTEN = "1"
55
+ const mod = (await import(entry)) as {
56
+ default?: unknown
57
+ app?: unknown
58
+ }
59
+ const raw = mod.default ?? mod.app ?? null
60
+ if (raw === null) return null
61
+ if (isDuckHyper(raw)) return raw.build()
62
+ if (isDuckHyperApp(raw)) return raw
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * Resolve a Hyper component module from the user's local install (preferred)
68
+ * or fall back to a workspace-resolved import (monorepo dev case).
69
+ *
70
+ * `name` is a registry component name (`"openapi"`, `"mcp"`, `"client"`, …).
71
+ *
72
+ * Resolution order:
73
+ * 1. `<cwd>/<baseDir>/<name>/index.ts` (user's installed copy)
74
+ * 2. `@hyper/<name>` (workspace dev, or user's tsconfig alias)
75
+ *
76
+ * Returns `null` if neither works.
77
+ */
78
+ export async function loadComponentModule<T>(name: string): Promise<T | null> {
79
+ const config = await readConfig()
80
+ const local = resolve(process.cwd(), config.baseDir, name, "index.ts")
81
+ for (const spec of [local, `@hyper/${name}`]) {
82
+ try {
83
+ return (await import(spec)) as T
84
+ } catch {
85
+ // try next
86
+ }
87
+ }
88
+ return null
89
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mergeEnvFile } from "../env-writer.ts"
3
+
4
+ describe("mergeEnvFile", () => {
5
+ test("appends new keys to empty file", () => {
6
+ const r = mergeEnvFile("", { FOO: "bar" })
7
+ expect(r.added).toEqual(["FOO"])
8
+ expect(r.preserved).toEqual([])
9
+ expect(r.merged).toContain("FOO=bar\n")
10
+ expect(r.merged.startsWith("# Added by `hyper add`")).toBe(true)
11
+ })
12
+
13
+ test("preserves existing keys verbatim", () => {
14
+ const existing = "FOO=existing-value\n"
15
+ const r = mergeEnvFile(existing, { FOO: "new-value" })
16
+ expect(r.added).toEqual([])
17
+ expect(r.preserved).toEqual(["FOO"])
18
+ expect(r.merged).toBe(existing)
19
+ })
20
+
21
+ test("mixed: keep existing, append missing", () => {
22
+ const existing = "FOO=keep-me\n"
23
+ const r = mergeEnvFile(existing, { FOO: "ignored", BAR: "added" })
24
+ expect(r.added).toEqual(["BAR"])
25
+ expect(r.preserved).toEqual(["FOO"])
26
+ expect(r.merged).toContain("FOO=keep-me\n")
27
+ expect(r.merged).toContain("BAR=added\n")
28
+ })
29
+
30
+ test("resolves ${random:hex:N} into 2N hex chars", () => {
31
+ const r = mergeEnvFile("", { JWT_SECRET: "${random:hex:32}" })
32
+ const m = /JWT_SECRET=([a-f0-9]+)/.exec(r.merged)
33
+ expect(m).not.toBeNull()
34
+ expect(m?.[1]?.length).toBe(64)
35
+ })
36
+
37
+ test("resolves ${random:base64:N}", () => {
38
+ const r = mergeEnvFile("", { S: "${random:base64:24}" })
39
+ const m = /S=(.+)/.exec(r.merged)
40
+ expect(m).not.toBeNull()
41
+ expect(m?.[1]).toMatch(/^[A-Za-z0-9+/=]+$/)
42
+ })
43
+
44
+ test("ignores ${...} forms it doesn't recognize", () => {
45
+ const r = mergeEnvFile("", { X: "${env:OTHER}" })
46
+ expect(r.merged).toContain('X="${env:OTHER}"\n')
47
+ })
48
+
49
+ test("respects export prefix when checking existing keys", () => {
50
+ const r = mergeEnvFile("export FOO=1\n", { FOO: "x" })
51
+ expect(r.preserved).toEqual(["FOO"])
52
+ expect(r.added).toEqual([])
53
+ })
54
+
55
+ test("skips comments and quoted hashes when reading keys", () => {
56
+ const existing = `# FOO=hidden
57
+ BAR="value with # hash"
58
+ `
59
+ const r = mergeEnvFile(existing, { FOO: "added", BAR: "ignored" })
60
+ expect(r.added).toEqual(["FOO"])
61
+ expect(r.preserved).toEqual(["BAR"])
62
+ })
63
+
64
+ test("idempotent: re-running yields the same file", () => {
65
+ const r1 = mergeEnvFile("", { A: "1", B: "2" })
66
+ const r2 = mergeEnvFile(r1.merged, { A: "x", B: "y" })
67
+ expect(r2.merged).toBe(r1.merged)
68
+ expect(r2.added).toEqual([])
69
+ expect(r2.preserved.sort()).toEqual(["A", "B"])
70
+ })
71
+
72
+ test("alphabetical key order in appended block", () => {
73
+ const r = mergeEnvFile("", { ZED: "z", ALPHA: "a", MID: "m" })
74
+ const lines = r.merged.split("\n").filter((l) => l.includes("="))
75
+ expect(lines).toEqual(["ALPHA=a", "MID=m", "ZED=z"])
76
+ })
77
+
78
+ test("preserves trailing-newline normalization", () => {
79
+ const r = mergeEnvFile("EXIST=1\n\n\n", { NEW: "v" })
80
+ expect(r.merged).toContain("EXIST=1\n\n# Added by `hyper add`")
81
+ expect(r.merged.endsWith("\n")).toBe(true)
82
+ })
83
+ })