@crievo/omni 0.1.4 → 0.1.6
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/package.json +5 -3
- package/reference/learnings.md +12 -0
- package/reference/studio.md +10 -5
- package/studio/api/services/queue.ts +2 -0
- package/studio/api/services/root.ts +2 -3
- package/studio/runner/runner.ts +1 -1
- package/studio/studio.ts +5 -3
- package/studio/web/lib/studio-offline.ts +31 -7
- package/tools/job-queue.mjs +25 -7
- package/tools/job_queue.py +100 -24
- package/tools/lib/deps.mjs +21 -10
- package/tools/lib/oauth-client.mjs +18 -45
- package/tools/lib/platform.mjs +102 -0
- package/tools/lib/service-run.mjs +124 -0
- package/tools/lib/service-windows.mjs +240 -0
- package/tools/lib/service.mjs +74 -4
- package/tools/lib/up.mjs +32 -17
- package/tools/lib/vault.mjs +116 -0
- package/tools/metrics.mjs +7 -2
- package/tools/omni.mjs +6 -8
- package/tools/omnichat.mjs +28 -10
- package/tools/package.mjs +4 -3
- package/tools/publish.mjs +13 -7
- package/tools/runner.mjs +6 -6
package/tools/lib/service.mjs
CHANGED
|
@@ -4,24 +4,52 @@
|
|
|
4
4
|
python, with ~/.omni/bin (tools/lib/deps.mjs) first; OMNI_HOME goes along when the workspace is not the
|
|
5
5
|
code root (tools/lib/paths.mjs).
|
|
6
6
|
|
|
7
|
+
On Windows the same job is a scheduled task at login, with a supervisor that reopens the studio
|
|
8
|
+
(tools/lib/service-windows.mjs); every function here picks one or the other by platform, and the macOS
|
|
9
|
+
code below is what it has always been.
|
|
10
|
+
|
|
7
11
|
Used by `tools/runner.mjs service` (this repo) and `omni up` (the installed CLI). Throws Errors in
|
|
8
12
|
Portuguese; the callers print them. */
|
|
9
13
|
|
|
10
14
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
16
|
import { homedir } from "node:os";
|
|
13
17
|
import { dirname, join } from "node:path";
|
|
14
18
|
import { BIN_DIR } from "./deps.mjs";
|
|
15
19
|
import { CODE_ROOT, DATA_ROOT, homeEnvPlist } from "./paths.mjs";
|
|
20
|
+
import { listeningOn, sleepSync } from "./platform.mjs";
|
|
21
|
+
import { windowsService } from "./service-windows.mjs";
|
|
16
22
|
|
|
17
23
|
export const AGENT = "com.omnivideo.studio";
|
|
18
24
|
export const PLIST = join(homedir(), "Library", "LaunchAgents", `${AGENT}.plist`);
|
|
19
25
|
export const LOG_FILE = join(DATA_ROOT, ".runner", "studio.log");
|
|
20
26
|
const domain = () => `gui/${process.getuid?.()}`;
|
|
21
27
|
const launchctl = (/** @type {string[]} */ ...a) => spawnSync("launchctl", a, { encoding: "utf8" });
|
|
28
|
+
const win = () => process.platform === "win32";
|
|
29
|
+
|
|
30
|
+
/** the scheduled task of this machine, set up for the code root it would run (Windows) */
|
|
31
|
+
const windowsTask = (/** @type {string} */ codeRoot = CODE_ROOT) =>
|
|
32
|
+
windowsService({
|
|
33
|
+
dir: dirname(LOG_FILE),
|
|
34
|
+
logFile: LOG_FILE,
|
|
35
|
+
runScript: join(codeRoot, "tools", "lib", "service-run.mjs"),
|
|
36
|
+
});
|
|
22
37
|
|
|
23
38
|
/** the bun to run the studio with: the CLI package's own (the `bun` dependency), else the one installed */
|
|
24
39
|
export function bunPath(codeRoot = CODE_ROOT) {
|
|
40
|
+
if (win()) {
|
|
41
|
+
/* `where` lists every match; the .cmd shims npm and bun's installer leave beside it can't be a service's command */
|
|
42
|
+
const found = spawnSync("where", ["bun"], { encoding: "utf8" })
|
|
43
|
+
.stdout.split(/\r?\n/)
|
|
44
|
+
.find(l => /\.exe$/i.test(l.trim()))
|
|
45
|
+
?.trim();
|
|
46
|
+
return [
|
|
47
|
+
join(codeRoot, "node_modules", "bun", "bin", "bun.exe"),
|
|
48
|
+
join(codeRoot, "..", "..", "bun", "bin", "bun.exe"),
|
|
49
|
+
found,
|
|
50
|
+
join(homedir(), ".bun", "bin", "bun.exe"),
|
|
51
|
+
].find(p => p && existsSync(p));
|
|
52
|
+
}
|
|
25
53
|
const found = spawnSync("/bin/sh", ["-lc", "command -v bun"], { encoding: "utf8" }).stdout.trim();
|
|
26
54
|
return [
|
|
27
55
|
join(codeRoot, "node_modules", ".bin", "bun"),
|
|
@@ -35,6 +63,7 @@ export function bunPath(codeRoot = CODE_ROOT) {
|
|
|
35
63
|
|
|
36
64
|
/** what launchd says about the studio's agent */
|
|
37
65
|
export function service() {
|
|
66
|
+
if (win()) return windowsTask().status();
|
|
38
67
|
const installed = existsSync(PLIST);
|
|
39
68
|
const printed = installed ? launchctl("print", `${domain()}/${AGENT}`) : null;
|
|
40
69
|
const pid = printed?.status === 0 ? Number(/\bpid = (\d+)/.exec(printed.stdout)?.[1] ?? 0) : 0;
|
|
@@ -90,7 +119,8 @@ function plist(/** @type {string} */ bun, /** @type {string} */ codeRoot) {
|
|
|
90
119
|
* @returns {{ changed: boolean }}
|
|
91
120
|
*/
|
|
92
121
|
export function serviceInstall({ codeRoot = CODE_ROOT, onlyIfChanged = false, log = () => {} } = {}) {
|
|
93
|
-
if (
|
|
122
|
+
if (win()) return windowsInstall({ codeRoot, onlyIfChanged, log });
|
|
123
|
+
if (process.platform !== "darwin") throw new Error("o serviço é do macOS (launchd) e do Windows (tarefa agendada)");
|
|
94
124
|
const bun = bunPath(codeRoot);
|
|
95
125
|
if (!bun) throw new Error("não achei o bun: instale com curl -fsSL https://bun.sh/install | bash");
|
|
96
126
|
const text = plist(bun, codeRoot);
|
|
@@ -116,22 +146,62 @@ export function serviceInstall({ codeRoot = CODE_ROOT, onlyIfChanged = false, lo
|
|
|
116
146
|
launchctl("bootout", `${domain()}/${AGENT}`);
|
|
117
147
|
let r = launchctl("bootstrap", domain(), PLIST);
|
|
118
148
|
for (let i = 0; i < 6 && r.status !== 0; i++) {
|
|
119
|
-
|
|
149
|
+
sleepSync(500);
|
|
120
150
|
r = launchctl("bootstrap", domain(), PLIST);
|
|
121
151
|
}
|
|
122
152
|
if (r.status !== 0) throw new Error(`launchctl bootstrap: ${(r.stderr || r.stdout).trim()}`);
|
|
123
|
-
if (
|
|
153
|
+
if (listeningOn(5432))
|
|
154
|
+
log("aviso: já há um studio aberto na porta 5432 (num terminal?): feche-o e o serviço assume em até 30 s");
|
|
155
|
+
return { changed: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** the Windows twin of the install above: the same bun, the same lockfile step, the same port warning */
|
|
159
|
+
function windowsInstall({ codeRoot, onlyIfChanged, log }) {
|
|
160
|
+
const bun = bunPath(codeRoot);
|
|
161
|
+
if (!bun) throw new Error('não achei o bun: instale com powershell -c "irm bun.sh/install.ps1 | iex"');
|
|
162
|
+
const task = windowsTask(codeRoot);
|
|
163
|
+
const options = {
|
|
164
|
+
bun,
|
|
165
|
+
codeRoot,
|
|
166
|
+
dataRoot: DATA_ROOT,
|
|
167
|
+
binDir: BIN_DIR,
|
|
168
|
+
path: process.env.PATH ?? process.env.Path ?? "",
|
|
169
|
+
};
|
|
170
|
+
if (onlyIfChanged && task.upToDate(options)) return { changed: false };
|
|
171
|
+
if (existsSync(join(codeRoot, "studio", "bun.lock"))) {
|
|
172
|
+
const deps = spawnSync(bun, ["install", "--frozen-lockfile", "--silent"], {
|
|
173
|
+
cwd: join(codeRoot, "studio"),
|
|
174
|
+
stdio: "inherit",
|
|
175
|
+
});
|
|
176
|
+
if (deps.status !== 0) throw new Error("bun install falhou em studio/");
|
|
177
|
+
}
|
|
178
|
+
task.install(options);
|
|
179
|
+
if (listeningOn(5432))
|
|
124
180
|
log("aviso: já há um studio aberto na porta 5432 (num terminal?): feche-o e o serviço assume em até 30 s");
|
|
125
181
|
return { changed: true };
|
|
126
182
|
}
|
|
127
183
|
|
|
128
184
|
export function serviceUninstall() {
|
|
185
|
+
if (win()) return windowsTask().uninstall();
|
|
129
186
|
launchctl("bootout", `${domain()}/${AGENT}`);
|
|
130
187
|
if (existsSync(PLIST)) unlinkSync(PLIST);
|
|
131
188
|
}
|
|
132
189
|
|
|
133
190
|
export function serviceRestart() {
|
|
191
|
+
if (win()) return windowsTask().restart();
|
|
134
192
|
if (!service().loaded) throw new Error("o serviço não está ligado");
|
|
135
193
|
const r = launchctl("kickstart", "-k", `${domain()}/${AGENT}`);
|
|
136
194
|
if (r.status !== 0) throw new Error(`launchctl kickstart: ${(r.stderr || r.stdout).trim()}`);
|
|
137
195
|
}
|
|
196
|
+
|
|
197
|
+
/** the studio folder the installed service runs from (a launchd plist's WorkingDirectory, the Windows
|
|
198
|
+
* spec's cwd), or "" when there is none */
|
|
199
|
+
export function serviceWorkingDir() {
|
|
200
|
+
if (win()) return windowsTask().workingDir();
|
|
201
|
+
try {
|
|
202
|
+
const wd = /<key>WorkingDirectory<\/key><string>([^<]+)<\/string>/.exec(readFileSync(PLIST, "utf8"))?.[1];
|
|
203
|
+
return wd ? wd.replace(/&/g, "&") : "";
|
|
204
|
+
} catch {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
}
|
package/tools/lib/up.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
background on a hook)
|
|
10
10
|
login none yet: the browser opens on the approval page (at most once an hour)
|
|
11
11
|
claude on a hook, the session's Claude Code folder into ~/.omni/claude.json (claude-settings.mjs)
|
|
12
|
-
service the studio
|
|
12
|
+
service the studio kept on at login (a launchd agent on macOS, a scheduled task on Windows, both in
|
|
13
|
+
tools/lib/service.mjs), from the app, so the hosted studio reaches this computer
|
|
13
14
|
|
|
14
15
|
In this repo (a git checkout) the app, the tools and the service are left to `tools/setup.mjs` and
|
|
15
16
|
`tools/runner.mjs service`: `up` never rewires a development machine. */
|
|
@@ -22,7 +23,8 @@ import { CLI_COMMAND, PACKAGE, STUDIO_URL } from "./cli.mjs";
|
|
|
22
23
|
import { OMNI_DIR, checkDeps, ensureDeps } from "./deps.mjs";
|
|
23
24
|
import { readTokens } from "./oauth-client.mjs";
|
|
24
25
|
import { CODE_ROOT, DATA_ROOT } from "./paths.mjs";
|
|
25
|
-
import {
|
|
26
|
+
import { npmCommand } from "./platform.mjs";
|
|
27
|
+
import { service, serviceInstall, serviceWorkingDir } from "./service.mjs";
|
|
26
28
|
|
|
27
29
|
const LOGIN_EVERY_MS = 60 * 60_000;
|
|
28
30
|
|
|
@@ -61,25 +63,40 @@ export function ensureWorkspace(home = DATA_ROOT, codeRoot = CODE_ROOT) {
|
|
|
61
63
|
return created;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** npx's cache, whichever way the OS writes the path @param {string} codeRoot */
|
|
67
|
+
const inNpxCache = codeRoot => /[\\/]_npx[\\/]/.test(codeRoot);
|
|
68
|
+
|
|
69
|
+
/** the `omni` shortcut in ~/.omni/bin: a shell script on macOS and Linux, a .cmd on Windows (where a file
|
|
70
|
+
* without an extension doesn't run) @param {string} installed the installed package folder @param {string} [platform] */
|
|
71
|
+
export function shortcut(installed, platform = process.platform) {
|
|
72
|
+
const main = join(installed, "bin", "omni.mjs");
|
|
73
|
+
if (platform === "win32")
|
|
74
|
+
/* % is cmd's variable mark, even inside quotes: a path with one needs it doubled */
|
|
75
|
+
return { name: "omni.cmd", text: `@echo off\r\nnode "${main.replace(/%/g, "%%")}" %*\r\n`, mode: 0o644 };
|
|
76
|
+
return { name: "omni", text: `#!/bin/sh\nexec node "${main}" "$@"\n`, mode: 0o755 };
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
/** The code root the service should run: this checkout, an already stable install, or a fresh copy in
|
|
65
80
|
* ~/.omni/app/<version>/ when this runs from npx's cache. @param {(line: string) => void} log */
|
|
66
81
|
export function ensureApp(log = () => {}, codeRoot = CODE_ROOT) {
|
|
67
|
-
if (isDev(codeRoot) || !codeRoot
|
|
82
|
+
if (isDev(codeRoot) || !inNpxCache(codeRoot)) return codeRoot;
|
|
68
83
|
const version = JSON.parse(readFileSync(join(codeRoot, "package.json"), "utf8")).version;
|
|
69
84
|
const prefix = join(OMNI_DIR, "app", version);
|
|
70
85
|
const installed = join(prefix, "node_modules", ...PACKAGE.split("/"));
|
|
71
86
|
if (!existsSync(join(installed, "package.json"))) {
|
|
72
87
|
log(`instalando o OmniVideo ${version} em ${prefix}…`);
|
|
73
88
|
const spec = process.env.OMNI_PACKAGE_SPEC || `${PACKAGE}@${version}`;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
const npm = npmCommand();
|
|
90
|
+
const r = spawnSync(
|
|
91
|
+
npm.cmd,
|
|
92
|
+
[...npm.prefix, "install", "--prefix", prefix, "--no-audit", "--no-fund", "--loglevel=error", spec],
|
|
93
|
+
{ encoding: "utf8", windowsHide: true },
|
|
94
|
+
);
|
|
77
95
|
if (r.status !== 0) throw new Error(`npm install falhou: ${(r.stderr || r.stdout).trim().split("\n").at(-1)}`);
|
|
78
96
|
}
|
|
79
97
|
mkdirSync(join(OMNI_DIR, "bin"), { recursive: true });
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
});
|
|
98
|
+
const link = shortcut(installed);
|
|
99
|
+
writeFileSync(join(OMNI_DIR, "bin", link.name), link.text, { mode: link.mode });
|
|
83
100
|
return installed;
|
|
84
101
|
}
|
|
85
102
|
|
|
@@ -104,6 +121,7 @@ export function ensureTools(codeRoot = CODE_ROOT, { background = false } = {}) {
|
|
|
104
121
|
const out = openSync(join(OMNI_DIR, "deps.log"), "a");
|
|
105
122
|
spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "deps", "install", "--unlock"], {
|
|
106
123
|
detached: true,
|
|
124
|
+
windowsHide: true,
|
|
107
125
|
stdio: ["ignore", out, out],
|
|
108
126
|
env: { ...process.env, OMNI_HOME: DATA_ROOT },
|
|
109
127
|
}).unref();
|
|
@@ -123,6 +141,7 @@ export function startLogin(codeRoot = CODE_ROOT) {
|
|
|
123
141
|
writeFileSync(marker, new Date().toISOString());
|
|
124
142
|
spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "login"], {
|
|
125
143
|
detached: true,
|
|
144
|
+
windowsHide: true,
|
|
126
145
|
stdio: "ignore",
|
|
127
146
|
env: { ...process.env, OMNI_HOME: DATA_ROOT },
|
|
128
147
|
}).unref();
|
|
@@ -131,13 +150,9 @@ export function startLogin(codeRoot = CODE_ROOT) {
|
|
|
131
150
|
|
|
132
151
|
/** the checkout the installed studio service runs from, when it is a git checkout (a development machine) */
|
|
133
152
|
function devServiceRoot() {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return root && existsSync(join(root, ".git")) ? root : "";
|
|
138
|
-
} catch {
|
|
139
|
-
return "";
|
|
140
|
-
}
|
|
153
|
+
const wd = serviceWorkingDir();
|
|
154
|
+
const root = wd ? join(wd, "..") : "";
|
|
155
|
+
return root && existsSync(join(root, ".git")) ? root : "";
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
/**
|
|
@@ -175,7 +190,7 @@ export async function up({ hook = false, log = () => {} } = {}) {
|
|
|
175
190
|
const devService = devServiceRoot();
|
|
176
191
|
if (!dev && devService)
|
|
177
192
|
notes.push(`o studio de desenvolvimento (${devService}) continua sendo o serviço deste computador`);
|
|
178
|
-
if (!dev && !devService && process.platform === "darwin") {
|
|
193
|
+
if (!dev && !devService && (process.platform === "darwin" || process.platform === "win32")) {
|
|
179
194
|
try {
|
|
180
195
|
if (serviceInstall({ codeRoot, onlyIfChanged: true, log }).changed) notes.push("studio ligado como serviço");
|
|
181
196
|
} catch (e) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* Where the CLI keeps the login: the operating system's credential vault, never `.env`
|
|
2
|
+
(docs/specs/phase-2-plugin-cli.md, "Windows"). One secret is a service ("omni"), an account (the studio's
|
|
3
|
+
host) and a string (the tokens, as JSON).
|
|
4
|
+
|
|
5
|
+
macOS /usr/bin/security, the Keychain's own tool. NOT a library: measured on this Mac, an item
|
|
6
|
+
written by `security` blocks on an authorization dialog when @napi-rs/keyring reads it,
|
|
7
|
+
and an item written by node blocks the bun that runs the studio service (and `security`
|
|
8
|
+
itself). The service has nobody to click it. `security` is the one reader and writer
|
|
9
|
+
every process shares, so no dialog ever shows.
|
|
10
|
+
Windows, Linux @napi-rs/keyring: Credential Manager on Windows, the Secret Service on Linux. A native
|
|
11
|
+
addon, loaded on first use, so a checkout without it (a Mac) never needs it.
|
|
12
|
+
|
|
13
|
+
A vault is { get, set, delete }, all synchronous like the callers. The tests hand in their own. Messages in
|
|
14
|
+
Portuguese: they reach the user as they are. */
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* get(service: string, account: string): string | null,
|
|
22
|
+
* set(service: string, account: string, secret: string): void,
|
|
23
|
+
* delete(service: string, account: string): void,
|
|
24
|
+
* label: string,
|
|
25
|
+
* }} Vault
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** what the vault is called where the user reads it @param {string} [platform] */
|
|
29
|
+
export const vaultName = (platform = process.platform) =>
|
|
30
|
+
platform === "darwin"
|
|
31
|
+
? "Keychain"
|
|
32
|
+
: platform === "win32"
|
|
33
|
+
? "Gerenciador de Credenciais do Windows"
|
|
34
|
+
: "chaveiro do sistema";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The macOS Keychain through `security`. The secret goes as an argument, which `security` has no other way
|
|
38
|
+
* to take; -U updates the item in place.
|
|
39
|
+
* @param {string} [bin] the `security` program (the tests point it at a fake)
|
|
40
|
+
* @returns {Vault}
|
|
41
|
+
*/
|
|
42
|
+
export function securityVault(bin = "security") {
|
|
43
|
+
return {
|
|
44
|
+
label: vaultName("darwin"),
|
|
45
|
+
get(service, account) {
|
|
46
|
+
const r = spawnSync(bin, ["find-generic-password", "-s", service, "-a", account, "-w"], { encoding: "utf8" });
|
|
47
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
48
|
+
},
|
|
49
|
+
set(service, account, secret) {
|
|
50
|
+
const r = spawnSync(
|
|
51
|
+
bin,
|
|
52
|
+
["add-generic-password", "-U", "-s", service, "-a", account, "-l", "OmniVideo", "-w", secret],
|
|
53
|
+
{ encoding: "utf8" },
|
|
54
|
+
);
|
|
55
|
+
if (r.status !== 0)
|
|
56
|
+
throw new Error(`não consegui gravar o login no Keychain: ${r.stderr.trim() || `código ${r.status}`}`);
|
|
57
|
+
},
|
|
58
|
+
delete(service, account) {
|
|
59
|
+
spawnSync(bin, ["delete-generic-password", "-s", service, "-a", account], { encoding: "utf8" });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Credential Manager (Windows) or the Secret Service (Linux) through @napi-rs/keyring.
|
|
66
|
+
* @param {() => { Entry: new (service: string, account: string) => { getPassword(): string | null, setPassword(p: string): void, deletePassword(): boolean } }} [load]
|
|
67
|
+
* loads the library (the tests hand in a fake)
|
|
68
|
+
* @param {string} [platform]
|
|
69
|
+
* @returns {Vault}
|
|
70
|
+
*/
|
|
71
|
+
export function keyringVault(load, platform = process.platform) {
|
|
72
|
+
const where = vaultName(platform);
|
|
73
|
+
const entry = (/** @type {string} */ service, /** @type {string} */ account) => {
|
|
74
|
+
let lib;
|
|
75
|
+
try {
|
|
76
|
+
lib = (load ?? (() => createRequire(import.meta.url)("@napi-rs/keyring")))();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`não achei a biblioteca do ${where} (@napi-rs/keyring): ${/** @type {Error} */ (e).message.split("\n")[0]}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return new lib.Entry(service, account);
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
label: where,
|
|
86
|
+
get(service, account) {
|
|
87
|
+
/* a missing library throws (a packaging bug the user must hear about); an item that isn't there, or a
|
|
88
|
+
vault that doesn't answer, reads as "not logged in", as `security` failing always did */
|
|
89
|
+
const e = entry(service, account);
|
|
90
|
+
try {
|
|
91
|
+
return e.getPassword() ?? null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
set(service, account, secret) {
|
|
97
|
+
const e = entry(service, account);
|
|
98
|
+
try {
|
|
99
|
+
e.setPassword(secret);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new Error(`não consegui gravar o login no ${where}: ${/** @type {Error} */ (err).message}`);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
delete(service, account) {
|
|
105
|
+
try {
|
|
106
|
+
entry(service, account).deletePassword();
|
|
107
|
+
} catch {
|
|
108
|
+
/* already gone */
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** the vault of this machine @param {string} [platform] @returns {Vault} */
|
|
115
|
+
export const defaultVault = (platform = process.platform) =>
|
|
116
|
+
platform === "darwin" ? securityVault() : keyringVault(undefined, platform);
|
package/tools/metrics.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
collectAccount as coreCollectAccount,
|
|
60
60
|
} from "./lib/metrics/metrics.mjs";
|
|
61
61
|
import { isMainModule, homeEnvPlist, DATA_ROOT as ROOT } from "./lib/paths.mjs";
|
|
62
|
+
import { localAgents, noLocalAgent } from "./lib/platform.mjs";
|
|
62
63
|
|
|
63
64
|
const VIDEOS = join(ROOT, "videos");
|
|
64
65
|
const DIR = join(ROOT, ".metrics");
|
|
@@ -305,7 +306,7 @@ function show(only) {
|
|
|
305
306
|
`\nconta @${c.username}: ${n(c.followers)} seguidores, ${n(c.posts)} posts, ${n(c.profile_visits_day)} visitas ao perfil hoje (foto de ${new Date(c.at).toLocaleString("pt-BR")})`,
|
|
306
307
|
);
|
|
307
308
|
if (cloudCollects()) console.log("\ncoleta: a cloud, de hora em hora (o agente daqui só puxa)");
|
|
308
|
-
else if (!agentStatus().loaded)
|
|
309
|
+
else if (localAgents() && !agentStatus().loaded)
|
|
309
310
|
console.log("\naviso: o agente está desligado e marco perdido não volta: node tools/metrics.mjs agent install");
|
|
310
311
|
}
|
|
311
312
|
|
|
@@ -316,11 +317,14 @@ const domain = () => `gui/${process.getuid()}`;
|
|
|
316
317
|
const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
|
|
317
318
|
|
|
318
319
|
function agentStatus() {
|
|
320
|
+
/* no launchd, no agent: on Windows and Linux the cloud collects (cloud.metrics) */
|
|
321
|
+
if (!localAgents()) return { installed: false, loaded: false, supported: false, log: LOG_FILE };
|
|
319
322
|
const installed = existsSync(PLIST);
|
|
320
323
|
return { installed, loaded: installed && launchctl("print", `${domain()}/${AGENT}`).status === 0, log: LOG_FILE };
|
|
321
324
|
}
|
|
322
325
|
|
|
323
326
|
function installAgent() {
|
|
327
|
+
if (!localAgents()) return console.log(noLocalAgent("a coleta de métricas", "metrics"));
|
|
324
328
|
const x = s => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
325
329
|
/* ffprobe measures the published MP4: launchd starts without the shell's PATH */
|
|
326
330
|
const path = [
|
|
@@ -375,6 +379,7 @@ function installAgent() {
|
|
|
375
379
|
}
|
|
376
380
|
|
|
377
381
|
function uninstallAgent() {
|
|
382
|
+
if (!localAgents()) return console.log(noLocalAgent("a coleta de métricas", "metrics"));
|
|
378
383
|
launchctl("bootout", `${domain()}/${AGENT}`);
|
|
379
384
|
try {
|
|
380
385
|
unlinkSync(PLIST);
|
|
@@ -403,7 +408,7 @@ if (isMain) {
|
|
|
403
408
|
if (args.includes("--json")) console.log(JSON.stringify(s));
|
|
404
409
|
else
|
|
405
410
|
console.log(
|
|
406
|
-
`agente: ${s.loaded ? `ligado, coleta de ${AGENT_STEP_H} em ${AGENT_STEP_H} horas` : "desligado (node tools/metrics.mjs agent install)"}`,
|
|
411
|
+
`agente: ${s.supported === false ? "sem agente local aqui (a coleta é da cloud: cloud.metrics)" : s.loaded ? `ligado, coleta de ${AGENT_STEP_H} em ${AGENT_STEP_H} horas` : "desligado (node tools/metrics.mjs agent install)"}`,
|
|
407
412
|
);
|
|
408
413
|
} else
|
|
409
414
|
fail(
|
package/tools/omni.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The `omni` CLI (docs/specs/phase-2-plugin-cli.md): `npx -y @crievo/omni …` once published (bin/omni.mjs
|
|
4
4
|
settles the workspace and calls main), `node tools/omni.mjs …` in this repo. The plugin calls it from its
|
|
5
5
|
hook, its MCP server and its skills' `!` lines, so the user needs no terminal. The login is OAuth with PKCE
|
|
6
|
-
in the browser (tools/lib/oauth-client.mjs); the tokens live in the
|
|
6
|
+
in the browser (tools/lib/oauth-client.mjs); the tokens live in the system's credential vault (the macOS
|
|
7
|
+
Keychain, Windows Credential Manager: tools/lib/vault.mjs), never in .env.
|
|
7
8
|
|
|
8
9
|
omni up [--hook] readies this machine: workspace, app, Python, login, studio service
|
|
9
10
|
(tools/lib/up.mjs); --hook prints one line and never waits on the login
|
|
@@ -32,7 +33,9 @@ import { OMNI_DIR, checkDeps, depsEnv, ensureDeps } from "./lib/deps.mjs";
|
|
|
32
33
|
import { serveMcp } from "./lib/mcp-server.mjs";
|
|
33
34
|
import { accessToken, login, logout, readTokens } from "./lib/oauth-client.mjs";
|
|
34
35
|
import { CODE_ROOT, DATA_ROOT, isMainModule } from "./lib/paths.mjs";
|
|
36
|
+
import { pickPython } from "./lib/platform.mjs";
|
|
35
37
|
import { service } from "./lib/service.mjs";
|
|
38
|
+
import { vaultName } from "./lib/vault.mjs";
|
|
36
39
|
import { skillText } from "./lib/skill-client.mjs";
|
|
37
40
|
import { up } from "./lib/up.mjs";
|
|
38
41
|
import { cloudSettings } from "./platforms.mjs";
|
|
@@ -200,12 +203,7 @@ export async function main(args) {
|
|
|
200
203
|
if (!tool || !/^[a-z0-9_-]+$/i.test(tool)) fail("uso: omni run <ferramenta> [args…]");
|
|
201
204
|
const mjs = join(CODE_ROOT, "tools", `${tool}.mjs`);
|
|
202
205
|
const py = join(CODE_ROOT, "tools", `${tool.replace(/-/g, "_")}.py`);
|
|
203
|
-
const
|
|
204
|
-
const [bin, script] = existsSync(mjs)
|
|
205
|
-
? [process.execPath, mjs]
|
|
206
|
-
: existsSync(py)
|
|
207
|
-
? [existsSync(venv) ? venv : "python3", py]
|
|
208
|
-
: [];
|
|
206
|
+
const [bin, script] = existsSync(mjs) ? [process.execPath, mjs] : existsSync(py) ? [pickPython(CODE_ROOT), py] : [];
|
|
209
207
|
if (!bin) fail(`não existe a ferramenta ${tool}`);
|
|
210
208
|
const r = spawnSync(/** @type {string} */ (bin), [/** @type {string} */ (script), ...rest], {
|
|
211
209
|
stdio: "inherit",
|
|
@@ -293,7 +291,7 @@ export async function main(args) {
|
|
|
293
291
|
const t = await login(url, { deviceName: flag("name") || deviceName(), log: line => console.log(line) }).catch(
|
|
294
292
|
e => fail(String(e.message)),
|
|
295
293
|
);
|
|
296
|
-
console.log(`Pronto: "${t.device_name}" entrou no OmniVideo. O login ficou no
|
|
294
|
+
console.log(`Pronto: "${t.device_name}" entrou no OmniVideo. O login ficou no ${vaultName()}.`);
|
|
297
295
|
}
|
|
298
296
|
if (cmd === "login") return console.log("O studio deste computador passa a usar este login em até 30 segundos.");
|
|
299
297
|
if (
|
package/tools/omnichat.mjs
CHANGED
|
@@ -174,6 +174,7 @@ import {
|
|
|
174
174
|
import { sendManual } from "./lib/omnichat/manual.mjs";
|
|
175
175
|
import { handleWebhook, WEBHOOK_FIELDS } from "./lib/omnichat/webhook.mjs";
|
|
176
176
|
import { isMainModule, homeEnvPlist, DATA_ROOT as ROOT } from "./lib/paths.mjs";
|
|
177
|
+
import { localAgents, noLocalAgent } from "./lib/platform.mjs";
|
|
177
178
|
|
|
178
179
|
const CFG = JSON.parse(readFileSync(join(ROOT, "omnivideo.config.json"), "utf8"));
|
|
179
180
|
const C = resolveOmnichatConfig(CFG.omnichat?.instagram ?? {});
|
|
@@ -398,7 +399,7 @@ function enable(dir) {
|
|
|
398
399
|
describe(dir, all.instagram);
|
|
399
400
|
if (!readJson(join(dir, "publication.json"))?.instagram?.id)
|
|
400
401
|
console.log(`${thePost(dir)} ainda não foi publicado: começa a responder quando ele subir`);
|
|
401
|
-
if (!agentStatus().loaded)
|
|
402
|
+
if (localAgents() && !agentStatus().loaded)
|
|
402
403
|
console.log("aviso: o agente está desligado e nada é respondido sozinho: node tools/omnichat.mjs agent install");
|
|
403
404
|
}
|
|
404
405
|
|
|
@@ -828,7 +829,7 @@ function published(dir) {
|
|
|
828
829
|
const file = ruleFile(dir);
|
|
829
830
|
const all = readJson(file) ?? {};
|
|
830
831
|
const agentHint = () => {
|
|
831
|
-
if (!cloudOwns() && !agentStatus().loaded)
|
|
832
|
+
if (!cloudOwns() && localAgents() && !agentStatus().loaded)
|
|
832
833
|
console.log(
|
|
833
834
|
"aviso: omnichat: o agente está desligado e nada é respondido sozinho: node tools/omnichat.mjs agent install",
|
|
834
835
|
);
|
|
@@ -1033,10 +1034,16 @@ async function status() {
|
|
|
1033
1034
|
const pause = readJson(PAUSE_FILE);
|
|
1034
1035
|
if (isPaused(pause, Date.now())) s.pause = pause;
|
|
1035
1036
|
if (cloudOwns()) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1037
|
+
/* the cloud answers: its pause and its token are the ones that count; its posts' updated_at say
|
|
1038
|
+
which records changed since the mirror, and only those are pulled */
|
|
1038
1039
|
try {
|
|
1039
1040
|
const c = await cloudApi("GET", "/api/status");
|
|
1041
|
+
const at = new Map((c.posts ?? []).map(p => [String(p.media_id), p.updated_at]));
|
|
1042
|
+
await Promise.all(
|
|
1043
|
+
videosWithRule()
|
|
1044
|
+
.filter(d => at.has(String(mediaOf(d))))
|
|
1045
|
+
.map(d => pullRecord(d, at.get(String(mediaOf(d)))).catch(() => {})),
|
|
1046
|
+
);
|
|
1040
1047
|
s.cloud = { ready: true, active: !!c.active, expires: c.account?.expires ?? null };
|
|
1041
1048
|
if (!c.active) s.cloud.reason = "a cloud não está respondendo (cloud push)";
|
|
1042
1049
|
if (c.account?.paused_until && Date.parse(c.account.paused_until) > Date.now())
|
|
@@ -1100,7 +1107,7 @@ async function status() {
|
|
|
1100
1107
|
);
|
|
1101
1108
|
console.log(" DM: a permissão de mensagens só se confere no primeiro envio");
|
|
1102
1109
|
console.log(
|
|
1103
|
-
` agente: ${s.agent.loaded ? "ligado, confere a cada minuto" : "desligado (node tools/omnichat.mjs agent install)"}`,
|
|
1110
|
+
` agente: ${s.agent.supported === false ? "sem agente local aqui (as respostas são da cloud: cloud.omnichat)" : s.agent.loaded ? "ligado, confere a cada minuto" : "desligado (node tools/omnichat.mjs agent install)"}`,
|
|
1104
1111
|
);
|
|
1105
1112
|
if (s.studio)
|
|
1106
1113
|
console.log(
|
|
@@ -1167,19 +1174,26 @@ function syncRule(dir) {
|
|
|
1167
1174
|
}
|
|
1168
1175
|
const flushCloud = () => Promise.all(pendingCloud.splice(0));
|
|
1169
1176
|
|
|
1170
|
-
/*
|
|
1171
|
-
|
|
1177
|
+
/* The cloud's record mirrored into omnichat-record.json: the studio and the log read the file.
|
|
1178
|
+
`cloudAt`: the post's updated_at from /api/status; when the mirror already has that version, nothing is
|
|
1179
|
+
asked (the studio's status runs every few minutes, and each record that leaves Neon counts against the
|
|
1180
|
+
free plan's monthly transfer). */
|
|
1181
|
+
async function pullRecord(dir, cloudAt = null) {
|
|
1172
1182
|
if (!cloudOwns()) return;
|
|
1173
1183
|
const media = mediaOf(dir);
|
|
1174
1184
|
if (!media) return;
|
|
1185
|
+
const file = recordFile(dir);
|
|
1186
|
+
if (cloudAt && readJson(file)?.cloud_updated_at === cloudAt) return;
|
|
1175
1187
|
try {
|
|
1176
1188
|
const out = await cloudApi("GET", `/api/posts/${media}`);
|
|
1177
|
-
const file = recordFile(dir);
|
|
1178
1189
|
const all = readJson(file) ?? {};
|
|
1179
1190
|
/* only a change is written: the write is a studio notice, and the notice makes the page read (and
|
|
1180
1191
|
pull) again, so an unchanged record written anyway loops against the cloud */
|
|
1181
|
-
|
|
1192
|
+
const at = out.updated_at ?? null;
|
|
1193
|
+
if (JSON.stringify(all.instagram ?? null) === JSON.stringify(out.record ?? null) && all.cloud_updated_at === at)
|
|
1194
|
+
return;
|
|
1182
1195
|
all.instagram = out.record;
|
|
1196
|
+
all.cloud_updated_at = at;
|
|
1183
1197
|
delete all.cloud;
|
|
1184
1198
|
writeJson(file, all);
|
|
1185
1199
|
} catch (e) {
|
|
@@ -1417,6 +1431,8 @@ const domain = () => `gui/${process.getuid()}`;
|
|
|
1417
1431
|
const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
|
|
1418
1432
|
|
|
1419
1433
|
function agentStatus() {
|
|
1434
|
+
/* no launchd, no agent: on Windows and Linux the cloud answers (cloud.omnichat) */
|
|
1435
|
+
if (!localAgents()) return { installed: false, loaded: false, node_ok: false, supported: false, log: LOG_FILE };
|
|
1420
1436
|
const installed = existsSync(PLIST);
|
|
1421
1437
|
let node = null;
|
|
1422
1438
|
if (installed) {
|
|
@@ -1437,6 +1453,7 @@ function agentStatus() {
|
|
|
1437
1453
|
}
|
|
1438
1454
|
|
|
1439
1455
|
function installAgent() {
|
|
1456
|
+
if (!localAgents()) return console.log(noLocalAgent("as respostas do omnichat", "omnichat"));
|
|
1440
1457
|
const x = s => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1441
1458
|
/* the shell PATH goes along: launchd starts without claude, which reads the context of uncertain comments */
|
|
1442
1459
|
const path = [
|
|
@@ -1493,6 +1510,7 @@ function installAgent() {
|
|
|
1493
1510
|
}
|
|
1494
1511
|
|
|
1495
1512
|
function uninstallAgent() {
|
|
1513
|
+
if (!localAgents()) return console.log(noLocalAgent("as respostas do omnichat", "omnichat"));
|
|
1496
1514
|
launchctl("bootout", `${domain()}/${AGENT_LABEL}`);
|
|
1497
1515
|
try {
|
|
1498
1516
|
unlinkSync(PLIST);
|
|
@@ -1551,7 +1569,7 @@ if (isMain) {
|
|
|
1551
1569
|
);
|
|
1552
1570
|
else
|
|
1553
1571
|
console.log(
|
|
1554
|
-
`agente: ${a.loaded ? "ligado, confere a cada minuto" : "desligado (agent install)"}${a.installed && !a.node_ok ? " · o node do agente sumiu: rode agent install de novo" : ""}`,
|
|
1572
|
+
`agente: ${a.supported === false ? "sem agente local aqui (as respostas são da cloud: cloud.omnichat)" : a.loaded ? "ligado, confere a cada minuto" : "desligado (agent install)"}${a.installed && !a.node_ok ? " · o node do agente sumiu: rode agent install de novo" : ""}`,
|
|
1555
1573
|
);
|
|
1556
1574
|
} else if (cmd === "agent" && sub === "install") installAgent();
|
|
1557
1575
|
else if (cmd === "agent" && sub === "uninstall") uninstallAgent();
|
package/tools/package.mjs
CHANGED
|
@@ -91,11 +91,12 @@ function build() {
|
|
|
91
91
|
type: "module",
|
|
92
92
|
bin: { omni: "bin/omni.mjs" },
|
|
93
93
|
engines: { node: ">=22" },
|
|
94
|
-
os: ["darwin"],
|
|
94
|
+
os: ["darwin", "win32"],
|
|
95
95
|
license: "UNLICENSED",
|
|
96
96
|
homepage: "https://omni.crievo.com",
|
|
97
|
-
/* the studio's runtime libraries, the bun that runs it,
|
|
98
|
-
|
|
97
|
+
/* the studio's runtime libraries, the bun that runs it, what fetches the Chrome without a window, and the
|
|
98
|
+
credential vault of Windows (tools/lib/vault.mjs; macOS keeps `security` and never loads it) */
|
|
99
|
+
dependencies: { ...studio.dependencies, bun: "1.4.3", "@puppeteer/browsers": "3.2.3", "@napi-rs/keyring": "2.1.1" },
|
|
99
100
|
};
|
|
100
101
|
writeFileSync(join(PKG, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
101
102
|
writeFileSync(
|