ossclip 0.1.17 → 0.1.19

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.
@@ -0,0 +1,286 @@
1
+ import { VIDEO_EXTENSIONS, run } from "@ossclip/core";
2
+ import { binOnPath } from "../llm-detect";
3
+
4
+ /**
5
+ * The native file/folder dialog (§136). Typing a path was the one step a
6
+ * non-technical user could not get past — the blocker was never the flags,
7
+ * it was the very first prompt.
8
+ *
9
+ * Split the way `open.ts` is split, and for the same reason: the platform
10
+ * matrix is decided by pure functions with a table test, so a Linux or
11
+ * Windows user is not the one who discovers the command was wrong. The
12
+ * `ossclip edit` crash that shipped in 0.1.4 for every non-macOS user is what
13
+ * this shape exists to prevent.
14
+ */
15
+
16
+ export type PickMode = "file" | "folder";
17
+
18
+ export interface PickerDeps {
19
+ platform: NodeJS.Platform;
20
+ env: NodeJS.ProcessEnv;
21
+ hasBin: (bin: string) => boolean;
22
+ }
23
+
24
+ export const livePickerDeps = (): PickerDeps => ({
25
+ platform: process.platform,
26
+ env: process.env,
27
+ hasBin: (bin) => binOnPath(bin),
28
+ });
29
+
30
+ /** `*.mov *.mp4 …` — the shape zenity and kdialog both want, space-joined. */
31
+ const globs = (): string => VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(" ");
32
+
33
+ /**
34
+ * A PowerShell single-quoted string has exactly one escape: a literal `'` is
35
+ * written `''`. Nothing else is special in there — not `$`, not a backtick,
36
+ * not the `\` that fills every Windows path — which is why single quotes are
37
+ * what the script uses and why this is the whole escape rather than a table.
38
+ * `C:\Users\Ahsan's videos` is the case that makes it necessary.
39
+ */
40
+ const psQuote = (s: string): string => s.replace(/'/g, "''");
41
+
42
+ /**
43
+ * Is there a dialog to open at all? Probed rather than attempted, because
44
+ * offering a "Browse…" row that cannot work is worse than not offering it —
45
+ * the menu drops the row entirely when this is false.
46
+ */
47
+ export function pickerAvailable(d: PickerDeps): boolean {
48
+ // Truthiness not presence, matching `isInteractive`'s treatment of CI.
49
+ if (d.env.OSSCLIP_NO_PICKER) return false;
50
+ // "Am I over SSH" is a PROXY for a signal these two platforms don't have
51
+ // (§136): neither macOS nor Windows exposes anything that says whether a
52
+ // reachable window server exists, so the remote-session hint is the best
53
+ // available stand-in. It has to be applied here, not just on darwin: macOS
54
+ // fails loudly — `choose file` returns -1743 (not authorized) — but Windows
55
+ // fails the dangerous way. It ships an in-box OpenSSH server, and
56
+ // ShowDialog() on a non-interactive window station blocks forever on a
57
+ // window nobody can see. `run()` has no timeout, so that is a CLI hung
58
+ // until Ctrl-C, the exact failure the -STA comment below exists to prevent.
59
+ //
60
+ // Note this deliberately does NOT cover Linux, which is handled below.
61
+ // osascript and WinForms are always installed, so there is nothing else to
62
+ // detect on either.
63
+ if (d.platform === "darwin" || d.platform === "win32") {
64
+ return !d.env.SSH_CONNECTION && !d.env.SSH_TTY;
65
+ }
66
+ // Linux is exempt from the SSH proxy above because it HAS the real signal.
67
+ // `ssh -X` sets DISPLAY=localhost:10.0 and zenity genuinely draws on the
68
+ // caller's local screen through the tunnel — a configuration that works.
69
+ // Layering the proxy on top of the evidence would override that evidence
70
+ // with a guess and break it.
71
+ //
72
+ // Both halves are required, and the display check is also what covers WSL
73
+ // without WSLg: zenity may well be installed there and still draw nowhere.
74
+ if (!d.env.DISPLAY && !d.env.WAYLAND_DISPLAY) return false;
75
+ return d.hasBin("zenity") || d.hasBin("kdialog");
76
+ }
77
+
78
+ export function pickerCommand(
79
+ d: PickerDeps,
80
+ mode: PickMode,
81
+ startDir?: string,
82
+ ): { bin: string; args: string[] } {
83
+ if (d.platform === "darwin") {
84
+ // Bare extensions, verified on macOS 26.3 — matching files stay
85
+ // selectable and the rest are dimmed. The UTI form {"public.movie"} was
86
+ // the alternative and is NOT used: its mkv coverage is unconfirmed, and
87
+ // mkv is in VIDEO_EXTENSIONS.
88
+ const types = VIDEO_EXTENSIONS.map((e) => `"${e}"`).join(",");
89
+ const clause =
90
+ mode === "folder"
91
+ ? 'choose folder with prompt "Pick a folder of clips"'
92
+ : `choose file with prompt "Pick your video" of type {${types}}`;
93
+ // startDir is always process.cwd(), never user text — but JSON.stringify
94
+ // still escapes it, because AppleScript string syntax is close enough to
95
+ // JSON's that this is the cheap correct thing rather than concatenation.
96
+ const loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
97
+ return { bin: "osascript", args: ["-e", `POSIX path of (${clause}${loc})`] };
98
+ }
99
+
100
+ if (d.platform === "win32") {
101
+ // -STA is load-bearing: WinForms dialogs deadlock on the MTA thread that
102
+ // `powershell -Command` uses by default, and a deadlock here looks
103
+ // exactly like a hung CLI. `powershell` (5.1, in-box) rather than
104
+ // `pwsh`, which is not installed by default.
105
+ // startDir is honoured here too, under different property names on the
106
+ // two dialogs. Without it a Windows user standing in D:\shoots\raw opens
107
+ // at Desktop while darwin, zenity and kdialog all land in the project
108
+ // folder — the same flag, three platforms agreeing and one not.
109
+ const start =
110
+ startDir === undefined
111
+ ? ""
112
+ : mode === "folder"
113
+ ? // FolderBrowserDialog seeds its start folder from SelectedPath —
114
+ // the same property it hands the answer back in.
115
+ `$d.SelectedPath = '${psQuote(startDir)}'; `
116
+ : `$d.InitialDirectory = '${psQuote(startDir)}'; `;
117
+ const script =
118
+ mode === "folder"
119
+ ? "Add-Type -AssemblyName System.Windows.Forms; " +
120
+ "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " +
121
+ start +
122
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }"
123
+ : "Add-Type -AssemblyName System.Windows.Forms; " +
124
+ "$d = New-Object System.Windows.Forms.OpenFileDialog; " +
125
+ `$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
126
+ start +
127
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }";
128
+ return { bin: "powershell", args: ["-NoProfile", "-STA", "-Command", script] };
129
+ }
130
+
131
+ // Linux. zenity first because it is the GTK/GNOME default and far more
132
+ // widely installed; kdialog is the KDE equivalent and its filter syntax is
133
+ // a DIFFERENT shape — `Name(*.ext)` parentheses, not zenity's pipe.
134
+ if (d.hasBin("zenity")) {
135
+ const args = [
136
+ "--file-selection",
137
+ mode === "folder" ? "--title=Pick a folder of clips" : "--title=Pick your video",
138
+ ];
139
+ if (mode === "folder") args.push("--directory");
140
+ else args.push(`--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
141
+ // The trailing slash is what makes zenity read this as a starting
142
+ // DIRECTORY rather than a pre-filled filename.
143
+ if (startDir !== undefined) args.push(`--filename=${startDir}/`);
144
+ return { bin: "zenity", args };
145
+ }
146
+
147
+ if (mode === "folder") {
148
+ // --getexistingdirectory accepts a start dir and nothing else; passing a
149
+ // filter here is an error, not an ignored argument.
150
+ return { bin: "kdialog", args: ["--getexistingdirectory", startDir ?? "."] };
151
+ }
152
+ return {
153
+ bin: "kdialog",
154
+ args: ["--getopenfilename", startDir ?? ".", `Video files(${globs()})`],
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Emptiness is the signal: no path came back. Exit codes cannot carry it —
160
+ * zenity and kdialog exit non-zero when dismissed, but PowerShell exits 0 and
161
+ * simply emits nothing, because the `if` guarding ShowDialog() just doesn't
162
+ * fire. So the caller passes `allowNonZero` and reads stdout, on every
163
+ * platform.
164
+ *
165
+ * What "no path came back" does NOT distinguish is a cancel from a silent
166
+ * backend failure — osascript hitting -1743 in a launchd or tmux context that
167
+ * sets neither SSH var, or Add-Type failing on a Windows box. That conflation
168
+ * is deliberate: no backend gives us a reliable way to tell the two apart
169
+ * (stderr is prose that varies by version and locale), and the caller's
170
+ * fallback is the typed-path prompt, which is the right next step either way.
171
+ */
172
+ export function parsePickerResult(stdout: string): string | undefined {
173
+ const picked = stdout.trim();
174
+ return picked === "" ? undefined : picked;
175
+ }
176
+
177
+ /**
178
+ * How much of stderr a user is shown. A zenity or osascript failure is one
179
+ * short sentence, but a PowerShell one is a multi-line exception with a
180
+ * banner, and pasting that above a wizard prompt is its own kind of unusable.
181
+ */
182
+ const MAX_STDERR_CHARS = 160;
183
+
184
+ /**
185
+ * AppleScript's user-cancelled error. Escape in `choose file` raises -128,
186
+ * which osascript writes to STDERR and exits 1 — verified on darwin 25.3.0
187
+ * (§136): `osascript -e 'error "User canceled." number -128'` prints
188
+ * `6:22: execution error: User canceled. (-128)`. So on the most common
189
+ * platform a cancel is the LOUDEST empty-handed return, not the quietest.
190
+ */
191
+ const OSASCRIPT_CANCEL = /\(-128\)/;
192
+
193
+ /**
194
+ * Toolkit chatter, not failure. GTK and Qt print these on a perfectly good
195
+ * dialog — `Gtk-Message: … Failed to load module "canberra-gtk-module"`,
196
+ * `Gdk-CRITICAL`, `qt.qpa.wayland:`, `kf.kio…` — and they are still sitting in
197
+ * stderr when the user cancels. Matched per LINE rather than against the whole
198
+ * blob, so a real error arriving alongside chatter still surfaces (§136).
199
+ */
200
+ const BENIGN_STDERR_PREFIXES = ["Gtk-Message:", "Gdk-", "qt.", "kf."];
201
+
202
+ /**
203
+ * The one line to print when the dialog came back with nothing and stderr says
204
+ * something went wrong. `parsePickerResult` above explains why stdout alone
205
+ * cannot tell a cancel from a silent backend failure; stderr can, but only
206
+ * after the noise is stripped, and the noise is backend-specific (§136):
207
+ *
208
+ * - **osascript** is NOT silent on a cancel — it emits error -128 (above).
209
+ * This was got wrong once, and the bug was that every Escape in the Finder
210
+ * dialog told a user whose picker works perfectly that it was broken.
211
+ * - **zenity / kdialog** exit 1 with empty stdout and *usually* empty stderr,
212
+ * but GTK and Qt leave launch chatter there routinely, cancel or not.
213
+ * - **PowerShell** is the only one the naive reading holds for: exit 0, empty
214
+ * stdout, empty stderr, because the `if` guarding ShowDialog() just does not
215
+ * fire.
216
+ *
217
+ * Deliberately NOT keyed on the exit code: this function is not given one, and
218
+ * PowerShell shows why it would be wrong anyway — it exits 0 on a cancel and
219
+ * would exit 0 on some failures too.
220
+ *
221
+ * Without a notice at all the user is in a feedback-free loop (§136, final
222
+ * review): a plain `ssh` into a box whose `~/.bashrc` exports `DISPLAY=:0`
223
+ * passes `pickerAvailable`, so `Browse…` is offered, prints "look for a new
224
+ * window", and returns to the menu with no window and no output — identically,
225
+ * forever, on every retry. The return value stays `undefined` either way; it
226
+ * was only ever the silence that was wrong.
227
+ *
228
+ * Pure so the wording is pinned without a dialog, which blocks on a human.
229
+ */
230
+ export function pickerFailureNotice(
231
+ bin: string,
232
+ stdout: string,
233
+ stderr: string,
234
+ ): string | undefined {
235
+ if (parsePickerResult(stdout) !== undefined) return undefined;
236
+ if (OSASCRIPT_CANCEL.test(stderr)) return undefined;
237
+ const real = stderr
238
+ .split("\n")
239
+ .map((l) => l.trim())
240
+ .filter((l) => l !== "" && !BENIGN_STDERR_PREFIXES.some((p) => l.startsWith(p)));
241
+ // First real line only: the rest is a stack or a usage dump on every backend
242
+ // that produces more than one.
243
+ const first = real[0];
244
+ if (first === undefined) return undefined; // a genuine cancel
245
+ const detail = first.length > MAX_STDERR_CHARS ? `${first.slice(0, MAX_STDERR_CHARS)}…` : first;
246
+ return `▸ ${bin} could not open a picker: ${detail} — type the path instead`;
247
+ }
248
+
249
+ /**
250
+ * Open the dialog and wait. The ONLY I/O in this module.
251
+ *
252
+ * The notice before the spawn is not decoration: a dialog that opens behind
253
+ * the terminal is indistinguishable from a hung CLI, and the wait here is
254
+ * unbounded by design — a human is deciding.
255
+ *
256
+ * The returned path is deliberately unvalidated (§136); `validateInputPath`
257
+ * owns existence and extension for the typed branch and this one alike, so
258
+ * the two cannot drift apart.
259
+ */
260
+ export async function pickPath(
261
+ mode: PickMode,
262
+ deps: PickerDeps = livePickerDeps(),
263
+ startDir: string = process.cwd(),
264
+ ): Promise<string | undefined> {
265
+ const { bin, args } = pickerCommand(deps, mode, startDir);
266
+ console.log("▸ file picker open — look for a new window");
267
+ try {
268
+ // allowNonZero: a cancel exits non-zero on zenity and kdialog — and 0 with
269
+ // empty stdout on PowerShell (see parsePickerResult above, and §136). The
270
+ // flag covers the first shape; emptiness is what actually decides.
271
+ const { stdout, stderr } = await run(bin, args, { allowNonZero: true });
272
+ // A backend that started and then failed says so on stderr and nowhere
273
+ // else. Say it back, or the user is told to look for a window that will
274
+ // never appear and is given nothing to act on (§136).
275
+ const notice = pickerFailureNotice(bin, stdout, stderr);
276
+ if (notice !== undefined) console.log(notice);
277
+ return parsePickerResult(stdout);
278
+ } catch {
279
+ // `run` only rejects when the binary would not start. pickerAvailable
280
+ // said yes, so this is a broken install or a PATH that changed under us —
281
+ // fall through to typing rather than failing the whole wizard, the same
282
+ // call openInBrowser makes about a box with no browser on it.
283
+ console.log("▸ couldn't open a file picker here — type the path instead");
284
+ return undefined;
285
+ }
286
+ }
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
- import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { askInput } from "./ask-input";
3
4
  import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-argv";
4
5
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
5
6
 
@@ -145,27 +146,10 @@ export async function produceWizard(
145
146
  // dropped it and asked again — the re-ask is where "./Anyhropic c Compiler"
146
147
  // became "./" (all of ~/Downloads). The router checks existence before the
147
148
  // wizard ever opens, so a prefilled path skips the prompt entirely.
148
- const input =
149
- cfg.input ??
150
- (unwrap(
151
- await text({
152
- // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
153
- // (folder-input-brief.md) but this prompt still rejected a directory —
154
- // the wizard was the only way in that couldn't do what the CLI could.
155
- // A folder is concatenated by name (codepoint order, like `ls`); --sort
156
- // mtime reorders it but stays a typed flag, not a wizard question (see
157
- // the file-level comment above for why).
158
- message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
159
- placeholder: "./raw/take1.mp4",
160
- validate: (v) => {
161
- if (!v) return "a path is required";
162
- if (!existsSync(v)) return `no such path: ${v}`;
163
- const st = statSync(v);
164
- if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
165
- return undefined;
166
- },
167
- }),
168
- ) as string);
149
+ //
150
+ // Everything else now lives in ask-input.ts (§136): suggestions, the native
151
+ // picker, and typing, all converging on one validator.
152
+ const input = cfg.input ?? (await askInput());
169
153
 
170
154
  const aspect = unwrap(
171
155
  await select({
@@ -0,0 +1,164 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, extname, join } from "node:path";
4
+ import { VIDEO_EXTENSIONS } from "@ossclip/core";
5
+
6
+ /**
7
+ * The rows above "Browse…" in the input prompt (§136). A non-technical user
8
+ * has just hit record and wants the file they made thirty seconds ago; it is
9
+ * nearly always the newest video in the working directory, Downloads, or
10
+ * Movies. Offering it by name beats any picker.
11
+ *
12
+ * Deliberately stateless: no recents file to keep, migrate or privacy-audit,
13
+ * and — the reason that mattered — a recents list is EMPTY on the very first
14
+ * run, which is the exact run a new user needs the help on.
15
+ */
16
+
17
+ export interface CandidateFile {
18
+ path: string;
19
+ mtimeMs: number;
20
+ size: number;
21
+ }
22
+
23
+ export interface Suggestion {
24
+ path: string;
25
+ label: string;
26
+ hint: string;
27
+ }
28
+
29
+ const VIDEO_EXT_SET = new Set<string>(VIDEO_EXTENSIONS);
30
+
31
+ /**
32
+ * Base-1000 like Finder and Explorer report it, not base-1024.
33
+ *
34
+ * The thresholds are the ROUNDING boundary (999_500), not the unit boundary
35
+ * (1_000_000): `Math.round` carries into the next unit while a unit-boundary
36
+ * check has already committed to the current one, so a 999.7 MB screen
37
+ * recording printed "1000 MB" next to a sibling's "1.1 GB".
38
+ */
39
+ export function humanSize(bytes: number): string {
40
+ if (bytes < 1_000) return `${bytes} B`;
41
+ if (bytes < 999_500) return `${Math.round(bytes / 1_000)} kB`;
42
+ if (bytes < 999_500_000) return `${Math.round(bytes / 1_000_000)} MB`;
43
+ return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
44
+ }
45
+
46
+ export function relativeAge(msInput: number): string {
47
+ // A file copied off a camera with an unset clock carries an mtime years
48
+ // ahead, which reaches here as a NEGATIVE age. Clamped rather than special
49
+ // cased: "just now" is the honest label for "not older than now". Such
50
+ // files are deliberately still ranked (highest, by mtime) rather than
51
+ // filtered — a few seconds of clock skew is ordinary and the file is real.
52
+ const ms = Math.max(0, msInput);
53
+ if (ms < 60_000) return "just now";
54
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
55
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
56
+ return `${Math.floor(ms / 86_400_000)}d ago`;
57
+ }
58
+
59
+ export function tildeify(path: string, home: string): string {
60
+ return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
61
+ }
62
+
63
+ export function likelyDirs(d: {
64
+ platform: NodeJS.Platform;
65
+ cwd: string;
66
+ home: string;
67
+ }): string[] {
68
+ // Movies on macOS, Videos everywhere else — the OS's own recording default.
69
+ const media = join(d.home, d.platform === "darwin" ? "Movies" : "Videos");
70
+ return [...new Set([d.cwd, join(d.home, "Downloads"), media])];
71
+ }
72
+
73
+ export function rankSuggestions(
74
+ files: CandidateFile[],
75
+ nowMs: number,
76
+ home: string,
77
+ limit = 3,
78
+ ): Suggestion[] {
79
+ return files
80
+ .filter((file) => {
81
+ const name = basename(file.path);
82
+ if (name.startsWith(".")) return false;
83
+ // ossclip's own output. Cutting an already-cut video compounds the
84
+ // trims and is never what somebody means to do from this menu.
85
+ if (name.toLowerCase().endsWith(".ossclip.mp4")) return false;
86
+ // Case-folded before the lookup because VIDEO_EXTENSIONS is lowercase
87
+ // and cameras and screen recorders write `.MP4`/`.MOV` — the same fold
88
+ // `listFolderVideos` does, for the same reason.
89
+ return VIDEO_EXT_SET.has(extname(name).slice(1).toLowerCase());
90
+ })
91
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
92
+ .slice(0, limit)
93
+ .map((file) => ({
94
+ path: file.path,
95
+ label: tildeify(file.path, home),
96
+ hint: `${humanSize(file.size)} · ${relativeAge(nowMs - file.mtimeMs)}`,
97
+ }));
98
+ }
99
+
100
+ /** How many files to `stat` per directory. See the placement note below. */
101
+ const MAX_STATS_PER_DIR = 2_000;
102
+
103
+ /**
104
+ * The only filesystem in this module, and non-recursive on purpose: this
105
+ * runs before the first prompt paints, so a deep walk of somebody's
106
+ * Downloads would show up as the CLI hanging on startup.
107
+ */
108
+ export async function scanLikelyDirs(
109
+ dirs: string[] = likelyDirs({ platform: process.platform, cwd: process.cwd(), home: homedir() }),
110
+ ): Promise<CandidateFile[]> {
111
+ const out: CandidateFile[] = [];
112
+ for (const dir of dirs) {
113
+ let names: string[];
114
+ try {
115
+ names = (await readdir(dir, { withFileTypes: true }))
116
+ // Symlinks are followed, matching `listFolderVideos` in concat.ts: a
117
+ // folder of symlinks into another drive is a normal way to stage
118
+ // takes. The `stat` below resolves the target, and a dangling link
119
+ // throws into the per-file catch there.
120
+ .filter((e) => e.isFile() || e.isSymbolicLink())
121
+ .map((e) => e.name)
122
+ // Case-folded because VIDEO_EXTENSIONS is lowercase and recorders
123
+ // write `.MP4`/`.MOV`, the same fold `listFolderVideos` does.
124
+ .filter((name) => VIDEO_EXT_SET.has(extname(name).slice(1).toLowerCase()))
125
+ // The cap sits AFTER the filter on purpose: `readdir` has already
126
+ // materialised every dirent, so capping before it saves no I/O and
127
+ // throws away videos at random — readdir order is filesystem order,
128
+ // not mtime, so the take recorded 30 seconds ago is as likely to land
129
+ // in the discarded tail as anything else. Here it bounds the only
130
+ // real cost, the per-file stat.
131
+ .slice(0, MAX_STATS_PER_DIR);
132
+ } catch {
133
+ // A missing ~/Movies or an unreadable directory is ordinary. The
134
+ // suggestions are a convenience; nothing here may fail the wizard.
135
+ continue;
136
+ }
137
+ // Stat'd in parallel: awaited one at a time, a directory of 400 clips on
138
+ // an SMB share or an iCloud "Optimize Storage" ~/Movies is seconds of
139
+ // dead terminal before the first prompt paints, which is the startup hang
140
+ // non-recursion alone does not prevent. try/catch inside each task rather
141
+ // than allSettled because the recovery is identical for every failure —
142
+ // drop that one file — and this keeps the settled result already typed.
143
+ const stats = await Promise.all(
144
+ names.map(async (name): Promise<CandidateFile | undefined> => {
145
+ const path = join(dir, name);
146
+ try {
147
+ const st = await stat(path);
148
+ // A symlink to a DIRECTORY named `takes.mp4` passes the dirent
149
+ // check above and stats fine; without this it would be offered
150
+ // carrying the directory's size and mtime. concat.ts:346-353 —
151
+ // the parity this module claims — resolves and drops it too.
152
+ if (!st.isFile()) return undefined;
153
+ return { path, mtimeMs: st.mtimeMs, size: st.size };
154
+ } catch {
155
+ // Raced with a delete between readdir and stat, or a dangling
156
+ // symlink — skips this file only, never the whole directory.
157
+ return undefined;
158
+ }
159
+ }),
160
+ );
161
+ for (const file of stats) if (file !== undefined) out.push(file);
162
+ }
163
+ return out;
164
+ }
@@ -0,0 +1,77 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
+ import type { OverrideDoc } from "@ossclip/core";
3
+
4
+ /**
5
+ * The one sanctioned `overrides.json` write (PLAN 2026-08-04 Task 4), and the
6
+ * rule about when it may spend the `.bak`.
7
+ *
8
+ * Lifted out of `produce.ts` so the backup rule is testable at all: nothing in
9
+ * the repo invokes `produce()` (it needs ffmpeg, a transcript, a workdir and a
10
+ * render), and "the previous copy is still on disk, byte for byte" is a claim
11
+ * about a FILE — there is no pure form of it. `produce.ts` keeps the decision
12
+ * of WHETHER to write, the ordering relative to `render-props.json`, and the
13
+ * `console.log`.
14
+ */
15
+
16
+ /**
17
+ * Replace `overrides.json`, optionally refreshing `overrides.json.bak` first.
18
+ *
19
+ * REFRESHING THE BACKUP IS NOT PART OF WRITING (final review round 2, Critical
20
+ * 2 residual). The `.bak` is single-generation, so every refresh SPENDS
21
+ * whatever it held, and the two writers have very different claims on it:
22
+ *
23
+ * - A CUT re-anchoring rewrites absolute output-second VALUES all over the
24
+ * doc — split times, pins, framing — from a frame the pipeline just
25
+ * recomputed. That is unreadable in a diff and irreversible by hand, so the
26
+ * copy it replaces is worth keeping and this is the write the `.bak` exists
27
+ * for.
28
+ * - A CAPTION-KEY migration differs from the copy on disk in caption KEYS
29
+ * only, every one of which the run just printed by name, and (since
30
+ * `captionEditsToKeep`) it deletes nothing. There is nothing in the old
31
+ * copy worth recovering — while the `.bak` it would overwrite may be the
32
+ * user's last PRE-CUT save, which on the §137 field workdir is the only
33
+ * artefact holding `splits: [0.6]` and so the only route back to the split
34
+ * half they deleted (`legacySplitId` can no longer derive `600` from a
35
+ * re-anchored `splits: [0]`).
36
+ *
37
+ * The first cut of the §137 fix refreshed unconditionally, which meant the
38
+ * branch's own marquee scenario — three of that user's four retypes recovered
39
+ * — destroyed the evidence for the other half of the same bug. Gating the
40
+ * WRITE on work done (`produce.ts`) only removed the zero-repair case; this is
41
+ * the rest of it.
42
+ *
43
+ * Atomic via tmp+rename either way, matching the edit server's own
44
+ * `PUT /overrides` handler: the producer or a live editor session may read
45
+ * this file at any moment, and a half-written document would be worse than a
46
+ * stale one.
47
+ */
48
+ export async function writeOverrideDoc(
49
+ overridesPath: string,
50
+ doc: OverrideDoc,
51
+ opts: { refreshBackup: boolean },
52
+ ): Promise<void> {
53
+ if (opts.refreshBackup) {
54
+ try {
55
+ const raw = await readFile(overridesPath, "utf8");
56
+ await writeFile(`${overridesPath}.bak`, raw);
57
+ } catch {
58
+ // Nothing on disk to back up (first cut ever applied here) — fine.
59
+ }
60
+ }
61
+ const tmp = `${overridesPath}.tmp`;
62
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
63
+ await rename(tmp, overridesPath);
64
+ }
65
+
66
+ /**
67
+ * What the run says about that write. Pure, and separate from the write for
68
+ * the house reason — but also because the two halves of this sentence are the
69
+ * two halves of the decision above, and a line that claims a backup nobody
70
+ * took is how a user finds out too late (final review round 2).
71
+ */
72
+ export function overridesWriteLine(cutChanged: boolean): string {
73
+ return cutChanged
74
+ ? "▸ overrides.json re-anchored to the new cut and saved (previous copy kept as .bak)"
75
+ : "▸ overrides.json re-anchored to source-time caption keys and saved " +
76
+ "(overrides.json.bak left alone — it may be an older, pre-cut copy worth more than this one)";
77
+ }