usagemax 0.3.3 → 0.3.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.
- package/README.md +208 -177
- package/package.json +18 -2
- package/src/cli.js +266 -59
- package/src/progress.js +73 -0
- package/src/resume.js +3 -1
- package/src/service.js +67 -5
- package/src/transport.js +76 -0
- package/src/updates.js +99 -0
package/src/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { access, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, chmod, copyFile, mkdir, readFile, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -27,13 +27,73 @@ export function assertDurablePath(path) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function brandedRuntimePath(directory, platform = process.platform) {
|
|
31
|
+
if (platform !== "darwin" && platform !== "win32") return null;
|
|
32
|
+
return platform === "win32"
|
|
33
|
+
? join(directory, "runtime", "UsageMax.exe")
|
|
34
|
+
: join(directory, "runtime", "bin", "UsageMax");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Give scheduled jobs a stable product-facing image name on platforms where
|
|
39
|
+
* the JavaScript runtime otherwise appears as `node` in process browsers.
|
|
40
|
+
* The copied runtime is intentionally private to this installation and is
|
|
41
|
+
* refreshed on every service install, so upgrading Node or UsageMax never
|
|
42
|
+
* leaves the scheduler pointing at an ephemeral cache path.
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureBrandedRuntime(directory, executable, platform = process.platform) {
|
|
45
|
+
const target = brandedRuntimePath(directory, platform);
|
|
46
|
+
if (!target) return executable;
|
|
47
|
+
const source = await realpath(executable);
|
|
48
|
+
const runtimeBinDirectory = dirname(target);
|
|
49
|
+
if (source === target) return target;
|
|
50
|
+
await mkdir(runtimeBinDirectory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = join(runtimeBinDirectory, `.${platform === "win32" ? "UsageMax.exe" : "UsageMax"}.${randomUUID()}.tmp`);
|
|
52
|
+
try {
|
|
53
|
+
await copyFile(source, temporary);
|
|
54
|
+
if (platform !== "win32") await chmod(temporary, 0o700);
|
|
55
|
+
await rename(temporary, target);
|
|
56
|
+
if (platform === "darwin") {
|
|
57
|
+
// Homebrew Node uses @rpath/libnode.<n>.dylib next to its bin folder;
|
|
58
|
+
// the official Node distribution is self-contained. Copy only adjacent
|
|
59
|
+
// dylibs when they exist, keeping both layouts runnable.
|
|
60
|
+
const sourceLibraryDirectory = resolve(dirname(source), "../lib");
|
|
61
|
+
const runtimeLibraryDirectory = resolve(runtimeBinDirectory, "../lib");
|
|
62
|
+
const sourceLibraries = await readdir(sourceLibraryDirectory, { withFileTypes: true }).catch((error) => {
|
|
63
|
+
if (error?.code === "ENOENT") return [];
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
const libraries = sourceLibraries.filter((entry) => entry.isFile() && entry.name.endsWith(".dylib"));
|
|
67
|
+
if (libraries.length) await mkdir(runtimeLibraryDirectory, { recursive: true, mode: 0o700 });
|
|
68
|
+
for (const library of libraries) {
|
|
69
|
+
const libraryTemporary = join(runtimeLibraryDirectory, `.${library.name}.${randomUUID()}.tmp`);
|
|
70
|
+
try {
|
|
71
|
+
await copyFile(join(sourceLibraryDirectory, library.name), libraryTemporary);
|
|
72
|
+
await chmod(libraryTemporary, 0o700);
|
|
73
|
+
await rename(libraryTemporary, join(runtimeLibraryDirectory, library.name));
|
|
74
|
+
} finally {
|
|
75
|
+
await unlink(libraryTemporary).catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
await unlink(temporary).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
|
|
31
86
|
minutes = intervalMinutes(minutes);
|
|
32
87
|
for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
|
|
33
88
|
const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
|
|
34
|
-
const name = `com.
|
|
89
|
+
const name = `com.UsageMax.sync.${id}`;
|
|
35
90
|
const args = [cli, "service", "run", "--config-dir", directory];
|
|
36
|
-
|
|
91
|
+
// Linux can execute the published shebang entry point directly. macOS and
|
|
92
|
+
// Windows launch the staged UsageMax runtime so process browsers do not show
|
|
93
|
+
// the generic JavaScript runtime image name.
|
|
94
|
+
const command = platform === "win32" || platform === "darwin"
|
|
95
|
+
? [executable, ...args]
|
|
96
|
+
: [cli, "service", "run", "--config-dir", directory];
|
|
37
97
|
if (platform === "darwin") {
|
|
38
98
|
const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
|
|
39
99
|
const domain = `gui/${uid}`;
|
|
@@ -88,7 +148,8 @@ export async function manageService(action, { directory, cli, minutes, env = pro
|
|
|
88
148
|
const settingsPath = join(directory, "service.json");
|
|
89
149
|
const settings = await readJson(settingsPath);
|
|
90
150
|
const configHome = settings?.configHome || env.XDG_CONFIG_HOME || join(home, ".config");
|
|
91
|
-
const
|
|
151
|
+
const executable = brandedRuntimePath(directory, platform) || process.execPath;
|
|
152
|
+
const plan = servicePlan({ directory, cli, executable, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
|
|
92
153
|
if (action === "status") {
|
|
93
154
|
let registered = false;
|
|
94
155
|
try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
|
|
@@ -105,8 +166,9 @@ export async function manageService(action, { directory, cli, minutes, env = pro
|
|
|
105
166
|
}
|
|
106
167
|
if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
|
|
107
168
|
assertDurablePath(await realpath(cli));
|
|
108
|
-
assertDurablePath(await realpath(process.execPath));
|
|
169
|
+
if (platform === "darwin" || platform === "win32") assertDurablePath(await realpath(process.execPath));
|
|
109
170
|
await access(join(directory, "config.json"));
|
|
171
|
+
if (platform === "darwin" || platform === "win32") await ensureBrandedRuntime(directory, process.execPath, platform);
|
|
110
172
|
// Check the user manager before writing anything. WSL without systemd fails clearly.
|
|
111
173
|
if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
|
|
112
174
|
if (settings) {
|
package/src/transport.js
CHANGED
|
@@ -7,6 +7,82 @@ export function retryAfterMs(value, now = Date.now()) {
|
|
|
7
7
|
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const collectorStates = new Set(["active", "revoked", "workspace_disabled", "membership_inactive", "device_mismatch", "scope_missing"]);
|
|
15
|
+
const deviceBindings = new Set(["unbound", "bound", "matched", "mismatch"]);
|
|
16
|
+
const collectorTokenPattern = /umx_[a-f0-9]{64}/gi;
|
|
17
|
+
|
|
18
|
+
function safeText(value, secret) {
|
|
19
|
+
let text = value;
|
|
20
|
+
if (secret) text = text.split(secret).join("[redacted]");
|
|
21
|
+
return text.replace(collectorTokenPattern, "[redacted]").slice(0, 160);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Only copy the documented diagnostic projection. This keeps a compromised or
|
|
25
|
+
// misconfigured endpoint from echoing a collector secret through the CLI.
|
|
26
|
+
export function collectorStatusView(httpStatus, value, secret) {
|
|
27
|
+
const body = record(value);
|
|
28
|
+
const view = { tokenFormat: "valid", httpStatus };
|
|
29
|
+
if (!body) {
|
|
30
|
+
return {
|
|
31
|
+
...view,
|
|
32
|
+
status: httpStatus === 401 ? "rejected" : "unavailable",
|
|
33
|
+
reason: httpStatus === 401
|
|
34
|
+
? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
|
|
35
|
+
: "UsageMax returned no machine-readable collector status.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof body.status === "string" && collectorStates.has(body.status)) view.status = body.status;
|
|
40
|
+
if (body.credentialType === "collector") view.credentialType = body.credentialType;
|
|
41
|
+
if (body.writeOnly === true) view.writeOnly = true;
|
|
42
|
+
if (body.activation === "not_required") view.activation = body.activation;
|
|
43
|
+
if (body.expiresAt === null) view.expiresAt = null;
|
|
44
|
+
if (Array.isArray(body.scopes)) view.scopes = body.scopes.filter((scope) => typeof scope === "string").slice(0, 16);
|
|
45
|
+
if (body.scopeStatus === "valid" || body.scopeStatus === "missing_telemetry_write") view.scopeStatus = body.scopeStatus;
|
|
46
|
+
if (typeof body.ingestAuthorized === "boolean") view.ingestAuthorized = body.ingestAuthorized;
|
|
47
|
+
if (typeof body.deviceBinding === "string" && deviceBindings.has(body.deviceBinding)) view.deviceBinding = body.deviceBinding;
|
|
48
|
+
for (const key of ["profileHandle", "deviceName", "platform", "cliVersion", "lastFailureCode"]) {
|
|
49
|
+
if (typeof body[key] === "string") view[key] = safeText(body[key], secret);
|
|
50
|
+
}
|
|
51
|
+
for (const key of ["createdAt", "lastSeenAt", "lastSuccessAt", "lastFailureAt"]) {
|
|
52
|
+
if (typeof body[key] === "number" && Number.isSafeInteger(body[key])) view[key] = body[key];
|
|
53
|
+
else if (body[key] === null) view[key] = null;
|
|
54
|
+
}
|
|
55
|
+
if (!view.status) {
|
|
56
|
+
view.status = httpStatus === 401 ? "rejected" : "unavailable";
|
|
57
|
+
view.reason = httpStatus === 401
|
|
58
|
+
? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
|
|
59
|
+
: "UsageMax returned an incomplete collector status.";
|
|
60
|
+
}
|
|
61
|
+
return view;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function requestCollectorStatus(endpoint, config, {
|
|
65
|
+
timeout = 15_000,
|
|
66
|
+
fetchImpl = fetch,
|
|
67
|
+
} = {}) {
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await fetchImpl(endpoint, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: {
|
|
73
|
+
authorization: `Bearer ${config.token}`,
|
|
74
|
+
...(config.deviceId ? { "x-usagemax-device-id": config.deviceId } : {}),
|
|
75
|
+
},
|
|
76
|
+
cache: "no-store",
|
|
77
|
+
signal: AbortSignal.timeout(timeout),
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error("Collector status could not reach UsageMax or timed out. Check your network connection.");
|
|
81
|
+
}
|
|
82
|
+
const body = await response.json().catch(() => null);
|
|
83
|
+
return { httpStatus: response.status, body };
|
|
84
|
+
}
|
|
85
|
+
|
|
10
86
|
// Only snapshot operations have server receipts. Never automatically replay a
|
|
11
87
|
// one-use link request or apply this policy to arbitrary POST operations.
|
|
12
88
|
export async function requestSnapshot(endpoint, config, operation, payload, {
|
package/src/updates.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/usagemax/latest";
|
|
7
|
+
export const UPDATE_CHECK_TTL_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function compareVersions(left, right) {
|
|
14
|
+
const a = String(left || "").split(".").map(Number);
|
|
15
|
+
const b = String(right || "").split(".").map(Number);
|
|
16
|
+
if (a.length !== 3 || b.length !== 3 || a.some((part) => !Number.isInteger(part)) || b.some((part) => !Number.isInteger(part))) return 0;
|
|
17
|
+
for (let index = 0; index < 3; index += 1) {
|
|
18
|
+
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cachePath(directory) {
|
|
24
|
+
return join(directory, "update-check.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readCache(directory) {
|
|
28
|
+
try {
|
|
29
|
+
const value = record(JSON.parse(await readFile(cachePath(directory), "utf8")));
|
|
30
|
+
if (!value || !Number.isFinite(value.checkedAt)) return null;
|
|
31
|
+
return value;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeCache(directory, value) {
|
|
38
|
+
await mkdir(dirname(cachePath(directory)), { recursive: true, mode: 0o700 });
|
|
39
|
+
const path = cachePath(directory);
|
|
40
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
43
|
+
await rename(temporary, path);
|
|
44
|
+
} finally {
|
|
45
|
+
await unlink(temporary).catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function latestVersion({ fetchImpl = fetch, timeout = 1_500 } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
52
|
+
headers: { accept: "application/json" },
|
|
53
|
+
cache: "no-store",
|
|
54
|
+
signal: AbortSignal.timeout(timeout),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) return null;
|
|
57
|
+
const body = record(await response.json());
|
|
58
|
+
return /^\d+\.\d+\.\d+$/.test(body?.version || "") ? body.version : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check npm at most twice a day. A failed check is deliberately non-fatal.
|
|
66
|
+
* The result contains no account data and is safe to cache locally.
|
|
67
|
+
*/
|
|
68
|
+
export async function checkForUpdate(directory, currentVersion, { force = false, now = Date.now(), fetchImpl = fetch } = {}) {
|
|
69
|
+
const cached = await readCache(directory);
|
|
70
|
+
if (!force && cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
71
|
+
return { ...cached, newer: compareVersions(cached.latest, currentVersion) > 0 };
|
|
72
|
+
}
|
|
73
|
+
const latest = await latestVersion({ fetchImpl });
|
|
74
|
+
const result = { checkedAt: now, latest: latest || cached?.latest || null };
|
|
75
|
+
await writeCache(directory, result).catch(() => undefined);
|
|
76
|
+
return { ...result, newer: compareVersions(result.latest, currentVersion) > 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runner() {
|
|
80
|
+
const override = process.env.USAGEMAX_PACKAGE_RUNNER?.trim();
|
|
81
|
+
if (override) return { command: override, prefix: [] };
|
|
82
|
+
if (process.env.npm_execpath) return { command: "npm", prefix: ["exec", "--yes"] };
|
|
83
|
+
return { command: "bunx", prefix: ["--bun"] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Re-run a command through the current npm dist-tag without requiring @latest. */
|
|
87
|
+
export async function runLatest(args, { spawnImpl = spawn } = {}) {
|
|
88
|
+
const selected = runner();
|
|
89
|
+
const child = spawnImpl(selected.command, [...selected.prefix, "usagemax@latest", "--", ...args], {
|
|
90
|
+
stdio: "inherit",
|
|
91
|
+
env: { ...process.env, USAGEMAX_UPDATE_HANDOFF: "1" },
|
|
92
|
+
});
|
|
93
|
+
const code = await new Promise((resolve, reject) => {
|
|
94
|
+
child.once("error", reject);
|
|
95
|
+
child.once("exit", (status) => resolve(status ?? 1));
|
|
96
|
+
});
|
|
97
|
+
process.exitCode = code;
|
|
98
|
+
return code;
|
|
99
|
+
}
|