ossclip 0.1.33 → 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.
- package/editor-dist/assets/index-DSB_SCmp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-regen-panel.ts +74 -0
- package/src/edit-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +905 -10
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +40 -3
- package/src/produce.ts +595 -6
- package/src/program.ts +119 -8
- package/src/publish.ts +350 -6
- package/src/render-replay-args.ts +50 -0
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-DA7vDWn_.js +0 -166
package/editor-dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
40
|
-
"@ossclip/renderer": "0.1.
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
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,74 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { defaultProviderName, providerOfLog, type ProviderName } from "@ossclip/core";
|
|
3
|
+
import { lastFlagValue } from "./thumbnail-panel";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which LLM provider the publish panel's caption regenerate should call
|
|
7
|
+
* (2026-08-29), pure so the usage-log × pin × env matrix is a table test
|
|
8
|
+
* with no server and no filesystem — the thumbnail-panel split. The I/O half
|
|
9
|
+
* lives in edit.ts's /api/publish/regenerate handler, which reads usage.json
|
|
10
|
+
* and command.json and hands them here.
|
|
11
|
+
*
|
|
12
|
+
* Resolution order: the provider usage.json says actually planned this
|
|
13
|
+
* project (`providerOfLog` — truthful even after a §143 fallback, which is
|
|
14
|
+
* why it beats the pin), then the `--llm` pin in command.json's recorded
|
|
15
|
+
* args, then `defaultProviderName`'s detection. Every candidate is PARSED
|
|
16
|
+
* against the provider enum, never coerced: a hand-edited usage.json saying
|
|
17
|
+
* "geminni" falls through to the next rung instead of reaching
|
|
18
|
+
* createProvider as garbage (CLAUDE.md's --source-fit rule).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** program.ts's `--llm` choices, restated as a schema — the same five names
|
|
22
|
+
* core's ProviderName union spells. */
|
|
23
|
+
const ProviderNameSchema = z.enum(["antigravity", "claude", "claude-cli", "gemini", "mock"]);
|
|
24
|
+
|
|
25
|
+
export type CaptionRegenProviderState =
|
|
26
|
+
| { status: "ready"; provider: ProviderName }
|
|
27
|
+
| { status: "unavailable"; reason: string };
|
|
28
|
+
|
|
29
|
+
export interface CaptionRegenProviderInputs {
|
|
30
|
+
/** usage.json parsed as-is, or null when absent/corrupt — read leniently
|
|
31
|
+
* by the caller (the /api/usage posture). */
|
|
32
|
+
usageLog: unknown;
|
|
33
|
+
/** command.json's recorded args, or null when the workdir has none. */
|
|
34
|
+
commandArgs: readonly string[] | null;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
/** Injected existence check (defaultProviderName's own seam) so the
|
|
37
|
+
* matrix needs no PATH scan. */
|
|
38
|
+
hasBin: (bin: string) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function captionRegenProvider(inputs: CaptionRegenProviderInputs): CaptionRegenProviderState {
|
|
42
|
+
// The log's shape is user-editable JSON: pick out the two arrays
|
|
43
|
+
// providerOfLog reads and let anything else read as an empty log.
|
|
44
|
+
const log = (inputs.usageLog ?? {}) as { runs?: unknown; records?: unknown };
|
|
45
|
+
const recorded = providerOfLog({
|
|
46
|
+
runs: Array.isArray(log.runs) ? (log.runs as never[]) : [],
|
|
47
|
+
records: Array.isArray(log.records) ? (log.records as never[]) : [],
|
|
48
|
+
});
|
|
49
|
+
const fromLog = ProviderNameSchema.safeParse(recorded);
|
|
50
|
+
if (fromLog.success) return { status: "ready", provider: fromLog.data };
|
|
51
|
+
const fromPin = ProviderNameSchema.safeParse(
|
|
52
|
+
lastFlagValue(inputs.commandArgs ?? [], ["--llm"]),
|
|
53
|
+
);
|
|
54
|
+
if (fromPin.success) return { status: "ready", provider: fromPin.data };
|
|
55
|
+
// defaultProviderName answers "claude-cli" even with nothing installed —
|
|
56
|
+
// deliberate on the CLI, where the spawn failure carries install guidance.
|
|
57
|
+
// A button in a panel deserves the sentence BEFORE the click, so this
|
|
58
|
+
// restates its four detections as an availability gate and only then takes
|
|
59
|
+
// its pick.
|
|
60
|
+
const reachable =
|
|
61
|
+
inputs.hasBin(inputs.env.OSSCLIP_AGY_BIN ?? "agy") ||
|
|
62
|
+
inputs.hasBin(inputs.env.OSSCLIP_CLAUDE_BIN ?? "claude") ||
|
|
63
|
+
Boolean(inputs.env.GEMINI_API_KEY) ||
|
|
64
|
+
Boolean(inputs.env.ANTHROPIC_API_KEY);
|
|
65
|
+
if (!reachable) {
|
|
66
|
+
return {
|
|
67
|
+
status: "unavailable",
|
|
68
|
+
reason:
|
|
69
|
+
"no LLM provider available — set GEMINI_API_KEY or ANTHROPIC_API_KEY in " +
|
|
70
|
+
"~/.ossclip/.env, or install the claude or agy CLI, then try again",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { status: "ready", provider: defaultProviderName(inputs.env, inputs.hasBin) };
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|
package/src/edit-port.ts
ADDED
|
@@ -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
|
+
}
|