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/LICENSE +27 -0
- package/README.md +38 -0
- package/bin/ossclip.mjs +11 -0
- package/editor-dist/assets/index-CI9c6j62.js +159 -0
- package/editor-dist/index.html +12 -0
- package/package.json +50 -0
- package/src/doctor.ts +190 -0
- package/src/edit.ts +465 -0
- package/src/env.ts +70 -0
- package/src/index.ts +230 -0
- package/src/produce.ts +1407 -0
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
|
+
}
|