import chalk from 'chalk'; import fs from 'fs-extra'; import path from 'path'; import inquirer from 'inquirer'; import axios from 'axios'; import AdmZip from 'adm-zip'; import { getAuthHeaders } from './utils/webLogin'; import { startBackgroundInstall } from './utils/installProjectDependencies'; import { createApiError, createCommandError, createConfigError, printCliError, } from './utils/cliError'; import { APP_CONFIG, getPinmeApiUrl } from './utils/config'; import { uploadPath } from './services/uploadService'; import { printHighlightedUrl } from './utils/urlDisplay'; import { patchPrebuiltFrontendDist } from './utils/prebuiltDistConfig'; import { getValidatedWorkerMetadataContent } from './utils/workerMetadata'; import { downloadFileWithRetries, getDownloadErrorMessage } from './utils/downloadFile'; import tracker, { getTrackErrorReason } from './utils/tracker'; import { TRACK_EVENTS, TRACK_PAGES, resolveTrackAction, } from './utils/trackerEvents'; // Template directory - relative to bin folder (works both in dev and npm) const PROJECT_DIR = process.cwd(); const TEMPLATE_BRANCH = process.env.PINME_TEMPLATE_BRANCH || 'main'; // 模板仓库地址 (使用 HTTPS 下载 zip) const TEMPLATE_REPO = 'glitternetwork/pinme-worker-template'; const TEMPLATE_REPO_NAME = TEMPLATE_REPO.split('/').pop() || 'pinme-worker-template'; function getTemplateZipUrl(branch: string): string { return `https://github.com/${TEMPLATE_REPO}/archive/refs/heads/${encodeURIComponent(branch)}.zip`; } interface CreateOptions { name?: string; force?: boolean; } interface CreateWorkerResponse { api_domain: string; metadata: string; project_name: string; uuid: string; api_key?: string; public_client_config?: Record; } function buildPublicClientConfigExport(publicClientConfig: Record): string { return `export const public_client_config = ${JSON.stringify(publicClientConfig, null, 2)};\n`; } function injectPublicClientConfigIntoFile(fileContent: string, publicClientConfig: Record): string { const configExport = buildPublicClientConfigExport(publicClientConfig).trimEnd(); const configPattern = /export\s+const\s+public_client_config\s*=\s*\{[\s\S]*?\};?/m; if (configPattern.test(fileContent)) { return fileContent.replace(configPattern, configExport); } const trimmed = fileContent.trimEnd(); if (!trimmed) { return `${configExport}\n`; } return `${trimmed}\n\n${configExport}\n`; } function resolveExtractedTemplateDir(extractDir: string): string { const entries = fs.readdirSync(extractDir, { withFileTypes: true }); const templateDir = entries.find((entry) => ( entry.isDirectory() && entry.name.startsWith(`${TEMPLATE_REPO_NAME}-`) )); if (!templateDir) { throw createConfigError('Downloaded template archive structure is invalid.', [ `Expected a directory starting with \`${TEMPLATE_REPO_NAME}-\` inside the extracted archive.`, `Template branch: ${TEMPLATE_BRANCH}`, ]); } return path.join(extractDir, templateDir.name); } function updateFrontendUrlInConfig(configPath: string, frontendUrl: string): void { let config = fs.readFileSync(configPath, 'utf-8'); if (config.includes('frontend_url')) { config = config.replace( /frontend_url\s*=\s*"[^"]*"/, `frontend_url = "${frontendUrl}"`, ); } else { config = config.replace( /(project_name\s*=\s*"[^"]*"\n)/, `$1frontend_url = "${frontendUrl}"\n`, ); } fs.writeFileSync(configPath, config); } function getProjectManagementUrl(projectName: string): string { return `${APP_CONFIG.projectPeviewUrl}${projectName}`; } /** * Create a new project from template * 1. Check login * 2. Call API to create worker/D1 database * 3. Copy template files * 4. Update configuration files */ export default async function createCmd(options: CreateOptions): Promise { try { // Check if user is logged in const headers = getAuthHeaders(); if (!headers['authentication-tokens'] || !headers['token-address']) { throw createConfigError('No valid local login session was found.', [ 'Run `pinme login` and retry.', ]); } console.log(chalk.blue('Creating new project from template...\n')); // Get project name from options or prompt let projectName = options.name; if (!projectName) { const answers = await inquirer.prompt([ { type: 'input', name: 'projectName', message: 'Enter project name:', validate: (input: string) => { if (!input.trim()) return 'Project name is required'; if (!/^[a-zA-Z0-9-_]+$/.test(input)) { return 'Project name can only contain letters, numbers, hyphens and underscores'; } return true; }, }, ]); projectName = answers.projectName; } const targetDir = path.join(PROJECT_DIR, projectName); // Check if directory exists if (fs.existsSync(targetDir) && !options.force) { console.log(chalk.yellow(`\nDirectory "${projectName}" already exists.`)); const answers = await inquirer.prompt([ { type: 'confirm', name: 'overwrite', message: 'Do you want to overwrite it?', default: false, }, ]); if (!answers.overwrite) { console.log(chalk.gray('Cancelled.')); process.exit(0); } fs.removeSync(targetDir); } // 1. Call API to create worker/D1 console.log(chalk.blue('\n1. Creating worker and database...')); const apiUrl = getPinmeApiUrl('/create_worker'); console.log(chalk.gray(`API URL: ${apiUrl}`)); // Convert project name to lowercase (API requires lowercase) const normalizedProjectName = projectName.toLowerCase(); console.log(chalk.gray(`Project name: ${normalizedProjectName}`)); let workerData: CreateWorkerResponse; try { const response = await axios.post(apiUrl, { project_name: normalizedProjectName, }, { headers: { ...headers, 'Content-Type': 'application/json', }, }); const data = response.data; if (data.code !== 200) { throw createApiError('project creation', { response: { status: response.status, data } }, [ `Project name: ${normalizedProjectName}`, `Endpoint: ${apiUrl}`, ]); } workerData = data.data; console.log(chalk.gray(` API Response: ${JSON.stringify(workerData)}`)); console.log(chalk.green(` API Domain: ${workerData.api_domain}`)); console.log(chalk.green(` Project Name: ${workerData.project_name}`)); } catch (error: any) { throw createApiError('project creation', error, [ `Project name: ${normalizedProjectName}`, `Endpoint: ${apiUrl}`, ]); } // 2. Download template from repository (using HTTPS, no git required) console.log(chalk.blue('\n2. Downloading template from repository...')); const zipPath = path.join(PROJECT_DIR, 'template.zip'); const extractDir = path.join(PROJECT_DIR, `.pinme-template-${Date.now()}`); const templateZipUrl = process.env.PINME_TEMPLATE_ZIP_URL || getTemplateZipUrl(TEMPLATE_BRANCH); console.log(chalk.gray(` Template branch: ${TEMPLATE_BRANCH}`)); try { const downloadResult = await downloadFileWithRetries(templateZipUrl, zipPath, { attempts: 3, retryDelayMs: 2000, minBytes: 100, onAttempt: (attempt, attempts) => { console.log(chalk.gray(` Download attempt ${attempt}/${attempts}...`)); }, onAttemptFailure: (attempt, error) => { console.log(chalk.yellow(` Attempt ${attempt} failed: ${getDownloadErrorMessage(error)}`)); }, }); console.log(chalk.green(` Template archive downloaded (${downloadResult.bytes} bytes)`)); } catch (error: any) { throw createCommandError('template download', `download "${templateZipUrl}" to "${zipPath}"`, error, [ 'Check your network connection and retry `pinme create`.', `Verify that the template branch exists: ${TEMPLATE_BRANCH}`, ]); } try { fs.ensureDirSync(extractDir); // Extract with Node so Windows users do not need a system `unzip` command. const templateZip = new AdmZip(zipPath); templateZip.extractAllTo(extractDir, true); // Move files from extracted subdirectory to target directory const subDir = resolveExtractedTemplateDir(extractDir); fs.copySync(subDir, targetDir); // Clean up zip file fs.removeSync(zipPath); fs.removeSync(extractDir); // Remove any existing node_modules to avoid platform-specific dependency leftovers. // This fixes issues with rollup platform-specific dependencies const nodeModulesPath = path.join(targetDir, 'node_modules'); if (fs.existsSync(nodeModulesPath)) { console.log(chalk.gray(' Removing existing node_modules...')); fs.removeSync(nodeModulesPath); } // Also clean frontend and backend subdirectories const frontendNodeModules = path.join(targetDir, 'frontend', 'node_modules'); const backendNodeModules = path.join(targetDir, 'backend', 'node_modules'); if (fs.existsSync(frontendNodeModules)) fs.removeSync(frontendNodeModules); if (fs.existsSync(backendNodeModules)) fs.removeSync(backendNodeModules); console.log(chalk.green(` Template downloaded to: ${targetDir}`)); } catch (error: any) { throw createCommandError('template extraction', `extract "${zipPath}" to "${extractDir}"`, error, [ 'Check whether the downloaded template archive is valid and has the expected GitHub archive structure.', ]); } // 3. Update pinme.toml with project_name console.log(chalk.blue('\n3. Updating configuration...')); const configPath = path.join(targetDir, 'pinme.toml'); const config = fs.readFileSync(configPath, 'utf-8'); // Update project_name let updatedConfig = config.replace( /project_name = ".*"/, `project_name = "${workerData.project_name}"` ); fs.writeFileSync(configPath, updatedConfig); console.log(chalk.green(` Updated pinme.toml`)); console.log(chalk.gray(` metadata: ${workerData.metadata}`)); const workerMetadataContent = getValidatedWorkerMetadataContent( workerData.metadata, workerData.project_name, ); // 4. Save metadata to backend directory const backendDir = path.join(targetDir, 'backend'); if (fs.existsSync(backendDir)) { fs.writeFileSync( path.join(backendDir, 'metadata.json'), workerMetadataContent ); console.log(chalk.green(` Saved metadata.json`)); } // 4.1 Update API_KEY in backend/wrangler.toml const wranglerPath = path.join(backendDir, 'wrangler.toml'); if (fs.existsSync(wranglerPath) && workerData.api_key) { let wranglerContent = fs.readFileSync(wranglerPath, 'utf-8'); wranglerContent = wranglerContent.replace( /^name = ".*"$/m, `name = "${workerData.project_name}"` ); fs.writeFileSync(wranglerPath, wranglerContent); console.log(chalk.green(` Updated backend/wrangler.toml API_KEY`)); } const frontendConfigPath = path.join(targetDir, 'frontend', 'src', 'utils', 'config.ts'); if (workerData.public_client_config) { const frontendConfigContent = fs.existsSync(frontendConfigPath) ? fs.readFileSync(frontendConfigPath, 'utf-8') : ''; fs.ensureDirSync(path.dirname(frontendConfigPath)); fs.writeFileSync( frontendConfigPath, injectPublicClientConfigIntoFile(frontendConfigContent, workerData.public_client_config) ); console.log(chalk.green(` Updated frontend/src/utils/config.ts public_client_config`)); } // 5. Create .env file from .env.example (in frontend directory) const envExamplePath = path.join(targetDir, 'frontend', '.env.example'); const envPath = path.join(targetDir, 'frontend', '.env'); if (fs.existsSync(envExamplePath)) { let envContent = fs.readFileSync(envExamplePath, 'utf-8'); // Replace your-project with actual project name envContent = envContent.replace(/your-project/g, workerData.project_name); // Update API_URL - match entire line envContent = envContent.replace( /^VITE_API_URL=.*$/m, `VITE_API_URL=${workerData.api_domain}` ); fs.writeFileSync(envPath, envContent); console.log(chalk.green(` Created frontend/.env file`)); console.log(chalk.gray(` VITE_API_URL: ${workerData.api_domain}`)); } // Update pinme.toml with api_url (append after project_name) let pinmeConfig = fs.readFileSync(configPath, 'utf-8'); if (pinmeConfig.includes('api_url')) { pinmeConfig = pinmeConfig.replace( /api_url\s*=\s*"[^"]*"/, `api_url = "${workerData.api_domain}"` ); } else { // Append api_url line after project_name const lines = pinmeConfig.split('\n'); const newLines: string[] = []; for (const line of lines) { newLines.push(line); if (line.trim().startsWith('project_name')) { newLines.push(`api_url = "${workerData.api_domain}"`); } } pinmeConfig = newLines.join('\n'); } fs.writeFileSync(configPath, pinmeConfig); console.log(chalk.green(` Updated pinme.toml with api_url`)); // 6. Install dependencies in the background. // The template ships prebuilt `dist-worker/` and `frontend/dist/`, so the // initial create + deploy below does NOT need node_modules. We kick off the // install asynchronously so dependencies are ready for later `pinme save` // and local development, without blocking (or failing) the first create. console.log(chalk.blue('\n4. Installing dependencies in the background...')); try { const { logPath } = startBackgroundInstall(targetDir); console.log(chalk.gray(' Dependencies are installing in the background; create will continue.')); console.log(chalk.gray(` Install progress is logged to: ${logPath}`)); console.log(chalk.gray(' `pinme save` will automatically wait for this install to finish.')); } catch (error: any) { // A failed background install must not block create; the prebuilt dist is enough. console.log(chalk.yellow(' Warning: could not start background dependency install.')); console.log(chalk.yellow(' Run `npm install` inside the project before `pinme save`.')); } // 7. Use the prebuilt backend worker shipped with the template console.log(chalk.blue('\n5. Preparing backend worker...')); // Get prebuilt worker files and SQL files const distWorkerDir = path.join(targetDir, 'dist-worker'); const workerJsPath = path.join(distWorkerDir, 'worker.js'); if (!fs.existsSync(distWorkerDir) || !fs.existsSync(workerJsPath)) { throw createConfigError('Prebuilt worker output not found: `dist-worker/worker.js`.', [ 'The template should ship a prebuilt `dist-worker/`.', 'Once dependencies finish installing, run `npm run build:worker` in the project, then `pinme save`.', ]); } // Get module files const modulePaths: string[] = []; const files = fs.readdirSync(distWorkerDir); for (const file of files) { if (file.endsWith('.js') && file !== 'worker.js') { modulePaths.push(path.join(distWorkerDir, file)); } } // Get SQL files const sqlDir = path.join(targetDir, 'db'); const sqlFiles: string[] = []; if (fs.existsSync(sqlDir)) { const sqlFileNames = fs.readdirSync(sqlDir) .filter(f => f.endsWith('.sql')) .sort(); for (const filename of sqlFileNames) { sqlFiles.push(path.join(sqlDir, filename)); console.log(chalk.gray(` Including SQL: ${filename}`)); } } // 9. Save worker to platform console.log(chalk.blue('\n6. Deploying backend worker...')); const saveApiUrl = `${getPinmeApiUrl('/save_worker')}?project_name=${encodeURIComponent(workerData.project_name)}`; console.log(chalk.gray(` API URL: ${saveApiUrl}`)); try { const FormData = (await import('formdata-node')).FormData; const Blob = (await import('formdata-node')).Blob; const formData = new FormData() as any; // Add metadata formData.append('metadata', new Blob([workerMetadataContent], { type: 'application/json', }), 'metadata.json'); // Add worker.js const workerCode = fs.readFileSync(workerJsPath, 'utf-8'); formData.append('worker.js', new Blob([workerCode], { type: 'application/javascript+module', }), 'worker.js'); // Add other modules for (const modulePath of modulePaths) { const filename = path.basename(modulePath); const content = fs.readFileSync(modulePath, 'utf-8'); formData.append(filename, new Blob([content], { type: 'application/javascript+module', }), filename); } // Add SQL files for (const sqlFile of sqlFiles) { const filename = path.basename(sqlFile); const content = fs.readFileSync(sqlFile, 'utf-8'); formData.append('sql_file', new Blob([content], { type: 'application/sql', }), filename); } const response = await axios.put(saveApiUrl, formData, { headers: { ...headers }, timeout: 120000, }); if (response.data) { console.log(chalk.green(' Worker deployed')); if (response.data?.data?.sql_results) { for (const result of response.data.data.sql_results) { console.log(chalk.gray(` SQL ${result.filename}: ${result.status}`)); } } } else { throw createApiError('worker save', { response: { data: response.data } }, [ `Project: ${workerData.project_name}`, `Endpoint: ${saveApiUrl}`, ], [ 'Verify the project exists and your account has permission to update it.', ]); } } catch (error: any) { throw createApiError('worker save', error, [ `Project: ${workerData.project_name}`, `Endpoint: ${saveApiUrl}`, ], [ 'Check whether backend metadata, SQL files, or worker bundle contains invalid content.', ]); } // 10. Deploy the prebuilt frontend shipped with the template console.log(chalk.blue('\n7. Preparing frontend...')); const frontendDir = path.join(targetDir, 'frontend'); const frontendDistDir = path.join(frontendDir, 'dist'); if (fs.existsSync(frontendDir)) { if (!fs.existsSync(frontendDistDir)) { throw createConfigError('Prebuilt frontend output not found: `frontend/dist/`.', [ 'The template should ship a prebuilt `frontend/dist/`.', 'Once dependencies finish installing, run `npm run build:frontend` in the project, then `pinme save`.', ]); } const patchResult = patchPrebuiltFrontendDist(frontendDistDir, workerData); console.log(chalk.green(' Patched prebuilt frontend dist config')); console.log(chalk.gray( ` Replacements: API URL ${patchResult.apiUrlReplacements}, auth ${patchResult.authReplacements}`, )); // Upload to IPFS and capture the URL console.log(chalk.blue(' Uploading to IPFS...')); try { const uploadResult = await uploadPath(frontendDistDir, { action: 'project_create', projectName: workerData.project_name, uid: headers['token-address'], }); printHighlightedUrl('Frontend URL', uploadResult.publicUrl, 'primary'); updateFrontendUrlInConfig( path.join(targetDir, 'pinme.toml'), uploadResult.publicUrl, ); console.log(chalk.green(' Updated pinme.toml with frontend URL')); } catch (error: any) { console.log(chalk.yellow(' Warning: IPFS upload failed, you can upload manually later')); } } console.log(chalk.green('\nProject created successfully.')); console.log(chalk.gray(`\nProject Details:`)); console.log(chalk.gray(` API Domain: ${workerData.api_domain}`)); console.log(chalk.gray(` Project Name: ${workerData.project_name}`)); printHighlightedUrl( 'Project Management URL', getProjectManagementUrl(workerData.project_name), 'management', ); console.log(chalk.gray(`\nNext steps:`)); console.log(chalk.gray(` cd ${projectName}`)); console.log(chalk.gray(` pinme save`)); void tracker.trackEvent( TRACK_EVENTS.projectCreateSuccess, TRACK_PAGES.project, { a: resolveTrackAction(TRACK_EVENTS.projectCreateSuccess), project_name: workerData.project_name, template_branch: TEMPLATE_BRANCH, force: Boolean(options.force), }, ); process.exit(0); } catch (error: any) { void tracker.trackEvent( TRACK_EVENTS.projectCreateFailed, TRACK_PAGES.project, { a: resolveTrackAction(TRACK_EVENTS.projectCreateFailed), project_name: options.name, template_branch: TEMPLATE_BRANCH, force: Boolean(options.force), reason: getTrackErrorReason(error), }, ); printCliError(error, 'Project creation failed.'); process.exit(1); } }