@kaydash9999/banker-plugins 0.20.2 → 0.21.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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -18,6 +18,7 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import os from 'node:os';
21
+ import crypto from 'node:crypto';
21
22
  // Default import (the CJS module.exports object) so ffmpeg helpers can read cp.spawnSync at
22
23
  // call time — a destructured `import { spawnSync }` snapshots the binding and cannot be stubbed.
23
24
  import cp from 'node:child_process';
@@ -27,6 +28,9 @@ import cp from 'node:child_process';
27
28
  // ---------------------------------------------------------------------------
28
29
 
29
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ // A real wait between polls is at least 1 s: an interval of 0 must not poll a paid API in a busy loop.
32
+ const MIN_POLL_WAIT_MS = 1000;
33
+ const pollSleep = (ms) => sleep(Math.max(MIN_POLL_WAIT_MS, ms));
30
34
  const round2 = (x) => Math.round(x * 100) / 100;
31
35
 
32
36
  // Scheme+host of a URL, dropping any path/query — Azure endpoints are the origin only.
@@ -53,7 +57,9 @@ function truncate(x, n = 300) {
53
57
 
54
58
  // Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
55
59
  // error's code or name only, never its text: fetch can quote a header value, a key included.
56
- const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
60
+ const why = (r) => (r.error ? ` (${r.errno || r.error.name})` : '');
61
+ // AbortSignal.timeout rejects with a DOMException whose numeric code (23) means nothing to a reader
62
+ export const errnoOf = (e) => (e?.name === 'TimeoutError' ? 'TimeoutError' : e?.cause?.code || e?.code || null);
57
63
 
58
64
  // Last n chars of ffmpeg stderr (the useful part) for error messages.
59
65
  const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
@@ -116,7 +122,7 @@ export function parseConsoleSample(text) {
116
122
  */
117
123
  export function parseEnvFile(text) {
118
124
  const env = {};
119
- for (const line of String(text || '').split(/\r?\n/)) {
125
+ for (const line of String(text || '').split(/\r\n|\r|\n/)) {
120
126
  if (line.trimStart().startsWith('#')) continue;
121
127
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=([^\n]*)$/);
122
128
  if (m) env[m[1]] = envValue(m[2]);
@@ -176,7 +182,8 @@ export function resolveCreds({ projectDir } = {}) {
176
182
  const p = candidates.find((c) => fs.existsSync(c));
177
183
  if (!p) return { _source: null };
178
184
  const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
179
- return p === home ? creds : withoutMachineOnly(creds);
185
+ if (p === home) return creds;
186
+ return trackedByGit(p) ? { ...withoutMachineOnly(creds), _tracked: true } : withoutMachineOnly(creds);
180
187
  }
181
188
 
182
189
  function withoutMachineOnly(creds) {
@@ -190,17 +197,174 @@ function withoutMachineOnly(creds) {
190
197
  * and null/undefined values are skipped. Returns the written path. A name that is not a variable
191
198
  * name, or a value with a line break, throws before anything is written: either would add lines of
192
199
  * its own (a FFMPEG_PATH, a second endpoint) to the file.
200
+ * The file's lines stay as a person wrote them (comments, `export` lines, names not set here, each
201
+ * line's own end, bytes in another encoding): only the line each set name is read from changes, and a
202
+ * name the file lacks is added at the end. The new file replaces the old one whole, so a write cut
203
+ * short by an error leaves the old one. A file with a second name (a hard link), one the new copy would
204
+ * give another owner, or one in a folder that takes no new file (or that Windows holds open) is written
205
+ * in place, as before. The home key and settings files are written where a link of theirs points; any other
206
+ * key file that is a link (one a repository can bring) is refused, as are a folder, a pipe and a link to nothing.
193
207
  */
194
208
  export function persistCreds(creds, { target } = {}) {
195
209
  if (!target) throw new Error('persistCreds: target path required');
196
- const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
197
- const body = lines.length ? `${lines.join('\n')}\n` : '';
210
+ const set = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => [k, String(v)]);
211
+ for (const [k, v] of set) envLine(k, v);
198
212
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
199
- fs.writeFileSync(target, body, { mode: 0o600 });
200
- try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
213
+ refuseTracked(target);
214
+ const file = keyFileAt(target);
215
+ writeKeyFile(file, mergedEnv(readIfAny(file), set));
201
216
  return target;
202
217
  }
203
218
 
219
+ // Where the key file's bytes live: the path, or what a link there points to (a plain write would
220
+ // follow it too). Anything but a regular file is refused: a pipe would block the write for good.
221
+ function keyFileAt(target) {
222
+ let st;
223
+ try { st = fs.lstatSync(target); } catch (e) { if (e.code === 'ENOENT') return target; throw e; }
224
+ const file = st.isSymbolicLink() ? linkedFile(target) : target;
225
+ if (!fs.statSync(file).isFile()) throw new Error(`persistCreds: ${target} is not a regular file; nothing was written`);
226
+ if (file !== target) refuseProjectLink(target);
227
+ return file;
228
+ }
229
+
230
+ // A project key file git tracks would carry a key into the next commit (.gitignore does not untrack it).
231
+ function refuseTracked(target) {
232
+ if ([homeCredsPath(), homeSettingsPath()].includes(path.resolve(target)) || !trackedByGit(target)) return;
233
+ throw new Error(`persistCreds: ${target} is tracked by git, so a key written there would go into a commit; nothing was written. Untrack it (git rm --cached) and keep it in .gitignore, or use the home key file`);
234
+ }
235
+
236
+ // Whether git tracks `file`: true, or false when it does not or cannot say (no git, no repository).
237
+ // git runs from the home folder with -C: Windows looks for a program in the folder it starts in first,
238
+ // and a repository can bring a git.exe. fsmonitor is off, so a repository's config starts no hook.
239
+ function trackedByGit(file) {
240
+ const r = cp.spawnSync('git', ['-C', path.dirname(path.resolve(file)), '-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', path.basename(file)], {
241
+ cwd: os.homedir(), stdio: 'ignore', timeout: 5000, windowsHide: true,
242
+ });
243
+ return r.status === 0;
244
+ }
245
+
246
+ // A project's key file can come with a repository, and a link there can lead to a file of the user's (a
247
+ // dotfile) or of the repository (its .git/config, a tracked file) that must not get KEY= lines: a link
248
+ // there is refused. The home key and settings files keep following a link anywhere (a dotfiles setup).
249
+ function refuseProjectLink(target) {
250
+ if ([homeCredsPath(), homeSettingsPath()].includes(path.resolve(target))) return;
251
+ throw new Error(`persistCreds: ${target} is a link, and a project key file is written only as a file of its own; nothing was written. Edit the file it points at yourself, or put a file of its own there (or use the home key file)`);
252
+ }
253
+
254
+ function linkedFile(link) {
255
+ try { return fs.realpathSync(link); } catch (e) {
256
+ if (e.code === 'ENOENT') throw new Error(`persistCreds: ${link} is a link to nothing; nothing was written`);
257
+ throw e;
258
+ }
259
+ }
260
+
261
+ // latin1 reads each byte as one character, so a line in another encoding (a CP949 comment) is
262
+ // written back byte for byte.
263
+ function readIfAny(file) {
264
+ try { return fs.readFileSync(file, 'latin1'); } catch (e) { if (e.code === 'ENOENT') return ''; throw e; }
265
+ }
266
+
267
+ // The file's bytes with each set name's value in place. parseEnvFile reads a name's last assignment,
268
+ // so that line is the one replaced, unless it already reads as the new value; a name the file lacks
269
+ // is appended. Every line keeps its own end; an added one takes \r\n when the file has any. New text
270
+ // goes in as UTF-8.
271
+ function mergedEnv(text, set) {
272
+ const eol = eolOf(text);
273
+ const lines = splitLines(text, eol);
274
+ const lastAt = new Map(lines.map((l, i) => [assignedName(l.body), i]).filter(([k]) => k));
275
+ for (const [k, v] of set) {
276
+ const i = lastAt.get(k);
277
+ const line = asLatin1(envLine(k, v));
278
+ if (i === undefined) lines.push({ body: line, end: eol });
279
+ else if (parseEnvFile(asUtf8(lines[i].body))[k] !== v) lines[i].body = line;
280
+ }
281
+ return Buffer.from(lines.map((l) => l.body + l.end).join(''), 'latin1');
282
+ }
283
+
284
+ // Each line with its own end; a last line without one gets `eol`.
285
+ // The line end an added line takes: \r\n when the file has any, else a lone \r when it has one, else \n.
286
+ const eolOf = (text) => (text.includes('\r\n') ? '\r\n' : text.includes('\r') ? '\r' : '\n');
287
+
288
+ function splitLines(text, eol) {
289
+ return (text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) || []).map((l) => {
290
+ const m = /(?:\r\n|\r|\n)$/.exec(l);
291
+ return m ? { body: l.slice(0, m.index), end: m[0] } : { body: l, end: eol };
292
+ });
293
+ }
294
+
295
+ const asLatin1 = (s) => Buffer.from(s, 'utf8').toString('latin1');
296
+ const asUtf8 = (s) => Buffer.from(s, 'latin1').toString('utf8');
297
+
298
+ // The name a line assigns, as parseEnvFile reads it (null for a comment, an `export` line, or text).
299
+ function assignedName(line) {
300
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
301
+ return m ? m[1] : null;
302
+ }
303
+
304
+ // The new contents go to a fresh random name opened exclusively ('wx') and are renamed over the old
305
+ // file. A file with a second name (a hard link), or one the new copy would give another owner or
306
+ // group, is written in place: a rename would part it from its other name, or hand it to this user.
307
+ // So is one whose folder takes no new file, or whose rename the system refuses: the old in-place
308
+ // write still works there.
309
+ function writeKeyFile(file, body) {
310
+ const old = statIfAny(file);
311
+ if (old && old.nlink > 1) return writeInPlace(file, body);
312
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
313
+ const fd = openNew(tmp);
314
+ if (fd === null) return writeInPlace(file, body);
315
+ let moved = false;
316
+ try {
317
+ moved = fillTemp(fd, body, old) && renamed(tmp, file);
318
+ } finally {
319
+ if (!moved) fs.rmSync(tmp, { force: true });
320
+ }
321
+ return moved ? chmodPrivate(file) : writeInPlace(file, body);
322
+ }
323
+
324
+ // A read-only folder, one that is not this user's, or (on Windows) a file another program holds.
325
+ const NO_NEW_FILE = new Set(['EACCES', 'EPERM', 'EBUSY', 'EROFS']);
326
+
327
+ function openNew(tmp) {
328
+ try { return fs.openSync(tmp, 'wx', 0o600); } catch (e) { if (NO_NEW_FILE.has(e.code)) return null; throw e; }
329
+ }
330
+
331
+ function renamed(tmp, file) {
332
+ try { fs.renameSync(tmp, file); return true; } catch (e) { if (NO_NEW_FILE.has(e.code)) return false; throw e; }
333
+ }
334
+
335
+ // Fill and close the new file. False, with nothing written, when its owner or group is not the old file's.
336
+ function fillTemp(fd, body, old) {
337
+ try {
338
+ const st = fs.fstatSync(fd);
339
+ if (old && (st.uid !== old.uid || st.gid !== old.gid)) return false;
340
+ fs.writeFileSync(fd, body);
341
+ syncIfAble(fd);
342
+ return true;
343
+ } finally {
344
+ fs.closeSync(fd);
345
+ }
346
+ }
347
+
348
+ // fsync where the file system does it. One that does not (EINVAL, ENOTSUP) still gets the whole new
349
+ // file by the rename; only its crash safety is less.
350
+ const NO_FSYNC = new Set(['EINVAL', 'ENOTSUP']);
351
+ function syncIfAble(fd) {
352
+ try { fs.fsyncSync(fd); } catch (e) { if (!NO_FSYNC.has(e.code)) throw e; }
353
+ }
354
+
355
+ function writeInPlace(file, body) {
356
+ fs.writeFileSync(file, body, { mode: 0o600 });
357
+ return chmodPrivate(file);
358
+ }
359
+
360
+ function statIfAny(file) {
361
+ try { return fs.statSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
362
+ }
363
+
364
+ function chmodPrivate(file) {
365
+ try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
366
+ }
367
+
204
368
  // One KEY=VALUE line for persistCreds, refusing a name or value that would make lines of its own.
205
369
  function envLine(k, v) {
206
370
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`persistCreds: ${JSON.stringify(k)} is not a variable name; nothing was written`);
@@ -242,6 +406,61 @@ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.f
242
406
  /** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
243
407
  export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
244
408
 
409
+ /**
410
+ * Whether this process's fetch sends an http request to `u` through a proxy named in the environment:
411
+ * Node 22 and 24 do when NODE_USE_ENV_PROXY=1 or --use-env-proxy is on and http_proxy or HTTP_PROXY is
412
+ * set, unless NO_PROXY names the host. Over plain http the proxy reads the request, a key included.
413
+ * Read as Node 22.23 and 24.13 do (measured), and toward "proxied" where unsure: NODE_USE_ENV_PROXY
414
+ * other than empty or 0 is on (Node takes only 1); the flag on the command line or in NODE_OPTIONS is on
415
+ * in any spelling Node takes (`_` for `-`, quoted, with a value, even `=false`), and so is a Node
416
+ * config file, which can turn it on unseen; and the host
417
+ * must be named in no_proxy and NO_PROXY both, when set (Node takes no_proxy when it is set, even
418
+ * empty). An entry names it as `*`, the host, or the host with this URL's own port written out;
419
+ * a leading dot, a range or a default port does not count.
420
+ */
421
+ export function envProxied(u, env = process.env, flags = process.execArgv) {
422
+ if (!envProxyOn(env, flags) || !(env.http_proxy || env.HTTP_PROXY)) return false;
423
+ const lists = [env.no_proxy, env.NO_PROXY].filter((list) => list !== undefined);
424
+ return !lists.length || !lists.every((list) => noProxyNames(list, u));
425
+ }
426
+
427
+ function envProxyOn(env, flags) {
428
+ if (env.NODE_USE_ENV_PROXY && env.NODE_USE_ENV_PROXY !== '0') return true;
429
+ const given = [...flags, env.NODE_OPTIONS || ''].join(' ');
430
+ return PROXY_FLAG.test(given) || CONFIG_FILE_FLAG.test(given);
431
+ }
432
+
433
+ // `--use-env-proxy` as Node takes it (`--no-use-env-proxy` turns it off), and the flags that load a
434
+ // Node config file, whose nodeOptions can turn it on without either showing here.
435
+ const PROXY_FLAG = /--use[-_]env[-_]proxy(?![\w-])/;
436
+ const CONFIG_FILE_FLAG = /--experimental[-_](?:default[-_])?config[-_]file(?![\w-])/;
437
+
438
+ // Entries are split at commas and spaces, and compared in lower case (URL gives the host in lower case).
439
+ const noProxyNames = (list, u) => list.toLowerCase().split(/[\s,]+/).some((x) => noProxyEntry(x, u));
440
+
441
+ function noProxyEntry(x, u) {
442
+ if (x === '*') return true;
443
+ const m = x.match(/^(\[[^\]]*\]|[^:[\]]*)(?::(\d+))?$/);
444
+ return Boolean(m) && m[1] === u.hostname && (m[2] === undefined || m[2] === u.port);
445
+ }
446
+
447
+ // The environment as this module found it. Node takes the proxy settings when it starts, so a change
448
+ // made later does not take the proxy away: a key is held back when either says a proxy is in the way.
449
+ const ENV_AT_LOAD = { ...process.env };
450
+
451
+ /** Whether a key is held back from this URL (EPROXYKEY): http that an env proxy would carry, now or as Node started. */
452
+ export const keyHeldBack = (u) => u.protocol === 'http:' && (envProxied(u) || envProxied(u, ENV_AT_LOAD));
453
+
454
+ // A key goes only where safeForKey allows, and over http only straight to this machine: not through
455
+ // a proxy the environment puts in the way. Both refusals come before anything is sent.
456
+ function assertKeyMayGo(target, headers) {
457
+ if (!Object.keys(headers).some(isSecret)) return;
458
+ if (!safeForKey(target)) throw refused('EINSECURE', 'a key is sent only over https');
459
+ if (keyHeldBack(target)) {
460
+ throw refused('EPROXYKEY', 'a key over http goes only straight to this machine, and the environment sends http through a proxy (NO_PROXY can name the host)');
461
+ }
462
+ }
463
+
245
464
  // Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
246
465
  // leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
247
466
  // while the request is on this machine over http (a local mock); from anywhere else, an https
@@ -273,13 +492,14 @@ function nextHop(req, status, crossOrigin) {
273
492
  * another origin without the key headers; a request with a body only by a 307 or 308 within its
274
493
  * origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
275
494
  * any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
276
- * URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
277
- * https. Throws as fetch does on a network error.
495
+ * URL with one is refused before anything is sent (cause.code EINSECURE), and so is an http one to this
496
+ * machine that an env proxy would carry (EPROXYKEY); no redirect leaves https. Throws as fetch does on
497
+ * a network error.
278
498
  */
279
499
  export async function fetchGuarded(url, init = {}) {
280
500
  let target = new URL(String(url));
281
501
  let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
282
- if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
502
+ assertKeyMayGo(target, req.headers);
283
503
  for (let hop = 0; hop <= MAX_HOPS; hop++) {
284
504
  const r = await globalThis.fetch(target.href, req);
285
505
  const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
@@ -304,35 +524,68 @@ function retryAfterMsOf(headers) {
304
524
  return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
305
525
  }
306
526
 
527
+ // Each request ends within its limit. Without one, a server that takes the connection and never
528
+ // answers holds a request for undici's 300 s, and a poll of 180 requests for hours.
529
+ export const API_TIMEOUT_MS = 120_000;
530
+ export const DOWNLOAD_TIMEOUT_MS = 600_000;
531
+ // An image is made while its request waits, and is billed: it keeps the 300 s undici gave it.
532
+ const IMAGE_TIMEOUT_MS = 300_000;
533
+
307
534
  /**
308
535
  * Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
309
- * `Authorization: Bearer`. Never throws on HTTP status; a network error yields {status:0}.
310
- * `retryAfterMs` carries the server's Retry-After hint (null when absent).
311
- * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error}>}
536
+ * `Authorization: Bearer`. Never throws on HTTP status; a network error, before the answer or
537
+ * while its body is read, yields {status:0} with `error` (its name and codes only, no message text) and `errno` (a code such as
538
+ * ECONNRESET or TimeoutError). Each request is limited to `timeoutMs` (default 120 s, 600 s
539
+ * for a binary download). `retryAfterMs` carries the server's Retry-After hint (null when absent).
540
+ * @param {{key?:string, binary?:boolean, headers?:object, method?:string, body?:any, timeoutMs?:number}} [opts] method defaults to GET
541
+ * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error, errno?:string|null}>}
312
542
  */
313
- export async function azFetch(url, { key, binary = false, headers = {}, method = 'GET', body } = {}) {
314
- const styles = key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}];
315
- let last = { status: 0, ok: false, ct: '', body: null, authStyle: null };
316
- for (const auth of styles) {
317
- const opts = { method, headers: { ...headers, ...auth } };
543
+ export async function azFetch(url, opts = {}) {
544
+ const { key, binary = false } = opts;
545
+ const limitMs = Math.ceil(opts.timeoutMs ?? (binary ? DOWNLOAD_TIMEOUT_MS : API_TIMEOUT_MS)); // AbortSignal.timeout takes whole ms only
546
+ let last;
547
+ for (const auth of authStyles(key)) {
548
+ last = await azAttempt(url, { ...opts, auth, binary, limitMs });
549
+ // accepted, a non-auth error, or a network error (not an auth problem): no other style is tried
550
+ if (last.status !== 401 && last.status !== 403) return last;
551
+ }
552
+ return last;
553
+ }
554
+
555
+ const authStyles = (key) => (key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}]);
556
+
557
+ // One request with one auth style. The body is read inside the try: a connection that drops
558
+ // mid-body is a network error like any other, not a throw past the caller's retry. So is a time limit
559
+ // AbortSignal.timeout will not take (not a whole number of ms).
560
+ async function azAttempt(url, { method = 'GET', headers = {}, body, auth, binary, limitMs }) {
561
+ const authStyle = Object.keys(auth)[0] || null;
562
+ try {
563
+ const opts = { method, headers: { ...headers, ...auth }, signal: AbortSignal.timeout(limitMs) };
318
564
  if (body !== undefined) opts.body = body;
319
- let r;
320
- try {
321
- r = await fetchGuarded(url, opts);
322
- } catch (e) {
323
- last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
324
- break; // network error is not an auth problem — do not retry other styles
325
- }
565
+ const r = await fetchGuarded(url, opts);
326
566
  const ct = r.headers.get('content-type') || '';
327
- let parsed;
328
- if (binary) parsed = Buffer.from(await r.arrayBuffer());
329
- else if (ct.includes('json')) parsed = await r.json().catch(() => null);
330
- else parsed = await r.text().catch(() => null);
331
- last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null, retryAfterMs: retryAfterMsOf(r.headers) };
332
- if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
333
- // else: this auth style was rejected — fall through and try the next
567
+ return { status: r.status, ok: r.ok, ct, body: await azBody(r, ct, binary), authStyle, retryAfterMs: retryAfterMsOf(r.headers) };
568
+ } catch (e) {
569
+ return { status: 0, ok: false, ct: '', body: null, authStyle: null, error: codeOnly(e), errno: errnoOf(e) };
334
570
  }
335
- return last;
571
+ }
572
+
573
+ // The error a request failed with, without its text: undici quotes a header value it refuses (a key
574
+ // with a NUL or a line break) in its message. Its name, its code and its cause's code stay.
575
+ function codeOnly(e) {
576
+ const { name, code, cause } = Object(e);
577
+ const err = Object.assign(new Error(errnoOf(e) || name || 'network error'), { name: name || 'Error' });
578
+ if (code !== undefined) err.code = code;
579
+ if (cause && cause.code) err.cause = { code: cause.code };
580
+ return err;
581
+ }
582
+
583
+ // A read cut short throws (the caller's catch makes it status 0); a JSON body that does not parse is null.
584
+ async function azBody(r, ct, binary) {
585
+ if (binary) return Buffer.from(await r.arrayBuffer());
586
+ const text = await r.text();
587
+ if (!ct.includes('json')) return text;
588
+ try { return JSON.parse(text); } catch { return null; }
336
589
  }
337
590
 
338
591
  // ---------------------------------------------------------------------------
@@ -343,7 +596,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
343
596
  // else 10s, 20s, 40s…; either way at most 60s per wait. Low-tier (S0) image deployments hit this.
344
597
  async function postJsonWith429Retry(url, { key, body, retries, onNote }) {
345
598
  for (let i = 0; ; i++) {
346
- const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
599
+ const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body, timeoutMs: IMAGE_TIMEOUT_MS });
347
600
  if (r.status !== 429 || i >= retries) return r;
348
601
  const ms = Math.min(60000, r.retryAfterMs ?? 10000 * 2 ** i);
349
602
  if (typeof onNote === 'function') onNote(`HTTP 429; retry ${i + 1}/${retries} in ${ms} ms`);
@@ -363,20 +616,24 @@ export async function generateImage({
363
616
  prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
364
617
  } = {}) {
365
618
  const genBody = { model: deployment, prompt, size, quality, n };
366
- let r = await postJsonWith429Retry(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
367
- key, body: JSON.stringify(genBody), retries: retry429, onNote,
368
- });
619
+ const post = (url, body) => postJsonWith429Retry(url, { key, body: JSON.stringify(body), retries: retry429, onNote });
620
+ let r = await post(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, genBody);
369
621
  let note = 'v1';
370
- // no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
371
- if (!(r.ok && r.body?.data?.[0]?.b64_json) && r.status !== 429 && r.status !== 0) {
622
+ if (classicWorthTrying(r)) {
372
623
  note = 'classic-fallback(2025-04-01-preview)';
373
624
  if (typeof onNote === 'function') onNote(`generateImage: v1 path failed (HTTP ${r.status}); trying ${note}`);
374
- r = await postJsonWith429Retry(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
375
- key, body: JSON.stringify({ ...genBody, size: classicSize(size) }), retries: retry429, onNote,
376
- });
625
+ r = await post(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, { ...genBody, size: classicSize(size) });
377
626
  }
378
- const b64 = r.body?.data?.[0]?.b64_json;
379
- if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
627
+ return pngFrom(r, dataB64(r), `generateImage failed (${note})`);
628
+ }
629
+
630
+ // no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
631
+ const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
632
+ const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
633
+
634
+ // The PNG an image call answered with, or an error that starts with `failed`.
635
+ function pngFrom(r, b64, failed) {
636
+ if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
380
637
  return Buffer.from(b64, 'base64');
381
638
  }
382
639
 
@@ -384,29 +641,29 @@ export async function generateImage({
384
641
  export async function editImage({
385
642
  endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
386
643
  } = {}) {
644
+ const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
645
+ key, method: 'POST', body: editForm({ deployment, prompt, imageBuf, maskBuf, size }), timeoutMs: IMAGE_TIMEOUT_MS,
646
+ });
647
+ return pngFrom(r, dataB64(r), 'editImage failed');
648
+ }
649
+
650
+ function editForm({ deployment, prompt, imageBuf, maskBuf, size }) {
387
651
  const fd = new FormData();
388
652
  fd.append('model', deployment);
389
653
  fd.append('prompt', prompt);
390
654
  if (size) fd.append('size', size);
391
655
  fd.append('image', new Blob([imageBuf], { type: 'image/png' }), 'image.png');
392
656
  if (maskBuf) fd.append('mask', new Blob([maskBuf], { type: 'image/png' }), 'mask.png');
393
- const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
394
- key, method: 'POST', body: fd,
395
- });
396
- const b64 = r.body?.data?.[0]?.b64_json;
397
- if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
398
- return Buffer.from(b64, 'base64');
657
+ return fd;
399
658
  }
400
659
 
401
660
  /** Generate a still via the Black Forest Labs FLUX provider path. Returns a PNG Buffer. */
402
661
  export async function generateImageFlux({ endpoint, key, prompt, width, height, apiVersion = 'preview' } = {}) {
403
662
  const r = await azFetch(`${endpoint}/providers/blackforestlabs/v1/flux-2-pro?api-version=${apiVersion}`, {
404
663
  key, method: 'POST', headers: { 'Content-Type': 'application/json' },
405
- body: JSON.stringify({ prompt, width, height }),
664
+ body: JSON.stringify({ prompt, width, height }), timeoutMs: IMAGE_TIMEOUT_MS,
406
665
  });
407
- const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
408
- if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
409
- return Buffer.from(b64, 'base64');
666
+ return pngFrom(r, dataB64(r) || r.body?.b64_json || r.body?.image, 'generateImageFlux failed');
410
667
  }
411
668
 
412
669
  // ---------------------------------------------------------------------------
@@ -427,6 +684,13 @@ export async function listVideos({ endpoint, key, apiVersion = 'preview' } = {})
427
684
  export async function createVideo({
428
685
  endpoint, key, model = 'sora-2', prompt, size = '720x1280', seconds = 4, inputReferencePng, apiVersion = 'preview',
429
686
  } = {}) {
687
+ const body = videoForm({ model, prompt, size, seconds, inputReferencePng });
688
+ const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body });
689
+ if (!(r.ok && r.body?.id)) throw createVideoError(r);
690
+ return r.body;
691
+ }
692
+
693
+ function videoForm({ model, prompt, size, seconds, inputReferencePng }) {
430
694
  const fd = new FormData();
431
695
  fd.append('model', model);
432
696
  fd.append('prompt', prompt);
@@ -435,44 +699,59 @@ export async function createVideo({
435
699
  if (inputReferencePng) {
436
700
  fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
437
701
  }
438
- const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
439
- if (!(r.ok && r.body?.id)) {
440
- // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
441
- const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
442
- err.status = r.status;
443
- err.errno = r.error?.cause?.code || r.error?.code || null;
444
- throw err;
445
- }
446
- return r.body;
702
+ return fd;
447
703
  }
448
704
 
449
- const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180 };
705
+ // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one.
706
+ // retryAfterMs is the server's wait for a 429.
707
+ function createVideoError(r) {
708
+ const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
709
+ return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null });
710
+ }
711
+
712
+ const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180, now: Date.now };
450
713
  const POLL_RETRY_4XX = new Set([408, 429]);
