create-turniza-app 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/package.json +46 -0
- package/src/index.ts +79 -0
- package/src/prompts.ts +140 -0
- package/src/scaffold.ts +131 -0
- package/src/utils.ts +39 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-turniza-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI scaffold for creating new Turniza monorepo apps (Flutter + Astro + Cloudflare Workers)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-turniza-app": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "bun run src/index.ts",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"chalk": "^5.4.1",
|
|
15
|
+
"handlebars": "^4.7.8",
|
|
16
|
+
"prompts": "^2.4.2"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.2.5",
|
|
20
|
+
"@types/prompts": "^2.4.9",
|
|
21
|
+
"typescript": "^5.8.3"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"scaffold",
|
|
25
|
+
"cli",
|
|
26
|
+
"flutter",
|
|
27
|
+
"astro",
|
|
28
|
+
"cloudflare-workers",
|
|
29
|
+
"monorepo",
|
|
30
|
+
"turniza"
|
|
31
|
+
],
|
|
32
|
+
"author": "Turniza",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Turniza/create-turniza-app.git"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src/**/*",
|
|
40
|
+
"templates/**/*"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18",
|
|
44
|
+
"bun": ">=1.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* create-turniza-app – CLI scaffold for Turniza monorepo apps
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bunx create-turniza-app [project-name]
|
|
8
|
+
* npx create-turniza-app [project-name]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { askProjectOptions, type ProjectOptions } from './prompts.js';
|
|
13
|
+
import { scaffoldProject } from './scaffold.js';
|
|
14
|
+
|
|
15
|
+
const BANNER = `
|
|
16
|
+
${chalk.bold.cyan('╔══════════════════════════════════════════╗')}
|
|
17
|
+
${chalk.bold.cyan('║')} ${chalk.bold('🚀 create-turniza-app')} ${chalk.bold.cyan('║')}
|
|
18
|
+
${chalk.bold.cyan('║')} ${chalk.dim('Scaffold a new Turniza monorepo app')} ${chalk.bold.cyan('║')}
|
|
19
|
+
${chalk.bold.cyan('╚══════════════════════════════════════════╝')}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
async function main(): Promise<void> {
|
|
23
|
+
console.log(BANNER);
|
|
24
|
+
|
|
25
|
+
const projectNameArg = process.argv[2];
|
|
26
|
+
|
|
27
|
+
let options: ProjectOptions;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
options = await askProjectOptions(projectNameArg);
|
|
31
|
+
} catch {
|
|
32
|
+
console.log(chalk.yellow('\n👋 Cancelled.'));
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(chalk.bold('📋 Configuration:'));
|
|
38
|
+
console.log(chalk.dim('─'.repeat(40)));
|
|
39
|
+
console.log(` ${chalk.cyan('Project:')} ${options.projectName}`);
|
|
40
|
+
console.log(` ${chalk.cyan('Org:')} ${options.org}`);
|
|
41
|
+
console.log(` ${chalk.cyan('GitHub:')} ${options.githubOrg}/${options.projectName}`);
|
|
42
|
+
console.log(` ${chalk.cyan('Mobile:')} ${options.includeMobile ? '✅' : '❌'}`);
|
|
43
|
+
console.log(` ${chalk.cyan('Web:')} ${options.includeWeb ? '✅' : '❌'}`);
|
|
44
|
+
console.log(` ${chalk.cyan('Git init:')} ${options.initGit ? '✅' : '❌'}`);
|
|
45
|
+
console.log(` ${chalk.cyan('Install:')} ${options.installDeps ? '✅' : '❌'}`);
|
|
46
|
+
console.log('');
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await scaffoldProject(options);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(chalk.red(`\n❌ Error: ${error instanceof Error ? error.message : error}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Success message ──────────────────────────────────────
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.green.bold('✅ Project created successfully!'));
|
|
58
|
+
console.log('');
|
|
59
|
+
console.log(chalk.bold('Next steps:'));
|
|
60
|
+
console.log(chalk.dim('─'.repeat(40)));
|
|
61
|
+
console.log(` ${chalk.cyan('cd')} ${options.projectName}`);
|
|
62
|
+
|
|
63
|
+
if (!options.installDeps) {
|
|
64
|
+
console.log(` ${chalk.cyan('make setup')}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.includeMobile) {
|
|
68
|
+
console.log(` ${chalk.cyan('make run-ios')} ${chalk.dim('# or make run-android')}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.includeWeb) {
|
|
72
|
+
console.log(` ${chalk.cyan('make run-web')} ${chalk.dim('# localhost:4321')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(` ${chalk.cyan('make ci')} ${chalk.dim('# full pipeline')}`);
|
|
76
|
+
console.log('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main();
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
|
|
3
|
+
export interface ProjectOptions {
|
|
4
|
+
projectName: string;
|
|
5
|
+
projectTitle: string;
|
|
6
|
+
projectDescription: string;
|
|
7
|
+
projectNameSnake: string;
|
|
8
|
+
org: string;
|
|
9
|
+
orgDomain: string;
|
|
10
|
+
githubOrg: string;
|
|
11
|
+
emoji: string;
|
|
12
|
+
includeMobile: boolean;
|
|
13
|
+
includeWeb: boolean;
|
|
14
|
+
initGit: boolean;
|
|
15
|
+
installDeps: boolean;
|
|
16
|
+
compatibilityDate: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toTitleCase(str: string): string {
|
|
20
|
+
return str
|
|
21
|
+
.split(/[-_]/)
|
|
22
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
23
|
+
.join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toSnakeCase(str: string): string {
|
|
27
|
+
return str.replace(/-/g, '_');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateProjectName(name: string): boolean | string {
|
|
31
|
+
if (!name) return 'Project name is required';
|
|
32
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
33
|
+
return 'Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens';
|
|
34
|
+
}
|
|
35
|
+
if (name.length > 50) return 'Must be 50 characters or less';
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function askProjectOptions(
|
|
40
|
+
projectNameArg?: string,
|
|
41
|
+
): Promise<ProjectOptions> {
|
|
42
|
+
const onCancel = (): void => {
|
|
43
|
+
throw new Error('cancelled');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Step 1: Project name ──────────────────────────────────
|
|
47
|
+
let projectName = projectNameArg;
|
|
48
|
+
|
|
49
|
+
if (!projectName) {
|
|
50
|
+
const { name } = await prompts(
|
|
51
|
+
{
|
|
52
|
+
type: 'text',
|
|
53
|
+
name: 'name',
|
|
54
|
+
message: 'Project name (kebab-case):',
|
|
55
|
+
validate: validateProjectName,
|
|
56
|
+
},
|
|
57
|
+
{ onCancel },
|
|
58
|
+
);
|
|
59
|
+
projectName = name as string;
|
|
60
|
+
} else {
|
|
61
|
+
const validation = validateProjectName(projectName);
|
|
62
|
+
if (validation !== true) {
|
|
63
|
+
throw new Error(validation as string);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Step 2: Rest of options ───────────────────────────────
|
|
68
|
+
const answers = await prompts(
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
type: 'text',
|
|
72
|
+
name: 'org',
|
|
73
|
+
message: 'Organization name:',
|
|
74
|
+
initial: 'Turniza',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'text',
|
|
78
|
+
name: 'githubOrg',
|
|
79
|
+
message: 'GitHub org/user:',
|
|
80
|
+
initial: 'Turniza',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'text',
|
|
84
|
+
name: 'emoji',
|
|
85
|
+
message: 'Project emoji:',
|
|
86
|
+
initial: '🚀',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
name: 'description',
|
|
91
|
+
message: 'Short project description:',
|
|
92
|
+
initial: `A monorepo for ${toTitleCase(projectName!)} – Flutter mobile + Astro web.`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: 'confirm',
|
|
96
|
+
name: 'includeMobile',
|
|
97
|
+
message: 'Include Flutter mobile app?',
|
|
98
|
+
initial: true,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'confirm',
|
|
102
|
+
name: 'includeWeb',
|
|
103
|
+
message: 'Include Astro web app (Cloudflare Workers)?',
|
|
104
|
+
initial: true,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: 'confirm',
|
|
108
|
+
name: 'initGit',
|
|
109
|
+
message: 'Initialize git repository?',
|
|
110
|
+
initial: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'confirm',
|
|
114
|
+
name: 'installDeps',
|
|
115
|
+
message: 'Install dependencies now?',
|
|
116
|
+
initial: true,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
{ onCancel },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const org = answers.org as string;
|
|
123
|
+
const today = new Date().toISOString().split('T')[0]!;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
projectName: projectName!,
|
|
127
|
+
projectTitle: toTitleCase(projectName!),
|
|
128
|
+
projectDescription: answers.description as string,
|
|
129
|
+
projectNameSnake: toSnakeCase(projectName!),
|
|
130
|
+
org,
|
|
131
|
+
orgDomain: `${org.toLowerCase()}.com`,
|
|
132
|
+
githubOrg: answers.githubOrg as string,
|
|
133
|
+
emoji: answers.emoji as string,
|
|
134
|
+
includeMobile: answers.includeMobile as boolean,
|
|
135
|
+
includeWeb: answers.includeWeb as boolean,
|
|
136
|
+
initGit: answers.initGit as boolean,
|
|
137
|
+
installDeps: answers.installDeps as boolean,
|
|
138
|
+
compatibilityDate: today,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { resolve, join, relative, dirname } from 'path';
|
|
2
|
+
import { readdir, stat, lstat, readFile, writeFile, mkdir, copyFile } from 'fs/promises';
|
|
3
|
+
import Handlebars from 'handlebars';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import type { ProjectOptions } from './prompts.js';
|
|
6
|
+
import { exec } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const TEMPLATES_DIR = resolve(import.meta.dirname, '../../templates');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Main scaffold function: copies templates, processes Handlebars, and runs post-setup.
|
|
12
|
+
*/
|
|
13
|
+
export async function scaffoldProject(options: ProjectOptions): Promise<void> {
|
|
14
|
+
const targetDir = resolve(process.cwd(), options.projectName);
|
|
15
|
+
|
|
16
|
+
// ── Check target doesn't exist ────────────────────────────
|
|
17
|
+
try {
|
|
18
|
+
await stat(targetDir);
|
|
19
|
+
throw new Error(`Directory "${options.projectName}" already exists.`);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await mkdir(targetDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
const context = {
|
|
29
|
+
...options,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── 1. Copy base templates ────────────────────────────────
|
|
33
|
+
console.log(chalk.cyan('📁 Creating project structure...'));
|
|
34
|
+
await processTemplateDir(join(TEMPLATES_DIR, 'base'), targetDir, context);
|
|
35
|
+
|
|
36
|
+
// ── 2. Copy mobile templates ──────────────────────────────
|
|
37
|
+
if (options.includeMobile) {
|
|
38
|
+
console.log(chalk.cyan('📱 Setting up Flutter mobile app...'));
|
|
39
|
+
const mobileTarget = join(targetDir, 'apps', 'mobile');
|
|
40
|
+
await mkdir(mobileTarget, { recursive: true });
|
|
41
|
+
await processTemplateDir(join(TEMPLATES_DIR, 'mobile'), mobileTarget, context);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── 3. Copy web templates ────────────────────────────────
|
|
45
|
+
if (options.includeWeb) {
|
|
46
|
+
console.log(chalk.cyan('🌐 Setting up Astro web app...'));
|
|
47
|
+
const webTarget = join(targetDir, 'apps', 'web');
|
|
48
|
+
await mkdir(webTarget, { recursive: true });
|
|
49
|
+
await processTemplateDir(join(TEMPLATES_DIR, 'web'), webTarget, context);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── 4. Create packages dir ────────────────────────────────
|
|
53
|
+
const packagesDir = join(targetDir, 'packages');
|
|
54
|
+
await mkdir(packagesDir, { recursive: true });
|
|
55
|
+
await writeFile(join(packagesDir, '.gitkeep'), '');
|
|
56
|
+
|
|
57
|
+
// ── 5. Git init ──────────────────────────────────────────
|
|
58
|
+
if (options.initGit) {
|
|
59
|
+
console.log(chalk.cyan('🔧 Initializing git repository...'));
|
|
60
|
+
await exec('git init', { cwd: targetDir });
|
|
61
|
+
await exec('git add -A', { cwd: targetDir });
|
|
62
|
+
await exec('git commit -m "chore: initial scaffold from create-turniza-app"', {
|
|
63
|
+
cwd: targetDir,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 6. Install deps ──────────────────────────────────────
|
|
68
|
+
if (options.installDeps) {
|
|
69
|
+
if (options.includeMobile) {
|
|
70
|
+
console.log(chalk.cyan('📦 Installing Flutter dependencies...'));
|
|
71
|
+
await exec('flutter pub get', { cwd: join(targetDir, 'apps', 'mobile') });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.includeWeb) {
|
|
75
|
+
console.log(chalk.cyan('📦 Installing web dependencies...'));
|
|
76
|
+
await exec('bun install', { cwd: join(targetDir, 'apps', 'web') });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(chalk.cyan('🪝 Installing lefthook...'));
|
|
80
|
+
try {
|
|
81
|
+
await exec('lefthook install', { cwd: targetDir });
|
|
82
|
+
} catch {
|
|
83
|
+
console.log(chalk.yellow(' ⚠️ lefthook not found, skipping hook install'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Recursively process a template directory:
|
|
90
|
+
* - `.hbs` files are rendered with Handlebars then written without the extension
|
|
91
|
+
* - All other files are copied as-is
|
|
92
|
+
*/
|
|
93
|
+
async function processTemplateDir(
|
|
94
|
+
sourceDir: string,
|
|
95
|
+
targetDir: string,
|
|
96
|
+
context: Record<string, unknown>,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
99
|
+
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
102
|
+
|
|
103
|
+
// Skip symlinks – they can't be copied reliably (e.g. iOS .symlinks)
|
|
104
|
+
const entryStat = await lstat(sourcePath);
|
|
105
|
+
if (entryStat.isSymbolicLink()) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isHbs = entry.name.endsWith('.hbs');
|
|
110
|
+
const targetName = isHbs ? entry.name.slice(0, -4) : entry.name;
|
|
111
|
+
const targetPath = join(targetDir, targetName);
|
|
112
|
+
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
await mkdir(targetPath, { recursive: true });
|
|
115
|
+
await processTemplateDir(sourcePath, targetPath, context);
|
|
116
|
+
} else if (isHbs) {
|
|
117
|
+
// Process Handlebars template
|
|
118
|
+
const templateContent = await readFile(sourcePath, 'utf-8');
|
|
119
|
+
const template = Handlebars.compile(templateContent, { noEscape: true });
|
|
120
|
+
const rendered = template(context);
|
|
121
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
122
|
+
await writeFile(targetPath, rendered);
|
|
123
|
+
console.log(chalk.dim(` + ${relative(process.cwd(), targetPath)}`));
|
|
124
|
+
} else {
|
|
125
|
+
// Copy file as-is
|
|
126
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
127
|
+
await copyFile(sourcePath, targetPath);
|
|
128
|
+
console.log(chalk.dim(` + ${relative(process.cwd(), targetPath)}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export interface ExecOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Execute a shell command and return stdout.
|
|
9
|
+
* Throws on non-zero exit code.
|
|
10
|
+
*/
|
|
11
|
+
export function exec(command: string, options: ExecOptions = {}): Promise<string> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn('sh', ['-c', command], {
|
|
14
|
+
cwd: options.cwd,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let stdout = '';
|
|
19
|
+
let stderr = '';
|
|
20
|
+
|
|
21
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
22
|
+
stdout += data.toString();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
26
|
+
stderr += data.toString();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
if (code !== 0) {
|
|
31
|
+
reject(new Error(`Command "${command}" failed (exit ${code}):\n${stderr}`));
|
|
32
|
+
} else {
|
|
33
|
+
resolve(stdout.trim());
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
child.on('error', reject);
|
|
38
|
+
});
|
|
39
|
+
}
|