videobook 0.1.0 → 0.2.1

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 (39) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +80 -18
  3. package/dist/cli/args.js +53 -3
  4. package/dist/cli/config-command.js +103 -0
  5. package/dist/cli/doctor-command.js +51 -17
  6. package/dist/cli/key-report.js +16 -0
  7. package/dist/cli/main.js +14 -1
  8. package/dist/cli/mcp-command.js +6 -0
  9. package/dist/cli/run-command.js +60 -16
  10. package/dist/cli/serve-command.js +5 -0
  11. package/dist/cli/setup-command.js +60 -0
  12. package/dist/dancing-workflow.js +2 -1
  13. package/dist/env-file.js +91 -0
  14. package/dist/env-secrets.js +38 -74
  15. package/dist/ffprobe-binary.js +29 -7
  16. package/dist/hydration.js +56 -0
  17. package/dist/jobs/archive-clip.js +2 -1
  18. package/dist/jobs/download-video.js +2 -1
  19. package/dist/load-env.js +5 -2
  20. package/dist/one-password.js +200 -0
  21. package/dist/queue.js +4 -0
  22. package/dist/serve.js +5 -30
  23. package/dist/tools/videobook-domain.js +24 -9
  24. package/dist/ytdlp-binary.js +65 -0
  25. package/package.json +4 -12
  26. package/ui/assets/{alive-captions-DPEr1nKM.js → alive-captions-e-n3xmCd.js} +1 -1
  27. package/ui/assets/{confirm-bodies-D8ouHXKy.js → confirm-bodies-DS6jh0Kb.js} +1 -1
  28. package/ui/assets/{fullscreen-B7nw4YbE.js → fullscreen-VY7f4mJ-.js} +1 -1
  29. package/ui/assets/{goal-ui-DK-xTCK0.js → goal-ui-B__NgZYz.js} +1 -1
  30. package/ui/assets/{index-kS1905s5.js → index-BPco0HoT.js} +2 -2
  31. package/ui/assets/{inspector-MlzQbTVo.js → inspector-BBuZIaqg.js} +1 -1
  32. package/ui/assets/{jobs-ui-CoinOWx0.js → jobs-ui-vmEH4zBT.js} +1 -1
  33. package/ui/assets/{music-video-Cbe80kmQ.js → music-video-CklLKD1R.js} +1 -1
  34. package/ui/assets/{ui-xo_Y1yQg.js → ui-CtYXY_po.js} +1 -1
  35. package/ui/assets/{vendor-DfTCWgKm.js → vendor-OClqq_Da.js} +2 -2
  36. package/ui/assets/{vendor-flow-BXbeWxW7.js → vendor-flow-DY81i6dC.js} +1 -1
  37. package/ui/assets/{vendor-mcp-BTlEJcHV.js → vendor-mcp-BsGNAD4I.js} +1 -1
  38. package/ui/assets/{videobook-Qlp9HQFu.js → videobook-lhLK6e6Q.js} +1 -1
  39. package/ui/index.html +11 -11
@@ -1,13 +1,17 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
3
  import { z } from "zod";
4
+ import { onePasswordWarnings } from "./key-report.js";
3
5
  import { gcLibraryCatalogs } from "../catalog-gc.js";
4
6
  import { bookManager, scopedEngine } from "../engine.js";
5
- import { waitForJob } from "../queue.js";
6
7
  import { MCP_PROTOCOL_VERSION, SERVER_VERSION } from "../server.js";
7
8
  import { inspectServerOwner } from "../server-lock.js";
8
9
  import { startServer } from "../serve.js";
9
10
  import { getAllToolDefs } from "../tools/index.js";
10
11
  const REQUEST_GRACE_MS = 60_000;
