@crievo/omni 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ /* The programs the pipeline runs, brought by the CLI itself into ~/.omni (docs/specs/phase-2-plugin-cli.md,
2
+ step 4): no Homebrew, no admin password, the same on macOS, Windows and Linux (x64 and arm64).
3
+
4
+ ffmpeg, ffprobe static builds from the ffmpeg-static project (FFMPEG_RELEASE), in ~/.omni/bin
5
+ chrome a Chrome, Chromium or Brave already installed, else Chrome Headless Shell (Google's
6
+ build for automation) through @puppeteer/browsers, in ~/.omni/browsers; the render
7
+ takes it from CHROME_PATH
8
+ python uv (UV_VERSION, in ~/.omni/bin) makes the app's .venv with a Python of its own and
9
+ installs requirements.txt into it
10
+ yt-dlp the project's standalone build (YTDLP_RELEASE), in ~/.omni/bin: a visual style from a
11
+ link (tools/reference.mjs). Sites change often, so bump the release when a download breaks
12
+
13
+ Each piece is checked by running it, and only fetched when it doesn't answer, so `omni up` can call
14
+ ensureDeps on every session. What was installed goes in ~/.omni/deps.json; depsEnv() is the PATH and
15
+ CHROME_PATH the service and `omni run` hand to every tool, so the 60-odd places that spawn "ffmpeg" by
16
+ name need no change. OMNI_DIR moves ~/.omni (tests). */
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import {
20
+ chmodSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ readdirSync,
26
+ renameSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { homedir, tmpdir } from "node:os";
31
+ import { delimiter, join } from "node:path";
32
+ import { gunzipSync } from "node:zlib";
33
+
34
+ export const OMNI_DIR = process.env.OMNI_DIR || join(homedir(), ".omni");
35
+ export const BIN_DIR = join(OMNI_DIR, "bin");
36
+ const STATE = join(OMNI_DIR, "deps.json");
37
+ export const FFMPEG_RELEASE = "b6.1.1";
38
+ export const UV_VERSION = "0.12.21";
39
+ export const YTDLP_RELEASE = "2026.08.19";
40
+ const PYTHON_VERSION = "3.12";
41
+
42
+ const win = process.platform === "win32";
43
+ const exe = (/** @type {string} */ name) => (win ? `${name}.exe` : name);
44
+ /* ffmpeg-static has no Windows arm64 build: the x64 one runs under emulation */
45
+ const arch = () => (process.arch === "arm64" && !win ? "arm64" : "x64");
46
+
47
+ /** the app's venv Python, as each OS lays a venv out @param {string} codeRoot */
48
+ export const venvPython = codeRoot =>
49
+ win ? join(codeRoot, ".venv", "Scripts", "python.exe") : join(codeRoot, ".venv", "bin", "python");
50
+
51
+ /** @returns {{ chrome?: string }} */
52
+ const readState = () => {
53
+ try {
54
+ return JSON.parse(readFileSync(STATE, "utf8"));
55
+ } catch {
56
+ return {};
57
+ }
58
+ };
59
+ const writeState = (/** @type {object} */ patch) => {
60
+ mkdirSync(OMNI_DIR, { recursive: true });
61
+ writeFileSync(STATE, `${JSON.stringify({ ...readState(), ...patch }, null, 2)}\n`);
62
+ };
63
+
64
+ /** the first line a program prints for these args, or null when it doesn't run @param {string} bin @param {string[]} args */
65
+ const answers = (bin, args) => {
66
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 20_000 });
67
+ return r.status === 0 ? (r.stdout || r.stderr).trim().split("\n")[0] : null;
68
+ };
69
+
70
+ /** @param {string} url */
71
+ async function fetchBytes(url) {
72
+ const r = await fetch(url);
73
+ if (!r.ok) throw new Error(`download falhou (${r.status}): ${url}`);
74
+ return Buffer.from(await r.arrayBuffer());
75
+ }
76
+
77
+ /** writes a binary atomically and makes it executable @param {string} file @param {Buffer} bytes */
78
+ function install(file, bytes) {
79
+ mkdirSync(BIN_DIR, { recursive: true });
80
+ const tmp = `${file}.download`;
81
+ writeFileSync(tmp, bytes);
82
+ chmodSync(tmp, 0o755);
83
+ renameSync(tmp, file);
84
+ }
85
+
86
+ /* --- ffmpeg and ffprobe ------------------------------------------------------------------ */
87
+
88
+ export async function ensureFfmpeg() {
89
+ for (const name of ["ffmpeg", "ffprobe"]) {
90
+ const file = join(BIN_DIR, exe(name));
91
+ if (existsSync(file) && answers(file, ["-version"])) continue;
92
+ const url = `https://github.com/eugeneware/ffmpeg-static/releases/download/${FFMPEG_RELEASE}/${name}-${win ? "win32" : process.platform}-${arch()}.gz`;
93
+ install(file, gunzipSync(await fetchBytes(url)));
94
+ if (!answers(file, ["-version"])) throw new Error(`o ${name} baixado não roda neste computador`);
95
+ }
96
+ }
97
+
98
+ /* --- Chrome Headless Shell --------------------------------------------------------------- */
99
+
100
+ /** a Chromium browser already on this machine, which the render can drive just as well */
101
+ export function systemChrome() {
102
+ const pf = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
103
+ return [
104
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
105
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
106
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
107
+ "/usr/bin/google-chrome",
108
+ "/usr/bin/chromium",
109
+ "/usr/bin/chromium-browser",
110
+ ...pf.map(d => join(/** @type {string} */ (d), "Google", "Chrome", "Application", "chrome.exe")),
111
+ ].find(p => existsSync(p));
112
+ }
113
+
114
+ /** the Chrome the render will use: CHROME_PATH, the one fetched into ~/.omni, else one installed */
115
+ export function chromePath() {
116
+ const fetched = readState().chrome;
117
+ return [process.env.CHROME_PATH, fetched, systemChrome()].find(p => p && existsSync(p)) ?? null;
118
+ }
119
+
120
+ export async function ensureChrome() {
121
+ const known = chromePath();
122
+ if (known) return known;
123
+ /* the CLI package depends on it; a development checkout uses the Chrome it has */
124
+ const browsers = await import("@puppeteer/browsers");
125
+ const platform = browsers.detectBrowserPlatform();
126
+ if (!platform) throw new Error(`não há Chrome Headless Shell para ${process.platform}/${process.arch}`);
127
+ const browser = browsers.Browser.CHROMEHEADLESSSHELL;
128
+ const buildId = await browsers.resolveBuildId(browser, platform, "stable");
129
+ const got = await browsers.install({ browser, buildId, platform, cacheDir: join(OMNI_DIR, "browsers") });
130
+ writeState({ chrome: got.executablePath });
131
+ return got.executablePath;
132
+ }
133
+
134
+ /* --- yt-dlp ----------------------------------------------------------------------------- */
135
+
136
+ /** the release asset for this machine: one universal macOS build, Linux per arch, Windows per arch */
137
+ export function ytdlpAsset(platform = process.platform, cpu = process.arch) {
138
+ if (platform === "darwin") return "yt-dlp_macos";
139
+ if (platform === "win32") return cpu === "arm64" ? "yt-dlp_arm64.exe" : "yt-dlp.exe";
140
+ return cpu === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
141
+ }
142
+
143
+ export async function ensureYtdlp() {
144
+ /* one already on the PATH (a development checkout's Homebrew) serves as well */
145
+ if (answers("yt-dlp", ["--version"])) return;
146
+ const file = join(BIN_DIR, exe("yt-dlp"));
147
+ if (existsSync(file) && answers(file, ["--version"])) return;
148
+ const url = `https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_RELEASE}/${ytdlpAsset()}`;
149
+ install(file, await fetchBytes(url));
150
+ if (!answers(file, ["--version"])) throw new Error("o yt-dlp baixado não roda neste computador");
151
+ }
152
+
153
+ /* --- uv and the Python venv -------------------------------------------------------------- */
154
+
155
+ async function ensureUv() {
156
+ const file = join(BIN_DIR, exe("uv"));
157
+ if (existsSync(file) && answers(file, ["--version"])) return file;
158
+ const triple = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-${win ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-gnu"}`;
159
+ const archive = `uv-${triple}.${win ? "zip" : "tar.gz"}`;
160
+ const tmp = mkdtempSync(join(tmpdir(), "omni-uv-"));
161
+ try {
162
+ writeFileSync(
163
+ join(tmp, archive),
164
+ await fetchBytes(`https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${archive}`),
165
+ );
166
+ /* tar reads both .tar.gz and .zip on macOS and Windows 10+, and .tar.gz on Linux */
167
+ const x = spawnSync("tar", ["-xf", archive], { cwd: tmp, encoding: "utf8" });
168
+ if (x.status !== 0) throw new Error(`não consegui abrir o ${archive}: ${x.stderr.trim()}`);
169
+ const found = readdirSync(tmp, { recursive: true, withFileTypes: true }).find(
170
+ e => e.isFile() && e.name === exe("uv"),
171
+ );
172
+ if (!found) throw new Error(`o ${archive} não tem o uv`);
173
+ install(file, readFileSync(join(found.parentPath, found.name)));
174
+ } finally {
175
+ rmSync(tmp, { recursive: true, force: true });
176
+ }
177
+ return file;
178
+ }
179
+
180
+ /** the venv with requirements.txt installed @param {string} codeRoot */
181
+ export async function ensurePython(codeRoot) {
182
+ const python = venvPython(codeRoot);
183
+ if (existsSync(python) && answers(python, ["-c", "import numpy, soundfile, requests; print('ok')"])) return python;
184
+ const uv = await ensureUv();
185
+ const env = {
186
+ ...process.env,
187
+ UV_PYTHON_INSTALL_DIR: join(OMNI_DIR, "python"),
188
+ UV_CACHE_DIR: join(OMNI_DIR, "uv-cache"),
189
+ };
190
+ const venv = spawnSync(uv, ["venv", "--python", PYTHON_VERSION, "--allow-existing", join(codeRoot, ".venv")], {
191
+ encoding: "utf8",
192
+ env,
193
+ });
194
+ if (venv.status !== 0) throw new Error(`uv venv falhou: ${venv.stderr.trim().split("\n").at(-1)}`);
195
+ const pip = spawnSync(uv, ["pip", "install", "--python", python, "-r", join(codeRoot, "requirements.txt")], {
196
+ encoding: "utf8",
197
+ env,
198
+ });
199
+ if (pip.status !== 0) throw new Error(`uv pip install falhou: ${pip.stderr.trim().split("\n").at(-1)}`);
200
+ return python;
201
+ }
202
+
203
+ /* --- together ---------------------------------------------------------------------------- */
204
+
205
+ /** the environment every tool runs with: ~/.omni/bin first on PATH, and the Chrome the render uses */
206
+ export function depsEnv(env = process.env) {
207
+ const chrome = chromePath();
208
+ return {
209
+ PATH: [BIN_DIR, env.PATH ?? ""].filter(Boolean).join(delimiter),
210
+ ...(chrome && !env.CHROME_PATH ? { CHROME_PATH: chrome } : {}),
211
+ };
212
+ }
213
+
214
+ /** what answers now: each program's version line, or null @param {string} codeRoot */
215
+ export function checkDeps(codeRoot) {
216
+ const env = depsEnv();
217
+ const on = (/** @type {string} */ name, /** @type {string[]} */ args) => {
218
+ const r = spawnSync(name, args, { encoding: "utf8", timeout: 20_000, env: { ...process.env, ...env } });
219
+ return r.status === 0 ? (r.stdout || r.stderr).trim().split("\n")[0] : null;
220
+ };
221
+ const python = venvPython(codeRoot);
222
+ return {
223
+ ffmpeg: on("ffmpeg", ["-version"]),
224
+ ffprobe: on("ffprobe", ["-version"]),
225
+ chrome: chromePath(),
226
+ python: existsSync(python)
227
+ ? answers(python, ["-c", "import numpy, soundfile, requests; print('ok')"]) && python
228
+ : null,
229
+ ytdlp: on("yt-dlp", ["--version"]),
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Fetches whatever doesn't answer, one piece at a time, and says how it went.
235
+ * @param {string} codeRoot @param {{ log?: (line: string) => void }} [o]
236
+ * @returns {Promise<{ ok: boolean, missing: string[], errors: string[] }>}
237
+ */
238
+ export async function ensureDeps(codeRoot, { log = () => {} } = {}) {
239
+ /** @type {string[]} */
240
+ const errors = [];
241
+ const steps = /** @type {const} */ ([
242
+ ["ffmpeg", () => ensureFfmpeg()],
243
+ ["Chrome sem janela", () => ensureChrome()],
244
+ ["Python", () => ensurePython(codeRoot)],
245
+ ["yt-dlp", () => ensureYtdlp()],
246
+ ]);
247
+ for (const [name, run] of steps) {
248
+ try {
249
+ log(`preparando ${name}…`);
250
+ await run();
251
+ } catch (e) {
252
+ errors.push(`${name}: ${/** @type {Error} */ (e).message}`);
253
+ }
254
+ }
255
+ const c = checkDeps(codeRoot);
256
+ const missing = Object.entries(c)
257
+ .filter(([, v]) => !v)
258
+ .map(([k]) => k);
259
+ return { ok: missing.length === 0, missing, errors };
260
+ }
@@ -355,7 +355,7 @@ export function instagramClient({ token, apiVersion, ...deps }) {
355
355
  /**
356
356
  * Publishes a Reel from a public video URL (and an optional public cover URL).
357
357
  * With Instagram login Meta downloads the file itself: the caller provides the
358
- * URLs (a local tunnel on the CLI, object storage in the cloud) and `release`,
358
+ * URLs (R2 behind the cloud Worker's /media, for the CLI and the cloud alike) and `release`,
359
359
  * called once Instagram has finished downloading.
360
360
  * @param {{ client: InstagramClient, userId: string, videoUrl: string, coverUrl?: string | null,
361
361
  * cover?: { version: string } | null, caption: string, shareToFeed: boolean, coverAtS?: number | null,
@@ -1,7 +1,8 @@
1
1
  /* The studio kept on by launchd (macOS): `bun studio.ts` in <code root>/studio, started at login and again
2
2
  whenever it stops (30 s apart, so a studio already open in a terminal holding the port just waits its
3
3
  turn). PATH goes along because launchd starts with a minimal one, without claude, ffmpeg or the venv's
4
- python; OMNI_HOME goes along when the workspace is not the code root (tools/lib/paths.mjs).
4
+ python, with ~/.omni/bin (tools/lib/deps.mjs) first; OMNI_HOME goes along when the workspace is not the
5
+ code root (tools/lib/paths.mjs).
5
6
 
6
7
  Used by `tools/runner.mjs service` (this repo) and `omni up` (the installed CLI). Throws Errors in
7
8
  Portuguese; the callers print them. */
@@ -10,6 +11,7 @@ import { spawnSync } from "node:child_process";
10
11
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { dirname, join } from "node:path";
14
+ import { BIN_DIR } from "./deps.mjs";
13
15
  import { CODE_ROOT, DATA_ROOT, homeEnvPlist } from "./paths.mjs";
14
16
 
15
17
  export const AGENT = "com.omnivideo.studio";
@@ -44,6 +46,7 @@ function plist(/** @type {string} */ bun, /** @type {string} */ codeRoot) {
44
46
  const x = (/** @type {string} */ v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
47
  const path = [
46
48
  ...new Set([
49
+ BIN_DIR,
47
50
  dirname(bun),
48
51
  dirname(process.execPath),
49
52
  "/opt/homebrew/bin",
package/tools/lib/up.mjs CHANGED
@@ -5,24 +5,25 @@
5
5
  workspace ~/OmniVideo (OMNI_HOME): videos/, omnivideo.config.json pointing at the hosted studio, .env
6
6
  app a stable copy of the package in ~/.omni/app/<version>/ (npx's cache can be wiped), and
7
7
  ~/.omni/bin/omni
8
- python the venv of the voice and timing tools, in the app's .venv (in the background on a hook)
8
+ tools ffmpeg, the Chrome without a window and Python in ~/.omni (tools/lib/deps.mjs; in the
9
+ background on a hook)
9
10
  login none yet: the browser opens on the approval page (at most once an hour)
11
+ claude on a hook, the session's Claude Code folder into ~/.omni/claude.json (claude-settings.mjs)
10
12
  service the studio as a launchd agent, from the app, so the hosted studio reaches this Mac
11
13
 
12
- In this repo (a git checkout) the app, the venv and the service are left to `tools/setup.mjs` and
14
+ In this repo (a git checkout) the app, the tools and the service are left to `tools/setup.mjs` and
13
15
  `tools/runner.mjs service`: `up` never rewires a development machine. */
14
16
 
15
17
  import { spawn, spawnSync } from "node:child_process";
16
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
- import { homedir } from "node:os";
18
+ import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
19
  import { join } from "node:path";
19
- import { missingRules } from "./claude-settings.mjs";
20
+ import { missingRules, recordClaudeDir } from "./claude-settings.mjs";
20
21
  import { CLI_COMMAND, PACKAGE, STUDIO_URL } from "./cli.mjs";
22
+ import { OMNI_DIR, checkDeps, ensureDeps } from "./deps.mjs";
21
23
  import { readTokens } from "./oauth-client.mjs";
22
24
  import { CODE_ROOT, DATA_ROOT } from "./paths.mjs";
23
25
  import { PLIST, service, serviceInstall } from "./service.mjs";
24
26
 
25
- export const OMNI_DIR = join(homedir(), ".omni");
26
27
  const LOGIN_EVERY_MS = 60 * 60_000;
27
28
 
28
29
  const isDev = (/** @type {string} */ codeRoot) => existsSync(join(codeRoot, ".git"));
@@ -82,26 +83,31 @@ export function ensureApp(log = () => {}, codeRoot = CODE_ROOT) {
82
83
  return installed;
83
84
  }
84
85
 
85
- /** the venv of the Python tools, inside the app; on a hook in the background, logging to ~/.omni/venv.log
86
- * @returns {"ok" | "started" | "creating" | "failed"} */
87
- export function ensureVenv(codeRoot = CODE_ROOT, { background = false } = {}) {
88
- if (existsSync(join(codeRoot, ".venv", "bin", "python"))) return "ok";
89
- const lock = join(OMNI_DIR, "venv.lock");
86
+ /** the programs the pipeline runs (tools/lib/deps.mjs), fetched by a process of its own on a hook so the
87
+ * session never waits; logs in ~/.omni/deps.log
88
+ * @returns {"ok" | "started" | "running" | string} "ok", or what is still missing */
89
+ export function ensureTools(codeRoot = CODE_ROOT, { background = false } = {}) {
90
+ const c = checkDeps(codeRoot);
91
+ const missing = Object.entries(c)
92
+ .filter(([, v]) => !v)
93
+ .map(([k]) => k);
94
+ if (!missing.length) return "ok";
90
95
  mkdirSync(OMNI_DIR, { recursive: true });
96
+ const lock = join(OMNI_DIR, "deps.lock");
91
97
  try {
92
- if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "creating";
98
+ if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "running";
93
99
  } catch {
94
- /* no one is creating it */
100
+ /* no one is fetching them */
95
101
  }
102
+ if (!background) return missing.join(", ");
96
103
  writeFileSync(lock, String(process.pid));
97
- const script = `python3 -m venv .venv && .venv/bin/python -m pip install -q -r requirements.txt; rm -f "${lock}"`;
98
- if (background) {
99
- const out = join(OMNI_DIR, "venv.log");
100
- spawn("/bin/sh", ["-c", `${script} >> "${out}" 2>&1`], { cwd: codeRoot, detached: true, stdio: "ignore" }).unref();
101
- return "started";
102
- }
103
- const r = spawnSync("/bin/sh", ["-c", script], { cwd: codeRoot, stdio: "inherit" });
104
- return r.status === 0 ? "ok" : "failed";
104
+ const out = openSync(join(OMNI_DIR, "deps.log"), "a");
105
+ spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "deps", "install", "--unlock"], {
106
+ detached: true,
107
+ stdio: ["ignore", out, out],
108
+ env: { ...process.env, OMNI_HOME: DATA_ROOT },
109
+ }).unref();
110
+ return "started";
105
111
  }
106
112
 
107
113
  /** opens the browser on the approval page in a process of its own, at most once an hour
@@ -140,15 +146,23 @@ function devServiceRoot() {
140
146
  */
141
147
  export async function up({ hook = false, log = () => {} } = {}) {
142
148
  const notes = [];
149
+ /* the session's Claude Code folder, for the studio service: its settings and its default agent account */
150
+ if (hook) recordClaudeDir();
143
151
  if (ensureWorkspace()) notes.push(`workspace criado em ${DATA_ROOT}`);
144
152
  const codeRoot = ensureApp(log);
145
153
  const dev = isDev(codeRoot);
146
154
  const url = studioUrlOf();
147
155
 
156
+ /* ffmpeg, the Chrome without a window and Python, in ~/.omni: in the background on a hook, here otherwise */
148
157
  if (!dev) {
149
- const venv = ensureVenv(codeRoot, { background: hook });
150
- if (venv === "started" || venv === "creating") notes.push("preparando o Python das ferramentas de voz");
151
- if (venv === "failed") notes.push("o Python das ferramentas não instalou (veja ~/.omni/venv.log)");
158
+ if (hook) {
159
+ const tools = ensureTools(codeRoot, { background: true });
160
+ if (tools === "started" || tools === "running")
161
+ notes.push("baixando o ffmpeg, o Chrome sem janela, o Python e o yt-dlp (alguns minutos)");
162
+ } else {
163
+ const r = await ensureDeps(codeRoot, { log });
164
+ if (!r.ok) notes.push(`faltou preparar ${r.missing.join(", ")}: ${r.errors.join("; ")}`);
165
+ }
152
166
  }
153
167
 
154
168
  const loggedIn = !!readTokens(url);
package/tools/omni.mjs CHANGED
@@ -12,7 +12,8 @@
12
12
  omni logout signs this computer out in the cloud and here
13
13
  omni allow [--yes] the Claude Code permission rules the skills need (asks first)
14
14
  omni setup login, the rules and up, for whoever starts from a terminal
15
- omni doctor the system programs and keys (tools/setup.mjs check)
15
+ omni doctor [--json] whether ffmpeg, ffprobe, the Chrome without a window, Python and yt-dlp answer
16
+ omni deps install fetches whichever of them doesn't answer into ~/.omni (tools/lib/deps.mjs)
16
17
  omni skill <nome> [--v <versão>] [--part i/n]
17
18
  one part of a skill's body from the registry (tools/lib/skill-client.mjs)
18
19
  omni run <ferramenta> [args…]
@@ -21,12 +22,13 @@
21
22
  ============================================================================ */
22
23
 
23
24
  import { spawnSync } from "node:child_process";
24
- import { existsSync, readFileSync } from "node:fs";
25
+ import { existsSync, readFileSync, rmSync } from "node:fs";
25
26
  import { hostname } from "node:os";
26
27
  import { join } from "node:path";
27
28
  import { createInterface } from "node:readline/promises";
28
29
  import { allowSkillRules, missingRules, settingsFile } from "./lib/claude-settings.mjs";
29
30
  import { CLI_COMMAND, STUDIO_URL } from "./lib/cli.mjs";
31
+ import { OMNI_DIR, checkDeps, depsEnv, ensureDeps } from "./lib/deps.mjs";
30
32
  import { serveMcp } from "./lib/mcp-server.mjs";
31
33
  import { accessToken, login, logout, readTokens } from "./lib/oauth-client.mjs";
32
34
  import { CODE_ROOT, DATA_ROOT, isMainModule } from "./lib/paths.mjs";
@@ -87,6 +89,12 @@ async function statusText(url) {
87
89
  ? "O studio deste computador está ligado e atende o studio hospedado."
88
90
  : "O studio deste computador está desligado.",
89
91
  );
92
+ const missing = depsMissing();
93
+ lines.push(
94
+ missing.length
95
+ ? `Ainda preparando os programas do pipeline (faltam: ${missing.join(", ")}); o omni up baixa sozinho, ou chame-o de novo se demorar mais de 10 minutos.`
96
+ : "O computador está pronto: ffmpeg, ffprobe, o Chrome sem janela, o Python e o yt-dlp respondem.",
97
+ );
90
98
  const rules = missingRules();
91
99
  lines.push(
92
100
  rules.length
@@ -97,6 +105,13 @@ async function statusText(url) {
97
105
  return lines.join("\n");
98
106
  }
99
107
 
108
+ /** which of the pipeline's programs don't answer (tools/lib/deps.mjs), by the name the user knows */
109
+ const DEP_NAMES = { chrome: "Chrome sem janela", python: "Python", ytdlp: "yt-dlp" };
110
+ const depsMissing = () =>
111
+ Object.entries(checkDeps(CODE_ROOT))
112
+ .filter(([, v]) => !v)
113
+ .map(([k]) => DEP_NAMES[/** @type {keyof typeof DEP_NAMES} */ (k)] ?? k);
114
+
100
115
  /** the plugin's MCP tools @param {string} url */
101
116
  const mcpTools = url => [
102
117
  {
@@ -194,17 +209,38 @@ export async function main(args) {
194
209
  if (!bin) fail(`não existe a ferramenta ${tool}`);
195
210
  const r = spawnSync(/** @type {string} */ (bin), [/** @type {string} */ (script), ...rest], {
196
211
  stdio: "inherit",
197
- env: { ...process.env, OMNI_HOME: DATA_ROOT },
212
+ env: { ...process.env, ...depsEnv(), OMNI_HOME: DATA_ROOT },
198
213
  });
199
214
  process.exit(r.status ?? 1);
200
215
  }
201
216
 
202
217
  if (cmd === "doctor") {
203
- const r = spawnSync(process.execPath, [join(CODE_ROOT, "tools", "setup.mjs"), "check"], {
204
- stdio: "inherit",
205
- env: { ...process.env, OMNI_HOME: DATA_ROOT },
206
- });
207
- process.exit(r.status ?? 1);
218
+ const c = checkDeps(CODE_ROOT);
219
+ if (args.includes("--json")) return console.log(JSON.stringify(c));
220
+ const names = {
221
+ ffmpeg: "ffmpeg",
222
+ ffprobe: "ffprobe",
223
+ chrome: "Chrome sem janela",
224
+ python: "Python das ferramentas",
225
+ ytdlp: "yt-dlp (estilo visual por link)",
226
+ };
227
+ for (const [k, v] of Object.entries(c))
228
+ console.log(`${v ? "ok " : "falta"} ${names[/** @type {keyof typeof names} */ (k)]}${v ? `: ${v}` : ""}`);
229
+ if (Object.values(c).some(v => !v)) console.log(`Para baixar o que falta: ${CLI_COMMAND} deps install`);
230
+ return;
231
+ }
232
+
233
+ if (cmd === "deps" && args[1] === "install") {
234
+ const r = await ensureDeps(CODE_ROOT, { log: line => console.log(line) });
235
+ /* the background run of `omni up --hook` holds this lock while it fetches */
236
+ if (args.includes("--unlock")) rmSync(join(OMNI_DIR, "deps.lock"), { force: true });
237
+ console.log(
238
+ r.ok
239
+ ? "Tudo pronto: ffmpeg, ffprobe, o Chrome sem janela, o Python e o yt-dlp respondem."
240
+ : `Faltou: ${r.errors.join("; ")}`,
241
+ );
242
+ if (!r.ok) process.exit(1);
243
+ return;
208
244
  }
209
245
 
210
246
  const url = studioUrl();
@@ -291,7 +327,7 @@ export async function main(args) {
291
327
  }
292
328
 
293
329
  fail(
294
- "uso: omni up [--hook] | login | status [--json] | logout | allow [--yes] | setup | doctor | skill <nome> | run <ferramenta> … | mcp",
330
+ "uso: omni up [--hook] | login | status [--json] | logout | allow [--yes] | setup | doctor | deps install | skill <nome> | run <ferramenta> … | mcp",
295
331
  );
296
332
  }
297
333
 
@@ -72,7 +72,7 @@
72
72
  node tools/omnichat.mjs webhook setup | subscribe | status | serve [--port 5434]
73
73
  the messaging webhook that the button and the follow gate need: `setup` creates the verify token and says
74
74
  what to paste in the Meta panel, `subscribe` signs the account up to messages and postbacks, `serve` listens
75
- (behind a tunnel: cloudflared tunnel --url http://127.0.0.1:5434)
75
+ here for a test (the Callback URL is the cloud Worker's /webhook)
76
76
  node tools/omnichat.mjs cloud status | push [videos/<slug>] [--records] [--force] | pull videos/<slug>
77
77
  the Worker in cloud/omnichat (cloud.url in the config, OMNICHAT_CLOUD_SECRET in the .env):
78
78
  `push` sends the config, the Instagram account and the rules (with --records, the local records too,
@@ -1284,9 +1284,8 @@ async function cloudCmd(sub) {
1284
1284
  /* --- the messaging webhook: the button's tap and the follow gate ------------------------------
1285
1285
  The comment's DM opens with a button (rule.button / rule.require_follow). The tap reaches here as a
1286
1286
  `messaging_postbacks` event; handleTap (tools/lib/omnichat/gate.mjs) checks the criterion and sends the
1287
- link, or asks to follow. Meta needs a public HTTPS callback URL: `serve` listens on this machine and a
1288
- tunnel (cloudflared) exposes it while the Mac is awake. A webhook handled elsewhere (a cloud function)
1289
- uses the same handleWebhook. The record is written under the pass lock, so the agent and the tap
1287
+ link, or asks to follow. Meta needs a public HTTPS callback URL: the cloud Worker's /webhook
1288
+ (cloud/omnichat), which runs the same handleWebhook; `serve` listens on this machine for a test. The record is written under the pass lock, so the agent and the tap
1290
1289
  don't overwrite each other. */
1291
1290
  const WEBHOOK_PORT = 5434;
1292
1291
  const gatedRules = () => videosWithRule().filter(d => gateOn(ruleOf(d) ?? {}));
@@ -1337,10 +1336,9 @@ async function webhook(sub) {
1337
1336
  console.log("✓ INSTAGRAM_WEBHOOK_VERIFY_TOKEN no .env");
1338
1337
  console.log(`
1339
1338
  Faltam estes passos, uma vez só:
1340
- 1. Expor a porta ${port}: node tools/omnichat.mjs webhook serve e, em outro terminal,
1341
- cloudflared tunnel --url http://127.0.0.1:${port} (anote a URL https://….trycloudflare.com)
1339
+ 1. A cloud no ar (cloud/omnichat, reference/omnichat.md): é ela que recebe o webhook.
1342
1340
  2. developers.facebook.com/apps → seu app → Instagram → API setup with Instagram login → Configure webhooks:
1343
- Callback URL: <a URL do túnel>/webhook
1341
+ Callback URL: ${cloudSettings().url || "https://omnichat.<você>.workers.dev"}/webhook
1344
1342
  Verify token: ${token}
1345
1343
  e ligue os campos messages e messaging_postbacks.
1346
1344
  3. node tools/omnichat.mjs webhook subscribe (inscreve a conta nesses campos)
package/tools/package.mjs CHANGED
@@ -4,11 +4,12 @@
4
4
  this repo into .release/npm/ and packed as a .tgz. It carries the code a user's machine runs (the CLI,
5
5
  the pipeline tools, the templates and guides, the studio's API, runner and web) and nothing of the
6
6
  skills, which come from the registry. The studio runs on the `bun` dependency, so a machine needs Node,
7
- not Bun; Python's venv is made by `omni up`.
7
+ not Bun; ffmpeg, the Chrome without a window and Python are fetched by `omni up` (tools/lib/deps.mjs).
8
8
 
9
9
  node tools/package.mjs [--version <x.y.z>] builds and packs; prints the .tgz path
10
- node tools/package.mjs publish [--version …] builds, packs and publishes to npm with the account in
11
- NPM_CONFIG_USERCONFIG (e.g. ~/.npmrc-pedroflp)
10
+ node tools/package.mjs publish [--version …] builds, packs and publishes to npm: in CI through npm's
11
+ trusted publishing (.github/workflows/deploy.yml), by
12
+ hand with the account in NPM_CONFIG_USERCONFIG
12
13
 
13
14
  Try it before publishing: npm install --prefix /tmp/omni-test <the .tgz>, then
14
15
  /tmp/omni-test/node_modules/.bin/omni status
@@ -93,8 +94,8 @@ function build() {
93
94
  os: ["darwin"],
94
95
  license: "UNLICENSED",
95
96
  homepage: "https://omni.crievo.com",
96
- /* the studio's runtime libraries, plus the bun that runs it */
97
- dependencies: { ...studio.dependencies, bun: "1.4.3" },
97
+ /* the studio's runtime libraries, the bun that runs it, and what fetches the Chrome without a window */
98
+ dependencies: { ...studio.dependencies, bun: "1.4.3", "@puppeteer/browsers": "3.2.3" },
98
99
  };
99
100
  writeFileSync(join(PKG, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
100
101
  writeFileSync(
@@ -120,7 +121,8 @@ const tgz = join(OUT, packed.stdout.trim().split("\n").at(-1) ?? "");
120
121
  console.log(`${PACKAGE}@${v}: ${(size(PKG) / 1e6).toFixed(1)} MB de arquivos, pacote em ${tgz}`);
121
122
 
122
123
  if (args[0] === "publish") {
123
- if (!process.env.NPM_CONFIG_USERCONFIG)
124
+ /* CI publishes through npm's trusted publishing (OIDC); by hand, with the account's own login file */
125
+ if (!process.env.NPM_CONFIG_USERCONFIG && !process.env.GITHUB_ACTIONS)
124
126
  fail("defina NPM_CONFIG_USERCONFIG com o login da conta que publica (ex.: ~/.npmrc-pedroflp)");
125
127
  const r = spawnSync("npm", ["publish", tgz, "--access", "public", ...(v.includes("-") ? ["--tag", "next"] : [])], {
126
128
  stdio: "inherit",