451
714
  const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
452
715
 
453
716
  /**
454
- * Poll a Sora job until a terminal status (or maxTicks). Polls first, then sleeps, so a job
455
- * that is already terminal returns without waiting. `onTick(status, i)` is optional; `sleep`
456
- * replaces the wait between polls. 408, 429, 5xx and network errors keep polling. Any other
457
- * 4xx throws at once with `err.status` and `err.code`: a job the server no longer knows, or a
458
- * key it refuses, never turns terminal.
717
+ * Poll a Sora job until a terminal status, maxTicks polls, or `timeoutMs` of wall time on the
718
+ * `now` clock (default maxTicks x intervalMs, at least one request's 120 s), whichever comes first. Each request is limited to
719
+ * the time left, so a server that stops answering cannot stretch the wait. Polls first, then
720
+ * sleeps, so a job that is already terminal returns without waiting. `onTick(status, i)` is
721
+ * optional; `sleep` replaces the wait between polls. 408, 429, 5xx and network errors keep
722
+ * polling. Any other 4xx throws at once with `err.status` and `err.code`: a job the server no
723
+ * longer knows, or a key it refuses, never turns terminal. So does a key held back before sending
724
+ * (err.status 0, err.code EINSECURE or EPROXYKEY): no later poll could send it.
459
725
  * @returns the last status object seen.
460
726
  */
