@rightkit/release 0.2.50 → 0.2.52
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/build-release.mjs +12 -5
- package/build-release.test.mjs +2 -0
- package/cargo-contract.mjs +5 -1
- package/cargo-guard.mjs +52 -0
- package/cargo-guard.test.mjs +39 -0
- package/heavy-command.mjs +129 -11
- package/heavy-command.test.mjs +70 -1
- package/package.json +1 -1
- package/release-invocation.mjs +20 -1
- package/release-invocation.test.mjs +21 -0
- package/right-suite-contract.test.mjs +54 -2
- package/rightkit-versions.json +2 -2
package/build-release.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { createTargetBridge } from "./target-bridge.mjs";
|
|
|
27
27
|
import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
|
|
28
28
|
import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
29
29
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
30
|
-
import { acquireHeavyWorkSlot, heavyCommandEnvironment } from "./heavy-command.mjs";
|
|
30
|
+
import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
|
|
31
31
|
|
|
32
32
|
/** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
|
|
33
33
|
function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
|
|
@@ -364,7 +364,16 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
|
|
|
364
364
|
let lastMtime = Math.max(...watchDirs.map(newestMtime));
|
|
365
365
|
const started = Date.now();
|
|
366
366
|
await new Promise((resolve, reject) => {
|
|
367
|
-
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
367
|
+
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32", detached: process.platform !== "win32" });
|
|
368
|
+
try {
|
|
369
|
+
if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
child.once("error", () => {});
|
|
372
|
+
if (child.pid) killTree(child.pid);
|
|
373
|
+
child = null;
|
|
374
|
+
reject(error);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
368
377
|
const closeWatchers = watchProgress(watchDirs, () => { lastProgress = Date.now(); });
|
|
369
378
|
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
|
370
379
|
stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
|
|
@@ -464,9 +473,7 @@ function runChecked(cmd, runArgs, cwd, env = process.env) {
|
|
|
464
473
|
}
|
|
465
474
|
|
|
466
475
|
function killTree(pid) {
|
|
467
|
-
|
|
468
|
-
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
469
|
-
else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
|
|
476
|
+
terminateProcessTree(pid);
|
|
470
477
|
}
|
|
471
478
|
|
|
472
479
|
function writeJson(file, value) {
|
package/build-release.test.mjs
CHANGED
|
@@ -16,6 +16,8 @@ test("macOS and Windows builds default to Cache V2 while explicit legacy mode re
|
|
|
16
16
|
assert.match(source, /acquireSuiteBuildSlot/);
|
|
17
17
|
assert.match(source, /acquireHeavyWorkSlot/);
|
|
18
18
|
assert.match(source, /heavyCommandEnvironment/);
|
|
19
|
+
assert.match(source, /heavySlot\.trackChild/);
|
|
20
|
+
assert.match(source, /detached: process\.platform !== "win32"/);
|
|
19
21
|
assert.match(source, /acquireCacheLease/);
|
|
20
22
|
assert.match(source, /markCacheEntrySuccessful/);
|
|
21
23
|
assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
|
package/cargo-contract.mjs
CHANGED
|
@@ -16,6 +16,10 @@ export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
|
|
|
16
16
|
return { ...metadataEnv, CARGO_HOME: cargoHome };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function cargoExecutable(platform = process.platform) {
|
|
20
|
+
return platform === "win32" ? "cargo.exe" : "cargo";
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
|
|
20
24
|
const scanRoot = path.resolve(root);
|
|
21
25
|
const boundary = findRepositoryRoot(scanRoot);
|
|
@@ -72,7 +76,7 @@ export function assertNoRightKitCargoOverrides(manifestPath, repoRoot, label) {
|
|
|
72
76
|
|
|
73
77
|
function readCargoManifestDependencies(manifestPath, cargoHome, label) {
|
|
74
78
|
const result = spawnSync(
|
|
75
|
-
|
|
79
|
+
cargoExecutable(),
|
|
76
80
|
["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
|
|
77
81
|
{
|
|
78
82
|
cwd: path.dirname(manifestPath),
|
package/cargo-guard.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { runHeavyCommand } from "./heavy-command.mjs";
|
|
7
|
+
|
|
8
|
+
const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
|
|
9
|
+
const GLOBAL_OPTIONS_WITH_VALUE = new Set(["--color", "--config", "-Z"]);
|
|
10
|
+
|
|
11
|
+
export function cargoSubcommand(args) {
|
|
12
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
13
|
+
const value = args[index];
|
|
14
|
+
if (value.startsWith("+") || value.startsWith("--color=") || value.startsWith("--config=")) continue;
|
|
15
|
+
if (GLOBAL_OPTIONS_WITH_VALUE.has(value)) { index += 1; continue; }
|
|
16
|
+
if (value.startsWith("-Z")) continue;
|
|
17
|
+
if (!value.startsWith("-") || value === "--version" || value === "-V") return value;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldGuardCargo(args) {
|
|
23
|
+
const command = cargoSubcommand(args);
|
|
24
|
+
return command !== null && !LIGHT_COMMANDS.has(command);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
|
|
28
|
+
if (env.RIGHTSUITE_REAL_CARGO) return path.resolve(env.RIGHTSUITE_REAL_CARGO);
|
|
29
|
+
const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
|
|
30
|
+
const result = run(rustup, ["which", "cargo"], { encoding: "utf8", windowsHide: true });
|
|
31
|
+
const cargo = String(result.stdout ?? "").trim();
|
|
32
|
+
if (result.status !== 0 || !cargo) throw new Error(`cargo-guard could not resolve real Cargo: ${String(result.stderr ?? "").trim()}`);
|
|
33
|
+
return path.resolve(cargo);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function spawnCargo(command, args, env) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(command, args, { env, stdio: "inherit", windowsHide: true });
|
|
39
|
+
child.once("error", reject);
|
|
40
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runCargoGuard(args, { env = process.env, resolveCargo = resolveRealCargo, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
|
|
45
|
+
const cargo = resolveCargo({ env });
|
|
46
|
+
if (shouldGuardCargo(args)) return runHeavy(["--", cargo, ...args], { env });
|
|
47
|
+
return runLight(cargo, args, env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
51
|
+
runCargoGuard(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { cargoSubcommand, resolveRealCargo, runCargoGuard, shouldGuardCargo } from "./cargo-guard.mjs";
|
|
5
|
+
|
|
6
|
+
test("Cargo guard serializes compiling commands but bypasses inspection and formatting", () => {
|
|
7
|
+
for (const args of [["build"], ["test"], ["check"], ["clippy"], ["nextest", "run"], ["clean"], ["+stable", "bench"]]) {
|
|
8
|
+
assert.equal(shouldGuardCargo(args), true, args.join(" "));
|
|
9
|
+
}
|
|
10
|
+
for (const args of [["fmt", "--check"], ["metadata"], ["tree"], ["fetch"], ["--version"], []]) {
|
|
11
|
+
assert.equal(shouldGuardCargo(args), false, args.join(" "));
|
|
12
|
+
}
|
|
13
|
+
assert.equal(cargoSubcommand(["+stable", "test"]), "test");
|
|
14
|
+
assert.equal(shouldGuardCargo(["--color", "always", "fmt"]), false);
|
|
15
|
+
assert.equal(shouldGuardCargo(["--config", "net.git-fetch-with-cli=true", "check"]), true);
|
|
16
|
+
assert.equal(shouldGuardCargo(["-Z", "unstable-options", "fmt"]), false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Cargo guard resolves real Cargo through rustup or explicit override", () => {
|
|
20
|
+
assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), "/opt/toolchain/cargo");
|
|
21
|
+
const calls = [];
|
|
22
|
+
const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
|
|
23
|
+
assert.equal(cargo, "/real/cargo");
|
|
24
|
+
assert.deepEqual(calls[0][1], ["which", "cargo"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Cargo guard routes heavy and light commands without recursion", async () => {
|
|
28
|
+
const calls = [];
|
|
29
|
+
const options = {
|
|
30
|
+
env: { TEST: "1" },
|
|
31
|
+
resolveCargo: () => "/real/cargo",
|
|
32
|
+
runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
|
|
33
|
+
runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
|
|
34
|
+
};
|
|
35
|
+
assert.equal(await runCargoGuard(["test"], options), 0);
|
|
36
|
+
assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
|
|
37
|
+
assert.deepEqual(calls[0], ["heavy", ["--", "/real/cargo", "test"], options.env]);
|
|
38
|
+
assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
|
|
39
|
+
});
|
package/heavy-command.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
|
|
9
9
|
const GB = 1024 ** 3;
|
|
10
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
10
11
|
|
|
11
12
|
export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
12
13
|
if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
|
|
@@ -27,6 +28,10 @@ export function heavyCommandEnvironment(env = process.env) {
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export function heavyChildDetached({ slot, platform = process.platform } = {}) {
|
|
32
|
+
return Boolean(slot) && platform !== "win32" && platform !== "win";
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
|
|
31
36
|
const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
|
|
32
37
|
const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
|
|
@@ -103,7 +108,11 @@ export function formatResourceSnapshot(snapshot) {
|
|
|
103
108
|
|
|
104
109
|
function processAlive(pid) {
|
|
105
110
|
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
106
|
-
try { process.kill(Number(pid), 0); return true; } catch { return
|
|
111
|
+
try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function processGroupAlive(pid) {
|
|
115
|
+
try { process.kill(-Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
function sleep(ms) {
|
|
@@ -111,7 +120,74 @@ function sleep(ms) {
|
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
function readOwner(lockDir) {
|
|
114
|
-
try {
|
|
123
|
+
try {
|
|
124
|
+
const owner = JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
|
125
|
+
try { return { ...owner, ...JSON.parse(readFileSync(path.join(lockDir, "child.json"), "utf8")) }; } catch { return owner; }
|
|
126
|
+
} catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeTrackedChild(lockDir, owner, child) {
|
|
130
|
+
const file = path.join(lockDir, "child.json");
|
|
131
|
+
const temporary = path.join(lockDir, `.child-${owner.token}.tmp`);
|
|
132
|
+
writeFileSync(temporary, `${JSON.stringify(child)}\n`, { mode: 0o600 });
|
|
133
|
+
renameSync(temporary, file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
|
|
137
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
|
|
138
|
+
const result = platform === "win32" || platform === "win"
|
|
139
|
+
? run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", windowsHide: true })
|
|
140
|
+
: run("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", windowsHide: true });
|
|
141
|
+
if (result.status !== 0) return null;
|
|
142
|
+
const value = Date.parse(String(result.stdout ?? "").trim());
|
|
143
|
+
return Number.isFinite(value) ? value : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function terminateProcessTree(pid, { platform = process.platform, run = spawnSync, kill = process.kill, treeAlive = processGroupAlive, pause = sleep } = {}) {
|
|
147
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return;
|
|
148
|
+
if (platform === "win32" || platform === "win") {
|
|
149
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try { kill(-Number(pid), "SIGTERM"); } catch { try { kill(Number(pid), "SIGTERM"); } catch { return; } }
|
|
153
|
+
pause(500);
|
|
154
|
+
if (treeAlive(pid)) { try { kill(-Number(pid), "SIGKILL"); } catch { try { kill(Number(pid), "SIGKILL"); } catch { /* gone */ } } }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function watchOwnedProcessTree(
|
|
158
|
+
{ root, token, ownerPid, childPid, childStartedAtMs },
|
|
159
|
+
{ alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {},
|
|
160
|
+
) {
|
|
161
|
+
const lockDir = path.join(root, "slot");
|
|
162
|
+
while (alive(ownerPid)) {
|
|
163
|
+
if (readOwner(lockDir)?.token !== token) return false;
|
|
164
|
+
await pause(500);
|
|
165
|
+
}
|
|
166
|
+
const owner = readOwner(lockDir);
|
|
167
|
+
if (owner?.token !== token || Number(owner.childPid) !== Number(childPid)) return false;
|
|
168
|
+
if (alive(childPid)) {
|
|
169
|
+
const observedStart = startedAt(childPid);
|
|
170
|
+
if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(childStartedAtMs)) > 2000) {
|
|
171
|
+
throw new Error(`watcher found reused child pid ${childPid}; refusing termination`);
|
|
172
|
+
}
|
|
173
|
+
terminate(childPid);
|
|
174
|
+
}
|
|
175
|
+
releaseOwned(lockDir, token);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function spawnOwnerWatcher(details) {
|
|
180
|
+
const payload = Buffer.from(JSON.stringify(details)).toString("base64url");
|
|
181
|
+
const watcher = spawn(process.execPath, [SELF, "--watch-owner-base64", payload], {
|
|
182
|
+
detached: true, env: process.env, stdio: "ignore", windowsHide: true,
|
|
183
|
+
});
|
|
184
|
+
watcher.once("error", () => {
|
|
185
|
+
const observedStart = processStartedAt(details.childPid);
|
|
186
|
+
if (Number.isFinite(observedStart) && Math.abs(observedStart - Number(details.childStartedAtMs)) <= 2000) {
|
|
187
|
+
terminateProcessTree(details.childPid);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
watcher.unref();
|
|
115
191
|
}
|
|
116
192
|
|
|
117
193
|
export function acquireHeavyWorkSlot({
|
|
@@ -119,6 +195,7 @@ export function acquireHeavyWorkSlot({
|
|
|
119
195
|
pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
|
|
120
196
|
minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
|
|
121
197
|
maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
|
|
198
|
+
processStartedAt: startedAt = processStartedAt, terminate = terminateProcessTree, startWatcher = spawnOwnerWatcher,
|
|
122
199
|
} = {}) {
|
|
123
200
|
mkdirSync(root, { recursive: true });
|
|
124
201
|
const lockDir = path.join(root, "slot");
|
|
@@ -137,6 +214,14 @@ export function acquireHeavyWorkSlot({
|
|
|
137
214
|
const owner = readOwner(lockDir);
|
|
138
215
|
const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
|
|
139
216
|
if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
|
|
217
|
+
if (owner?.childPid && alive(owner.childPid)) {
|
|
218
|
+
const observedStart = startedAt(owner.childPid);
|
|
219
|
+
if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(owner.childStartedAtMs)) > 2000) {
|
|
220
|
+
throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
|
|
221
|
+
}
|
|
222
|
+
log(`reaping owned process tree pid ${owner.childPid} after owner pid ${owner.pid} exited`);
|
|
223
|
+
terminate(owner.childPid);
|
|
224
|
+
}
|
|
140
225
|
rmSync(lockDir, { recursive: true, force: true });
|
|
141
226
|
continue;
|
|
142
227
|
}
|
|
@@ -160,7 +245,22 @@ export function acquireHeavyWorkSlot({
|
|
|
160
245
|
if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
|
|
161
246
|
pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
|
|
162
247
|
}
|
|
163
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
root,
|
|
250
|
+
trackChild(childPid, childCommand = "") {
|
|
251
|
+
const owner = readOwner(lockDir);
|
|
252
|
+
if (owner?.token !== token) throw new Error("heavy-work slot ownership changed before child tracking");
|
|
253
|
+
const childStartedAtMs = startedAt(childPid);
|
|
254
|
+
if (!Number.isFinite(childStartedAtMs)) {
|
|
255
|
+
if (!alive(childPid)) return false;
|
|
256
|
+
throw new Error(`heavy-work could not verify child pid ${childPid} start time`);
|
|
257
|
+
}
|
|
258
|
+
writeTrackedChild(lockDir, owner, { childPid: Number(childPid), childCommand: String(childCommand), childStartedAtMs });
|
|
259
|
+
startWatcher({ root, token, ownerPid: Number(pid), childPid: Number(childPid), childStartedAtMs });
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
release: () => releaseOwned(lockDir, token),
|
|
263
|
+
};
|
|
164
264
|
}
|
|
165
265
|
|
|
166
266
|
function releaseOwned(lockDir, token) {
|
|
@@ -183,17 +283,35 @@ export async function runHeavyCommand(args, { env = process.env } = {}) {
|
|
|
183
283
|
if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
|
|
184
284
|
if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
|
|
185
285
|
const slot = acquireHeavyWorkSlot();
|
|
186
|
-
try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env)); } finally { slot.release(); }
|
|
286
|
+
try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env), slot); } finally { slot.release(); }
|
|
187
287
|
}
|
|
188
288
|
|
|
189
|
-
function spawnAttached(command, args, env) {
|
|
289
|
+
function spawnAttached(command, args, env, slot) {
|
|
190
290
|
return new Promise((resolve, reject) => {
|
|
191
|
-
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true });
|
|
192
|
-
|
|
193
|
-
|
|
291
|
+
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
|
|
292
|
+
try {
|
|
293
|
+
if (child.pid && slot) slot.trackChild(child.pid, command);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
child.once("error", () => {});
|
|
296
|
+
if (child.pid) terminateProcessTree(child.pid);
|
|
297
|
+
reject(error);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const handlers = new Map(["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => [signal, () => {
|
|
301
|
+
if (child.pid) terminateProcessTree(child.pid);
|
|
302
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
303
|
+
}]));
|
|
304
|
+
for (const [signal, handler] of handlers) process.once(signal, handler);
|
|
305
|
+
const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
|
|
306
|
+
child.once("error", (error) => { cleanup(); reject(error); });
|
|
307
|
+
child.once("exit", (code, signal) => { cleanup(); resolve(code ?? (signal ? 1 : 0)); });
|
|
194
308
|
});
|
|
195
309
|
}
|
|
196
310
|
|
|
197
|
-
if (process.argv[1] && path.resolve(process.argv[1]) ===
|
|
198
|
-
|
|
311
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
|
|
312
|
+
const args = process.argv.slice(2);
|
|
313
|
+
const task = args[0] === "--watch-owner-base64"
|
|
314
|
+
? watchOwnedProcessTree(JSON.parse(Buffer.from(args[1] || "", "base64url").toString("utf8"))).then(() => 0)
|
|
315
|
+
: runHeavyCommand(args);
|
|
316
|
+
task.then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
|
|
199
317
|
}
|
package/heavy-command.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { mkdtempSync } from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
@@ -8,6 +8,7 @@ import test from "node:test";
|
|
|
8
8
|
import {
|
|
9
9
|
acquireHeavyWorkSlot,
|
|
10
10
|
formatResourceSnapshot,
|
|
11
|
+
heavyChildDetached,
|
|
11
12
|
heavyCommandEnvironment,
|
|
12
13
|
heavyWorkRoot,
|
|
13
14
|
parseMacResourceSnapshot,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
resourceBlockers,
|
|
16
17
|
runHeavyCommand,
|
|
17
18
|
systemResourceSnapshot,
|
|
19
|
+
terminateProcessTree,
|
|
20
|
+
watchOwnedProcessTree,
|
|
18
21
|
} from "./heavy-command.mjs";
|
|
19
22
|
|
|
20
23
|
test("heavy-work root is machine-wide rather than repository-local", () => {
|
|
@@ -30,6 +33,12 @@ test("heavy commands default compiler and test concurrency to two", () => {
|
|
|
30
33
|
assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
|
|
31
34
|
});
|
|
32
35
|
|
|
36
|
+
test("only the slot owner creates a new POSIX process group", () => {
|
|
37
|
+
assert.equal(heavyChildDetached({ slot: {}, platform: "mac" }), true);
|
|
38
|
+
assert.equal(heavyChildDetached({ slot: null, platform: "mac" }), false);
|
|
39
|
+
assert.equal(heavyChildDetached({ slot: {}, platform: "win" }), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
test("mac resource parser reports memory, swap and thermal limiting", () => {
|
|
34
43
|
const snapshot = parseMacResourceSnapshot({
|
|
35
44
|
memory: "System-wide memory free percentage: 9%",
|
|
@@ -76,6 +85,15 @@ test("nested heavy command preserves ownership without another slot", async () =
|
|
|
76
85
|
assert.equal(code, 0);
|
|
77
86
|
});
|
|
78
87
|
|
|
88
|
+
test("owned process-tree termination targets a POSIX group or Windows task tree", () => {
|
|
89
|
+
const signals = [];
|
|
90
|
+
terminateProcessTree(202, { platform: "mac", kill: (pid, signal) => signals.push([pid, signal]), treeAlive: () => true, pause: () => {} });
|
|
91
|
+
assert.deepEqual(signals, [[-202, "SIGTERM"], [-202, "SIGKILL"]]);
|
|
92
|
+
const calls = [];
|
|
93
|
+
terminateProcessTree(303, { platform: "win", run: (command, args) => calls.push([command, args]) });
|
|
94
|
+
assert.deepEqual(calls, [["taskkill", ["/PID", "303", "/T", "/F"]]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
79
97
|
test("slot serializes contenders and removes stale owners", () => {
|
|
80
98
|
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
|
|
81
99
|
const quiet = () => {};
|
|
@@ -93,3 +111,54 @@ test("slot serializes contenders and removes stale owners", () => {
|
|
|
93
111
|
const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
|
|
94
112
|
replacement.release();
|
|
95
113
|
});
|
|
114
|
+
|
|
115
|
+
test("dead slot owner reaps its recorded matching child tree", () => {
|
|
116
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-owned-"));
|
|
117
|
+
const killed = [];
|
|
118
|
+
const options = {
|
|
119
|
+
root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
|
|
120
|
+
alive: (pid) => pid === 202,
|
|
121
|
+
processStartedAt: (pid) => pid === 202 ? 1234 : null,
|
|
122
|
+
terminate: (pid) => killed.push(pid),
|
|
123
|
+
startWatcher: () => {},
|
|
124
|
+
};
|
|
125
|
+
const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
|
|
126
|
+
first.trackChild(202, "cargo");
|
|
127
|
+
assert.equal(JSON.parse(readFileSync(path.join(root, "slot", "child.json"), "utf8")).childPid, 202);
|
|
128
|
+
const replacement = acquireHeavyWorkSlot({ ...options, pid: 404 });
|
|
129
|
+
assert.deepEqual(killed, [202]);
|
|
130
|
+
replacement.release();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("dead slot owner refuses to kill a reused child PID", () => {
|
|
134
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-reused-"));
|
|
135
|
+
let observedStart = 1234;
|
|
136
|
+
const options = {
|
|
137
|
+
root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
|
|
138
|
+
alive: (pid) => pid === 202,
|
|
139
|
+
processStartedAt: (pid) => pid === 202 ? observedStart : null,
|
|
140
|
+
terminate: () => assert.fail("reused PID must not be terminated"),
|
|
141
|
+
startWatcher: () => {},
|
|
142
|
+
};
|
|
143
|
+
const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
|
|
144
|
+
first.trackChild(202, "cargo");
|
|
145
|
+
observedStart = 5678;
|
|
146
|
+
assert.throws(() => acquireHeavyWorkSlot({ ...options, pid: 404 }), /identity changed/);
|
|
147
|
+
first.release();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
|
|
151
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
|
|
152
|
+
const lockDir = path.join(root, "slot");
|
|
153
|
+
mkdirSync(lockDir);
|
|
154
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, token: "owned" }));
|
|
155
|
+
writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 1234 }));
|
|
156
|
+
const killed = [];
|
|
157
|
+
const result = await watchOwnedProcessTree(
|
|
158
|
+
{ root, token: "owned", ownerPid: 101, childPid: 202, childStartedAtMs: 1234 },
|
|
159
|
+
{ alive: (pid) => pid === 202, startedAt: () => 1234, terminate: (pid) => killed.push(pid), pause: async () => {} },
|
|
160
|
+
);
|
|
161
|
+
assert.equal(result, true);
|
|
162
|
+
assert.deepEqual(killed, [202]);
|
|
163
|
+
assert.equal(existsSync(lockDir), false);
|
|
164
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.52",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-invocation.mjs
CHANGED
|
@@ -16,7 +16,16 @@ function canonical(file) {
|
|
|
16
16
|
|
|
17
17
|
export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
18
18
|
const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
19
|
-
const
|
|
19
|
+
const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
|
|
20
|
+
const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
|
|
21
|
+
cwd: repoRoot, encoding: "utf8", windowsHide: true,
|
|
22
|
+
});
|
|
23
|
+
const primary = resolvePrimaryWorktree({
|
|
24
|
+
repoRoot,
|
|
25
|
+
worktrees,
|
|
26
|
+
gitDir,
|
|
27
|
+
coreWorktree: configured.status === 0 ? configured.stdout.trim() : "",
|
|
28
|
+
});
|
|
20
29
|
if (!primary) throw new Error("unable to resolve the primary Git worktree");
|
|
21
30
|
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
31
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
@@ -25,6 +34,16 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
|
25
34
|
if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
export function resolvePrimaryWorktree({ repoRoot, worktrees, gitDir, coreWorktree }) {
|
|
38
|
+
const listed = worktrees.match(/^worktree (.+)$/m)?.[1];
|
|
39
|
+
if (!listed) return undefined;
|
|
40
|
+
const resolvedGitDir = path.resolve(repoRoot, gitDir);
|
|
41
|
+
if (path.resolve(listed) === resolvedGitDir && coreWorktree) {
|
|
42
|
+
return path.resolve(resolvedGitDir, coreWorktree);
|
|
43
|
+
}
|
|
44
|
+
return listed;
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
export function resolveConfiguredBuildInputs(config, target, label = "release config") {
|
|
29
48
|
const configured = target?.buildInputs ?? config?.buildInputs;
|
|
30
49
|
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolvePrimaryWorktree } from "./release-invocation.mjs";
|
|
4
|
+
|
|
5
|
+
test("resolves a submodule primary worktree from core.worktree", () => {
|
|
6
|
+
assert.equal(resolvePrimaryWorktree({
|
|
7
|
+
repoRoot: "/suite/membrane",
|
|
8
|
+
worktrees: "worktree /suite/.git/modules/membrane\nHEAD abc\n",
|
|
9
|
+
gitDir: "/suite/.git/modules/membrane",
|
|
10
|
+
coreWorktree: "../../../membrane",
|
|
11
|
+
}), "/suite/membrane");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("preserves an ordinary primary worktree", () => {
|
|
15
|
+
assert.equal(resolvePrimaryWorktree({
|
|
16
|
+
repoRoot: "/suite/app",
|
|
17
|
+
worktrees: "worktree /suite/app\nHEAD abc\n",
|
|
18
|
+
gitDir: "/suite/app/.git",
|
|
19
|
+
coreWorktree: "",
|
|
20
|
+
}), "/suite/app");
|
|
21
|
+
});
|
|
@@ -8,6 +8,7 @@ import test, { after } from "node:test";
|
|
|
8
8
|
import {
|
|
9
9
|
assertNoRightKitCargoOverrides,
|
|
10
10
|
assertPublishedRightKitCargoDependencies,
|
|
11
|
+
cargoExecutable,
|
|
11
12
|
validateRightKitCargoContract,
|
|
12
13
|
} from "./cargo-contract.mjs";
|
|
13
14
|
import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
|
|
@@ -36,6 +37,13 @@ const apps = [
|
|
|
36
37
|
{ key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
|
|
37
38
|
];
|
|
38
39
|
|
|
40
|
+
// ScreenRight is intentionally a macOS-only AppKit consumer. It has no Tauri
|
|
41
|
+
// manifest or Windows target, so keep it in an explicit registry lane rather
|
|
42
|
+
// than manufacturing unsupported platform entries.
|
|
43
|
+
const macOnlyApps = [
|
|
44
|
+
{ key: "screenright", root: "tools/screenright", releaseFiles: ["scripts/release-package.mjs"] },
|
|
45
|
+
];
|
|
46
|
+
|
|
39
47
|
function assertReleasePackageScripts(scripts, label) {
|
|
40
48
|
for (const platform of ["mac", "win"]) {
|
|
41
49
|
assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
|
|
@@ -442,10 +450,10 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
442
450
|
"@rightkit/legal": "0.3.0",
|
|
443
451
|
"@rightkit/legal-ui": "0.1.0",
|
|
444
452
|
"@rightkit/license": "0.1.6",
|
|
445
|
-
"@rightkit/release": "0.2.
|
|
453
|
+
"@rightkit/release": "0.2.52",
|
|
446
454
|
});
|
|
447
455
|
assert.deepEqual(versions.legacyNpm, {
|
|
448
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49"],
|
|
456
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"],
|
|
449
457
|
});
|
|
450
458
|
assert.ok(
|
|
451
459
|
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
|
|
@@ -466,6 +474,11 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
466
474
|
getCurrentCargoVersionContract();
|
|
467
475
|
});
|
|
468
476
|
|
|
477
|
+
test("Cargo metadata uses the native Windows executable", () => {
|
|
478
|
+
assert.equal(cargoExecutable("win32"), "cargo.exe");
|
|
479
|
+
assert.equal(cargoExecutable("darwin"), "cargo");
|
|
480
|
+
});
|
|
481
|
+
|
|
469
482
|
test("license v2 public vector is identical at every portable consumer boundary", () => {
|
|
470
483
|
const canonical = readFileSync(
|
|
471
484
|
path.join(workspace, "tools/rightkit/crates/rightkit-license/test-vectors/license-v2.json"),
|
|
@@ -608,6 +621,45 @@ for (const app of apps) {
|
|
|
608
621
|
});
|
|
609
622
|
}
|
|
610
623
|
|
|
624
|
+
for (const app of macOnlyApps) {
|
|
625
|
+
test(`${app.key} follows the macOS-only AppKit Right Release contract`, async () => {
|
|
626
|
+
const root = path.join(workspace, app.root);
|
|
627
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
628
|
+
assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
|
|
629
|
+
assert.equal(pkg.scripts["release:build:mac"], "right-release build --platform mac");
|
|
630
|
+
assert.equal(pkg.scripts["release:upload:patch:mac"], "right-release upload --platform mac --tier patch");
|
|
631
|
+
assert.equal(pkg.scripts["release:build:win"], undefined, `${app.key} must not fake a Windows target`);
|
|
632
|
+
assert.equal(pkg.scripts["release:upload:patch:win"], undefined, `${app.key} must not fake a Windows target`);
|
|
633
|
+
assert.equal(pkg.scripts["release:upload:update:mac"], undefined, `${app.key} must not expose ungated feature-update upload`);
|
|
634
|
+
assert.equal(pkg.scripts["release:upload:update:win"], undefined);
|
|
635
|
+
|
|
636
|
+
const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?mac-only=${Date.now()}`)).default;
|
|
637
|
+
assert.equal(config.app, app.key);
|
|
638
|
+
assert.deepEqual(Object.keys(config.targets), ["mac"]);
|
|
639
|
+
assert.equal(config.targets.mac.signed, true);
|
|
640
|
+
assert.deepEqual(config.targets.mac.publish, {
|
|
641
|
+
cmd: "right-release",
|
|
642
|
+
args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
|
|
643
|
+
});
|
|
644
|
+
assert.equal(config.targets.mac.updater.artifacts.length, 1);
|
|
645
|
+
const updater = config.targets.mac.updater.artifacts[0];
|
|
646
|
+
assert.equal(updater.platform, "darwin-aarch64");
|
|
647
|
+
assert.match(updater.file, /\.dmg$/);
|
|
648
|
+
assert.equal(updater.signature, `${updater.file}.sig`);
|
|
649
|
+
assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
|
|
650
|
+
assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
|
|
651
|
+
assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
|
|
652
|
+
assert.ok(config.targets.mac.installer.artifacts.length >= 1);
|
|
653
|
+
assert.ok(config.checks.includes("legal:check"), `${app.key} must gate generated legal notices`);
|
|
654
|
+
assert.deepEqual(config.deps, { workdirs: ["."] });
|
|
655
|
+
assert.equal(existsSync(path.join(root, "src-tauri", "tauri.conf.json")), false, `${app.key} must not invent a Tauri manifest`);
|
|
656
|
+
for (const releaseFile of app.releaseFiles) {
|
|
657
|
+
const source = readFileSync(path.join(root, releaseFile), "utf8");
|
|
658
|
+
assert.match(source, /mirror-root-artifact/);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
611
663
|
test("HeardRight and ScrapeRight expose one locked ASR promotion adapter contract", async () => {
|
|
612
664
|
const heard = (await import(`${pathToFileURL(path.join(workspace, "heardright/tauri-app-next/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
|
|
613
665
|
const scrape = (await import(`${pathToFileURL(path.join(workspace, "scraperight/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
|
package/rightkit-versions.json
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"@rightkit/legal": "0.3.0",
|
|
16
16
|
"@rightkit/legal-ui": "0.1.0",
|
|
17
17
|
"@rightkit/license": "0.1.6",
|
|
18
|
-
"@rightkit/release": "0.2.
|
|
18
|
+
"@rightkit/release": "0.2.52"
|
|
19
19
|
},
|
|
20
20
|
"legacyNpm": {
|
|
21
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49"]
|
|
21
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"]
|
|
22
22
|
},
|
|
23
23
|
"cargo": {
|
|
24
24
|
"rightkit-license": "0.1.2",
|