snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# CLAUDE: `webapp/scripts/`
## Purpose
- Node-based helpers for building, running, and maintaining the webapp (e.g., dev server orchestration, build pipelines, localization tooling).
- Invoked via `npm`/`make` targets—direct execution should be rare.
## Key Scripts
- `dev-server.mjs` – webpack-dev-server bootstrap; shares config with `make dev`.
- `run.mjs`, `build.mjs`, `dist` helpers – orchestrate multi-workspace builds and env wiring.
- `gen_lang_imports.mjs` – regenerates locale import lists.
- `update-versions.sh` - updates version of published platform packages.
## Guidelines
- Scripts should be idempotent and safe to run on CI and macOS/Linux dev machines.
- Prefer ES modules + top-level `await` already used in existing scripts.
- Keep configuration (ports, paths) sourced from `config.mk` or env vars instead of hard-coding.
- Log actionable errors; exit with non-zero codes so CI fails fast.
- When script behavior changes, update associated `Makefile` targets and `webapp/CLAUDE.md` command docs.
## References
- `config.mk`, root `Makefile`, and workspace `package.json` scripts to understand entry points.
- `webapp/STYLE_GUIDE.md → Automated style checking` for how scripts integrate with lint/test tooling.

48
webapp/scripts/build.mjs Normal file
View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
import chalk from 'chalk';
import concurrently from 'concurrently';
import {getExitCode, getPlatformCommands} from './utils.mjs';
async function buildAll() {
console.log(chalk.inverse.bold('Building subpackages...') + '\n');
try {
const {result} = concurrently(
getPlatformCommands('build'),
{
killOthers: 'failure',
},
);
await result;
} catch (closeEvents) {
console.error(chalk.inverse.bold.red('Failed to build subpackages'), closeEvents);
return getExitCode(closeEvents);
}
console.log('\n' + chalk.inverse.bold('Subpackages built! Building web app...') + '\n');
// It's not necessary to run these commands through concurrently, but it makes the output consistent
try {
const {result} = concurrently([
{command: 'npm:build --workspace=channels', name: 'webapp', prefixColor: 'cyan'},
]);
await result;
} catch (closeEvents) {
console.error(chalk.inverse.bold.red('Failed to build web app'), closeEvents);
return getExitCode(closeEvents);
}
console.log('\n' + chalk.inverse.bold('Web app built!'));
return 0;
}
buildAll().then((exitCode) => {
process.exitCode = exitCode;
});

View File

