@crievo/omni 0.1.5 → 0.1.7

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
  );
@@ -1106,7 +1107,7 @@ async function status() {
1106
1107
  );
1107
1108
  console.log(" DM: a permissão de mensagens só se confere no primeiro envio");
1108
1109
  console.log(
1109
- ` 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)"}`,
1110
1111
  );
1111
1112
  if (s.studio)
1112
1113
  console.log(
@@ -1430,6 +1431,8 @@ const domain = () => `gui/${process.getuid()}`;
1430
1431
  const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
1431
1432
 
1432
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 };
1433
1436
  const installed = existsSync(PLIST);
1434
1437
  let node = null;
1435
1438
  if (installed) {
@@ -1450,6 +1453,7 @@ function agentStatus() {
1450
1453
  }
1451
1454
 
1452
1455
  function installAgent() {
1456
+ if (!localAgents()) return console.log(noLocalAgent("as respostas do omnichat", "omnichat"));
1453
1457
  const x = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1454
1458
  /* the shell PATH goes along: launchd starts without claude, which reads the context of uncertain comments */
1455
1459
  const path = [
@@ -1506,6 +1510,7 @@ function installAgent() {
1506
1510
  }
1507
1511
 
1508
1512
  function uninstallAgent() {
1513
+ if (!localAgents()) return console.log(noLocalAgent("as respostas do omnichat", "omnichat"));
1509
1514
  launchctl("bootout", `${domain()}/${AGENT_LABEL}`);
1510
1515
  try {
1511
1516
  unlinkSync(PLIST);
@@ -1564,7 +1569,7 @@ if (isMain) {
1564
1569
  );
1565
1570
  else
1566
1571
  console.log(
1567
- `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" : ""}`,
1568
1573
  );
1569
1574
  } else if (cmd === "agent" && sub === "install") installAgent();
1570
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(
package/tools/publish.mjs CHANGED
@@ -149,6 +149,7 @@ import {
149
149
  attemptOutcome,
150
150
  } from "./lib/schedule/schedule.mjs";
151
151
  import { CODE_ROOT, homeEnvPlist, DATA_ROOT as ROOT } from "./lib/paths.mjs";
152
+ import { localAgents, noLocalAgent, openUrl, pickPython } from "./lib/platform.mjs";
152
153
 
153
154
  const CFG = JSON.parse(readFileSync(join(ROOT, "omnivideo.config.json"), "utf8"));
154
155
  const P = publishConfig(CFG.publish ?? {});
@@ -211,7 +212,7 @@ async function localLogin({ name, who, path = "/", buildUrl }) {
211
212
  );
212
213
  else {
213
214
  console.log(`abrindo o login ${who} ${app ? `no ${app}` : "no navegador"}…`);
214
- const p = spawnSync("open", [...(app ? ["-a", app] : []), url], { encoding: "utf8" });
215
+ const p = openUrl(url, { app });
215
216
  if (p.status !== 0)
216
217
  console.log(`não abriu${app ? ` o ${app}` : ""}: ${(p.stderr || "").trim()}. Cole o link à mão.`);
217
218
  }
@@ -457,7 +458,7 @@ async function status() {
457
458
  ` automático: ${P.auto.approved ? `ligado (${P.auto.platforms.join(", ")})` : "desligado (publish.auto.approved)"}`,
458
459
  );
459
460
  console.log(
460
- ` agenda: ${r.scheduler.loaded ? "agente ligado, confere a cada minuto" : "agente desligado (node tools/publish.mjs scheduler install)"}`,
461
+ ` agenda: ${agentWords(r.scheduler, "agente ligado, confere a cada minuto", "agente desligado (node tools/publish.mjs scheduler install)")}`,
461
462
  );
462
463
  if (r.youtube.ready && !P.youtube.audited)
463
464
  console.log(
@@ -581,9 +582,7 @@ function checkAuto(dir) {
581
582
  for (const n of [...PLATFORMS, "privacy", "tiktok-privacy", "again", "confirm"]) {
582
583
  if (has(n)) fail(`--auto usa as plataformas e a privacidade da config; não combina com --${n}`);
583
584
  }
584
- const py =
585
- [join(CODE_ROOT, ".venv", "bin", "python"), CFG.voicestudio?.python ?? ""].find(p => p && existsSync(p)) ??
586
- "python3";
585
+ const py = pickPython(CODE_ROOT, [CFG.voicestudio?.python ?? ""]);
587
586
  const r = spawnSync(py, [join(CODE_ROOT, "tools", "omnivideo.py"), "status", dir, "--json"], { encoding: "utf8" });
588
587
  if (r.status !== 0) fail(`status do vídeo: ${(r.stderr || r.stdout).slice(-400)}`);
589
588
  const st = JSON.parse(r.stdout);
@@ -952,7 +951,7 @@ async function schedule(dir) {
952
951
  writeFileSync(INDEX_FILE, JSON.stringify([...index], null, 2) + "\n");
953
952
  }
954
953
  console.log(`agendado: ${platforms.join(", ")} em ${dayTime(when)}`);
955
- if (!agentStatus().loaded)
954
+ if (localAgents() && !agentStatus().loaded)
956
955
  console.log(
957
956
  "aviso: o agente da agenda está desligado e nada sobe na hora: node tools/publish.mjs scheduler install",
958
957
  );
@@ -1137,7 +1136,7 @@ function listSchedule() {
1137
1136
  return;
1138
1137
  }
1139
1138
  console.log(
1140
- `agente: ${agent.loaded ? "ligado, confere a cada minuto" : "desligado (scheduler install)"}${agent.installed && !agent.node_ok ? " · o node do agente sumiu: rode scheduler install de novo" : ""}`,
1139
+ `agente: ${agentWords(agent, "ligado, confere a cada minuto", "desligado (scheduler install)")}${agent.installed && !agent.node_ok ? " · o node do agente sumiu: rode scheduler install de novo" : ""}`,
1141
1140
  );
1142
1141
  if (!items.length) console.log("nada agendado");
1143
1142
  for (const i of items) {
@@ -1267,8 +1266,13 @@ function runOne(dir) {
1267
1266
  launchd starts with a minimal PATH, without ffprobe or claude. */
1268
1267
  const domain = () => `gui/${process.getuid()}`;
1269
1268
  const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
1269
+ /* what a status line says about the agent: on, off, or (Windows, Linux) that the cloud does the work */
1270
+ const agentWords = (a, on, off) =>
1271
+ a.supported === false ? "sem agente local aqui (a agenda é da cloud: cloud.schedule)" : a.loaded ? on : off;
1270
1272
 
1271
1273
  function agentStatus() {
1274
+ /* no launchd, no agent: on Windows and Linux the cloud keeps the schedule (cloud.schedule) */
1275
+ if (!localAgents()) return { installed: false, loaded: false, node_ok: false, supported: false, log: LOG_FILE };
1272
1276
  const installed = existsSync(PLIST);
1273
1277
  let node = null;
1274
1278
  if (installed) {
@@ -1289,6 +1293,7 @@ function agentStatus() {
1289
1293
  }
1290
1294
 
1291
1295
  function installAgent() {
1296
+ if (!localAgents()) return console.log(noLocalAgent("a agenda de publicação", "schedule"));
1292
1297
  const x = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1293
1298
  const path = [
1294
1299
  ...new Set([
@@ -1346,6 +1351,7 @@ function installAgent() {
1346
1351
  }
1347
1352
 
1348
1353
  function uninstallAgent() {
1354
+ if (!localAgents()) return console.log(noLocalAgent("a agenda de publicação", "schedule"));
1349
1355
  launchctl("bootout", `${domain()}/${AGENT}`);
1350
1356
  try {
1351
1357
  unlinkSync(PLIST);