UNPKG

@curvenote/cli

Version:
451 lines (450 loc) 19.7 kB
import path from 'node:path'; import { createStore } from 'redux'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { default as nodeFetch } from 'node-fetch'; import pLimit from 'p-limit'; import latestVersion from 'latest-version'; import { findCurrentProjectAndLoad, findCurrentSiteAndLoad, logUpdateAvailable, reloadAllConfigsForCurrentSite, selectors, } from 'myst-cli'; import { LogLevel, basicLogger, chalkLogger } from 'myst-cli-utils'; // use the version mystjs brings in! // 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, } 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 { 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 = {}) { var _a, _b; this.$activeTokens = {}; this._shownUpgrade = false; this._clones = []; this.configFiles = CONFIG_FILES; this.$logger = (_a = opts.logger) !== null && _a !== void 0 ? _a : basicLogger(LogLevel.info); this.doiLimiter = (_b = opts.doiLimiter) !== null && _b !== void 0 ? _b : pLimit(3); const proxyUrl = process.env.HTTPS_PROXY; if (proxyUrl) { this.log.warn(`Using HTTPS proxy: ${proxyUrl}`); this.proxyAgent = new HttpsProxyAgent(proxyUrl); } this.API_URL = 'NOTSET'; this.store = createStore(rootReducer); // Allow the latest version to be loaded latestVersion('curvenote') .then((latest) => { this._latestVersion = latest; }) .catch(() => null); } 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() { var _a, _b, _c, _d, _e; if (!((_a = this.$activeTokens.session) === null || _a === void 0 ? void 0 : _a.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, ...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 = (_c = (_b = this.$config) === null || _b === void 0 ? void 0 : _b.editorApiUrl) !== null && _c !== void 0 ? _c : 'INVALID'; 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 = (_e = (_d = this.$config) === null || _d === void 0 ? void 0 : _d.editorApiUrl) !== null && _e !== void 0 ? _e : 'INVALID'; } 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; } async clone() { var _a; const cloneSession = await Session.create((_a = this.$activeTokens.user) === null || _a === void 0 ? void 0 : _a.token, { logger: this.$logger, doiLimiter: this.doiLimiter, }); 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) { var _a; const urlOnly = new URL((_a = url.url) !== null && _a !== void 0 ? _a : 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); try { const resp = await nodeFetch(url, init); logData.done = true; checkForPlatformAPIClientVersionRejection(this.log, resp); return resp; } catch (e) { console.log('session fetch error', e); throw e; } } async loadPlugins() { // Early return if a promise has already been initiated if (this._pluginPromise) return this._pluginPromise; this._pluginPromise = loadProjectPlugins(this); const loadedPlugins = await this._pluginPromise; this.plugins = combinePlugins([getBuiltInPlugins(), loadedPlugins]); 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() { var _a; const state = this.store.getState(); const sitePath = selectors.selectCurrentSitePath(state); const projectPath = selectors.selectCurrentProjectPath(state); const root = (_a = sitePath !== null && sitePath !== void 0 ? sitePath : projectPath) !== null && _a !== void 0 ? _a : '.'; 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(() => { var _a; kernelManager.dispose(); (_a = partialServerSettings === null || partialServerSettings === void 0 ? void 0 : partialServerSettings.dispose) === null || _a === void 0 ? void 0 : _a.call(partialServerSettings); }); return manager; } catch (err) { this.log.error('Unable to instantiate connection to Jupyter Server', err); return undefined; } } dispose() { if (this._jupyterSessionManagerPromise) { this._jupyterSessionManagerPromise.then((manager) => { var _a; return (_a = manager === null || manager === void 0 ? void 0 : manager.dispose) === null || _a === void 0 ? void 0 : _a.call(manager); }); this._jupyterSessionManagerPromise = undefined; } } } export async function anonSession(opts) { const logger = chalkLogger(getLogLevel(opts === null || opts === void 0 ? void 0 : opts.debug), process.cwd()); const session = await Session.create(undefined, { logger }); return session; } export async function getSession(opts) { var _a; const logger = chalkLogger(getLogLevel(opts === null || opts === void 0 ? void 0 : opts.debug), process.cwd()); const data = getTokens(logger); if (!data.current && !(opts === null || opts === void 0 ? void 0 : 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 ((_a = data.saved) === null || _a === void 0 ? void 0 : _a.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; }