create-zephyr-apps
Version:
A CLI tool to create web applications using Zephyr.
494 lines (493 loc) • 20.7 kB
JavaScript
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