@rightkit/release 0.6.8 → 0.6.11
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 +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// `install.kind: "assembled-app"` for `right-release dev`: assemble the composite app declared in `assembly.<mac|win>` from the
|
|
2
|
+
// dev-profile build outputs and install it IN PLACE at the one fixed path, with no backup of the old copy
|
|
3
|
+
// (Orthic-Labs/rightkit#348). macOS signs with the stable Developer ID; Windows signs the payload exes through sign-windows.
|
|
4
|
+
//
|
|
5
|
+
// macOS (installAssembledMac): resolve DerivedData (rustright storage) -> xcodegen + xcodebuild the outer app -> assemble in a
|
|
6
|
+
// private temp folder -> sign inside-out (signMacosDevelopmentBundle) -> VERIFY THE STAGED BUNDLE (strict deep, required files,
|
|
7
|
+
// launchd BundleProgram, identity assertions) -> same designated requirement as the installed copy -> installApp (dev) ->
|
|
8
|
+
// verify the installed copy. Nothing touches the destination before the staged bundle verified.
|
|
9
|
+
//
|
|
10
|
+
// Windows (installAssembledWindows): assemble the payload at `<installDirectory>.incoming` -> sign it -> prove it -> lock ->
|
|
11
|
+
// stop the background task and every process in the install folder -> rename the folder aside, the payload in -> restart ->
|
|
12
|
+
// delete the old folder. Failures before the swap leave the install untouched.
|
|
13
|
+
//
|
|
14
|
+
// Every external effect is injectable through `deps`; the defaults are the real RightKit pieces.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import nodeFs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { spawn, spawnSync } from "./background-process.mjs";
|
|
21
|
+
import { assertSignedIdentities, assertStableDesignatedRequirement, readDevSigningSettings, signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
22
|
+
import { installApp } from "./install-app.mjs";
|
|
23
|
+
import { previousPathOf } from "./in-place-install.mjs";
|
|
24
|
+
import { resolveXcodeStorage } from "./ios-host.mjs";
|
|
25
|
+
import { resolveTargetRoot } from "./cargo-target.mjs";
|
|
26
|
+
import { ASSEMBLED_APP_KIND, AssembledAppError, assembleMacBundle, assembleWindowsPayload, containedRelative, loadStageReceipt, platformPath, resolveAssembly, resolveNestedApp, verifyMacBundle, verifyWindowsPayload } from "./assembled-app.mjs";
|
|
27
|
+
|
|
28
|
+
export { ASSEMBLED_APP_KIND };
|
|
29
|
+
const P = path.posix;
|
|
30
|
+
const refuse = (code, message, details) => { throw new AssembledAppError(code, message, details); };
|
|
31
|
+
const invalid = (message) => refuse("ASSEMBLY_CONFIG_INVALID", message);
|
|
32
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
const text = (result) => `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.trim();
|
|
34
|
+
const digest = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
35
|
+
|
|
36
|
+
const MAC_KEYS = ["kind", "destination", "bundleId", "identifiers", "identifierTemplate", "entitlements", "assert", "assertTeamSignedNested", "timestamp", "verifyBinaries"];
|
|
37
|
+
const WIN_KEYS = ["kind", "installDirectory", "stop", "restart", "carryOver"];
|
|
38
|
+
const SAME_IDENTITY_RULE = "dev builds use the SAME install path and bundle id as production (else permission issues), replaced in place with no backup copy";
|
|
39
|
+
|
|
40
|
+
function assertKeys(object, allowed, where) {
|
|
41
|
+
if (!isObject(object)) invalid(`${where} must be an object`);
|
|
42
|
+
const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
|
|
43
|
+
if (unknown.length) invalid(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: ${allowed.join(", ")})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The macOS install descriptor, validated against the assembly it installs. */
|
|
47
|
+
export function resolveAssembledMacDescriptor(descriptor, spec, { config = {} } = {}) {
|
|
48
|
+
assertKeys(descriptor, MAC_KEYS, "development.targets.mac.install");
|
|
49
|
+
if (descriptor.kind !== ASSEMBLED_APP_KIND) invalid(`development.targets.mac.install.kind must be "${ASSEMBLED_APP_KIND}"`);
|
|
50
|
+
if (typeof descriptor.destination !== "string" || !P.isAbsolute(descriptor.destination) || !descriptor.destination.endsWith(".app") || descriptor.destination.split("/").includes("..")) invalid("install.destination must be an absolute /…/<Product>.app path");
|
|
51
|
+
const destination = P.normalize(descriptor.destination);
|
|
52
|
+
if (destination !== `/Applications/${spec.productName}.app`) invalid(`install.destination ${destination} is not the production install path /Applications/${spec.productName}.app: ${SAME_IDENTITY_RULE}`);
|
|
53
|
+
if (descriptor.bundleId !== undefined && descriptor.bundleId !== spec.bundleId) invalid(`install.bundleId ${descriptor.bundleId} differs from assembly.mac.bundleId ${spec.bundleId}: ${SAME_IDENTITY_RULE}`);
|
|
54
|
+
const binaries = descriptor.verifyBinaries ?? [];
|
|
55
|
+
if (!Array.isArray(binaries) || binaries.some((item) => typeof item !== "string")) invalid("install.verifyBinaries must be bundle-relative file paths");
|
|
56
|
+
const verifyBinaries = binaries.map((item, index) => containedRelative(item, `install.verifyBinaries[${index}]`));
|
|
57
|
+
try { readDevSigningSettings({ ...descriptor, bundleId: spec.bundleId }, config ?? {}); }
|
|
58
|
+
catch (error) { invalid(error.message); }
|
|
59
|
+
return { ...descriptor, destination, bundleId: spec.bundleId, verifyBinaries };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
63
|
+
const STANDARD_LOCAL_APPDATA = /^[A-Za-z]:\\Users\\[^\\]+\\AppData\\Local$/i;
|
|
64
|
+
const REDIRECTION_SEGMENT = /^ClaudeProfiles$/i;
|
|
65
|
+
/**
|
|
66
|
+
* Fail closed when a Windows install root is a redirected profile copy (agent shells on the Dell point LOCALAPPDATA and USERPROFILE at a
|
|
67
|
+
* Claude profile, so the swap would hit a copy while the running install stays). Applies to Windows-shaped paths only (drive letter):
|
|
68
|
+
* an installDirectory that is not under %LOCALAPPDATA% must be absolute and free of a ClaudeProfiles segment; one derived from
|
|
69
|
+
* %LOCALAPPDATA% needs LOCALAPPDATA to be exactly C:\Users\<name>\AppData\Local with no redirection segment.
|
|
70
|
+
*/
|
|
71
|
+
export function assertRealWindowsRoot(template, resolved, env = process.env) {
|
|
72
|
+
const winPath = (value) => String(value).replace(/\//g, "\\").replace(/\\+$/, "");
|
|
73
|
+
const segments = (value) => winPath(value).split("\\");
|
|
74
|
+
const local = env.LOCALAPPDATA;
|
|
75
|
+
const derived = /%LOCALAPPDATA%/i.test(String(template)) && typeof local === "string" && WINDOWS_DRIVE.test(local);
|
|
76
|
+
if (derived) {
|
|
77
|
+
const redirected = segments(local).some((segment) => REDIRECTION_SEGMENT.test(segment));
|
|
78
|
+
if (redirected || !STANDARD_LOCAL_APPDATA.test(winPath(local))) invalid(`install.installDirectory resolves from LOCALAPPDATA=${winPath(local)}, which is a redirected or non-standard profile (expected C:\\Users\\<name>\\AppData\\Local, no ClaudeProfiles segment), so ${winPath(resolved)} would not be the real install; refusing. Give install.installDirectory as an absolute path to the real install, e.g. C:\\Users\\<name>\\AppData\\Local\\Programs\\<Product>`);
|
|
79
|
+
} else if (WINDOWS_DRIVE.test(String(resolved)) && segments(resolved).some((segment) => REDIRECTION_SEGMENT.test(segment))) {
|
|
80
|
+
invalid(`install.installDirectory ${winPath(resolved)} is inside a redirected ClaudeProfiles profile, not the real install; refusing`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const WINDOWS_VARS = { LOCALAPPDATA: "LOCALAPPDATA", PROGRAMFILES: "ProgramFiles" };
|
|
85
|
+
/** Expand `%LOCALAPPDATA%` / `%PROGRAMFILES%` in the install folder; the result must be an absolute non-root path. */
|
|
86
|
+
export function expandInstallDirectory(value, env = process.env, { assertProfile = assertRealWindowsRoot } = {}) {
|
|
87
|
+
if (typeof value !== "string" || !value.trim()) invalid("install.installDirectory is required");
|
|
88
|
+
const expanded = value.replace(/%([A-Za-z]+)%/g, (whole, name) => {
|
|
89
|
+
const key = WINDOWS_VARS[name.toUpperCase()];
|
|
90
|
+
if (!key) invalid(`install.installDirectory may only expand %LOCALAPPDATA% and %PROGRAMFILES% (found ${whole})`);
|
|
91
|
+
const found = env[key] ?? env[name.toUpperCase()];
|
|
92
|
+
if (!found) invalid(`${whole} is not set; cannot resolve install.installDirectory`);
|
|
93
|
+
return found;
|
|
94
|
+
});
|
|
95
|
+
const normalized = path.normalize(expanded);
|
|
96
|
+
assertProfile(value, normalized, env);
|
|
97
|
+
if (!path.isAbsolute(normalized) || path.parse(normalized).root === normalized || normalized.split(/[\\/]/).includes("..")) invalid(`install.installDirectory must be an absolute, non-root folder: ${normalized}`);
|
|
98
|
+
return normalized.replace(/[\\/]+$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The Windows install descriptor, validated; `installDirectory` stays unexpanded (the environment decides at run time). */
|
|
102
|
+
export function resolveAssembledWindowsDescriptor(descriptor, spec) {
|
|
103
|
+
assertKeys(descriptor, WIN_KEYS, "development.targets.win.install");
|
|
104
|
+
if (descriptor.kind !== ASSEMBLED_APP_KIND) invalid(`development.targets.win.install.kind must be "${ASSEMBLED_APP_KIND}"`);
|
|
105
|
+
if (typeof descriptor.installDirectory !== "string" || !descriptor.installDirectory.trim()) invalid("install.installDirectory is required");
|
|
106
|
+
const stop = descriptor.stop ?? {};
|
|
107
|
+
assertKeys(stop, ["scheduledTask", "process", "timeoutMs"], "install.stop");
|
|
108
|
+
for (const key of ["scheduledTask", "process"]) if (stop[key] !== undefined && (typeof stop[key] !== "string" || !stop[key].trim() || /["'`$;&|<>]/.test(stop[key]))) invalid(`install.stop.${key} must be a plain name`);
|
|
109
|
+
if (stop.timeoutMs !== undefined && (!Number.isInteger(stop.timeoutMs) || stop.timeoutMs < 1000 || stop.timeoutMs > 120000)) invalid("install.stop.timeoutMs must be 1000..120000");
|
|
110
|
+
const restart = descriptor.restart ?? {};
|
|
111
|
+
assertKeys(restart, ["scheduledTask", "launch"], "install.restart");
|
|
112
|
+
if (restart.scheduledTask !== undefined && typeof restart.scheduledTask !== "boolean") invalid("install.restart.scheduledTask must be true or false");
|
|
113
|
+
if (restart.scheduledTask && !stop.scheduledTask) invalid("install.restart.scheduledTask needs install.stop.scheduledTask (the task to run again)");
|
|
114
|
+
if (restart.launch !== undefined) containedRelative(restart.launch, "install.restart.launch");
|
|
115
|
+
const carry = descriptor.carryOver ?? [];
|
|
116
|
+
if (!Array.isArray(carry)) invalid("install.carryOver must be an array of file names");
|
|
117
|
+
const carryOver = carry.map((item, index) => containedRelative(item, `install.carryOver[${index}]`));
|
|
118
|
+
if (!spec.sign.length) invalid("the Windows dev install signs the payload exes: assembly.win.sign must list them");
|
|
119
|
+
return { ...descriptor, stop: { timeoutMs: 15000, ...stop }, restart: { ...restart }, carryOver };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The xcodegen + xcodebuild argv commands of the outer app, in order (pure). */
|
|
123
|
+
export function outerBuildCommands({ root, spec, storage, profile }) {
|
|
124
|
+
const outer = spec.outer;
|
|
125
|
+
const commands = [];
|
|
126
|
+
if (outer.projectSpec) commands.push({ cmd: "xcodegen", name: "xcodegen generate", args: ["generate", "--spec", path.resolve(root, outer.projectSpec), "--project", path.resolve(root, path.posix.dirname(outer.project))] });
|
|
127
|
+
commands.push({
|
|
128
|
+
cmd: "xcodebuild", name: `xcodebuild ${profile}`,
|
|
129
|
+
args: ["-quiet", "-project", path.resolve(root, outer.project), "-scheme", outer.scheme, "-configuration", outer.configuration, "-destination", "generic/platform=macOS", "-derivedDataPath", storage.derivedData,
|
|
130
|
+
...(storage.resultBundle ? ["-resultBundlePath", storage.resultBundle] : []), "CODE_SIGNING_ALLOWED=NO", ...Object.entries(outer.extraBuildSettings).map(([name, value]) => `${name}=${value}`), "build"],
|
|
131
|
+
});
|
|
132
|
+
return commands;
|
|
133
|
+
}
|
|
134
|
+
export const outerProductPath = ({ spec, storage }) => path.join(storage.derivedData, "Build", "Products", spec.outer.configuration, spec.outer.product);
|
|
135
|
+
|
|
136
|
+
/** Run the outer build: unset variables removed from the environment, argv only, no shell. */
|
|
137
|
+
export function buildOuterApp({ root, spec, storage, profile, env = process.env, run = spawnSync }) {
|
|
138
|
+
const clean = { ...env };
|
|
139
|
+
for (const name of spec.outer.unsetEnv) delete clean[name];
|
|
140
|
+
for (const command of outerBuildCommands({ root, spec, storage, profile })) {
|
|
141
|
+
const result = run(command.cmd, command.args, { cwd: root, env: clean, stdio: "inherit", windowsHide: true, shell: false });
|
|
142
|
+
if (result.error?.code === "ENOENT") refuse("ASSEMBLY_TOOL_MISSING", `${command.cmd} is not installed`);
|
|
143
|
+
if (result.error || result.status !== 0) refuse("ASSEMBLY_BUILD_FAILED", `${command.name} failed (${result.status ?? result.error?.message})`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveAssemblyStorage({ root, profile, env, run }) {
|
|
148
|
+
return resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(root, ".right-release", "assembled-app", profile) });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- macOS --------------------------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function macDefaults() {
|
|
154
|
+
return {
|
|
155
|
+
fs: nodeFs, run: spawnSync, sign: signMacosDevelopmentBundle, assertRequirement: assertStableDesignatedRequirement, assertIdentities: assertSignedIdentities,
|
|
156
|
+
installApp, storage: resolveAssemblyStorage, resolveTargetRoot, buildOuter: buildOuterApp, assemble: assembleMacBundle,
|
|
157
|
+
makeTemp: () => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-assembled-app-")), log: (line) => console.log(line),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read-only verification of a staged or installed bundle with /usr/bin/codesign (never the door shim). */
|
|
162
|
+
export function verifyMacSignature({ root, bundle, expectedRequirement, env, run, label }) {
|
|
163
|
+
const capture = (args) => run("/usr/bin/codesign", args, { cwd: root, env, encoding: "utf8", windowsHide: true, shell: false });
|
|
164
|
+
const strict = capture(["--verify", "--strict", "--deep", bundle]);
|
|
165
|
+
if (strict.error || strict.status !== 0) refuse("ASSEMBLY_VERIFY_FAILED", `${label} ${bundle} failed codesign --verify --strict --deep: ${strict.error?.message ?? text(strict)}`);
|
|
166
|
+
if (expectedRequirement) {
|
|
167
|
+
const display = capture(["--display", "-r-", bundle]);
|
|
168
|
+
const actual = /^designated => (.+)$/m.exec(text(display))?.[1]?.trim();
|
|
169
|
+
if (display.status !== 0 || actual !== expectedRequirement) refuse("ASSEMBLY_VERIFY_FAILED", `${label} ${bundle} designated requirement ${JSON.stringify(actual)} is not the signed ${JSON.stringify(expectedRequirement)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {{root: string, config: object, descriptor: object, spec: object, env?: object, deps?: object}} options
|
|
175
|
+
* `descriptor` is development.targets.mac.install (already resolved by resolveAssembledMacDescriptor or the raw block).
|
|
176
|
+
*/
|
|
177
|
+
export async function installAssembledMac({ root, config, descriptor: rawDescriptor, spec, env = {}, deps = {} }) {
|
|
178
|
+
const d = { ...macDefaults(), ...deps };
|
|
179
|
+
const descriptor = resolveAssembledMacDescriptor(rawDescriptor, spec, { config });
|
|
180
|
+
const destination = descriptor.destination;
|
|
181
|
+
const storage = d.storage({ root, profile: "dev", env, run: d.run });
|
|
182
|
+
d.buildOuter({ root, spec, storage, profile: "dev", env, run: d.run });
|
|
183
|
+
const outerApp = outerProductPath({ spec, storage });
|
|
184
|
+
const nestedApps = spec.nested.map((entry) => resolveNestedApp({ root, entry, spec, profile: "dev", deps: { fs: d.fs, resolveTargetRoot: d.resolveTargetRoot } }));
|
|
185
|
+
const receipt = loadStageReceipt({ root, spec, profile: "dev", fs: d.fs });
|
|
186
|
+
const tempRoot = d.makeTemp();
|
|
187
|
+
const name = P.basename(destination);
|
|
188
|
+
const staged = path.join(tempRoot, name);
|
|
189
|
+
try {
|
|
190
|
+
d.assemble({ root, spec, profile: "dev", out: staged, outerApp, nestedApps, receipt, env, deps: { fs: d.fs, run: d.run, log: d.log, ...(d.commitCount ? { commitCount: d.commitCount } : {}) } });
|
|
191
|
+
// 1. Sign inside-out with the stable Developer ID (identifiers/entitlements/assertions are the install.* keys).
|
|
192
|
+
const signing = await d.sign({ root, bundle: staged, descriptor, config, env, run: d.run, ...(d.listFiles ? { listFiles: d.listFiles } : {}), ...(d.isMachO ? { isMachO: d.isMachO } : {}) });
|
|
193
|
+
if (signing.adhoc || !signing.designatedRequirement) refuse("ASSEMBLY_SIGNING_UNSTABLE", `signing produced no stable Developer ID designated requirement for ${staged}; refusing to install`);
|
|
194
|
+
// 2. Verify the STAGED bundle before the destination is touched.
|
|
195
|
+
verifyMacBundle({ root, spec, bundle: staged, profile: "dev", signed: false, env, deps: { fs: d.fs, run: d.run } });
|
|
196
|
+
verifyMacSignature({ root, bundle: staged, expectedRequirement: signing.designatedRequirement, env, run: d.run, label: "staged" });
|
|
197
|
+
const stagedDigests = new Map();
|
|
198
|
+
for (const binary of descriptor.verifyBinaries) {
|
|
199
|
+
try { stagedDigests.set(binary, digest(d.fs, platformPath(staged, binary))); }
|
|
200
|
+
catch (error) { refuse("ASSEMBLY_VERIFY_FAILED", `cannot read ${binary} of the staged bundle: ${error.message}`); }
|
|
201
|
+
}
|
|
202
|
+
// 3. Same identity as the installed copy, or fail: a changed designated requirement resets every privacy grant.
|
|
203
|
+
if (d.fs.existsSync(destination)) await d.assertRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run: d.run });
|
|
204
|
+
// 4. Install in place (no backup), then verify the installed copy; a failure removes the bad copy.
|
|
205
|
+
await d.installApp({ app: staged, dev: true, dest: P.dirname(destination), appName: name, bundleId: spec.bundleId, relaunch: null }, { env });
|
|
206
|
+
d.fs.rmSync(previousPathOf(destination), { recursive: true, force: true });
|
|
207
|
+
try {
|
|
208
|
+
verifyMacSignature({ root, bundle: destination, expectedRequirement: signing.designatedRequirement, env, run: d.run, label: "installed" });
|
|
209
|
+
const settings = readDevSigningSettings(descriptor, config ?? {});
|
|
210
|
+
if (settings.assertions.length || settings.assertTeamSignedNested) {
|
|
211
|
+
d.assertIdentities({ root, bundle: destination, settings, teamIdentifier: signing.teamIdentifier, identity: signing.identity, env, run: d.run, label: "installed copy", ...(d.listFiles ? { listFiles: d.listFiles } : {}), ...(d.isMachO ? { isMachO: d.isMachO } : {}) });
|
|
212
|
+
}
|
|
213
|
+
for (const [binary, want] of stagedDigests) {
|
|
214
|
+
let got;
|
|
215
|
+
try { got = digest(d.fs, platformPath(destination, binary)); } catch (error) { refuse("ASSEMBLY_VERIFY_FAILED", `cannot read installed ${binary}: ${error.message}`); }
|
|
216
|
+
if (got !== want) refuse("ASSEMBLY_VERIFY_FAILED", `installed ${binary} does not match the signed staged copy`);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
d.fs.rmSync(destination, { recursive: true, force: true });
|
|
220
|
+
error.rolledBack = "removed-failed-install";
|
|
221
|
+
error.message = `${error.message}; the failed install was removed and no old copy is kept: reinstall from an older DMG`;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
d.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; designated=${signing.designatedRequirement}`);
|
|
225
|
+
return { installed: destination, designatedRequirement: signing.designatedRequirement, identity: signing.identity, teamIdentifier: signing.teamIdentifier, signing };
|
|
226
|
+
} finally { d.fs.rmSync(tempRoot, { recursive: true, force: true }); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- Windows ------------------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const SIGN_WINDOWS = fileURLToPath(new URL("./sign-windows.mjs", import.meta.url));
|
|
232
|
+
const BUSY = ["EBUSY", "EPERM", "EPIPE", "EACCES"];
|
|
233
|
+
const quoted = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
234
|
+
|
|
235
|
+
function windowsDefaults(env) {
|
|
236
|
+
const system = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
237
|
+
const run = spawnSync;
|
|
238
|
+
const ps = (command) => run(path.win32.join(system, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", windowsHide: true, shell: false });
|
|
239
|
+
const schtasks = (args) => run(path.win32.join(system, "System32", "schtasks.exe"), args, { encoding: "utf8", windowsHide: true, shell: false });
|
|
240
|
+
const signTool = (extra, files) => run(process.execPath, [SIGN_WINDOWS, ...extra, ...files], { stdio: "inherit", windowsHide: true, shell: false });
|
|
241
|
+
return {
|
|
242
|
+
fs: nodeFs, run, platform: process.platform, pid: process.pid,
|
|
243
|
+
sleep: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
|
|
244
|
+
isAlive: (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } },
|
|
245
|
+
sign: (files) => signTool([], files), verifySigned: (files) => signTool(["--verify-only"], files),
|
|
246
|
+
isRunning: (exe) => ps(`if (@(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq ${quoted(exe)} }).Count -gt 0) { exit 0 } else { exit 1 }`).status === 0,
|
|
247
|
+
stopInDirectory: (dir) => ps(`Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith(${quoted(`${dir}\\`)}, [StringComparison]::OrdinalIgnoreCase) } | Stop-Process -Force`),
|
|
248
|
+
// Discovery fails closed: a profile folder it cannot read, or a running process whose path it cannot see, throws (unknown is
|
|
249
|
+
// never reported as "no conflict"). The system junctions and Public carry no per-user Programs folder and are skipped.
|
|
250
|
+
peerInstalls: (product, except) => {
|
|
251
|
+
if (process.platform !== "win32") return [];
|
|
252
|
+
const users = "C:\\Users";
|
|
253
|
+
const found = [];
|
|
254
|
+
for (const entry of nodeFs.readdirSync(users, { withFileTypes: true })) {
|
|
255
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || /^(public|default|default user|all users)$/i.test(entry.name)) continue;
|
|
256
|
+
const candidate = path.win32.join(users, entry.name, "AppData", "Local", "Programs", product);
|
|
257
|
+
if (candidate.toLowerCase() === String(except).toLowerCase()) continue;
|
|
258
|
+
try { nodeFs.statSync(candidate); found.push(candidate); }
|
|
259
|
+
catch (error) { if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") throw new Error(`cannot inspect ${candidate} (${error?.code ?? error?.message})`); }
|
|
260
|
+
}
|
|
261
|
+
return found;
|
|
262
|
+
},
|
|
263
|
+
runningDirectories: (processName) => {
|
|
264
|
+
if (process.platform !== "win32") return [];
|
|
265
|
+
const name = quoted(String(processName).replace(/\.exe$/i, ""));
|
|
266
|
+
const out = ps(`try { foreach ($p in @(Get-Process -Name ${name} -ErrorAction SilentlyContinue)) { if ($p.Path) { Split-Path -Parent $p.Path } else { '?' } }; exit 0 } catch { exit 2 }`);
|
|
267
|
+
if (out.error || out.status !== 0) throw new Error(`cannot list running ${processName} (${out.error?.message ?? `exit ${out.status}`})`);
|
|
268
|
+
const dirs = String(out.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
269
|
+
if (dirs.includes("?")) throw new Error(`a running ${processName} hides its path (another user's or an elevated process)`);
|
|
270
|
+
return dirs;
|
|
271
|
+
},
|
|
272
|
+
anyInDirectory: (dir) => ps(`if (@(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith(${quoted(`${dir}\\`)}, [StringComparison]::OrdinalIgnoreCase) }).Count -eq 0) { exit 1 } else { exit 0 }`).status === 0,
|
|
273
|
+
endTask: (name) => schtasks(["/end", "/tn", name]), runTask: (name) => schtasks(["/run", "/tn", name]),
|
|
274
|
+
launch: (exe) => { spawn(exe, [], { detached: true, stdio: "ignore", windowsHide: false, shell: false }).unref(); },
|
|
275
|
+
assemble: assembleWindowsPayload, log: (line) => console.log(line),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const exists = (fs, file) => { try { fs.lstatSync(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } };
|
|
280
|
+
function listFiles(fs, root, dir = root) {
|
|
281
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
282
|
+
const file = path.join(dir, entry.name);
|
|
283
|
+
return entry.isDirectory() ? listFiles(fs, root, file) : [path.relative(root, file).split(path.sep).join("/")];
|
|
284
|
+
}).sort();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The Windows dev install. Synchronous and ordered: failures before the swap leave the install untouched; once the folders are
|
|
289
|
+
* swapped the install is a whole new payload and later problems are reported as `warnings` (the swap is not undone).
|
|
290
|
+
* @returns {{installDirectory: string, files: number, signed: number, warnings: string[], launched: boolean}}
|
|
291
|
+
*/
|
|
292
|
+
export function installAssembledWindows({ root, config, descriptor: rawDescriptor, spec, env = process.env, deps = {} }) {
|
|
293
|
+
const d = { ...windowsDefaults(env), ...deps };
|
|
294
|
+
const descriptor = resolveAssembledWindowsDescriptor(rawDescriptor, spec);
|
|
295
|
+
const fs = d.fs;
|
|
296
|
+
// The profile-shape check is injectable for tests whose fixture LOCALAPPDATA is a temp folder; production always runs it.
|
|
297
|
+
const dir = expandInstallDirectory(descriptor.installDirectory, env, { assertProfile: d.assertProfile ?? assertRealWindowsRoot });
|
|
298
|
+
// A LOCALAPPDATA-derived root must be the real install: another user's Programs\<Product> that holds the running app, or that exists
|
|
299
|
+
// while this root has no install, means the environment is redirected (an agent-shell profile); refuse before anything is touched.
|
|
300
|
+
if (/%LOCALAPPDATA%/i.test(descriptor.installDirectory)) {
|
|
301
|
+
const product = path.win32.basename(dir.replace(/\//g, "\\"));
|
|
302
|
+
const discover = (what, fn) => { try { return (fn() ?? []).map((item) => String(item).toLowerCase()); } catch (error) { return refuse("ASSEMBLY_INSTALL_FAILED", `cannot verify install root ${dir} is the real install: ${what} discovery failed (${error.message}); nothing was touched`); } };
|
|
303
|
+
const peers = discover("peer install", () => d.peerInstalls?.(product, dir));
|
|
304
|
+
if (peers.length) {
|
|
305
|
+
const running = descriptor.stop.process ? discover("running process", () => d.runningDirectories?.(descriptor.stop.process)) : [];
|
|
306
|
+
const here = dir.toLowerCase();
|
|
307
|
+
const elsewhere = running.some((item) => peers.includes(item)) && !running.includes(here);
|
|
308
|
+
if (elsewhere || !exists(fs, dir)) refuse("ASSEMBLY_INSTALL_FAILED", `install root ${dir} (from LOCALAPPDATA=${env.LOCALAPPDATA}) does not hold the running ${product}, but ${peers.join(", ")} does: the environment is redirected; set install.installDirectory to that absolute path`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const incoming = `${dir}.incoming`;
|
|
312
|
+
const replaced = `${dir}.replaced`;
|
|
313
|
+
const lockFile = `${dir}.install-lock`;
|
|
314
|
+
const warnings = [];
|
|
315
|
+
const fail = (message) => refuse("ASSEMBLY_INSTALL_FAILED", `${message}`);
|
|
316
|
+
const removeOrAside = (target) => {
|
|
317
|
+
try { fs.rmSync(target, { recursive: true, force: true }); return true; }
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (!exists(fs, target)) return true;
|
|
320
|
+
try { fs.renameSync(target, `${target}-aside-${Date.now()}`); return true; }
|
|
321
|
+
catch { d.log(`right-release dev: ${target} could not be removed or moved aside (${error.code})`); return false; }
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
// One install at a time: the lock holds this pid; a lock whose process is gone is stale and taken over.
|
|
325
|
+
let locked = false;
|
|
326
|
+
for (let attempt = 0; !locked; attempt += 1) {
|
|
327
|
+
try { fs.writeFileSync(lockFile, String(d.pid), { flag: "wx" }); locked = true; }
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error.code !== "EEXIST" || attempt >= 2) fail(`cannot take the install lock ${lockFile} (${error.code}); the install was not touched`);
|
|
330
|
+
let holder = 0;
|
|
331
|
+
try { holder = Number.parseInt(fs.readFileSync(lockFile, "utf8"), 10); } catch { continue; }
|
|
332
|
+
if (Number.isInteger(holder) && holder > 0 && d.isAlive(holder)) fail(`another install (pid ${holder}) is running; the install was not touched`);
|
|
333
|
+
try { fs.rmSync(lockFile, { force: true }); } catch { /* retry */ }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
// A run that stopped between its two renames: the missing install folder gets its old copy back first.
|
|
338
|
+
if (!exists(fs, dir) && exists(fs, replaced)) { fs.renameSync(replaced, dir); d.log(`right-release dev: put back ${dir} from an install that stopped between its two renames`); }
|
|
339
|
+
for (const leftover of [replaced, incoming, previousPathOf(dir)]) if (!removeOrAside(leftover)) fail(`${leftover} could not be removed or moved aside; the install was not touched`);
|
|
340
|
+
|
|
341
|
+
// 1. Assemble, sign and prove the incoming payload beside the install (same volume); the running install is not touched.
|
|
342
|
+
const abandon = (message) => { removeOrAside(incoming); fail(`${message}; the install was not touched`); };
|
|
343
|
+
let assembled;
|
|
344
|
+
try {
|
|
345
|
+
const receipt = loadStageReceipt({ root, spec, profile: "dev", fs });
|
|
346
|
+
assembled = d.assemble({ root, spec, profile: "dev", out: incoming, receipt, deps: { fs, log: d.log } });
|
|
347
|
+
} catch (error) { removeOrAside(incoming); throw error; }
|
|
348
|
+
const before = listFiles(fs, incoming);
|
|
349
|
+
const signFiles = spec.sign.map((file) => platformPath(incoming, file));
|
|
350
|
+
const unsigned = before.filter((file) => !spec.sign.map((s) => s.toLowerCase()).includes(file.toLowerCase())).map((file) => [file, digest(fs, platformPath(incoming, file))]);
|
|
351
|
+
const signResult = d.sign(signFiles);
|
|
352
|
+
if (signResult?.status !== 0) abandon("signing the payload failed");
|
|
353
|
+
const verifyResult = d.verifySigned(signFiles);
|
|
354
|
+
if (verifyResult?.status !== 0) abandon("Authenticode verification of the incoming payload failed");
|
|
355
|
+
if (listFiles(fs, incoming).join("\n") !== before.join("\n")) abandon("the incoming payload changed its file list while signing");
|
|
356
|
+
for (const [file, want] of unsigned) if (digest(fs, platformPath(incoming, file)) !== want) abandon(`${file} changed while signing`);
|
|
357
|
+
try { verifyWindowsPayload({ spec, payload: incoming, profile: "dev", fs }); } catch (error) { removeOrAside(incoming); throw error; }
|
|
358
|
+
|
|
359
|
+
// 2. Stop: the background task and everything that runs from the install folder.
|
|
360
|
+
const exe = descriptor.stop.process ? path.join(dir, descriptor.stop.process) : null;
|
|
361
|
+
const wasRunning = exe ? d.isRunning(exe) : false;
|
|
362
|
+
if (descriptor.stop.scheduledTask) d.endTask(descriptor.stop.scheduledTask);
|
|
363
|
+
d.stopInDirectory(dir);
|
|
364
|
+
const relaunch = () => {
|
|
365
|
+
if (descriptor.restart.scheduledTask) d.runTask(descriptor.stop.scheduledTask);
|
|
366
|
+
if (wasRunning && descriptor.restart.launch && !d.isRunning(path.join(dir, descriptor.restart.launch))) d.launch(path.join(dir, descriptor.restart.launch));
|
|
367
|
+
};
|
|
368
|
+
const deadline = d.now ? d.now() + descriptor.stop.timeoutMs : Date.now() + descriptor.stop.timeoutMs;
|
|
369
|
+
const nowMs = () => (d.now ? d.now() : Date.now());
|
|
370
|
+
while (exists(fs, dir) && d.anyInDirectory(dir)) {
|
|
371
|
+
if (nowMs() > deadline) { relaunch(); abandon(`processes still run from the install folder after ${descriptor.stop.timeoutMs / 1000} s; the old copy was started again`); }
|
|
372
|
+
d.sleep(500);
|
|
373
|
+
d.stopInDirectory(dir);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 3. Swap by rename; Windows refuses while a process (a chat hook, say) runs from the folder, so retry after stopping it again.
|
|
377
|
+
let movedAside = !exists(fs, dir);
|
|
378
|
+
for (let attempt = 1; !movedAside; attempt += 1) {
|
|
379
|
+
try { fs.renameSync(dir, replaced); movedAside = true; }
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (attempt >= 6 || !BUSY.includes(error.code)) { relaunch(); abandon(`the install folder could not be moved aside (${error.code}); the old copy was started again`); }
|
|
382
|
+
d.log(`right-release dev: swap attempt ${attempt} hit ${error.code}; stopping what runs from the install folder and retrying`);
|
|
383
|
+
d.stopInDirectory(dir);
|
|
384
|
+
d.sleep(700);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
try { fs.renameSync(incoming, dir); }
|
|
388
|
+
catch (error) {
|
|
389
|
+
try { if (exists(fs, replaced)) fs.renameSync(replaced, dir); }
|
|
390
|
+
catch (back) { fail(`the swap failed (${error.code}) and the old copy could not be put back (${back.code}); it is at ${replaced}. Nothing was started; run the install again, which puts it back first`); }
|
|
391
|
+
relaunch();
|
|
392
|
+
abandon(`the new copy could not be moved into place (${error.code}); the old copy was put back and started again`);
|
|
393
|
+
}
|
|
394
|
+
// 4. Installer-owned files the payload lacks (Uninstall.exe) come from the folder that was moved aside, before it is deleted.
|
|
395
|
+
for (const name of descriptor.carryOver) {
|
|
396
|
+
if (assembled.bound.some((item) => item.dest === name) || before.includes(name) || !exists(fs, platformPath(replaced, name)) || exists(fs, platformPath(dir, name))) continue;
|
|
397
|
+
try { fs.renameSync(platformPath(replaced, name), platformPath(dir, name)); } catch (error) { warnings.push(`${name} could not be carried over (${error.code}); the replaced copy is kept at ${replaced}`); }
|
|
398
|
+
}
|
|
399
|
+
// 5. Start the new copy; the old folder goes only when nothing needs it (no backup is kept).
|
|
400
|
+
if (descriptor.restart.scheduledTask) d.runTask(descriptor.stop.scheduledTask);
|
|
401
|
+
let launched = !wasRunning;
|
|
402
|
+
if (wasRunning && descriptor.restart.launch) {
|
|
403
|
+
d.launch(path.join(dir, descriptor.restart.launch));
|
|
404
|
+
for (let waited = 0; waited < 10 && !launched; waited += 1) { d.sleep(1000); launched = d.isRunning(path.join(dir, descriptor.restart.launch)); }
|
|
405
|
+
if (!launched) warnings.push(`the new ${descriptor.restart.launch} did not start within 10 s; the replaced copy is kept at ${replaced}`);
|
|
406
|
+
}
|
|
407
|
+
if (!warnings.length) {
|
|
408
|
+
try { fs.rmSync(replaced, { recursive: true, force: true }); } catch (error) { warnings.push(`${replaced} could not be deleted (${error.code}); the next run removes it`); }
|
|
409
|
+
}
|
|
410
|
+
for (const warning of warnings) d.log(`right-release dev: WARNING (swap succeeded): ${warning}`);
|
|
411
|
+
d.log(`right-release dev: ${dir}: whole payload swapped in (${before.length} files, ${signFiles.length} signed and verified)`);
|
|
412
|
+
return { installDirectory: dir, files: before.length, signed: signFiles.length, warnings, launched };
|
|
413
|
+
} finally { try { fs.rmSync(lockFile, { force: true }); } catch { /* a leftover lock of a dead pid is taken over */ } }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Dispatch used by development.mjs: the validated assembly + descriptor for this platform. */
|
|
417
|
+
export function resolveAssembledInstall({ config, platform }) {
|
|
418
|
+
const spec = resolveAssembly(config, platform);
|
|
419
|
+
const descriptor = config.development.targets[platform].install;
|
|
420
|
+
if (platform === "mac") resolveAssembledMacDescriptor(descriptor, spec, { config });
|
|
421
|
+
else resolveAssembledWindowsDescriptor(descriptor, spec);
|
|
422
|
+
return { spec, descriptor };
|
|
423
|
+
}
|
package/inventory-check.mjs
CHANGED
|
@@ -621,8 +621,8 @@ async function checkThirdParty({ repoRoot, records, live, findings }) {
|
|
|
621
621
|
declarations.get(key).wheres.add(where);
|
|
622
622
|
};
|
|
623
623
|
|
|
624
|
-
|
|
625
|
-
const cargoSources = [
|
|
624
|
+
// Every crate is its own Cargo workspace (rightkit#1285): there is no root manifest, each crate declares its own requirements.
|
|
625
|
+
const cargoSources = [];
|
|
626
626
|
for (const record of records.filter((r) => r.ecosystem === "cargo")) {
|
|
627
627
|
cargoSources.push({ where: relative(repoRoot, join(record.dir, "Cargo.toml")), manifest: record.manifest });
|
|
628
628
|
}
|
|
@@ -673,11 +673,14 @@ async function checkThirdParty({ repoRoot, records, live, findings }) {
|
|
|
673
673
|
});
|
|
674
674
|
|
|
675
675
|
const lockDuplicates = [];
|
|
676
|
-
|
|
677
|
-
|
|
676
|
+
// One lock per crate workspace: look for duplicate resolutions in each crate's own crates/<x>/Cargo.lock.
|
|
677
|
+
for (const record of records.filter((r) => r.ecosystem === "cargo").sort((a, b) => a.dir.localeCompare(b.dir))) {
|
|
678
|
+
const lockFile = relative(repoRoot, join(record.dir, "Cargo.lock"));
|
|
679
|
+
const lockText = await readText(join(repoRoot, lockFile));
|
|
680
|
+
if (lockText === null) continue;
|
|
678
681
|
for (const dup of duplicateCargoVersions(lockText)) {
|
|
679
|
-
lockDuplicates.push(dup);
|
|
680
|
-
findings.push(issue("thirdParty", `crate ${dup.name}`, "duplicate-lock-versions",
|
|
682
|
+
lockDuplicates.push({ ...dup, lock: lockFile });
|
|
683
|
+
findings.push(issue("thirdParty", `crate ${dup.name} (${lockFile})`, "duplicate-lock-versions", `${lockFile} resolves ${dup.versions.join(", ")}`));
|
|
681
684
|
}
|
|
682
685
|
}
|
|
683
686
|
return { declarations: declarations.size, uniquePackages: unique, lockDuplicates };
|
|
@@ -914,8 +917,8 @@ export function renderMarkdown(report) {
|
|
|
914
917
|
section("account");
|
|
915
918
|
lines.push(`## ${SECTIONS.thirdParty}`, "", `Declarations checked: ${report.sections.thirdParty.declarations}. Unique external packages: ${report.sections.thirdParty.uniquePackages}.`, "");
|
|
916
919
|
section("thirdParty");
|
|
917
|
-
lines.push("Multiple versions in
|
|
918
|
-
for (const dup of report.sections.thirdParty.lockDuplicates) lines.push(`- \`${dup.name}\`: ${dup.versions.join(", ")}`);
|
|
920
|
+
lines.push("Multiple versions in a crate's own Cargo.lock (one per crate workspace):", "");
|
|
921
|
+
for (const dup of report.sections.thirdParty.lockDuplicates) lines.push(`- \`${dup.name}\` in \`${dup.lock}\`: ${dup.versions.join(", ")}`);
|
|
919
922
|
if (!report.sections.thirdParty.lockDuplicates.length) lines.push("- none");
|
|
920
923
|
lines.push("");
|
|
921
924
|
lines.push(`## ${SECTIONS.apps}`, "", "| App | Present | Duplicate copies in lock files |", "| --- | --- | ---: |");
|
package/ios-dev.mjs
CHANGED
|
@@ -27,7 +27,8 @@ export class IosDevelopmentError extends Error {
|
|
|
27
27
|
const refuse = (code, message) => { throw new IosDevelopmentError(code, message); };
|
|
28
28
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
29
29
|
|
|
30
|
-
const TARGET_KEYS = ["project", "projectSpec", "scheme", "configuration", "teamId", "extraBuildSettings", "prepare", "install"];
|
|
30
|
+
const TARGET_KEYS = ["project", "projectSpec", "scheme", "configuration", "teamId", "extraBuildSettings", "prepare", "install", "uiTests"];
|
|
31
|
+
const UI_TEST_KEYS = ["scheme", "testPlan", "onlyTesting"];
|
|
31
32
|
const INSTALL_KEYS = ["kind", "device", "bundleId", "productName", "boot", "launch", "launchArgs"];
|
|
32
33
|
const UDID = /^[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$/;
|
|
33
34
|
const DEVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,80}$/;
|
|
@@ -101,7 +102,21 @@ export function validateIosDevelopment(target, { root, env = process.env } = {})
|
|
|
101
102
|
if (descriptor.kind === "device" && !target.teamId) refuse("DEV_IOS_CONFIG_INVALID", "a device build needs development.targets.ios.teamId (there is no built-in default)");
|
|
102
103
|
install = { ...descriptor, productName: descriptor.productName ?? target.scheme, launchArgs: [...(descriptor.launchArgs ?? [])] };
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
// UI test journeys (XCUITest) run by `right-release test --platform ios` on the same device or simulator as the dev install.
|
|
106
|
+
let uiTests = null;
|
|
107
|
+
if (target.uiTests !== undefined) {
|
|
108
|
+
const t = target.uiTests;
|
|
109
|
+
if (!isObject(t)) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.uiTests must be an object");
|
|
110
|
+
const bad = Object.keys(t).filter((key) => !UI_TEST_KEYS.includes(key));
|
|
111
|
+
if (bad.length) refuse("DEV_IOS_CONFIG_INVALID", `development.targets.ios.uiTests has unknown key(s): ${bad.join(", ")} (allowed: ${UI_TEST_KEYS.join(", ")})`);
|
|
112
|
+
if (!install) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.uiTests needs install (the device or simulator the journeys run on)");
|
|
113
|
+
if (!Array.isArray(t.onlyTesting) || !t.onlyTesting.length || t.onlyTesting.some((x) => typeof x !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*(\/[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(x))) {
|
|
114
|
+
refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.uiTests.onlyTesting must list Target[/Class[/method]] journeys (an empty list would run nothing)");
|
|
115
|
+
}
|
|
116
|
+
for (const key of ["scheme", "testPlan"]) if (t[key] !== undefined && (typeof t[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,100}$/.test(t[key]))) refuse("DEV_IOS_CONFIG_INVALID", `development.targets.ios.uiTests.${key} must be a name`);
|
|
117
|
+
uiTests = { scheme: t.scheme ?? target.scheme, ...(t.testPlan ? { testPlan: t.testPlan } : {}), onlyTesting: [...t.onlyTesting] };
|
|
118
|
+
}
|
|
119
|
+
const normalized = { project, projectSpec, scheme: target.scheme, configuration, teamId: target.teamId, extraBuildSettings, prepare, install, uiTests };
|
|
105
120
|
return { ...normalized, build: iosDevelopmentBuildCommands(normalized, { root, storage: { derivedData: "<rustright storage xcode-args -derivedDataPath>" }, destinationId: "<device or simulator id>" }) };
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -170,6 +185,25 @@ export function selectDevice(devices, wanted) {
|
|
|
170
185
|
return { identifier: String(found.identifier ?? found.deviceProperties?.identifier ?? wanted), detail: state.detail };
|
|
171
186
|
}
|
|
172
187
|
|
|
188
|
+
/** CoreDevice drops an idle tunnel within seconds to minutes and listing does not re-establish it (rightkit#1228): a paired, connected
|
|
189
|
+
* iPhone reads tunnel=disconnected. A disconnected device is woken (`devicectl device info details` brings the tunnel up) and read
|
|
190
|
+
* again; it is refused only when it is still not ready. An unavailable device (not reachable) is refused at once. */
|
|
191
|
+
export function selectReadyDevice({ list, wake, wanted }) {
|
|
192
|
+
const devices = list();
|
|
193
|
+
const physical = devices.filter((device) => (device.deviceProperties?.name ?? device.name) !== "My Mac" && device.hardwareProperties?.deviceType !== "Mac");
|
|
194
|
+
const found = physical.find((device) => [device.identifier, device.deviceProperties?.identifier, device.hardwareProperties?.udid, device.deviceProperties?.name, device.name].includes(wanted));
|
|
195
|
+
if (found && !deviceReadiness(found).ready && found.connectionProperties?.tunnelState === "disconnected") {
|
|
196
|
+
wake(String(found.identifier ?? found.deviceProperties?.identifier ?? wanted));
|
|
197
|
+
return selectDevice(list(), wanted);
|
|
198
|
+
}
|
|
199
|
+
return selectDevice(devices, wanted);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function wakeTunnel({ run, id }) {
|
|
203
|
+
// Its result is not trusted: the re-read list decides. A failure here only means the device stays not ready.
|
|
204
|
+
xcrun(run, ["devicectl", "device", "info", "details", "--device", id, "--timeout", "30"], { timeout: 90_000 });
|
|
205
|
+
}
|
|
206
|
+
|
|
173
207
|
function listDevices({ run, fs, tmpRoot }) {
|
|
174
208
|
const dir = fs.mkdtempSync(path.join(tmpRoot, "rightkit-ios-dev-"));
|
|
175
209
|
try {
|
|
@@ -193,7 +227,7 @@ export function prepareIosDevelopment(dev, { root, env = process.env, run = spaw
|
|
|
193
227
|
const simulator = selectSimulator(listSimulators(run), wanted);
|
|
194
228
|
resolved = { id: simulator.udid, state: simulator.state, name: simulator.name };
|
|
195
229
|
} else {
|
|
196
|
-
const device =
|
|
230
|
+
const device = selectReadyDevice({ list: () => listDevices({ run, fs, tmpRoot }), wake: (id) => wakeTunnel({ run, id }), wanted });
|
|
197
231
|
resolved = { id: device.identifier, detail: device.detail };
|
|
198
232
|
}
|
|
199
233
|
}
|
|
@@ -206,6 +240,39 @@ export function prepareIosDevelopment(dev, { root, env = process.env, run = spaw
|
|
|
206
240
|
// read as an option of simctl or devicectl (the standard ArgumentParser terminator; unverified on a live Mac, see the docs).
|
|
207
241
|
const launchTail = (args) => (args.length ? ["--", ...args] : []);
|
|
208
242
|
|
|
243
|
+
// ---- UI test journeys -------------------------------------------------------------------------------------------
|
|
244
|
+
/**
|
|
245
|
+
* Pure: `xcodebuild build-for-testing` then `test-without-building` of the declared journeys on the resolved device or simulator,
|
|
246
|
+
* with the same project, configuration, signing and derived data as the dev build. The result bundle is a fresh path per run.
|
|
247
|
+
*/
|
|
248
|
+
export function iosUiTestCommands(dev, { root, storage, destinationId, resultBundle }) {
|
|
249
|
+
if (!dev.uiTests) throw new Error("development.targets.ios.uiTests is not declared");
|
|
250
|
+
const device = dev.install?.kind === "device";
|
|
251
|
+
const common = [
|
|
252
|
+
"-project", path.resolve(root, dev.project),
|
|
253
|
+
"-scheme", dev.uiTests.scheme,
|
|
254
|
+
...(dev.uiTests.testPlan ? ["-testPlan", dev.uiTests.testPlan] : []),
|
|
255
|
+
"-configuration", dev.configuration,
|
|
256
|
+
"-destination", destinationFor(dev.install, destinationId),
|
|
257
|
+
"-derivedDataPath", storage.derivedData,
|
|
258
|
+
...(dev.teamId ? [`DEVELOPMENT_TEAM=${dev.teamId}`] : []),
|
|
259
|
+
...Object.entries(dev.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
|
|
260
|
+
...(device ? ["-allowProvisioningUpdates", "-allowProvisioningDeviceRegistration"] : []),
|
|
261
|
+
];
|
|
262
|
+
return [
|
|
263
|
+
{ cmd: "xcodebuild", name: "xcodebuild build-for-testing", args: [...common, "build-for-testing"] },
|
|
264
|
+
{ cmd: "xcodebuild", name: "xcodebuild test-without-building", args: [...common, "-resultBundlePath", resultBundle, ...dev.uiTests.onlyTesting.map((x) => `-only-testing:${x}`), "test-without-building"] },
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Resolve the device (waking a sleeping tunnel), then the UI test commands; the caller runs them under its lease. */
|
|
269
|
+
export function prepareIosUiTests(dev, { root, env = process.env, run = spawnSync, fs = nodeFs, serial, tmpRoot = os.tmpdir(), now = Date.now }) {
|
|
270
|
+
const prepared = prepareIosDevelopment(dev, { root, env, run, fs, serial, tmpRoot });
|
|
271
|
+
const base = prepared.storage.resultBundle ? path.dirname(prepared.storage.resultBundle) : path.join(root, ".right-release", "ios-dev");
|
|
272
|
+
const resultBundle = path.join(base, `ui-tests-${now()}.xcresult`);
|
|
273
|
+
return { ...prepared, resultBundle, tests: iosUiTestCommands(dev, { root, storage: prepared.storage, destinationId: prepared.install.resolved?.id, resultBundle }) };
|
|
274
|
+
}
|
|
275
|
+
|
|
209
276
|
// ---- install ------------------------------------------------------------------------------------------------------
|
|
210
277
|
/** `run` is spawnSync-shaped (tests inject a fake). Every step is bounded; the first failure stops and throws. */
|
|
211
278
|
export function installIosDevelopment(install, { root, env = process.env, run = spawnSync, fs = nodeFs, tmpRoot = os.tmpdir(), log = console.log } = {}) {
|