1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-24 08:12:24 +02:00
joplin/packages/tools/tool-utils.ts

542 lines
17 KiB
TypeScript

import { pathExists, readFile, writeFile, unlink, stat, createWriteStream } from 'fs-extra';
import { hasCredentialFile, readCredentialFile } from '@joplin/lib/utils/credentialFiles';
import { execCommand as execCommand2, commandToString } from '@joplin/utils';
const fetch = require('node-fetch');
const execa = require('execa');
const moment = require('moment');
export interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
}
export interface GitHubRelease {
assets: GitHubReleaseAsset[];
tag_name: string;
upload_url: string;
html_url: string;
prerelease: boolean;
draft: boolean;
body: string;
}
async function insertChangelog(tag: string, changelogPath: string, changelog: string, isPrerelease: boolean, repoTagUrl = '') {
repoTagUrl = repoTagUrl || 'https://github.com/laurent22/joplin/releases/tag';
const currentText = await readFile(changelogPath, 'utf8');
const lines = currentText.split('\n');
const beforeLines = [];
const afterLines = [];
for (const line of lines) {
if (afterLines.length) {
afterLines.push(line);
continue;
}
if (line.indexOf('##') === 0) {
afterLines.push(line);
continue;
}
beforeLines.push(line);
}
const header = [
'##',
`[${tag}](${repoTagUrl}/${tag})`,
];
if (isPrerelease) header.push('(Pre-release)');
header.push('-');
// eslint-disable-next-line no-useless-escape
header.push(`${moment.utc().format('YYYY-MM-DD\THH:mm:ss')}Z`);
let newLines = [];
newLines.push(header.join(' '));
newLines.push('');
newLines = newLines.concat(changelog.split('\n'));
newLines.push('');
const output = beforeLines.concat(newLines).concat(afterLines);
return output.join('\n');
}
export function releaseFinalGitCommands(appName: string, newVersion: string, newTag: string): string {
const finalCmds = [
'git add -A',
`git commit -m "${appName} ${newVersion}"`,
`git tag "${newTag}"`,
'git push',
`git push origin refs/tags/${newTag}`,
];
return finalCmds.join(' && ');
}
export async function completeReleaseWithChangelog(changelogPath: string, newVersion: string, newTag: string, appName: string, isPreRelease: boolean, repoTagUrl = '') {
const changelog = (await execCommand2(`node ${rootDir}/packages/tools/git-changelog ${newTag} --publish-format full`, { showStdout: false })).trim();
const newChangelog = await insertChangelog(newTag, changelogPath, changelog, isPreRelease, repoTagUrl);
await writeFile(changelogPath, newChangelog);
console.info('');
console.info('Verify that the changelog is correct:');
console.info('');
console.info(`${process.env.EDITOR} "${changelogPath}"`);
console.info('');
console.info('Then run these commands:');
console.info('');
console.info(releaseFinalGitCommands(appName, newVersion, newTag));
}
async function loadGitHubUsernameCache() {
const path = `${__dirname}/github_username_cache.json`;
if (await pathExists(path)) {
const jsonString = await readFile(path, 'utf8');
return JSON.parse(jsonString);
}
return {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
async function saveGitHubUsernameCache(cache: any) {
const path = `${__dirname}/github_username_cache.json`;
await writeFile(path, JSON.stringify(cache));
}
// Returns the project root dir
export const rootDir: string = require('path').dirname(require('path').dirname(__dirname));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function execCommand(command: string, options: any = null): Promise<string> {
options = options || {};
const exec = require('child_process').exec;
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
exec(command, options, (error: any, stdout: any, stderr: any) => {
if (error) {
if (error.signal === 'SIGTERM') {
resolve('Process was killed');
} else {
reject(error);
}
} else {
resolve([stdout.trim(), stderr.trim()].join('\n'));
}
});
});
}
export function resolveRelativePathWithinDir(baseDir: string, ...relativePath: string[]): string {
const path = require('path');
const resolvedBaseDir = path.resolve(baseDir);
const resolvedPath = path.resolve(baseDir, ...relativePath);
if (resolvedPath.indexOf(resolvedBaseDir) !== 0) throw new Error(`Resolved path for relative path "${JSON.stringify(relativePath)}" is not within base directory "${baseDir}" (Was resolved to ${resolvedPath})`);
return resolvedPath;
}
export function execCommandVerbose(commandName: string, args: string[] = []) {
console.info(`> ${commandToString(commandName, args)}`);
const promise = execa(commandName, args);
promise.stdout.pipe(process.stdout);
return promise;
}
export function execCommandWithPipes(executable: string, args: string[]) {
const spawn = require('child_process').spawn;
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { stdio: 'inherit' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
child.on('error', (error: any) => {
reject(error);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
child.on('close', (code: any) => {
if (code !== 0) {
reject(new Error(`Ended with code ${code}`));
} else {
resolve(null);
}
});
});
}
export function toSystemSlashes(path: string) {
const os = process.platform;
if (os === 'win32') return path.replace(/\//g, '\\');
return path.replace(/\\/g, '/');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function setPackagePrivateField(filePath: string, value: any) {
const text = await readFile(filePath, 'utf8');
const obj = JSON.parse(text);
if (!value) {
delete obj.private;
} else {
obj.private = true;
}
await writeFile(filePath, JSON.stringify(obj, null, 2), 'utf8');
}
export async function downloadFile(url: string, targetPath: string, headers: { [key: string]: string }) {
const https = require('https');
return new Promise((resolve, reject) => {
const makeDownloadRequest = (url: string) => {
const file = createWriteStream(targetPath);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
https.get(url, headers, (response: any) => {
if (response.statusCode === 403) {
let data = '';
response.on('data', (chunk: string) => {
data += chunk;
});
response.on('end', () => {
console.log(`Body: ${data}`);
reject(new Error('Access forbidden. Possibly due to rate limiting or lack of permission.'));
});
} else if (response.statusCode === 302 || response.statusCode === 301) {
const newUrl = response.headers.location;
if (newUrl) {
console.log(`Redirecting download request to ${newUrl}`);
file.close();
makeDownloadRequest(url);
} else {
reject(new Error('Redirection failed, url undefined'));
}
} else if (response.statusCode !== 200) {
reject(new Error(`HTTP error ${response.statusCode}`));
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve(null);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
}).on('error', (error: any) => {
reject(error);
});
};
makeDownloadRequest(url);
});
}
export function fileSha256(filePath: string) {
return new Promise((resolve, reject) => {
const crypto = require('crypto');
const fs = require('fs');
const algo = 'sha256';
const shasum = crypto.createHash(algo);
const s = fs.ReadStream(filePath);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
s.on('data', (d: any) => { shasum.update(d); });
s.on('end', () => {
const d = shasum.digest('hex');
resolve(d);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
s.on('error', (error: any) => {
reject(error);
});
});
}
export async function unlinkForce(filePath: string) {
try {
await unlink(filePath);
} catch (error) {
if (error.code === 'ENOENT') return;
throw error;
}
}
export function fileExists(filePath: string) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
stat(filePath, (error: any) => {
if (!error) {
resolve(true);
} else if (error.code === 'ENOENT') {
resolve(false);
} else {
reject(error);
}
});
});
}
export async function gitRepoClean(): Promise<boolean> {
const output = await execCommand2('git status --porcelain', { quiet: true });
return !output.trim();
}
export async function gitRepoCleanTry() {
if (!(await gitRepoClean())) throw new Error(`There are pending changes in the repository: ${process.cwd()}`);
}
export async function gitPullTry(ignoreIfNotBranch = true) {
try {
await execCommand('git pull');
} catch (error) {
if (ignoreIfNotBranch && error.message.includes('no tracking information for the current branch')) {
console.info('Skipping git pull because no tracking information on current branch');
} else {
throw error;
}
}
}
export const gitCurrentBranch = async (): Promise<string> => {
const output = await execCommand2('git rev-parse --abbrev-ref HEAD', { quiet: true });
return output.trim();
};
export async function githubUsername(email: string, name: string) {
if (email.endsWith('@users.noreply.github.com')) {
const splitted = email.split('@')[0].split('+');
return splitted.length === 1 ? splitted[0] : splitted[1];
}
const cache = await loadGitHubUsernameCache();
const cacheKey = `${email}:${name}`;
if (cacheKey in cache) return cache[cacheKey];
let output = null;
const oauthToken = await githubOauthToken();
const urlsToTry = [
`https://api.github.com/search/users?q=${encodeURI(email)}+in:email`,
// Note that this can fail if the email could not be found and the user
// shares a name with someone else. It's rare enough that we can leave
// it for now.
// https://github.com/laurent22/joplin/pull/5390
`https://api.github.com/search/users?q=user:${encodeURI(name)}`,
];
for (const url of urlsToTry) {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${oauthToken}`,
},
});
const responseText = await response.text();
if (!response.ok) continue;
const responseJson = JSON.parse(responseText);
if (!responseJson || !responseJson.items || responseJson.items.length !== 1) continue;
output = responseJson.items[0].login;
break;
}
cache[cacheKey] = output;
await saveGitHubUsernameCache(cache);
return output;
}
export function patreonOauthToken() {
return readCredentialFile('patreon_oauth_token.txt');
}
export function githubOauthToken() {
const filename = 'github_oauth_token.txt';
if (hasCredentialFile(filename)) return readCredentialFile(filename);
if (process.env.JOPLIN_GITHUB_OAUTH_TOKEN) return process.env.JOPLIN_GITHUB_OAUTH_TOKEN;
throw new Error(`Cannot get Oauth token. Neither ${filename} nor the env variable JOPLIN_GITHUB_OAUTH_TOKEN are present`);
}
// Note that the GitHub API releases/latest is broken on the joplin-android repo
// as of Nov 2021 (last working on 3 November 2021, first broken on 19
// November). It used to return the latest **published** release but now it
// returns... some release, always the same one, but not the latest one. GitHub
// says that nothing has changed on the API, although it used to work. So since
// we can't use /latest anymore, we need to fetch all the releases to find the
// latest published one.
//
// As of July 2023 /latest seems to be working again, so switching back to this
// method, but let's keep the old method just in case they break the API again.
export async function gitHubLatestRelease(repoName: string): Promise<GitHubRelease> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const response: any = await fetch(`https://api.github.com/repos/laurent22/${repoName}/releases/latest`, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Joplin Readme Updater',
},
});
if (!response.ok) throw new Error(`Cannot fetch releases: ${response.statusText}`);
return response.json();
}
export async function gitHubLatestRelease_KeepInCaseMicrosoftBreaksTheApiAgain(repoName: string): Promise<GitHubRelease> {
let pageNum = 1;
while (true) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const response: any = await fetch(`https://api.github.com/repos/laurent22/${repoName}/releases?page=${pageNum}`, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Joplin Readme Updater',
},
});
if (!response.ok) throw new Error(`Cannot fetch releases: ${response.statusText}`);
const releases = await response.json();
if (!releases.length) throw new Error('Cannot find latest release');
for (const release of releases) {
if (release.prerelease || release.draft) continue;
return release;
}
pageNum++;
}
}
export const gitHubLatestReleases = async (page: number, perPage: number) => {
const response = await fetch(`https://api.github.com/repos/laurent22/joplin/releases?page=${page}&per_page=${perPage}`, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Joplin Forum Updater',
},
});
if (!response.ok) throw new Error(`Cannot fetch releases: ${response.statusText}`);
const releases: GitHubRelease[] = await response.json();
if (!releases.length) throw new Error('Cannot find latest release');
return releases;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function githubRelease(project: string, tagName: string, options: any = null): Promise<GitHubRelease> {
options = { isDraft: false,
isPreRelease: false, ...options };
const oauthToken = await githubOauthToken();
const response = await fetch(`https://api.github.com/repos/laurent22/${project}/releases`, {
method: 'POST',
body: JSON.stringify({
tag_name: tagName,
name: tagName,
draft: options.isDraft,
prerelease: options.isPreRelease,
}),
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${oauthToken}`,
},
});
const responseText = await response.text();
if (!response.ok) throw new Error(`Cannot create GitHub release: ${responseText}`);
const responseJson = JSON.parse(responseText);
if (!responseJson.url) throw new Error(`No URL for release: ${responseText}`);
return responseJson;
}
export const gitHubLinkify = (markdown: string) => {
markdown = markdown.replace(/#(\d+)/g, '[#$1](https://github.com/laurent22/joplin/issues/$1)');
markdown = markdown.replace(/\(([a-f0-9]+)\)/g, '([$1](https://github.com/laurent22/joplin/commit/$1))');
return markdown;
};
export function readline(question: string) {
return new Promise((resolve) => {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${question} `, (answer: string) => {
resolve(answer);
rl.close();
});
});
}
export function isLinux() {
return process && process.platform === 'linux';
}
export function isWindows() {
return process && process.platform === 'win32';
}
export function isMac() {
return process && process.platform === 'darwin';
}
export async function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string) {
let content = await readFile(filePath, 'utf-8');
// [^]* matches any character including new lines
const regex = new RegExp(`${markerOpen}[^]*?${markerClose}`);
content = content.replace(regex, markerOpen + contentToInsert + markerClose);
await writeFile(filePath, content);
}
export function dirname(path: string) {
if (!path) throw new Error('Path is empty');
const s = path.split(/\/|\\/);
s.pop();
return s.join('/');
}
export function basename(path: string) {
if (!path) throw new Error('Path is empty');
const s = path.split(/\/|\\/);
return s[s.length - 1];
}
export function filename(path: string, includeDir = false) {
if (!path) throw new Error('Path is empty');
const output = includeDir ? path : basename(path);
if (output.indexOf('.') < 0) return output;
const splitted = output.split('.');
splitted.pop();
return splitted.join('.');
}
export function fileExtension(path: string) {
if (!path) throw new Error('Path is empty');
const output = path.split('.');
if (output.length <= 1) return '';
return output[output.length - 1];
}