@@ -0,0 +1,470 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
const MATTERMOST_URL_PATTERN = /https?:\/\/(?:[a-z0-9-]+\.)*mattermost\.com(?:[/?#][^"'\s<>()]*)?/gi;
const PERMALINK_PATTERN = /^https?:\/\/(www\.)?mattermost\.com\/pl\//;
const PUSH_SERVER_PATTERN = /^https?:\/\/(([a-z0-9-]+\.)?push|hpns-[a-z]+)\.mattermost\.com(\/|$)/;
const ROOT_DOMAIN_PATTERN = /^https?:\/\/(www\.)?mattermost\.com\/?$/;
/** Marketing forms; often slow or block bot User-Agents — not reliable to verify in CI. */
const FORMS_MATTERMOST_PATTERN = /^https?:\/\/forms\.mattermost\.com\//i;
// Hosts whose bot-management (Cloudflare TLS fingerprint checks etc.) rejects
// non-browser clients regardless of User-Agent spoofing, so we can't validate
// them from CI.
const UNCRAWLABLE_HOSTS = new Set([
'support.mattermost.com', // Zendesk fronted by Cloudflare bot mgmt
]);
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
const DIRECTORIES_TO_SCAN = [
'channels/src',
'platform/client/src',
'platform/components/src',
'platform/mattermost-redux/src',
];
function getAllSourceFiles(dir, excludeTests = true) {
const files = [];
function walk(currentDir) {
if (!fs.existsSync(currentDir)) {
return;
}
const entries = fs.readdirSync(currentDir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'coverage') {
continue;
}
walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (!SOURCE_EXTENSIONS.includes(ext)) {
continue;
}
if (excludeTests && (entry.name.includes('.test.') || entry.name.includes('.spec.'))) {
continue;
}
files.push(fullPath);
}
}
}
walk(dir);
return files;
}
function extractUrls(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const matches = content.match(MATTERMOST_URL_PATTERN) || [];
return matches.map((url) => {
let cleanUrl = url;
cleanUrl = cleanUrl.replace(/[',;)}\]]+$/, '');
cleanUrl = cleanUrl.replace(/\\n$/, '');
return cleanUrl;
});
}
function findAllMattermostUrls(rootDir, excludeTests = true) {
const urlMap = new Map();
for (const dir of DIRECTORIES_TO_SCAN) {
const fullDir = path.join(rootDir, dir);
const files = getAllSourceFiles(fullDir, excludeTests);
for (const file of files) {
const urls = extractUrls(file);
for (const url of urls) {
if (!urlMap.has(url)) {
urlMap.set(url, []);
}
urlMap.get(url).push(path.relative(rootDir, file));
}
}
}
return urlMap;
}
function isValidPermalink(url) {
return PERMALINK_PATTERN.test(url) ||
ROOT_DOMAIN_PATTERN.test(url) ||
PUSH_SERVER_PATTERN.test(url);
}
function findNonPermalinkUrls(urlMap) {
const invalid = [];
for (const [url, files] of urlMap) {
if (!isValidPermalink(url)) {
invalid.push({url, files});
}
}
return invalid;
}
const FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; MattermostLinkChecker/1.0; +https://github.com/mattermost/mattermost)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
};
const MAX_REDIRECTS = 10;
async function checkUrl(url, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await checkUrlWithRedirects(url);
} catch (error) {
if (attempt === retries) {
return {
url,
status: 0,
ok: false,
error: error.message,
};
}
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
async function checkUrlWithRedirects(originalUrl) {
let currentUrl = originalUrl;
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
let response = await fetch(currentUrl, {
method: 'HEAD',
redirect: 'manual',
headers: FETCH_HEADERS,
signal: AbortSignal.timeout(10000),
});
// Some origins reject HEAD; retry with GET and let the outer loop
// handle any 3xx the GET returns so we still follow the chain.
if (response.status === 405 || response.status === 403) {
await response.body?.cancel();
response = await fetch(currentUrl, {
method: 'GET',
redirect: 'manual',
headers: FETCH_HEADERS,
signal: AbortSignal.timeout(10000),
});
}
// Always release the response body — HEAD leaves it null (safe no-op),
// but the GET fallback returns real bodies we never read. Leaving them
// open pins sockets in undici's pool until finalization.
try {
const result = processResponse(originalUrl, response);
if (!result.redirect) {
return result;
}
const location = response.headers.get('location');
if (!location) {
return {
url: originalUrl,
status: response.status,
ok: false,
error: 'Redirect without Location header',
};
}
currentUrl = new URL(location, currentUrl).href;
} finally {
await response.body?.cancel();
}
}
return {
url: originalUrl,
status: 0,
ok: false,
error: 'Too many redirects',
};
}
function processResponse(originalUrl, response) {
const {status} = response;
const cfHeader = response.headers.get('cf-mitigated');
const isRedirect = status >= 300 && status < 400;
if (cfHeader && (isRedirect || status === 403 || status === 503)) {
return {url: originalUrl, status, ok: true};
}
// Follow every 3xx (including 301) to its final destination. Short-circuiting
// 301s as "OK" hides real 404s at the chain's end
if (isRedirect) {
return {url: originalUrl, status, redirect: true};
}
if (status === 429) {
return {url: originalUrl, status, ok: true, rateLimited: true};
}
if (response.ok) {
return {url: originalUrl, status, ok: true};
}
return {url: originalUrl, status, ok: false};
}
function shouldSkipUrlCheck(url) {
if (PUSH_SERVER_PATTERN.test(url) || FORMS_MATTERMOST_PATTERN.test(url)) {
return true;
}
try {
return UNCRAWLABLE_HOSTS.has(new URL(url).hostname);
} catch {
return false;
}
}
async function checkUrls(urls, concurrency = 5, silentProgress = false) {
const results = [];
const urlList = Array.from(urls.keys()).filter((url) => !shouldSkipUrlCheck(url));
for (let i = 0; i < urlList.length; i += concurrency) {
const batch = urlList.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map((url) => checkUrl(url)));
results.push(...batchResults);
if (!silentProgress) {
const completed = Math.min(i + concurrency, urlList.length);
process.stderr.write(`\rChecking URLs: ${completed}/${urlList.length}`);
}
}
if (!silentProgress) {
process.stderr.write('\n');
}
return results;
}
function printResults(results, urlMap, nonPermalinkUrls) {
const broken = results.filter((r) => !r.ok);
const rateLimited = results.filter((r) => r.ok && r.rateLimited);
const working = results.filter((r) => r.ok && !r.rateLimited);
console.log('\n' + chalk.bold('=== External Link Check Results ===\n'));
console.log(chalk.green(`✓ ${working.length} URLs are accessible`));
if (rateLimited.length > 0) {
console.log(chalk.yellow(`⚠ ${rateLimited.length} URLs returned 429 (rate limited, skipped)`));
}
if (broken.length > 0) {
console.log(chalk.red(`✗ ${broken.length} URLs are broken`));
}
if (nonPermalinkUrls.length > 0) {
console.log(chalk.yellow(`⚠ ${nonPermalinkUrls.length} URLs are not using permalink format (warning)`));
}
console.log();
if (rateLimited.length > 0) {
console.log(chalk.yellow.bold('Rate-limited URLs (warning only):\n'));
for (const result of rateLimited) {
console.log(chalk.yellow(` ${result.url}`));
console.log(chalk.gray(` Status: HTTP 429 (Too Many Requests)`));
console.log(chalk.gray(` Found in:`));
for (const file of urlMap.get(result.url)) {
console.log(chalk.gray(` - ${file}`));
}
console.log();
}
}
if (broken.length > 0) {
console.log(chalk.red.bold('Broken URLs:\n'));
for (const result of broken) {
const statusText = result.error ? `Error: ${result.error}` : `HTTP ${result.status}`;
console.log(chalk.red(` ${result.url}`));
console.log(chalk.gray(` Status: ${statusText}`));
console.log(chalk.gray(` Found in:`));
for (const file of urlMap.get(result.url)) {
console.log(chalk.gray(` - ${file}`));
}
console.log();
}
}
if (nonPermalinkUrls.length > 0) {
console.log(chalk.yellow.bold('URLs not using permalink format (warning):\n'));
console.log(chalk.gray(' All mattermost.com links should route via https://mattermost.com/pl/\n'));
for (const item of nonPermalinkUrls) {
console.log(chalk.yellow(` ${item.url}`));
console.log(chalk.gray(` Found in:`));
for (const file of item.files) {
console.log(chalk.gray(` - ${file}`));
}
console.log();
}
}
if (broken.length === 0 && nonPermalinkUrls.length === 0) {
console.log(chalk.green.bold(`✓ All URLs are valid and accessible\n`));
}
return broken.length > 0 ? 1 : 0;
}
function generateMarkdownSummary(results, urlMap, nonPermalinkUrls) {
const broken = results.filter((r) => !r.ok);
const rateLimited = results.filter((r) => r.ok && r.rateLimited);
const working = results.filter((r) => r.ok && !r.rateLimited);
const lines = [];
lines.push('## External Link Check Results\n');
if (broken.length === 0 && nonPermalinkUrls.length === 0 && rateLimited.length === 0) {
lines.push(`✅ **All ${working.length} mattermost.com URLs are valid and accessible**\n`);
return lines.join('\n');
}
lines.push(`| Status | Count |`);
lines.push(`|--------|-------|`);
lines.push(`| ✅ Working | ${working.length} |`);
if (rateLimited.length > 0) {
lines.push(`| ⚠️ Rate limited / 429 (warning) | ${rateLimited.length} |`);
}
if (broken.length > 0) {
lines.push(`| ❌ Broken | ${broken.length} |`);
}
if (nonPermalinkUrls.length > 0) {
lines.push(`| ⚠️ Missing /pl/ prefix (warning) | ${nonPermalinkUrls.length} |`);
}
lines.push('');
if (rateLimited.length > 0) {
lines.push('### ⚠️ Rate-limited URLs (warning only)\n');
lines.push('> These URLs returned HTTP 429 (Too Many Requests) and could not be verified. They are not treated as failures.\n');
lines.push('| URL | Files |');
lines.push('|-----|-------|');
for (const result of rateLimited) {
const files = urlMap.get(result.url).map((f) => `\`${f}\``).join(', ');
lines.push(`| ${result.url} | ${files} |`);
}
lines.push('');
}
if (broken.length > 0) {
lines.push('### Broken URLs\n');
lines.push('| URL | Status | Files |');
lines.push('|-----|--------|-------|');
for (const result of broken) {
const statusText = result.error ? `Error: ${result.error}` : `HTTP ${result.status}`;
const files = urlMap.get(result.url).map((f) => `\`${f}\``).join(', ');
lines.push(`| ${result.url} | ${statusText} | ${files} |`);
}
lines.push('');
}
if (nonPermalinkUrls.length > 0) {
lines.push('### ⚠️ URLs Missing Permalink Format (warning)\n');
lines.push('> All mattermost.com links should route via `https://mattermost.com/pl/`\n');
lines.push('<details>\n<summary>Show URLs</summary>\n');
lines.push('| URL | Files |');
lines.push('|-----|-------|');
for (const item of nonPermalinkUrls) {
const files = item.files.map((f) => `\`${f}\``).join(', ');
lines.push(`| ${item.url} | ${files} |`);
}
lines.push('\n</details>');
lines.push('');
}
return lines.join('\n');
}
async function main() {
const args = process.argv.slice(2);
const includeTests = args.includes('--include-tests');
const jsonOutput = args.includes('--json');
const markdownOutput = args.includes('--markdown');
const rootDir = process.cwd();
if (!markdownOutput) {
console.log(chalk.inverse.bold(' Checking mattermost.com links in webapp... ') + '\n');
if (includeTests) {
console.log(chalk.yellow('Including test files in scan\n'));
}
}
const urlMap = findAllMattermostUrls(rootDir, !includeTests);
if (!markdownOutput) {
console.log(`Found ${chalk.bold(urlMap.size)} unique mattermost.com URLs\n`);
}
if (urlMap.size === 0) {
if (markdownOutput) {
console.log('## External Link Check Results\n\n⚠️ No URLs found to check');
} else {
console.log(chalk.yellow('No URLs found to check'));
}
return 0;
}
const nonPermalinkUrls = findNonPermalinkUrls(urlMap);
const results = await checkUrls(urlMap, 5, markdownOutput);
const brokenCount = results.filter((r) => !r.ok).length;
if (markdownOutput) {
console.log(generateMarkdownSummary(results, urlMap, nonPermalinkUrls));
return brokenCount > 0 ? 1 : 0;
}
if (jsonOutput) {
const output = {
total: results.length,
working: results.filter((r) => r.ok && !r.rateLimited).length,
rateLimited: results.filter((r) => r.rateLimited).map((r) => ({
url: r.url,
status: r.status,
files: urlMap.get(r.url),
})),
broken: results.filter((r) => !r.ok).map((r) => ({
url: r.url,
status: r.status,
error: r.error,
files: urlMap.get(r.url),
})),
nonPermalink: nonPermalinkUrls,
};
console.log(JSON.stringify(output, null, 2));
return brokenCount > 0 ? 1 : 0;
}
return printResults(results, urlMap, nonPermalinkUrls);
}
main().then((exitCode) => {
process.exitCode = exitCode;
}).catch((error) => {
console.error(chalk.red('Error:'), error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
import chalk from 'chalk';
import concurrently from 'concurrently';
import {getPlatformCommands} from './utils.mjs';
async function watchAllWithDevServer() {
console.log(chalk.inverse.bold('Watching web app and all subpackages...'));
const commands = [
{command: 'npm:dev-server --workspace=channels', name: 'webapp', prefixColor: 'cyan'},
];
commands.push(...getPlatformCommands('run'));
console.log('\n');
const {result} = concurrently(
commands,
{
killOthers: 'failure',
},
);
let exitCode = 0;
try {
await result;
} catch (closeEvents) {
exitCode = getExitCode(closeEvents, 0);
}
return exitCode;
}
watchAllWithDevServer().then((exitCode) => {
process.exit(exitCode);
});

View File

@@ -0,0 +1,55 @@
#!/bin/node
import * as fs from 'fs';
let lines = '';
const langIDs = [];
const langFiles = {};
fs.readdirSync('./channels/src/i18n').forEach(file => {
if (file.endsWith('.json')) {
const langID = file.substr(0, file.indexOf('.json'));
// Skip default language
if (langID === 'en') {
return
}
langIDs.push(langID);
lines += `import ${langID.replace('-', '')} from './${file}';\n`;
langFiles[langID] = langID.replace('-', '');
}
});
// To generate the file exports we need to do a bit more work to handle ids with dashes and also output a map of literals rather than strings.
lines += `
// TypeScript thinks it's importing these language files' contents directly, but Webpack rewrites the above imports
// to the URL the file for lazy loading. That's the reason for the ugly type assertions below.
export const langFiles: {
[langID: string]: string;
} = {
` + Object.keys(langFiles).reduce((out, id, idx) => {
out += ' ';
if (id.includes('-')) {
out += `'${id}': ${langFiles[id]}`;
} else {
out += `${id}: ${langFiles[id]}`;
}
out += ' as unknown as string';
if (idx !== (langIDs.length - 1)) {
out += ',';
}
out += '\n';
return out;
}, '') + '};';
const header = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.\n\n// DO NOT EDIT - This file is generated by gen_lang_imports through the gen-lang-imports script\n\n/* eslint-disable */\n\n`;
fs.writeFileSync('./channels/src/i18n/imports.ts', header+lines);

58
webapp/scripts/run.mjs Normal file
View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console, no-process-env */
import chalk from 'chalk';
import concurrently from 'concurrently';
import {makeRunner} from './runner.mjs';
import {getExitCode, getPlatformCommands} from './utils.mjs';
async function watchAll(useRunner) {
if (!useRunner) {
console.log(chalk.inverse.bold('Watching web app and all subpackages...'));
}
const commands = [
{command: 'npm:run --workspace=channels', name: 'webapp', prefixColor: 'cyan'},
];
commands.push(...getPlatformCommands('run'));
let runner;
if (useRunner) {
runner = makeRunner(commands);
}
console.log('\n');
const {result, commands: runningCommands} = concurrently(
commands,
{
killOthers: 'failure',
outputStream: runner?.getOutputStream(),
},
);
runner?.addCloseListener(() => {
for (const command of runningCommands) {
command.kill('SIGINT');
}
});
let exitCode = 0;
try {
await result;
} catch (closeEvents) {
exitCode = getExitCode(closeEvents, 0);
}
return exitCode;
}
const useRunner = process.argv[2] === '--runner' || process.env.MM_USE_WEBAPP_RUNNER;
watchAll(useRunner).then((exitCode) => {
process.exit(exitCode);
});

237
webapp/scripts/runner.mjs Normal file
View File

@@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Writable} from 'node:stream';
import blessed from 'blessed';
import stripAnsi from 'strip-ansi';
class Runner {
commands;
filter = '';
ui;
scrollLocked = true;
buffer = [];
partialBuffer = '';
outputStream;
closeListeners = new Set();
constructor(commands) {
this.commands = commands;
this.outputStream = new Writable({
write: (chunk, encoding, callback) => this.writeToStream(chunk, encoding, callback),
});
this.makeUi(commands.map((command) => command.name), this.onFilter);
this.registerHotkeys();
}
// Initialization
makeUi(commandNames) {
// Set up screen and output panes
const screen = blessed.screen({
smartCSR: true,
dockBorders: true,
});
const output = blessed.box({
top: 0,
left: 0,
width: '100%',
height: '100%-3',
content: 'THE END IS NEVER '.repeat(1000),
tags: true,
alwaysScroll: true,
scrollable: true,
scrollbar: {
ch: '#',
style: {},
track: {
ch: '|',
},
},
style: {},
});
screen.append(output);
// Set up the menu bar
const menu = blessed.listbar({
top: '100%-3',
left: 0,
width: '100%',
height: 3,
border: {
type: 'line',
},
style: {
item: {
bg: 'red',
hover: {
bg: 'green',
},
},
selected: {
bg: 'blue',
},
},
tags: true,
autoCommandKeys: true,
mouse: true,
});
menu.add('All', () => this.onFilter(''));
for (const name of commandNames) {
menu.add(name, () => this.onFilter(name));
}
screen.append(menu);
this.ui = {
menu,
output,
screen,
};
}
registerHotkeys() {
this.ui.screen.key(['escape', 'q', 'C-c'], () => {
for (const listener of this.closeListeners) {
listener();
}
});
this.ui.screen.key(['up', 'down'], (char, key) => {
this.scrollDelta(key.name === 'up' ? -1 : 1);
});
this.ui.screen.on('wheelup', () => {
this.scrollDelta(-3);
});
this.ui.screen.on('wheeldown', () => {
this.scrollDelta(3);
});
this.ui.screen.key('end', () => {
this.scrollToBottom();
});
}
// Rendering and internal logic
renderUi() {
const filtered = this.buffer.filter((line) => this.filter === '' || line.tag === this.filter);
this.ui.output.setContent(filtered.map((line) => this.formatLine(line)).join('\n'));
if (this.scrollLocked) {
this.ui.output.scrollbar.style.inverse = true;
this.ui.output.setScrollPerc(100);
} else {
this.ui.output.scrollbar.style.inverse = false;
}
this.ui.screen.render();
}
formatLine(line) {
const color = this.commands.find((command) => command.name === line.tag)?.prefixColor;
return color ? `{bold}{${color}-fg}[${line.tag}]{/} ${line.text}` : `[${line.tag}] ${line.text}`;
}
onFilter(newFilter) {
this.filter = newFilter;
this.scrollLocked = true;
this.renderUi();
}
scrollDelta(delta) {
this.ui.output.scroll(delta);
if (this.ui.output.getScrollPerc() >= 100 || this.ui.output.getScrollHeight() <= this.ui.output.height) {
this.scrollLocked = true;
} else {
this.scrollLocked = false;
}
this.renderUi();
}
scrollToBottom() {
this.scrollLocked = true;
this.renderUi();
}
// Terminal output handling
getOutputStream() {
return this.outputStream;
}
writeToStream(chunk, encoding, callback) {
const str = String(chunk);
if (str.includes('\n')) {
const parts = str.split('\n');
// Add completed lines to buffer
this.appendToBuffer(this.partialBuffer + parts[0]);
for (let i = 1; i < parts.length - 1; i++) {
this.appendToBuffer(parts[i]);
}
// Track partial line
this.partialBuffer = parts[parts.length - 1];
} else {
// Track partial line
this.partialBuffer += str;
}
this.renderUi();
callback();
}
appendToBuffer(line) {
// This regex is more complicated than expected because it
const match = (/^\[([^\]]*)\]\s*(.*)$/).exec(stripAnsi(line));
if (match) {
this.buffer.push({tag: match[1], text: match[2]});
} else {
this.buffer.push({tag: '', text: 'Line not recognized correctly: ' + line});
}
// Keep the buffer from using too much memory by removing the oldest chunk of it every time it goes over 5000 lines
const bufferCapacity = 5000;
const capacityReduction = 1000;
if (this.buffer.length > bufferCapacity) {
this.buffer = this.buffer.slice(this.buffer.length - capacityReduction);
}
}
// Event handlers
addCloseListener(listener) {
this.closeListeners.add(listener);
}
removeCloseListener(listener) {
this.closeListeners.remove(listener);
}
}
export function makeRunner(commands) {
const runner = new Runner(commands);
runner.renderUi();
return runner;
}

View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
# See LICENSE.txt for license information.
set -euo pipefail
usage() {
echo "Usage: scripts/update-versions.sh monorepo_version"
exit 1
}
if [ $# -ne 1 ]; then
usage
fi
version="$1"
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$'; then
# The version provided appears to be invalid
usage
fi
# These workspaces and packages within the monorepo all synchronize their versions with the web app and server
workspaces=(
channels
platform/client
platform/components
platform/mattermost-redux
platform/shared
platform/types
)
packages=(
@mattermost/client
@mattermost/shared
@mattermost/types
)
packages_json=$(printf '%s\n' "${packages[@]}" | jq -R . | jq -s .)
workspaces_json=$(printf '%s\n' "${workspaces[@]}" | jq -R . | jq -s .)
# In each package's package.json, update the version of that package and any of the other workspace packages to match
for workspace in "${workspaces[@]}"; do
pkg_json="${workspace}/package.json"
jq --arg version "$version" --argjson packages "$packages_json" '
.version = $version |
reduce $packages[] as $pkg (.;
reduce ["dependencies", "devDependencies", "peerDependencies"][] as $section (.;
if .[$section][$pkg] then .[$section][$pkg] = $version else . end
)
)
' "$pkg_json" > "${pkg_json}.tmp" && mv "${pkg_json}.tmp" "$pkg_json"
done
# Then go through the package-lock.json and make those same changes
jq --arg version "$version" --argjson packages "$packages_json" --argjson workspaces "$workspaces_json" '
reduce $workspaces[] as $ws (.;
.packages[$ws].version = $version |
reduce $packages[] as $pkg (.;
reduce ["dependencies", "devDependencies", "peerDependencies"][] as $section (.;
if .packages[$ws][$section][$pkg] then .packages[$ws][$section][$pkg] = $version else . end
)
)
)
' package-lock.json > package-lock.json.tmp && mv package-lock.json.tmp package-lock.json

59
webapp/scripts/utils.mjs Normal file
View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
function getWorkspaces() {
const packageFile = fs.readFileSync('package.json');
const packageJson = JSON.parse(packageFile, 'utf8');
return packageJson.workspaces;
}
function getPlatformPackagesContainingCommand(scriptName) {
return getWorkspaces().filter((workspace) => {
if (!workspace.startsWith('platform/')) {
return false;
}
const workspacePackageFile = fs.readFileSync(path.join(workspace, 'package.json'));
const workspacePackageJson = JSON.parse(workspacePackageFile, 'utf8');
return workspacePackageJson?.scripts?.[scriptName];
});
}
/**
* Returns an array of concurrently commands to run a given script on every platform workspace that contains it.
*/
export function getPlatformCommands(scriptName) {
return getPlatformPackagesContainingCommand(scriptName).map((workspace) => ({
command: `npm:${scriptName} --workspace=${workspace}`,
name: workspace.substring(workspace.lastIndexOf('/') + 1),
prefixColor: getColorForWorkspace(workspace),
}));
}
const workspaceColors = ['green', 'magenta', 'yellow', 'red', 'blue'];
function getColorForWorkspace(workspace) {
const index = getWorkspaces().indexOf(workspace);
return index === -1 ? chalk.white : workspaceColors[index % workspaceColors.length];
}
/**
* @param {import("concurrently").CloseEvent[]} closeEvents - An array of CloseEvents thrown by concurrently when waiting on a result
* @param {number} codeOnSignal - Which error code to return when the process is interrupted
*/
export function getExitCode(closeEvents, codeOnSignal = 1) {
const exitCode = closeEvents.find((event) => !event.killed && event.exitCode > 0)?.exitCode;
if (typeof exitCode === 'string') {
return codeOnSignal
} else {
return exitCode;
}
}