ossclip 0.1.34 → 0.1.35

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-Dn813PGp.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DSB_SCmp.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-Bx2VQLP8.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.34",
40
- "@ossclip/renderer": "0.1.34",
41
- "@ossclip/scenes": "0.1.34"
39
+ "@ossclip/core": "0.1.35",
40
+ "@ossclip/renderer": "0.1.35",
41
+ "@ossclip/scenes": "0.1.35"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
@@ -0,0 +1,51 @@
1
+ import { z } from "zod/v4";
2
+
3
+ /**
4
+ * The edit server's identity endpoint — ONE spelling, shared by the route that
5
+ * serves it (edit.ts) and the probe that reads it (edit-port.ts).
6
+ *
7
+ * It exists for the port-conflict flow: `ossclip edit` on a taken 5174 used to
8
+ * die with a raw EADDRINUSE stack, and the only way to tell "my own editor is
9
+ * already open on this project" from "something else owns this port" is to ask
10
+ * whoever answers. Two copies of this contract would mean an attach that
11
+ * silently stops working the day one side adds a field.
12
+ *
13
+ * Nothing here may carry a secret. The workdir path is the one path included,
14
+ * and only because `/api/production` already serves it to the same loopback
15
+ * origin — it IS the thing the caller has to compare.
16
+ */
17
+ export const EDIT_HEALTH_PATH = "/api/health";
18
+
19
+ /**
20
+ * Parsed, never cast (CLAUDE.md): whatever answers on 127.0.0.1:<port> is an
21
+ * unknown process until it proves otherwise, and a dev server that happens to
22
+ * serve JSON at /api/health must read as a STRANGER — the flow kills a pid it
23
+ * gets from here, so `app: "ossclip"` is a literal and the pid is a positive
24
+ * integer or the whole body is rejected.
25
+ *
26
+ * `workdir` is nullable because the server legitimately runs with no project
27
+ * open (R17 §83's picker state); `version` is optional so an install that
28
+ * cannot read its own manifest still identifies itself.
29
+ */
30
+ export const EditHealthSchema = z.object({
31
+ app: z.literal("ossclip"),
32
+ version: z.string().optional(),
33
+ workdir: z.string().nullable(),
34
+ pid: z.number().int().positive(),
35
+ });
36
+ export type EditHealth = z.infer<typeof EditHealthSchema>;
37
+
38
+ /** The route's body, built through the schema's own type so a field added to
39
+ * one side cannot miss the other. */
40
+ export function editHealthBody(o: {
41
+ version?: string | undefined;
42
+ workdir: string | null;
43
+ pid: number;
44
+ }): EditHealth {
45
+ return {
46
+ app: "ossclip",
47
+ ...(o.version !== undefined ? { version: o.version } : {}),
48
+ workdir: o.workdir,
49
+ pid: o.pid,
50
+ };
51
+ }
@@ -0,0 +1,296 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { EDIT_HEALTH_PATH, EditHealthSchema, type EditHealth } from "./edit-health";
3
+ import type { EditServer } from "./edit";
4
+ import { assertInteractive, isInteractive, select, unwrap } from "./interactive/prompts";
5
+
6
+ /**
7
+ * What `ossclip edit` does when its port is already taken.
8
+ *
9
+ * The failure this replaces was a raw Node `EADDRINUSE` stack plus an
10
+ * `ELIFECYCLE` from the package manager — recurring, and almost always the
11
+ * user's OWN editor already open on the SAME project, i.e. a situation with an
12
+ * obviously right answer (attach to it) that the tool refused to take.
13
+ *
14
+ * The decision is pure (`resolvePortConflict`) and the sockets, prompts and
15
+ * kills live in `openEditServer` below — the `openCommand`/`openInBrowser`
16
+ * split, so the whole matrix is testable without a TTY or a second server.
17
+ */
18
+
19
+ /** How many ports past the requested one an automatic bump will try. */
20
+ export const PORT_BUMP_ATTEMPTS = 20;
21
+
22
+ /** Who is holding the port, as far as `/api/health` would say. */
23
+ export interface PortHolder {
24
+ pid: number;
25
+ /** Null when that server is sitting on the project picker (R17 §83). */
26
+ workdir: string | null;
27
+ }
28
+
29
+ /**
30
+ * The four things that can happen to a requested port. Every one of them
31
+ * carries its own user-facing sentence, so the wording is pinned by the pure
32
+ * test rather than by whoever reads the terminal that day.
33
+ */
34
+ export type PortDecision =
35
+ /** Our own editor, same project: print and reuse it. */
36
+ | { kind: "attach"; url: string; message: string }
37
+ /** Our own editor, another project, at a TTY: the three-way prompt. */
38
+ | { kind: "ask"; holder: PortHolder; message: string }
39
+ /** Move to the next free port and say why. */
40
+ | { kind: "bump"; message: string }
41
+ /** Refuse, because the user named this port explicitly. */
42
+ | { kind: "refuse"; message: string };
43
+
44
+ /** How a workdir is named in a message: the basename, which is what a user
45
+ * recognises, with the null (picker) server spelled out rather than printed as
46
+ * an empty pair of quotes. */
47
+ function holderName(workdir: string | null): string {
48
+ return workdir === null ? "no project (the picker)" : `"${basename(workdir)}"`;
49
+ }
50
+
51
+ /**
52
+ * Same project or not. Both sides go through `resolve` because one may have
53
+ * come from a typed relative path while the server always reports a resolved
54
+ * one; two spellings of the same directory must not read as two projects.
55
+ * Symlinked temp roots (macOS `/var` → `/private/var`) can still disagree —
56
+ * that degrades to the prompt, never to a wrong attach.
57
+ */
58
+ function sameProject(a: string | null, b: string | null): boolean {
59
+ if (a === null || b === null) return a === b;
60
+ return resolve(a) === resolve(b);
61
+ }
62
+
63
+ /**
64
+ * The whole port-conflict matrix, as one pure function.
65
+ *
66
+ * `pinned` means the user TYPED `--port` (commander's `getOptionValueSource`,
67
+ * the `--sort` idiom). It never changes attach or the prompt — those are still
68
+ * what the user wants — it only forbids the silent bump: someone who names a
69
+ * port has a reason (a bookmark, a tunnel, a proxy config), and quietly serving
70
+ * on a different one hands them a page that never loads.
71
+ *
72
+ * A process that does not identify as ossclip is NEVER killed and never
73
+ * prompted about: it is not ours to stop.
74
+ */
75
+ export function resolvePortConflict(input: {
76
+ health: EditHealth | null;
77
+ port: number;
78
+ /** The workdir this run is opening; null when opening the picker. */
79
+ workdir: string | null;
80
+ interactive: boolean;
81
+ pinned: boolean;
82
+ }): PortDecision {
83
+ const { health, port, workdir, interactive, pinned } = input;
84
+ const url = `http://127.0.0.1:${port}`;
85
+ if (health === null) {
86
+ // A stranger. Bump around it, or — when the port was named — say so and
87
+ // stop, since "free it" is the only thing we could honestly suggest.
88
+ return pinned
89
+ ? {
90
+ kind: "refuse",
91
+ message:
92
+ `port ${port} is taken by something that isn't ossclip — stop it, ` +
93
+ "or pass a different `--port <n>`.",
94
+ }
95
+ : {
96
+ kind: "bump",
97
+ message: `▸ port ${port} is taken by another program — using the next free port`,
98
+ };
99
+ }
100
+ if (sameProject(health.workdir, workdir)) {
101
+ return { kind: "attach", url, message: `▸ already open at ${url}` };
102
+ }
103
+ const holder: PortHolder = { pid: health.pid, workdir: health.workdir };
104
+ if (interactive) {
105
+ return {
106
+ kind: "ask",
107
+ holder,
108
+ message: `port ${port} is already serving ${holderName(health.workdir)} (pid ${health.pid})`,
109
+ };
110
+ }
111
+ // No TTY: nobody can answer, so never block a script on a question.
112
+ return pinned
113
+ ? {
114
+ kind: "refuse",
115
+ message:
116
+ `port ${port} is already serving ${holderName(health.workdir)} (pid ${health.pid}) — ` +
117
+ `stop it, or pass a different \`--port <n>\`.`,
118
+ }
119
+ : {
120
+ kind: "bump",
121
+ message:
122
+ `▸ port ${port} is serving ${holderName(health.workdir)} (pid ${health.pid}) — ` +
123
+ "using the next free port",
124
+ };
125
+ }
126
+
127
+ /** The give-up sentence when a whole block of ports is occupied — pure so the
128
+ * number in it can never drift from `PORT_BUMP_ATTEMPTS`. */
129
+ export function portsExhaustedMessage(from: number, attempts: number): string {
130
+ return (
131
+ `ports ${from}-${from + attempts - 1} are all taken — free one, or pass ` +
132
+ "`--port <n>` to pick another."
133
+ );
134
+ }
135
+
136
+ /** Node's "already bound" as a predicate, so no call site has to cast an
137
+ * unknown catch value into an ErrnoException. */
138
+ export function isAddrInUse(err: unknown): boolean {
139
+ return (
140
+ typeof err === "object" &&
141
+ err !== null &&
142
+ (err as NodeJS.ErrnoException).code === "EADDRINUSE"
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Ask whoever answered on `port` who they are. Null for anything that is not a
148
+ * well-formed ossclip health body — a timeout, a refused connection, a page
149
+ * that 404s, an unrelated JSON API.
150
+ *
151
+ * The timeout is short on purpose: this sits between the user and their editor
152
+ * opening, and a process that has a socket bound but never answers must cost a
153
+ * blink, not a hang.
154
+ */
155
+ export async function probeEditHealth(
156
+ port: number,
157
+ opts: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
158
+ ): Promise<EditHealth | null> {
159
+ try {
160
+ const res = await (opts.fetchImpl ?? fetch)(`http://127.0.0.1:${port}${EDIT_HEALTH_PATH}`, {
161
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 500),
162
+ });
163
+ if (!res.ok) return null;
164
+ const parsed = EditHealthSchema.safeParse(await res.json());
165
+ return parsed.success ? parsed.data : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /** The I/O the bind flow needs, each piece injectable — tests drive the whole
172
+ * ladder with no TTY, no browser and no signals. */
173
+ export interface EditPortDeps {
174
+ /** Start a server on exactly this port; rejects with EADDRINUSE when taken. */
175
+ start: (port: number) => Promise<EditServer>;
176
+ health: (port: number) => Promise<EditHealth | null>;
177
+ interactive: boolean;
178
+ ask: (d: { message: string; holder: PortHolder; port: number }) => Promise<"stop" | "next" | "cancel">;
179
+ kill: (pid: number) => void;
180
+ log: (line: string) => void;
181
+ wait: (ms: number) => Promise<void>;
182
+ }
183
+
184
+ export type OpenEditServerResult =
185
+ | { kind: "started"; server: EditServer }
186
+ /** Someone else's process is serving this project already — nothing to run. */
187
+ | { kind: "attached"; url: string }
188
+ /** The user chose "cancel" at the prompt: exit 0, having changed nothing. */
189
+ | { kind: "cancelled" };
190
+
191
+ /** How long we give a stopped server to release the socket before bumping —
192
+ * a SIGTERM is not instant, and the listener lingers in the kernel briefly
193
+ * after the process is gone. */
194
+ const STOP_WAIT_MS = 2000;
195
+ const STOP_POLL_MS = 100;
196
+
197
+ /**
198
+ * Bind, or do the right thing about not being able to. Returns rather than
199
+ * exits so the caller keeps ownership of the browser open and the telemetry
200
+ * line; throws only for the refusals, which are real errors.
201
+ */
202
+ export async function openEditServer(
203
+ workdir: string | null,
204
+ opts: { port: number; pinned: boolean },
205
+ deps: EditPortDeps,
206
+ ): Promise<OpenEditServerResult> {
207
+ const tryStart = async (port: number): Promise<EditServer | null> => {
208
+ try {
209
+ return await deps.start(port);
210
+ } catch (err) {
211
+ // Only EADDRINUSE is a port conflict. EACCES on a privileged port, a
212
+ // bad interface, anything else — that is the user's real error and it
213
+ // must not be swallowed by a silent bump to port+1.
214
+ if (!isAddrInUse(err)) throw err;
215
+ return null;
216
+ }
217
+ };
218
+ const bumpFrom = async (from: number): Promise<OpenEditServerResult> => {
219
+ for (let port = from; port < from + PORT_BUMP_ATTEMPTS; port++) {
220
+ const server = await tryStart(port);
221
+ if (server !== null) return { kind: "started", server };
222
+ }
223
+ throw new Error(portsExhaustedMessage(from, PORT_BUMP_ATTEMPTS));
224
+ };
225
+
226
+ const first = await tryStart(opts.port);
227
+ if (first !== null) return { kind: "started", server: first };
228
+
229
+ const decision = resolvePortConflict({
230
+ health: await deps.health(opts.port),
231
+ port: opts.port,
232
+ workdir,
233
+ interactive: deps.interactive,
234
+ pinned: opts.pinned,
235
+ });
236
+ if (decision.kind === "refuse") throw new Error(decision.message);
237
+ if (decision.kind === "attach") {
238
+ deps.log(decision.message);
239
+ return { kind: "attached", url: decision.url };
240
+ }
241
+ if (decision.kind === "bump") {
242
+ deps.log(decision.message);
243
+ return await bumpFrom(opts.port + 1);
244
+ }
245
+
246
+ const answer = await deps.ask({ message: decision.message, holder: decision.holder, port: opts.port });
247
+ if (answer === "cancel") return { kind: "cancelled" };
248
+ if (answer === "next") return await bumpFrom(opts.port + 1);
249
+
250
+ // "Stop it and take the port". The pid came from a body that had to say
251
+ // `app: "ossclip"` to parse at all, so this can only ever signal our own
252
+ // editor. A kill that throws (ESRCH — it exited between the probe and now)
253
+ // is the outcome we wanted anyway, so it falls through to the same retry.
254
+ try {
255
+ deps.kill(decision.holder.pid);
256
+ } catch {
257
+ // already gone
258
+ }
259
+ for (let waited = 0; waited < STOP_WAIT_MS; waited += STOP_POLL_MS) {
260
+ await deps.wait(STOP_POLL_MS);
261
+ const server = await tryStart(opts.port);
262
+ if (server !== null) return { kind: "started", server };
263
+ }
264
+ // It refused to die (or something else grabbed the port in the gap). Say so
265
+ // rather than looping forever — the editor still opens, just elsewhere.
266
+ deps.log(`▸ port ${opts.port} is still busy — using the next free port`);
267
+ return await bumpFrom(opts.port + 1);
268
+ }
269
+
270
+ /**
271
+ * The real seams. The prompt is `select` + `unwrap` like every other choice in
272
+ * this CLI, so Esc/Ctrl-C exits 0 with "nothing changed" instead of a stack.
273
+ */
274
+ export function liveEditPortDeps(start: (port: number) => Promise<EditServer>): EditPortDeps {
275
+ return {
276
+ start,
277
+ health: (port) => probeEditHealth(port),
278
+ interactive: isInteractive(),
279
+ ask: async ({ message, holder, port }) => {
280
+ assertInteractive("the edit port conflict prompt");
281
+ return unwrap(
282
+ await select({
283
+ message: `${message} — what now?`,
284
+ options: [
285
+ { value: "stop", label: `Stop it and take port ${port}` },
286
+ { value: "next", label: "Open this project on the next free port" },
287
+ { value: "cancel", label: "Cancel" },
288
+ ],
289
+ }),
290
+ ) as "stop" | "next" | "cancel";
291
+ },
292
+ kill: (pid) => process.kill(pid),
293
+ log: (line) => console.log(line),
294
+ wait: (ms) => new Promise((r) => setTimeout(r, ms)),
295
+ };
296
+ }