461
727
  export async function pollVideo(opts = {}) {
462
- const o = { ...POLL_DEFAULTS, sleep, ...withoutUndefined(opts) };
728
+ const o = { ...POLL_DEFAULTS, sleep: pollSleep, ...withoutUndefined(opts) };
463
729
  const url = `${o.endpoint}/openai/v1/videos/${o.id}?api-version=${o.apiVersion}`;
730
+ const end = o.now() + (o.timeoutMs ?? Math.max(o.intervalMs * o.maxTicks, API_TIMEOUT_MS));
464
731
  let st = null;
465
732
  for (let i = 0; i < o.maxTicks; i++) {
466
- const r = await azFetch(url, { key: o.key, method: 'GET' });
733
+ const r = await azFetch(url, { key: o.key, method: 'GET', timeoutMs: timeLeft(end, o.now()) });
734
+ throwIfHeldBack(r, o.id);
467
735
  throwIfRefused(r, o.id);
468
736
  if (r.ok) st = r.body || st;
469
737
  if (typeof o.onTick === 'function') o.onTick(st, i);
470
- if (st && TERMINAL_STATUS.has(st.status)) break;
738
+ if (settled(st)) break;
471
739
  await o.sleep(o.intervalMs);
740
+ if (o.now() >= end) break;
472
741
  }
473
742
  return st;
474
743
  }
