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 +42 -0
- package/bin/flowsites.js +8 -0
- package/bin/mytemplates.js +8 -0
- package/package.json +33 -0
- package/src/downloader.js +58 -0
- package/src/extractor.js +33 -0
- package/src/index.js +148 -0
- package/src/inspector.js +76 -0
- package/src/runner.js +177 -0
- package/src/ui.js +55 -0
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.
|
package/bin/flowsites.js
ADDED
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
|
+
}
|
package/src/extractor.js
ADDED
|
@@ -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
|
+
}
|
package/src/inspector.js
ADDED
|
@@ -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
|
+
}
|