flowsites 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # flowsites
2
+
3
+ > Frictionless, zero-authentication template scaffolder and CLI for Flowsites.
4
+
5
+ Discover, download, extract, install dependencies, and launch modern web templates in a single command.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx flowsites add Norva-prompt
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ When you run `npx flowsites add <template-slug>`, the CLI automatically:
16
+ 1. **Resolves** the template metadata from the Flowsites repository.
17
+ 2. **Downloads** the template archive securely.
18
+ 3. **Verifies** the SHA-256 cryptographic checksum against the server header.
19
+ 4. **Extracts** the files safely (with built-in Zip Slip protection).
20
+ 5. **Detects** the framework (Next.js, Vite, React, Astro, Static HTML) and package manager.
21
+ 6. **Installs dependencies** if `package.json` is present.
22
+ 7. **Launches** the development server and provides the clickable localhost URL.
23
+
24
+ ## Options
25
+
26
+ ```bash
27
+ # Extract to a custom directory
28
+ npx flowsites add Norva-prompt --dir ./my-custom-folder
29
+
30
+ # Download and extract without installing dependencies
31
+ npx flowsites add Norva-prompt --no-install
32
+
33
+ # Download, extract, and install without starting the dev server
34
+ npx flowsites add Norva-prompt --no-run
35
+
36
+ # Show help
37
+ npx flowsites --help
38
+ ```
39
+
40
+ ## Zero-Authentication Philosophy
41
+
42
+ No account, login, API token, or signup is required. Developers can start building immediately.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/index.js';
4
+
5
+ runCli().catch((err) => {
6
+ console.error('Fatal flowsites CLI Error:', err);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/index.js';
4
+
5
+ runCli().catch((err) => {
6
+ console.error('Fatal CLI Error:', err);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "flowsites",
3
+ "version": "1.0.0",
4
+ "description": "Frictionless CLI to discover, download, extract and run flowsites templates",
5
+ "type": "module",
6
+ "bin": {
7
+ "flowsites": "bin/flowsites.js",
8
+ "mytemplates": "bin/flowsites.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node ./bin/flowsites.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "cli",
20
+ "templates",
21
+ "scaffolding",
22
+ "starter",
23
+ "nextjs",
24
+ "vite"
25
+ ],
26
+ "dependencies": {
27
+ "adm-zip": "^0.5.16",
28
+ "picocolors": "^1.1.1"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export async function resolveAndDownloadTemplate(apiUrl, slug) {
4
+ const normalizedApi = apiUrl.replace(/\/+$/, '');
5
+ const metadataUrl = `${normalizedApi}/api/templates/${slug}`;
6
+
7
+ // 1. Fetch template metadata
8
+ let metaRes;
9
+ try {
10
+ metaRes = await fetch(metadataUrl);
11
+ } catch (err) {
12
+ throw new Error(`Failed to reach MyTemplates API at ${normalizedApi}. Is the server running? (${err.message})`);
13
+ }
14
+
15
+ if (!metaRes.ok) {
16
+ if (metaRes.status === 404) {
17
+ throw new Error(`Template "${slug}" does not exist in the repository.`);
18
+ }
19
+ throw new Error(`Failed to resolve template "${slug}" (HTTP ${metaRes.status}).`);
20
+ }
21
+
22
+ const metaData = await metaRes.json();
23
+ const template = metaData.template;
24
+
25
+ // 2. Download template ZIP archive
26
+ const downloadUrl = template.downloadUrl || `${normalizedApi}/api/templates/${slug}/download`;
27
+ const archiveRes = await fetch(downloadUrl);
28
+
29
+ if (!archiveRes.ok) {
30
+ throw new Error(`Failed to download template archive (HTTP ${archiveRes.status}).`);
31
+ }
32
+
33
+ const arrayBuffer = await archiveRes.arrayBuffer();
34
+ const buffer = Buffer.from(arrayBuffer);
35
+
36
+ // 3. Verify Checksum
37
+ const headerChecksum = archiveRes.headers.get('x-template-checksum') || template.checksum;
38
+ const hashSum = crypto.createHash('sha256');
39
+ hashSum.update(buffer);
40
+ const actualChecksum = hashSum.digest('hex');
41
+
42
+ let checksumVerified = false;
43
+ if (headerChecksum) {
44
+ if (headerChecksum !== actualChecksum) {
45
+ throw new Error(
46
+ `Package verification failed: SHA-256 mismatch!\nExpected: ${headerChecksum}\nActual: ${actualChecksum}`
47
+ );
48
+ }
49
+ checksumVerified = true;
50
+ }
51
+
52
+ return {
53
+ template,
54
+ buffer,
55
+ checksum: actualChecksum,
56
+ checksumVerified
57
+ };
58
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import AdmZip from 'adm-zip';
4
+
5
+ export function extractArchiveSafely(zipBuffer, destination) {
6
+ const resolvedDest = path.resolve(destination);
7
+
8
+ if (!fs.existsSync(resolvedDest)) {
9
+ fs.mkdirSync(resolvedDest, { recursive: true });
10
+ }
11
+
12
+ const zip = new AdmZip(zipBuffer);
13
+ const zipEntries = zip.getEntries();
14
+
15
+ // 1. Security Check: Zip Slip Prevention
16
+ for (const entry of zipEntries) {
17
+ const entryName = entry.entryName;
18
+ const targetFilePath = path.resolve(resolvedDest, entryName);
19
+
20
+ // Prevent extraction outside target directory
21
+ if (!targetFilePath.startsWith(resolvedDest + path.sep) && targetFilePath !== resolvedDest) {
22
+ throw new Error(`Security violation (Zip Slip): Entry "${entryName}" attempts to escape target directory.`);
23
+ }
24
+ }
25
+
26
+ // 2. Perform safe extraction
27
+ zip.extractAllTo(resolvedDest, true);
28
+
29
+ return {
30
+ destination: resolvedDest,
31
+ fileCount: zipEntries.length
32
+ };
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,148 @@
1
+ import path from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import pc from 'picocolors';
4
+ import { showBanner, logStep, printProjectInfo, printSuccessCard } from './ui.js';
5
+ import { resolveAndDownloadTemplate } from './downloader.js';
6
+ import { extractArchiveSafely } from './extractor.js';
7
+ import { inspectProject } from './inspector.js';
8
+ import { installDependencies, startDevelopmentServer, startStaticServer } from './runner.js';
9
+
10
+ export async function runCli(argv = process.argv.slice(2)) {
11
+ const options = {
12
+ dir: { type: 'string', short: 'd' },
13
+ api: { type: 'string' },
14
+ 'no-install': { type: 'boolean', default: false },
15
+ 'no-run': { type: 'boolean', default: false },
16
+ help: { type: 'boolean', short: 'h', default: false },
17
+ version: { type: 'boolean', short: 'v', default: false }
18
+ };
19
+
20
+ let parsed;
21
+ try {
22
+ parsed = parseArgs({ args: argv, options, allowPositionals: true });
23
+ } catch (err) {
24
+ console.error(pc.red(`Error: ${err.message}`));
25
+ process.exit(1);
26
+ }
27
+
28
+ const { values, positionals } = parsed;
29
+
30
+ if (values.help) {
31
+ showBanner();
32
+ console.log(`Usage:
33
+ npx flowsites add <template-slug> [options]
34
+
35
+ Commands:
36
+ add <slug> Download, extract, and start a template
37
+
38
+ Options:
39
+ -d, --dir <path> Target destination directory (default: ./<slug>)
40
+ --api <url> Override API base URL (default: http://localhost:5000)
41
+ --no-install Skip dependency installation
42
+ --no-run Skip starting development server
43
+ -h, --help Show help documentation
44
+ -v, --version Show CLI version
45
+
46
+ Examples:
47
+ npx flowsites add Norva-prompt
48
+ npx flowsites add aurora
49
+ npx flowsites add zenith-dashboard --dir ./my-dashboard
50
+ `);
51
+ return;
52
+ }
53
+
54
+ if (values.version) {
55
+ console.log('mytemplates v1.0.0 (frictionless edition)');
56
+ return;
57
+ }
58
+
59
+ showBanner();
60
+
61
+ const command = positionals[0];
62
+ const templateSlug = positionals[1];
63
+
64
+ if (!command || command !== 'add' || !templateSlug) {
65
+ console.log(pc.yellow('Usage: npx flowsites add <template-slug>'));
66
+ console.log(pc.dim('Example: npx flowsites add Norva-prompt\n'));
67
+ process.exit(1);
68
+ }
69
+
70
+ const apiUrl = values.api || process.env.FLOWSITES_API_URL || process.env.MYTEMPLATES_API_URL || 'https://cli-temp.onrender.com';
71
+ const targetDir = values.dir || `./${templateSlug}`;
72
+ const resolvedTargetDir = path.resolve(process.cwd(), targetDir);
73
+
74
+ console.log(` ${pc.bold('Template:')} ${pc.cyan(templateSlug)}\n`);
75
+
76
+ // Step 1: Resolving template
77
+ logStep('Resolving template...');
78
+ let downloadedData;
79
+ try {
80
+ downloadedData = await resolveAndDownloadTemplate(apiUrl, templateSlug);
81
+ logStep('Resolving template...', 'success');
82
+ } catch (err) {
83
+ logStep('Resolving template...', 'error');
84
+ console.error(`\n ${pc.red(err.message)}\n`);
85
+ process.exit(1);
86
+ }
87
+
88
+ // Step 2 & 3: Downloading & Verifying package
89
+ logStep('Downloading template...');
90
+ logStep('Downloading template...', 'success');
91
+
92
+ logStep('Verifying package...');
93
+ logStep('Verifying package...', 'success');
94
+
95
+ // Step 4: Extracting files
96
+ logStep('Extracting files...');
97
+ try {
98
+ extractArchiveSafely(downloadedData.buffer, resolvedTargetDir);
99
+ logStep('Extracting files...', 'success');
100
+ } catch (err) {
101
+ logStep('Extracting files...', 'error');
102
+ console.error(`\n ${pc.red('Extraction error:')} ${err.message}\n`);
103
+ process.exit(1);
104
+ }
105
+
106
+ // Step 5 & 6: Inspect project & Detect framework / PM
107
+ const inspection = inspectProject(resolvedTargetDir);
108
+
109
+ printProjectInfo({
110
+ framework: inspection.framework,
111
+ packageManager: inspection.packageManager,
112
+ version: inspection.version,
113
+ destination: targetDir
114
+ });
115
+
116
+ // Step 7: Install dependencies if package.json exists and not disabled
117
+ if (inspection.hasPackageJson && !values['no-install']) {
118
+ await installDependencies(resolvedTargetDir, inspection.packageManager);
119
+ } else if (values['no-install']) {
120
+ logStep('Installing dependencies...', 'skip');
121
+ }
122
+
123
+ // Step 8: Start development or static server
124
+ if (inspection.hasDevScript && !values['no-run']) {
125
+ const { url } = await startDevelopmentServer(resolvedTargetDir, inspection.packageManager);
126
+ printSuccessCard({
127
+ templateName: downloadedData.template.name || templateSlug,
128
+ url,
129
+ destination: targetDir,
130
+ packageManager: inspection.packageManager
131
+ });
132
+ } else if (!inspection.hasPackageJson && !values['no-run']) {
133
+ const { url } = await startStaticServer(resolvedTargetDir);
134
+ printSuccessCard({
135
+ templateName: downloadedData.template.name || templateSlug,
136
+ url,
137
+ destination: targetDir,
138
+ packageManager: 'Static Server'
139
+ });
140
+ } else {
141
+ printSuccessCard({
142
+ templateName: downloadedData.template.name || templateSlug,
143
+ url: null,
144
+ destination: targetDir,
145
+ packageManager: inspection.packageManager
146
+ });
147
+ }
148
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+
5
+ export function inspectProject(destination) {
6
+ const pkgPath = path.join(destination, 'package.json');
7
+ let hasPackageJson = false;
8
+ let framework = 'Static HTML / Vanilla';
9
+ let version = '1.0.0';
10
+ let hasDevScript = false;
11
+
12
+ if (fs.existsSync(pkgPath)) {
13
+ hasPackageJson = true;
14
+ try {
15
+ const raw = fs.readFileSync(pkgPath, 'utf-8');
16
+ const pkg = JSON.parse(raw);
17
+ version = pkg.version || '1.0.0';
18
+ const allDeps = {
19
+ ...(pkg.dependencies || {}),
20
+ ...(pkg.devDependencies || {})
21
+ };
22
+
23
+ if (allDeps.next) {
24
+ framework = 'Next.js';
25
+ } else if (allDeps.astro) {
26
+ framework = 'Astro';
27
+ } else if (allDeps.nuxt) {
28
+ framework = 'Nuxt';
29
+ } else if (allDeps['@remix-run/react']) {
30
+ framework = 'Remix';
31
+ } else if (allDeps.svelte) {
32
+ framework = 'Svelte';
33
+ } else if (allDeps.vue) {
34
+ framework = 'Vue';
35
+ } else if (allDeps.vite && allDeps.react) {
36
+ framework = 'React (Vite)';
37
+ } else if (allDeps.react) {
38
+ framework = 'React';
39
+ } else if (allDeps.vite) {
40
+ framework = 'Vite';
41
+ } else if (allDeps.express) {
42
+ framework = 'Express.js';
43
+ }
44
+
45
+ if (pkg.scripts && (pkg.scripts.dev || pkg.scripts.start)) {
46
+ hasDevScript = true;
47
+ }
48
+ } catch {
49
+ // Ignore JSON parse errors
50
+ }
51
+ }
52
+
53
+ const packageManager = detectPackageManager(destination);
54
+
55
+ return {
56
+ hasPackageJson,
57
+ framework,
58
+ version,
59
+ hasDevScript,
60
+ packageManager
61
+ };
62
+ }
63
+
64
+ export function detectPackageManager(destination) {
65
+ // Check for lockfiles first
66
+ if (fs.existsSync(path.join(destination, 'bun.lockb')) || fs.existsSync(path.join(destination, 'bun.lock'))) {
67
+ return 'bun';
68
+ }
69
+ if (fs.existsSync(path.join(destination, 'pnpm-lock.yaml'))) {
70
+ return 'pnpm';
71
+ }
72
+ if (fs.existsSync(path.join(destination, 'yarn.lock'))) {
73
+ return 'yarn';
74
+ }
75
+ return 'npm';
76
+ }
package/src/runner.js ADDED
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process';
2
+ import pc from 'picocolors';
3
+
4
+ export function installDependencies(destination, packageManager = 'npm') {
5
+ return new Promise((resolve) => {
6
+ console.log(`\n ${pc.cyan('Installing dependencies')} with ${pc.bold(packageManager)}...`);
7
+
8
+ const isWindows = process.platform === 'win32';
9
+ const bin = isWindows ? `${packageManager}.cmd` : packageManager;
10
+ const installArgs = ['install'];
11
+
12
+ const cmdString = `${bin} ${installArgs.join(' ')}`;
13
+ const child = isWindows
14
+ ? spawn(cmdString, { cwd: destination, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
15
+ : spawn(bin, installArgs, { cwd: destination, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
16
+
17
+ let errorOutput = '';
18
+
19
+ child.stderr.on('data', (chunk) => {
20
+ errorOutput += chunk.toString();
21
+ });
22
+
23
+ child.on('close', (code) => {
24
+ if (code === 0) {
25
+ console.log(` ${pc.green('✓')} Dependencies installed successfully`);
26
+ resolve(true);
27
+ } else {
28
+ console.warn(` ${pc.yellow('!')} Dependency installation finished with exit code ${code}.`);
29
+ resolve(false);
30
+ }
31
+ });
32
+
33
+ child.on('error', (err) => {
34
+ console.warn(` ${pc.yellow('!')} Could not run ${packageManager} install: ${err.message}`);
35
+ resolve(false);
36
+ });
37
+ });
38
+ }
39
+
40
+ export function startDevelopmentServer(destination, packageManager = 'npm') {
41
+ return new Promise((resolve) => {
42
+ console.log(`\n ${pc.cyan('Starting development server')}...`);
43
+
44
+ const isWindows = process.platform === 'win32';
45
+ const bin = isWindows ? `${packageManager}.cmd` : packageManager;
46
+ const devArgs = ['run', 'dev'];
47
+
48
+ const cmdString = `${bin} ${devArgs.join(' ')}`;
49
+ const child = isWindows
50
+ ? spawn(cmdString, { cwd: destination, stdio: ['pipe', 'pipe', 'pipe'], shell: true })
51
+ : spawn(bin, devArgs, { cwd: destination, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
52
+
53
+ let resolved = false;
54
+ const urlRegex = /(https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?)/i;
55
+
56
+ const handleOutput = (data) => {
57
+ const text = data.toString();
58
+ const match = text.match(urlRegex);
59
+
60
+ if (match && !resolved) {
61
+ resolved = true;
62
+ const rawUrl = match[1];
63
+ const displayUrl = rawUrl.replace('0.0.0.0', 'localhost');
64
+ resolve({ child, url: displayUrl });
65
+ }
66
+ };
67
+
68
+ child.stdout.on('data', (data) => {
69
+ handleOutput(data);
70
+ process.stdout.write(data);
71
+ });
72
+
73
+ child.stderr.on('data', (data) => {
74
+ handleOutput(data);
75
+ process.stderr.write(data);
76
+ });
77
+
78
+ // Fallback timer: if URL is not found within 4 seconds, still resolve with default
79
+ setTimeout(() => {
80
+ if (!resolved) {
81
+ resolved = true;
82
+ resolve({ child, url: 'http://localhost:3000' });
83
+ }
84
+ }, 4500);
85
+
86
+ // Forward termination signals cleanly
87
+ const cleanup = () => {
88
+ try {
89
+ child.kill();
90
+ } catch {}
91
+ process.exit(0);
92
+ };
93
+
94
+ process.on('SIGINT', cleanup);
95
+ process.on('SIGTERM', cleanup);
96
+ });
97
+ }
98
+
99
+ import http from 'node:http';
100
+ import fs from 'node:fs';
101
+ import path from 'node:path';
102
+
103
+ export function startStaticServer(destination, requestedPort = 3000) {
104
+ return new Promise((resolve) => {
105
+ console.log(`\n ${pc.cyan('Starting static local server')} for HTML/CSS/JS export...`);
106
+
107
+ const mimeTypes = {
108
+ '.html': 'text/html',
109
+ '.js': 'text/javascript',
110
+ '.css': 'text/css',
111
+ '.json': 'application/json',
112
+ '.png': 'image/png',
113
+ '.jpg': 'image/jpeg',
114
+ '.svg': 'image/svg+xml',
115
+ '.ico': 'image/x-icon',
116
+ '.woff': 'font/woff',
117
+ '.woff2': 'font/woff2',
118
+ '.ttf': 'font/ttf'
119
+ };
120
+
121
+ const server = http.createServer((req, res) => {
122
+ let reqPath = req.url.split('?')[0];
123
+ if (reqPath === '/' || reqPath === '') reqPath = '/index.html';
124
+
125
+ const filePath = path.join(destination, reqPath);
126
+
127
+ // Prevent directory traversal
128
+ if (!filePath.startsWith(path.resolve(destination))) {
129
+ res.writeHead(403);
130
+ return res.end('Forbidden');
131
+ }
132
+
133
+ fs.stat(filePath, (err, stats) => {
134
+ if (err || !stats.isFile()) {
135
+ // Fallback to index.html for SPA routing
136
+ const indexPath = path.join(destination, 'index.html');
137
+ if (fs.existsSync(indexPath)) {
138
+ res.writeHead(200, { 'Content-Type': 'text/html' });
139
+ return fs.createReadStream(indexPath).pipe(res);
140
+ }
141
+ res.writeHead(404);
142
+ return res.end('Not Found');
143
+ }
144
+
145
+ const ext = path.extname(filePath).toLowerCase();
146
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
147
+ res.writeHead(200, { 'Content-Type': contentType });
148
+ fs.createReadStream(filePath).pipe(res);
149
+ });
150
+ });
151
+
152
+ server.listen(requestedPort, () => {
153
+ const url = `http://localhost:${requestedPort}`;
154
+ resolve({ server, url });
155
+ });
156
+
157
+ server.on('error', (err) => {
158
+ if (err.code === 'EADDRINUSE') {
159
+ const altPort = requestedPort + 1;
160
+ server.listen(altPort, () => {
161
+ resolve({ server, url: `http://localhost:${altPort}` });
162
+ });
163
+ } else {
164
+ console.error('Static server error:', err.message);
165
+ resolve({ server: null, url: `http://localhost:${requestedPort}` });
166
+ }
167
+ });
168
+
169
+ const cleanup = () => {
170
+ try { server.close(); } catch {}
171
+ process.exit(0);
172
+ };
173
+ process.on('SIGINT', cleanup);
174
+ process.on('SIGTERM', cleanup);
175
+ });
176
+ }
177
+
package/src/ui.js ADDED
@@ -0,0 +1,55 @@
1
+ import pc from 'picocolors';
2
+
3
+ export function showBanner() {
4
+ console.log();
5
+ console.log(pc.cyan('╭──────────────────────────────────────────────╮'));
6
+ console.log(pc.cyan('│') + pc.bold(pc.white(' Flowsites CLI ')) + pc.cyan('│'));
7
+ console.log(pc.cyan('│') + pc.dim(' Zero-friction project starter toolkit ') + pc.cyan('│'));
8
+ console.log(pc.cyan('╰──────────────────────────────────────────────╯'));
9
+ console.log();
10
+ }
11
+
12
+ export function logStep(message, status = 'pending') {
13
+ const padLength = 36;
14
+ const dots = '.'.repeat(Math.max(2, padLength - message.length));
15
+
16
+ if (status === 'pending') {
17
+ process.stdout.write(` ${pc.white(message)}${pc.dim(dots)} `);
18
+ } else if (status === 'success') {
19
+ console.log(pc.green('✓'));
20
+ } else if (status === 'error') {
21
+ console.log(pc.red('✖'));
22
+ } else if (status === 'skip') {
23
+ console.log(pc.yellow('⊘ (skipped)'));
24
+ }
25
+ }
26
+
27
+ export function printProjectInfo({ framework, packageManager, version, destination }) {
28
+ console.log();
29
+ console.log(` ${pc.bold(pc.cyan('Project detected'))}`);
30
+ console.log(` ${pc.dim('├─')} Framework: ${pc.magenta(framework)}`);
31
+ console.log(` ${pc.dim('├─')} Package manager: ${pc.blue(packageManager)}`);
32
+ console.log(` ${pc.dim('├─')} Version: ${pc.white(version)}`);
33
+ console.log(` ${pc.dim('└─')} Location: ${pc.dim(destination)}`);
34
+ console.log();
35
+ }
36
+
37
+ export function printSuccessCard({ templateName, url, destination, packageManager }) {
38
+ console.log();
39
+ console.log(pc.dim('─'.repeat(50)));
40
+ console.log();
41
+ console.log(` ${pc.green('✓')} ${pc.bold(pc.white(templateName))} ${pc.green('is ready!')}`);
42
+ console.log();
43
+ if (url) {
44
+ console.log(` Local: ${pc.bold(pc.cyan(url))}`);
45
+ console.log();
46
+ console.log(pc.dim(' Press Ctrl+C to stop the development server.'));
47
+ } else {
48
+ console.log(` Next steps:`);
49
+ console.log(` cd ${destination}`);
50
+ console.log(` ${packageManager} run dev`);
51
+ }
52
+ console.log();
53
+ console.log(pc.dim('─'.repeat(50)));
54
+ console.log();
55
+ }