perso-dubbing 0.2.0

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,227 @@
1
+ #!/usr/bin/env node
2
+ // Resolve API key (env override → local file → onboarding help).
3
+ // The raw key is never written to stdout. The CLI (--check) only shows a masked form.
4
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, realpathSync, mkdirSync } from 'node:fs';
5
+ import { execFileSync, spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+ import { CRED_DIR, CRED_FILE } from '../lib/config.mjs';
9
+ import { maskKey } from '../lib/mask.mjs';
10
+
11
+ const isWindows = process.platform === 'win32';
12
+
13
+ // Windows: decrypt the DPAPI ciphertext stored via ConvertFrom-SecureString under the current account.
14
+ function decryptDpapi(enc) {
15
+ const ps =
16
+ '$ErrorActionPreference="Stop";' +
17
+ `$sec = ConvertTo-SecureString ${JSON.stringify(enc.trim())};` +
18
+ '$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);' +
19
+ 'try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) }' +
20
+ 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }';
21
+ return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], {
22
+ encoding: 'utf8',
23
+ windowsHide: true,
24
+ stdio: ['ignore', 'pipe', 'ignore'], // explicit stdio — mitigates libuv handle conflicts in background/non-console contexts
25
+ }).replace(/\r?\n$/, '');
26
+ }
27
+
28
+ /** Returns the key in-process (never prints it). Returns null if absent.
29
+ * The resolved key is cached once per process — so Windows DPAPI decryption (running powershell)
30
+ * is not repeated on every request/poll (avoids blocking the event loop). A missing key (null) is not cached. */
31
+ let _cachedKey = null;
32
+ export function resolveKey() {
33
+ if (_cachedKey) return _cachedKey;
34
+ if (process.env.XP_API_KEY) return (_cachedKey = process.env.XP_API_KEY.trim());
35
+ if (existsSync(CRED_FILE)) {
36
+ const raw = readFileSync(CRED_FILE, 'utf8').trim();
37
+ if (!raw) return null;
38
+ if (!isWindows) return (_cachedKey = raw);
39
+ try { return (_cachedKey = decryptDpapi(raw)); }
40
+ catch { return null; } // corrupted credential (empty/broken) → treated as 'absent' (prompts re-registration), prevents crash
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Preload the key for long-running processes (dubbing/resume). At startup (before async), it finishes
47
+ * DPAPI decryption in a **clean child process** and passes the result via the XP_API_KEY env var. This avoids
48
+ * the Windows Node libuv (async.c) crash that occurs when the main process calls powershell.exe
49
+ * synchronously itself. The key is passed only through a pipe (never printed).
50
+ * Failures are ignored — falls back to the existing path (resolveKey decrypts directly).
51
+ */
52
+ export function preloadKeyEnv() {
53
+ if (process.env.XP_API_KEY) return; // not needed if already in env
54
+ if (!isWindows) return; // non-Windows reads the plaintext file directly (no powershell)
55
+ if (!existsSync(CRED_FILE)) return; // if there's no key, the gate handles it
56
+ try {
57
+ const self = fileURLToPath(import.meta.url);
58
+ const key = execFileSync(process.execPath, [self, '--export'], {
59
+ encoding: 'utf8',
60
+ windowsHide: true,
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ env: { ...process.env, PERSO_KEY_EXPORT: '1' }, // internal-call marker
63
+ }).replace(/\r?\n$/, '');
64
+ if (key) process.env.XP_API_KEY = key;
65
+ } catch { /* fallback: resolveKey decrypts directly */ }
66
+ }
67
+
68
+ /** Registration guidance when no key is present. Interactive input (Read-Host) yields empty values in agent environments without a TTY, so the file-based path (--import) is recommended first. */
69
+ export function onboardingHelp() {
70
+ const self = fileURLToPath(import.meta.url).replace(/\\/g, '/'); // forward-slash path, shell-agnostic
71
+ const example = join(dirname(CRED_DIR), 'perso_key.txt').replace(/\\/g, '/');
72
+ return [
73
+ 'No API key found. Do not paste the key into the chat — register it as below:',
74
+ '',
75
+ ` Recommended) Run: ! node "${self}" --watch`,
76
+ ' → Creates the key file and opens it in your editor automatically.',
77
+ ' Paste just the key and save — it is encrypted on the fly and the file is auto-deleted.',
78
+ '',
79
+ ' Manual) If you would rather create the file yourself:',
80
+ ` 1) Create a text file containing just the key on one line. e.g. ${example}`,
81
+ ` 2) Run: ! node "${self}" --import "${example}"`,
82
+ ' → The key is stored encrypted and the key file is auto-deleted.',
83
+ '',
84
+ ` (If you opened a real terminal window yourself, interactive entry also works: node "${self}" --set)`,
85
+ '',
86
+ 'Get an API key: https://developers.perso.ai/api-keys',
87
+ ].join('\n');
88
+ }
89
+
90
+ /** Takes a known key string: ensure directory → encrypt (Windows DPAPI) → save as ascii → read back to verify. The key is passed only via stdin (avoids command-line exposure). */
91
+ function storeKey(key) {
92
+ const k = (key ?? '').trim();
93
+ if (!k) { console.error('❌ The key is empty.'); process.exit(1); }
94
+ mkdirSync(CRED_DIR, { recursive: true });
95
+ if (isWindows) {
96
+ const lit = `'${CRED_FILE.replace(/'/g, "''")}'`;
97
+ const ps =
98
+ '$ErrorActionPreference="Stop";' +
99
+ '$k = ([Console]::In.ReadToEnd()).Trim();' +
100
+ 'if ($k.Length -lt 1) { throw "empty" };' +
101
+ `($k | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString) | Set-Content -Encoding ascii ${lit}`;
102
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { input: k });
103
+ } else {
104
+ writeFileSync(CRED_FILE, k, { mode: 0o600 });
105
+ }
106
+ _cachedKey = null;
107
+ const back = resolveKey();
108
+ if (!back) { console.error('❌ Could not verify registration. Try again.'); process.exit(1); }
109
+ console.log(`✅ Key registered: ${maskKey(back)}`);
110
+ }
111
+
112
+ /** Register using a key contained in a file — no interactive input needed, so it works in TTY-less environments/agents. Deletes the original file after registration. */
113
+ export function importKey(srcPath) {
114
+ let key;
115
+ try { key = readFileSync(srcPath, 'utf8'); }
116
+ catch { console.error(`❌ Could not read the key file: ${srcPath}`); process.exit(1); }
117
+ storeKey(key);
118
+ try { unlinkSync(srcPath); console.log(' (original key file deleted)'); } catch { /* ignore deletion failure */ }
119
+ }
120
+
121
+ /** Interactive key registration — only in a real terminal (TTY). Without a TTY an empty value would be saved, so steer users to --import. */
122
+ export function setKey() {
123
+ if (!process.stdin.isTTY) {
124
+ const self = fileURLToPath(import.meta.url).replace(/\\/g, '/');
125
+ const example = join(dirname(CRED_DIR), 'perso_key.txt').replace(/\\/g, '/');
126
+ console.error([
127
+ 'This environment has no interactive input (Read-Host/read), so the key would be saved empty.',
128
+ 'Register via a key file instead:',
129
+ ` 1) Create a file containing just the key on one line e.g. ${example}`,
130
+ ` 2) node "${self}" --import "${example}"`,
131
+ ].join('\n'));
132
+ process.exit(1);
133
+ }
134
+ mkdirSync(CRED_DIR, { recursive: true });
135
+ try {
136
+ if (isWindows) {
137
+ const lit = `'${CRED_FILE.replace(/'/g, "''")}'`;
138
+ const ps =
139
+ '$ErrorActionPreference="Stop";' +
140
+ "$s = Read-Host 'Perso API Key' -AsSecureString;" +
141
+ "if ($s.Length -lt 1) { throw 'empty' };" +
142
+ `($s | ConvertFrom-SecureString) | Set-Content -Encoding ascii ${lit}`;
143
+ execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' });
144
+ } else {
145
+ const lit = `'${CRED_FILE.replace(/'/g, "'\\''")}'`;
146
+ const sh = `umask 177; printf 'Perso API Key: '; IFS= read -rs k; [ -n "$k" ] || { echo; exit 1; }; printf '%s' "$k" > ${lit}; echo`;
147
+ execFileSync('/bin/sh', ['-c', sh], { stdio: 'inherit' });
148
+ }
149
+ } catch {
150
+ console.error('\n❌ The key was empty or could not be saved. Run it again.');
151
+ process.exit(1);
152
+ }
153
+ _cachedKey = null;
154
+ const back = resolveKey();
155
+ if (back) console.log(`✅ Key registered: ${maskKey(back)}`);
156
+ else { console.error('❌ Could not read the saved file. Try again.'); process.exit(1); }
157
+ }
158
+
159
+ /** Default key-input file path (next to the home directory). */
160
+ export function keyFilePath() {
161
+ return join(dirname(CRED_DIR), 'perso_key.txt');
162
+ }
163
+
164
+ /** Open a file in the OS default editor (best-effort). Skipped when PERSO_NO_OPEN is set (for headless/automation). */
165
+ function openInEditor(path) {
166
+ if (process.env.PERSO_NO_OPEN) return;
167
+ try {
168
+ const [bin, args] =
169
+ isWindows ? ['notepad.exe', [path]] :
170
+ process.platform === 'darwin' ? ['open', ['-t', path]] :
171
+ ['xdg-open', [path]];
172
+ spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
173
+ } catch { /* ignore auto-open failure — the user can open the path manually */ }
174
+ }
175
+
176
+ /** Create an empty key file and watch it — when the user pastes the key and saves, it auto-detects → encrypts and saves → deletes the file. No TTY needed. */
177
+ export function watchKey(file) {
178
+ const path = file || keyFilePath();
179
+ if (!existsSync(path)) writeFileSync(path, '');
180
+ openInEditor(path); // auto-open in the OS default editor (Notepad, etc.)
181
+ console.log('An editor will open. Paste only the API key and save (Ctrl+S / Cmd+S) — it registers automatically.');
182
+ console.log(`(If it doesn't open, open this file yourself): ${path}`);
183
+ const start = Date.now();
184
+ const TIMEOUT_MS = 10 * 60 * 1000;
185
+ const tick = () => {
186
+ let content = '';
187
+ try { content = readFileSync(path, 'utf8').trim(); } catch { /* lock/transient error → next tick */ }
188
+ if (content) {
189
+ storeKey(content); // encrypt and save in real time + verify (prints ✅)
190
+ try { unlinkSync(path); console.log(' (key file auto-deleted)'); } catch { /* ignore */ }
191
+ process.exit(0);
192
+ }
193
+ if (Date.now() - start > TIMEOUT_MS) {
194
+ console.error('Timed out — no key input detected. Try again.');
195
+ process.exit(1);
196
+ }
197
+ setTimeout(tick, 500);
198
+ };
199
+ setTimeout(tick, 500);
200
+ }
201
+
202
+ // CLI: `--watch [file]` auto-detect registration (recommended) / `--import <file>` / `--set` interactive / `--check` (default) verify
203
+ const isMain = process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
204
+ if (isMain) {
205
+ const argv = process.argv.slice(2);
206
+ if (argv[0] === '--export') {
207
+ // internal only: print the key only when preloadKeyEnv captures it via a pipe (avoids accidental chat exposure)
208
+ if (process.env.PERSO_KEY_EXPORT !== '1') { console.error('--export is internal only.'); process.exit(1); }
209
+ const key = resolveKey();
210
+ if (key) process.stdout.write(key);
211
+ process.exit(key ? 0 : 2);
212
+ } else if (argv[0] === '--watch') {
213
+ watchKey(argv[1]);
214
+ } else if (argv[0] === '--import') {
215
+ if (!argv[1]) { console.error('Usage: --import "<key file path>"'); process.exit(1); }
216
+ importKey(argv[1]);
217
+ } else if (argv.includes('--set')) {
218
+ setKey();
219
+ } else {
220
+ const key = resolveKey();
221
+ if (!key) {
222
+ console.error(onboardingHelp());
223
+ process.exit(2);
224
+ }
225
+ console.log(`Key OK: ${maskKey(key)}`);
226
+ }
227
+ }