myst-cli
Version:
Command line tools for MyST
226 lines (225 loc) • 8.82 kB
JavaScript
import path from 'node:path';
import { createStore } from 'redux';
import { chalkLogger, LogLevel } from 'myst-cli-utils';
import latestVersion from 'latest-version';
import boxen from 'boxen';
import chalk from 'chalk';
import { HttpsProxyAgent } from 'https-proxy-agent';
import pLimit from 'p-limit';
import { Semaphore } from 'async-mutex';
import { cpus } from 'node:os';
import { findCurrentProjectAndLoad, findCurrentSiteAndLoad, reloadAllConfigsForCurrentSite, } from '../config.js';
import { loadPlugins } from '../plugins.js';
import { selectors } from '../store/index.js';
import { rootReducer } from '../store/reducers.js';
import version from '../version.js';
import { isWhiteLabelled } from '../utils/whiteLabelling.js';
import { KernelManager, ServerConnection, SessionManager } from '@jupyterlab/services';
import { launchJupyterServer } from 'myst-execute';
import { default as nodeFetch, Headers, Request, Response } from 'node-fetch';
// fetch polyfill for node<18
if (!globalThis.fetch) {
globalThis.fetch = nodeFetch;
globalThis.Headers = Headers;
globalThis.Request = Request;
globalThis.Response = Response;
}
const CONFIG_FILES = ['myst.yml', 'myst.yaml'];
const DEFAULT_API_URL = 'https://api.mystmd.org';
const NPM_COMMAND = 'npm i -g mystmd@latest';
const PIP_COMMAND = 'pip install -U mystmd';
const LOCALHOSTS = ['localhost', '127.0.0.1', '::1'];
function socialLink({ twitter, bsky }) {
if (bsky) {
return `Follow ${chalk.yellowBright(`@${bsky}`)} for updates!\nhttps://bsky.app/profile/${bsky}`;
}
if (twitter) {
return `Follow ${chalk.yellowBright(`@${twitter}`)} for updates!\nhttps://x.com/${twitter}`;
}
return '';
}
export function logUpdateAvailable({ current, latest, upgradeCommand, twitter, bsky, }) {
return boxen(`Update available! ${chalk.dim(`v${current}`)} ≫ ${chalk.green.bold(`v${latest}`)}\n\nRun \`${chalk.cyanBright.bold(upgradeCommand)}\` to update.\n\n${socialLink({ bsky, twitter })}`, {
padding: 1,
margin: 1,
borderColor: 'green',
borderStyle: 'round',
textAlignment: 'center',
});
}
export class Session {
API_URL;
configFiles;
store;
$logger;
doiLimiter;
executionSemaphore;
proxyAgent;
_shownUpgrade = false;
_latestVersion;
_jupyterSessionManagerPromise;
get log() {
return this.$logger;
}
constructor(opts = {}) {
// use env variable if set
this.API_URL = process.env.API_URL ?? DEFAULT_API_URL;
// trailing slashes will cause issues
this.API_URL = this.API_URL.replace(/\/+$/, '');
console.debug(`building myst-cli session with API URL: ${this.API_URL}`);
this.configFiles = (opts.configFiles ? opts.configFiles : CONFIG_FILES).slice();
this.$logger = opts.logger ?? chalkLogger(LogLevel.info, process.cwd());
this.doiLimiter = opts.doiLimiter ?? pLimit(3);
this.executionSemaphore =
opts.executionSemaphore ?? new Semaphore(Math.max(1, cpus().length - 1));
const proxyUrl = process.env.HTTPS_PROXY;
if (proxyUrl)
this.proxyAgent = new HttpsProxyAgent(proxyUrl);
this.store = createStore(rootReducer);
// Allow the latest version to be loaded
latestVersion('mystmd')
.then((latest) => {
this._latestVersion = latest;
})
.catch(() => null);
}
showUpgradeNotice() {
if (this._shownUpgrade ||
!this._latestVersion ||
version === this._latestVersion ||
isWhiteLabelled())
return;
this.log.info(logUpdateAvailable({
current: version,
latest: this._latestVersion,
upgradeCommand: process.env.MYST_LANG === 'PYTHON' ? PIP_COMMAND : NPM_COMMAND,
bsky: 'mystmd.org',
}));
this._shownUpgrade = true;
}
async reload() {
await findCurrentProjectAndLoad(this, '.');
await findCurrentSiteAndLoad(this, '.');
if (selectors.selectCurrentSitePath(this.store.getState())) {
await reloadAllConfigsForCurrentSite(this);
}
return this;
}
async fetch(url, init) {
const urlOnly = new URL(url.url ?? url);
this.log.debug(`Fetching: ${urlOnly}`);
if (this.proxyAgent && !LOCALHOSTS.includes(urlOnly.hostname)) {
if (!init)
init = {};
init = { agent: this.proxyAgent, ...init };
this.log.debug(`Using HTTPS proxy: ${this.proxyAgent.proxy}`);
}
const logData = { url: urlOnly, done: false };
setTimeout(() => {
if (!logData.done)
this.log.info(`⏳ Waiting for response from ${url}`);
}, 5000);
const resp = await nodeFetch(url, init);
logData.done = true;
return resp;
}
plugins;
async loadPlugins(plugins) {
this.plugins = await loadPlugins(this, plugins);
return this.plugins;
}
sourcePath() {
const state = this.store.getState();
const sitePath = selectors.selectCurrentSitePath(state);
const projectPath = selectors.selectCurrentProjectPath(state);
const root = sitePath ?? projectPath ?? '.';
return path.resolve(root);
}
buildPath() {
return path.join(this.sourcePath(), '_build');
}
sitePath() {
return path.join(this.buildPath(), 'site');
}
contentPath() {
return path.join(this.sitePath(), 'content');
}
publicPath() {
return path.join(this.sitePath(), 'public');
}
_clones = [];
async clone() {
const cloneSession = new Session({
logger: this.log,
doiLimiter: this.doiLimiter,
executionSemaphore: this.executionSemaphore,
configFiles: this.configFiles,
});
await cloneSession.reload();
// TODO: clean this up through better state handling
cloneSession._jupyterSessionManagerPromise = this._jupyterSessionManagerPromise;
this._clones.push(cloneSession);
return cloneSession;
}
getAllWarnings(ruleId) {
const stringWarnings = [];
const warnings = [];
[this, ...this._clones].forEach((session) => {
const sessionWarnings = selectors.selectFileWarningsByRule(session.store.getState(), ruleId);
sessionWarnings.forEach((warning) => {
const stringWarning = JSON.stringify(Object.entries(warning).sort());
if (!stringWarnings.includes(stringWarning)) {
stringWarnings.push(stringWarning);
warnings.push(warning);
}
});
});
return warnings;
}
jupyterSessionManager() {
if (this._jupyterSessionManagerPromise === undefined) {
this._jupyterSessionManagerPromise = this.createJupyterSessionManager();
}
return this._jupyterSessionManagerPromise;
}
async createJupyterSessionManager() {
try {
let partialServerSettings;
// Load from environment
if (process.env.JUPYTER_BASE_URL !== undefined) {
partialServerSettings = {
baseUrl: process.env.JUPYTER_BASE_URL,
token: process.env.JUPYTER_TOKEN,
};
}
else {
// Note: To use an existing Jupyter server use `findExistingJupyterServer`, see #1716
this.log.debug(`Launching jupyter server on ${this.sourcePath()}`);
// Create and load new server
partialServerSettings = await launchJupyterServer(this.sourcePath(), this.log);
}
const serverSettings = ServerConnection.makeSettings(partialServerSettings);
const kernelManager = new KernelManager({ serverSettings });
const manager = new SessionManager({ kernelManager, serverSettings });
// Tie the lifetime of the kernelManager and (potential) spawned server to the manager
manager.disposed.connect(() => {
kernelManager.dispose();
partialServerSettings?.dispose?.();
});
return manager;
}
catch (err) {
this.log.error('Unable to instantiate connection to Jupyter Server', err);
return undefined;
}
}
dispose() {
this._clones.forEach((session) => {
session.dispose();
});
if (this._jupyterSessionManagerPromise) {
this._jupyterSessionManagerPromise.then((manager) => manager?.dispose?.());
this._jupyterSessionManagerPromise = undefined;
}
}
}