UNPKG

@curvenote/cli

Version:
186 lines (182 loc) 7.01 kB
import chalk from 'chalk'; import path from 'node:path'; import { uuidv7 } from 'uuidv7'; import { getGithubUrl } from 'myst-cli'; import { docLinks } from '../docs.js'; import { Project, RemoteSiteConfig } from '../models.js'; import { oxaLinkToId } from '@curvenote/blocks'; import { getFromJournals } from '../utils/api.js'; import CurvenoteVersion from '../version.js'; export function projectLogString(project) { return `"${project.data.title}" (@${project.data.team}/${project.data.name})`; } export const INIT_LOGO_PATH = path.join('public', 'logo.svg'); export function getDefaultSiteConfig(title) { return { title: title || 'My Curve Space', domains: [], options: { logo: INIT_LOGO_PATH, logo_text: title || 'My Curve Space' }, nav: [], actions: [{ title: 'Learn More', url: docLinks.web }], }; } export async function getDefaultSiteConfigFromRemote(session, projectId, siteProject) { const project = await new Project(session, projectId).get(); const remoteSiteConfig = await new RemoteSiteConfig(session, project.id).get(); const siteConfig = getDefaultSiteConfig(); siteConfig.title = project.data.title; siteConfig.options = { logo_text: project.data.title }; if (remoteSiteConfig.data.domains) siteConfig.domains = remoteSiteConfig.data.domains; // Add an entry to the nav if it doesn't exist (i.e. empty list is fine) if (!remoteSiteConfig.data.nav) { siteConfig.nav = [{ title: project.data.title || '', url: `/${siteProject.slug}` }]; } return siteConfig; } export async function getDefaultProjectConfig(title) { const github = await getGithubUrl(); return { id: uuidv7(), title: title || 'my-project', github, }; } const knownServices = new Set(['blocks', 'drafts', 'projects']); export function projectIdFromLink(session, link) { const id = oxaLinkToId(link); if (id) { return id.block.project; } if (link.startsWith('@') && link.split('/').length === 2) { // This is something, maybe, of the form @team/project return link.replace('/', ':'); } if (link.startsWith(session.config.editorApiUrl)) { const [service, project] = link.split('/').slice(3); // https://api.curvenote.com/{service}/{maybeProjectId} if (!knownServices.has(service)) throw new Error('Unknown API URL for project.'); return project; } if (link.startsWith(session.config.editorUrl)) { const [team, project] = link.split('/').slice(-2); return `${team}:${project}`; } return link; } export async function validateLinkIsAProject(session, projectLink) { const id = projectIdFromLink(session, projectLink); let project; try { project = await new Project(session, id).get(); } catch (error) { session.log.error('Could not load project from link.'); if (session.isAnon) { session.log.info(`To add your own Curvenote projects, please authenticate using:\n\ncurvenote token set [token]\n\nLearn more at ${docLinks.auth}`); } return undefined; } session.log.info(chalk.green(`🔍 Found ${projectLogString(project)}`)); return project; } export function processOption(opts) { if (!opts) return undefined; return { ...opts, yes: opts.ci || opts.yes, }; } /** * Normalize GitHub URL to HTTPS clone URL * Handles formats: * - https://github.com/user/repo * - https://github.com/user/repo.git * - git@github.com:user/repo.git * - github.com/user/repo * - user/repo */ export function normalizeGithubUrl(url) { let normalized = url.trim(); // Convert SSH format to HTTPS if (normalized.startsWith('git@github.com:')) { normalized = normalized.replace('git@github.com:', 'https://github.com/'); } // Ensure HTTPS protocol if (!normalized.startsWith('http')) { normalized = 'https://github.com/' + normalized.replace(/^github\.com\//, ''); } // Ensure .git extension for cloning if (!normalized.endsWith('.git')) { normalized = normalized + '.git'; } return normalized; } /** * Generate a new work key (UUID) and validate it against the API to ensure it's available. * Retries up to 3 times if the key is already taken. * * @param session - The session object for API calls * @returns A validated work key that is available * @throws Error if all retry attempts fail, including Node.js version, Curvenote version, and support contact info */ export async function generateNewValidatedWorkKey(session) { const MAX_RETRIES = 3; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { const newKey = uuidv7(); session.log.debug(`Generated work key (attempt ${attempt}/${MAX_RETRIES}): ${newKey}`); try { const { exists } = await getFromJournals(session, `/works/key/${newKey}`); if (!exists) { session.log.debug(`Work key validated and available: ${newKey}`); return newKey; } session.log.debug(`Work key already exists, retrying...`); } catch (error) { // If the API call fails, we'll retry with a new key session.log.debug(`Key validation failed, retrying: ${error.message}`); } } // All retries exhausted - throw error with diagnostic information const nodeVersion = process.version; const errorMessage = `Failed to generate a unique work key after ${MAX_RETRIES} attempts. This is an unexpected error that may indicate an issue with UUID generation or API connectivity. Diagnostic Information: - Node.js version: ${nodeVersion} - Curvenote version: ${CurvenoteVersion} Please contact support@curvenote.com for assistance.`; throw new Error(errorMessage); } /** * Clean author/contributor objects by removing computed or internal fields * that shouldn't be persisted to the config file (id, nameParsed) */ function cleanContributors(contributors) { if (!contributors || !Array.isArray(contributors)) return contributors; return contributors.map((contributor) => { if (!contributor || typeof contributor !== 'object') return contributor; // Create a shallow copy and remove unwanted fields const { id, nameParsed, ...cleaned } = contributor; return cleaned; }); } /** * Prepare project config for writing by removing computed/internal fields * This makes the YAML more compact and readable */ export function cleanProjectConfigForWrite(projectConfig) { const result = { ...projectConfig }; // Clean authors (remove id and nameParsed) if (result.authors) { result.authors = cleanContributors(result.authors); } // Clean contributors (remove id and nameParsed) if (result.contributors) { result.contributors = cleanContributors(result.contributors); } return result; }