@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,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"
|
package/src/load-app.ts
ADDED
|
@@ -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
|
+
})
|