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