omnindicator 0.1.0
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/LICENSE +201 -0
- package/README.md +959 -0
- package/docs/npm-installer.md +91 -0
- package/npm/omnindicator/bin/omnindicator.js +11 -0
- package/npm/omnindicator/lib/cli.js +267 -0
- package/npm/omnindicator/lib/host.js +207 -0
- package/npm/omnindicator/lib/installer.js +178 -0
- package/npm/omnindicator/lib/models.js +114 -0
- package/package.json +40 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# `omnindicator` npm installer
|
|
2
|
+
|
|
3
|
+
`omnindicator` is a small, dependency-free Node.js host doctor and installer
|
|
4
|
+
for this repository. It does not contain model weights and it does not replace
|
|
5
|
+
the Python/CUDA runtime. Its job is deliberately narrow:
|
|
6
|
+
|
|
7
|
+
1. identify the operating system, architecture, Tegra versus discrete NVIDIA
|
|
8
|
+
memory model, broker mode, system memory, model-class capacity, disk space,
|
|
9
|
+
desktop-session signal, and required host tools;
|
|
10
|
+
2. stop before cloning or pulling model data when the coarse compute floor,
|
|
11
|
+
disk floor, platform, or prerequisites are not satisfied;
|
|
12
|
+
3. prepare a release-pinned checkout of this repository;
|
|
13
|
+
4. set the selected logical model as both the Omni and language tag, avoiding a
|
|
14
|
+
redundant language runner; and
|
|
15
|
+
5. hand control to the repository-native deployer, which remains responsible
|
|
16
|
+
for exact artifact validation, live memory admission, GPU residency, service
|
|
17
|
+
readiness, rollback, and the model download itself.
|
|
18
|
+
|
|
19
|
+
The fastest guided path is one command:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx omnindicator@latest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install the CLI once:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install --global omnindicator
|
|
29
|
+
omnindicator
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
There is intentionally no npm `postinstall` hook. Installing a JavaScript
|
|
33
|
+
dependency must not silently clone source, request administrator access, pull
|
|
34
|
+
tens of gigabytes of weights, or start a microphone-enabled service. `npx
|
|
35
|
+
omnindicator` installs and immediately invokes the visible guided command; a
|
|
36
|
+
global install exposes the same command for an explicit invocation.
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
omnindicator doctor
|
|
42
|
+
omnindicator doctor --json
|
|
43
|
+
omnindicator install
|
|
44
|
+
omnindicator install --profile ornith15 --yes
|
|
45
|
+
omnindicator status
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `--dry-run` with `install` to print the checkout and native deploy command
|
|
49
|
+
without cloning, pulling weights, or changing services. `--repo-dir PATH` uses
|
|
50
|
+
an existing checkout without changing its Git state. On Linux, `--core-only`
|
|
51
|
+
is the only way to opt out of the desktop indicator.
|
|
52
|
+
|
|
53
|
+
## Platform scope
|
|
54
|
+
|
|
55
|
+
| Platform | Compute path | Managed runtime | Always-listening tray/top-bar indicator |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| NVIDIA Jetson/Tegra Linux | CUDA on integrated GPU/unified memory | systemd direct mode | Yes; GTK/AppIndicator harness is the default |
|
|
58
|
+
| Discrete NVIDIA Linux | broker-scoped CUDA when ollama-unify is present, direct otherwise | systemd | Yes; GTK/AppIndicator harness is the default |
|
|
59
|
+
| macOS | pinned llama.cpp Metal build | per-user launchd agent | No native tray implementation yet; portal only |
|
|
60
|
+
| Windows x64 + NVIDIA | pinned llama.cpp CUDA build | scheduled task or pywin32 service | No native tray implementation yet; portal only |
|
|
61
|
+
|
|
62
|
+
The package refuses to describe macOS or Windows as indicator deployments.
|
|
63
|
+
Interactive users must acknowledge the core-only boundary; unattended use must
|
|
64
|
+
pass `--core-only --yes`.
|
|
65
|
+
|
|
66
|
+
## Capacity screening
|
|
67
|
+
|
|
68
|
+
The npm doctor uses conservative class-level floors, not an optimistic free
|
|
69
|
+
VRAM snapshot. The compact Ornith bundle is about 8.15 GiB and is the preferred
|
|
70
|
+
32 GB Jetson profile. The compact Qwen3.8 E03 bundle is about 18.33 GiB and
|
|
71
|
+
needs substantially more headroom for KV cache, TTS, pointing, CUDA workspaces,
|
|
72
|
+
the desktop, and tools. A 32 GB-class Tegra can be screened as conditional for
|
|
73
|
+
Qwen3.8; that is not a promise that a useful live context tier will fit.
|
|
74
|
+
|
|
75
|
+
After the coarse screen, the native deployment performs the authoritative
|
|
76
|
+
checks using installed layer bytes, current unified/GPU memory, the operating
|
|
77
|
+
reserve, GPU utilization policy, per-worker residency evidence, and readiness.
|
|
78
|
+
It may still refuse or roll back a host that passed the npm screen. Conversely,
|
|
79
|
+
the npm package offers no `--force` switch that can erase a compute failure.
|
|
80
|
+
|
|
81
|
+
## Checkout and update behavior
|
|
82
|
+
|
|
83
|
+
The default checkout lives in the platform application-data directory and is
|
|
84
|
+
pinned to the release tag corresponding to the npm version. It stays on a
|
|
85
|
+
tracking `main` branch so the Linux indicator's explicit **Update available**
|
|
86
|
+
action can fast-forward it later. `omnindicator` will not overwrite tracked
|
|
87
|
+
local changes, switch an unexpected branch, or update an unexpected Git
|
|
88
|
+
remote. Untracked runtime data is preserved.
|
|
89
|
+
|
|
90
|
+
No registry credential, portal access token, model credential, voice clip, or
|
|
91
|
+
host identifier is embedded in the npm package.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const { main } = require("../lib/cli");
|
|
6
|
+
|
|
7
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
8
|
+
const message = error && error.message ? error.message : String(error);
|
|
9
|
+
process.stderr.write(`omnindicator: ${message}\n`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const readline = require("node:readline/promises");
|
|
6
|
+
const { stdin, stdout } = require("node:process");
|
|
7
|
+
const { inspectHost } = require("./host");
|
|
8
|
+
const {
|
|
9
|
+
RELEASE_REF,
|
|
10
|
+
buildDeployment,
|
|
11
|
+
dataRoot,
|
|
12
|
+
ensureRepository,
|
|
13
|
+
runDeployment,
|
|
14
|
+
runStatus,
|
|
15
|
+
} = require("./installer");
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
return `Usage: omnindicator [install|doctor|status] [options]
|
|
19
|
+
|
|
20
|
+
With no command, omnindicator runs the guarded interactive installer.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--profile ornith15|qwen38 Select a trained audio-bridge model
|
|
24
|
+
--repo-dir PATH Use an existing checkout without changing its Git state
|
|
25
|
+
--core-only Linux: omit the always-listening indicator harness
|
|
26
|
+
--yes, -y Accept the deployment plan (requires --profile without a TTY)
|
|
27
|
+
--dry-run Show the resolved plan without cloning, pulling weights, or starting services
|
|
28
|
+
--json Emit machine-readable doctor output
|
|
29
|
+
--help, -h Show this help
|
|
30
|
+
--version, -v Show the package version
|
|
31
|
+
|
|
32
|
+
The Linux desktop default installs the visible AppIndicator harness. macOS and
|
|
33
|
+
Windows currently install the GPU runtime/service and portal only; no native
|
|
34
|
+
tray indicator is claimed on those platforms.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const result = {
|
|
39
|
+
command: "install",
|
|
40
|
+
profile: null,
|
|
41
|
+
repoDir: null,
|
|
42
|
+
externalCheckout: false,
|
|
43
|
+
coreOnly: false,
|
|
44
|
+
yes: false,
|
|
45
|
+
dryRun: false,
|
|
46
|
+
json: false,
|
|
47
|
+
help: false,
|
|
48
|
+
version: false,
|
|
49
|
+
};
|
|
50
|
+
let commandSeen = false;
|
|
51
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
52
|
+
const value = argv[index];
|
|
53
|
+
if (!value.startsWith("-") && !commandSeen) {
|
|
54
|
+
if (!new Set(["install", "doctor", "status"]).has(value)) throw new Error(`Unknown command: ${value}`);
|
|
55
|
+
result.command = value;
|
|
56
|
+
commandSeen = true;
|
|
57
|
+
} else if (value === "--profile") {
|
|
58
|
+
result.profile = argv[++index];
|
|
59
|
+
if (!result.profile) throw new Error("--profile requires a value");
|
|
60
|
+
} else if (value === "--repo-dir") {
|
|
61
|
+
result.repoDir = path.resolve(argv[++index] || "");
|
|
62
|
+
if (!argv[index]) throw new Error("--repo-dir requires a value");
|
|
63
|
+
result.externalCheckout = true;
|
|
64
|
+
} else if (value === "--core-only") result.coreOnly = true;
|
|
65
|
+
else if (value === "--yes" || value === "-y") result.yes = true;
|
|
66
|
+
else if (value === "--dry-run") result.dryRun = true;
|
|
67
|
+
else if (value === "--json") result.json = true;
|
|
68
|
+
else if (value === "--help" || value === "-h") result.help = true;
|
|
69
|
+
else if (value === "--version" || value === "-v") result.version = true;
|
|
70
|
+
else throw new Error(`Unknown option: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function packageVersion() {
|
|
76
|
+
const packagePath = path.resolve(__dirname, "..", "..", "..", "package.json");
|
|
77
|
+
return JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function round1(value) {
|
|
81
|
+
return Number.isFinite(value) ? (Math.round(value * 10) / 10).toFixed(1) : "unknown";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function platformLabel(host) {
|
|
85
|
+
if (host.tegra) return `Linux/${host.arch} NVIDIA ${host.tegraSoc || "Tegra"} (unified memory)`;
|
|
86
|
+
if (host.platform === "darwin") return `macOS/${host.arch} Metal`;
|
|
87
|
+
if (host.platform === "win32") return `Windows/${host.arch} NVIDIA CUDA`;
|
|
88
|
+
return `Linux/${host.arch} ${host.brokerManaged ? "broker-managed NVIDIA" : "direct NVIDIA"}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderDoctor(host) {
|
|
92
|
+
const lines = [
|
|
93
|
+
`Host: ${platformLabel(host)}`,
|
|
94
|
+
`System memory: ${round1(host.totalMemoryGiB)} GiB total, ${round1(host.freeMemoryGiB)} GiB currently free`,
|
|
95
|
+
`Install filesystem: ${round1(host.diskFreeGiB)} GiB free`,
|
|
96
|
+
];
|
|
97
|
+
if (host.gpus.length) {
|
|
98
|
+
for (const gpu of host.gpus) lines.push(`GPU ${gpu.index}: ${gpu.name}, ${round1(gpu.memoryGiB)} GiB`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(`Runtime mode: ${host.tegra ? "direct Tegra" : host.brokerManaged ? "ollama-unify broker" : "platform-native direct"}`);
|
|
101
|
+
lines.push(`Desktop indicator: ${host.indicatorAvailable ? "Linux harness available; target desktop is proven during deployment" : "not implemented on this platform (core runtime/portal only)"}`);
|
|
102
|
+
if (host.platform === "linux") lines.push(`Desktop session signal: ${host.desktopSessionDetected ? "detected" : "not visible from this shell; native installer will attempt signed-in-session recovery"}`);
|
|
103
|
+
lines.push("");
|
|
104
|
+
lines.push("Model screening (coarse pre-download check):");
|
|
105
|
+
for (const profile of host.profiles) {
|
|
106
|
+
lines.push(` ${profile.id.padEnd(9)} ${profile.state.padEnd(11)} ${profile.artifactGiB} GiB artifacts — ${profile.reason}`);
|
|
107
|
+
if (profile.diskWarning) lines.push(` disk warning: ${profile.diskWarning}`);
|
|
108
|
+
}
|
|
109
|
+
if (host.supportError) lines.push(`\nBLOCKED: ${host.supportError}`);
|
|
110
|
+
if (host.missingDependencies.length) lines.push(`\nBLOCKED: missing host tools: ${host.missingDependencies.join(", ")}`);
|
|
111
|
+
if (host.runningAsRoot && host.platform === "linux") lines.push("\nWARNING: run omnindicator as the signed-in desktop user, not root; deploy.sh requests sudo only for system steps.");
|
|
112
|
+
lines.push("\nThis is an early screen, not a residency guarantee. The repository doctor, exact layer-byte admission, live memory reserve, GPU-residency proof, and service readiness gates still run on the target host.");
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function doctorJson(host) {
|
|
117
|
+
return {
|
|
118
|
+
platform: host.platform,
|
|
119
|
+
arch: host.arch,
|
|
120
|
+
tegra: host.tegra,
|
|
121
|
+
tegraSoc: host.tegraSoc,
|
|
122
|
+
totalMemoryGiB: host.totalMemoryGiB,
|
|
123
|
+
freeMemoryGiB: host.freeMemoryGiB,
|
|
124
|
+
diskFreeGiB: host.diskFreeGiB,
|
|
125
|
+
gpus: host.gpus.map(({ index, name, memoryGiB, driver }) => ({ index, name, memoryGiB, driver })),
|
|
126
|
+
maxGpuMemoryGiB: host.maxGpuMemoryGiB,
|
|
127
|
+
metalSupported: host.metalSupported,
|
|
128
|
+
brokerManaged: host.brokerManaged,
|
|
129
|
+
indicatorAvailable: host.indicatorAvailable,
|
|
130
|
+
desktopSessionDetected: host.desktopSessionDetected,
|
|
131
|
+
dependencies: Object.fromEntries(Object.entries(host.dependencies).map(([name, value]) => [name, Boolean(value)])),
|
|
132
|
+
missingDependencies: host.missingDependencies,
|
|
133
|
+
supportError: host.supportError,
|
|
134
|
+
runningAsRoot: host.runningAsRoot,
|
|
135
|
+
profiles: host.profiles,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function ask(prompt) {
|
|
140
|
+
const interface_ = readline.createInterface({ input: stdin, output: stdout });
|
|
141
|
+
try {
|
|
142
|
+
return (await interface_.question(prompt)).trim();
|
|
143
|
+
} finally {
|
|
144
|
+
interface_.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function chooseProfile(host, requested) {
|
|
149
|
+
if (requested) {
|
|
150
|
+
const fit = host.profiles.find((profile) => profile.id === requested);
|
|
151
|
+
if (!fit) throw new Error(`Unknown profile ${requested}; expected ornith15 or qwen38.`);
|
|
152
|
+
return fit;
|
|
153
|
+
}
|
|
154
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error("Non-interactive installation requires --profile and --yes.");
|
|
155
|
+
const candidates = host.profiles.filter((profile) => profile.state !== "unsupported");
|
|
156
|
+
if (!candidates.length) throw new Error("This host did not pass the coarse compute floor for either model.");
|
|
157
|
+
stdout.write("\nChoose a model:\n");
|
|
158
|
+
candidates.forEach((profile, index) => stdout.write(` ${index + 1}) ${profile.title} [${profile.state}]\n`));
|
|
159
|
+
const answer = await ask(`Selection [1]: `);
|
|
160
|
+
const choice = answer === "" ? 0 : Number(answer) - 1;
|
|
161
|
+
if (!Number.isInteger(choice) || choice < 0 || choice >= candidates.length) throw new Error("Invalid model selection.");
|
|
162
|
+
return candidates[choice];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function shellDisplay(command, args) {
|
|
166
|
+
return [command, ...args].map((value) => /[^A-Za-z0-9_./:=+-]/.test(value) ? JSON.stringify(value) : value).join(" ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function install(options, host) {
|
|
170
|
+
if (host.supportError) throw new Error(host.supportError);
|
|
171
|
+
if (host.missingDependencies.length) {
|
|
172
|
+
throw new Error(`Install prerequisites are missing: ${host.missingDependencies.join(", ")}. No repository or model data was downloaded.`);
|
|
173
|
+
}
|
|
174
|
+
if (host.platform === "linux" && host.runningAsRoot && !options.coreOnly) {
|
|
175
|
+
throw new Error("Refusing a root-owned desktop indicator. Run as the signed-in desktop user; deploy.sh will request sudo when needed.");
|
|
176
|
+
}
|
|
177
|
+
if (host.platform === "linux" && !options.coreOnly && !host.desktopSessionDetected) {
|
|
178
|
+
throw new Error("No graphical desktop session was detected for the indicator. Run from the signed-in desktop session or pass --core-only; no repository or model data was downloaded.");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const profile = await chooseProfile(host, options.profile);
|
|
182
|
+
if (profile.state === "unsupported") throw new Error(`${profile.title} is not admitted: ${profile.reason}`);
|
|
183
|
+
if (profile.diskWarning) throw new Error(`${profile.diskWarning} No repository or model data was downloaded.`);
|
|
184
|
+
|
|
185
|
+
let coreOnly = options.coreOnly;
|
|
186
|
+
if (!host.indicatorAvailable && !coreOnly) {
|
|
187
|
+
stdout.write("\nThis platform has no native omnindicator tray implementation. The supported deployment is the core GPU service and portal.\n");
|
|
188
|
+
if (!options.yes) {
|
|
189
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error("Pass --core-only --yes to acknowledge core-only deployment on this platform.");
|
|
190
|
+
const answer = (await ask("Continue with the core runtime and portal only? [y/N] ")).toLowerCase();
|
|
191
|
+
if (answer !== "y" && answer !== "yes") return;
|
|
192
|
+
}
|
|
193
|
+
coreOnly = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const root = dataRoot(host.platform);
|
|
197
|
+
const repoDir = options.repoDir || path.join(root, "qwen-omni-adapters");
|
|
198
|
+
const planSummary = [
|
|
199
|
+
"\nDeployment plan",
|
|
200
|
+
` Release: omnindicator ${packageVersion()} / ${RELEASE_REF}`,
|
|
201
|
+
` Checkout: ${repoDir}`,
|
|
202
|
+
` Model: ${profile.title}`,
|
|
203
|
+
` Artifacts: ${profile.artifactGiB} GiB plus KV/workspaces/runtime reserve`,
|
|
204
|
+
` Interface: ${coreOnly ? "core service + portal" : "core service + always-listening Linux indicator"}`,
|
|
205
|
+
` Screening: ${profile.state} — ${profile.reason}`,
|
|
206
|
+
].join("\n");
|
|
207
|
+
stdout.write(`${planSummary}\n`);
|
|
208
|
+
|
|
209
|
+
if (!options.yes) {
|
|
210
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error("Non-interactive installation requires --yes.");
|
|
211
|
+
const answer = (await ask("Proceed with checkout and native deployment? [y/N] ")).toLowerCase();
|
|
212
|
+
if (answer !== "y" && answer !== "yes") return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const existing = fs.existsSync(repoDir);
|
|
216
|
+
const preview = buildDeployment({ platform: host.platform, repoDir, profile: profile.id, coreOnly, existing });
|
|
217
|
+
if (options.dryRun) {
|
|
218
|
+
stdout.write(`\nDry run: would prepare ${options.externalCheckout ? "the supplied checkout" : `the release-pinned ${RELEASE_REF} checkout`} and run:\n ${shellDisplay(preview.command, preview.args)}\n`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const checkout = ensureRepository({
|
|
223
|
+
repoDir,
|
|
224
|
+
ref: RELEASE_REF,
|
|
225
|
+
externalCheckout: options.externalCheckout,
|
|
226
|
+
git: host.dependencies.git,
|
|
227
|
+
});
|
|
228
|
+
const deployment = buildDeployment({
|
|
229
|
+
platform: host.platform,
|
|
230
|
+
repoDir: checkout.repoDir,
|
|
231
|
+
profile: profile.id,
|
|
232
|
+
coreOnly,
|
|
233
|
+
existing: !checkout.created,
|
|
234
|
+
});
|
|
235
|
+
stdout.write(`\nStarting the repository-native deployment. Pulls begin only after the coarse host gate above; the native deployer then validates artifacts, live memory, GPU residency, and readiness before cutover.\n`);
|
|
236
|
+
runDeployment(deployment);
|
|
237
|
+
stdout.write(`\nomnindicator deployment completed. Run \"omnindicator status\" for current component state.\n`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function main(argv) {
|
|
241
|
+
const options = parseArgs(argv);
|
|
242
|
+
if (options.help) {
|
|
243
|
+
stdout.write(`${usage()}\n`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (options.version) {
|
|
247
|
+
stdout.write(`${packageVersion()}\n`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (options.command === "status") {
|
|
251
|
+
const repoDir = options.repoDir || path.join(dataRoot(process.platform), "qwen-omni-adapters");
|
|
252
|
+
runStatus(repoDir, process.platform);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const host = inspectHost({ installTarget: options.repoDir ? path.dirname(options.repoDir) : dataRoot(process.platform) });
|
|
257
|
+
if (options.command === "doctor") {
|
|
258
|
+
stdout.write(options.json ? `${JSON.stringify(doctorJson(host), null, 2)}\n` : `${renderDoctor(host)}\n`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stdout.write("omnindicator — guarded installer for the Qwen Omni local voice, perception, tool, and desktop-indicator runtime\n\n");
|
|
263
|
+
stdout.write(`${renderDoctor(host)}\n`);
|
|
264
|
+
await install(options, host);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { chooseProfile, doctorJson, install, main, packageVersion, parseArgs, renderDoctor, usage };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { evaluateProfiles } = require("./models");
|
|
8
|
+
|
|
9
|
+
const GIB = 1024 ** 3;
|
|
10
|
+
|
|
11
|
+
function executableCandidates(name, platform = process.platform) {
|
|
12
|
+
if (platform !== "win32") return [name];
|
|
13
|
+
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
|
|
14
|
+
.split(";")
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
return path.extname(name) ? [name] : extensions.map((extension) => `${name}${extension.toLowerCase()}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findCommand(name, env = process.env, platform = process.platform) {
|
|
20
|
+
const searchPath = env.PATH || env.Path || env.path || "";
|
|
21
|
+
for (const directory of searchPath.split(path.delimiter).filter(Boolean)) {
|
|
22
|
+
for (const candidate of executableCandidates(name, platform)) {
|
|
23
|
+
const target = path.join(directory, candidate);
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(target, fs.constants.X_OK);
|
|
26
|
+
return target;
|
|
27
|
+
} catch (_) {
|
|
28
|
+
// Keep searching.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readTegraCompatible() {
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync("/proc/device-tree/compatible").toString("utf8").replaceAll("\0", "\n");
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectNvidiaGpus(command) {
|
|
44
|
+
if (!command) return [];
|
|
45
|
+
const result = spawnSync(command, [
|
|
46
|
+
"--query-gpu=index,name,memory.total,driver_version",
|
|
47
|
+
"--format=csv,noheader,nounits",
|
|
48
|
+
], { encoding: "utf8", timeout: 8000, windowsHide: true });
|
|
49
|
+
if (result.status !== 0) return [];
|
|
50
|
+
return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
51
|
+
const [index, name, memoryMiB, driver] = line.split(",").map((value) => value.trim());
|
|
52
|
+
return {
|
|
53
|
+
index: Number(index),
|
|
54
|
+
name,
|
|
55
|
+
memoryGiB: Number(memoryMiB) / 1024,
|
|
56
|
+
driver,
|
|
57
|
+
};
|
|
58
|
+
}).filter((gpu) => Number.isFinite(gpu.memoryGiB));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function detectBroker(dockerCommand) {
|
|
62
|
+
if (!dockerCommand || process.platform !== "linux") return false;
|
|
63
|
+
const result = spawnSync(dockerCommand, ["gpu", "discover"], {
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
timeout: 10000,
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
});
|
|
68
|
+
return result.status === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectMacMetal(systemProfilerCommand, platform = process.platform) {
|
|
72
|
+
if (!systemProfilerCommand || platform !== "darwin") return null;
|
|
73
|
+
const result = spawnSync(systemProfilerCommand, ["SPDisplaysDataType"], {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
timeout: 10000,
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
if (result.status !== 0) return false;
|
|
79
|
+
return /Metal Support:\s*(?:Supported|Metal\b)/i.test(result.stdout);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function diskFreeGiB(target) {
|
|
83
|
+
let candidate = path.resolve(target);
|
|
84
|
+
while (!fs.existsSync(candidate)) {
|
|
85
|
+
const parent = path.dirname(candidate);
|
|
86
|
+
if (parent === candidate) return null;
|
|
87
|
+
candidate = parent;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const stats = fs.statfsSync(candidate);
|
|
91
|
+
const available = Number(stats.bavail ?? stats.bfree);
|
|
92
|
+
return available * Number(stats.bsize) / GIB;
|
|
93
|
+
} catch (_) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasLinuxDesktopSession(env = process.env) {
|
|
99
|
+
if (env.DISPLAY || env.WAYLAND_DISPLAY) return true;
|
|
100
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
101
|
+
if (uid === null) return false;
|
|
102
|
+
const runtime = env.XDG_RUNTIME_DIR || `/run/user/${uid}`;
|
|
103
|
+
try {
|
|
104
|
+
const entries = fs.readdirSync(runtime);
|
|
105
|
+
const displaySocket = entries.some((entry) => entry.startsWith("wayland-"));
|
|
106
|
+
const bus = fs.existsSync(path.join(runtime, "bus"));
|
|
107
|
+
if (displaySocket && bus) return true;
|
|
108
|
+
} catch (_) {
|
|
109
|
+
// No recoverable user runtime directory.
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return fs.readdirSync("/tmp/.X11-unix").some((entry) => entry.startsWith("X"));
|
|
113
|
+
} catch (_) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requiredCommands(platform, brokerManaged) {
|
|
119
|
+
if (platform === "win32") {
|
|
120
|
+
return ["git", "cmake", "ffmpeg", "ollama", "powershell", "py"];
|
|
121
|
+
}
|
|
122
|
+
if (platform === "darwin") {
|
|
123
|
+
return ["bash", "git", "cmake", "ffmpeg", "ollama", "python3", "system_profiler", "xcrun"];
|
|
124
|
+
}
|
|
125
|
+
const commands = ["bash", "git", "cmake", "ffmpeg", "ollama", "python3", "sudo", "systemctl"];
|
|
126
|
+
if (brokerManaged) commands.push("docker", "jq", "ss");
|
|
127
|
+
return commands;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function platformSupport(platform, arch, tegra) {
|
|
131
|
+
if (!new Set(["linux", "darwin", "win32"]).has(platform)) {
|
|
132
|
+
return `Unsupported operating system: ${platform}.`;
|
|
133
|
+
}
|
|
134
|
+
if (tegra && arch !== "arm64") {
|
|
135
|
+
return `Tegra was detected with unexpected architecture ${arch}.`;
|
|
136
|
+
}
|
|
137
|
+
if (platform === "darwin" && !new Set(["arm64", "x64"]).has(arch)) {
|
|
138
|
+
return `Unsupported macOS architecture: ${arch}.`;
|
|
139
|
+
}
|
|
140
|
+
if (platform === "win32" && arch !== "x64") {
|
|
141
|
+
return `Windows CUDA deployment currently requires x64, not ${arch}.`;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inspectHost(options = {}) {
|
|
147
|
+
const platform = options.platform || process.platform;
|
|
148
|
+
const arch = options.arch || process.arch;
|
|
149
|
+
const env = options.env || process.env;
|
|
150
|
+
const compatible = platform === "linux" ? readTegraCompatible() : "";
|
|
151
|
+
const tegra = /^nvidia,tegra\d+/m.test(compatible);
|
|
152
|
+
const nvidiaSmi = findCommand("nvidia-smi", env, platform);
|
|
153
|
+
const gpus = tegra ? [] : detectNvidiaGpus(nvidiaSmi);
|
|
154
|
+
const systemProfiler = findCommand("system_profiler", env, platform);
|
|
155
|
+
const metalSupported = platform === "darwin" ? detectMacMetal(systemProfiler, platform) : null;
|
|
156
|
+
const docker = findCommand("docker", env, platform);
|
|
157
|
+
const brokerManaged = !tegra && detectBroker(docker);
|
|
158
|
+
const totalMemoryGiB = os.totalmem() / GIB;
|
|
159
|
+
const freeMemoryGiB = os.freemem() / GIB;
|
|
160
|
+
const maxGpuMemoryGiB = gpus.length ? Math.max(...gpus.map((gpu) => gpu.memoryGiB)) : null;
|
|
161
|
+
const dependencyNames = requiredCommands(platform, brokerManaged);
|
|
162
|
+
const dependencies = Object.fromEntries(dependencyNames.map((name) => [name, findCommand(name, env, platform)]));
|
|
163
|
+
const missingDependencies = Object.entries(dependencies).filter(([, value]) => !value).map(([name]) => name);
|
|
164
|
+
let supportError = platformSupport(platform, arch, tegra);
|
|
165
|
+
if (!supportError && platform === "darwin" && metalSupported !== true) {
|
|
166
|
+
supportError = "A Metal-capable macOS GPU was not detected.";
|
|
167
|
+
}
|
|
168
|
+
if (!supportError && !tegra && platform !== "darwin" && gpus.length === 0) {
|
|
169
|
+
supportError = "No discrete NVIDIA CUDA GPU was detected.";
|
|
170
|
+
}
|
|
171
|
+
const indicatorAvailable = platform === "linux";
|
|
172
|
+
const desktopSessionDetected = platform === "linux" && hasLinuxDesktopSession(env);
|
|
173
|
+
const installTarget = options.installTarget || os.homedir();
|
|
174
|
+
|
|
175
|
+
const host = {
|
|
176
|
+
platform,
|
|
177
|
+
arch,
|
|
178
|
+
tegra,
|
|
179
|
+
tegraSoc: (compatible.match(/^nvidia,(tegra\d+)/m) || [])[1] || null,
|
|
180
|
+
totalMemoryGiB,
|
|
181
|
+
freeMemoryGiB,
|
|
182
|
+
diskFreeGiB: diskFreeGiB(installTarget),
|
|
183
|
+
gpus,
|
|
184
|
+
maxGpuMemoryGiB,
|
|
185
|
+
metalSupported,
|
|
186
|
+
brokerManaged,
|
|
187
|
+
indicatorAvailable,
|
|
188
|
+
desktopSessionDetected,
|
|
189
|
+
dependencies,
|
|
190
|
+
missingDependencies,
|
|
191
|
+
supportError,
|
|
192
|
+
runningAsRoot: platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0,
|
|
193
|
+
};
|
|
194
|
+
host.profiles = evaluateProfiles(host);
|
|
195
|
+
return host;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
detectNvidiaGpus,
|
|
200
|
+
detectMacMetal,
|
|
201
|
+
diskFreeGiB,
|
|
202
|
+
findCommand,
|
|
203
|
+
hasLinuxDesktopSession,
|
|
204
|
+
inspectHost,
|
|
205
|
+
platformSupport,
|
|
206
|
+
requiredCommands,
|
|
207
|
+
};
|