@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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component applier.
|
|
3
|
+
*
|
|
4
|
+
* Takes a fetched `RegistryComponent`, the user's `HyperConfig`, and writes
|
|
5
|
+
* the files into their repo. Handles:
|
|
6
|
+
*
|
|
7
|
+
* - recursive `registryDeps` resolution (depth-first, deduplicated)
|
|
8
|
+
* - import rewriting per the user's alias
|
|
9
|
+
* - sha256-based drift detection: refuse to overwrite locally-modified
|
|
10
|
+
* files unless `--force` is set
|
|
11
|
+
* - atomic-ish lockfile updates: in-memory mutation of the lock object,
|
|
12
|
+
* a single write at the end
|
|
13
|
+
* - peer-dependency reporting (returned to the caller)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
|
|
17
|
+
import { dirname, resolve } from "node:path"
|
|
18
|
+
import type { HyperConfig, HyperLock, LockedComponent } from "../config/types.ts"
|
|
19
|
+
import type { RegistryClient } from "./client.ts"
|
|
20
|
+
import { mergeEnvFile } from "./env-writer.ts"
|
|
21
|
+
import { resolveTarget, rewriteFile } from "./rewrite.ts"
|
|
22
|
+
import type { RegistryComponent, RegistryFile } from "./types.ts"
|
|
23
|
+
|
|
24
|
+
export interface ApplyOptions {
|
|
25
|
+
readonly cwd: string
|
|
26
|
+
readonly config: HyperConfig
|
|
27
|
+
readonly client: RegistryClient
|
|
28
|
+
readonly lock: HyperLock
|
|
29
|
+
readonly force: boolean
|
|
30
|
+
readonly dryRun: boolean
|
|
31
|
+
/** Pretend each component is at this version (snapshot/CI use case). */
|
|
32
|
+
readonly version?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApplyOutcome {
|
|
36
|
+
/** Files written or that would have been written (dry run). */
|
|
37
|
+
readonly written: readonly { path: string; component: string; reason: "new" | "updated" }[]
|
|
38
|
+
/** Files unchanged (already match the registry). */
|
|
39
|
+
readonly unchanged: readonly { path: string; component: string }[]
|
|
40
|
+
/** Files that conflict (local-modified, no --force). */
|
|
41
|
+
readonly conflicts: readonly { path: string; component: string }[]
|
|
42
|
+
/** Components touched, in install order (deps first). */
|
|
43
|
+
readonly components: readonly RegistryComponent[]
|
|
44
|
+
/** Updated lock — caller persists with `writeLock`. */
|
|
45
|
+
readonly lock: HyperLock
|
|
46
|
+
/** Peer deps surfaced after install. */
|
|
47
|
+
readonly peerDeps: Readonly<Record<string, string>>
|
|
48
|
+
readonly optionalPeerDeps: Readonly<Record<string, string>>
|
|
49
|
+
/** Warnings raised by `resolveTarget` (unknown placeholders). */
|
|
50
|
+
readonly warnings: readonly string[]
|
|
51
|
+
/** Env-var changes applied to `.env.local`. */
|
|
52
|
+
readonly envVars: {
|
|
53
|
+
readonly path: string
|
|
54
|
+
readonly added: readonly string[]
|
|
55
|
+
readonly preserved: readonly string[]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the full transitive dep set for a list of root components.
|
|
61
|
+
* Returns components in topological order (deps before dependents).
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveDeps(
|
|
64
|
+
roots: readonly string[],
|
|
65
|
+
client: RegistryClient,
|
|
66
|
+
): Promise<RegistryComponent[]> {
|
|
67
|
+
const visited = new Map<string, RegistryComponent>()
|
|
68
|
+
const order: RegistryComponent[] = []
|
|
69
|
+
const visiting = new Set<string>()
|
|
70
|
+
|
|
71
|
+
const visit = async (name: string): Promise<void> => {
|
|
72
|
+
if (visited.has(name)) return
|
|
73
|
+
if (visiting.has(name)) {
|
|
74
|
+
throw new Error(`registry cycle detected at ${name}`)
|
|
75
|
+
}
|
|
76
|
+
visiting.add(name)
|
|
77
|
+
const c = await client.getComponent(name)
|
|
78
|
+
for (const d of c.registryDeps) await visit(d)
|
|
79
|
+
visiting.delete(name)
|
|
80
|
+
visited.set(name, c)
|
|
81
|
+
order.push(c)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const r of roots) await visit(r)
|
|
85
|
+
return order
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readIfExists(path: string): Promise<string | null> {
|
|
89
|
+
try {
|
|
90
|
+
return await readFile(path, "utf8")
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function sha256(s: string): Promise<string> {
|
|
97
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s))
|
|
98
|
+
return Array.from(new Uint8Array(buf))
|
|
99
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
100
|
+
.join("")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
104
|
+
try {
|
|
105
|
+
await stat(p)
|
|
106
|
+
return true
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Apply (or simulate applying) a list of root components.
|
|
114
|
+
*
|
|
115
|
+
* The roots come from the user (`hyper add cors auth-jwt` → `["cors", "auth-jwt"]`).
|
|
116
|
+
* Their transitive deps are resolved automatically.
|
|
117
|
+
*/
|
|
118
|
+
export async function applyComponents(
|
|
119
|
+
roots: readonly string[],
|
|
120
|
+
opts: ApplyOptions,
|
|
121
|
+
): Promise<ApplyOutcome> {
|
|
122
|
+
const components = await resolveDeps(roots, opts.client)
|
|
123
|
+
const subpathsByComponent = new Map(components.map((c) => [c.name, c.subpaths]))
|
|
124
|
+
|
|
125
|
+
const written: { path: string; component: string; reason: "new" | "updated" }[] = []
|
|
126
|
+
const unchanged: { path: string; component: string }[] = []
|
|
127
|
+
const conflicts: { path: string; component: string }[] = []
|
|
128
|
+
const peerDeps: Record<string, string> = {}
|
|
129
|
+
const optionalPeerDeps: Record<string, string> = {}
|
|
130
|
+
const warnings: string[] = []
|
|
131
|
+
|
|
132
|
+
const componentsByName: Record<string, LockedComponent> = { ...opts.lock.components }
|
|
133
|
+
|
|
134
|
+
for (const c of components) {
|
|
135
|
+
Object.assign(peerDeps, c.peerDeps)
|
|
136
|
+
Object.assign(optionalPeerDeps, c.optionalPeerDeps)
|
|
137
|
+
|
|
138
|
+
const lockedFiles: { path: string; sha256: string }[] = []
|
|
139
|
+
|
|
140
|
+
for (const f of c.files) {
|
|
141
|
+
const resolved = resolveTarget(f, c.name, opts.config.baseDir)
|
|
142
|
+
const relPath = resolved.relPath
|
|
143
|
+
if (resolved.warning) warnings.push(`${c.name}: ${resolved.warning}`)
|
|
144
|
+
const abs = resolve(opts.cwd, relPath)
|
|
145
|
+
|
|
146
|
+
const rewritten = resolved.rewriteImports
|
|
147
|
+
? rewriteFile(f.contents, {
|
|
148
|
+
alias: opts.config.alias,
|
|
149
|
+
targetPath: relPath,
|
|
150
|
+
baseDir: opts.config.baseDir,
|
|
151
|
+
subpathsByComponent,
|
|
152
|
+
})
|
|
153
|
+
: f.contents
|
|
154
|
+
const newHash = await sha256(rewritten)
|
|
155
|
+
|
|
156
|
+
const existing = await readIfExists(abs)
|
|
157
|
+
if (existing !== null) {
|
|
158
|
+
const existingHash = await sha256(existing)
|
|
159
|
+
if (existingHash === newHash) {
|
|
160
|
+
unchanged.push({ path: relPath, component: c.name })
|
|
161
|
+
lockedFiles.push({ path: relPath, sha256: newHash })
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
// The file exists and differs.
|
|
165
|
+
const lockedHash = opts.lock.components[c.name]?.files.find(
|
|
166
|
+
(lf) => lf.path === relPath,
|
|
167
|
+
)?.sha256
|
|
168
|
+
const isUpdate = lockedHash === existingHash
|
|
169
|
+
// - isUpdate: matches the lockfile → user hasn't touched it → safe to overwrite as an update.
|
|
170
|
+
// - else: user-edited file → conflict unless --force.
|
|
171
|
+
if (!isUpdate && !opts.force) {
|
|
172
|
+
conflicts.push({ path: relPath, component: c.name })
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (!opts.dryRun) {
|
|
176
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
177
|
+
await writeFile(abs, rewritten)
|
|
178
|
+
}
|
|
179
|
+
written.push({ path: relPath, component: c.name, reason: "updated" })
|
|
180
|
+
lockedFiles.push({ path: relPath, sha256: newHash })
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Net-new file.
|
|
185
|
+
if (!opts.dryRun) {
|
|
186
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
187
|
+
await writeFile(abs, rewritten)
|
|
188
|
+
}
|
|
189
|
+
written.push({ path: relPath, component: c.name, reason: "new" })
|
|
190
|
+
lockedFiles.push({ path: relPath, sha256: newHash })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Only update the lock entry if no conflicts blocked installs for this component.
|
|
194
|
+
const componentConflicts = conflicts.filter((x) => x.component === c.name)
|
|
195
|
+
if (componentConflicts.length === 0) {
|
|
196
|
+
const entry: LockedComponent = {
|
|
197
|
+
version: opts.version ?? c.version,
|
|
198
|
+
installedAt: new Date().toISOString(),
|
|
199
|
+
alias: opts.config.alias,
|
|
200
|
+
files: lockedFiles.sort((a, b) => a.path.localeCompare(b.path)),
|
|
201
|
+
}
|
|
202
|
+
componentsByName[c.name] = entry
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const lock: HyperLock = {
|
|
207
|
+
schema: 1,
|
|
208
|
+
registryUrl: opts.config.registryUrl,
|
|
209
|
+
components: componentsByName,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Collect env vars across the install. First component to declare a key
|
|
213
|
+
// wins; later components see it as "preserved" (idempotent re-runs).
|
|
214
|
+
// Components with conflicts are skipped so a half-applied install doesn't
|
|
215
|
+
// leak secrets.
|
|
216
|
+
const conflictedNames = new Set(conflicts.map((x) => x.component))
|
|
217
|
+
const envVarsToAdd: Record<string, string> = {}
|
|
218
|
+
for (const c of components) {
|
|
219
|
+
if (!c.envVars || conflictedNames.has(c.name)) continue
|
|
220
|
+
for (const [k, v] of Object.entries(c.envVars)) {
|
|
221
|
+
if (!(k in envVarsToAdd)) envVarsToAdd[k] = v
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const envPath = ".env.local"
|
|
225
|
+
const envSummary = await applyEnvVars(opts.cwd, envPath, envVarsToAdd, opts.dryRun)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
written,
|
|
229
|
+
unchanged,
|
|
230
|
+
conflicts,
|
|
231
|
+
components,
|
|
232
|
+
lock,
|
|
233
|
+
peerDeps,
|
|
234
|
+
optionalPeerDeps,
|
|
235
|
+
warnings,
|
|
236
|
+
envVars: { path: envPath, ...envSummary },
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function applyEnvVars(
|
|
241
|
+
cwd: string,
|
|
242
|
+
relPath: string,
|
|
243
|
+
vars: Readonly<Record<string, string>>,
|
|
244
|
+
dryRun: boolean,
|
|
245
|
+
): Promise<{ added: readonly string[]; preserved: readonly string[] }> {
|
|
246
|
+
if (Object.keys(vars).length === 0) return { added: [], preserved: [] }
|
|
247
|
+
const abs = resolve(cwd, relPath)
|
|
248
|
+
const existing = (await readIfExists(abs)) ?? ""
|
|
249
|
+
const merge = mergeEnvFile(existing, vars)
|
|
250
|
+
if (!dryRun && merge.added.length > 0) {
|
|
251
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
252
|
+
await writeFile(abs, merge.merged)
|
|
253
|
+
}
|
|
254
|
+
return { added: merge.added, preserved: merge.preserved }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Read the actual on-disk version of a registry file (for `hyper diff`). */
|
|
258
|
+
export async function readLocalFile(
|
|
259
|
+
cwd: string,
|
|
260
|
+
config: HyperConfig,
|
|
261
|
+
componentName: string,
|
|
262
|
+
manifestFile: RegistryFile,
|
|
263
|
+
): Promise<string | null> {
|
|
264
|
+
const relPath = resolveTarget(manifestFile, componentName, config.baseDir).relPath
|
|
265
|
+
const abs = resolve(cwd, relPath)
|
|
266
|
+
if (!(await pathExists(abs))) return null
|
|
267
|
+
return await readFile(abs, "utf8")
|
|
268
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry client.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. fetch `<registryUrl>/r/<name>(@<version>)?.json`
|
|
6
|
+
* 2. on network failure or non-200, fall back to the bundled snapshot
|
|
7
|
+
* (regenerated by the root `postinstall` and the CLI's `prepack` hook)
|
|
8
|
+
* 3. on no match, throw
|
|
9
|
+
*
|
|
10
|
+
* The bundled snapshot keeps `hyper add` working in air-gapped CI and during
|
|
11
|
+
* registry outages. It's meant as a safety net, not a primary path — production
|
|
12
|
+
* installs always hit the network when reachable.
|
|
13
|
+
*
|
|
14
|
+
* The snapshot is loaded lazily because it's gitignored: in a fresh clone
|
|
15
|
+
* before `postinstall` runs, the file may not exist. That's fine — we just
|
|
16
|
+
* skip the fallback in that case.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RegistryComponent, RegistryIndex } from "./types.ts"
|
|
20
|
+
|
|
21
|
+
interface Snapshot {
|
|
22
|
+
readonly index: RegistryIndex
|
|
23
|
+
readonly components: Readonly<Record<string, RegistryComponent>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let snapshotCache: Snapshot | null | undefined
|
|
27
|
+
|
|
28
|
+
async function loadSnapshot(): Promise<Snapshot | null> {
|
|
29
|
+
if (snapshotCache !== undefined) return snapshotCache
|
|
30
|
+
try {
|
|
31
|
+
const mod = (await import("./snapshot.ts")) as {
|
|
32
|
+
SNAPSHOT_INDEX: RegistryIndex
|
|
33
|
+
SNAPSHOT_COMPONENTS: Readonly<Record<string, RegistryComponent>>
|
|
34
|
+
}
|
|
35
|
+
snapshotCache = { index: mod.SNAPSHOT_INDEX, components: mod.SNAPSHOT_COMPONENTS }
|
|
36
|
+
} catch {
|
|
37
|
+
snapshotCache = null
|
|
38
|
+
}
|
|
39
|
+
return snapshotCache
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RegistryClient {
|
|
43
|
+
readonly url: string
|
|
44
|
+
getIndex(): Promise<RegistryIndex>
|
|
45
|
+
getComponent(name: string, version?: string): Promise<RegistryComponent>
|
|
46
|
+
listComponents(): Promise<
|
|
47
|
+
readonly { name: string; version: string; title?: string; description: string }[]
|
|
48
|
+
>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RegistryClientOptions {
|
|
52
|
+
readonly url?: string
|
|
53
|
+
/** Force snapshot-only mode (offline, deterministic CI). */
|
|
54
|
+
readonly offline?: boolean
|
|
55
|
+
/** Throw on snapshot fallback (used by tests + strict CI). */
|
|
56
|
+
readonly noFallback?: boolean
|
|
57
|
+
/** Override fetch for tests. */
|
|
58
|
+
readonly fetch?: typeof fetch
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_URL = "https://hyperjs.ai"
|
|
62
|
+
|
|
63
|
+
export class RegistryError extends Error {
|
|
64
|
+
override readonly name = "RegistryError"
|
|
65
|
+
constructor(
|
|
66
|
+
message: string,
|
|
67
|
+
readonly status?: number,
|
|
68
|
+
) {
|
|
69
|
+
super(message)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createRegistryClient(opts: RegistryClientOptions = {}): RegistryClient {
|
|
74
|
+
const url = (opts.url ?? DEFAULT_URL).replace(/\/+$/, "")
|
|
75
|
+
const offline = opts.offline === true
|
|
76
|
+
const noFallback = opts.noFallback === true
|
|
77
|
+
const fetchFn = opts.fetch ?? globalThis.fetch
|
|
78
|
+
|
|
79
|
+
const tryFetch = async <T>(path: string): Promise<T | null> => {
|
|
80
|
+
if (offline) return null
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetchFn(`${url}${path}`, {
|
|
83
|
+
headers: { accept: "application/json" },
|
|
84
|
+
})
|
|
85
|
+
if (!res.ok) return null
|
|
86
|
+
return (await res.json()) as T
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
url,
|
|
94
|
+
async getIndex() {
|
|
95
|
+
const remote = await tryFetch<RegistryIndex>("/r/index.json")
|
|
96
|
+
if (remote) return remote
|
|
97
|
+
if (noFallback) throw new RegistryError(`failed to fetch ${url}/r/index.json`)
|
|
98
|
+
const snap = await loadSnapshot()
|
|
99
|
+
if (!snap) throw new RegistryError("registry unreachable and no offline snapshot available")
|
|
100
|
+
return snap.index
|
|
101
|
+
},
|
|
102
|
+
async getComponent(name, version) {
|
|
103
|
+
const slug = version ? `${name}@${version}` : name
|
|
104
|
+
const remote = await tryFetch<RegistryComponent>(`/r/${slug}.json`)
|
|
105
|
+
if (remote) return remote
|
|
106
|
+
const snap = await loadSnapshot()
|
|
107
|
+
const cached = snap?.components[name]
|
|
108
|
+
if (!cached) throw new RegistryError(`component not found: ${name}`)
|
|
109
|
+
if (version && cached.version !== version) {
|
|
110
|
+
throw new RegistryError(
|
|
111
|
+
`version mismatch (snapshot has ${cached.version}, requested ${version})`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
if (noFallback) throw new RegistryError(`failed to fetch ${url}/r/${slug}.json`)
|
|
115
|
+
return cached
|
|
116
|
+
},
|
|
117
|
+
async listComponents() {
|
|
118
|
+
const idx = await this.getIndex()
|
|
119
|
+
return idx.components.map((c) => ({
|
|
120
|
+
name: c.name,
|
|
121
|
+
version: c.version,
|
|
122
|
+
description: c.description,
|
|
123
|
+
...(c.title !== undefined && { title: c.title }),
|
|
124
|
+
}))
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env.local` merger for `hyper add`.
|
|
3
|
+
*
|
|
4
|
+
* When a component declares `envVars`, the CLI appends entries to the
|
|
5
|
+
* project's `.env.local`, leaving any existing keys untouched. Re-running
|
|
6
|
+
* `hyper add` is therefore idempotent: secrets are generated exactly once.
|
|
7
|
+
*
|
|
8
|
+
* Two kinds of `${...}` interpolation are resolved LOCALLY (so secrets
|
|
9
|
+
* never leave the user's machine):
|
|
10
|
+
*
|
|
11
|
+
* ${random:hex:N} -> N random bytes encoded as 2N hex chars
|
|
12
|
+
* ${random:base64:N} -> N random bytes encoded as base64
|
|
13
|
+
*
|
|
14
|
+
* Anything else (including unknown `${...}` forms) is written through
|
|
15
|
+
* verbatim. Users can fill those in by hand.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from "node:crypto"
|
|
19
|
+
|
|
20
|
+
export interface EnvMergeResult {
|
|
21
|
+
/** New file contents (unchanged + appended block). */
|
|
22
|
+
readonly merged: string
|
|
23
|
+
/** Keys we added in this run. */
|
|
24
|
+
readonly added: readonly string[]
|
|
25
|
+
/** Keys that were already present and left as-is. */
|
|
26
|
+
readonly preserved: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge a flat record of `KEY -> value-template` into an existing `.env`
|
|
31
|
+
* file body. Existing keys are preserved verbatim; new keys are appended
|
|
32
|
+
* with their `${random:...}` placeholders resolved.
|
|
33
|
+
*
|
|
34
|
+
* Pure function: no I/O, no clock, no env access. Callers handle the
|
|
35
|
+
* file read + write + dry-run gating.
|
|
36
|
+
*/
|
|
37
|
+
export function mergeEnvFile(
|
|
38
|
+
existing: string,
|
|
39
|
+
vars: Readonly<Record<string, string>>,
|
|
40
|
+
): EnvMergeResult {
|
|
41
|
+
const known = parseEnvKeys(existing)
|
|
42
|
+
const added: string[] = []
|
|
43
|
+
const preserved: string[] = []
|
|
44
|
+
const newLines: string[] = []
|
|
45
|
+
|
|
46
|
+
// Stable order: alphabetical by key, so re-runs produce reproducible diffs.
|
|
47
|
+
const keys = Object.keys(vars).sort()
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
if (known.has(key)) {
|
|
50
|
+
preserved.push(key)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const template = vars[key] ?? ""
|
|
54
|
+
const value = resolveInterpolations(template)
|
|
55
|
+
newLines.push(`${key}=${formatValue(value)}`)
|
|
56
|
+
added.push(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (newLines.length === 0) {
|
|
60
|
+
return { merged: existing, added, preserved }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Existing body keeps its content verbatim. We strip *all* trailing
|
|
64
|
+
// newlines, then separate with exactly one blank line before the new
|
|
65
|
+
// block. Pre-empty files just get the block itself.
|
|
66
|
+
const trimmed = existing.replace(/(?:\r?\n)+$/g, "")
|
|
67
|
+
const header = "# Added by `hyper add`"
|
|
68
|
+
const block = `${header}\n${newLines.join("\n")}\n`
|
|
69
|
+
const merged = trimmed === "" ? block : `${trimmed}\n\n${block}`
|
|
70
|
+
return { merged, added, preserved }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse just the keys defined in an `.env` file body. Tolerates blank lines,
|
|
75
|
+
* `# comments`, `export FOO=…` prefixes, and quoted values. We do NOT need
|
|
76
|
+
* to evaluate the values — only know which keys are taken.
|
|
77
|
+
*/
|
|
78
|
+
function parseEnvKeys(body: string): Set<string> {
|
|
79
|
+
const keys = new Set<string>()
|
|
80
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
81
|
+
const line = stripComment(rawLine).trim()
|
|
82
|
+
if (line === "") continue
|
|
83
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)
|
|
84
|
+
if (m?.[1]) keys.add(m[1])
|
|
85
|
+
}
|
|
86
|
+
return keys
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stripComment(line: string): string {
|
|
90
|
+
// A `#` only starts a comment when not inside quotes. Cheap heuristic:
|
|
91
|
+
// scan once, flipping a quote state. Good enough for the limited set of
|
|
92
|
+
// shapes a `.env.local` ever contains.
|
|
93
|
+
let inSingle = false
|
|
94
|
+
let inDouble = false
|
|
95
|
+
for (let i = 0; i < line.length; i++) {
|
|
96
|
+
const ch = line[i]
|
|
97
|
+
if (ch === "\\") {
|
|
98
|
+
i++
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (!inDouble && ch === "'") inSingle = !inSingle
|
|
102
|
+
else if (!inSingle && ch === '"') inDouble = !inDouble
|
|
103
|
+
else if (!inSingle && !inDouble && ch === "#") return line.slice(0, i)
|
|
104
|
+
}
|
|
105
|
+
return line
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const RANDOM_HEX_RE = /^\$\{random:hex:(\d+)\}$/
|
|
109
|
+
const RANDOM_B64_RE = /^\$\{random:base64:(\d+)\}$/
|
|
110
|
+
|
|
111
|
+
function resolveInterpolations(template: string): string {
|
|
112
|
+
// Whole-string interpolation only. Mid-string `${...}` is rare in
|
|
113
|
+
// .env.local conventions and ambiguous to escape; keep the contract small.
|
|
114
|
+
const hex = RANDOM_HEX_RE.exec(template)
|
|
115
|
+
if (hex?.[1]) {
|
|
116
|
+
const n = Math.max(1, Math.min(1024, Number.parseInt(hex[1], 10)))
|
|
117
|
+
return randomBytes(n).toString("hex")
|
|
118
|
+
}
|
|
119
|
+
const b64 = RANDOM_B64_RE.exec(template)
|
|
120
|
+
if (b64?.[1]) {
|
|
121
|
+
const n = Math.max(1, Math.min(1024, Number.parseInt(b64[1], 10)))
|
|
122
|
+
return randomBytes(n).toString("base64")
|
|
123
|
+
}
|
|
124
|
+
return template
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Quote values that need it. Bare values (no whitespace, no `#`, no `$`,
|
|
129
|
+
* etc.) stay unquoted to match the dominant `.env` convention.
|
|
130
|
+
*/
|
|
131
|
+
function formatValue(value: string): string {
|
|
132
|
+
if (value === "") return ""
|
|
133
|
+
if (/^[A-Za-z0-9_./:+\-=]+$/.test(value)) return value
|
|
134
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Public surface for the registry client + applier. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
applyComponents,
|
|
5
|
+
readLocalFile,
|
|
6
|
+
resolveDeps,
|
|
7
|
+
} from "./apply.ts"
|
|
8
|
+
export type { ApplyOptions, ApplyOutcome } from "./apply.ts"
|
|
9
|
+
export { createRegistryClient, RegistryError } from "./client.ts"
|
|
10
|
+
export type { RegistryClient, RegistryClientOptions } from "./client.ts"
|
|
11
|
+
export { resolveTarget, rewriteFile } from "./rewrite.ts"
|
|
12
|
+
export { SNAPSHOT_COMPONENTS, SNAPSHOT_INDEX } from "./snapshot.ts"
|
|
13
|
+
export type {
|
|
14
|
+
RegistryComponent,
|
|
15
|
+
RegistryFile,
|
|
16
|
+
RegistryIndex,
|
|
17
|
+
RegistryIndexEntry,
|
|
18
|
+
} from "./types.ts"
|