475
744
 
745
+ // A poll request's limit: the usual one, or less when the deadline is nearer (azFetch rounds it up to whole ms).
746
+ export const timeLeft = (end, t) => Math.min(API_TIMEOUT_MS, Math.max(1, Math.ceil(end - t)));
747
+ const settled = (st) => Boolean(st) && TERMINAL_STATUS.has(st.status);
748
+
749
+ export const HELD_KEY = new Set(['EINSECURE', 'EPROXYKEY']);
750
+ function throwIfHeldBack(r, id) {
751
+ if (r.status !== 0 || !HELD_KEY.has(r.errno)) return;
752
+ throw Object.assign(new Error(`pollVideo: job ${id}: the key was held back (${r.errno}); nothing was sent`), { status: 0, code: r.errno });
753
+ }
754
+
476
755
  function throwIfRefused(r, id) {
477
756
  if (r.status < 400 || r.status >= 500 || POLL_RETRY_4XX.has(r.status)) return;
478
757
  const code = r.body?.error?.code || null;
@@ -513,6 +792,16 @@ export async function detectTwoImageSupport({ endpoint, key, apiVersion = 'previ
513
792
  export async function createVideoTwoImage({
514
793
  endpoint, key, model = 'sora-2', prompt = '', firstPng, lastPng, size = '720x1280', seconds = 4, apiVersion = 'preview',
515
794
  } = {}) {
795
+ const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
796
+ key, method: 'POST', body: twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }),
797
+ });
798
+ if (!(r.ok && jobIdOf(r))) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
799
+ return r.body;
800
+ }
801
+
802
+ const jobIdOf = (r) => r.body?.id || r.body?.job_id;
803
+
804
+ function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
516
805
  const fd = new FormData();
