ossclip 0.1.16 → 0.1.18
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/README.md +2 -0
- package/package.json +4 -4
- package/src/interactive/ask-input.ts +191 -0
- package/src/interactive/picker.ts +286 -0
- package/src/interactive/produce-wizard.ts +6 -22
- package/src/interactive/suggest-inputs.ts +164 -0
- package/src/program.ts +32 -0
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ ossclip produce podcast.mp4 --produce --clip 60 -o clip.mp4
|
|
|
31
31
|
ossclip edit "<work directory>"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
**Not sure what to run?** `ossclip` with no arguments opens a menu, and every choice prints the equivalent command before it runs. Choose **produce a video** and the wizard's first question offers the newest videos in your working directory, Downloads and Movies (Videos on Linux and Windows); a **Browse…** row that opens your operating system's own file picker; and typing a path. Over SSH on macOS or Windows, or on a Linux box with no display or no `zenity`/`kdialog`, the Browse rows are simply not shown — there is no window to open. `OSSCLIP_NO_PICKER` set to a non-empty value hides them too, leaving suggestions and typing — truthiness, not presence, so an empty `OSSCLIP_NO_PICKER=` leaves the picker on.
|
|
35
|
+
|
|
34
36
|
**Scope, honestly:** ossclip is at its best polishing a take you have already cut down. `--clip` selects a single strongest window from long-form input — one clip, not N.
|
|
35
37
|
|
|
36
38
|
AI can make mistakes: the cut, the captions and every graphic are generated — review the output before publishing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
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.18",
|
|
40
|
+
"@ossclip/renderer": "0.1.18",
|
|
41
|
+
"@ossclip/scenes": "0.1.18"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { livePickerDeps, pickPath, pickerAvailable, type PickMode } from "./picker";
|
|
4
|
+
import { assertInteractive, select, text, unwrap } from "./prompts";
|
|
5
|
+
import { rankSuggestions, scanLikelyDirs, type Suggestion } from "./suggest-inputs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* "Which video?" — the first prompt of the wizard, and until §136 the one
|
|
9
|
+
* step a non-technical user could not get past: it demanded a typed path.
|
|
10
|
+
* Now it is a menu of the newest videos on the machine, a native picker, and
|
|
11
|
+
* typing, in that order.
|
|
12
|
+
*
|
|
13
|
+
* Every branch converges on `validateInputPath` rather than trusting its
|
|
14
|
+
* source. A picker result is not more trustworthy than typed text: the file
|
|
15
|
+
* can be deleted between the dialog closing and the path arriving, and
|
|
16
|
+
* "Browse for a folder" happily returns a folder with no video in it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const BROWSE_FILE = "__browse_file__";
|
|
20
|
+
export const BROWSE_FOLDER = "__browse_folder__";
|
|
21
|
+
export const TYPE_PATH = "__type_path__";
|
|
22
|
+
|
|
23
|
+
export type InputSource = "suggestion" | "picker" | "typed";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Not a zod schema, deliberately: the house rule parses user values with zod
|
|
27
|
+
* because a typo must not silently coerce, and there is nothing to coerce
|
|
28
|
+
* here — this is a filesystem predicate (does it exist, is it a file or a
|
|
29
|
+
* folder), which zod cannot answer. The wording is carried over verbatim
|
|
30
|
+
* from the prompt this replaced.
|
|
31
|
+
*
|
|
32
|
+
* There is deliberately no extension whitelist, on any branch: typing a path
|
|
33
|
+
* was never extension-checked either, and ossclip accepts whatever ffmpeg can
|
|
34
|
+
* read — a list would reject legitimate containers.
|
|
35
|
+
*/
|
|
36
|
+
export function validateInputPath(v: string | undefined): string | undefined {
|
|
37
|
+
if (!v) return "a path is required";
|
|
38
|
+
if (!existsSync(v)) return `no such path: ${v}`;
|
|
39
|
+
const st = statSync(v);
|
|
40
|
+
if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function inputChoices(
|
|
45
|
+
suggestions: Suggestion[],
|
|
46
|
+
canBrowse: boolean,
|
|
47
|
+
): { value: string; label: string; hint?: string }[] {
|
|
48
|
+
const rows = suggestions.map((s) => ({ value: s.path, label: s.label, hint: s.hint }));
|
|
49
|
+
// Offering a "Browse…" row on a machine with no dialog to open is worse
|
|
50
|
+
// than not offering it — `pickerAvailable` has already probed, so the row
|
|
51
|
+
// simply does not exist when it cannot work.
|
|
52
|
+
if (canBrowse) {
|
|
53
|
+
rows.push(
|
|
54
|
+
{ value: BROWSE_FILE, label: "Browse…", hint: "opens a file picker" },
|
|
55
|
+
{ value: BROWSE_FOLDER, label: "Browse for a folder of clips", hint: "concatenated by name" },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
rows.push({ value: TYPE_PATH, label: "Type a path", hint: "if you already know it" });
|
|
59
|
+
return rows;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let lastInputSource: InputSource | "argv" = "argv";
|
|
63
|
+
|
|
64
|
+
/** Which branch produced the input, for the produce_completed event (§136). */
|
|
65
|
+
export function noteInputSource(s: InputSource): void {
|
|
66
|
+
lastInputSource = s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function inputSourceUsed(): InputSource | "argv" {
|
|
70
|
+
return lastInputSource;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Module state outlives a single wizard, and "argv" is a real answer — the
|
|
75
|
+
* value the telemetry reads when `ossclip <path>` prefilled the input and
|
|
76
|
+
* `askInput` never ran. So a second run in the same process (a batch or REPL
|
|
77
|
+
* caller, and every test file, where module state persists across `it`s)
|
|
78
|
+
* would report the PREVIOUS run's branch with nothing to signal the staleness.
|
|
79
|
+
* Reset at the start of a run — or of a test — rather than trusting one
|
|
80
|
+
* invocation per process.
|
|
81
|
+
*/
|
|
82
|
+
export function resetInputSource(): void {
|
|
83
|
+
lastInputSource = "argv";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The seam. Same shape and same reason as `PickerDeps` (picker.ts) and
|
|
88
|
+
* `TtyDeps` (tty.ts): the branch logic is what has rules worth pinning — a
|
|
89
|
+
* cancelled dialog re-asks, every branch validates, each sets its own source —
|
|
90
|
+
* and none of that is reachable through a real `select` blocking on a human.
|
|
91
|
+
* `pickPath` is tested the same way through its own injected deps.
|
|
92
|
+
*
|
|
93
|
+
* Structural signatures rather than `typeof select`: clack's are generic, and
|
|
94
|
+
* a fake cannot satisfy an unresolved type parameter. Narrowing here is what
|
|
95
|
+
* makes the fakes writable, exactly as `PickerDeps` declares `hasBin` rather
|
|
96
|
+
* than `typeof binOnPath`.
|
|
97
|
+
*/
|
|
98
|
+
export interface AskInputDeps {
|
|
99
|
+
/** Probed once per run, not per loop — no dialog appears mid-prompt. */
|
|
100
|
+
canBrowse: boolean;
|
|
101
|
+
suggest: () => Promise<Suggestion[]>;
|
|
102
|
+
pick: (mode: PickMode) => Promise<string | undefined>;
|
|
103
|
+
select: (opts: {
|
|
104
|
+
message: string;
|
|
105
|
+
options: { value: string; label: string; hint?: string }[];
|
|
106
|
+
}) => Promise<string | symbol>;
|
|
107
|
+
text: (opts: {
|
|
108
|
+
message: string;
|
|
109
|
+
placeholder?: string;
|
|
110
|
+
validate?: (v: string | undefined) => string | undefined;
|
|
111
|
+
}) => Promise<string | symbol>;
|
|
112
|
+
/** Injected because the guard itself takes an injected check (tty.ts:58). */
|
|
113
|
+
assertInteractive: () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const liveAskInputDeps = (): AskInputDeps => ({
|
|
117
|
+
canBrowse: pickerAvailable(livePickerDeps()),
|
|
118
|
+
suggest: async () => rankSuggestions(await scanLikelyDirs(), Date.now(), homedir()),
|
|
119
|
+
pick: (mode) => pickPath(mode),
|
|
120
|
+
select,
|
|
121
|
+
text,
|
|
122
|
+
assertInteractive: () => assertInteractive("input prompt"),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const typePath = async (deps: AskInputDeps): Promise<string> =>
|
|
126
|
+
unwrap(
|
|
127
|
+
await deps.text({
|
|
128
|
+
// Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
|
|
129
|
+
// (folder-input-brief.md) but this prompt still rejected a directory —
|
|
130
|
+
// the wizard was the only way in that couldn't do what the CLI could.
|
|
131
|
+
// A folder is concatenated by name (codepoint order, like `ls`); --sort
|
|
132
|
+
// mtime reorders it but stays a typed flag, not a wizard question (see
|
|
133
|
+
// the file-level comment in produce-wizard.ts for why).
|
|
134
|
+
message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
|
|
135
|
+
placeholder: "./raw/take1.mp4",
|
|
136
|
+
validate: validateInputPath,
|
|
137
|
+
}),
|
|
138
|
+
) as string;
|
|
139
|
+
|
|
140
|
+
export async function askInput(deps: AskInputDeps = liveAskInputDeps()): Promise<string> {
|
|
141
|
+
deps.assertInteractive();
|
|
142
|
+
const canBrowse = deps.canBrowse;
|
|
143
|
+
const suggestions = await deps.suggest();
|
|
144
|
+
|
|
145
|
+
// Nothing found and nowhere to browse: a one-row menu is pure noise, so go
|
|
146
|
+
// straight to the prompt this whole unit replaced.
|
|
147
|
+
if (suggestions.length === 0 && !canBrowse) {
|
|
148
|
+
noteInputSource("typed");
|
|
149
|
+
return typePath(deps);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Loops rather than returns, because cancelling the OS dialog must land
|
|
153
|
+
// back on the menu — Escape in a Finder window means "not that one", not
|
|
154
|
+
// "abandon the run". Ctrl-C at the menu still exits via `unwrap`.
|
|
155
|
+
for (;;) {
|
|
156
|
+
const choice = unwrap(
|
|
157
|
+
await deps.select({ message: "Which video?", options: inputChoices(suggestions, canBrowse) }),
|
|
158
|
+
) as string;
|
|
159
|
+
|
|
160
|
+
if (choice === TYPE_PATH) {
|
|
161
|
+
noteInputSource("typed");
|
|
162
|
+
return typePath(deps);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (choice === BROWSE_FILE || choice === BROWSE_FOLDER) {
|
|
166
|
+
const picked = await deps.pick(choice === BROWSE_FOLDER ? "folder" : "file");
|
|
167
|
+
// An empty result conflates "cancelled" with "the backend failed
|
|
168
|
+
// silently" — no backend distinguishes them reliably (picker.ts), and
|
|
169
|
+
// `pickPath` has already printed the fallback notice in the failure
|
|
170
|
+
// case. Re-asking is the right next step either way.
|
|
171
|
+
if (picked === undefined) continue;
|
|
172
|
+
const problem = validateInputPath(picked);
|
|
173
|
+
if (problem !== undefined) {
|
|
174
|
+
console.log(`▸ ${problem}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
noteInputSource("picker");
|
|
178
|
+
return picked;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A suggestion. Still validated: the listing is a snapshot, and a file
|
|
182
|
+
// can be moved between the scan and the keypress.
|
|
183
|
+
const problem = validateInputPath(choice);
|
|
184
|
+
if (problem !== undefined) {
|
|
185
|
+
console.log(`▸ ${problem}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
noteInputSource("suggestion");
|
|
189
|
+
return choice;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}
|
package/src/program.ts
CHANGED
|
@@ -7,6 +7,21 @@ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
|
7
7
|
import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
8
8
|
import { loadEnvFiles } from "./env";
|
|
9
9
|
import { produce } from "./produce";
|
|
10
|
+
// The one interactive import that is STATIC rather than `await import()`: the
|
|
11
|
+
// `resetInputSource()` run boundary in `buildProgram` has to run synchronously
|
|
12
|
+
// while the program is being built, and `buildProgram` cannot await. The graph
|
|
13
|
+
// already loads @ossclip/renderer and @ossclip/scenes eagerly through
|
|
14
|
+
// produce.ts, so clack riding along costs ~14ms on a ~320ms startup — cheap
|
|
15
|
+
// enough not to trade for a racy fire-and-forget import (§136).
|
|
16
|
+
//
|
|
17
|
+
// Consequence worth stating for whoever edits ask-input.ts next: this module,
|
|
18
|
+
// and everything it imports (picker.ts, prompts.ts → @clack/prompts,
|
|
19
|
+
// suggest-inputs.ts), is now EAGER on every ossclip invocation — `--version`
|
|
20
|
+
// and `doctor` included. A heavy dependency added there is no longer free.
|
|
21
|
+
// Removing this line to "restore laziness" deletes the run boundary with it:
|
|
22
|
+
// `input_source` would then report the PREVIOUS run's branch, and the only
|
|
23
|
+
// test that notices is the buildProgram case in telemetry.test.ts.
|
|
24
|
+
import { inputSourceUsed, resetInputSource } from "./interactive/ask-input";
|
|
10
25
|
import { setReplayArgv } from "./replay-argv";
|
|
11
26
|
import {
|
|
12
27
|
bootstrapTelemetry,
|
|
@@ -38,6 +53,19 @@ const envFiles = loadEnvFiles();
|
|
|
38
53
|
export function buildProgram(): Command {
|
|
39
54
|
const program = new Command();
|
|
40
55
|
|
|
56
|
+
// §136: the input-source telemetry is module state, so something has to say
|
|
57
|
+
// when a RUN begins. Not the produce action — every wizard route re-enters
|
|
58
|
+
// that same action through `program.parseAsync` (§129), so a reset there
|
|
59
|
+
// fires on the re-entered parse, AFTER `askInput` recorded the branch, and
|
|
60
|
+
// `input_source` would read "argv" for every wizard run: the feature would
|
|
61
|
+
// measure exactly nothing. Nor is the process the boundary — a batch or REPL
|
|
62
|
+
// driver runs produce more than once, and the second run would report the
|
|
63
|
+
// first one's branch. The PROGRAM CONSTRUCTION is the boundary: commander 12
|
|
64
|
+
// keeps option state across parseAsync calls (see the bare-`produce` refusal
|
|
65
|
+
// below), so any batch has to rebuild the program per run anyway, which makes
|
|
66
|
+
// one reset per `buildProgram` exactly one per run in both cases.
|
|
67
|
+
resetInputSource();
|
|
68
|
+
|
|
41
69
|
// Before dispatch, so the one-time first-run notice precedes any command's
|
|
42
70
|
// own output. Inert in this repo's tests by construction: while POSTHOG_KEY
|
|
43
71
|
// is the placeholder, bootstrap touches no disk and sends nothing (FINDINGS
|
|
@@ -433,6 +461,10 @@ export function buildProgram(): Command {
|
|
|
433
461
|
render: opts.render !== false,
|
|
434
462
|
source_duration_bucket: durationBucket(result.sourceDurationSec),
|
|
435
463
|
scenes: result.sceneCount,
|
|
464
|
+
// Which branch of the input prompt was used — a branch name, never
|
|
465
|
+
// the path itself (§136). The picker exists because typing a path
|
|
466
|
+
// blocked non-technical users; this is how we find out if it helped.
|
|
467
|
+
input_source: inputSourceUsed(),
|
|
436
468
|
});
|
|
437
469
|
if (!telemetry.disabled) {
|
|
438
470
|
telemetry.state.produceCount += 1;
|