12
+ const JOB_POLL_MS = 1_000;
13
+ const FINISHED_JOB_STATES = new Set(["done", "failed", "aborted"]);
14
+ const SETTLED_STATUSES = new Set(["completed", "done"]);
11
15
  function newClient() {
12
16
  return new Client({ name: "videobook-cli", version: SERVER_VERSION }, {
13
17
  capabilities: {},
@@ -27,6 +31,8 @@ function refuseUnresponsive(owner) {
27
31
  }
28
32
  async function temporaryOwner() {
29
33
  const running = await startServer({ port: 0, ui: false });
34
+ for (const warning of onePasswordWarnings())
35
+ process.stderr.write(`videobook: ${warning}\n`);
30
36
  await running.ready;
31
37
  const client = await connect(running.mcpUrl);
32
38
  return {
@@ -64,17 +70,51 @@ function stringField(record, key) {
64
70
  const value = record?.[key];
65
71
  return typeof value === "string" && value !== "" ? value : null;
66
72
  }
67
- async function waitInProcess(jobId, timeoutSeconds) {
68
- process.stderr.write(`Waiting for job ${jobId}...\n`);
69
- try {
70
- await waitForJob(jobId, timeoutSeconds * 1000);
73
+ function jobReference(first) {
74
+ const value = first?.job_id;
75
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
76
+ return { job_ids: [value] };
77
+ if (typeof value === "string" && value !== "")
78
+ return { job_keys: [value] };
79
+ return null;
80
+ }
81
+ function parseSnapshot(result) {
82
+ if (result.isError)
83
+ return null;
84
+ const rows = JSON.parse(textOf(result)[0] ?? "[]");
85
+ const job = Array.isArray(rows) ? rows[0] : undefined;
86
+ if (!job || typeof job.state !== "string")
87
+ return null;
88
+ return { state: job.state, error: typeof job.error?.message === "string" ? job.error.message : null };
89
+ }
90
+ async function jobSnapshot(client, bookId, reference) {
91
+ const result = await client.callTool({ name: "list_jobs", arguments: { book_id: bookId, ...reference } });
92
+ return parseSnapshot(result);
93
+ }
94
+ function reportJob(label, job) {
95
+ if (job.state === "done") {
96
+ process.stderr.write(`Job ${label} completed.\n`);
97
+ return 0;
71
98
  }
72
- catch (error) {
73
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
74
- return 1;
99
+ process.stderr.write(`Job ${label} ${job.state}${job.error ? `: ${job.error}` : ""}\n`);
100
+ return 1;
101
+ }
102
+ async function waitForJob(client, bookId, reference, label, timeoutSeconds) {
103
+ process.stderr.write(`Waiting for job ${label}...\n`);
104
+ const deadline = Date.now() + timeoutSeconds * 1000;
105
+ let job = await jobSnapshot(client, bookId, reference);
106
+ while (job && !FINISHED_JOB_STATES.has(job.state) && Date.now() < deadline) {
107
+ await delay(JOB_POLL_MS);
108
+ job = await jobSnapshot(client, bookId, reference);
75
109
  }
76
- process.stderr.write(`Job ${jobId} completed.\n`);
77
- return 0;
110
+ if (!job) {
111
+ process.stderr.write(`Job ${label} is not tracked by the server; check it with list_jobs.\n`);
112
+ return 0;
113
+ }
114
+ if (FINISHED_JOB_STATES.has(job.state))
115
+ return reportJob(label, job);
116
+ process.stderr.write(`Job ${label} did not finish within ${timeoutSeconds} s.\n`);
117
+ return 1;
78
118
  }
79
119
  async function waitForAsset(client, bookId, assetId, timeoutSeconds) {
80
120
  process.stderr.write(`Waiting for asset ${assetId}...\n`);
@@ -84,17 +124,21 @@ async function waitForAsset(client, bookId, assetId, timeoutSeconds) {
84
124
  return result.isError ? 1 : 0;
85
125
  }
86
126
  async function waitForOutcome(session, flags, args, first) {
87
- const jobId = stringField(first, "job_id");
127
+ const reference = jobReference(first);
88
128
  const assetId = stringField(first, "asset_id");
89
129
  const bookId = typeof args.book_id === "string" ? args.book_id : null;
90
- if (session.temporary && jobId)
91
- return waitInProcess(jobId, flags.timeoutSeconds);
92
- if (!flags.wait || !jobId)
130
+ const status = stringField(first, "status");
131
+ if (!reference || (status && SETTLED_STATUSES.has(status)))
132
+ return 0;
133
+ if (!flags.wait && !session.temporary)
93
134
  return 0;
94
135
  if (assetId && bookId)
95
136
  return waitForAsset(session.client, bookId, assetId, flags.timeoutSeconds);
96
- process.stderr.write(`Job ${jobId} is queued on the running server; follow it with list_jobs.\n`);
97
- return 0;
137
+ if (!bookId) {
138
+ process.stderr.write(`Job ${String(first?.job_id)} was queued; pass book_id to wait for it.\n`);
139
+ return 0;
140
+ }
141
+ return waitForJob(session.client, bookId, reference, String(first?.job_id), flags.timeoutSeconds);
98
142
  }
99
143
  async function callOnce(session, flags, tool, args) {
100
144
  try {
@@ -1,4 +1,5 @@
1
1
  import { resolvePort } from "./args.js";
2
+ import { onePasswordSummary, onePasswordWarnings } from "./key-report.js";
2
3
  import { openInBrowser } from "./open-browser.js";
3
4
  import { logger } from "../logger.js";
4
5
  import { SERVER_VERSION } from "../server.js";
@@ -12,14 +13,18 @@ function uiLine(running) {
12
13
  return `not built at ${running.uiDir}; serving MCP and API only`;
13
14
  }
14
15
  function printBanner(running) {
16
+ const keys = onePasswordSummary();
15
17
  process.stdout.write([
16
18
  `videobook ${SERVER_VERSION}`,
17
19
  `Library ${running.libraryDir}`,
18
20
  `UI ${uiLine(running)}`,
19
21
  `MCP ${running.mcpUrl}`,
22
+ ...(keys ? [`Keys ${keys}`] : []),
20
23
  "Agents claude mcp add videobook -- videobook mcp",
21
24
  "",
22
25
  ].join("\n"));
26
+ for (const warning of onePasswordWarnings())
27
+ process.stderr.write(`videobook: ${warning}\n`);
23
28
  }
24
29
  function reportStartupFailure(error, port) {
25
30
  if (error instanceof ServerAlreadyRunningError) {
@@ -0,0 +1,60 @@
1
+ import { readEnvValues, updateEnvFile } from "../env-file.js";
2
+ import { MANAGED_ENV_KEYS, getDefaultSecretStore } from "../env-secrets.js";
3
+ import { OP_TOKEN_SETTING, OP_VAULT_SETTING, listVaultItemTitles, opErrorMessage, resolveServiceToken, } from "../one-password.js";
4
+ const AMBIENT_TOKEN_VARIABLE = "OP_SERVICE_ACCOUNT_TOKEN";
5
+ function savedSetting(name, file) {
6
+ return process.env[name]?.trim() || file[name]?.trim() || null;
7
+ }
8
+ function chosenVault(flags, file) {
9
+ return flags.vault?.trim() || savedSetting(OP_VAULT_SETTING, file);
10
+ }
11
+ function chosenToken(flags, file) {
12
+ const explicit = flags.token?.trim() || savedSetting(OP_TOKEN_SETTING, file);
13
+ if (explicit)
14
+ return explicit;
15
+ return process.env[AMBIENT_TOKEN_VARIABLE]?.trim() ? `env:${AMBIENT_TOKEN_VARIABLE}` : null;
16
+ }
17
+ function describeToken(token) {
18
+ if (!token)
19
+ return "op's own sign-in";
20
+ const resolved = resolveServiceToken(token, process.env);
21
+ if (resolved.source === "literal")
22
+ return "a literal token (stored in the file; prefer env:VAR)";
23
+ return resolved.value ? token : `${token}, which is not set in this shell`;
24
+ }
25
+ async function removeSettings(envPath) {
26
+ await updateEnvFile(envPath, { [OP_VAULT_SETTING]: null, [OP_TOKEN_SETTING]: null });
27
+ process.stdout.write(`Removed ${OP_VAULT_SETTING} and ${OP_TOKEN_SETTING} from ${envPath}\n`);
28
+ return 0;
29
+ }
30
+ function verifyVault(vault, token) {
31
+ const value = token ? resolveServiceToken(token, process.env).value : null;
32
+ try {
33
+ const titles = listVaultItemTitles(vault, process.env, value);
34
+ const found = MANAGED_ENV_KEYS.filter((key) => titles.has(key));
35
+ const missing = MANAGED_ENV_KEYS.filter((key) => !titles.has(key));
36
+ process.stdout.write(`1Password vault ${vault}: ${found.length} of ${MANAGED_ENV_KEYS.length} videobook keys found\n`);
37
+ if (missing.length > 0)
38
+ process.stdout.write(`Not in the vault: ${missing.join(", ")}\n`);
39
+ return 0;
40
+ }
41
+ catch (error) {
42
+ process.stderr.write(`Could not list 1Password vault ${vault}: ${opErrorMessage(error, value)}\n`);
43
+ return 1;
44
+ }
45
+ }
46
+ export async function runSetupCommand(flags) {
47
+ const envPath = getDefaultSecretStore().envPath();
48
+ if (flags.remove)
49
+ return removeSettings(envPath);
50
+ const file = readEnvValues(envPath);
51
+ const vault = chosenVault(flags, file);
52
+ if (!vault) {
53
+ process.stderr.write("setup 1password needs --op-vault <vault>, the vault whose items are named after videobook's keys\n");
54
+ return 2;
55
+ }
56
+ const token = chosenToken(flags, file);
57
+ await updateEnvFile(envPath, { [OP_VAULT_SETTING]: vault, [OP_TOKEN_SETTING]: token });
58
+ process.stdout.write(`Saved ${OP_VAULT_SETTING}=${vault} and the token setting (${describeToken(token)}) to ${envPath}\n`);
59
+ return flags.verify ? verifyVault(vault, token) : 0;
60
+ }
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { YtDlp } from "ytdlp-nodejs";
6
+ import { ytdlpRunOptions } from "./ytdlp-binary.js";
6
7
  import { findVideoFile, resolveNotebookCellArtifactId, } from "videobook-engine";
7
8
  import { probeImage } from "./image.js";
8
9
  import { notebookCellFingerprint } from "./jobs/run-notebook.js";
@@ -73,7 +74,7 @@ async function hasFile(engine, artifactId, filename) {
73
74
  && manifest.value.files.some((file) => file.name === filename);
74
75
  }
75
76
  async function downloadDancingVideo(destination) {
76
- const ytdlp = new YtDlp();
77
+ const ytdlp = new YtDlp(ytdlpRunOptions());
77
78
  await ytdlp
78
79
  .download(DANCING_SOURCE_URL)
79
80
  .setOutputTemplate(destination)
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { parse } from "dotenv";
6
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
7
+ const NEW_FILE_MODE = 0o600;
8
+ const NEW_DIRECTORY_MODE = 0o700;
9
+ function assignmentPattern(key) {
10
+ return new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`, "u");
11
+ }
12
+ export function hasControlCharacters(value) {
13
+ return [...value].some((character) => {
14
+ const code = character.codePointAt(0) ?? 0;
15
+ return code < 32 || code === 127;
16
+ });
17
+ }
18
+ export function formatEnvValue(value) {
19
+ if (!/[#'"\s]/u.test(value))
20
+ return value;
21
+ if (!value.includes("'"))
22
+ return `'${value}'`;
23
+ if (!value.includes("\""))
24
+ return `"${value}"`;
25
+ throw new Error("value cannot contain both single and double quotes");
26
+ }
27
+ function withoutAssignments(lines, key) {
28
+ const pattern = assignmentPattern(key);
29
+ return lines.filter((line) => !pattern.test(line));
30
+ }
31
+ function withAssignment(lines, key, value) {
32
+ const pattern = assignmentPattern(key);
33
+ const line = `${key}=${formatEnvValue(value)}`;
34
+ const last = lines.reduce((found, candidate, index) => pattern.test(candidate) ? index : found, -1);
35
+ if (last >= 0)
36
+ return lines.map((candidate, index) => index === last ? line : candidate);
37
+ return [...lines, line];
38
+ }
39
+ function splitLines(raw) {
40
+ if (raw === "")
41
+ return [];
42
+ const lines = raw.split("\n");
43
+ return lines.at(-1) === "" ? lines.slice(0, -1) : lines;
44
+ }
45
+ export function applyEnvUpdates(raw, updates) {
46
+ let lines = splitLines(raw);
47
+ for (const [key, value] of Object.entries(updates)) {
48
+ if (!KEY_PATTERN.test(key))
49
+ throw new Error(`${key} is not a valid environment variable name`);
50
+ lines = value === null ? withoutAssignments(lines, key) : withAssignment(lines, key, value);
51
+ }
52
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
53
+ }
54
+ export function readEnvValues(path) {
55
+ try {
56
+ return parse(readFileSync(path, "utf8"));
57
+ }
58
+ catch (error) {
59
+ if (error.code === "ENOENT")
60
+ return {};
61
+ throw error;
62
+ }
63
+ }
64
+ async function readIfPresent(path) {
65
+ try {
66
+ return await readFile(path, "utf8");
67
+ }
68
+ catch (error) {
69
+ if (error.code === "ENOENT")
70
+ return null;
71
+ throw error;
72
+ }
73
+ }
74
+ async function existingMode(path) {
75
+ try {
76
+ return (await stat(path)).mode & 0o777;
77
+ }
78
+ catch {
79
+ return NEW_FILE_MODE;
80
+ }
81
+ }
82
+ export async function updateEnvFile(path, updates) {
83
+ const raw = await readIfPresent(path);
84
+ const next = applyEnvUpdates(raw ?? "", updates);
85
+ await mkdir(dirname(path), { recursive: true, mode: NEW_DIRECTORY_MODE });
86
+ const mode = raw === null ? NEW_FILE_MODE : await existingMode(path);
87
+ const temporary = `${path}.tmp.${randomUUID()}`;
88
+ await writeFile(temporary, next, { encoding: "utf8", mode });
89
+ await chmod(temporary, mode);
90
+ await rename(temporary, path);
91
+ }
@@ -1,29 +1,30 @@
1
1
  /**
2
2
  * Secret-source detection and .env editing.
3
3
  *
4
- * Secrets reach the server two ways:
4
+ * Secrets reach the server three ways:
5
5
  * 1. `op run --env-file=.env.tpl --` resolves op:// references and injects
6
6
  * them into the environment BEFORE Node starts (npm run dev:server).
7
7
  * 2. A plain `.env` file, loaded here with dotenv semantics: never override
8
8
  * a var that is already set, so op-injected values always win.
9
+ * 3. op:// references left in the environment or `.env`, plus the vault
10
+ * named by VIDEOBOOK_OP_VAULT, resolved through the 1Password CLI by
11
+ * resolveOnePassword() before a server starts.
9
12
  *
10
- * A managed key that was already set before `.env` loaded is therefore
11
- * "1password" — externally managed and read-only, because a `.env` write
12
- * would be silently shadowed on the next start. A key whose value arrived
13
- * via `.env` is "env" and editable. A shell-exported var is also classified
14
- * "1password"; the label is approximate but the read-only behavior is right.
13
+ * A managed key that was already set before `.env` loaded, or that 1Password
14
+ * resolved, is therefore "1password" — externally managed and read-only,
15
+ * because a `.env` write would be silently shadowed on the next start. A key
16
+ * whose literal value arrived via `.env` is "env" and editable. A
17
+ * shell-exported var is also classified "1password"; the label is
18
+ * approximate but the read-only behavior is right.
15
19
  *
16
20
  * `.env` lives in the data home (the repo root in a source checkout) and is
17
21
  * server runtime configuration, not a project artifact, so direct node:fs is
18
22
  * correct here — the videobook-engine rule covers managed artifacts and their
19
23
  * materialized workspaces.
20
24
  */
21
- import { readFileSync } from "node:fs";
22
- import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
23
- import { dirname } from "node:path";
24
- import { randomUUID } from "node:crypto";
25
- import { parse } from "dotenv";
26
25
  import { dataLayout } from "./data-home.js";
26
+ import { hasControlCharacters, readEnvValues, updateEnvFile } from "./env-file.js";
27
+ import { resolveOnePasswordEnv } from "./one-password.js";
27
28
  export const MANAGED_ENV_KEYS = [
28
29
  "OPENAI_API_KEY",
29
30
  "KIE_API_KEY",
@@ -62,41 +63,6 @@ export function maskEnvValue(value) {
62
63
  /** Raised on rejected writes. Messages never include the secret value. */
63
64
  export class SecretWriteError extends Error {
64
65
  }
65
- function hasControlCharacters(value) {
66
- for (const ch of value) {
67
- const code = ch.codePointAt(0) ?? 0;
68
- if (code < 32 || code === 127)
69
- return true;
70
- }
71
- return false;
72
- }
73
- function formatEnvValue(value) {
74
- const needsQuoting = /[#'"\s]/.test(value);
75
- if (!needsQuoting)
76
- return value;
77
- if (!value.includes("'"))
78
- return `'${value}'`;
79
- if (!value.includes('"'))
80
- return `"${value}"`;
81
- throw new SecretWriteError("value cannot contain both single and double quotes");
82
- }
83
- /** Replace the last `KEY=...` line (last one wins in dotenv) or append. Other lines are untouched. */
84
- function upsertEnvLine(raw, key, newLine) {
85
- const lines = raw.split("\n");
86
- const matcher = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`);
87
- let lastIndex = -1;
88
- for (let i = 0; i < lines.length; i++) {
89
- if (matcher.test(lines[i]))
90
- lastIndex = i;
91
- }
92
- if (lastIndex >= 0) {
93
- lines[lastIndex] = newLine;
94
- return lines.join("\n");
95
- }
96
- if (raw === "")
97
- return `${newLine}\n`;
98
- return raw.endsWith("\n") ? `${raw}${newLine}\n` : `${raw}\n${newLine}\n`;
99
- }
100
66
  function defaultEnvPath() {
101
67
  return process.env.VIDEOBOOK_ENV_PATH ?? dataLayout().envPath;
102
68
  }
@@ -104,17 +70,9 @@ export function createSecretStore(options = {}) {
104
70
  const envPath = options.envPath ?? defaultEnvPath();
105
71
  const processEnv = options.processEnv ?? process.env;
106
72
  const preSet = new Set();
73
+ const fromOnePassword = new Set();
107
74
  let loaded = false;
108
- function readEnvFileSync() {
109
- try {
110
- return readFileSync(envPath, "utf8");
111
- }
112
- catch (err) {
113
- if (err.code === "ENOENT")
114
- return "";
115
- throw err;
116
- }
117
- }
75
+ let resolution = null;
118
76
  function loadDotenv() {
119
77
  if (loaded)
120
78
  return;
@@ -124,8 +82,7 @@ export function createSecretStore(options = {}) {
124
82
  if (value !== undefined && value !== "")
125
83
  preSet.add(key);
126
84
  }
127
- const parsed = parse(readEnvFileSync());
128
- for (const [key, value] of Object.entries(parsed)) {
85
+ for (const [key, value] of Object.entries(readEnvValues(envPath))) {
129
86
  if (processEnv[key] === undefined)
130
87
  processEnv[key] = value;
131
88
  }
@@ -139,7 +96,7 @@ export function createSecretStore(options = {}) {
139
96
  const value = processEnv[key];
140
97
  if (!value || value.startsWith("op://"))
141
98
  return "missing";
142
- if (preSet.has(key))
99
+ if (preSet.has(key) || fromOnePassword.has(key))
143
100
  return "1password";
144
101
  return "env";
145
102
  }
@@ -157,24 +114,31 @@ export function createSecretStore(options = {}) {
157
114
  if (getSource(key) === "1password") {
158
115
  throw new SecretWriteError(`${key} is managed by 1Password; edit it in the vault`);
159
116
  }
160
- const line = `${key}=${formatEnvValue(value)}`;
161
- let raw;
162
- try {
163
- raw = await readFile(envPath, "utf8");
117
+ if (value.includes("'") && value.includes("\"")) {
118
+ throw new SecretWriteError("value cannot contain both single and double quotes");
164
119
  }
165
- catch (err) {
166
- if (err.code !== "ENOENT")
167
- throw err;
168
- raw = "";
169
- }
170
- const updated = upsertEnvLine(raw, key, line);
171
- await mkdir(dirname(envPath), { recursive: true });
172
- const tmp = `${envPath}.tmp.${randomUUID()}`;
173
- await writeFile(tmp, updated, "utf8");
174
- await rename(tmp, envPath);
120
+ await updateEnvFile(envPath, { [key]: value });
175
121
  processEnv[key] = value;
176
122
  }
177
- return { loadDotenv, getValue, getSource, setEnvSecret };
123
+ function resolveOnePassword() {
124
+ if (resolution)
125
+ return resolution;
126
+ loadDotenv();
127
+ const resolved = resolveOnePasswordEnv(processEnv, MANAGED_ENV_KEYS);
128
+ for (const key of [...resolved.fromReferences, ...resolved.fromVault])
129
+ fromOnePassword.add(key);
130
+ resolution = resolved;
131
+ return resolved;
132
+ }
133
+ return {
134
+ loadDotenv,
135
+ getValue,
136
+ getSource,
137
+ setEnvSecret,
138
+ resolveOnePassword,
139
+ onePasswordResolution: () => resolution,
140
+ envPath: () => envPath,
141
+ };
178
142
  }
179
143
  let defaultStore = null;
180
144
  export function getDefaultSecretStore() {
@@ -1,4 +1,4 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { accessSync, chmodSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  const MACH_O_64_MAGIC_LE = 0xfeedfacf;
4
4
  const FAT_MAGIC_BE = 0xcafebabe;
@@ -40,16 +40,38 @@ export function readBinaryHeader(path) {
40
40
  return null;
41
41
  }
42
42
  }
43
- /**
44
- * ffprobe-static ships an x86_64 build under darwin/arm64, which cannot spawn
45
- * on Apple Silicon without Rosetta. An explicit override wins; otherwise the
46
- * bundled binary is used only when its CPU matches, else `ffprobe` on PATH.
47
- */
43
+ export function ensureExecutable(path) {
44
+ try {
45
+ accessSync(path, constants.X_OK);
46
+ return true;
47
+ }
48
+ catch {
49
+ try {
50
+ chmodSync(path, statSync(path).mode | 0o111);
51
+ accessSync(path, constants.X_OK);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ }
48
59
  export function resolveFfprobePath(bundledPath, override, hostArch) {
49
60
  if (override)
50
61
  return override;
62
+ if (!bundledPath)
63
+ return "ffprobe";
51
64
  const header = readBinaryHeader(bundledPath);
52
65
  return header && binaryRunsOnHost(header, hostArch) ? bundledPath : "ffprobe";
53
66
  }
54
67
  const require = createRequire(import.meta.url);
55
- export const FFPROBE = resolveFfprobePath(require("ffprobe-static").path, process.env.VIDEOBOOK_FFPROBE_PATH, process.arch);
68
+ export function bundledFfprobe() {
69
+ try {
70
+ const { path } = require("@ffprobe-installer/ffprobe");
71
+ return ensureExecutable(path) ? path : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ export const FFPROBE = resolveFfprobePath(bundledFfprobe(), process.env.VIDEOBOOK_FFPROBE_PATH, process.arch);
@@ -0,0 +1,56 @@
1
+ import { startPostMetricsScheduler } from "./posts/post-metrics.js";
2
+ import { queue } from "./queue.js";
3
+ import { loadAndArmAll } from "./scheduled-posts.js";
4
+ const runDirectly = async (step) => await step.run();
5
+ function openBooks(books) {
6
+ return books.list().flatMap((book) => {
7
+ const selected = books.get(book.bookId);
8
+ return selected.ok ? [selected.value] : [];
9
+ });
10
+ }
11
+ function bookTag(book) {
12
+ return `${book.name} [${book.bookId.slice(0, 8)}]`;
13
+ }
14
+ async function loadReconcilers(run) {
15
+ const imageSubjects = await run({ phase: "import", label: "jobs/image-subjects (hydrate)", run: () => import("./jobs/image-subjects.js") });
16
+ const runNotebook = await run({ phase: "import", label: "jobs/run-notebook (hydrate)", run: () => import("./jobs/run-notebook.js") });
17
+ return { imageSubjects, runNotebook };
18
+ }
19
+ async function ensureDevFixtures(books, run) {
20
+ const dancing = await run({ phase: "import", label: "dancing-workflow (fixtures)", run: () => import("./dancing-workflow.js") });
21
+ await run({ phase: "reconcile", label: "ensureDancingWorkflow()", run: () => dancing.ensureDancingWorkflow(books) });
22
+ }
23
+ async function reconcileBeforeQueue(books, run, reconcilers) {
24
+ const { imageSubjects, runNotebook } = reconcilers;
25
+ for (const book of openBooks(books)) {
26
+ const tag = bookTag(book);
27
+ await run({ phase: "reconcile", label: `${tag} jobs.queue.reap()`, run: () => book.engine.jobs.queue.reap() });
28
+ await run({ phase: "reconcile", label: `${tag} reconcileImageSubjectJobFailures`, run: () => imageSubjects.reconcileImageSubjectJobFailures(book.engine, book.bookId) });
29
+ await run({ phase: "reconcile", label: `${tag} abortLeasedNotebookRunsOnStartup`, run: () => runNotebook.abortLeasedNotebookRunsOnStartup(book.engine) });
30
+ await run({ phase: "reconcile", label: `${tag} reconcileGenerationMediaOutputs`, run: () => runNotebook.reconcileGenerationMediaOutputs(book.engine) });
31
+ await run({ phase: "reconcile", label: `${tag} reconcileQueuedNotebookCells`, run: () => runNotebook.reconcileQueuedNotebookCells(book.engine) });
32
+ }
33
+ }
34
+ async function reconcileAfterQueue(books, run, reconcilers) {
35
+ for (const book of openBooks(books)) {
36
+ await run({
37
+ phase: "reconcile",
38
+ label: `${bookTag(book)} reconcileAwaitingNotebookOutputs`,
39
+ run: () => reconcilers.runNotebook.reconcileAwaitingNotebookOutputs(book.engine, book.bookId),
40
+ });
41
+ }
42
+ }
43
+ export async function hydrateLibrary(books, options) {
44
+ const run = options.runStep ?? runDirectly;
45
+ await run({ phase: "open", label: "bookManager.ready()", run: () => books.ready() });
46
+ if (options.devFixtures)
47
+ await ensureDevFixtures(books, run);
48
+ const reconcilers = await loadReconcilers(run);
49
+ await reconcileBeforeQueue(books, run, reconcilers);
50
+ if (options.startQueue !== false)
51
+ await run({ phase: "reconcile", label: "queue.start()", run: () => queue.start() });
52
+ await reconcileAfterQueue(books, run, reconcilers);
53
+ await run({ phase: "reconcile", label: "loadAndArmAll()", run: () => loadAndArmAll() });
54
+ if (options.startPostMetrics !== false)
55
+ startPostMetricsScheduler();
56
+ }
@@ -5,6 +5,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { extname, join, posix } from "node:path";
7
7
  import { YtDlp } from "ytdlp-nodejs";
8
+ import { ytdlpRunOptions } from "../ytdlp-binary.js";
8
9
  import { registerJobHandler } from "../queue.js";
9
10
  import { getApiKey, requireApiKey } from "../api-key.js";
10
11
  import { probeVideo } from "../ffmpeg.js";
@@ -117,7 +118,7 @@ async function prepareUrlMedia(payload) {
117
118
  throw new Error("url archive requires url");
118
119
  const dir = await mkdtemp(join(tmpdir(), "videocity-archive-"));
119
120
  const outputPath = join(dir, "original.mp4");
120
- const ytdlp = new YtDlp();
121
+ const ytdlp = new YtDlp(ytdlpRunOptions());
121
122
  try {
122
123
  await ytdlp
123
124
  .download(payload.url)
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { stat, unlink } from "node:fs/promises";
3
3
  import { YtDlp } from "ytdlp-nodejs";
4
+ import { ytdlpRunOptions } from "../ytdlp-binary.js";
4
5
  import { extractOpenGraph } from "@devmehq/open-graph-extractor";
5
6
  import { queue, registerJobHandler } from "../queue.js";
6
7
  import { runFfmpeg, probeVideo, H264_MEDIUM, AUDIO_OPTS } from "../ffmpeg.js";
@@ -178,8 +179,8 @@ export function imageExtension(info) {
178
179
  return IMAGE_EXTENSIONS.has(ext) ? `.${ext}` : ".jpg";
179
180
  }
180
181
  export function registerDownloadVideoJob(engine) {
181
- const ytdlp = new YtDlp();
182
182
  registerJobHandler("download_video", async (payload, ctx) => {
183
+ const ytdlp = new YtDlp(ytdlpRunOptions());
183
184
  const { url, assetId, assetDir, exportVideos, sourcePrefix, requestedRange, } = payload;
184
185
  const bookId = payload.bookId ?? "";
185
186
  const outputPath = join(assetDir, "original.mp4");
package/dist/load-env.js CHANGED
@@ -3,7 +3,10 @@
3
3
  * every entry point: ESM executes it before the other imports' module bodies,
4
4
  * so env vars are populated before any module reads them at import time. It
5
5
  * also snapshots which managed keys were set before `.env` loaded, which is
6
- * how the secret store tells 1Password-injected keys from `.env`-backed ones.
6
+ * how the secret store tells 1Password-injected keys from `.env`-backed ones,
7
+ * then resolves op:// references and the configured vault through `op`.
7
8
  */
8
9
  import { getDefaultSecretStore } from "./env-secrets.js";
9
- getDefaultSecretStore().loadDotenv();
10
+ const store = getDefaultSecretStore();
11
+ store.loadDotenv();
12
+ store.resolveOnePassword();