ossclip 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/src/edit.ts ADDED
@@ -0,0 +1,465 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createReadStream, existsSync, statSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
5
+ import { homedir } from "node:os";
6
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { z } from "zod/v4";
9
+ import { OverrideDocSchema, emptyOverrideDoc } from "@ossclip/core";
10
+
11
+ /**
12
+ * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
13
+ * package for an npm install (prepack copies the Vite build there so
14
+ * `ossclip edit` works with no build step), else the monorepo sibling's
15
+ * `dist/` for a clone. Null when neither exists — callers own the loud
16
+ * error, and `ossclip doctor` reports it as a check.
17
+ */
18
+ export function resolveEditorPageDir(): string | null {
19
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
20
+ for (const candidate of [join(pkgRoot, "editor-dist"), join(pkgRoot, "../editor/dist")]) {
21
+ if (existsSync(join(candidate, "index.html"))) return candidate;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * The editor's backend: a handful of endpoints and a static file server,
28
+ * deliberately dependency-free. It reads the workdir a `produce` run left
29
+ * behind and owns exactly one file — `overrides.json`. The render endpoint
30
+ * replays the invocation `produce` recorded, never anything a client sent.
31
+ */
32
+
33
+ /**
34
+ * The invocation `produce` recorded into the workdir (R11 Task 4.1).
35
+ * Validated on read — it's a file on disk like any other user data — and the
36
+ * ONLY thing `/api/render` will ever spawn: this server binds locally, but
37
+ * accepting a client-supplied command would make it a remote shell.
38
+ */
39
+ const CommandSchema = z.object({
40
+ execPath: z.string(),
41
+ execArgv: z.array(z.string()).default([]),
42
+ script: z.string(),
43
+ args: z.array(z.string()),
44
+ cwd: z.string(),
45
+ out: z.string().optional(),
46
+ });
47
+
48
+ /** Ring-buffer cap for captured render output. */
49
+ const RENDER_LOG_LINES = 200;
50
+ export interface EditServer {
51
+ url: string;
52
+ close: () => void;
53
+ }
54
+
55
+ /** A workdir is exactly "a directory a produce run wrote its props into". */
56
+ const isWorkdir = (dir: string): boolean => existsSync(join(dir, "render-props.json"));
57
+
58
+ /** Where the recent-projects list lives — beside config.json. Overridable
59
+ * for tests, which must not write into the runner's real home. */
60
+ const recentsPath = (dir?: string): string =>
61
+ join(dir ?? join(homedir(), ".ossclip"), "recent-projects.json");
62
+
63
+ /** Recent workdirs, newest first, invalid entries filtered at READ time —
64
+ * a deleted workdir silently drops off the list instead of 404ing a click. */
65
+ export async function readRecentProjects(recentDir?: string): Promise<string[]> {
66
+ try {
67
+ const parsed: unknown = JSON.parse(await readFile(recentsPath(recentDir), "utf8"));
68
+ if (!Array.isArray(parsed)) return [];
69
+ return parsed.filter((p): p is string => typeof p === "string" && isWorkdir(p));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Remember a workdir (R17 §83) — called by `produce` after a successful run
77
+ * and by the edit server on every open, so the picker's "recent" list is the
78
+ * projects you actually touched. Best-effort: a read-only home must never
79
+ * fail a produce run over a convenience file.
80
+ */
81
+ export async function recordRecentProject(dir: string, recentDir?: string): Promise<void> {
82
+ try {
83
+ const path = recentsPath(recentDir);
84
+ await mkdir(dirname(path), { recursive: true });
85
+ const existing = await readRecentProjects(recentDir);
86
+ const next = [resolve(dir), ...existing.filter((p) => p !== resolve(dir))].slice(0, 12);
87
+ await writeFile(path, JSON.stringify(next, null, 2));
88
+ } catch {
89
+ // Convenience only.
90
+ }
91
+ }
92
+
93
+ const MIME: Record<string, string> = {
94
+ ".html": "text/html",
95
+ ".js": "text/javascript",
96
+ ".css": "text/css",
97
+ ".json": "application/json",
98
+ ".mp4": "video/mp4",
99
+ };
100
+
101
+ /**
102
+ * Correct-by-construction containment check. `file.startsWith(parent)` is a
103
+ * naive string-prefix test with no separator boundary: it's fooled both by
104
+ * sibling directories that merely share a prefix (`/tmp/wd` vs
105
+ * `/tmp/wd-evil/secret`) and — because `decodeURIComponent` runs AFTER a
106
+ * prefix check would happen — by a `..%2Fsibling%2Ffile` request, whose
107
+ * `%2F` survives WHATWG URL's dot-segment normalization as an opaque
108
+ * segment and only becomes a real `..` once decoded. `path.relative` is
109
+ * asked instead: `child` is inside `parent` iff the relative path from one
110
+ * to the other never has to climb out with `..`.
111
+ */
112
+ function isInside(parent: string, child: string): boolean {
113
+ const rel = relative(parent, child);
114
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
115
+ }
116
+
117
+ /** Parse a `Range: bytes=start-end` header against a known file size, or
118
+ * `undefined` if there's no header, it's malformed, or it's out of bounds. */
119
+ function parseRange(rangeHeader: string | undefined, size: number): { start: number; end: number } | undefined {
120
+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
121
+ if (!match) return undefined;
122
+ const start = match[1] ? Number.parseInt(match[1], 10) : 0;
123
+ const end = match[2] ? Number.parseInt(match[2], 10) : size - 1;
124
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > end || end >= size) return undefined;
125
+ return { start, end };
126
+ }
127
+
128
+ /**
129
+ * Serve a file, honouring a `Range: bytes=...` request with 206 +
130
+ * Content-Range when `supportsRange` is set (video seeking needs this);
131
+ * otherwise a plain 200.
132
+ *
133
+ * Headers are written from the stream's `open` handler rather than upfront:
134
+ * a read can still fail after `existsSync` was true (deleted mid-request,
135
+ * permission change), and `createReadStream(...).pipe(res)` has no attached
136
+ * `error` handler by default — an unhandled `error` event on a stream is
137
+ * fatal to the whole process, not just this one request. Deferring the
138
+ * headers means the common failure (open fails outright: ENOENT, EACCES)
139
+ * can still turn into a clean 500 instead of a crash; a failure after open
140
+ * succeeds (e.g. reading a directory) can only get the connection dropped,
141
+ * since the 200 status line is already out the door by then.
142
+ */
143
+ function sendFile(
144
+ req: IncomingMessage,
145
+ res: ServerResponse,
146
+ file: string,
147
+ contentType: string,
148
+ supportsRange: boolean,
149
+ ): void {
150
+ const stat = statSync(file);
151
+ const range = supportsRange ? parseRange(req.headers.range, stat.size) : undefined;
152
+ const stream = range ? createReadStream(file, { start: range.start, end: range.end }) : createReadStream(file);
153
+
154
+ let headersSent = false;
155
+ stream.once("open", () => {
156
+ headersSent = true;
157
+ if (range) {
158
+ res.writeHead(206, {
159
+ "content-type": contentType,
160
+ "content-range": `bytes ${range.start}-${range.end}/${stat.size}`,
161
+ "accept-ranges": "bytes",
162
+ "content-length": String(range.end - range.start + 1),
163
+ });
164
+ } else {
165
+ res.writeHead(200, {
166
+ "content-type": contentType,
167
+ ...(supportsRange ? { "accept-ranges": "bytes" } : {}),
168
+ "content-length": String(stat.size),
169
+ });
170
+ }
171
+ });
172
+ stream.on("error", (err) => {
173
+ if (!headersSent && !res.headersSent) {
174
+ res.writeHead(500, { "content-type": "application/json" });
175
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
176
+ } else {
177
+ res.destroy();
178
+ }
179
+ });
180
+ stream.pipe(res);
181
+ }
182
+
183
+ export async function startEditServer(
184
+ workdirArg?: string,
185
+ opts: { port?: number; pageDir?: string; recentDir?: string } = {},
186
+ ): Promise<EditServer> {
187
+ // MUTABLE since R17 §83: the server can start with no project (the page
188
+ // shows a picker) and switch projects without restarting. Every workdir-
189
+ // touching endpoint guards on null.
190
+ let workdir: string | null = null;
191
+ const propsPath = (): string => join(workdir!, "render-props.json");
192
+ const overridesPath = (): string => join(workdir!, "overrides.json");
193
+ const commandPath = (): string => join(workdir!, "command.json");
194
+ const openWorkdir = async (dirArg: string): Promise<void> => {
195
+ const dir = resolve(dirArg);
196
+ if (!isWorkdir(dir)) {
197
+ throw new Error(`no render-props.json in ${dir} — run \`ossclip produce\` there first`);
198
+ }
199
+ workdir = dir;
200
+ await recordRecentProject(dir, opts.recentDir);
201
+ };
202
+ if (workdirArg !== undefined) await openWorkdir(workdirArg);
203
+
204
+ // One render at a time (R11 Task 4.2). The child is killed on server
205
+ // close so a Ctrl-C on the edit server never orphans an ffmpeg.
206
+ let renderChild: ChildProcess | null = null;
207
+ let renderLines: string[] = [];
208
+ let renderExit: number | null = null;
209
+ // Spawn time, SERVER-side: the client derives its elapsed clock from this,
210
+ // so a page reload mid-render still shows honest elapsed time rather than
211
+ // restarting from zero.
212
+ let renderStartedAt: number | null = null;
213
+ // Whether the CURRENT run's end was a user cancel (R16 §60) — the child's
214
+ // exit code alone can't say, and "render failed (exit 1)" after a
215
+ // deliberate cancel reads as a bug.
216
+ let renderCancelled = false;
217
+ const pushLines = (chunk: Buffer): void => {
218
+ for (const line of chunk.toString().split("\n")) {
219
+ if (!line.trim()) continue;
220
+ renderLines.push(line);
221
+ if (renderLines.length > RENDER_LOG_LINES) renderLines.shift();
222
+ }
223
+ };
224
+
225
+ const server = createServer((req, res) => {
226
+ const send = (code: number, body: unknown): void => {
227
+ res.writeHead(code, { "content-type": "application/json" });
228
+ res.end(JSON.stringify(body));
229
+ };
230
+
231
+ void (async () => {
232
+ try {
233
+ const url = new URL(req.url ?? "/", "http://localhost");
234
+
235
+ if (url.pathname === "/api/production" && req.method === "GET") {
236
+ if (!workdir) {
237
+ // Not an error — the picker state (R17 §83). Recents ride along
238
+ // so the page needs no second request to draw it.
239
+ return send(200, { noWorkdir: true, recent: await readRecentProjects(opts.recentDir) });
240
+ }
241
+ const renderProps = JSON.parse(await readFile(propsPath(), "utf8"));
242
+ const overrides = existsSync(overridesPath())
243
+ ? OverrideDocSchema.parse(JSON.parse(await readFile(overridesPath(), "utf8")))
244
+ : emptyOverrideDoc();
245
+ return send(200, {
246
+ renderProps,
247
+ overrides,
248
+ workdir,
249
+ videoFileName: renderProps.videoFileName,
250
+ // Whether the Render button has a recorded invocation to replay.
251
+ canRender: existsSync(commandPath()),
252
+ // Recents ride along here too, so the top bar's Open picker has
253
+ // a list without a second endpoint.
254
+ recent: await readRecentProjects(opts.recentDir),
255
+ });
256
+ }
257
+
258
+ if (url.pathname === "/api/usage" && req.method === "GET") {
259
+ // The run summary (R21 §104): what planned this video and what it
260
+ // cost. Straight reads of the artefacts `produce` already writes —
261
+ // usage.json carries per-run totals, production.json the producer
262
+ // stamp and the clip window; the editor only has to display them.
263
+ if (!workdir) return send(409, { error: "no workdir open" });
264
+ const readJson = async (name: string): Promise<unknown> => {
265
+ try {
266
+ return JSON.parse(await readFile(join(workdir!, name), "utf8"));
267
+ } catch {
268
+ return null;
269
+ }
270
+ };
271
+ const usage = await readJson("usage.json");
272
+ const production = (await readJson("production.json")) as Record<string, unknown> | null;
273
+ return send(200, {
274
+ usage,
275
+ production: production
276
+ ? {
277
+ producer: production.producer ?? null,
278
+ clip: production.clip ?? null,
279
+ cleanup: production.cleanup ?? null,
280
+ intent: production.intent ?? null,
281
+ sourceDuration:
282
+ (production.source as { probe?: { duration?: number } } | undefined)?.probe
283
+ ?.duration ?? null,
284
+ }
285
+ : null,
286
+ });
287
+ }
288
+
289
+ if (url.pathname === "/api/workdir" && req.method === "POST") {
290
+ // Open/switch the project (R17 §83). Refused mid-render: the
291
+ // running child belongs to the CURRENT workdir, and its status
292
+ // reporting against a switched one would lie.
293
+ if (renderChild) return send(409, { error: "a render is running — cancel it first" });
294
+ const chunks: Buffer[] = [];
295
+ for await (const c of req) chunks.push(c as Buffer);
296
+ const parsed = z
297
+ .object({ path: z.string().min(1) })
298
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
299
+ if (!parsed.success) return send(400, { error: "expected { path }" });
300
+ try {
301
+ await openWorkdir(parsed.data.path);
302
+ } catch (err) {
303
+ return send(400, { error: err instanceof Error ? err.message : String(err) });
304
+ }
305
+ return send(200, { ok: true, workdir });
306
+ }
307
+
308
+ if (url.pathname === "/api/fs" && req.method === "GET") {
309
+ // The picker's folder browser (R17 §83): directories only, with
310
+ // "this one is a project" flagged. Local tool on a local loopback
311
+ // — the same trust as typing the path as a CLI argument.
312
+ // R21 §103: hidden directories are OMITTED — a dev home holds
313
+ // dozens of dot-dirs, and listing them made the picker read as a
314
+ // wall of noise — EXCEPT `.ossclip`, whose projects are the whole
315
+ // point: its workdirs surface INLINE, so browsing ~/Downloads
316
+ // shows the projects produced there without knowing the
317
+ // convention directory exists.
318
+ const dir = resolve(url.searchParams.get("dir") || homedir());
319
+ try {
320
+ const names = await readdir(dir, { withFileTypes: true });
321
+ const entries: Array<{ name: string; path: string; isWorkdir: boolean }> = [];
322
+ for (const d of names.filter((x) => x.isDirectory())) {
323
+ if (d.name.startsWith(".")) {
324
+ if (d.name !== ".ossclip") continue;
325
+ try {
326
+ const inner = await readdir(join(dir, d.name), { withFileTypes: true });
327
+ for (const w of inner.filter((x) => x.isDirectory())) {
328
+ const path = join(dir, d.name, w.name);
329
+ if (isWorkdir(path)) {
330
+ entries.push({ name: `.ossclip/${w.name}`, path, isWorkdir: true });
331
+ }
332
+ }
333
+ } catch {
334
+ // unreadable convention dir — nothing to surface
335
+ }
336
+ continue;
337
+ }
338
+ const path = join(dir, d.name);
339
+ entries.push({ name: d.name, path, isWorkdir: isWorkdir(path) });
340
+ }
341
+ entries.sort((a, b) =>
342
+ a.isWorkdir !== b.isWorkdir ? (a.isWorkdir ? -1 : 1) : a.name.localeCompare(b.name),
343
+ );
344
+ const parent = dirname(dir);
345
+ return send(200, {
346
+ dir,
347
+ parent: parent === dir ? null : parent,
348
+ isWorkdir: isWorkdir(dir),
349
+ entries: entries.slice(0, 500),
350
+ });
351
+ } catch (err) {
352
+ return send(400, { error: err instanceof Error ? err.message : String(err) });
353
+ }
354
+ }
355
+
356
+ if (url.pathname === "/api/render" && req.method === "POST") {
357
+ // Replays ONLY the argv `produce` recorded — the request body is
358
+ // never read, let alone executed.
359
+ if (!workdir) return send(409, { error: "no workdir open" });
360
+ if (renderChild) return send(409, { error: "a render is already running" });
361
+ if (!existsSync(commandPath())) {
362
+ return send(412, {
363
+ error:
364
+ "no command.json in this workdir — run `ossclip produce` once from " +
365
+ "the terminal so the invocation is recorded, then Render can replay it",
366
+ });
367
+ }
368
+ const parsed = CommandSchema.safeParse(
369
+ JSON.parse(await readFile(commandPath(), "utf8")),
370
+ );
371
+ if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
372
+ const cmd = parsed.data;
373
+ renderLines = [];
374
+ renderExit = null;
375
+ renderStartedAt = Date.now();
376
+ renderCancelled = false;
377
+ const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...cmd.args], {
378
+ cwd: cmd.cwd,
379
+ stdio: ["ignore", "pipe", "pipe"],
380
+ });
381
+ renderChild = child;
382
+ child.stdout?.on("data", pushLines);
383
+ child.stderr?.on("data", pushLines);
384
+ child.on("error", (err) => {
385
+ renderLines.push(`spawn failed: ${err.message}`);
386
+ renderExit = 1;
387
+ renderChild = null;
388
+ });
389
+ child.on("exit", (code) => {
390
+ renderExit = code ?? 1;
391
+ renderChild = null;
392
+ });
393
+ return send(202, { ok: true });
394
+ }
395
+
396
+ if (url.pathname === "/api/render/cancel" && req.method === "POST") {
397
+ // Kill the replayed child (R16 §60). The exit handler above still
398
+ // runs and clears `renderChild`; the flag lets the status say
399
+ // "cancelled" instead of dressing a deliberate stop as a failure.
400
+ if (!renderChild) return send(409, { error: "no render is running" });
401
+ renderCancelled = true;
402
+ renderChild.kill();
403
+ return send(202, { ok: true });
404
+ }
405
+
406
+ if (url.pathname === "/api/render/status" && req.method === "GET") {
407
+ return send(200, {
408
+ running: renderChild !== null,
409
+ exitCode: renderExit,
410
+ lines: renderLines,
411
+ startedAt: renderStartedAt,
412
+ cancelled: renderCancelled,
413
+ });
414
+ }
415
+
416
+ if (url.pathname === "/api/overrides" && req.method === "PUT") {
417
+ if (!workdir) return send(409, { error: "no workdir open" });
418
+ const chunks: Buffer[] = [];
419
+ for await (const c of req) chunks.push(c as Buffer);
420
+ const parsed = OverrideDocSchema.safeParse(JSON.parse(Buffer.concat(chunks).toString()));
421
+ if (!parsed.success) return send(400, { error: parsed.error.message });
422
+ // Atomic: the producer may read this file at any moment, and a
423
+ // half-written document would be worse than a stale one.
424
+ const tmp = `${overridesPath()}.tmp`;
425
+ await writeFile(tmp, JSON.stringify(parsed.data, null, 2));
426
+ await rename(tmp, overridesPath());
427
+ return send(200, { ok: true });
428
+ }
429
+
430
+ if (url.pathname.startsWith("/media/")) {
431
+ if (!workdir) return send(409, { error: "no workdir open" });
432
+ const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
433
+ // Never serve outside the workdir, whatever the path claims.
434
+ if (!isInside(workdir, file) || !existsSync(file)) return send(404, { error: "not found" });
435
+ sendFile(req, res, file, MIME[extname(file)] ?? "application/octet-stream", true);
436
+ return;
437
+ }
438
+
439
+ const pageDir = opts.pageDir;
440
+ if (pageDir) {
441
+ const rel = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
442
+ const file = join(pageDir, rel);
443
+ if (isInside(pageDir, file) && existsSync(file)) {
444
+ sendFile(req, res, file, MIME[extname(file)] ?? "text/plain", false);
445
+ return;
446
+ }
447
+ }
448
+ send(404, { error: "not found" });
449
+ } catch (err) {
450
+ send(500, { error: err instanceof Error ? err.message : String(err) });
451
+ }
452
+ })();
453
+ });
454
+
455
+ await new Promise<void>((r) => server.listen(opts.port ?? 5174, "127.0.0.1", r));
456
+ const addr = server.address();
457
+ const port = typeof addr === "object" && addr ? addr.port : (opts.port ?? 5174);
458
+ return {
459
+ url: `http://127.0.0.1:${port}`,
460
+ close: () => {
461
+ renderChild?.kill();
462
+ server.close();
463
+ },
464
+ };
465
+ }
package/src/env.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ /**
6
+ * Load `.env` files into `process.env` (R16 §77).
7
+ *
8
+ * The provider keys are the one piece of configuration ossclip reads from the
9
+ * environment rather than `~/.ossclip/config.json` — secrets do not belong in
10
+ * a file that gets pasted into issues. But "export it in your shell" is a bad
11
+ * contract for a tool you launch from an editor, a script, or a replayed
12
+ * `command.json`: the key was set in the shell that ran `produce` and absent
13
+ * everywhere else, so auto-detection silently picked a different provider.
14
+ *
15
+ * Read in order, FIRST hit wins for each key, and a real environment variable
16
+ * always beats a file — an explicit `GEMINI_API_KEY=… ossclip produce` must
17
+ * not be overridden by a stale `.env`:
18
+ * 1. `$OSSCLIP_ENV_FILE`, when set
19
+ * 2. `.env` walking UP from the cwd, nearest first
20
+ * 3. `~/.ossclip/.env`
21
+ *
22
+ * The upward walk is not a flourish: `pnpm --filter ossclip exec …` runs
23
+ * with the cwd set to `apps/cli`, so a repo-root `.env` — the only place
24
+ * anyone puts one — is invisible to a plain `<cwd>/.env` lookup. Same reason
25
+ * every JS toolchain walks up for its config.
26
+ *
27
+ * Parsing is deliberately small: `KEY=value`, `#` comments, blank lines, an
28
+ * optional `export ` prefix, and surrounding quotes stripped. Anything fancier
29
+ * belongs in a shell, not in a secrets file.
30
+ */
31
+ export function loadEnvFiles(cwd: string = process.cwd()): string[] {
32
+ const upward: string[] = [];
33
+ for (let dir = resolve(cwd); ; ) {
34
+ upward.push(join(dir, ".env"));
35
+ const parent = dirname(dir);
36
+ if (parent === dir) break;
37
+ dir = parent;
38
+ }
39
+ const candidates = [
40
+ process.env.OSSCLIP_ENV_FILE ? resolve(process.env.OSSCLIP_ENV_FILE) : null,
41
+ ...upward,
42
+ join(homedir(), ".ossclip", ".env"),
43
+ ].filter((p): p is string => Boolean(p));
44
+
45
+ const loaded: string[] = [];
46
+ for (const path of candidates) {
47
+ let text: string;
48
+ try {
49
+ text = readFileSync(path, "utf8");
50
+ } catch {
51
+ continue; // absent is the normal case, not an error
52
+ }
53
+ let applied = 0;
54
+ for (const raw of text.split(/\r?\n/)) {
55
+ const line = raw.trim();
56
+ if (!line || line.startsWith("#")) continue;
57
+ const eq = line.indexOf("=");
58
+ if (eq === -1) continue;
59
+ const key = line.slice(0, eq).replace(/^export\s+/, "").trim();
60
+ if (!key) continue;
61
+ // The real environment wins: never clobber what the caller set.
62
+ if (process.env[key] !== undefined) continue;
63
+ const value = line.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, "$2");
64
+ process.env[key] = value;
65
+ applied++;
66
+ }
67
+ if (applied > 0) loaded.push(path);
68
+ }
69
+ return loaded;
70
+ }