Merge upstream v1.12.26 - reconnect the fork to Element Web (management #0099)
The repo had no upstream ancestry: a whole tree arrived in one commit in May, so
every update meant re-applying our patches by hand onto a fresh checkout, and a file
Element moved would take our lines with it silently.
The real base was found by measuring tree distance across develop rather than trusting
the changelog: deadd548, not the v1.12.17 tag. With that set as a temporary graft, this
merge computed as a proper three-way merge - 32 conflicts instead of 1757.
Resolutions, each decided rather than defaulted:
- 24 GitHub workflows stay deleted; we build on GitLab CI.
- MImageBody.tsx is gone upstream, migrated to MVVM. Our ClamAV error label moved into
ImageBodyViewModel.computeErrorLabel, ahead of the DecryptError branch, matching what
VideoBodyViewModel and FileBodyViewModel already do.
- Upstream extracted the room list item body into RoomListItemContent. Our call
participants list and its getInitials helper moved there; both sides' CSS classes and
both sides' props are kept.
- matrix-js-sdk follows upstream at 42.2.0 - our git ref pin was a workaround for a
stale ref, and following upstream is the point of this merge.
- Element Call stays ours. Checked before deciding: @element-hq/element-call-embedded
is referenced nowhere in the tree, while webpack.config.ts needs
@sorb/threadnet-call-embedded, so taking upstream's line would have deleted the noise
suppression from #0054 without a word.
The lockfile was regenerated with pnpm 11.20.0, which upstream now requires through
devEngines. CI already runs corepack enable, and onFail: download makes it fetch that
version by itself.
Not yet accepted: this needs a build and the ClamAV functional test - send an encrypted
file, receive a rejected one - before it goes near main.
This commit is contained in:
@@ -1,12 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { exec } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const includeJSSDK = process.argv.includes("--include-js-sdk");
|
||||
const ignore: string[] = [];
|
||||
|
||||
ignore.push(...Object.values<string>(JSON.parse(fs.readFileSync(`${__dirname}/../components.json`, "utf-8"))));
|
||||
ignore.push(
|
||||
...Object.values<string>(
|
||||
JSON.parse(fs.readFileSync(fileURLToPath(import.meta.resolve("../components.json")), "utf-8")),
|
||||
),
|
||||
);
|
||||
ignore.push("/index.ts");
|
||||
ignore.push("/jest-matrix-react.tsx");
|
||||
ignore.push("/customisations/");
|
||||
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
// Finds settings declared in apps/web/src/settings/Settings.tsx which are never
|
||||
// referenced anywhere else in apps/ outside of settings/ directories. This is a
|
||||
// rough heuristic (plain string search over the codebase) rather than a full
|
||||
// type-aware usage analysis, but setting names are unique enough in practice.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import ts from "typescript";
|
||||
|
||||
const ROOT = fileURLToPath(import.meta.resolve(".."));
|
||||
const SETTINGS_DIR = path.join(ROOT, "apps/web/src/settings");
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, "Settings.tsx");
|
||||
|
||||
const EXCLUDE_GLOBS = [
|
||||
// Only the settings *definitions* directory should be excluded from the usage search -
|
||||
// there are plenty of other directories literally named "settings" (e.g.
|
||||
// apps/web/src/components/views/settings/) which hold real usages and must stay included.
|
||||
"apps/web/src/settings/**",
|
||||
"*/*/test/**",
|
||||
"*-test.*",
|
||||
"*.test.*",
|
||||
].map((pattern) => path.relative(ROOT, pattern));
|
||||
|
||||
// See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message
|
||||
const SETTINGS_FILE_RELATIVE = path.relative(ROOT, SETTINGS_FILE);
|
||||
|
||||
// Settings that are only ever referenced from inside another setting's `controller: ...`
|
||||
// expression in Settings.tsx (both live in the excluded settings/ directory) are treated as
|
||||
// used. This is detected automatically by scanning controller text for other settings'
|
||||
// search terms, but keep this list as a manual escape hatch for cases the heuristic can't
|
||||
// see (e.g. usage mediated through a helper function rather than a literal reference).
|
||||
const KNOWN_USED_OVERRIDES = new Set<string>([
|
||||
"test_setting", // only used in tests
|
||||
]);
|
||||
|
||||
interface DeclaredSetting {
|
||||
// The literal setting name (as passed to SettingsStore), used for reporting.
|
||||
name: string;
|
||||
// Text(s) that count as "usage" when found via git grep, e.g. both the enum
|
||||
// member reference (`UIFeature.Registration`) and its resolved value.
|
||||
searchTerms: string[];
|
||||
line: number;
|
||||
// Source text of this setting's own `controller: ...` property, if any. Settings are
|
||||
// sometimes only ever read by another setting's controller (e.g. a UIFeatureController
|
||||
// gating a different setting) - see KNOWN_USED_VIA_CONTROLLER below for how this is used.
|
||||
controllerText?: string;
|
||||
}
|
||||
|
||||
function parseFile(filePath: string): ts.SourceFile {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
}
|
||||
|
||||
/** Maps `EnumName.MemberName` -> its string literal value, for every string enum under settings/. */
|
||||
function buildEnumLookup(): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
const files = fs.readdirSync(SETTINGS_DIR).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
||||
|
||||
for (const file of files) {
|
||||
const sourceFile = parseFile(path.join(SETTINGS_DIR, file));
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isEnumDeclaration(node)) {
|
||||
for (const member of node.members) {
|
||||
if (member.initializer && ts.isStringLiteral(member.initializer)) {
|
||||
const memberName = member.name.getText(sourceFile);
|
||||
lookup.set(`${node.name.text}.${memberName}`, member.initializer.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/** Extracts every top-level key of `export const SETTINGS = { ... }` via the TS AST. */
|
||||
function extractSettingNames(enumLookup: Map<string, string>): DeclaredSetting[] {
|
||||
const sourceFile = parseFile(SETTINGS_FILE);
|
||||
const settings: DeclaredSetting[] = [];
|
||||
|
||||
let settingsObject: ts.ObjectLiteralExpression | undefined;
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (
|
||||
ts.isVariableStatement(node) &&
|
||||
node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === "SETTINGS")
|
||||
) {
|
||||
const decl = node.declarationList.declarations.find(
|
||||
(d) => ts.isIdentifier(d.name) && d.name.text === "SETTINGS",
|
||||
)!;
|
||||
if (decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
|
||||
settingsObject = decl.initializer;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!settingsObject) {
|
||||
throw new Error("Could not find `export const SETTINGS` object literal in Settings.tsx");
|
||||
}
|
||||
|
||||
for (const prop of settingsObject.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue;
|
||||
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(prop.getStart(sourceFile)).line + 1;
|
||||
const propName = prop.name;
|
||||
const controllerText = findControllerText(prop.initializer, sourceFile);
|
||||
|
||||
if (ts.isIdentifier(propName) || ts.isStringLiteral(propName)) {
|
||||
const name = propName.text;
|
||||
settings.push({ name, searchTerms: [name], line, controllerText });
|
||||
} else if (ts.isComputedPropertyName(propName)) {
|
||||
const enumRef = propName.expression.getText(sourceFile);
|
||||
const value = enumLookup.get(enumRef);
|
||||
if (!value) {
|
||||
console.warn(`Warning: could not resolve computed setting key [${enumRef}] at Settings.tsx:${line}`);
|
||||
continue;
|
||||
}
|
||||
// Code typically references these via the enum member (e.g. `UIFeature.Registration`)
|
||||
// rather than the resolved string, so both count as usage.
|
||||
settings.push({ name: value, searchTerms: [enumRef, value], line, controllerText });
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Returns the source text of a setting definition's `controller: ...` property, if it has one. */
|
||||
function findControllerText(settingValue: ts.Expression, sourceFile: ts.SourceFile): string | undefined {
|
||||
if (!ts.isObjectLiteralExpression(settingValue)) return undefined;
|
||||
|
||||
for (const member of settingValue.properties) {
|
||||
if (ts.isPropertyAssignment(member) && ts.isIdentifier(member.name) && member.name.text === "controller") {
|
||||
return member.initializer.getText(sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether any of the given search terms appears anywhere under apps/ outside the
|
||||
* settings definitions directory. Relies on the `git` binary being available on PATH.
|
||||
*/
|
||||
function isUsedOutsideSettings(searchTerms: string[]): boolean {
|
||||
const patternArgs = searchTerms.flatMap((term) => ["-e", term]);
|
||||
const excludeArgs = EXCLUDE_GLOBS.map((term) => `:(exclude)${term}`);
|
||||
try {
|
||||
execFileSync("git", ["grep", "-F", "-q", ...patternArgs, "--", "apps", ...excludeArgs], { cwd: ROOT });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (typeof (e as { status?: number }).status === "number") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks whether another setting's `controller` expression references one of this setting's search terms. */
|
||||
function isReferencedByOtherController(setting: DeclaredSetting, allSettings: DeclaredSetting[]): boolean {
|
||||
return allSettings.some(
|
||||
(other) =>
|
||||
other.name !== setting.name &&
|
||||
other.controllerText !== undefined &&
|
||||
setting.searchTerms.some((term) => other.controllerText!.includes(term)),
|
||||
);
|
||||
}
|
||||
|
||||
function printAnnotation(line: number, message: string): void {
|
||||
const escape = (s: string): string => s.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
||||
console.log(`::error file=${SETTINGS_FILE_RELATIVE},line=${line},title=Unused setting::${escape(message)}`);
|
||||
}
|
||||
|
||||
/** De-duplicates settings by name, keeping the first occurrence. */
|
||||
function dedupeSettingsByName(settings: DeclaredSetting[]): Map<string, DeclaredSetting> {
|
||||
const firstByName = new Map<string, DeclaredSetting>();
|
||||
for (const setting of settings) {
|
||||
if (!firstByName.has(setting.name)) firstByName.set(setting.name, setting);
|
||||
}
|
||||
return firstByName;
|
||||
}
|
||||
|
||||
function findUnusedSettings(candidates: Iterable<DeclaredSetting>, allSettings: DeclaredSetting[]): DeclaredSetting[] {
|
||||
const unused: DeclaredSetting[] = [];
|
||||
for (const setting of candidates) {
|
||||
if (KNOWN_USED_OVERRIDES.has(setting.name)) continue;
|
||||
if (isUsedOutsideSettings(setting.searchTerms)) continue;
|
||||
if (isReferencedByOtherController(setting, allSettings)) continue;
|
||||
unused.push(setting);
|
||||
}
|
||||
return unused;
|
||||
}
|
||||
|
||||
function reportUnused(unused: DeclaredSetting[]): void {
|
||||
unused.sort((a, b) => a.line - b.line);
|
||||
console.error(`⛔ Found ${unused.length} setting(s) declared with no usage:\n`);
|
||||
for (const { name, line } of unused) {
|
||||
console.error(` Settings.tsx:${line}: "${name}"`);
|
||||
if (process.env.GITHUB_ACTIONS === "true") {
|
||||
printAnnotation(line, `Setting "${name}" is declared but never used outside ${EXCLUDE_GLOBS}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const enumLookup = buildEnumLookup();
|
||||
const settings = extractSettingNames(enumLookup);
|
||||
const firstByName = dedupeSettingsByName(settings);
|
||||
const unused = findUnusedSettings(firstByName.values(), settings);
|
||||
|
||||
if (unused.length > 0) {
|
||||
reportUnused(unused);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ All ${firstByName.size} settings appear to be used.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
@@ -192,19 +199,19 @@ interface WorkflowYaml {
|
||||
workflow_run?: {
|
||||
workflows: string[];
|
||||
}; // Magic
|
||||
workflow_call?: {}; // Reusable
|
||||
workflow_dispatch?: {}; // Manual
|
||||
pull_request?: {};
|
||||
merge_group?: {};
|
||||
workflow_call?: unknown; // Reusable
|
||||
workflow_dispatch?: unknown; // Manual
|
||||
pull_request?: unknown;
|
||||
merge_group?: unknown;
|
||||
push?: {
|
||||
tags?: string[];
|
||||
branches?: string[];
|
||||
};
|
||||
schedule?: { cron: string }[];
|
||||
release?: {};
|
||||
release?: unknown;
|
||||
//
|
||||
label?: {};
|
||||
issues?: {};
|
||||
label?: unknown;
|
||||
issues?: unknown;
|
||||
};
|
||||
jobs: {
|
||||
[job: string]: {
|
||||
@@ -221,7 +228,7 @@ interface WorkflowYaml {
|
||||
type Trigger = Node;
|
||||
|
||||
// TODO workflow_call reusables
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
const TRIGGERS: {
|
||||
[key in keyof WorkflowYaml["on"]]: (
|
||||
data: NonNullable<WorkflowYaml["on"][key]>,
|
||||
@@ -266,13 +273,12 @@ const TRIGGERS: {
|
||||
// TODO should we be just dropping these?
|
||||
workflow_run: (data) => data.workflows.map((parent) => workflows.get(parent)).filter(Boolean) as Workflow[],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
const triggers = new Map<string, Trigger>(); // keyed by trigger id
|
||||
const projects = new Map<string, Project>(); // keyed by project name
|
||||
const workflows = new Map<string, Workflow>(); // keyed by workflow name
|
||||
|
||||
function getTriggerNodes<K extends keyof WorkflowYaml["on"]>(key: K, workflow: Workflow, on?: string[]): Trigger[] {
|
||||
function getTriggerNodes(key: keyof WorkflowYaml["on"], workflow: Workflow, on?: string[]): Trigger[] {
|
||||
if (!TRIGGERS[key]) return [];
|
||||
|
||||
if (on && !on.includes(key)) {
|
||||
@@ -280,7 +286,7 @@ function getTriggerNodes<K extends keyof WorkflowYaml["on"]>(key: K, workflow: W
|
||||
}
|
||||
|
||||
const data = workflow.on[key]!;
|
||||
const nodes = toArray(TRIGGERS[key]!(data, workflow));
|
||||
const nodes = toArray(TRIGGERS[key](data, workflow));
|
||||
return nodes.map((node) => {
|
||||
if (triggers.has(node.id)) return triggers.get(node.id)!;
|
||||
triggers.set(node.id, node);
|
||||
@@ -561,7 +567,11 @@ export default async function main(dirs: string[], on?: string[], print = false,
|
||||
subgraph.addNode(job);
|
||||
if (job.needs) {
|
||||
toArray(job.needs).forEach((req) => {
|
||||
subgraph.addEdge(node.jobs.find((job) => job.jobId === req)!, job, "needs");
|
||||
subgraph.addEdge(
|
||||
node.jobs.find((job) => job.jobId === req)!,
|
||||
job,
|
||||
"needs",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -4,7 +4,7 @@ set -ex
|
||||
|
||||
# Creates a layered environment with the full repo for the app and SDKs cloned
|
||||
# and linked. This gives an element-web dev environment ready to build with
|
||||
# matching branches of react-sdk's dependencies so that changes can be tested
|
||||
# matching branches of matrix-js-sdk so that changes can be tested
|
||||
# in element-web.
|
||||
|
||||
# Note that this style is different from the recommended developer setup: this
|
||||
@@ -22,13 +22,15 @@ export PR_REPO=element-web
|
||||
|
||||
js_sdk_dep=$(jq -r '.dependencies["matrix-js-sdk"]' < $(pnpm -w root)/../apps/web/package.json)
|
||||
|
||||
# Set up the js-sdk first (unless package.json pins a specific version)
|
||||
# Set up the js-sdk (unless package.json pins a specific version)
|
||||
if [ "$js_sdk_dep" = "github:matrix-org/matrix-js-sdk#develop" ]; then
|
||||
echo "layered.sh: Cloning matching branch of matrix-js-sdk"
|
||||
scripts/fetchdep.sh matrix-org matrix-js-sdk develop
|
||||
|
||||
if [ -n "$JS_SDK_GITHUB_BASE_REF" ]; then
|
||||
echo "layered.sh: Switching js-sdk to $JS_SDK_GITHUB_BASE_REF"
|
||||
git -C matrix-js-sdk fetch --depth 1 origin $JS_SDK_GITHUB_BASE_REF
|
||||
git -C matrix-js-sdk checkout $JS_SDK_GITHUB_BASE_REF
|
||||
git -C matrix-js-sdk -c advice.detachedHead=false checkout $JS_SDK_GITHUB_BASE_REF
|
||||
fi
|
||||
pnpm -C matrix-js-sdk install --frozen-lockfile --ignore-scripts
|
||||
|
||||
@@ -36,5 +38,5 @@ if [ "$js_sdk_dep" = "github:matrix-org/matrix-js-sdk#develop" ]; then
|
||||
pnpm -C apps/web link ./matrix-js-sdk
|
||||
pnpm link ./matrix-js-sdk
|
||||
else
|
||||
echo "Skipping matrix-js-sdk fetch and link as package.json pins $js_sdk_dep"
|
||||
echo "layered.sh: Skipping matrix-js-sdk fetch and link as package.json pins $js_sdk_dep"
|
||||
fi
|
||||
|
||||
+31
-19
@@ -14,13 +14,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
// This tool is a helpful substitute to `pnpm link` as that modifies the package.json & pnpm-lock.yaml files.
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const configPath = join(__dirname, "..", ".link-config");
|
||||
const nodeModulesPath = join(__dirname, "..", "node_modules");
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const configPath = path.join(__dirname, "..", ".link-config");
|
||||
const nodeModulesPath = path.join(__dirname, "..", "node_modules");
|
||||
|
||||
try {
|
||||
if (process.env.PLAYWRIGHT_COMMON_DOCKER) process.exit(0); // Skip in docker env
|
||||
@@ -28,28 +28,40 @@ try {
|
||||
const configFile = await fs.readFile(configPath, "utf-8");
|
||||
for (const line of configFile.trim().split("\n")) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const [dependency, path] = line.split("=");
|
||||
const dependencyPath = join(nodeModulesPath, dependency);
|
||||
const [dependency, targetPath, dir] = line.split("=");
|
||||
const nodeModules = dir ? path.join(dir, "node_modules") : nodeModulesPath;
|
||||
const dependencyPath = path.join(nodeModules, dependency);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(dependencyPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const linkPath = await fs.readlink(dependencyPath);
|
||||
if (linkPath === path) {
|
||||
// already done
|
||||
continue;
|
||||
try {
|
||||
const stat = await fs.lstat(dependencyPath);
|
||||
console.log(`Existing is ${stat.isSymbolicLink() ? "symlink" : "directory"}`);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const linkPath = await fs.readlink(dependencyPath);
|
||||
if (linkPath === targetPath) {
|
||||
// already done
|
||||
continue;
|
||||
} else {
|
||||
await fs.unlink(dependencyPath);
|
||||
}
|
||||
} else {
|
||||
await fs.unlink(dependencyPath);
|
||||
await fs.rm(dependencyPath, { recursive: true });
|
||||
}
|
||||
} catch (e: any) {
|
||||
// fs.lstat throws ENOENT if the path doesn't exist (on Windows)
|
||||
if (e.code === "ENOENT") {
|
||||
console.log("Received ENOENT error on dependency path - assuming it doesn't exist");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
await fs.rm(dependencyPath, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Linking ${dependency} to ${path}`);
|
||||
await fs.symlink(path, dependencyPath);
|
||||
console.log(`Linking ${dependency} to ${targetPath}`);
|
||||
await fs.symlink(targetPath, dependencyPath, "junction"); // use a junction type to avoid EPERM errors on Windows
|
||||
|
||||
const pkgJson = await fs.readFile(join(path, "package.json"), "utf-8");
|
||||
const pkgManager = JSON.parse(pkgJson)["packageManager"]?.split("@").at(0) ?? "yarn";
|
||||
const pkgJson = JSON.parse(await fs.readFile(path.join(targetPath, "package.json"), "utf-8"));
|
||||
const pkgManager =
|
||||
pkgJson.devEngines?.packageManager?.name ?? pkgJson.packageManager?.split("@").at(0) ?? "yarn";
|
||||
// pnpm install may have wiped out the `node_modules` dir so we have to restore it
|
||||
execSync(`${pkgManager} install --ignore-scripts --frozen-lockfile`, {
|
||||
cwd: dependencyPath,
|
||||
|
||||
Reference in New Issue
Block a user