@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.
- package/docs/onboarding.md +69 -161
- package/omnivideo.config.example.json +1 -1
- package/package.json +3 -2
- package/reference/omnichat.md +5 -4
- package/reference/publish.md +9 -9
- package/reference/studio.md +1 -1
- package/studio/api/services/onboarding.ts +242 -21
- package/studio/api/services/settings.ts +4 -2
- package/studio/shared/types/api.ts +26 -1
- package/studio/web/hooks/useOnboarding.ts +24 -6
- package/studio/web/lib/alerts.ts +10 -0
- package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
- package/studio/web/styles/pages/getting-started.css +46 -0
- package/studio/web/styles/pages/settings/shell.css +4 -0
- package/tools/lib/agents.mjs +24 -13
- package/tools/lib/claude-settings.mjs +32 -3
- package/tools/lib/deps.mjs +260 -0
- package/tools/lib/platforms/instagram.mjs +1 -1
- package/tools/lib/service.mjs +4 -1
- package/tools/lib/up.mjs +38 -24
- package/tools/omni.mjs +45 -9
- package/tools/omnichat.mjs +5 -7
- package/tools/package.mjs +8 -6
- package/tools/publish.mjs +40 -137
- package/tools/reference.mjs +1 -1
- package/tools/render.mjs +11 -0
- package/tools/setup.mjs +16 -15
|
@@ -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 (
|
|
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,
|
package/tools/lib/service.mjs
CHANGED
|
@@ -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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
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
|
|
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
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
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 "
|
|
98
|
+
if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "running";
|
|
93
99
|
} catch {
|
|
94
|
-
/* no one is
|
|
100
|
+
/* no one is fetching them */
|
|
95
101
|
}
|
|
102
|
+
if (!background) return missing.join(", ");
|
|
96
103
|
writeFileSync(lock, String(process.pid));
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
package/tools/omnichat.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
1288
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
11
|
-
|
|
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,
|
|
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
|
-
|
|
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",
|