@curvenote/cli
Version:
CLI Client library for Curvenote
486 lines (485 loc) • 20.4 kB
JavaScript
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;
}