@crievo/omni 0.1.7 → 0.1.9
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/omnivideo.config.example.json +2 -0
- package/package.json +1 -1
- package/reference/cloud.md +15 -1
- package/reference/learnings.md +7 -0
- package/reference/scenes-contract.md +3 -2
- package/reference/studio.md +3 -2
- package/studio/api/app.ts +4 -0
- package/studio/api/routes/cost.ts +13 -0
- package/studio/api/routes/me.ts +8 -0
- package/studio/api/services/agent-persona.ts +1 -0
- package/studio/api/services/agent-tools.ts +2 -2
- package/studio/api/services/cost.ts +22 -0
- package/studio/api/services/me.ts +67 -0
- package/studio/api/services/sessions.ts +8 -2
- package/studio/api/services/sound.ts +17 -6
- package/studio/runner/runner.ts +57 -10
- package/studio/shared/omni-screens.ts +1 -1
- package/studio/shared/schemas/cost.ts +10 -0
- package/studio/shared/types/api.ts +77 -1
- package/studio/studio.ts +27 -2
- package/studio/web/components/AppShell.tsx +11 -3
- package/studio/web/components/FirstConnection.tsx +222 -0
- package/studio/web/components/OfflineNotice.tsx +59 -0
- package/studio/web/components/SetupSteps.tsx +63 -0
- package/studio/web/components/Sidebar.tsx +10 -25
- package/studio/web/components/StudioOffline.tsx +48 -50
- package/studio/web/components/UserCard.tsx +79 -0
- package/studio/web/components/icons.tsx +2 -0
- package/studio/web/hooks/useComputers.ts +55 -0
- package/studio/web/hooks/useCost.ts +11 -0
- package/studio/web/hooks/useMe.ts +32 -0
- package/studio/web/hooks/useSetupProgress.ts +54 -0
- package/studio/web/lib/me.ts +17 -0
- package/studio/web/lib/query-client.ts +6 -0
- package/studio/web/lib/settings-categories.ts +2 -2
- package/studio/web/lib/setup-retry.ts +29 -0
- package/studio/web/lib/sound-timeline.ts +33 -0
- package/studio/web/lib/studio-offline.ts +105 -59
- package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
- package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
- package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
- package/studio/web/pages/SettingsPage/index.tsx +15 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/setup-steps.css +163 -0
- package/studio/web/styles/components/user-card.css +121 -0
- package/studio/web/styles/index.css +2 -0
- package/studio/web/styles/pages/settings/shell.css +79 -0
- package/studio/web/styles/pages/settings/usage.css +47 -0
- package/studio/web/styles/pages/studio-offline.css +224 -38
- package/tools/cost.mjs +114 -0
- package/tools/lib/agents.mjs +3 -2
- package/tools/lib/channel.mjs +1 -1
- package/tools/lib/cloud-user.mjs +58 -0
- package/tools/lib/cost.mjs +357 -0
- package/tools/lib/deps.mjs +27 -12
- package/tools/lib/oauth-client.mjs +28 -14
- package/tools/lib/pages-style.mjs +67 -0
- package/tools/lib/pairing.mjs +1 -1
- package/tools/lib/schedule/schedule.mjs +1 -1
- package/tools/lib/self-update.mjs +122 -0
- package/tools/lib/setup-progress.mjs +315 -0
- package/tools/lib/up.mjs +176 -61
- package/tools/metrics.mjs +6 -1
- package/tools/omni.mjs +41 -16
- package/tools/omnichat.mjs +45 -9
- package/tools/platforms.mjs +75 -14
- package/tools/runner.mjs +6 -6
- package/tools/setup.mjs +18 -5
- package/tools/sound.mjs +198 -94
package/tools/lib/up.mjs
CHANGED
|
@@ -5,26 +5,33 @@
|
|
|
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
|
-
tools ffmpeg, the Chrome without a window and Python in ~/.omni (tools/lib/deps.mjs
|
|
9
|
-
background on a hook)
|
|
8
|
+
tools ffmpeg, the Chrome without a window and Python in ~/.omni (tools/lib/deps.mjs)
|
|
10
9
|
login none yet: the browser opens on the approval page (at most once an hour)
|
|
11
10
|
claude on a hook, the session's Claude Code folder into ~/.omni/claude.json (claude-settings.mjs)
|
|
12
11
|
service the studio kept on at login (a launchd agent on macOS, a scheduled task on Windows, both in
|
|
13
12
|
tools/lib/service.mjs), from the app, so the hosted studio reaches this computer
|
|
14
13
|
|
|
14
|
+
The hook only decides and starts: no login yet opens the browser, and the process that waits for it goes on,
|
|
15
|
+
once the user approves, to the whole setup (`omni setup --auto`); a login without the rest of the setup
|
|
16
|
+
starts that same process. Nothing in a hook waits on the network. The setup reports each piece as it goes
|
|
17
|
+
(tools/lib/setup-progress.mjs), so the hosted page shows it to the user while the studio is not yet
|
|
18
|
+
connected.
|
|
19
|
+
|
|
15
20
|
In this repo (a git checkout) the app, the tools and the service are left to `tools/setup.mjs` and
|
|
16
21
|
`tools/runner.mjs service`: `up` never rewires a development machine. */
|
|
17
22
|
|
|
18
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync,
|
|
24
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { hostname } from "node:os";
|
|
20
26
|
import { join } from "node:path";
|
|
21
27
|
import { missingRules, recordClaudeDir } from "./claude-settings.mjs";
|
|
22
28
|
import { CLI_COMMAND, PACKAGE, STUDIO_URL } from "./cli.mjs";
|
|
23
|
-
import { OMNI_DIR, checkDeps, ensureDeps } from "./deps.mjs";
|
|
24
|
-
import { readTokens } from "./oauth-client.mjs";
|
|
29
|
+
import { DEP_STEPS, OMNI_DIR, checkDeps, ensureDep, ensureDeps } from "./deps.mjs";
|
|
30
|
+
import { accessToken, readTokens } from "./oauth-client.mjs";
|
|
25
31
|
import { CODE_ROOT, DATA_ROOT } from "./paths.mjs";
|
|
26
32
|
import { npmCommand } from "./platform.mjs";
|
|
27
33
|
import { service, serviceInstall, serviceWorkingDir } from "./service.mjs";
|
|
34
|
+
import { createReporter, setupRunning, silentReporter } from "./setup-progress.mjs";
|
|
28
35
|
|
|
29
36
|
const LOGIN_EVERY_MS = 60 * 60_000;
|
|
30
37
|
|
|
@@ -100,37 +107,48 @@ export function ensureApp(log = () => {}, codeRoot = CODE_ROOT) {
|
|
|
100
107
|
return installed;
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
/** the
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
/** whether the app is already where the service runs it from: this checkout, an installed package, or the
|
|
111
|
+
* copy of this version in ~/.omni/app @param {string} codeRoot */
|
|
112
|
+
export function appInstalled(codeRoot = CODE_ROOT) {
|
|
113
|
+
if (isDev(codeRoot) || !inNpxCache(codeRoot)) return true;
|
|
114
|
+
const version = JSON.parse(readFileSync(join(codeRoot, "package.json"), "utf8")).version;
|
|
115
|
+
return existsSync(join(OMNI_DIR, "app", version, "node_modules", ...PACKAGE.split("/"), "package.json"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** whether anything of the setup is still to do (the hook starts it when so): the app of this version, the
|
|
119
|
+
* programs, the studio service @param {string} codeRoot */
|
|
120
|
+
export function needsSetup(codeRoot = CODE_ROOT) {
|
|
121
|
+
if (isDev(codeRoot)) return false;
|
|
122
|
+
if (!appInstalled(codeRoot)) return true;
|
|
123
|
+
if (Object.values(checkDeps(codeRoot)).some(v => !v)) return true;
|
|
124
|
+
return (process.platform === "darwin" || process.platform === "win32") && !devServiceRoot() && !service().loaded;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** a process of its own that runs `omni setup --auto` (the login if it is missing, then the whole setup), so
|
|
128
|
+
* the session never waits @param {string} codeRoot */
|
|
129
|
+
function spawnSetup(codeRoot) {
|
|
112
130
|
mkdirSync(OMNI_DIR, { recursive: true });
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "running";
|
|
116
|
-
} catch {
|
|
117
|
-
/* no one is fetching them */
|
|
118
|
-
}
|
|
119
|
-
if (!background) return missing.join(", ");
|
|
120
|
-
writeFileSync(lock, String(process.pid));
|
|
121
|
-
const out = openSync(join(OMNI_DIR, "deps.log"), "a");
|
|
122
|
-
spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "deps", "install", "--unlock"], {
|
|
131
|
+
spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "setup", "--auto"], {
|
|
123
132
|
detached: true,
|
|
124
133
|
windowsHide: true,
|
|
125
|
-
stdio:
|
|
134
|
+
stdio: "ignore",
|
|
126
135
|
env: { ...process.env, OMNI_HOME: DATA_ROOT },
|
|
127
136
|
}).unref();
|
|
128
|
-
return "started";
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
/**
|
|
139
|
+
/** Starts the setup in the background, unless one is running.
|
|
140
|
+
* @returns {boolean} whether it started now */
|
|
141
|
+
export function startSetup(codeRoot = CODE_ROOT) {
|
|
142
|
+
if (setupRunning()) return false;
|
|
143
|
+
spawnSetup(codeRoot);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Opens the browser on the approval page, in the process that goes on to the setup once the user
|
|
148
|
+
* approves; at most once an hour, and never while one is already waiting.
|
|
132
149
|
* @returns {boolean} whether it opened now */
|
|
133
150
|
export function startLogin(codeRoot = CODE_ROOT) {
|
|
151
|
+
if (setupRunning()) return false;
|
|
134
152
|
const marker = join(OMNI_DIR, "login-opened");
|
|
135
153
|
mkdirSync(OMNI_DIR, { recursive: true });
|
|
136
154
|
try {
|
|
@@ -139,12 +157,7 @@ export function startLogin(codeRoot = CODE_ROOT) {
|
|
|
139
157
|
/* never opened */
|
|
140
158
|
}
|
|
141
159
|
writeFileSync(marker, new Date().toISOString());
|
|
142
|
-
|
|
143
|
-
detached: true,
|
|
144
|
-
windowsHide: true,
|
|
145
|
-
stdio: "ignore",
|
|
146
|
-
env: { ...process.env, OMNI_HOME: DATA_ROOT },
|
|
147
|
-
}).unref();
|
|
160
|
+
spawnSetup(codeRoot);
|
|
148
161
|
return true;
|
|
149
162
|
}
|
|
150
163
|
|
|
@@ -155,53 +168,155 @@ function devServiceRoot() {
|
|
|
155
168
|
return root && existsSync(join(root, ".git")) ? root : "";
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
const deviceName = () => hostname().replace(/\.local$/, "");
|
|
172
|
+
|
|
173
|
+
/** the studio as a service, unless this is a development machine or a system without one
|
|
174
|
+
* @returns {boolean} whether it changed anything @param {string} codeRoot @param {(line: string) => void} log */
|
|
175
|
+
function installService(codeRoot, log) {
|
|
176
|
+
if (isDev(codeRoot) || devServiceRoot()) return false;
|
|
177
|
+
if (process.platform !== "darwin" && process.platform !== "win32") return false;
|
|
178
|
+
return serviceInstall({ codeRoot, onlyIfChanged: true, log }).changed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Does one step of the setup again, or every step that isn't done (what the page's "Tentar de novo" asks for), and
|
|
183
|
+
* tells the reporter. Once nothing is left failing, the studio's own step goes last: it waits for the others, so
|
|
184
|
+
* the page doesn't leave the setup screen with a step still broken. `fns` are the steps themselves, replaced in tests.
|
|
185
|
+
* @param {string} step an id of tools/lib/setup-progress.mjs, or "all"
|
|
186
|
+
* @param {{ reporter: import("./setup-progress.mjs").Reporter, log?: (line: string) => void, fns?: Record<string, () => unknown> }} o
|
|
187
|
+
*/
|
|
188
|
+
export async function retrySetup(step, { reporter, log = () => {}, fns }) {
|
|
189
|
+
let codeRoot = CODE_ROOT;
|
|
190
|
+
const dev = () => isDev(codeRoot);
|
|
191
|
+
const steps = fns ?? {
|
|
192
|
+
workspace: () => ensureWorkspace(),
|
|
193
|
+
app: () => {
|
|
194
|
+
codeRoot = ensureApp(log);
|
|
195
|
+
},
|
|
196
|
+
...Object.fromEntries(DEP_STEPS.map(id => [id, () => (dev() ? undefined : ensureDep(id, codeRoot))])),
|
|
197
|
+
studio: () => installService(codeRoot, log),
|
|
198
|
+
};
|
|
199
|
+
/* the programs and the service run from the app: find it first, quietly, when this isn't the app's own step */
|
|
200
|
+
if (!fns && step !== "app" && step !== "workspace") {
|
|
201
|
+
try {
|
|
202
|
+
codeRoot = ensureApp(log);
|
|
203
|
+
} catch {
|
|
204
|
+
/* the app step reports its own failure when it is retried */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const failing = () => reporter.record.steps.some(s => s.state === "error");
|
|
208
|
+
const ids =
|
|
209
|
+
step === "all" ? reporter.record.steps.filter(s => s.state !== "done" && s.id !== "studio").map(s => s.id) : [step];
|
|
210
|
+
for (const id of ids) {
|
|
211
|
+
if (id === "studio" || !steps[id]) continue;
|
|
212
|
+
reporter.step(id, "running");
|
|
213
|
+
try {
|
|
214
|
+
await steps[id]();
|
|
215
|
+
reporter.step(id, "done");
|
|
216
|
+
} catch (e) {
|
|
217
|
+
reporter.step(id, "error", /** @type {Error} */ (e).message);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const studio = reporter.record.steps.find(s => s.id === "studio");
|
|
221
|
+
if (studio?.state !== "done" && !failing() && steps.studio) {
|
|
222
|
+
reporter.step("studio", "running");
|
|
223
|
+
try {
|
|
224
|
+
await steps.studio();
|
|
225
|
+
reporter.step("studio", "done");
|
|
226
|
+
} catch (e) {
|
|
227
|
+
reporter.step("studio", "error", /** @type {Error} */ (e).message);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await reporter.flush();
|
|
231
|
+
}
|
|
232
|
+
|
|
158
233
|
/**
|
|
159
|
-
* @param {{ hook?: boolean, log?: (line: string) => void }} [o]
|
|
234
|
+
* @param {{ hook?: boolean, log?: (line: string) => void, report?: boolean }} [o] `report`: tell the hosted
|
|
235
|
+
* page each step as it happens (the setup does; a plain `omni up` stays quiet)
|
|
160
236
|
* @returns {Promise<{ line: string, codeRoot: string, loggedIn: boolean, missingRules: string[] }>}
|
|
161
237
|
*/
|
|
162
|
-
export async function up({ hook = false, log = () => {} } = {}) {
|
|
238
|
+
export async function up({ hook = false, log = () => {}, report = false } = {}) {
|
|
163
239
|
const notes = [];
|
|
164
240
|
/* the session's Claude Code folder, for the studio service: its settings and its default agent account */
|
|
165
241
|
if (hook) recordClaudeDir();
|
|
166
|
-
if (ensureWorkspace()) notes.push(`workspace criado em ${DATA_ROOT}`);
|
|
167
|
-
const codeRoot = ensureApp(log);
|
|
168
|
-
const dev = isDev(codeRoot);
|
|
169
242
|
const url = studioUrlOf();
|
|
243
|
+
const loggedIn = !!readTokens(url);
|
|
170
244
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (!r.ok) notes.push(`faltou preparar ${r.missing.join(", ")}: ${r.errors.join("; ")}`);
|
|
180
|
-
}
|
|
245
|
+
if (hook) {
|
|
246
|
+
/* decide and start; the work happens in a process of its own */
|
|
247
|
+
if (ensureWorkspace()) notes.push(`workspace criado em ${DATA_ROOT}`);
|
|
248
|
+
if (!loggedIn)
|
|
249
|
+
notes.push(startLogin() ? "abri o navegador para entrar no OmniVideo" : "falta entrar no OmniVideo (omni login)");
|
|
250
|
+
else if (needsSetup())
|
|
251
|
+
notes.push(startSetup() ? "preparando este computador em segundo plano" : "ainda preparando este computador");
|
|
252
|
+
return finish({ notes, loggedIn, codeRoot: CODE_ROOT });
|
|
181
253
|
}
|
|
182
254
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
255
|
+
/* no login yet: the process that opens the browser carries on to the whole setup once the user approves, so
|
|
256
|
+
doing it here too would run two at once */
|
|
257
|
+
if (!loggedIn) {
|
|
258
|
+
notes.push(startLogin() ? "abri o navegador para entrar no OmniVideo" : "falta entrar no OmniVideo (omni login)");
|
|
259
|
+
return finish({ notes, loggedIn, codeRoot: CODE_ROOT });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const rep = report
|
|
263
|
+
? createReporter({ studioUrl: url, token: () => accessToken(url), device: deviceName() })
|
|
264
|
+
: silentReporter;
|
|
265
|
+
rep.start();
|
|
266
|
+
let broke = false;
|
|
267
|
+
/** one step of the setup: running, then done, or error with what went wrong
|
|
268
|
+
* @template T @param {string} id @param {() => T | Promise<T>} fn @returns {Promise<T | undefined>} */
|
|
269
|
+
const step = async (id, fn) => {
|
|
270
|
+
rep.step(id, "running");
|
|
271
|
+
try {
|
|
272
|
+
const r = await fn();
|
|
273
|
+
rep.step(id, "done");
|
|
274
|
+
return r;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
broke = true;
|
|
277
|
+
rep.step(id, "error", /** @type {Error} */ (e).message);
|
|
278
|
+
notes.push(/** @type {Error} */ (e).message);
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await step("workspace", () => {
|
|
284
|
+
if (ensureWorkspace()) notes.push(`workspace criado em ${DATA_ROOT}`);
|
|
285
|
+
});
|
|
286
|
+
const root = await step("app", () => ensureApp(log));
|
|
287
|
+
const codeRoot = root ?? CODE_ROOT;
|
|
288
|
+
const dev = isDev(codeRoot);
|
|
289
|
+
|
|
290
|
+
/* ffmpeg, the Chrome without a window, Python and yt-dlp, in ~/.omni */
|
|
291
|
+
if (!dev) {
|
|
292
|
+
const r = await ensureDeps(codeRoot, { log, onStep: (id, state, error) => rep.step(id, state, error) });
|
|
293
|
+
if (!r.ok) {
|
|
294
|
+
broke = true;
|
|
295
|
+
notes.push(`faltou preparar ${r.missing.join(", ")}: ${r.errors.join("; ")}`);
|
|
296
|
+
}
|
|
297
|
+
} else for (const id of ["video", "render", "voice", "style"]) rep.step(id, "done");
|
|
188
298
|
|
|
189
299
|
/* a development studio already kept on by launchd (this repo's runner.mjs) is never swapped for the app */
|
|
190
300
|
const devService = devServiceRoot();
|
|
191
301
|
if (!dev && devService)
|
|
192
302
|
notes.push(`o studio de desenvolvimento (${devService}) continua sendo o serviço deste computador`);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
303
|
+
/* the studio connects last, and (when the page is watching) only when nothing failed: it is what takes the page off
|
|
304
|
+
the setup screen, and a step still broken is better fixed there, with its "Tentar de novo", than found missing
|
|
305
|
+
later */
|
|
306
|
+
if (report && broke) notes.push("o studio liga quando as etapas que falharam terminarem");
|
|
307
|
+
else
|
|
308
|
+
await step("studio", () => {
|
|
309
|
+
if (installService(codeRoot, log)) notes.push("studio ligado como serviço");
|
|
310
|
+
});
|
|
311
|
+
await rep.flush();
|
|
312
|
+
return { ...finish({ notes, loggedIn, codeRoot, dev }), reporter: rep, failed: report && broke };
|
|
313
|
+
}
|
|
200
314
|
|
|
315
|
+
/** the one line `up` prints, and what it found @param {{ notes: string[], loggedIn: boolean, codeRoot: string, dev?: boolean }} o */
|
|
316
|
+
function finish({ notes, loggedIn, codeRoot, dev = isDev(codeRoot) }) {
|
|
201
317
|
const rules = missingRules({ cli: CLI_COMMAND });
|
|
202
318
|
if (rules.length)
|
|
203
319
|
notes.push("as skills ainda precisam de permissão: peça ao Claude para liberar as skills do OmniVideo");
|
|
204
|
-
|
|
205
320
|
const s = service();
|
|
206
321
|
const state = [
|
|
207
322
|
loggedIn ? "logado" : "sem login",
|
package/tools/metrics.mjs
CHANGED
|
@@ -203,7 +203,12 @@ async function pull(only) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
async function cloudPush() {
|
|
206
|
-
if (!cloudSettings().
|
|
206
|
+
if (!cloudSettings().jobsReady)
|
|
207
|
+
fail(
|
|
208
|
+
cloudSettings().user.on
|
|
209
|
+
? "cloud não configurada: cloud.studio_url no config"
|
|
210
|
+
: "cloud não configurada: cloud.url no config e OMNICHAT_CLOUD_SECRET no .env",
|
|
211
|
+
);
|
|
207
212
|
const { token, id } = await instagramToken();
|
|
208
213
|
await cloudApi("PUT", "/api/account", { id, token });
|
|
209
214
|
const videos = published(null);
|
package/tools/omni.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
omni status [--json] the account, the plan, the credits and this computer
|
|
13
13
|
omni logout signs this computer out in the cloud and here
|
|
14
14
|
omni allow [--yes] the Claude Code permission rules the skills need (asks first)
|
|
15
|
-
omni setup
|
|
15
|
+
omni setup [--auto] login, the rules and the whole setup (reported step by step to the hosted page);
|
|
16
|
+
--auto is what the plugin's hook starts in the background: no questions
|
|
16
17
|
omni doctor [--json] whether ffmpeg, ffprobe, the Chrome without a window, Python and yt-dlp answer
|
|
17
18
|
omni deps install fetches whichever of them doesn't answer into ~/.omni (tools/lib/deps.mjs)
|
|
18
19
|
omni skill <nome> [--v <versão>] [--part i/n]
|
|
@@ -37,7 +38,8 @@ import { pickPython } from "./lib/platform.mjs";
|
|
|
37
38
|
import { service } from "./lib/service.mjs";
|
|
38
39
|
import { vaultName } from "./lib/vault.mjs";
|
|
39
40
|
import { skillText } from "./lib/skill-client.mjs";
|
|
40
|
-
import {
|
|
41
|
+
import { acquireSetupLock, watchRetries } from "./lib/setup-progress.mjs";
|
|
42
|
+
import { retrySetup, startSetup, up } from "./lib/up.mjs";
|
|
41
43
|
import { cloudSettings } from "./platforms.mjs";
|
|
42
44
|
|
|
43
45
|
const fail = (/** @type {string} */ m) => {
|
|
@@ -131,7 +133,9 @@ const mcpTools = url => [
|
|
|
131
133
|
inputSchema: { type: "object", properties: {} },
|
|
132
134
|
run: async () => {
|
|
133
135
|
const t = await login(url, { deviceName: deviceName() });
|
|
134
|
-
|
|
136
|
+
/* the login is half of it: the programs and the studio service come next, shown on the hosted page */
|
|
137
|
+
startSetup();
|
|
138
|
+
return `Pronto: "${t.device_name}" entrou no OmniVideo. Estou preparando o computador em segundo plano (alguns minutos); o studio liga sozinho quando terminar.`;
|
|
135
139
|
},
|
|
136
140
|
},
|
|
137
141
|
{
|
|
@@ -287,20 +291,41 @@ export async function main(args) {
|
|
|
287
291
|
}
|
|
288
292
|
|
|
289
293
|
if (cmd === "login" || cmd === "setup") {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
294
|
+
const auto = args.includes("--auto");
|
|
295
|
+
/* one setup at a time: the hook starts this in the background, and so may the user */
|
|
296
|
+
const release = cmd === "setup" ? acquireSetupLock() : null;
|
|
297
|
+
if (cmd === "setup" && !release) return console.log("O OmniVideo já está sendo preparado neste computador.");
|
|
298
|
+
try {
|
|
299
|
+
if (cmd === "login" || !readTokens(url)) {
|
|
300
|
+
const t = await login(url, { deviceName: flag("name") || deviceName(), log: line => console.log(line) }).catch(
|
|
301
|
+
e => fail(String(e.message)),
|
|
302
|
+
);
|
|
303
|
+
console.log(`Pronto: "${t.device_name}" entrou no OmniVideo. O login ficou no ${vaultName()}.`);
|
|
304
|
+
}
|
|
305
|
+
if (cmd === "login") return console.log("O studio deste computador passa a usar este login em até 30 segundos.");
|
|
306
|
+
if (
|
|
307
|
+
!auto &&
|
|
308
|
+
missingRules().length &&
|
|
309
|
+
(args.includes("--yes") || (await confirm("Liberar as skills do OmniVideo nas settings do Claude Code?")))
|
|
310
|
+
)
|
|
311
|
+
allowSkillRules();
|
|
312
|
+
const done = await up({ log: line => console.log(line), report: true });
|
|
313
|
+
console.log(done.line);
|
|
314
|
+
/* a step failed: stay here, so the page's "Tentar de novo" has someone to ask (only in the background: an
|
|
315
|
+
interactive setup just prints what failed) */
|
|
316
|
+
if (auto && done.failed)
|
|
317
|
+
await watchRetries({
|
|
318
|
+
studioUrl: url,
|
|
319
|
+
token: () => accessToken(url),
|
|
320
|
+
reporter: done.reporter,
|
|
321
|
+
run: step => retrySetup(step, { reporter: done.reporter }),
|
|
322
|
+
});
|
|
323
|
+
} finally {
|
|
324
|
+
release?.();
|
|
295
325
|
}
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
(args.includes("--yes") || (await confirm("Liberar as skills do OmniVideo nas settings do Claude Code?")))
|
|
300
|
-
)
|
|
301
|
-
allowSkillRules();
|
|
302
|
-
console.log((await up({ log: line => console.log(line) })).line);
|
|
303
|
-
return console.log("Para usar no Claude Code: claude plugin install omnivideo --marketplace pedroflp/omni-plugin");
|
|
326
|
+
if (!auto)
|
|
327
|
+
console.log("Para usar no Claude Code: claude plugin install omnivideo --marketplace pedroflp/omni-plugin");
|
|
328
|
+
return;
|
|
304
329
|
}
|
|
305
330
|
|
|
306
331
|
if (cmd === "logout") {
|
package/tools/omnichat.mjs
CHANGED
|
@@ -1111,7 +1111,7 @@ async function status() {
|
|
|
1111
1111
|
);
|
|
1112
1112
|
if (s.studio)
|
|
1113
1113
|
console.log(
|
|
1114
|
-
` studio hospedado: ${s.studio.paired ? `
|
|
1114
|
+
` studio hospedado: ${s.studio.paired ? `esta máquina (${s.studio.name}) ${s.studio.online ? "atende por ela agora" : "não está conectada: o studio hospedado mostra a última cópia (node tools/runner.mjs status)"}` : "esta máquina não está pareada (node tools/runner.mjs login)"}`,
|
|
1115
1115
|
);
|
|
1116
1116
|
if (s.pause) console.log(` em pausa até ${dayTime(s.pause.until)}: ${s.pause.reason}`);
|
|
1117
1117
|
if (!s.rules.length) console.log(" nenhum vídeo com regra");
|
|
@@ -1129,8 +1129,11 @@ async function status() {
|
|
|
1129
1129
|
once, so whoever already got the DM never gets it again. While cloud.omnichat is false nothing
|
|
1130
1130
|
else changes. With owns: true the cloud is the one answering: every saved rule goes up on its own,
|
|
1131
1131
|
the record is read from there (and mirrored into omnichat-record.json for the studio), the manual
|
|
1132
|
-
send goes through it, and the local agent and webhook stop answering.
|
|
1133
|
-
|
|
1132
|
+
send goes through it, and the local agent and webhook stop answering.
|
|
1133
|
+
With cloud.user the omnichat lives in the user's own cloud (the studio's UserCloud, signed in with the Omni
|
|
1134
|
+
login, cloud.studio_url) instead of the Worker: cloudApi translates, so nothing below changes. That cloud
|
|
1135
|
+
verifies Meta's events with this user's own app secret, which `cloud push` sends as a secret. */
|
|
1136
|
+
const cloudReady = () => cloudSettings().jobsReady;
|
|
1134
1137
|
const cloudOwns = () => cloudSettings().owns.omnichat;
|
|
1135
1138
|
const CLOUD = {
|
|
1136
1139
|
get url() {
|
|
@@ -1160,6 +1163,20 @@ async function pushRule(dir) {
|
|
|
1160
1163
|
return true;
|
|
1161
1164
|
}
|
|
1162
1165
|
|
|
1166
|
+
/* The user's own cloud checks Meta's events with this user's own app secret and answers the handshake with their
|
|
1167
|
+
verify token: both go up as write-only secrets. The Worker had them as its own secrets, set by hand. */
|
|
1168
|
+
async function pushWebhookSecrets() {
|
|
1169
|
+
if (!cloudSettings().user.on) return [];
|
|
1170
|
+
const sent = [];
|
|
1171
|
+
for (const name of ["INSTAGRAM_APP_SECRET", "INSTAGRAM_WEBHOOK_VERIFY_TOKEN"]) {
|
|
1172
|
+
const value = env(name);
|
|
1173
|
+
if (!value) continue;
|
|
1174
|
+
await cloudApi("PUT", `/api/cloud/secrets/${name}`, { value });
|
|
1175
|
+
sent.push(name);
|
|
1176
|
+
}
|
|
1177
|
+
return sent;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1163
1180
|
/* a saved rule goes up when the cloud answers (the CLI waits for it before exiting: flushCloud) */
|
|
1164
1181
|
const pendingCloud = [];
|
|
1165
1182
|
function syncRule(dir) {
|
|
@@ -1220,10 +1237,17 @@ async function cloudHealth() {
|
|
|
1220
1237
|
|
|
1221
1238
|
async function cloudCmd(sub) {
|
|
1222
1239
|
if (sub === "status") {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1240
|
+
const user = cloudSettings().user;
|
|
1241
|
+
if (user.on) {
|
|
1242
|
+
console.log(
|
|
1243
|
+
`${user.url ? "✓" : "·"} cloud.user: a cloud do usuário${user.url ? ` em ${user.url}` : " (falta cloud.studio_url no omnivideo.config.json)"}`,
|
|
1244
|
+
);
|
|
1245
|
+
} else {
|
|
1246
|
+
console.log(
|
|
1247
|
+
`${CLOUD.url ? "✓" : "·"} cloud.url${CLOUD.url ? `: ${CLOUD.url}` : " (falta no omnivideo.config.json)"}`,
|
|
1248
|
+
);
|
|
1249
|
+
console.log(`${env("OMNICHAT_CLOUD_SECRET") ? "✓" : "·"} OMNICHAT_CLOUD_SECRET no .env`);
|
|
1250
|
+
}
|
|
1227
1251
|
console.log(` quem responde: ${cloudOwns() ? "a cloud (cloud.omnichat)" : "esta máquina"}`);
|
|
1228
1252
|
if (!cloudReady()) return;
|
|
1229
1253
|
const s = await cloudApi("GET", "/api/status");
|
|
@@ -1259,6 +1283,13 @@ async function cloudCmd(sub) {
|
|
|
1259
1283
|
expires: env("INSTAGRAM_TOKEN_EXPIRES") || null,
|
|
1260
1284
|
});
|
|
1261
1285
|
console.log(`config e conta @${acc.username} na cloud`);
|
|
1286
|
+
const secrets = await pushWebhookSecrets();
|
|
1287
|
+
if (cloudSettings().user.on)
|
|
1288
|
+
console.log(
|
|
1289
|
+
secrets.length === 2
|
|
1290
|
+
? "segredo do app e token de verificação do webhook na cloud"
|
|
1291
|
+
: `webhook: faltam no .env ${["INSTAGRAM_APP_SECRET", "INSTAGRAM_WEBHOOK_VERIFY_TOKEN"].filter(n => !secrets.includes(n)).join(" e ")} (node tools/omnichat.mjs webhook setup)`,
|
|
1292
|
+
);
|
|
1262
1293
|
for (const dir of only ? [only] : videosWithRule()) {
|
|
1263
1294
|
if (!(await pushRule(dir))) {
|
|
1264
1295
|
if (only) console.log(`${nameOf(dir)}: sem regra ou ainda não publicado`);
|
|
@@ -1348,11 +1379,16 @@ async function webhook(sub) {
|
|
|
1348
1379
|
`${secret ? "✓" : "·"} INSTAGRAM_APP_SECRET no .env${secret ? "" : " (falta: o segredo do app Instagram, em API setup with Instagram login → App secret)"}`,
|
|
1349
1380
|
);
|
|
1350
1381
|
console.log("✓ INSTAGRAM_WEBHOOK_VERIFY_TOKEN no .env");
|
|
1382
|
+
/* the user's own cloud has its own address for Meta: /hook/<its id> */
|
|
1383
|
+
const callback = cloudSettings().user.on
|
|
1384
|
+
? ((await cloudApi("GET", "/api/cloud/status").catch(() => null))?.hook_url ??
|
|
1385
|
+
`${cloudSettings().user.url || "https://<studio>"}/hook/<id da sua cloud: entre na conta Omni>`)
|
|
1386
|
+
: `${cloudSettings().url || "https://omnichat.<você>.workers.dev"}/webhook`;
|
|
1351
1387
|
console.log(`
|
|
1352
1388
|
Faltam estes passos, uma vez só:
|
|
1353
|
-
1. A cloud no ar (cloud/omnichat, reference/omnichat.md): é ela que recebe o webhook.
|
|
1389
|
+
1. A cloud no ar${cloudSettings().user.on ? " (a sua, na conta Omni; `cloud push` manda o segredo do app e o token abaixo para ela)" : " (cloud/omnichat, reference/omnichat.md)"}: é ela que recebe o webhook.
|
|
1354
1390
|
2. developers.facebook.com/apps → seu app → Instagram → API setup with Instagram login → Configure webhooks:
|
|
1355
|
-
Callback URL: ${
|
|
1391
|
+
Callback URL: ${callback}
|
|
1356
1392
|
Verify token: ${token}
|
|
1357
1393
|
e ligue os campos messages e messaging_postbacks.
|
|
1358
1394
|
3. node tools/omnichat.mjs webhook subscribe (inscreve a conta nesses campos)
|
package/tools/platforms.mjs
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { userRoute } from "./lib/cloud-user.mjs";
|
|
12
13
|
import { request as coreRequest, sleep, DAY } from "./lib/http.mjs";
|
|
14
|
+
import { accessToken } from "./lib/oauth-client.mjs";
|
|
13
15
|
import { igBase, instagramToken as coreInstagramToken, instagramClient } from "./lib/platforms/instagram.mjs";
|
|
14
16
|
import { DATA_ROOT as ROOT } from "./lib/paths.mjs";
|
|
15
17
|
|
|
@@ -79,27 +81,41 @@ export function writeEnv(values) {
|
|
|
79
81
|
renameSync(tmp, ENV_FILE);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
/* --- the cloud
|
|
83
|
-
`cloud` in the config: { url, omnichat, metrics, schedule }, each flag saying the cloud does that job
|
|
84
|
-
instead of this machine (the old omnichat.cloud { url, owns } still reads).
|
|
85
|
-
|
|
84
|
+
/* --- the cloud ----------------------------------------------------------------------------
|
|
85
|
+
`cloud` in the config: { url, omnichat, metrics, schedule, user }, each job flag saying the cloud does that job
|
|
86
|
+
instead of this machine (the old omnichat.cloud { url, owns } still reads).
|
|
87
|
+
|
|
88
|
+
Two clouds, while the move to a cloud of each user's own is under way (docs/specs/phase-3-per-user-cloud.md):
|
|
89
|
+
- the omnichat Worker (cloud/omnichat): `cloud.url`, with OMNICHAT_CLOUD_SECRET in the .env (its SYNC_SECRET;
|
|
90
|
+
OMNICHAT_CLOUD_URL points at another Worker: wrangler dev, a test). It does everything today.
|
|
91
|
+
- the user's own cloud (UserCloud in cloud/studio): `cloud.user: true`, at `cloud.studio_url`, signed in with the
|
|
92
|
+
Omni login of this computer. The omnichat, the Instagram account and the metrics go there; the schedule, the
|
|
93
|
+
media published now and the YouTube/TikTok credentials stay on the Worker until publishing moves (last step). */
|
|
86
94
|
export function cloudSettings() {
|
|
87
95
|
const top = CFG.cloud ?? {};
|
|
88
96
|
const legacy = CFG.omnichat?.cloud ?? {};
|
|
89
97
|
const url = process.env.OMNICHAT_CLOUD_URL || top.url || legacy.url || "";
|
|
90
98
|
const secret = env("OMNICHAT_CLOUD_SECRET");
|
|
91
99
|
const ready = !!(url && secret);
|
|
100
|
+
const studioUrl = String(process.env.OMNIVIDEO_STUDIO_URL || top.studio_url || "").replace(/\/+$/, "");
|
|
101
|
+
const user = { on: top.user === true, url: studioUrl };
|
|
102
|
+
/* whichever cloud the omnichat and the metrics are on can be asked */
|
|
103
|
+
const jobsReady = user.on ? !!studioUrl : ready;
|
|
92
104
|
return {
|
|
93
105
|
url: String(url).replace(/\/+$/, ""),
|
|
94
106
|
ready,
|
|
107
|
+
/** the omnichat and the metrics are in the user's own cloud (the login is checked when it is called) */
|
|
108
|
+
user,
|
|
109
|
+
/** the cloud those two jobs live in is configured */
|
|
110
|
+
jobsReady,
|
|
95
111
|
owns: {
|
|
96
|
-
omnichat:
|
|
97
|
-
metrics:
|
|
112
|
+
omnichat: jobsReady && (top.omnichat ?? legacy.owns) === true,
|
|
113
|
+
metrics: jobsReady && top.metrics === true,
|
|
98
114
|
schedule: ready && top.schedule === true,
|
|
99
115
|
},
|
|
100
116
|
/* the hosted studio (cloud/studio): this machine connects to it as the runner */
|
|
101
117
|
studio: {
|
|
102
|
-
url:
|
|
118
|
+
url: studioUrl,
|
|
103
119
|
on: !!(process.env.OMNIVIDEO_STUDIO_URL || top.studio_url) && top.studio === true,
|
|
104
120
|
},
|
|
105
121
|
};
|
|
@@ -131,13 +147,11 @@ export async function runnerStatus() {
|
|
|
131
147
|
return out;
|
|
132
148
|
}
|
|
133
149
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (!c.ready) throw new Error("cloud não configurada: cloud.url no config e OMNICHAT_CLOUD_SECRET no .env");
|
|
138
|
-
const r = await fetch(`${c.url}${path}`, {
|
|
150
|
+
/* one JSON call; throws `<label>: <status> <error>` with `status` */
|
|
151
|
+
async function callJson(label, url, method, headers, body) {
|
|
152
|
+
const r = await fetch(url, {
|
|
139
153
|
method,
|
|
140
|
-
headers: {
|
|
154
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
141
155
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
142
156
|
});
|
|
143
157
|
const text = await r.text();
|
|
@@ -150,7 +164,7 @@ export async function cloudApi(/** @type {string} */ method, /** @type {string}
|
|
|
150
164
|
if (!r.ok) {
|
|
151
165
|
/** @type {Error & { status?: number }} */
|
|
152
166
|
const e = new Error(
|
|
153
|
-
|
|
167
|
+
`${label}: ${r.status} ${j?.error ?? text.slice(0, 200)}${j?.problems ? `: ${j.problems.join("; ")}` : ""}`,
|
|
154
168
|
);
|
|
155
169
|
e.status = r.status;
|
|
156
170
|
throw e;
|
|
@@ -158,6 +172,53 @@ export async function cloudApi(/** @type {string} */ method, /** @type {string}
|
|
|
158
172
|
return j ?? {};
|
|
159
173
|
}
|
|
160
174
|
|
|
175
|
+
/** One call to the cloud's API, with the omnichat Worker's paths and bodies (cloud/omnichat); throws
|
|
176
|
+
* `cloud <method> <path>: <status> <error>` with `status`. With cloud.user, the omnichat, account and metrics calls
|
|
177
|
+
* go to the user's own cloud, translated (lib/cloud-user.mjs); the rest still goes to the Worker. */
|
|
178
|
+
export async function cloudApi(/** @type {string} */ method, /** @type {string} */ path, /** @type {unknown} */ body) {
|
|
179
|
+
const c = cloudSettings();
|
|
180
|
+
const label = `cloud ${method} ${path}`;
|
|
181
|
+
const mapped = c.user.on ? userRoute(method, path, body) : null;
|
|
182
|
+
if (mapped) {
|
|
183
|
+
if (!c.user.url) throw new Error("cloud.user pede cloud.studio_url no omnivideo.config.json");
|
|
184
|
+
const token = await accessToken(c.user.url);
|
|
185
|
+
if (!token)
|
|
186
|
+
throw new Error(
|
|
187
|
+
"esta máquina não está logada na conta Omni: abra uma sessão do Claude Code (o plugin faz o login)",
|
|
188
|
+
);
|
|
189
|
+
const out = await callJson(
|
|
190
|
+
label,
|
|
191
|
+
`${c.user.url}${mapped.path}`,
|
|
192
|
+
mapped.method,
|
|
193
|
+
{ authorization: `Bearer ${token}` },
|
|
194
|
+
mapped.body,
|
|
195
|
+
);
|
|
196
|
+
/* the Worker that still publishes needs the same Instagram token */
|
|
197
|
+
if (method === "PUT" && path === "/api/account" && c.owns.schedule)
|
|
198
|
+
await legacyApi(c, method, path, body).catch(e =>
|
|
199
|
+
console.error(`aviso: o Worker que publica não recebeu a conta do Instagram (${e.message})`),
|
|
200
|
+
);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
return legacyApi(c, method, path, body);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function legacyApi(
|
|
207
|
+
/** @type {ReturnType<typeof cloudSettings>} */ c,
|
|
208
|
+
/** @type {string} */ method,
|
|
209
|
+
/** @type {string} */ path,
|
|
210
|
+
/** @type {unknown} */ body,
|
|
211
|
+
) {
|
|
212
|
+
if (!c.ready) throw new Error("cloud não configurada: cloud.url no config e OMNICHAT_CLOUD_SECRET no .env");
|
|
213
|
+
return callJson(
|
|
214
|
+
`cloud ${method} ${path}`,
|
|
215
|
+
`${c.url}${path}`,
|
|
216
|
+
method,
|
|
217
|
+
{ authorization: `Bearer ${env("OMNICHAT_CLOUD_SECRET")}` },
|
|
218
|
+
body,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
161
222
|
/* The cloud's copy of the Instagram token. While the cloud does any job, a token this machine renews or
|
|
162
223
|
receives goes up too, so the two never drift: Meta does not promise that
|
|
163
224
|
the token before a renewal keeps working. A failure is only a warning: `omnichat.mjs cloud push` redoes it. */
|