UNPKG

@curvenote/cli

Version:
486 lines (485 loc) 20.4 kB
import path from 'node:path'; import { cpus } from 'node:os'; import { createStore } from 'redux'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { default as nodeFetch } from 'node-fetch'; import pLimit from 'p-limit'; import { Semaphore } from 'async-mutex'; import latestVersion from 'latest-version'; import { findCurrentProjectAndLoad, findCurrentSiteAndLoad, logUpdateAvailable, reloadAllConfigsForCurrentSite, selectors, } from 'myst-cli'; import { LogLevel, basicLogger, chalkLogger } from 'myst-cli-utils'; // Resolved via myst-cli at runtime; types come from IMystSession below. // eslint-disable-next-line import/no-extraneous-dependencies import { KernelManager, ServerConnection, SessionManager } from '@jupyterlab/services'; import { findExistingJupyterServer, launchJupyterServer } from 'myst-execute'; import { rootReducer } from '../store/index.js'; import { decodeTokenAndCheckExpiry, getTokens } from './tokens.js'; import { XClientName } from '@curvenote/blocks'; import CLIENT_VERSION from '../version.js'; import { loadProjectPlugins } from './plugins.js'; import { combinePlugins, getBuiltInPlugins } from './builtinPlugins.js'; import { makeDefaultConfig, ensureBaseUrl, checkForPlatformAPIClientVersionRejection, withQuery, checkForCurvenoteAPIClientVersionRejection, DEFAULT_EDITOR_API_URL, } from './utils/index.js'; import jwt from 'jsonwebtoken'; import { getLogLevel } from './utils/getLogLevel.js'; import { checkUserTokenStatus } from './auth/checkUserTokenStatus.js'; const LOCALHOSTS = ['localhost', '127.0.0.1', '::1']; const CONFIG_FILES = ['curvenote.yml', 'myst.yml']; export class Session { $config; $activeTokens = {}; $logger; API_URL; configFiles; store; doiLimiter; executionSemaphore; plugins = combinePlugins([getBuiltInPlugins()]); server; proxyAgent; _shownUpgrade = false; _latestVersion; _jupyterSessionManagerPromise; get log() { return this.$logger; } get isAnon() { return !(this.$activeTokens.user || this.$activeTokens.session); } get config() { if (!this.$config) throw new Error('No config set on session'); return this.$config; } get activeTokens() { return this.$activeTokens; } static async create(token, opts = {}) { const session = new Session(opts); if (token) { const { decoded } = decodeTokenAndCheckExpiry(token, session.$logger, false); session.log.debug('Decoded token', JSON.stringify(decoded, null, 2)); if (decoded.iss.endsWith('/session')) { session.log.debug('Creating session with token (session):'); session.$activeTokens.session = { token, decoded }; } else { session.log.debug('Creating session with token (decoded):'); session.log.debug(JSON.stringify(decoded, null, 2)); session.setUserToken({ token, decoded }); await session.refreshSessionToken(); } } await session.configure(); return session; } constructor(opts = {}) { this.configFiles = (opts.configFiles ?? CONFIG_FILES).slice(); this.$logger = opts.logger ?? basicLogger(LogLevel.info); 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.log.warn(`Using HTTPS proxy: ${proxyUrl}`); this.proxyAgent = new HttpsProxyAgent(proxyUrl); } // We are still setting this as some of the myst-cli functions rely on it this.API_URL = DEFAULT_EDITOR_API_URL; this.store = createStore(rootReducer); // Allow the latest version to be loaded latestVersion('curvenote') .then((latest) => { this._latestVersion = latest; }) .catch(() => null); } setLogger(logger) { this.$logger = logger; } setUserToken(token) { this.$activeTokens.user = token; } async refreshSessionToken(opts = { checkStatusOnFailure: true }) { if (!this.$activeTokens.user) { if (!this.$activeTokens.session) { throw new Error('No user or session token to refresh.'); } const { expired } = decodeTokenAndCheckExpiry(this.$activeTokens.session.token, this.log); if (expired === 'soon') { this.log.debug('Session token will expire soon.'); } else if (expired) { throw new Error('Session token is expired and no user token provided.'); } return; // no user token, nothing left to do } // There is a user token, meaning refresh is possible if (this.$activeTokens.session) { // check current session token const { expired } = decodeTokenAndCheckExpiry(this.$activeTokens.session.token, this.log, false); if (expired === 'soon') this.log.debug('SessionToken: The session token will expire soon.'); if (expired) this.log.debug('SessionToken: The session token has expired.'); if (expired === 'soon' || expired) { this.$activeTokens.session = undefined; } else return; // no need to refresh } // Request a new session token this.log.debug('SessionToken: requesting a new session token.'); const { decoded: { aud }, } = this.$activeTokens.user; try { const response = await this.fetch(aud, { method: 'post', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.$activeTokens.user.token}`, }, }); if (!response.ok) { this.log.debug(`Response: ${response.status} ${response.statusText}`); throw new Error(`API Response: ${response.status} ${response.statusText}`); } const json = (await response.json()); if (!json.session) throw new Error("API Response: There was an error in the response, expected a 'session' in the JSON object."); const decoded = jwt.decode(json.session); this.$activeTokens.session = { token: json.session, decoded }; this.log.debug('SessionToken: new session token created.'); this.log.debug('SessionToken payload:'); this.log.debug(JSON.stringify(decoded, null, 2)); } catch (error) { if (opts.checkStatusOnFailure) { this.log.error(`⛔️ There was a problem with your API token or the API at ${aud} is unreachable.`); this.log.error('If the error persists try generating a new token or contact support@curvenote.com.'); await checkUserTokenStatus(this); } throw error; } } async getHeaders() { const headers = { 'X-Client-Name': XClientName.javascript, 'X-Client-Version': CLIENT_VERSION, }; try { await this.refreshSessionToken(); } catch (error) { this.log.debug(error.message); } if (this.$activeTokens.session) { headers.Authorization = `Bearer ${this.$activeTokens.session.token}`; } return headers; } async configure() { if (!this.$activeTokens.session?.decoded.aud) { this.log.debug(`Configured for anonymous session.`); this.$config = makeDefaultConfig(); this.$config.anonymous = true; this.log.debug(`Configuration set: "${JSON.stringify(this.$config, null, 2)}".\n`); return; } const { aud, cfg } = this.$activeTokens.session.decoded; const audience = Array.isArray(aud) ? aud[0] : this.$activeTokens.session.decoded.aud; const defaultConfig = makeDefaultConfig(audience); if (cfg) { this.log.debug(`Configure using 'cfg' claim: "${cfg}".`); try { const headers = await this.getHeaders(); this.log.debug(`GET ${cfg}`); const response = await this.fetch(cfg, { method: 'get', headers: { 'Content-Type': 'application/json', ...headers, }, }); if (response.ok) { this.log.debug(`Response ok.`); const configFromApi = (await response.json()); this.$config = { ...defaultConfig, // ensure the deploymentCdnUrl is set ...configFromApi, }; this.log.debug(`Configuration set: "${JSON.stringify(this.$config, null, 2)}".`); // We are still setting this as some of the myst-cli functions rely on it this.API_URL = this.$config?.editorApiUrl ?? DEFAULT_EDITOR_API_URL; return; } const json = await response.json(); this.log.debug(`Response not ok: ${response.status} "${response.statusText}" ${JSON.stringify(json)}.`); } catch (e) { this.log.debug(`Failed to fetch config from "${cfg}".`); this.log.debug(e); } this.log.debug('Falling back to default configuration via audience.'); } this.log.debug(`Configure using audience: "${audience}".`); this.$config = defaultConfig; this.log.debug(`Configuration set: "${JSON.stringify(this.$config, null, 2)}".\n`); // We are still setting this as some of the myst-cli functions rely on it this.API_URL = this.$config?.editorApiUrl ?? DEFAULT_EDITOR_API_URL; } showUpgradeNotice() { if (this._shownUpgrade || !this._latestVersion || CLIENT_VERSION === this._latestVersion) return; this.log.info(logUpdateAvailable({ current: CLIENT_VERSION, latest: this._latestVersion, upgradeCommand: 'npm i -g curvenote@latest', twitter: 'curvenote', })); this._shownUpgrade = true; } _clones = []; async clone() { const cloneSession = await Session.create(this.$activeTokens.user?.token, { logger: this.$logger, 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; } async reload() { await findCurrentProjectAndLoad(this, '.'); await findCurrentSiteAndLoad(this, '.'); if (selectors.selectCurrentSitePath(this.store.getState())) { await reloadAllConfigsForCurrentSite(this); } return this; } async fetch(url, init) { const MAX_REDIRECTS = 10; let currentUrl = url.url ?? url.toString?.() ?? String(url); let currentInit = init ?? {}; let resp; for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { const urlOnly = new URL(currentUrl); this.log.debug(`Fetching: ${urlOnly}`); const fetchInit = { ...currentInit, redirect: 'manual', }; if (this.proxyAgent && !LOCALHOSTS.includes(urlOnly.hostname)) { fetchInit.agent = this.proxyAgent; this.log.debug(`Using HTTPS proxy: ${this.proxyAgent.proxy}`); } const logData = { url: currentUrl, done: false }; setTimeout(() => { if (!logData.done) this.log.info(`⏳ Waiting for response from ${currentUrl}`); }, 5000); try { resp = await nodeFetch(currentUrl, fetchInit); logData.done = true; } catch (e) { console.log('session fetch error', e); throw e; } const isRedirect = [301, 302, 307, 308].includes(resp.status); const location = resp.headers.get('location'); if (isRedirect && location && redirectCount < MAX_REDIRECTS) { currentUrl = new URL(location, currentUrl).toString(); currentInit = { method: currentInit.method, headers: currentInit.headers, body: currentInit.body, }; this.log.debug(`Following redirect to ${currentUrl}`); continue; } checkForPlatformAPIClientVersionRejection(this.log, resp); return resp; } throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`); } _pluginPromise; async loadPlugins(plugins) { this.plugins = await loadProjectPlugins(this, plugins); return this.plugins; } async get(url, query) { if (!this.$config) throw new Error('Cannot make API requests without an configured session'); const parsed = ensureBaseUrl(url, this.$config.apiUrl); // allow origins from caller const fullUrl = withQuery(parsed.toString(), query); const headers = await this.getHeaders(); this.log.debug(`GET ${url}`); const response = await this.fetch(fullUrl, { method: 'get', headers: { 'Content-Type': 'application/json', ...headers, }, }); const json = (await response.json()); checkForCurvenoteAPIClientVersionRejection(this.log, response, json); checkForPlatformAPIClientVersionRejection(this.log, response); return { ok: response.ok, status: response.status, json, }; } async patch(url, data) { return this.post(url, data, 'patch'); } async post(url, data, method = 'post') { if (!this.$config || this.$config.anonymous) throw new Error('Cannot make API requests without an authenticated session'); const parsed = ensureBaseUrl(url, this.$config.apiUrl); // allow origins from caller const fullUrl = parsed.toString(); const headers = await this.getHeaders(); this.log.debug(`${method.toUpperCase()} ${fullUrl}`); const response = await this.fetch(fullUrl, { method, headers: { 'Content-Type': 'application/json', ...headers, }, body: JSON.stringify(data), }); const json = (await response.json()); if (!response.ok) { const dataString = JSON.stringify(json, null, 2); this.log.debug(`${method.toUpperCase()} FAILED ${url}: ${response.status}\n\n${dataString}`); } checkForCurvenoteAPIClientVersionRejection(this.log, response, json); checkForPlatformAPIClientVersionRejection(this.log, response); return { ok: response.ok, status: response.status, json, }; } 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'); } 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 { // Load existing running server const existing = await findExistingJupyterServer(this); if (existing) { this.log.debug(`Found existing server on: ${existing.appUrl}`); partialServerSettings = existing; } else { 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; } } } export async function anonSession(opts) { const logger = chalkLogger(getLogLevel(opts?.debug), process.cwd()); const session = await Session.create(undefined, { logger }); return session; } export async function getSession(opts) { const logger = chalkLogger(getLogLevel(opts?.debug), process.cwd()); const data = getTokens(logger); if (!data.current && !opts?.hideNoTokenWarning) { logger.warn('No token was found in settings or CURVENOTE_TOKEN. Session is not authenticated.'); logger.info('You can set a new token with: `curvenote token set API_TOKEN`'); if (data.saved?.length) { logger.info('or you can select an existing token with: `curvenote token select`'); } } let session; try { session = await Session.create(data.current, { logger }); if (data.environment) { logger.warn('Checking user token...'); await checkUserTokenStatus(session); } } catch (error) { logger.error(error.message); process.exit(1); } return session; }