ossclip 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/paths.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { copyFile, rename, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname } from "node:path";
5
+
6
+ /**
7
+ * Out-path safety helpers (2026-08-16 field incident): the wizard's output
8
+ * prompt is a plain text input, and NO shell expands a wizard text input —
9
+ * a typed `~/Downloads/x.mp4` resolved against cwd, so the end-of-run rename
10
+ * ENOENT'd after a 50-minute render because `<cwd>/~/Downloads` never
11
+ * existed. Two defenses live here: tilde expansion applied at every
12
+ * user-supplied path's resolution site, and failing (or healing) a bad out
13
+ * path in the first second instead of at the final rename.
14
+ */
15
+
16
+ /**
17
+ * Expand a leading `~` to the home directory. `~user` forms are deliberately
18
+ * left untouched — resolving another user's home needs /etc/passwd semantics
19
+ * we don't have, and a wrong guess would be worse than the literal path.
20
+ * `home` is injectable so the matrix is testable without the real homedir.
21
+ */
22
+ export function expandHome(path: string, home: string = homedir()): string {
23
+ if (path === "~") return home;
24
+ if (path.startsWith("~/")) return home + path.slice(1);
25
+ return path;
26
+ }
27
+
28
+ /**
29
+ * mkdir -p the parent of a would-be output file. mkdir chosen over refusal:
30
+ * the path is the user's explicit intent and creating a folder is what they'd
31
+ * do by hand; a genuinely un-creatable path (permissions) still fails loudly
32
+ * — just upfront now, not after the render. `mkdirFn` is injectable so the
33
+ * test asserts the directory asked for without touching a filesystem.
34
+ */
35
+ export function ensureParentDir(
36
+ filePath: string,
37
+ mkdirFn: (dir: string) => void = (dir) => mkdirSync(dir, { recursive: true }),
38
+ ): void {
39
+ mkdirFn(dirname(filePath));
40
+ }
41
+
42
+ /**
43
+ * `<out>.mp4` + `".cover.jpg"` → `<out>.cover.jpg`: the output's sibling
44
+ * artifact path, centralizing the replace idiom that lived (twice, and once
45
+ * stale — see the completion banner's call site) inline. The extension match
46
+ * excludes path separators on purpose: the bare-idiom regex `(\.[^.]+)?$`
47
+ * would treat a dotted DIRECTORY name as the extension of an extensionless
48
+ * output ("/out.v2/final" → "/out.cover.jpg", a file outside the folder the
49
+ * user chose). An input with no extension gains the suffix whole.
50
+ *
51
+ * Lives here rather than in produce.ts (its original home, 2026-08-17): the
52
+ * edit server derives `<out>.thumbnail.png` from command.json's recorded out
53
+ * and cannot import produce.ts — produce imports edit (recordRecentProject),
54
+ * and produce's import graph drags the renderer into a deliberately
55
+ * dependency-free server. produce.ts re-exports it for its existing callers.
56
+ */
57
+ export function artifactPath(outPath: string, suffix: string): string {
58
+ return outPath.replace(/(\.[^./\\]+)?$/, suffix);
59
+ }
60
+
61
+ /** The rename/copy seam, injectable for the EXDEV test. */
62
+ export interface MoveDeps {
63
+ rename: (from: string, to: string) => Promise<void>;
64
+ copyFile: (from: string, to: string) => Promise<void>;
65
+ unlink: (path: string) => Promise<void>;
66
+ }
67
+
68
+ const liveMoveDeps: MoveDeps = { rename, copyFile, unlink };
69
+
70
+ /**
71
+ * `fs.rename` cannot cross volumes — an `--out` on an external drive throws
72
+ * EXDEV at the very end of the run (the sibling trap to the ENOENT above;
73
+ * ENOENT is prevented upfront by `ensureParentDir`). On EXDEV, fall back to
74
+ * copy+unlink; every other failure still throws, unchanged.
75
+ */
76
+ export async function moveFile(
77
+ from: string,
78
+ to: string,
79
+ deps: MoveDeps = liveMoveDeps,
80
+ ): Promise<void> {
81
+ try {
82
+ await deps.rename(from, to);
83
+ } catch (err) {
84
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
85
+ await deps.copyFile(from, to);
86
+ await deps.unlink(from);
87
+ }
88
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PORTRAIT_MIME_TYPES } from "@ossclip/core";
4
+ import { expandHome } from "./paths";
5
+
6
+ /**
7
+ * The per-project portrait override (editor face swap, 2026-08-17): a
8
+ * `portrait-override.<ext>` file in the workdir that outranks both the
9
+ * `--portrait` flag/pin and the config's `portrait`. One rule, spelled once,
10
+ * shared by produce.ts's resolution, thumbnail-panel.ts's pure matrix and
11
+ * edit.ts's endpoints.
12
+ *
13
+ * Lives in apps/cli rather than core because every consumer is here — core's
14
+ * thumbnailStep receives an already-resolved path — and in its own leaf
15
+ * module rather than produce.ts for the same reason `artifactPath` moved to
16
+ * paths.ts: edit.ts cannot import produce.ts (produce imports edit, and
17
+ * produce's import graph drags the renderer into a deliberately
18
+ * dependency-free server).
19
+ */
20
+
21
+ export const PORTRAIT_OVERRIDE_BASENAME = "portrait-override";
22
+
23
+ /** Which portrait a resolution picked — the panel labels the swap state
24
+ * from this, so the vocabulary is part of the contract. */
25
+ export type PortraitSource = "override" | "flag" | "config";
26
+
27
+ /**
28
+ * The workdir's override file, or null when none exists. Extensions are
29
+ * probed in PORTRAIT_MIME_TYPES key order, first hit wins — the POST
30
+ * endpoint enforces at most one override, so two can only mean a hand-copied
31
+ * file, and a deterministic table-order pick beats a readdir-order coin
32
+ * flip. `exists` is injectable so the extension matrix needs no filesystem.
33
+ */
34
+ export function portraitOverridePath(
35
+ work: string,
36
+ exists: (path: string) => boolean = existsSync,
37
+ ): string | null {
38
+ for (const ext of Object.keys(PORTRAIT_MIME_TYPES)) {
39
+ const path = join(work, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
40
+ if (exists(path)) return path;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * The override's filename extension for an uploaded mime type, or undefined
47
+ * when the type is outside the table the Gemini API accepts. Reverse lookup
48
+ * over PORTRAIT_MIME_TYPES so the two directions can never drift; for
49
+ * `image/jpeg`'s two spellings the first table key (`jpg`) wins.
50
+ */
51
+ export function portraitExtensionForMime(mimeType: string): string | undefined {
52
+ return Object.keys(PORTRAIT_MIME_TYPES).find((ext) => PORTRAIT_MIME_TYPES[ext] === mimeType);
53
+ }
54
+
55
+ export interface ResolvedPortrait {
56
+ path: string;
57
+ source: PortraitSource;
58
+ }
59
+
60
+ /**
61
+ * The one portrait-precedence rule: workdir override > flag/pin > config.
62
+ * A per-project expression chosen in the editor must survive CLI re-renders,
63
+ * so the override beats even an explicit `--portrait` — the flag/config
64
+ * portrait is the fallback headshot, and a replay silently reverting the
65
+ * swapped face would undo the one thing the swap exists for.
66
+ *
67
+ * expandHome covers the flag and config paths (the 2026-08-16 tilde
68
+ * incident, paths.ts) but not the override, which is server-built and
69
+ * already absolute. The config side is `typeof`, never truthiness — the
70
+ * `portrait` posture: config.json is hand-edited and unparsed.
71
+ */
72
+ export function resolvePortrait(args: {
73
+ overridePath: string | null;
74
+ flagPortrait: string | undefined;
75
+ cfgPortrait: unknown;
76
+ home?: string;
77
+ }): ResolvedPortrait | undefined {
78
+ if (args.overridePath !== null) return { path: args.overridePath, source: "override" };
79
+ if (args.flagPortrait !== undefined) {
80
+ return { path: expandHome(args.flagPortrait, args.home), source: "flag" };
81
+ }
82
+ if (typeof args.cfgPortrait === "string") {
83
+ return { path: expandHome(args.cfgPortrait, args.home), source: "config" };
84
+ }
85
+ return undefined;
86
+ }