517
806
  fd.append('model', model);
518
807
  fd.append('prompt', prompt);
@@ -525,12 +814,7 @@ export async function createVideoTwoImage({
525
814
  ]));
526
815
  fd.append('files', new Blob([firstPng], { type: 'image/png' }), 'first.png');
527
816
  fd.append('files', new Blob([lastPng], { type: 'image/png' }), 'last.png');
528
- const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
529
- key, method: 'POST', body: fd,
530
- });
531
- const id = r.body?.id || r.body?.job_id;
532
- if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
533
- return r.body;
817
+ return fd;
534
818
  }
535
819
 
536
820
  // ---------------------------------------------------------------------------
@@ -656,16 +940,25 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
656
940
  */
657
941
  export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
658
942
  const bin = ffmpeg || await resolveFfmpeg();
943
+ const r = runFf(bin, concatArgs(clipList(mp4List), out, { fps, width, height }));
944
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
945
+ return out;
946
+ }
947
+
948
+ function clipList(mp4List) {
659
949
  const list = Array.isArray(mp4List) ? mp4List : [];
660
950
  if (list.length === 0) throw new Error('concatClips: no input clips');
951
+ return list;
952
+ }
953
+
954
+ // One -i per clip, each normalized (fps, scale, sar, pix_fmt) before the concat filter joins them.
955
+ function concatArgs(list, out, { fps, width, height }) {
661
956
  const args = ['-y'];
662
957
  for (const f of list) args.push('-i', f);
663
958
  const norm = list.map((_, i) => `[${i}:v]fps=${fps},scale=${width}:${height},setsar=1,format=yuv420p[v${i}]`);
664
959
  const chain = `${list.map((_, i) => `[v${i}]`).join('')}concat=n=${list.length}:v=1[v]`;
665
960
  args.push('-filter_complex', `${norm.join(';')};${chain}`, '-map', '[v]', out);
666
- const r = runFf(bin, args);
667
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
668
- return out;
961
+ return args;
669
962
  }
670
963
 
671
964
  // Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.