Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a CLI for creating kitforstartup-Apps #20

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions create/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
build/
test-module/
32 changes: 32 additions & 0 deletions create/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@kfs/create",
"version": "0.0.1",
"private": false,
"description": "",
"bin": "build/index.js",
"scripts": {
"cli": "rm -rf test-module && tsc && node build/index.js test-module",
"prepublishOnly": "tsc"
},
"keywords": [],
"license": "MIT",
"type": "module",
"devDependencies": {
"@types/node": "^20.6.0",
"typescript": "^5.2.2"
},
"files": [
"src",
"build"
],
"dependencies": {
"@clack/prompts": "^0.7.0",
"@types/command-exists": "^1.2.1",
"@types/shelljs": "^0.8.12",
"@types/degit": "^2.8.6",
"chalk": "^5.3.0",
"command-exists": "^1.2.9",
"shelljs": "^0.8.5",
"degit": "^2.8.4"
}
}
68 changes: 68 additions & 0 deletions create/src/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as p from '@clack/prompts';
import * as fs from 'fs/promises';
import {unwrap_cancellation} from "./utils.js";

export type PrimaryDatabase = "MySQL" | "Postgres" | "Turso" | null;

async function replace_recursively(pwd: string, old_name: string, new_name: string) {
let files = await fs.readdir(pwd, {withFileTypes: true});
for (let file of files) {
if (file.isDirectory()) {
await replace_recursively(pwd + "/" + file.name, old_name, new_name);
} else {
let content = await fs.readFile(pwd + "/" + file.name, "utf-8");
content = content.replace(old_name, new_name);
await fs.writeFile(pwd + "/" + file.name, content);
}
}
}

async function select_lucia_module(new_name: string) {
// print current cwd (not argument)
await replace_recursively( "src", "$lib/lucia/mysql", "$lib/lucia/" + new_name);
}

async function select_database_driver(new_name: string) {
await replace_recursively("src", "$lib/drizzle/mysql/models", "$lib/drizzle/" + new_name + "/models");
}

export async function configureDatabase(): Promise<PrimaryDatabase> {
let db = unwrap_cancellation(await p.select({
message: "Primary Database",
options: [
{
value: "MySQL",
label: "MySQL",
hint: "Simpler, faster for read-heavy workloads"
},
{
value: "Postgres",
label: "Postgres",
hint: "Feature-rich, better for complex queries, supports advanced data types"
},
{
value: "Turso",
label: "Turso",
hint: "Online database platform"
},
{
value: "None",
label: "None",
hint: "I'll configure the database later."
}
],
initialValue: "MySQL"
}));
if (db === "None") return null;

if (db === "Postgres") {
await select_database_driver("postgres")
await select_lucia_module("postgres")
}
if (db === "Turso") {
await select_database_driver("turso")
await select_lucia_module("turso")
}

return db as PrimaryDatabase;
}
95 changes: 95 additions & 0 deletions create/src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import chalk from 'chalk';
import commandExists from "command-exists";
import * as p from '@clack/prompts';

async function check_command(command: string) {
return await commandExists(command).then(() => true).catch(() => false);
}

export type FeatureConfigurationFlag = "core" | "create_git_repo";

type Dependency = {
name: string;
message: string;
error: string;
check: () => Promise<boolean>;
required_flags: FeatureConfigurationFlag[];
docker_feature: boolean;
}

const dependencies: Dependency[] = [
{
name: "git",
message: "Git is installed",
error: "Git is not installed",
check: () => check_command('git'),
required_flags: ["create_git_repo"],
docker_feature: false,
},
{
name: "pnpm",
message: "PNPM is installed",
error: "PNPM is not installed",
check: () => check_command('pnpm'),
required_flags: ["core"],
docker_feature: false,
},
{
name: "node",
message: "Node.js version v18 or later is installed",
error: "Node.js version v18 or later is not installed",
check: async () => {
let node_version = process.version.split('.')[0].replace('v', '');
return parseInt(node_version) >= 18;
},
required_flags: ["core"],
docker_feature: false,
},
{
name: "docker",
message: "Docker is installed",
error: "Docker is not installed",
check: () => check_command('docker'),
required_flags: [],
docker_feature: true,
},
{
name: "docker-compose",
message: "Docker Compose is installed",
error: "Docker Compose is not installed",
check: () => check_command('docker-compose'),
required_flags: [],
docker_feature: true,
}
]

export type DockerSupported = boolean;

export async function check_dependencies(flags: FeatureConfigurationFlag[]): Promise<DockerSupported> {
let had_error = false;
let docker_supported = true;

for (let dependency of dependencies) {
if (await dependency.check()) {
p.log.info(dependency.message);
} else {
if (dependency.required_flags.some(flag => flags.includes(flag))) {
had_error = true;
p.log.error(dependency.error);
} else {
p.log.error(dependency.error + chalk.gray(" (optional)"));
}

if (dependency.docker_feature) {
docker_supported = false;
}
}
}

if (had_error) {
p.cancel("Please install the missing dependencies and try again");
process.exit(1);
}

return docker_supported;
}
107 changes: 107 additions & 0 deletions create/src/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as p from '@clack/prompts';
import {PrimaryDatabase} from "./database.js";
import {unwrap_cancellation} from "./utils.js";
import fs from "fs/promises";
import {DynamicConfigBuilder} from "./environment/utils.js";
import mysql_builder from "./environment/mysql_builder.js";
import postgres_builder from "./environment/postgres_builder.js";
import turso_builder from "./environment/turso_builder.js";
import github_oauth_builder from "./environment/github_oauth_builder.js";
import google_oauth_builder from "./environment/google_oauth_builder.js";
import resend_builder from "./environment/resend_builder.js";
import email_builder from "./environment/email_builder.js";

