@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* The CLI side of the studio's OAuth login (cloud/studio/src/oauth.mjs; docs/specs/phase-2-plugin-cli.md,
|
|
2
|
-
step 2): PKCE, the loopback callback, the token exchange, and the tokens in the
|
|
3
|
-
under a lock.
|
|
2
|
+
step 2): PKCE, the loopback callback, the token exchange, and the tokens in the system's credential vault
|
|
3
|
+
(tools/lib/vault.mjs: the macOS Keychain, Windows Credential Manager), refreshed under a lock.
|
|
4
4
|
|
|
5
5
|
login(studioUrl, …) opens the approval page in the browser, waits on http://127.0.0.1:<port>/callback,
|
|
6
6
|
trades the code and stores the tokens
|
|
@@ -9,18 +9,19 @@
|
|
|
9
9
|
|
|
10
10
|
Two processes (the studio's runner and a CLI command) may refresh at once. The server treats a rotated
|
|
11
11
|
refresh token used twice as a leak and signs the machine out, so a refresh holds a lock and rereads the
|
|
12
|
-
|
|
12
|
+
vault inside it: the second process finds the tokens the first one just stored.
|
|
13
13
|
|
|
14
|
-
The
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
The vault item: service "omni", account = the studio's host, the secret a JSON with the tokens. The vault
|
|
15
|
+
(and the lock dir, the clock, fetch, the browser) are injected for the tests. Messages in Portuguese: they
|
|
16
|
+
reach the user as they are. */
|
|
17
17
|
|
|
18
|
-
import { spawnSync } from "node:child_process";
|
|
19
18
|
import { createHash, randomBytes } from "node:crypto";
|
|
20
19
|
import { mkdirSync, rmSync, statSync } from "node:fs";
|
|
21
20
|
import { createServer } from "node:http";
|
|
22
21
|
import { homedir } from "node:os";
|
|
23
22
|
import { join } from "node:path";
|
|
23
|
+
import { openUrl } from "./platform.mjs";
|
|
24
|
+
import { defaultVault } from "./vault.mjs";
|
|
24
25
|
|
|
25
26
|
export const CLI_CLIENT = "omnivideo-cli";
|
|
26
27
|
/* an access token's prefix (cloud/studio/src/oauth.mjs): tells it from a Phase 1 key (`ovr_`) */
|
|
@@ -43,7 +44,7 @@ export function pkce() {
|
|
|
43
44
|
* @typedef {{
|
|
44
45
|
* fetch?: typeof fetch,
|
|
45
46
|
* now?: () => number,
|
|
46
|
-
*
|
|
47
|
+
* vault?: import("./vault.mjs").Vault,
|
|
47
48
|
* lockDir?: string,
|
|
48
49
|
* open?: (url: string) => void,
|
|
49
50
|
* log?: (line: string) => void,
|
|
@@ -52,18 +53,16 @@ export function pkce() {
|
|
|
52
53
|
|
|
53
54
|
const hostOf = (/** @type {string} */ studioUrl) => new URL(studioUrl).host;
|
|
54
55
|
|
|
55
|
-
/* --- the
|
|
56
|
+
/* --- the vault --------------------------------------------------------------------------- */
|
|
57
|
+
|
|
58
|
+
const vaultOf = (/** @type {Deps} */ d) => d.vault ?? defaultVault();
|
|
56
59
|
|
|
57
60
|
/** @param {string} studioUrl @param {Deps} [d] @returns {Tokens | null} */
|
|
58
61
|
export function readTokens(studioUrl, d = {}) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", hostOf(studioUrl), "-w"],
|
|
62
|
-
{ encoding: "utf8" },
|
|
63
|
-
);
|
|
64
|
-
if (r.status !== 0) return null;
|
|
62
|
+
const secret = vaultOf(d).get(KEYCHAIN_SERVICE, hostOf(studioUrl));
|
|
63
|
+
if (!secret) return null;
|
|
65
64
|
try {
|
|
66
|
-
const t = JSON.parse(
|
|
65
|
+
const t = JSON.parse(secret);
|
|
67
66
|
return t?.access_token && t?.refresh_token ? t : null;
|
|
68
67
|
} catch {
|
|
69
68
|
return null;
|
|
@@ -72,32 +71,12 @@ export function readTokens(studioUrl, d = {}) {
|
|
|
72
71
|
|
|
73
72
|
/** @param {string} studioUrl @param {Tokens} tokens @param {Deps} [d] */
|
|
74
73
|
export function writeTokens(studioUrl, tokens, d = {}) {
|
|
75
|
-
|
|
76
|
-
const r = spawnSync(
|
|
77
|
-
d.security ?? "security",
|
|
78
|
-
[
|
|
79
|
-
"add-generic-password",
|
|
80
|
-
"-U",
|
|
81
|
-
"-s",
|
|
82
|
-
KEYCHAIN_SERVICE,
|
|
83
|
-
"-a",
|
|
84
|
-
hostOf(studioUrl),
|
|
85
|
-
"-l",
|
|
86
|
-
"OmniVideo",
|
|
87
|
-
"-w",
|
|
88
|
-
JSON.stringify(tokens),
|
|
89
|
-
],
|
|
90
|
-
{ encoding: "utf8" },
|
|
91
|
-
);
|
|
92
|
-
if (r.status !== 0)
|
|
93
|
-
throw new Error(`não consegui gravar o login no Keychain: ${r.stderr.trim() || `código ${r.status}`}`);
|
|
74
|
+
vaultOf(d).set(KEYCHAIN_SERVICE, hostOf(studioUrl), JSON.stringify(tokens));
|
|
94
75
|
}
|
|
95
76
|
|
|
96
77
|
/** @param {string} studioUrl @param {Deps} [d] */
|
|
97
78
|
export function forgetTokens(studioUrl, d = {}) {
|
|
98
|
-
|
|
99
|
-
encoding: "utf8",
|
|
100
|
-
});
|
|
79
|
+
vaultOf(d).delete(KEYCHAIN_SERVICE, hostOf(studioUrl));
|
|
101
80
|
}
|
|
102
81
|
|
|
103
82
|
/* --- the token endpoint ------------------------------------------------------------------ */
|
|
@@ -216,7 +195,7 @@ export async function login(studioUrl, o) {
|
|
|
216
195
|
}))
|
|
217
196
|
url.searchParams.set(k, v);
|
|
218
197
|
log(`Abrindo o navegador para entrar. Se ele não abrir, use este link:\n ${url}`);
|
|
219
|
-
(o.open ??
|
|
198
|
+
(o.open ?? openUrl)(url.toString());
|
|
220
199
|
|
|
221
200
|
const timeout = new Promise(r =>
|
|
222
201
|
setTimeout(() => r({ error: "o login não voltou em 10 minutos" }), LOGIN_TIMEOUT_MS).unref(),
|
|
@@ -287,9 +266,3 @@ export async function logout(studioUrl, d = {}) {
|
|
|
287
266
|
forgetTokens(studioUrl, d);
|
|
288
267
|
return confirmed;
|
|
289
268
|
}
|
|
290
|
-
|
|
291
|
-
/** @param {string} url */
|
|
292
|
-
function openBrowser(url) {
|
|
293
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
294
|
-
spawnSync(cmd, [url], { stdio: "ignore" });
|
|
295
|
-
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* What differs between macOS, Windows and Linux for the tools, in one place (docs/specs/phase-2-plugin-cli.md,
|
|
2
|
+
"Windows"). macOS stays what it always was; each helper has the Mac behavior first and the Windows one beside it.
|
|
3
|
+
|
|
4
|
+
openUrl(url) the browser (or an app) on a URL: open · rundll32 url.dll · xdg-open
|
|
5
|
+
listeningOn(port) whether something listens on a local TCP port: lsof · netstat
|
|
6
|
+
venvPython(root) the app's venv Python, as each OS lays a venv out
|
|
7
|
+
SYSTEM_PYTHON the Python on PATH when there is no venv: python3 · python
|
|
8
|
+
npmCommand() how to run npm without a shell (npm is a .cmd on Windows)
|
|
9
|
+
sleepSync(ms) a blocking pause, without a `sleep` binary
|
|
10
|
+
localAgents whether the launchd agents (agenda, métricas, omnichat) exist here
|
|
11
|
+
noLocalAgent(what) the Portuguese notice that says the cloud does that work where there is no launchd
|
|
12
|
+
|
|
13
|
+
Everything takes `platform` as an argument (default: this machine's) so the tests can ask for the other OS. */
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const IS_WINDOWS = process.platform === "win32";
|
|
20
|
+
|
|
21
|
+
/** the OS by the name the user knows @param {string} [platform] */
|
|
22
|
+
export const osName = (platform = process.platform) =>
|
|
23
|
+
({ darwin: "macOS", win32: "Windows", linux: "Linux" })[/** @type {"darwin"} */ (platform)] ?? platform;
|
|
24
|
+
|
|
25
|
+
/** the app's venv Python, as each OS lays a venv out @param {string} codeRoot @param {string} [platform] */
|
|
26
|
+
export const venvPython = (codeRoot, platform = process.platform) =>
|
|
27
|
+
platform === "win32" ? join(codeRoot, ".venv", "Scripts", "python.exe") : join(codeRoot, ".venv", "bin", "python");
|
|
28
|
+
|
|
29
|
+
/** the Python to fall back on when the project has no venv: Windows has no `python3` */
|
|
30
|
+
export const SYSTEM_PYTHON = IS_WINDOWS ? "python" : "python3";
|
|
31
|
+
|
|
32
|
+
/** the first Python that exists: the project's venv, then `others` (the config's), else the system one
|
|
33
|
+
* @param {string} codeRoot @param {string[]} [others] */
|
|
34
|
+
export const pickPython = (codeRoot, others = []) =>
|
|
35
|
+
[venvPython(codeRoot), ...others].find(p => p && existsSync(p)) ?? SYSTEM_PYTHON;
|
|
36
|
+
|
|
37
|
+
/** the program and arguments that open `url`, or `url` in `app` (macOS only: `open -a`)
|
|
38
|
+
* @param {string} url @param {{ app?: string, platform?: string }} [o] @returns {[string, string[]]} */
|
|
39
|
+
export function openCommand(url, { app, platform = process.platform } = {}) {
|
|
40
|
+
if (platform === "darwin") return ["open", [...(app ? ["-a", app] : []), url]];
|
|
41
|
+
/* no shell: a URL with & stays one argument, which `cmd /c start` would split */
|
|
42
|
+
if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
43
|
+
return ["xdg-open", [url]];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Opens a URL in the user's browser, or in `app` (macOS only).
|
|
48
|
+
* @param {string} url @param {{ app?: string, platform?: string }} [o]
|
|
49
|
+
* @returns {{ status: number | null, stderr: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function openUrl(url, o = {}) {
|
|
52
|
+
const [cmd, args] = openCommand(url, o);
|
|
53
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
|
|
54
|
+
return { status: r.status, stderr: String(r.stderr ?? "") };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** whether `netstat -ano -p TCP` shows a listener on `port`. The state column is translated ("LISTENING",
|
|
58
|
+
* "OUVINDO"…); a listener is the row whose foreign address is 0.0.0.0:0 or [::]:0 @param {string} out @param {number} port */
|
|
59
|
+
export const netstatListening = (out, port) =>
|
|
60
|
+
out.split(/\r?\n/).some(l => new RegExp(`^\\s*TCP\\s+\\S+:${port}\\s+(?:0\\.0\\.0\\.0|\\[::\\]):0\\s`).test(l));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether some process listens on a local TCP port.
|
|
64
|
+
* @param {number} port @param {string} [platform]
|
|
65
|
+
*/
|
|
66
|
+
export function listeningOn(port, platform = process.platform) {
|
|
67
|
+
if (platform === "win32")
|
|
68
|
+
return netstatListening(spawnSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf8" }).stdout ?? "", port);
|
|
69
|
+
return !!spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" }).stdout?.includes("LISTEN");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** the command that runs npm without a shell: npm is `npm.cmd` on Windows, which Node won't spawn on its own.
|
|
73
|
+
* It runs npm's own script with the same node. @returns {{ cmd: string, prefix: string[] }} */
|
|
74
|
+
export function npmCommand(platform = process.platform, env = process.env, execPath = process.execPath) {
|
|
75
|
+
if (platform !== "win32") return { cmd: "npm", prefix: [] };
|
|
76
|
+
const found = [
|
|
77
|
+
join(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
|
78
|
+
/* under npx, npm_execpath is npx-cli.js, next to npm-cli.js */
|
|
79
|
+
env.npm_execpath ? join(dirname(env.npm_execpath), "npm-cli.js") : "",
|
|
80
|
+
env.npm_execpath ?? "",
|
|
81
|
+
].find(p => p && /npm-cli\.js$/.test(p) && existsSync(p));
|
|
82
|
+
return found ? { cmd: execPath, prefix: [found] } : { cmd: "npm.cmd", prefix: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** a blocking pause @param {number} ms */
|
|
86
|
+
export function sleepSync(ms) {
|
|
87
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* --- the local agents (launchd) ---------------------------------------------------------- */
|
|
91
|
+
|
|
92
|
+
/** whether the launchd agents (agenda, métricas, omnichat) exist on this machine @param {string} [platform] */
|
|
93
|
+
export const localAgents = (platform = process.platform) => platform === "darwin";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The notice for a machine without launchd: the work those agents do is the cloud's here.
|
|
97
|
+
* @param {"a agenda de publicação" | "a coleta de métricas" | "as respostas do omnichat"} what
|
|
98
|
+
* @param {"schedule" | "metrics" | "omnichat"} key the `cloud.<key>` switch that turns it on
|
|
99
|
+
* @param {string} [platform]
|
|
100
|
+
*/
|
|
101
|
+
export const noLocalAgent = (what, key, platform = process.platform) =>
|
|
102
|
+
`aviso: no ${osName(platform)} não há agente local (o launchd é do macOS): ${what} fica com a cloud. Ligue cloud.${key} no omnivideo.config.json e rode cloud push.`;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Keeps the studio running on Windows, the way launchd's KeepAlive does on macOS
|
|
2
|
+
(tools/lib/service-windows.mjs installs a scheduled task that runs this at login):
|
|
3
|
+
|
|
4
|
+
node service-run.mjs <studio-service.json> [state file]
|
|
5
|
+
|
|
6
|
+
It starts the command in the spec, and each time it exits starts it again, never sooner than `throttle_s`
|
|
7
|
+
after the previous start (launchd's ThrottleInterval: a studio already open in a terminal holding the port
|
|
8
|
+
just waits its turn). The command's output, and these notes, go to the spec's log. The pids go to the state
|
|
9
|
+
file, which is how `service()` knows the studio is up and how it is stopped. Ending the supervisor ends the
|
|
10
|
+
command with it. The spec is JSON, written at install time:
|
|
11
|
+
|
|
12
|
+
{ "command": "<bun>", "args": ["studio.ts"], "cwd": "<code root>/studio", "log": "<.runner/studio.log>",
|
|
13
|
+
"throttle_s": 30, "env": { "PATH": "…", "OMNI_HOME": "…" } }
|
|
14
|
+
|
|
15
|
+
Runs under any OS (the tests run it on macOS and Linux too). Notes in Portuguese: they land in a log the
|
|
16
|
+
user reads. */
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { isMainModule } from "./paths.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ command: string, args?: string[], cwd?: string, log: string, throttle_s?: number, throttle_ms?: number,
|
|
25
|
+
* env?: Record<string, string> }} Spec
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** `base` with `extra` laid over it. Windows names are case-blind (Path, PATH): an extra PATH replaces the
|
|
29
|
+
* Path already there instead of sitting beside it @param {NodeJS.ProcessEnv} base @param {Record<string, string>} extra */
|
|
30
|
+
export function mergeEnv(base, extra, caseBlind = process.platform === "win32") {
|
|
31
|
+
const out = { ...base };
|
|
32
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
33
|
+
if (caseBlind) for (const key of Object.keys(out)) if (key.toLowerCase() === k.toLowerCase()) delete out[key];
|
|
34
|
+
out[k] = v;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Runs the spec's command until the supervisor is stopped (or `maxStarts` runs have ended: the tests).
|
|
41
|
+
* @param {Spec} spec
|
|
42
|
+
* @param {{ stateFile?: string, maxStarts?: number, signals?: boolean }} [o]
|
|
43
|
+
* @returns {Promise<{ starts: number }>}
|
|
44
|
+
*/
|
|
45
|
+
export async function supervise(spec, { stateFile, maxStarts = Number.POSITIVE_INFINITY, signals = true } = {}) {
|
|
46
|
+
mkdirSync(dirname(spec.log), { recursive: true });
|
|
47
|
+
const logFd = openSync(spec.log, "a");
|
|
48
|
+
const say = (/** @type {string} */ line) => {
|
|
49
|
+
try {
|
|
50
|
+
writeSync(logFd, `[omni ${new Date().toISOString()}] ${line}\n`);
|
|
51
|
+
} catch {
|
|
52
|
+
/* the log is a copy of what happened; the loop goes on */
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const throttleMs = spec.throttle_ms ?? (spec.throttle_s ?? 30) * 1000;
|
|
56
|
+
/** @type {import("node:child_process").ChildProcess | null} */
|
|
57
|
+
let child = null;
|
|
58
|
+
let stopping = false;
|
|
59
|
+
let wake = () => {};
|
|
60
|
+
|
|
61
|
+
const writeState = (/** @type {number | null} */ pid) => {
|
|
62
|
+
if (!stateFile) return;
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(stateFile, `${JSON.stringify({ supervisor: process.pid, child: pid })}\n`);
|
|
65
|
+
} catch {
|
|
66
|
+
/* the state is a convenience for status */
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const stop = () => {
|
|
70
|
+
stopping = true;
|
|
71
|
+
child?.kill();
|
|
72
|
+
wake();
|
|
73
|
+
};
|
|
74
|
+
if (signals) for (const sig of ["SIGTERM", "SIGINT", "SIGBREAK"]) process.on(sig, stop);
|
|
75
|
+
|
|
76
|
+
let starts = 0;
|
|
77
|
+
while (!stopping && starts < maxStarts) {
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
starts++;
|
|
80
|
+
const exit = await new Promise(resolve => {
|
|
81
|
+
try {
|
|
82
|
+
child = spawn(spec.command, spec.args ?? [], {
|
|
83
|
+
cwd: spec.cwd,
|
|
84
|
+
env: mergeEnv(process.env, spec.env ?? {}),
|
|
85
|
+
stdio: ["ignore", logFd, logFd],
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
say(`não consegui abrir ${spec.command}: ${/** @type {Error} */ (e).message}`);
|
|
90
|
+
resolve("erro");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
writeState(child.pid ?? null);
|
|
94
|
+
say(`studio aberto (pid ${child.pid})`);
|
|
95
|
+
child.once("error", e => {
|
|
96
|
+
say(`erro ao abrir ${spec.command}: ${e.message}`);
|
|
97
|
+
resolve("erro");
|
|
98
|
+
});
|
|
99
|
+
child.once("exit", (code, signal) => resolve(code ?? signal));
|
|
100
|
+
});
|
|
101
|
+
child = null;
|
|
102
|
+
writeState(null);
|
|
103
|
+
if (stopping || starts >= maxStarts) break;
|
|
104
|
+
const wait = Math.max(0, throttleMs - (Date.now() - startedAt));
|
|
105
|
+
say(`o studio saiu (${exit}); abro de novo em ${Math.ceil(wait / 1000)} s`);
|
|
106
|
+
await new Promise(resolve => {
|
|
107
|
+
wake = /** @type {() => void} */ (resolve);
|
|
108
|
+
setTimeout(resolve, wait);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
say("supervisor encerrado");
|
|
112
|
+
if (stateFile) rmSync(stateFile, { force: true });
|
|
113
|
+
return { starts };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isMainModule(import.meta.url)) {
|
|
117
|
+
const [specFile, stateArg] = process.argv.slice(2);
|
|
118
|
+
if (!specFile) {
|
|
119
|
+
console.error("uso: node service-run.mjs <studio-service.json> [arquivo de estado]");
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
const spec = /** @type {Spec} */ (JSON.parse(readFileSync(specFile, "utf8")));
|
|
123
|
+
await supervise(spec, { stateFile: stateArg ?? join(dirname(specFile), "studio-service.state.json") });
|
|
124
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/* The studio kept on at login on Windows: a scheduled task, the equivalent of the launchd agent in
|
|
2
|
+
tools/lib/service.mjs (docs/specs/phase-2-plugin-cli.md, "Windows"). No administrator and no extra program:
|
|
3
|
+
`schtasks`, which every Windows has, registers it for the current user.
|
|
4
|
+
|
|
5
|
+
the task runs at the user's logon, in the user's own session (so the credential vault answers),
|
|
6
|
+
with no time limit, one instance, on battery too; Task Scheduler's own restart-on-failure
|
|
7
|
+
is the net under the supervisor
|
|
8
|
+
the action PowerShell, hidden, running node on tools/lib/service-run.mjs: the supervisor keeps the
|
|
9
|
+
studio up and restarts it when it exits (launchd's KeepAlive), writing to .runner/studio.log
|
|
10
|
+
the files .runner/studio-service.json (what to run), studio-task.xml (the task as registered, to
|
|
11
|
+
tell whether a reinstall would change anything), studio-service.state.json (the pids)
|
|
12
|
+
|
|
13
|
+
Everything that touches the machine goes through `deps` (schtasks, taskkill, liveness), so the tests drive it
|
|
14
|
+
from any OS. Throws Errors in Portuguese; the callers print them. */
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { userInfo } from "node:os";
|
|
19
|
+
import { delimiter, dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const TASK_NAME = "OmniVideoStudio";
|
|
22
|
+
export const SPEC_FILE = "studio-service.json";
|
|
23
|
+
export const TASK_FILE = "studio-task.xml";
|
|
24
|
+
export const STATE_FILE = "studio-service.state.json";
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ status: number | null, stdout: string, stderr: string }} Run */
|
|
27
|
+
/** @typedef {{ bun: string, codeRoot: string, dataRoot: string, binDir: string, path: string }} InstallOptions */
|
|
28
|
+
/** @typedef {{ schtasks: (args: string[]) => Run, killTree: (pid: number) => void, isAlive: (pid: number) => boolean, user: () => string }} Deps */
|
|
29
|
+
|
|
30
|
+
/** the real machine @returns {Deps} */
|
|
31
|
+
export const realDeps = () => ({
|
|
32
|
+
schtasks: args => {
|
|
33
|
+
const r = spawnSync("schtasks", args, { encoding: "utf8", windowsHide: true });
|
|
34
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
35
|
+
},
|
|
36
|
+
killTree: pid => {
|
|
37
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
|
|
38
|
+
},
|
|
39
|
+
isAlive: pid => {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return /** @type {NodeJS.ErrnoException} */ (e).code === "EPERM";
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
user: () => (process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : userInfo().username),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/* --- what gets written ------------------------------------------------------------------- */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the supervisor runs: bun on studio.ts from the studio folder, with a PATH that has ~/.omni/bin first
|
|
54
|
+
* (ffmpeg, the venv's Python, yt-dlp) and OMNI_HOME when the workspace is not the code root.
|
|
55
|
+
* @param {{ bun: string, codeRoot: string, dataRoot: string, binDir: string, nodeDir: string, path: string, log: string, throttleS?: number, delimiter?: string }} o
|
|
56
|
+
*/
|
|
57
|
+
export function serviceSpec(o) {
|
|
58
|
+
const sep = o.delimiter ?? delimiter;
|
|
59
|
+
const dirs = [o.binDir, dirname(o.bun), o.nodeDir, ...o.path.split(sep)].filter(Boolean);
|
|
60
|
+
return {
|
|
61
|
+
command: o.bun,
|
|
62
|
+
args: ["studio.ts"],
|
|
63
|
+
cwd: join(o.codeRoot, "studio"),
|
|
64
|
+
log: o.log,
|
|
65
|
+
throttle_s: o.throttleS ?? 30,
|
|
66
|
+
env: {
|
|
67
|
+
PATH: [...new Set(dirs)].join(sep),
|
|
68
|
+
...(o.dataRoot === o.codeRoot ? {} : { OMNI_HOME: o.dataRoot }),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const xmlText = (/** @type {string} */ v) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
74
|
+
/** a PowerShell single-quoted string: the only escape is doubling the quote */
|
|
75
|
+
const psQuote = (/** @type {string} */ v) => `'${v.replace(/'/g, "''")}'`;
|
|
76
|
+
|
|
77
|
+
/** the PowerShell arguments: hidden window, no profile, node on the supervisor with the spec and state files */
|
|
78
|
+
export function powershellArguments(
|
|
79
|
+
/** @type {{ node: string, runScript: string, specFile: string, stateFile: string }} */ o,
|
|
80
|
+
) {
|
|
81
|
+
const call = `& ${psQuote(o.node)} ${psQuote(o.runScript)} ${psQuote(o.specFile)} ${psQuote(o.stateFile)}; exit $LASTEXITCODE`;
|
|
82
|
+
return `-NoProfile -NonInteractive -WindowStyle Hidden -Command "${call}"`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The task definition (Task Scheduler's XML schema 1.2). LogonType InteractiveToken + LeastPrivilege: it runs as
|
|
87
|
+
* the user, in the user's session, with no password stored and no administrator.
|
|
88
|
+
* @param {{ user: string, node: string, runScript: string, specFile: string, stateFile: string }} o
|
|
89
|
+
*/
|
|
90
|
+
export function taskXml(o) {
|
|
91
|
+
const user = xmlText(o.user);
|
|
92
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
93
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
94
|
+
<RegistrationInfo>
|
|
95
|
+
<Description>OmniVideo: o studio deste computador, aberto no login e reaberto se parar</Description>
|
|
96
|
+
</RegistrationInfo>
|
|
97
|
+
<Triggers>
|
|
98
|
+
<LogonTrigger>
|
|
99
|
+
<Enabled>true</Enabled>
|
|
100
|
+
<UserId>${user}</UserId>
|
|
101
|
+
</LogonTrigger>
|
|
102
|
+
</Triggers>
|
|
103
|
+
<Principals>
|
|
104
|
+
<Principal id="Author">
|
|
105
|
+
<UserId>${user}</UserId>
|
|
106
|
+
<LogonType>InteractiveToken</LogonType>
|
|
107
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
108
|
+
</Principal>
|
|
109
|
+
</Principals>
|
|
110
|
+
<Settings>
|
|
111
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
112
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
113
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
114
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
115
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
116
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
117
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
118
|
+
<Enabled>true</Enabled>
|
|
119
|
+
<Hidden>false</Hidden>
|
|
120
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
121
|
+
<Priority>7</Priority>
|
|
122
|
+
<RestartOnFailure>
|
|
123
|
+
<Interval>PT1M</Interval>
|
|
124
|
+
<Count>999</Count>
|
|
125
|
+
</RestartOnFailure>
|
|
126
|
+
</Settings>
|
|
127
|
+
<Actions Context="Author">
|
|
128
|
+
<Exec>
|
|
129
|
+
<Command>powershell.exe</Command>
|
|
130
|
+
<Arguments>${xmlText(powershellArguments(o))}</Arguments>
|
|
131
|
+
</Exec>
|
|
132
|
+
</Actions>
|
|
133
|
+
</Task>
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** schtasks reads the definition as UTF-16 with a byte order mark @param {string} xml */
|
|
138
|
+
export const encodeTaskXml = xml => Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, "utf16le")]);
|
|
139
|
+
|
|
140
|
+
/* --- the service ------------------------------------------------------------------------- */
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {{ dir: string, logFile: string, runScript: string, nodePath?: string, deps?: Deps }} cfg
|
|
144
|
+
* dir: where the files above live (.runner/); logFile: the studio's log; runScript: the supervisor's path
|
|
145
|
+
*/
|
|
146
|
+
export function windowsService({ dir, logFile, runScript, nodePath = process.execPath, deps = realDeps() }) {
|
|
147
|
+
const file = (/** @type {string} */ name) => join(dir, name);
|
|
148
|
+
const readState = () => {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(readFileSync(file(STATE_FILE), "utf8"));
|
|
151
|
+
} catch {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const registered = () => deps.schtasks(["/Query", "/TN", TASK_NAME]).status === 0;
|
|
156
|
+
|
|
157
|
+
/** the two files an install writes, as text and bytes @param {InstallOptions} o */
|
|
158
|
+
const plan = o => ({
|
|
159
|
+
spec: `${JSON.stringify(serviceSpec({ ...o, nodeDir: dirname(nodePath), log: logFile }), null, 2)}\n`,
|
|
160
|
+
xml: encodeTaskXml(
|
|
161
|
+
taskXml({
|
|
162
|
+
user: deps.user(),
|
|
163
|
+
node: nodePath,
|
|
164
|
+
runScript,
|
|
165
|
+
specFile: file(SPEC_FILE),
|
|
166
|
+
stateFile: file(STATE_FILE),
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** ends the task and, to be sure, the processes it left: Task Scheduler's End takes the tree down, taskkill is the net */
|
|
172
|
+
const stop = () => {
|
|
173
|
+
deps.schtasks(["/End", "/TN", TASK_NAME]);
|
|
174
|
+
const { supervisor, child } = readState();
|
|
175
|
+
for (const pid of [supervisor, child]) if (pid && deps.isAlive(pid)) deps.killTree(pid);
|
|
176
|
+
rmSync(file(STATE_FILE), { force: true });
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
/** what Task Scheduler and the supervisor say: registered, and the studio's pid when it is up */
|
|
181
|
+
status() {
|
|
182
|
+
const installed = registered();
|
|
183
|
+
const child = readState().child;
|
|
184
|
+
const pid = installed && child && deps.isAlive(child) ? child : null;
|
|
185
|
+
return { installed, loaded: installed, pid, log: logFile };
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/** the studio folder the installed service runs from (the spec's cwd), or "" */
|
|
189
|
+
workingDir() {
|
|
190
|
+
try {
|
|
191
|
+
return String(JSON.parse(readFileSync(file(SPEC_FILE), "utf8")).cwd ?? "");
|
|
192
|
+
} catch {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** whether installing `o` would change nothing: same spec, same task, and the task is registered
|
|
198
|
+
* @param {InstallOptions} o */
|
|
199
|
+
upToDate(o) {
|
|
200
|
+
const { spec, xml } = plan(o);
|
|
201
|
+
return (
|
|
202
|
+
existsSync(file(SPEC_FILE)) &&
|
|
203
|
+
existsSync(file(TASK_FILE)) &&
|
|
204
|
+
readFileSync(file(SPEC_FILE), "utf8") === spec &&
|
|
205
|
+
readFileSync(file(TASK_FILE)).equals(xml) &&
|
|
206
|
+
registered()
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Registers (or replaces) the task and starts it now, which a logon trigger would only do at the next login.
|
|
212
|
+
* @param {InstallOptions} o
|
|
213
|
+
*/
|
|
214
|
+
install(o) {
|
|
215
|
+
const { spec, xml } = plan(o);
|
|
216
|
+
mkdirSync(dir, { recursive: true });
|
|
217
|
+
writeFileSync(file(SPEC_FILE), spec);
|
|
218
|
+
writeFileSync(file(TASK_FILE), xml);
|
|
219
|
+
/* a reinstall replaces what runs: stop the old one first, like bootout before bootstrap */
|
|
220
|
+
stop();
|
|
221
|
+
const created = deps.schtasks(["/Create", "/TN", TASK_NAME, "/XML", file(TASK_FILE), "/F"]);
|
|
222
|
+
if (created.status !== 0) throw new Error(`schtasks /Create: ${(created.stderr || created.stdout).trim()}`);
|
|
223
|
+
const started = deps.schtasks(["/Run", "/TN", TASK_NAME]);
|
|
224
|
+
if (started.status !== 0) throw new Error(`schtasks /Run: ${(started.stderr || started.stdout).trim()}`);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
uninstall() {
|
|
228
|
+
stop();
|
|
229
|
+
deps.schtasks(["/Delete", "/TN", TASK_NAME, "/F"]);
|
|
230
|
+
for (const name of [SPEC_FILE, TASK_FILE, STATE_FILE]) rmSync(file(name), { force: true });
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
restart() {
|
|
234
|
+
if (!registered()) throw new Error("o serviço não está ligado");
|
|
235
|
+
stop();
|
|
236
|
+
const r = deps.schtasks(["/Run", "/TN", TASK_NAME]);
|
|
237
|
+
if (r.status !== 0) throw new Error(`schtasks /Run: ${(r.stderr || r.stdout).trim()}`);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|