UNPKG

create-zephyr-apps

Version:

A CLI tool to create web applications using Zephyr.

494 lines (493 loc) 20.7 kB
import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import node_path from "node:path"; import { TemplateRepositories, getTemplate } from "./templates.js"; import * as __rspack_external_node_fs_1b05aee1 from "node:fs"; class ScaffoldFailure extends Error { exitCode; receipt; constructor(message, exitCode, receipt){ super(message); this.name = 'ScaffoldFailure'; this.exitCode = exitCode; this.receipt = receipt; } } class CommandFailure extends Error { stage; result; constructor(stage, command, result){ super(`${command} exited with code ${result.exitCode}.`); this.name = 'CommandFailure'; this.stage = stage; this.result = result; } } function normalizeReceiptPath(value) { return value.replaceAll(node_path.win32.sep, node_path.posix.sep); } const TEMPLATE_FETCH_TIMEOUT_MS = 300000; const COMMAND_TERMINATION_GRACE_MS = 5000; async function defaultCommandRunner(command, args, options) { return new Promise((resolve)=>{ const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, NO_COLOR: '1' }, shell: false, stdio: [ 'ignore', 'pipe', 'pipe' ] }); let stdout = ''; let stderr = ''; let settled = false; let timedOut = false; let timeout; let forceKillTimeout; const settle = (result)=>{ if (settled) return; settled = true; if (timeout) clearTimeout(timeout); if (forceKillTimeout) clearTimeout(forceKillTimeout); resolve(result); }; const timeoutStderr = ()=>{ const timeoutMessage = `Command timed out after ${options.timeoutMs}ms.`; return `${stderr}${stderr ? '\n' : ''}${timeoutMessage}`; }; child.stdout?.setEncoding('utf8'); child.stderr?.setEncoding('utf8'); child.stdout?.on('data', (chunk)=>{ stdout += chunk; }); child.stderr?.on('data', (chunk)=>{ stderr += chunk; }); if (void 0 !== options.timeoutMs) timeout = setTimeout(()=>{ timedOut = true; if (!child.kill('SIGTERM')) return void settle({ exitCode: 124, stdout, stderr: timeoutStderr() }); forceKillTimeout = setTimeout(()=>{ child.kill('SIGKILL'); }, COMMAND_TERMINATION_GRACE_MS); }, options.timeoutMs); child.on('error', (error)=>{ settle({ exitCode: timedOut ? 124 : 'ENOENT' === error.code ? 127 : 1, stdout, stderr: timedOut ? timeoutStderr() : `${stderr}${stderr ? '\n' : ''}${error.message}` }); }); child.on('close', (code)=>{ settle({ exitCode: timedOut ? 124 : code ?? 1, stdout, stderr: timedOut ? timeoutStderr() : stderr }); }); }); } async function scaffoldProject(options, dependencies = {}) { const outputDirectory = node_path.resolve(options.directory); const repository = { ...TemplateRepositories[options.projectType], ...dependencies.repositories?.[options.projectType] }; let packageManager = options.packageManager ?? 'pnpm'; const receipt = { success: false, directory: normalizeReceiptPath(outputDirectory), projectType: options.projectType, template: options.template ?? null, templateRepository: repository.url, templateRevision: options.templateRevision, packageManager: { name: packageManager, version: null }, createdFiles: [], artifacts: [], resolvedPackageVersions: [], commands: [], failures: [] }; const runCommand = dependencies.runCommand ?? defaultCommandRunner; const progress = dependencies.onProgress ?? (()=>void 0); let temporaryDirectory; let outputMutationStarted = false; let currentStage = 'prepare'; const reportProgress = (stage, message)=>{ currentStage = stage; progress(stage, message); }; const execute = async (stage, command, args, cwd, commandOptions = {})=>{ const result = await runCommand(command, args, { cwd, ...commandOptions }); receipt.commands.push({ stage, command, args, cwd: normalizeReceiptPath(cwd), exitCode: result.exitCode }); if (0 !== result.exitCode) throw new CommandFailure(stage, command, result); return result; }; try { reportProgress('prepare', 'Validating the project directory'); await assertEmptyOutputDirectory(outputDirectory); temporaryDirectory = await __rspack_external_node_fs_1b05aee1.promises.mkdtemp(node_path.join(tmpdir(), 'create-zephyr-apps-')); const checkoutDirectory = node_path.join(temporaryDirectory, repository.name); await __rspack_external_node_fs_1b05aee1.promises.mkdir(checkoutDirectory, { recursive: true }); reportProgress('fetch-template', `Fetching ${repository.name} at ${options.templateRevision}`); await execute('fetch-template', 'git', [ 'init', '--quiet' ], checkoutDirectory); await execute('fetch-template', 'git', [ 'remote', 'add', 'origin', repository.url ], checkoutDirectory); await execute('fetch-template', 'git', [ 'fetch', '--quiet', '--depth', '1', 'origin', options.templateRevision ], checkoutDirectory, { timeoutMs: TEMPLATE_FETCH_TIMEOUT_MS }); await execute('fetch-template', 'git', [ 'checkout', '--quiet', '--detach', 'FETCH_HEAD' ], checkoutDirectory); const revisionResult = await execute('fetch-template', 'git', [ 'rev-parse', 'HEAD' ], checkoutDirectory); const resolvedRevision = revisionResult.stdout.trim(); if (resolvedRevision.toLowerCase() !== options.templateRevision.toLowerCase()) throw new Error(`Fetched template revision ${resolvedRevision}, expected ${options.templateRevision}.`); receipt.templateRevision = resolvedRevision; const sourceDirectory = resolveTemplateSource(options, checkoutDirectory); await __rspack_external_node_fs_1b05aee1.promises.access(sourceDirectory, __rspack_external_node_fs_1b05aee1.constants.R_OK); reportProgress('copy-template', `Copying the template to ${outputDirectory}`); await __rspack_external_node_fs_1b05aee1.promises.mkdir(outputDirectory, { recursive: true }); outputMutationStarted = true; await __rspack_external_node_fs_1b05aee1.promises.cp(sourceDirectory, outputDirectory, { recursive: true, force: false, dereference: true, filter (source) { return '.git' !== node_path.basename(source); } }); await refreshReceiptFiles(outputDirectory, receipt); if (!options.packageManager) { packageManager = await detectPackageManager(outputDirectory, process.cwd(), process.env); receipt.packageManager.name = packageManager; } if (options.initializeGit) { reportProgress('git', 'Initializing the Git repository'); await execute('git', 'git', [ 'init' ], outputDirectory); } let beforeBuild = new Set(await listProjectFiles(outputDirectory)); if (options.install) { reportProgress('install', `Installing dependencies with ${packageManager}`); const packageManagerMetadataWasReconciled = options.packageManager ? await removeConflictingPackageManagerMetadata(outputDirectory, packageManager) : false; const versionResult = await execute('install', packageManager, [ '--version' ], outputDirectory); receipt.packageManager.version = versionResult.stdout.trim() || null; if (packageManagerMetadataWasReconciled && null !== receipt.packageManager.version) await writePackageManagerMetadata(outputDirectory, packageManager, receipt.packageManager.version); const installCommand = packageManagerCommands(packageManager).install; await execute('install', installCommand.command, installCommand.args, outputDirectory); await refreshReceiptFiles(outputDirectory, receipt); beforeBuild = new Set(await listProjectFiles(outputDirectory)); } if (options.initializeGit) { reportProgress('git', 'Creating the initial Git commit'); await execute('git', 'git', [ 'add', '.' ], outputDirectory); await execute('git', 'git', [ '-c', 'user.email=zephyrbot@zephyr-cloud.io', '-c', 'user.name=Zephyr Bot', 'commit', '--no-gpg-sign', '-m', 'Initial commit from Zephyr' ], outputDirectory); } if (options.build) { reportProgress('build', `Building the project with ${packageManager}`); const buildCommand = packageManagerCommands(packageManager).build; await execute('build', buildCommand.command, buildCommand.args, outputDirectory); receipt.artifacts = (await listProjectFiles(outputDirectory)).filter((file)=>!beforeBuild.has(file)); } reportProgress('inspect', 'Collecting scaffold results'); await refreshReceiptFiles(outputDirectory, receipt); receipt.resolvedPackageVersions = await collectResolvedPackageVersions(outputDirectory); receipt.success = true; return receipt; } catch (error) { const commandFailure = error instanceof CommandFailure ? error : void 0; const exitCode = commandFailure?.result.exitCode ?? 1; const message = error instanceof Error ? error.message : String(error); if (outputMutationStarted) await refreshReceiptFiles(outputDirectory, receipt).catch(()=>void 0); receipt.failures.push({ stage: commandFailure?.stage ?? currentStage, message, exitCode, stderr: commandFailure ? tail(commandFailure.result.stderr.trim(), 4000) : void 0 }); throw new ScaffoldFailure(message, exitCode, receipt); } finally{ if (temporaryDirectory) { const removeTemporaryDirectory = dependencies.removeTemporaryDirectory ?? ((directory)=>__rspack_external_node_fs_1b05aee1.promises.rm(directory, { recursive: true, force: true })); try { await removeTemporaryDirectory(temporaryDirectory); } catch {} } } } async function detectPackageManager(outputDirectory, invocationDirectory, environment) { const packageManagerFromOutput = await readPackageManagerField(outputDirectory); if (packageManagerFromOutput) return packageManagerFromOutput; const outputLockfile = await detectLockfile(outputDirectory); if (outputLockfile) return outputLockfile; const userAgent = environment['npm_config_user_agent']?.trim().split(/\s+/u)[0]?.split(/[/@]/u)[0]; if (isPackageManager(userAgent)) return userAgent; const packageManagerFromInvocation = await readPackageManagerField(invocationDirectory); if (packageManagerFromInvocation) return packageManagerFromInvocation; const invocationLockfile = await detectLockfile(invocationDirectory); return invocationLockfile ?? 'pnpm'; } function resolveTemplateSource(options, checkoutDirectory) { if ('react-native' === options.projectType) return checkoutDirectory; const template = options.template ? getTemplate(options.template) : void 0; if (!template) throw new Error(`Unknown web template "${String(options.template)}".`); return node_path.join(checkoutDirectory, template.directory, template.sourceName ?? template.name); } async function assertEmptyOutputDirectory(outputDirectory) { try { const stats = await __rspack_external_node_fs_1b05aee1.promises.stat(outputDirectory); if (!stats.isDirectory()) throw new Error(`${outputDirectory} exists and is not a directory.`); const files = await __rspack_external_node_fs_1b05aee1.promises.readdir(outputDirectory); if (files.length > 0) throw new Error(`Output directory ${outputDirectory} must be empty. Existing files were not changed.`); } catch (error) { if ('ENOENT' !== error.code) throw error; } } function packageManagerCommands(packageManager) { return { install: { command: packageManager, args: [ 'install' ] }, build: { command: packageManager, args: [ 'run', 'build' ] } }; } async function removeConflictingPackageManagerMetadata(directory, selectedPackageManager) { const packageManifest = await readMutablePackageManifest(directory); if (!packageManifest) return false; const declaredPackageManager = 'string' == typeof packageManifest.manifest.packageManager ? packageManifest.manifest.packageManager.split('@')[0] : void 0; if (!Object.hasOwn(packageManifest.manifest, 'packageManager') || declaredPackageManager === selectedPackageManager) return false; delete packageManifest.manifest.packageManager; await writeMutablePackageManifest(packageManifest); return true; } async function writePackageManagerMetadata(directory, packageManager, version) { const packageManifest = await readMutablePackageManifest(directory); if (!packageManifest) return; packageManifest.manifest.packageManager = `${packageManager}@${version}`; await writeMutablePackageManifest(packageManifest); } async function readMutablePackageManifest(directory) { const manifestPath = node_path.join(directory, 'package.json'); try { const source = await __rspack_external_node_fs_1b05aee1.promises.readFile(manifestPath, 'utf8'); return { path: manifestPath, source, manifest: JSON.parse(source) }; } catch (error) { if ('ENOENT' === error.code) return; throw error; } } async function writeMutablePackageManifest(packageManifest) { const indentation = packageManifest.source.match(/\n([ \t]+)"/u)?.[1] ?? ' '; const trailingNewline = packageManifest.source.endsWith('\n') ? '\n' : ''; await __rspack_external_node_fs_1b05aee1.promises.writeFile(packageManifest.path, `${JSON.stringify(packageManifest.manifest, null, indentation)}${trailingNewline}`); } async function readPackageManagerField(directory) { try { const manifest = JSON.parse(await __rspack_external_node_fs_1b05aee1.promises.readFile(node_path.join(directory, 'package.json'), 'utf8')); const name = manifest.packageManager?.split('@')[0]; return isPackageManager(name) ? name : void 0; } catch { return; } } async function detectLockfile(directory) { const lockfiles = [ [ 'pnpm-lock.yaml', 'pnpm' ], [ 'package-lock.json', 'npm' ], [ 'yarn.lock', 'yarn' ], [ 'bun.lock', 'bun' ], [ 'bun.lockb', 'bun' ] ]; for (const [lockfile, packageManager] of lockfiles)try { await __rspack_external_node_fs_1b05aee1.promises.access(node_path.join(directory, lockfile)); return packageManager; } catch {} } function isPackageManager(value) { return 'pnpm' === value || 'npm' === value || 'yarn' === value || 'bun' === value; } async function listProjectFiles(root) { const files = []; const visit = async (directory)=>{ const entries = await __rspack_external_node_fs_1b05aee1.promises.readdir(directory, { withFileTypes: true }); for (const entry of entries){ if ('.git' === entry.name || 'node_modules' === entry.name) continue; const absolutePath = node_path.join(directory, entry.name); if (entry.isDirectory()) await visit(absolutePath); else files.push(normalizeReceiptPath(node_path.relative(root, absolutePath))); } }; await visit(root); return files.sort(); } async function refreshReceiptFiles(outputDirectory, receipt) { const allFiles = await listProjectFiles(outputDirectory); const artifacts = new Set(receipt.artifacts); receipt.createdFiles = allFiles.filter((file)=>!artifacts.has(file)); } async function collectResolvedPackageVersions(root) { const manifests = await findWorkspaceManifests(root); const versions = new Map(); for (const manifestPath of manifests){ let manifest; try { manifest = JSON.parse(await __rspack_external_node_fs_1b05aee1.promises.readFile(manifestPath, 'utf8')); } catch { continue; } if (manifest.name && manifest.version) versions.set(`workspace:${manifest.name}@${manifest.version}`, { name: manifest.name, version: manifest.version, source: 'workspace' }); const dependencyNames = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}) ]); for (const dependencyName of dependencyNames){ const installedManifest = await findInstalledManifest(node_path.dirname(manifestPath), root, dependencyName); if (installedManifest) try { const dependencyManifest = JSON.parse(await __rspack_external_node_fs_1b05aee1.promises.readFile(installedManifest, 'utf8')); if (dependencyManifest.name && dependencyManifest.version) versions.set(`installed:${dependencyManifest.name}@${dependencyManifest.version}`, { name: dependencyManifest.name, version: dependencyManifest.version, source: 'installed' }); } catch {} } } return [ ...versions.values() ].sort((left, right)=>left.name.localeCompare(right.name) || left.source.localeCompare(right.source) || left.version.localeCompare(right.version)); } async function findWorkspaceManifests(root) { const manifests = []; const excludedDirectories = new Set([ '.git', 'node_modules', 'dist', 'build', 'out', '.output' ]); const visit = async (directory)=>{ const entries = await __rspack_external_node_fs_1b05aee1.promises.readdir(directory, { withFileTypes: true }); for (const entry of entries)if (entry.isDirectory()) { if (!excludedDirectories.has(entry.name)) await visit(node_path.join(directory, entry.name)); } else if ('package.json' === entry.name) manifests.push(node_path.join(directory, entry.name)); }; await visit(root); return manifests; } async function findInstalledManifest(fromDirectory, root, packageName) { let current = fromDirectory; while(current.startsWith(root)){ const candidate = node_path.join(current, 'node_modules', packageName, 'package.json'); try { await __rspack_external_node_fs_1b05aee1.promises.access(candidate); return candidate; } catch {} if (current === root) break; current = node_path.dirname(current); } } function tail(value, maximumLength) { return value.length <= maximumLength ? value : value.slice(value.length - maximumLength); } export { ScaffoldFailure, TEMPLATE_FETCH_TIMEOUT_MS, collectResolvedPackageVersions, defaultCommandRunner, detectPackageManager, normalizeReceiptPath, scaffoldProject }; //# sourceMappingURL=scaffold.js.map