type ConfigBuilder = DynamicConfigBuilder | string[]

const environmentConfigBuilder: ConfigBuilder[] = [
[
"# DATABASE",
"ENABLE_DRIZZLE_LOGGER=true",
],
mysql_builder,
postgres_builder,
turso_builder,
[
"# OAUTH",
],
github_oauth_builder,
google_oauth_builder,
resend_builder,
email_builder
]

export async function configureEnvironment(docker_supported: boolean, primary_db: PrimaryDatabase) {
// Let the user select which environment variables to configure
let sections: {
label: string,
hint: string
}[] = environmentConfigBuilder
.filter((builder: ConfigBuilder) => !Array.isArray(builder)) // Only dynamic builders
.map((builder: ConfigBuilder) => builder as DynamicConfigBuilder)
.filter((builder: DynamicConfigBuilder) => {
if (docker_supported && builder.required_for_database) {
// Hide the database that is already configured
if (builder.required_for_database === primary_db) {
p.log.message(`Skipping ${builder.name} because it'll be configured automatically.`)
return false
}
}

return true
})
.map((builder: DynamicConfigBuilder) => ({
label: builder.name,
hint: builder.hint || ""
}))

let result: string[] = unwrap_cancellation(await p.multiselect({
message: "What parts of the environment would you like to configure? You'll have to configure the rest later in your .env file.",
required: false,
options: sections.map((section) => ({label: section.label, value: section.label, hint: section.hint})),
}));

let environment_sections: ConfigBuilder[] = environmentConfigBuilder
.map((builder: ConfigBuilder) => {
if (Array.isArray(builder)) return builder
let dynamic_builder = builder as DynamicConfigBuilder

// If the selection is the primary database
if (dynamic_builder.required_for_database === primary_db) {
if (dynamic_builder.autoconfigure) return {
...dynamic_builder,
configure: dynamic_builder.autoconfigure,
}

return dynamic_builder
}

// If the section is not selected, return the default environment variables
if (!result.includes(dynamic_builder.name)) {
let header = "#".repeat(dynamic_builder.depth) + " " + dynamic_builder.name
return [header, ...dynamic_builder.default_env]
}
return dynamic_builder
})

let env: string[] = []
for (let section of environment_sections) {
if (Array.isArray(section)) {
env.push(...section)
env.push("")
} else {
let dynamic_builder = section as DynamicConfigBuilder

let header = "#".repeat(dynamic_builder.depth) + " " + dynamic_builder.name
env.push(header)

let configured_env = await dynamic_builder.configure()
env.push(...configured_env)
env.push("")
}
}

let file_contents = env.join("\n")

// Write the environment variables to the .env file
await fs.writeFile(".env", file_contents, { encoding: "utf-8" })
}
30 changes: 30 additions & 0 deletions create/src/environment/email_builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {askEnvironmentConfig, DynamicConfigBuilder} from "./utils.js";

const builder: DynamicConfigBuilder = {
name: "Emails",
depth: 1,
configure: () => {
return askEnvironmentConfig([
{
name: "Email Sender Name",
hint: "The name that will appear as the sender of the emails",
key: "TRANSACTIONAL_EMAILS_SENDER",
default: "KitForStartups"
},
{
name: "Email Address",
hint: "The email address that will be used to send the emails",
key: "TRANSACTIONAL_EMAILS_ADDRESS",
default: "no-reply@kitforstartups.com"
},
])
},
default_env: [
"TRANSACTIONAL_EMAILS_SENDER=",
"TRANSACTIONAL_EMAILS_ADDRESS=",
],
default_enabled: true,
hint: "Transactional emails are sent from this address"
};

export default builder;
28 changes: 28 additions & 0 deletions create/src/environment/github_oauth_builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {askEnvironmentConfig, DynamicConfigBuilder} from "./utils.js";

const builder: DynamicConfigBuilder = {
name: "GitHub OAuth",
depth: 1,
configure: () => {
return askEnvironmentConfig([
{
name: "GitHub Client ID",
hint: "The client ID of the GitHub OAuth application",
key: "GITHUB_CLIENT_ID",
},
{
name: "GitHub Client Secret",
hint: "The client secret of the GitHub OAuth application",
key: "GITHUB_CLIENT_SECRET",
},
])
},
default_env: [
"GITHUB_CLIENT_ID=",
"GITHUB_CLIENT_SECRET=",
],
default_enabled: false,
hint: "This is used to authenticate users with GitHub"
};

export default builder;
Loading