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.
- package/CHANGELOG.md +45 -0
- package/README.md +80 -18
- package/dist/cli/args.js +53 -3
- package/dist/cli/config-command.js +103 -0
- package/dist/cli/doctor-command.js +51 -17
- package/dist/cli/key-report.js +16 -0
- package/dist/cli/main.js +14 -1
- package/dist/cli/mcp-command.js +6 -0
- package/dist/cli/run-command.js +60 -16
- package/dist/cli/serve-command.js +5 -0
- package/dist/cli/setup-command.js +60 -0
- package/dist/dancing-workflow.js +2 -1
- package/dist/env-file.js +91 -0
- package/dist/env-secrets.js +38 -74
- package/dist/ffprobe-binary.js +29 -7
- package/dist/hydration.js +56 -0
- package/dist/jobs/archive-clip.js +2 -1
- package/dist/jobs/download-video.js +2 -1
- package/dist/load-env.js +5 -2
- package/dist/one-password.js +200 -0
- package/dist/queue.js +4 -0
- package/dist/serve.js +5 -30
- package/dist/tools/videobook-domain.js +24 -9
- package/dist/ytdlp-binary.js +65 -0
- package/package.json +4 -12
- package/ui/assets/{alive-captions-DPEr1nKM.js → alive-captions-e-n3xmCd.js} +1 -1
- package/ui/assets/{confirm-bodies-D8ouHXKy.js → confirm-bodies-DS6jh0Kb.js} +1 -1
- package/ui/assets/{fullscreen-B7nw4YbE.js → fullscreen-VY7f4mJ-.js} +1 -1
- package/ui/assets/{goal-ui-DK-xTCK0.js → goal-ui-B__NgZYz.js} +1 -1
- package/ui/assets/{index-kS1905s5.js → index-BPco0HoT.js} +2 -2
- package/ui/assets/{inspector-MlzQbTVo.js → inspector-BBuZIaqg.js} +1 -1
- package/ui/assets/{jobs-ui-CoinOWx0.js → jobs-ui-vmEH4zBT.js} +1 -1
- package/ui/assets/{music-video-Cbe80kmQ.js → music-video-CklLKD1R.js} +1 -1
- package/ui/assets/{ui-xo_Y1yQg.js → ui-CtYXY_po.js} +1 -1
- package/ui/assets/{vendor-DfTCWgKm.js → vendor-OClqq_Da.js} +2 -2
- package/ui/assets/{vendor-flow-BXbeWxW7.js → vendor-flow-DY81i6dC.js} +1 -1
- package/ui/assets/{vendor-mcp-BTlEJcHV.js → vendor-mcp-BsGNAD4I.js} +1 -1
- package/ui/assets/{videobook-Qlp9HQFu.js → videobook-lhLK6e6Q.js} +1 -1
- package/ui/index.html +11 -11
package/dist/cli/run-command.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
+
}
|
package/dist/dancing-workflow.js
CHANGED
|
@@ -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)
|
package/dist/env-file.js
ADDED
|
@@ -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
|
+
}
|
package/dist/env-secrets.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret-source detection and .env editing.
|
|
3
3
|
*
|
|
4
|
-
* Secrets reach the server
|
|
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
|
|
11
|
-
* "1password" — externally managed and read-only,
|
|
12
|
-
* would be silently shadowed on the next start. A key
|
|
13
|
-
* via `.env` is "env" and editable. A
|
|
14
|
-
* "1password"; the label is
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/ffprobe-binary.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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()
|
|
10
|
+
const store = getDefaultSecretStore();
|
|
11
|
+
store.loadDotenv();
|
|
12
|
+
store.resolveOnePassword();
|