@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.
@@ -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 (process.platform !== "darwin") throw new Error("o serviço é do macOS (launchd)");
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
- spawnSync("sleep", ["0.5"]);
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 (spawnSync("lsof", ["-nP", "-iTCP:5432", "-sTCP:LISTEN"], { encoding: "utf8" }).stdout.includes("LISTEN"))
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(/&amp;/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 as a launchd agent, from the app, so the hosted studio reaches this Mac
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 { PLIST, service, serviceInstall } from "./service.mjs";
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.includes("/_npx/")) return 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 r = spawnSync("npm", ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--loglevel=error", spec], {
75
- encoding: "utf8",
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
- writeFileSync(join(OMNI_DIR, "bin", "omni"), `#!/bin/sh\nexec node "${join(installed, "bin", "omni.mjs")}" "$@"\n`, {
81
- mode: 0o755,
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
- try {
135
- const wd = /<key>WorkingDirectory<\/key><string>([^<]+)<\/string>/.exec(readFileSync(PLIST, "utf8"))?.[1];
136
- const root = wd ? join(wd.replace(/&amp;/g, "&"), "..") : "";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 macOS Keychain, never in .env.
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 venv = join(CODE_ROOT, ".venv", "bin", "python");
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 Keychain.`);
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 (
@@ -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
- await Promise.all(videosWithRule().map(d => pullRecord(d).catch(() => {})));
1037
- /* the cloud answers: its pause and its token are the ones that count */
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
- /* the cloud's record mirrored into omnichat-record.json: the studio and the log read the file */
1171
- async function pullRecord(dir) {
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
- if (JSON.stringify(all.instagram ?? null) === JSON.stringify(out.record ?? null)) return;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, and what fetches the Chrome without a window */
98
- dependencies: { ...studio.dependencies, bun: "1.4.3", "@puppeteer/browsers": "3.2.3" },
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(