зеркало из https://github.com/microsoft/lage.git
Set shell: true when spawning npm commands (#732)
This commit is contained in:
Родитель
5dfaea6117
Коммит
f324cccc43
|
@ -16,7 +16,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
os: [ubuntu-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
|
@ -33,19 +33,23 @@ jobs:
|
|||
- run: yarn
|
||||
|
||||
- name: Code Format Check
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: yarn format:check
|
||||
|
||||
- name: Check Change Files
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: yarn checkchange
|
||||
|
||||
# @see https://www.npmjs.com/package/syncpack
|
||||
- name: Check consistent package.json dep versions
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: yarn syncpack list-mismatches
|
||||
|
||||
- name: Dependency checks
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: yarn lage depcheck
|
||||
|
||||
- name: Build, Test, Lint (Linux)
|
||||
- name: Build, Test, Lint
|
||||
run: yarn ci --concurrency 2 --verbose
|
||||
env:
|
||||
BACKFILL_CACHE_PROVIDER: ${{ secrets.backfill_cache_provider }}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
name: Windows Rolling
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 5,17 * * *"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
os: [windows-2019]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- run: yarn
|
||||
|
||||
- name: Build, Test (Windows)
|
||||
run: yarn lage build test --concurrency 2 --verbose
|
||||
env:
|
||||
BACKFILL_CACHE_PROVIDER: ${{ secrets.backfill_cache_provider }}
|
||||
BACKFILL_CACHE_PROVIDER_OPTIONS: ${{ secrets.backfill_cache_provider_options }}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"changes": [
|
||||
{
|
||||
"type": "minor",
|
||||
"comment": "Set shell: true when spawning npm commands, due to Node security fix. Also remove custom npm client resolution logic, which should be handled based on the PATH in the shell.",
|
||||
"packageName": "@lage-run/cli",
|
||||
"email": "elcraig@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
},
|
||||
{
|
||||
"type": "minor",
|
||||
"comment": "Set shell: true when spawning npm commands, due to Node security fix",
|
||||
"packageName": "@lage-run/scheduler",
|
||||
"email": "elcraig@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -22,7 +22,6 @@
|
|||
"dependencies": {
|
||||
"@lage-run/cache": "^1.1.5",
|
||||
"@lage-run/config": "^0.3.5",
|
||||
"@lage-run/find-npm-client": "^0.1.4",
|
||||
"@lage-run/hasher": "^1.1.0",
|
||||
"@lage-run/logger": "^1.3.0",
|
||||
"@lage-run/reporters": "^1.2.7",
|
||||
|
|
|
@ -102,7 +102,7 @@ async function installLage(cwd: string, workspaceManager: WorkspaceManager, pipe
|
|||
packageJson.devDependencies.lage = lageVersion;
|
||||
writePackageJson(cwd, packageJson);
|
||||
|
||||
await execa(workspaceManager, ["install"], { stdio: "inherit" });
|
||||
await execa(workspaceManager, ["install"], { stdio: "inherit", shell: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import type { Command } from "commander";
|
|||
import { createTargetGraph } from "./createTargetGraph.js";
|
||||
import { filterArgsForTasks } from "./filterArgsForTasks.js";
|
||||
import { filterPipelineDefinitions } from "./filterPipelineDefinitions.js";
|
||||
import { findNpmClient } from "@lage-run/find-npm-client";
|
||||
import { getConfig, getMaxWorkersPerTask, getMaxWorkersPerTaskFromOptions, getConcurrency } from "@lage-run/config";
|
||||
import { getPackageInfos, getWorkspaceRoot } from "workspace-tools";
|
||||
import { initializeReporters } from "../initializeReporters.js";
|
||||
|
@ -97,7 +96,7 @@ export async function runAction(options: RunOptions, command: Command) {
|
|||
options: {
|
||||
nodeArg: options.nodeArg,
|
||||
taskArgs,
|
||||
npmCmd: findNpmClient(config.npmClient),
|
||||
npmCmd: config.npmClient,
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { Command } from "commander";
|
||||
import { createTargetGraph } from "./createTargetGraph.js";
|
||||
import { filterArgsForTasks } from "./filterArgsForTasks.js";
|
||||
import { findNpmClient } from "@lage-run/find-npm-client";
|
||||
import { getConfig, getMaxWorkersPerTask, getMaxWorkersPerTaskFromOptions, getConcurrency } from "@lage-run/config";
|
||||
import { getPackageInfosAsync, getWorkspaceRoot } from "workspace-tools";
|
||||
import { filterPipelineDefinitions } from "./filterPipelineDefinitions.js";
|
||||
|
@ -93,7 +92,7 @@ export async function watchAction(options: RunOptions, command: Command) {
|
|||
options: {
|
||||
nodeArg: options.nodeArg,
|
||||
taskArgs,
|
||||
npmCmd: findNpmClient(config.npmClient),
|
||||
npmCmd: config.npmClient,
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
|
|
|
@ -2,8 +2,15 @@ import { Monorepo } from "./mock/monorepo.js";
|
|||
import { filterEntry, parseNdJson } from "./parseNdJson.js";
|
||||
|
||||
describe("basics", () => {
|
||||
let repo: Monorepo | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
repo?.cleanup();
|
||||
repo = undefined;
|
||||
});
|
||||
|
||||
it("basic test case", () => {
|
||||
const repo = new Monorepo("basics");
|
||||
repo = new Monorepo("basics");
|
||||
|
||||
repo.init();
|
||||
repo.addPackage("a", ["b"]);
|
||||
|
@ -20,12 +27,10 @@ describe("basics", () => {
|
|||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "build", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "test", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "lint", "success"))).toBeFalsy();
|
||||
|
||||
repo.cleanup();
|
||||
});
|
||||
|
||||
it("basic with missing script names - logging should not include those targets", () => {
|
||||
const repo = new Monorepo("basics-missing-scripts");
|
||||
repo = new Monorepo("basics-missing-scripts");
|
||||
|
||||
repo.init();
|
||||
repo.addPackage("a", ["b"]);
|
||||
|
@ -47,12 +52,10 @@ describe("basics", () => {
|
|||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "build", "success"))).toBeFalsy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "test", "success"))).toBeFalsy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "lint", "success"))).toBeFalsy();
|
||||
|
||||
repo.cleanup();
|
||||
});
|
||||
|
||||
it("basic test case - with task args", () => {
|
||||
const repo = new Monorepo("basics-with-task-args");
|
||||
repo = new Monorepo("basics-with-task-args");
|
||||
|
||||
repo.init();
|
||||
repo.addPackage("a", ["b"]);
|
||||
|
@ -106,7 +109,25 @@ describe("basics", () => {
|
|||
expect(jsonOutput4.find((entry) => filterEntry(entry.data, "a", "build", "skipped"))).toBeTruthy();
|
||||
expect(jsonOutput4.find((entry) => filterEntry(entry.data, "a", "test", "skipped"))).toBeTruthy();
|
||||
expect(jsonOutput4.find((entry) => filterEntry(entry.data, "a", "lint", "skipped"))).toBeFalsy();
|
||||
});
|
||||
|
||||
repo.cleanup();
|
||||
it("works in repo with spaces", () => {
|
||||
repo = new Monorepo("spaces why");
|
||||
|
||||
repo.init();
|
||||
repo.addPackage("a", ["b"]);
|
||||
repo.addPackage("b");
|
||||
|
||||
repo.install();
|
||||
|
||||
const results = repo.run("test");
|
||||
const output = results.stdout + results.stderr;
|
||||
const jsonOutput = parseNdJson(output);
|
||||
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "b", "build", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "b", "test", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "build", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "test", "success"))).toBeTruthy();
|
||||
expect(jsonOutput.find((entry) => filterEntry(entry.data, "a", "lint", "success"))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -44,26 +44,27 @@ export class Monorepo {
|
|||
fs.cpSync(packagePath, path.join(this.root, "node_modules", name), { recursive: true });
|
||||
}
|
||||
|
||||
fs.cpSync(path.join(__dirname, "..", "..", "yarn"), path.dirname(this.yarnPath), { recursive: true });
|
||||
execa.sync("node", [this.yarnPath, "install"], { cwd: this.root });
|
||||
fs.cpSync(path.resolve(__dirname, "..", "..", "yarn"), path.dirname(this.yarnPath), { recursive: true });
|
||||
execa.sync("yarn", ["install"], { cwd: this.root });
|
||||
}
|
||||
|
||||
generateRepoFiles() {
|
||||
this.commitFiles({
|
||||
".yarnrc": `yarn-path "${this.yarnPath}"`,
|
||||
"package.json": {
|
||||
name: this.name,
|
||||
name: this.name.replace(/ /g, "-"),
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
workspaces: ["packages/*"],
|
||||
scripts: {
|
||||
bundle: `node ${this.yarnPath} lage bundle --reporter json --log-level silly`,
|
||||
transpile: `node ${this.yarnPath} lage transpile --reporter json --log-level silly`,
|
||||
build: `node ${this.yarnPath} lage build --reporter json --log-level silly`,
|
||||
writeInfo: `node ${this.yarnPath} lage info`,
|
||||
test: `node ${this.yarnPath} lage test --reporter json --log-level silly`,
|
||||
lint: `node ${this.yarnPath} lage lint --reporter json --log-level silly`,
|
||||
clear: `node ${this.yarnPath} lage cache --clear --reporter json --log-level silly`,
|
||||
extra: `node ${this.yarnPath} lage extra --clear --reporter json --log-level silly`,
|
||||
bundle: `lage bundle --reporter json --log-level silly`,
|
||||
transpile: `lage transpile --reporter json --log-level silly`,
|
||||
build: `lage build --reporter json --log-level silly`,
|
||||
writeInfo: `lage info`,
|
||||
test: `lage test --reporter json --log-level silly`,
|
||||
lint: `lage lint --reporter json --log-level silly`,
|
||||
clear: `lage cache --clear --reporter json --log-level silly`,
|
||||
extra: `lage extra --clear --reporter json --log-level silly`,
|
||||
},
|
||||
devDependencies: {
|
||||
lage: path.resolve(__dirname, "..", "..", "..", "lage"),
|
||||
|
@ -75,7 +76,8 @@ export class Monorepo {
|
|||
test: ['build'],
|
||||
lint: [],
|
||||
extra: []
|
||||
}
|
||||
},
|
||||
npmClient: 'yarn'
|
||||
};`,
|
||||
".gitignore": "node_modules",
|
||||
});
|
||||
|
@ -151,6 +153,7 @@ export class Monorepo {
|
|||
run(command: string, args?: string[], silent?: boolean) {
|
||||
return execa.sync("yarn", [...(silent === true ? ["--silent"] : []), command, ...(args || [])], {
|
||||
cwd: this.root,
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"name": "@lage-run/find-npm-client",
|
||||
"entries": [
|
||||
{
|
||||
"date": "Thu, 21 Dec 2023 08:37:41 GMT",
|
||||
"version": "0.1.4",
|
||||
"tag": "@lage-run/find-npm-client_v0.1.4",
|
||||
"comments": {
|
||||
"none": [
|
||||
{
|
||||
"author": "elcraig@microsoft.com",
|
||||
"package": "@lage-run/find-npm-client",
|
||||
"commit": "0752bad677868d982719f792f6692ab43ad325e0",
|
||||
"comment": "Use ranges for devDependencies"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "Tue, 01 Nov 2022 22:25:59 GMT",
|
||||
"tag": "@lage-run/find-npm-client_v0.1.4",
|
||||
"version": "0.1.4",
|
||||
"comments": {
|
||||
"patch": [
|
||||
{
|
||||
"author": "kchau@microsoft.com",
|
||||
"package": "@lage-run/find-npm-client",
|
||||
"commit": "1664f38eca34da2d51b6a581c92caba5fc51e5fd",
|
||||
"comment": "adds import extensions of .js to prepare of esmodule switchover"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "Tue, 01 Nov 2022 20:43:17 GMT",
|
||||
"tag": "@lage-run/find-npm-client_v0.1.3",
|
||||
"version": "0.1.3",
|
||||
"comments": {
|
||||
"patch": [
|
||||
{
|
||||
"author": "kchau@microsoft.com",
|
||||
"package": "@lage-run/find-npm-client",
|
||||
"commit": "d93ffd227f46718fafd1062f9107bde2c98d4f37",
|
||||
"comment": "cleaning up the tsconfig files"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "Mon, 31 Oct 2022 21:27:52 GMT",
|
||||
"tag": "@lage-run/find-npm-client_v0.1.2",
|
||||
"version": "0.1.2",
|
||||
"comments": {
|
||||
"patch": [
|
||||
{
|
||||
"author": "kchau@microsoft.com",
|
||||
"package": "@lage-run/find-npm-client",
|
||||
"commit": "e8946dc08fb76dc616d61f3b67b0ca99e15d9a7e",
|
||||
"comment": "adds depcheck and fixes"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "Sat, 01 Oct 2022 16:21:41 GMT",
|
||||
"tag": "@lage-run/find-npm-client_v0.1.1",
|
||||
"version": "0.1.1",
|
||||
"comments": {
|
||||
"patch": [
|
||||
{
|
||||
"author": "ken@gizzar.com",
|
||||
"package": "@lage-run/find-npm-client",
|
||||
"commit": "8f3016548ae7d9b05d0356a5a52d7602843e9ab2",
|
||||
"comment": "new find-npm-client package"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
# Change Log - @lage-run/find-npm-client
|
||||
|
||||
This log was last generated on Tue, 01 Nov 2022 22:25:59 GMT and should not be manually modified.
|
||||
|
||||
<!-- Start content -->
|
||||
|
||||
## 0.1.4
|
||||
|
||||
Tue, 01 Nov 2022 22:25:59 GMT
|
||||
|
||||
### Patches
|
||||
|
||||
- adds import extensions of .js to prepare of esmodule switchover (kchau@microsoft.com)
|
||||
|
||||
## 0.1.3
|
||||
|
||||
Tue, 01 Nov 2022 20:43:17 GMT
|
||||
|
||||
### Patches
|
||||
|
||||
- cleaning up the tsconfig files (kchau@microsoft.com)
|
||||
|
||||
## 0.1.2
|
||||
|
||||
Mon, 31 Oct 2022 21:27:52 GMT
|
||||
|
||||
### Patches
|
||||
|
||||
- adds depcheck and fixes (kchau@microsoft.com)
|
||||
|
||||
## 0.1.1
|
||||
|
||||
Sat, 01 Oct 2022 16:21:41 GMT
|
||||
|
||||
### Patches
|
||||
|
||||
- new find-npm-client package (ken@gizzar.com)
|
|
@ -1,9 +0,0 @@
|
|||
# @lage-run/cli
|
||||
|
||||
This is the command line interface (CLI) for lage. It contains the logic to tie everything together:
|
||||
|
||||
1. parses CLI arguments via `commander`
|
||||
2. initializes the various commands
|
||||
3. for running the targets, there are some reserved options for lage, but the rest are passed through to the scripts
|
||||
4. figures out the filtered packages as entry points (dependencies are also run, unless --no-dependents are specified)
|
||||
5. scheduler, reporter, cache, logger are initialized and run
|
|
@ -1 +0,0 @@
|
|||
module.exports = require("@lage-run/monorepo-scripts/config/jest.config.js");
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "@lage-run/find-npm-client",
|
||||
"version": "0.1.4",
|
||||
"description": "Finds the npm client for Lage",
|
||||
"repository": {
|
||||
"url": "https://github.com/microsoft/lage"
|
||||
},
|
||||
"license": "MIT",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "tsc -w --preserveWatchOutput",
|
||||
"lint": "monorepo-scripts lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lage-run/monorepo-scripts": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export function findNpmClient(npmClient: string) {
|
||||
const found = findInPath(npmClient);
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`npm client not found: ${npmClient}`);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function findInPath(target: string) {
|
||||
const pathEnv = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
const pathExtEnv = Object.keys(process.env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT";
|
||||
|
||||
const envPath = process.env[pathEnv] ?? "";
|
||||
const pathExt = process.env[pathExtEnv] ?? "";
|
||||
|
||||
for (const search of envPath.split(path.delimiter)) {
|
||||
const found = pathExt
|
||||
.split(path.delimiter)
|
||||
.map((ext) => path.join(search, `${target}${ext}`))
|
||||
.find((p) => fs.existsSync(p));
|
||||
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { findNpmClient } from "./findNpmClient.js";
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../tsconfig.lage2.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": ["src/cli.ts", "src/index.ts"]
|
||||
}
|
|
@ -211,6 +211,7 @@ export class Monorepo {
|
|||
run(command: string, args?: string[], silent?: boolean) {
|
||||
return execa("yarn", [...(silent === true ? ["--silent"] : []), command, ...(args || [])], {
|
||||
cwd: this.root,
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { readFile } from "fs/promises";
|
||||
import { spawn } from "child_process";
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import type { TargetRunner, TargetRunnerOptions } from "@lage-run/scheduler-types";
|
||||
import type { ChildProcess } from "child_process";
|
||||
import type { Target } from "@lage-run/target-graph";
|
||||
|
||||
export interface NpmScriptRunnerOptions {
|
||||
|
@ -33,9 +32,7 @@ export interface NpmScriptRunnerOptions {
|
|||
export class NpmScriptRunner implements TargetRunner {
|
||||
static gracefulKillTimeout = 2500;
|
||||
|
||||
constructor(private options: NpmScriptRunnerOptions) {
|
||||
this.validateOptions(options);
|
||||
}
|
||||
constructor(private options: NpmScriptRunnerOptions) {}
|
||||
|
||||
private getNpmArgs(task: string, taskTargs: string[]) {
|
||||
const extraArgs = taskTargs.length > 0 ? ["--", ...taskTargs] : [];
|
||||
|
@ -49,12 +46,6 @@ export class NpmScriptRunner implements TargetRunner {
|
|||
return !!packageJson.scripts?.[task];
|
||||
}
|
||||
|
||||
private validateOptions(options: NpmScriptRunnerOptions) {
|
||||
if (!existsSync(options.npmCmd)) {
|
||||
throw new Error(`NPM Script Runner: ${this.options.npmCmd} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
async shouldRun(target: Target) {
|
||||
// By convention, do not run anything if there is no script for this task defined in package.json (counts as "success")
|
||||
return await this.hasNpmScript(target);
|
||||
|
@ -112,9 +103,11 @@ export class NpmScriptRunner implements TargetRunner {
|
|||
childProcess = spawn(npmCmd, npmRunArgs, {
|
||||
cwd: target.cwd,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
// This is required for Windows due to https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
|
||||
shell: true,
|
||||
env: {
|
||||
...(process.stdout.isTTY && { FORCE_COLOR: "1" }), // allow user env to override this
|
||||
...process.env,
|
||||
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
|
||||
...(npmRunNodeOptions && { NODE_OPTIONS: npmRunNodeOptions }),
|
||||
LAGE_PACKAGE_NAME: target.packageName,
|
||||
LAGE_TASK: target.task,
|
||||
|
|
|
@ -17,6 +17,7 @@ if (spawnCommand.endsWith(".js")) {
|
|||
console.log(`Running ${command} as: ${spawnCommand} ${spawnArgs.join(" ")}`);
|
||||
cp = spawn(spawnCommand, spawnArgs, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче