@curvenote/cli
Version:
CLI Client library for Curvenote
222 lines (221 loc) โข 9.54 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import { join, resolve, basename } from 'node:path';
import fs from 'node:fs';
import yaml from 'js-yaml';
import { loadProjectFromDisk } from 'myst-cli';
import { interactiveCloneQuestions } from '../clone.js';
import questions from '../questions.js';
import { getDefaultProjectConfig, normalizeGithubUrl, generateNewValidatedWorkKey, } from '../utils.js';
import { DEFAULT_TEMPLATE_INIT_QUESTIONS, runTemplateInitQuestions, } from './templateInitQuestions.js';
// ============================================================================
// PROJECT INITIALIZATION HANDLERS
// These functions create NEW projects from different sources
// ============================================================================
/**
* Handle initialization from local folder content
*/
export async function handleLocalFolderContent(session, currentPath, projectConfigPaths, opts, existingProjectConfig, existingTitle) {
// Note: The nested project paths are now shown in the nestedProjectChoice question
// in index.ts, so we don't need to show them again here
let title = existingTitle;
if (!opts.yes) {
const promptTitle = await inquirer.prompt([questions.title({ title: title || '' })]);
title = promptTitle.title;
}
let projectConfig = existingProjectConfig;
if (!projectConfig) {
try {
await loadProjectFromDisk(session, currentPath);
session.log.info(`๐ Creating project config`);
projectConfig = await getDefaultProjectConfig(title);
projectConfigPaths.unshift(currentPath);
}
catch {
if (!projectConfigPaths.length) {
throw Error(`No markdown or notebook files found`);
}
session.log.info(`๐งน No additional markdown or notebook files found`);
}
}
return { projectConfig, title, currentPath };
}
/**
* Handle initialization from remote Curvenote project
*/
export async function handleCurvenoteImport(session, currentPath, opts, providedCurvenoteUrl) {
// Get Curvenote URL (from CLI option or interactive prompt)
let curvenoteUrl = providedCurvenoteUrl;
if (!curvenoteUrl) {
const curvenoteResponse = await inquirer.prompt([questions.projectLink()]);
curvenoteUrl = curvenoteResponse.projectLink;
}
if (!curvenoteUrl) {
throw new Error('Curvenote project URL is required');
}
// Extract project name from URL for default folder name
// URL format: https://curvenote.com/@username/project-name
let defaultPath = '.';
if (providedCurvenoteUrl && !opts.output) {
// Only set default folder for CLI mode (when URL was provided via --curvenote)
const urlPath = curvenoteUrl.replace(/^https?:\/\/[^/]+\//, ''); // Remove domain
const pathSegments = urlPath.split('/').filter(Boolean);
if (pathSegments.length > 0) {
defaultPath = pathSegments[pathSegments.length - 1]; // Last segment
}
}
// Use the existing clone logic with the URL
const results = await interactiveCloneQuestions(session, {
...opts,
remote: curvenoteUrl,
path: opts.output || (defaultPath !== '.' ? defaultPath : undefined),
});
const { siteProject } = results;
const projectConfig = results.projectConfig;
const title = projectConfig.title;
const targetPath = siteProject.path;
return { projectConfig, title, currentPath: targetPath };
}
/**
* Handle initialization from GitHub repository template
*/
export async function handleGithubImport(session, currentPath, opts, providedGithubUrl) {
session.log.info(`\n๐ ${chalk.bold('Initializing from GitHub template...')}\n`);
// Get GitHub URL (from CLI option or interactive prompt)
let githubUrl = providedGithubUrl;
if (!githubUrl) {
const githubResponse = await inquirer.prompt([questions.githubUrl()]);
githubUrl = githubResponse.githubUrl;
}
if (!githubUrl) {
throw new Error('GitHub URL is required');
}
// Normalize the GitHub URL
const cloneUrl = normalizeGithubUrl(githubUrl);
session.log.debug(`Normalized GitHub URL: ${cloneUrl}`);
// Determine target path
const repoName = basename(cloneUrl, '.git');
let targetFolder;
// Get target folder from --output option or interactive prompt
if (opts.output) {
// CLI mode with --output option
targetFolder = opts.output;
}
else if (!opts.github) {
// Interactive mode: ask for target folder
const folderResponse = await inquirer.prompt([
questions.githubFolder({ defaultFolder: repoName }),
]);
targetFolder = folderResponse.githubFolder;
}
// If opts.github is set but no opts.output, targetFolder remains undefined (uses repoName default)
let targetPath;
let displayName;
if (targetFolder === '.') {
// Clone into current directory
targetPath = currentPath;
displayName = basename(currentPath);
}
else {
// Use provided folder or default to repo name
const folderName = targetFolder || repoName;
targetPath = resolve(currentPath, folderName);
displayName = folderName;
// Check if target directory already exists
if (fs.existsSync(targetPath)) {
throw new Error(`Directory "${folderName}" already exists. Please remove it or choose a different location.`);
}
}
session.log.info(`๐ฅ Cloning repository to ${chalk.cyan(displayName)}...`);
// Clone the repository using git command
const { exec } = await import('node:child_process');
const { promisify } = await import('node:util');
const execPromise = promisify(exec);
try {
await execPromise(`git clone ${cloneUrl} ${targetPath}`, {
cwd: currentPath,
});
session.log.info(chalk.green(`โ Repository cloned successfully`));
}
catch (error) {
throw new Error(`Failed to clone repository: ${error.message}\nPlease ensure you have git installed and the repository URL is correct.`);
}
// Check for curvenote.yml or myst.yml
const curvenoteYmlPath = join(targetPath, 'curvenote.yml');
const mystYmlPath = join(targetPath, 'myst.yml');
let configPath;
if (fs.existsSync(curvenoteYmlPath)) {
configPath = curvenoteYmlPath;
session.log.info(`๐ Found ${chalk.bold('curvenote.yml')}`);
}
else if (fs.existsSync(mystYmlPath)) {
configPath = mystYmlPath;
session.log.info(`๐ Found ${chalk.bold('myst.yml')}`);
}
let projectConfig;
let title;
if (configPath) {
// Load raw YAML file directly (no expansion, no validation)
try {
session.log.info(`๐ Loading project configuration...`);
const rawYamlContent = fs.readFileSync(configPath, 'utf-8');
const rawConfig = yaml.load(rawYamlContent);
// Extract project config directly from the project key
if (rawConfig?.project) {
projectConfig = rawConfig.project;
if (projectConfig) {
title = projectConfig.title;
session.log.info(chalk.green(`โ Project configuration loaded: ${chalk.bold(title)}`));
}
}
}
catch (error) {
session.log.warn(`Warning: Found configuration file but failed to load it: ${error.message}`);
session.log.info(`Continuing with default project setup...`);
}
}
else {
session.log.info(chalk.yellow(`โ ๏ธ No curvenote.yml or myst.yml found in the repository\nWill create a new project configuration.`));
}
// If no valid config was found, create a default one
if (!projectConfig) {
session.log.info(`๐ Creating project config from repository content`);
const repoTitle = repoName.replace(/-/g, ' ').replace(/_/g, ' ');
projectConfig = await getDefaultProjectConfig(repoTitle);
title = repoTitle;
}
// Always generate a new UUID for the project ID (don't reuse template's ID)
if (projectConfig) {
const newUuid = await generateNewValidatedWorkKey(session);
let newId;
if (projectConfig.id) {
// If template has an ID, append the new UUID after a hyphen
newId = `${projectConfig.id}-${newUuid}`;
session.log.debug(`Appending UUID to existing ID: ${projectConfig.id} -> ${newId}`);
}
else {
// If no ID exists, just use the new UUID
newId = newUuid;
session.log.debug(`Generating new project ID: ${newId}`);
}
projectConfig = {
...projectConfig,
id: newId,
};
}
// Ask template initialization questions (both CLI --github and interactive modes)
// Try to load custom questions from template.yml in the cloned directory
if (projectConfig) {
const templateMetadata = await runTemplateInitQuestions(session, targetPath, DEFAULT_TEMPLATE_INIT_QUESTIONS);
// Merge template metadata into project config
projectConfig = {
...projectConfig,
...templateMetadata,
};
// Update title if provided
if (templateMetadata.title) {
title = templateMetadata.title;
}
}
return { projectConfig, title, currentPath: targetPath };
}