create-droid 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,83 @@
1
+ # create-droid 🤖
2
+
3
+ The fastest way to start an Android project. No Studio required.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/create-droid.svg)](https://www.npmjs.com/package/create-droid)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Why?
9
+
10
+ Most Android tutorials assume you want to download a 2GB IDE just to write "Hello World".
11
+ Modern web developers are used to tools like `create-react-app` or `vite` – simple, fast, and CLI-first.
12
+
13
+ **`create-droid` brings that DX to Android.**
14
+
15
+ * **⚡️ Fast:** Scaffolds a project in seconds.
16
+ * **🚫 No Studio Required:** Fully functional Gradle builds out of the box.
17
+ * **🛠 Local SDK Management:** Auto-downloads and configures the Android SDK locally (no global pollution).
18
+ * **💎 Modern Stack:** Kotlin DSL, Version Catalogs (`libs.versions.toml`), Jetpack Compose, and Material 3 by default.
19
+ * **🐧 Linux & Mac First:** Designed for terminal-centric workflows.
20
+
21
+ ## Prerequisites
22
+
23
+ * **Node.js**: >= 18.0.0
24
+ * **Java (JDK)**: >= 17 (Run `java -version` to check)
25
+
26
+ ## Usage
27
+
28
+ Simply run:
29
+
30
+ ```bash
31
+ npm create droid my-app
32
+ # or
33
+ npx create-droid my-app
34
+ ```
35
+
36
+ Follow the interactive prompts:
37
+ 1. **Project Name**: Defaults to directory name.
38
+ 2. **UI Framework**: Choose **Jetpack Compose** (Modern declarative UI) or **XML Views** (Classic).
39
+
40
+ ### After Scaffolding
41
+
42
+ ```bash
43
+ cd my-app
44
+
45
+ # Build and verify the project
46
+ ./gradlew build
47
+
48
+ # Install on a connected device/emulator
49
+ ./gradlew installDebug
50
+ ```
51
+
52
+ ## What's Inside?
53
+
54
+ The generated project is **clean** and follows modern best practices:
55
+
56
+ ```text
57
+ my-app/
58
+ ├── app/
59
+ │ ├── src/main/java/com/example/ # Your Kotlin source code
60
+ │ └── build.gradle.kts # App module configuration
61
+ ├── gradle/
62
+ │ └── libs.versions.toml # Central dependency management
63
+ ├── build.gradle.kts # Root project configuration
64
+ ├── settings.gradle.kts # Module inclusion
65
+ ├── gradlew # The Gradle wrapper (runs builds)
66
+ └── local.properties # SDK location (auto-generated)
67
+ ```
68
+
69
+ ## Advanced
70
+
71
+ ### Customizing the SDK Location
72
+
73
+ By default, the SDK is installed to `~/.local/share/create-android-app/sdk`.
74
+ If you already have an SDK installed, simply set the environment variable:
75
+
76
+ ```bash
77
+ export ANDROID_HOME=/path/to/existing/sdk
78
+ npm create droid my-app
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT © YELrhilassi
package/bin/cli.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../dist/index.js";
3
+
4
+ run(process.argv.slice(2)).catch(err => {
5
+ console.error(err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,42 @@
1
+ import { execa } from 'execa';
2
+ import { CONSTANTS } from '../utils/constants.js';
3
+ import { logger } from '../utils/logger.js';
4
+ export async function checkEnv() {
5
+ // 1. Check Node
6
+ const nodeVersion = process.version;
7
+ if (!nodeVersion.startsWith('v' + CONSTANTS.NODE_VERSION_REQ) && parseInt(nodeVersion.substring(1)) < CONSTANTS.NODE_VERSION_REQ) {
8
+ logger.error(`Node.js ${CONSTANTS.NODE_VERSION_REQ}+ required. Found ${nodeVersion}`);
9
+ process.exit(1);
10
+ }
11
+ logger.success(`Node.js ${nodeVersion}`);
12
+ // 2. Check Java (Robust Regex)
13
+ try {
14
+ const { stdout, stderr } = await execa('java', ['-version']);
15
+ const output = stdout || stderr; // Java version often prints to stderr
16
+ // Regex to capture major version: "version \"17.0.1\"" or "17.0.1"
17
+ const versionMatch = output.match(/version\s+"?(\d+)/i);
18
+ if (versionMatch && versionMatch[1]) {
19
+ const majorVersion = parseInt(versionMatch[1], 10);
20
+ if (majorVersion >= CONSTANTS.JAVA_VERSION_REQ) {
21
+ logger.success(`Java version ${majorVersion} detected.`);
22
+ return;
23
+ }
24
+ else {
25
+ logger.error(`Java ${CONSTANTS.JAVA_VERSION_REQ}+ required. Found version ${majorVersion}.`);
26
+ }
27
+ }
28
+ else {
29
+ logger.warn(`Could not parse Java version from output: ${output}`);
30
+ logger.warn(`Assuming compatible if "17" or "21" is present...`);
31
+ if (!output.includes('17') && !output.includes('21')) {
32
+ logger.error(`Java 17+ required. Please verify your installation.`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+ }
37
+ catch (e) {
38
+ logger.error(`Java runtime not found. Please install JDK 17+.`);
39
+ logger.info(`Download: https://adoptium.net/temurin/releases/`);
40
+ process.exit(1);
41
+ }
42
+ }
@@ -0,0 +1,232 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { execa } from 'execa';
4
+ import { logger } from '../utils/logger.js';
5
+ import { pipeline } from 'stream/promises';
6
+ import { createWriteStream } from 'fs';
7
+ export async function setupGradle(projectPath) {
8
+ const wrapperDir = path.join(projectPath, 'gradle', 'wrapper');
9
+ await fs.ensureDir(wrapperDir);
10
+ const wrapperJarPath = path.join(wrapperDir, 'gradle-wrapper.jar');
11
+ const gradlewPath = path.join(projectPath, 'gradlew');
12
+ // 1. Ensure gradle-wrapper.jar exists
13
+ if (!fs.existsSync(wrapperJarPath)) {
14
+ await ensureWrapperJar(wrapperJarPath);
15
+ }
16
+ // 2. Write gradlew script (Shell) - Full standard script
17
+ if (!fs.existsSync(gradlewPath)) {
18
+ await fs.writeFile(gradlewPath, GRADLEW_SCRIPT.trim(), { mode: 0o755 });
19
+ }
20
+ // 3. Make executable
21
+ await fs.chmod(gradlewPath, 0o755);
22
+ // 4. Verify Wrapper (Dry Run)
23
+ logger.info(`Verifying Gradle setup...`);
24
+ try {
25
+ await execa(gradlewPath, ['--version'], { cwd: projectPath, stdio: 'inherit' });
26
+ logger.success('Gradle wrapper verified.');
27
+ }
28
+ catch (e) {
29
+ logger.warn('Gradle wrapper verification failed. You may need to run it manually.');
30
+ }
31
+ }
32
+ async function ensureWrapperJar(destPath) {
33
+ logger.step('Downloading gradle-wrapper.jar...');
34
+ // Reliable source from Google's sample
35
+ const WRAPPER_URL = 'https://raw.githubusercontent.com/android/nowinandroid/main/gradle/wrapper/gradle-wrapper.jar';
36
+ try {
37
+ const response = await fetch(WRAPPER_URL);
38
+ if (!response.ok)
39
+ throw new Error(`Failed to fetch wrapper jar: ${response.statusText}`);
40
+ if (!response.body)
41
+ throw new Error('No body in response');
42
+ await pipeline(response.body, createWriteStream(destPath));
43
+ }
44
+ catch (e) {
45
+ throw new Error(`Failed to download gradle-wrapper.jar. Error: ${e.message}`);
46
+ }
47
+ }
48
+ // Standard POSIX gradlew script (simplified but fully functional variant)
49
+ const GRADLEW_SCRIPT = `#!/bin/sh
50
+
51
+ #
52
+ # Copyright 2015 the original author or authors.
53
+ #
54
+ # Licensed under the Apache License, Version 2.0 (the "License");
55
+ # you may not use this file except in compliance with the License.
56
+ # You may obtain a copy of the License at
57
+ #
58
+ # https://www.apache.org/licenses/LICENSE-2.0
59
+ #
60
+ # Unless required by applicable law or agreed to in writing, software
61
+ # distributed under the License is distributed on an "AS IS" BASIS,
62
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
63
+ # See the License for the specific language governing permissions and
64
+ # limitations under the License.
65
+ #
66
+
67
+ ##############################################################################
68
+ #
69
+ # Gradle start up script for POSIX generated by Gradle.
70
+ #
71
+ ##############################################################################
72
+
73
+ # Attempt to set APP_HOME
74
+ # Resolve links: $0 may be a link
75
+ PRG="$0"
76
+ # Need this for relative symlinks.
77
+ while [ -h "$PRG" ] ; do
78
+ ls=\`ls -ld "$PRG"\`
79
+ link=\`expr "$ls" : '.*-> \\(.*\\)$'\`
80
+ if expr "$link" : '/.*' > /dev/null; then
81
+ PRG="$link"
82
+ else
83
+ PRG=\`dirname "$PRG"\`"/$link"
84
+ fi
85
+ done
86
+ SAVED="$\`pwd\`"
87
+ cd "\`dirname \\"$PRG\\"\`/" >/dev/null
88
+ APP_HOME="\`pwd -P\`"
89
+ cd "$SAVED" >/dev/null
90
+
91
+ APP_NAME="Gradle"
92
+ APP_BASE_NAME=\`basename "$0"\`
93
+
94
+ # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
95
+ DEFAULT_JVM_OPTS=""
96
+
97
+ # Use the maximum available, or set MAX_FD != -1 to use that value.
98
+ MAX_FD="maximum"
99
+
100
+ warn () {
101
+ echo "$*"
102
+ }
103
+
104
+ die () {
105
+ echo
106
+ echo "$*"
107
+ echo
108
+ exit 1
109
+ }
110
+
111
+ # OS specific support (must be 'true' or 'false').
112
+ cygwin=false
113
+ msys=false
114
+ darwin=false
115
+ nonstop=false
116
+ case "\`uname\`" in
117
+ CYGWIN* )
118
+ cygwin=true
119
+ ;;
120
+ Darwin* )
121
+ darwin=true
122
+ ;;
123
+ MINGW* )
124
+ msys=true
125
+ ;;
126
+ NONSTOP* )
127
+ nonstop=true
128
+ ;;
129
+ esac
130
+
131
+ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
132
+
133
+ # Determine the Java command to use to start the JVM.
134
+ if [ -n "$JAVA_HOME" ] ; then
135
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
136
+ # IBM's JDK on AIX uses strange locations for the executables
137
+ JAVACMD="$JAVA_HOME/jre/sh/java"
138
+ else
139
+ JAVACMD="$JAVA_HOME/bin/java"
140
+ fi
141
+ if [ ! -x "$JAVACMD" ] ; then
142
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
143
+
144
+ Please set the JAVA_HOME variable in your environment to match the
145
+ location of your Java installation."
146
+ fi
147
+ else
148
+ JAVACMD="java"
149
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
150
+
151
+ Please set the JAVA_HOME variable in your environment to match the
152
+ location of your Java installation."
153
+ fi
154
+
155
+ # Increase the maximum file descriptors if we can.
156
+ if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
157
+ MAX_FD_LIMIT=\`ulimit -H -n\`
158
+ if [ $? -eq 0 ] ; then
159
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
160
+ MAX_FD="$MAX_FD_LIMIT"
161
+ fi
162
+ ulimit -n $MAX_FD
163
+ if [ $? -ne 0 ] ; then
164
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
165
+ fi
166
+ else
167
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
168
+ fi
169
+ fi
170
+
171
+ # For Darwin, add options to specify how the application appears in the dock
172
+ if $darwin; then
173
+ GRADLE_OPTS="$GRADLE_OPTS \\"-Xdock:name=$APP_NAME\\" \\"-Xdock:icon=$APP_HOME/media/gradle.icns\\""
174
+ fi
175
+
176
+ # For Cygwin, switch paths to Windows format before running java
177
+ if $cygwin ; then
178
+ APP_HOME=\`cygpath --path --mixed "$APP_HOME"\`
179
+ CLASSPATH=\`cygpath --path --mixed "$CLASSPATH"\`
180
+ JAVACMD=\`cygpath --unix "$JAVACMD"\`
181
+
182
+ # We build the pattern for arguments to be converted via cygpath
183
+ ROOTDIRSRAW=\`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null\`
184
+ SEP=""
185
+ for dir in $ROOTDIRSRAW ; do
186
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
187
+ SEP="|"
188
+ done
189
+ OURCYGPATTERN="(^($ROOTDIRS))"
190
+ # Add a user-defined pattern to the cygpath arguments
191
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
192
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
193
+ fi
194
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
195
+ i=0
196
+ for arg in "$@" ; do
197
+ CHECK=\`echo "$arg"|egrep -c "$OURCYGPATTERN" - \`
198
+ CHECK2=\`echo "$arg"|egrep -c "^-"\` ### Determine if an option
199
+
200
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
201
+ eval \`echo args$i\`=\`cygpath --path --ignore --mixed "$arg"\`
202
+ else
203
+ eval \`echo args$i\`="\\"$arg\\""
204
+ fi
205
+ i=$((i+1))
206
+ done
207
+ case $i in
208
+ (0) set -- ;;
209
+ (1) set -- "$args0" ;;
210
+ (2) set -- "$args0" "$args1" ;;
211
+ (3) set -- "$args0" "$args1" "$args2" ;;
212
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
213
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
214
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
215
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
216
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
217
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
218
+ esac
219
+ fi
220
+
221
+ # Escape application args
222
+ save () {
223
+ for i do printf %s\\\\n "$i" | sed "s/'/'\\\\\\\\''/g;1s/^/'/;\$s/\$/' \\\\\\\\/" ; done
224
+ echo " "
225
+ }
226
+ APP_ARGS=$(save "$@")
227
+
228
+ # Collect all arguments for the java command, following the shell quoting and substitution rules
229
+ eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\\"-Dorg.gradle.appname=$APP_BASE_NAME\\"" -classpath "\\"$CLASSPATH\\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
230
+
231
+ exec "$JAVACMD" "$@"
232
+ `;
package/dist/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import prompts from 'prompts';
2
+ import { logger } from './utils/logger.js';
3
+ import { checkEnv } from './env/checkEnv.js';
4
+ import { installSdk } from './sdk/installSdk.js';
5
+ import { generateProject } from './template/generateProject.js';
6
+ import { setupGradle } from './gradle/setupGradle.js';
7
+ import path from 'path';
8
+ export async function run(args) {
9
+ logger.banner();
10
+ // 1. Collect Input
11
+ let targetDir = args[0];
12
+ const defaultProjectName = targetDir || 'android-app';
13
+ const response = await prompts([
14
+ {
15
+ type: targetDir ? null : 'text',
16
+ name: 'projectName',
17
+ message: 'Project name:',
18
+ initial: defaultProjectName
19
+ },
20
+ {
21
+ type: 'select',
22
+ name: 'uiType',
23
+ message: 'Select UI Framework:',
24
+ choices: [
25
+ { title: 'Jetpack Compose (Recommended)', value: 'compose' },
26
+ { title: 'XML Views (Legacy)', value: 'views' }
27
+ ],
28
+ initial: 0
29
+ }
30
+ ], {
31
+ onCancel: () => {
32
+ logger.error('Operation cancelled');
33
+ process.exit(0);
34
+ }
35
+ });
36
+ const projectName = response.projectName || targetDir;
37
+ const projectPath = path.resolve(process.cwd(), projectName);
38
+ const uiType = response.uiType;
39
+ // 2. Validate Environment
40
+ logger.step('Checking Environment...');
41
+ await checkEnv();
42
+ // 3. Setup Android SDK
43
+ logger.step('Setting up Android SDK...');
44
+ const sdkPath = await installSdk();
45
+ // 4. Generate Project Files
46
+ logger.step(`Scaffolding project in ${projectName}...`);
47
+ await generateProject({
48
+ projectPath,
49
+ projectName,
50
+ uiType,
51
+ sdkPath
52
+ });
53
+ // 5. Setup Gradle Wrapper
54
+ logger.step('Configuring Gradle...');
55
+ await setupGradle(projectPath);
56
+ logger.success(`Project created at ${projectPath}`);
57
+ logger.info('To get started:');
58
+ console.log(` cd ${projectName}`);
59
+ console.log(` ./gradlew installDebug`);
60
+ }
@@ -0,0 +1,112 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fs from 'fs-extra';
4
+ import { CONSTANTS } from '../utils/constants.js';
5
+ import { logger } from '../utils/logger.js';
6
+ import { execa } from 'execa';
7
+ import { pipeline } from 'stream/promises';
8
+ import { createWriteStream } from 'fs';
9
+ import AdmZip from 'adm-zip';
10
+ export async function installSdk() {
11
+ const isMac = process.platform === 'darwin';
12
+ // 1. Determine SDK Path
13
+ const sdkPath = process.env.ANDROID_HOME || path.join(os.homedir(), '.local', 'share', 'create-android-app', 'sdk');
14
+ if (process.env.ANDROID_HOME) {
15
+ logger.info(`Using ANDROID_HOME: ${sdkPath}`);
16
+ }
17
+ else {
18
+ logger.info(`Using local SDK path: ${sdkPath}`);
19
+ }
20
+ await fs.ensureDir(sdkPath);
21
+ // 2. Check for cmdline-tools
22
+ const cmdlineToolsRoot = path.join(sdkPath, 'cmdline-tools');
23
+ const cmdlineToolsLatest = path.join(cmdlineToolsRoot, 'latest');
24
+ const sdkManagerPath = path.join(cmdlineToolsLatest, 'bin', 'sdkmanager');
25
+ if (!fs.existsSync(sdkManagerPath)) {
26
+ logger.step('Android Command Line Tools not found. Downloading...');
27
+ await downloadCmdlineTools(sdkPath, cmdlineToolsLatest, isMac);
28
+ }
29
+ else {
30
+ logger.success('Command Line Tools found.');
31
+ }
32
+ // 3. Accept Licenses (Robust Approach: File Injection)
33
+ // Instead of piping 'yes', we write known hashes.
34
+ // These hashes correspond to standard Android SDK licenses.
35
+ // Source: common knowledge in Android community + verification.
36
+ const licensesDir = path.join(sdkPath, 'licenses');
37
+ await fs.ensureDir(licensesDir);
38
+ const androidSdkLicense = [
39
+ '8933bad161af4178b1185d1a37fbf41ea5269c55',
40
+ 'd56f5187479451eabf01fb78af6dfcb131a6481e',
41
+ '24333f8a63b6825ea9c5514f83c2829b004d1fee',
42
+ ].join('\n');
43
+ const androidSdkPreviewLicense = '84831b9409646a918e30573bab4c9c91346d8abd';
44
+ logger.step('Writing license files...');
45
+ await fs.writeFile(path.join(licensesDir, 'android-sdk-license'), androidSdkLicense);
46
+ await fs.writeFile(path.join(licensesDir, 'android-sdk-preview-license'), androidSdkPreviewLicense);
47
+ // 4. Install Packages
48
+ logger.step('Installing SDK packages (this may take a while)...');
49
+ const packages = CONSTANTS.SDK_PACKAGES;
50
+ try {
51
+ // With licenses pre-accepted, we just run sdkmanager directly.
52
+ // Use --verbose to verify output if needed, but keep it clean for user unless debug.
53
+ // Check if packages are already installed to skip slow process?
54
+ // sdkmanager is slow. If platforms/android-34 exists, maybe skip?
55
+ const platformCheck = path.join(sdkPath, 'platforms', 'android-34');
56
+ const buildToolsCheck = path.join(sdkPath, 'build-tools', '34.0.0');
57
+ if (fs.existsSync(platformCheck) && fs.existsSync(buildToolsCheck)) {
58
+ logger.success('SDK packages appear to be installed. Skipping redundant install.');
59
+ }
60
+ else {
61
+ await execa(sdkManagerPath, [`--sdk_root=${sdkPath}`, ...packages], {
62
+ stdio: 'inherit', // Let user see progress bars from sdkmanager
63
+ env: {
64
+ ANDROID_HOME: sdkPath,
65
+ // Ensure JAVA_HOME is picked up if set, usually execa inherits env
66
+ }
67
+ });
68
+ logger.success('SDK packages installed.');
69
+ }
70
+ }
71
+ catch (e) {
72
+ logger.error('Failed to install SDK packages.');
73
+ logger.error(e.message);
74
+ throw e; // Fail hard if SDK setup fails
75
+ }
76
+ return sdkPath;
77
+ }
78
+ async function downloadCmdlineTools(sdkPath, targetDir, isMac) {
79
+ const url = isMac ? CONSTANTS.CMDLINE_TOOLS_URL_MAC
80
+ : CONSTANTS.CMDLINE_TOOLS_URL_LINUX;
81
+ const zipPath = path.join(sdkPath, 'cmdline-tools.zip');
82
+ logger.info(`Downloading ${url}...`);
83
+ const response = await fetch(url);
84
+ if (!response.ok)
85
+ throw new Error(`Failed to download SDK: ${response.statusText}`);
86
+ if (!response.body)
87
+ throw new Error('No body in response');
88
+ await pipeline(response.body, createWriteStream(zipPath));
89
+ logger.info('Extracting (using adm-zip)...');
90
+ // Extract to temp folder
91
+ const tempDir = path.join(sdkPath, 'temp_extract');
92
+ await fs.ensureDir(tempDir);
93
+ const zip = new AdmZip(zipPath);
94
+ zip.extractAllTo(tempDir, true); // overwrite: true
95
+ // Structure: tempDir/cmdline-tools/...
96
+ // We need to find the root folder inside tempDir
97
+ const extractedContents = await fs.readdir(tempDir);
98
+ const rootFolder = extractedContents.find(f => fs.statSync(path.join(tempDir, f)).isDirectory());
99
+ if (!rootFolder)
100
+ throw new Error('Unknown zip structure');
101
+ const source = path.join(tempDir, rootFolder); // usually 'cmdline-tools'
102
+ // Move to targetDir (which is sdk/cmdline-tools/latest)
103
+ // Ensure parent exists
104
+ await fs.ensureDir(path.dirname(targetDir));
105
+ if (fs.existsSync(targetDir)) {
106
+ await fs.remove(targetDir);
107
+ }
108
+ await fs.move(source, targetDir);
109
+ // Cleanup
110
+ await fs.remove(tempDir);
111
+ await fs.remove(zipPath);
112
+ }
@@ -0,0 +1,109 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { CONSTANTS } from '../utils/constants.js';
5
+ import { logger } from '../utils/logger.js';
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ export async function generateProject(options) {
9
+ const { projectPath, projectName, uiType, sdkPath } = options;
10
+ // 1. Ensure target directory
11
+ await fs.ensureDir(projectPath);
12
+ // 2. Resolve template paths
13
+ const templateRoot = path.resolve(__dirname, '../../templates');
14
+ const baseTemplate = path.join(templateRoot, 'base');
15
+ const uiTemplateName = uiType === 'compose' ? 'ui-compose' : 'ui-views';
16
+ const uiTemplate = path.join(templateRoot, uiTemplateName);
17
+ if (!fs.existsSync(baseTemplate)) {
18
+ throw new Error(`Template not found at ${baseTemplate}`);
19
+ }
20
+ // 3. Copy Base
21
+ logger.info(`Copying base template from ${baseTemplate}...`);
22
+ await fs.copy(baseTemplate, projectPath);
23
+ // 4. Copy UI specific files
24
+ if (fs.existsSync(uiTemplate)) {
25
+ logger.info(`Applying ${uiType} template...`);
26
+ await fs.copy(uiTemplate, projectPath, { overwrite: true });
27
+ }
28
+ // 5. Patch Configuration
29
+ logger.info(`Patching configuration...`);
30
+ const packageName = `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
31
+ // Replace in settings.gradle.kts
32
+ await patchFile(path.join(projectPath, 'settings.gradle.kts'), {
33
+ '{{PROJECT_NAME}}': projectName,
34
+ });
35
+ // Replace in app/build.gradle.kts
36
+ await patchFile(path.join(projectPath, 'app/build.gradle.kts'), {
37
+ '{{APPLICATION_ID}}': packageName,
38
+ '{{COMPILE_SDK}}': CONSTANTS.COMPILE_SDK.toString(),
39
+ '{{MIN_SDK}}': CONSTANTS.MIN_SDK.toString(),
40
+ '{{TARGET_SDK}}': CONSTANTS.TARGET_SDK.toString(),
41
+ });
42
+ // Replace in app/src/main/res/values/strings.xml
43
+ await patchFile(path.join(projectPath, 'app/src/main/res/values/strings.xml'), {
44
+ '{{PROJECT_NAME}}': projectName,
45
+ });
46
+ // Handle Source Code Relocation
47
+ const srcBase = path.join(projectPath, 'app/src/main/java');
48
+ const oldPackagePath = path.join(srcBase, 'com/example/template');
49
+ const newPackagePath = path.join(srcBase, ...packageName.split('.'));
50
+ if (fs.existsSync(oldPackagePath)) {
51
+ await fs.move(oldPackagePath, newPackagePath, { overwrite: true });
52
+ // Recursive file patching for package statement
53
+ await patchSourceFiles(newPackagePath, packageName);
54
+ // Clean up empty dirs (com/example/template -> com/example -> com)
55
+ // Only if empty.
56
+ await cleanEmptyDirs(srcBase);
57
+ }
58
+ // Create local.properties with SDK location
59
+ const localProperties = `sdk.dir=${sdkPath}`;
60
+ await fs.writeFile(path.join(projectPath, 'local.properties'), localProperties);
61
+ }
62
+ async function patchFile(filePath, replacements) {
63
+ if (!fs.existsSync(filePath))
64
+ return;
65
+ let content = await fs.readFile(filePath, 'utf-8');
66
+ for (const [key, value] of Object.entries(replacements)) {
67
+ content = content.replaceAll(key, value);
68
+ }
69
+ await fs.writeFile(filePath, content);
70
+ }
71
+ async function patchSourceFiles(dir, packageName) {
72
+ const files = await fs.readdir(dir);
73
+ for (const file of files) {
74
+ const fullPath = path.join(dir, file);
75
+ const stat = await fs.stat(fullPath);
76
+ if (stat.isDirectory()) {
77
+ await patchSourceFiles(fullPath, packageName);
78
+ }
79
+ else if (file.endsWith('.kt') || file.endsWith('.java')) {
80
+ await patchFile(fullPath, {
81
+ '{{PACKAGE_NAME}}': packageName
82
+ });
83
+ }
84
+ }
85
+ }
86
+ async function cleanEmptyDirs(dir) {
87
+ // Basic cleanup: remove com/example/template if empty
88
+ // We moved content from com/example/template to new path.
89
+ // So we check if com/example/template is empty, then com/example, then com.
90
+ // This is tricky because we don't know the exact depth of the old structure easily without hardcoding.
91
+ // Hardcoded for "com/example/template":
92
+ const oldPath = path.join(dir, 'com/example/template');
93
+ try {
94
+ if (fs.existsSync(oldPath) && (await fs.readdir(oldPath)).length === 0) {
95
+ await fs.rmdir(oldPath);
96
+ const parent = path.dirname(oldPath);
97
+ if ((await fs.readdir(parent)).length === 0) {
98
+ await fs.rmdir(parent);
99
+ const grandParent = path.dirname(parent);
100
+ if ((await fs.readdir(grandParent)).length === 0) {
101
+ await fs.rmdir(grandParent);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ catch (e) {
107
+ // Ignore errors during cleanup
108
+ }
109
+ }
@@ -0,0 +1,24 @@
1
+ // The "Truth" - Pinned versions for stability
2
+ export const CONSTANTS = {
3
+ // Tooling
4
+ JAVA_VERSION_REQ: 17,
5
+ NODE_VERSION_REQ: 18,
6
+ // Android SDK
7
+ CMDLINE_TOOLS_URL_LINUX: "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip",
8
+ CMDLINE_TOOLS_URL_MAC: "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip",
9
+ SDK_PACKAGES: [
10
+ "platform-tools",
11
+ "platforms;android-34",
12
+ "build-tools;34.0.0",
13
+ "cmdline-tools;latest"
14
+ ],
15
+ // Template Defaults
16
+ COMPILE_SDK: 34,
17
+ TARGET_SDK: 34,
18
+ MIN_SDK: 24,
19
+ KOTLIN_VERSION: "1.9.23",
20
+ COMPOSE_COMPILER_EXTENSION_VERSION: "1.5.11",
21
+ AGP_VERSION: "8.3.2",
22
+ // Gradle
23
+ GRADLE_VERSION: "8.7"
24
+ };
@@ -0,0 +1,14 @@
1
+ import kleur from 'kleur';
2
+ export const logger = {
3
+ info: (msg) => console.log(kleur.blue('ℹ') + ' ' + msg),
4
+ success: (msg) => console.log(kleur.green('✔') + ' ' + msg),
5
+ warn: (msg) => console.log(kleur.yellow('⚠') + ' ' + msg),
6
+ error: (msg) => console.error(kleur.red('✖') + ' ' + msg),
7
+ step: (msg) => console.log(kleur.cyan('→') + ' ' + msg),
8
+ // Minimal "Vite-like" output
9
+ banner: () => {
10
+ console.log();
11
+ console.log(kleur.bgGreen().black(' CREATE-DROID '));
12
+ console.log();
13
+ }
14
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "create-droid",
3
+ "version": "1.0.0",
4
+ "description": "The fastest way to start an Android project. No Studio required.",
5
+ "author": "YELrhilassi",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/YELrhilassi/create-android-app.git"
10
+ },
11
+ "keywords": [
12
+ "android",
13
+ "kotlin",
14
+ "compose",
15
+ "cli",
16
+ "create-droid",
17
+ "scaffold",
18
+ "mobile"
19
+ ],
20
+ "bin": {
21
+ "create-droid": "bin/cli.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "dist",
26
+ "templates"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "main": "dist/index.js",
33
+ "type": "module",
34
+ "dependencies": {
35
+ "adm-zip": "^0.5.16",
36
+ "execa": "^9.6.1",
37
+ "fs-extra": "^11.3.3",
38
+ "kleur": "^4.1.5",
39
+ "prompts": "^2.4.2",
40
+ "tar": "^7.5.9"
41
+ },
42
+ "devDependencies": {
43
+ "@types/adm-zip": "^0.5.7",
44
+ "@types/fs-extra": "^11.0.4",
45
+ "@types/node": "^25.2.3",
46
+ "@types/prompts": "^2.4.9",
47
+ "@types/tar": "^6.1.13",
48
+ "typescript": "^5.9.3"
49
+ }
50
+ }
@@ -0,0 +1,3 @@
1
+ # Add project specific ProGuard rules here.
2
+ # By default, the flags in this file are appended to flags specified
3
+ # in 'proguard-android-optimize.txt' which is shipped with the Android SDK.
@@ -0,0 +1,5 @@
1
+ // Top-level build file where you can add configuration options common to all sub-projects/modules.
2
+ plugins {
3
+ alias(libs.plugins.android.application) apply false
4
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
5
+ }
@@ -0,0 +1,34 @@
1
+ [versions]
2
+ agp = "8.3.2"
3
+ kotlin = "1.9.23"
4
+ coreKtx = "1.12.0"
5
+ junit = "4.13.2"
6
+ junitVersion = "1.1.5"
7
+ espressoCore = "3.5.1"
8
+ lifecycleRuntimeKtx = "2.7.0"
9
+ activityCompose = "1.8.2"
10
+ composeBom = "2024.02.01"
11
+ appcompat = "1.6.1"
12
+ material = "1.11.0"
13
+
14
+ [libraries]
15
+ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
16
+ junit = { group = "junit", name = "junit", version.ref = "junit" }
17
+ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
18
+ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
19
+ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
20
+ androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21
+ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
22
+ androidx-ui = { group = "androidx.compose.ui", name = "ui" }
23
+ androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
24
+ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
25
+ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
26
+ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
27
+ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
28
+ androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
29
+ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
30
+ material = { group = "com.google.android.material", name = "material", version.ref = "material" }
31
+
32
+ [plugins]
33
+ android-application = { id = "com.android.application", version.ref = "agp" }
34
+ jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
@@ -0,0 +1,5 @@
1
+ distributionBase=GRADLE_USER_HOME
2
+ distributionPath=wrapper/dists
3
+ distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
4
+ zipStoreBase=GRADLE_USER_HOME
5
+ zipStorePath=wrapper/dists
@@ -0,0 +1,4 @@
1
+ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2
+ android.useAndroidX=true
3
+ kotlin.code.style=official
4
+ android.nonTransitiveRClass=true
@@ -0,0 +1,23 @@
1
+ pluginManagement {
2
+ repositories {
3
+ google {
4
+ content {
5
+ includeGroupByRegex("com\\.android.*")
6
+ includeGroupByRegex("com\\.google.*")
7
+ includeGroupByRegex("androidx.*")
8
+ }
9
+ }
10
+ mavenCentral()
11
+ gradlePluginPortal()
12
+ }
13
+ }
14
+ dependencyResolutionManagement {
15
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16
+ repositories {
17
+ google()
18
+ mavenCentral()
19
+ }
20
+ }
21
+
22
+ rootProject.name = "{{PROJECT_NAME}}"
23
+ include(":app")
@@ -0,0 +1,65 @@
1
+ plugins {
2
+ alias(libs.plugins.android.application)
3
+ alias(libs.plugins.jetbrains.kotlin.android)
4
+ }
5
+
6
+ android {
7
+ namespace = "{{APPLICATION_ID}}"
8
+ compileSdk = {{COMPILE_SDK}}
9
+
10
+ defaultConfig {
11
+ applicationId = "{{APPLICATION_ID}}"
12
+ minSdk = {{MIN_SDK}}
13
+ targetSdk = {{TARGET_SDK}}
14
+ versionCode = 1
15
+ versionName = "1.0"
16
+
17
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18
+ vectorDrawables {
19
+ useSupportLibrary = true
20
+ }
21
+ }
22
+
23
+ buildTypes {
24
+ release {
25
+ isMinifyEnabled = false
26
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
27
+ }
28
+ }
29
+ compileOptions {
30
+ sourceCompatibility = JavaVersion.VERSION_1_8
31
+ targetCompatibility = JavaVersion.VERSION_1_8
32
+ }
33
+ kotlinOptions {
34
+ jvmTarget = "1.8"
35
+ }
36
+ buildFeatures {
37
+ compose = true
38
+ }
39
+ composeOptions {
40
+ kotlinCompilerExtensionVersion = "1.5.11"
41
+ }
42
+ packaging {
43
+ resources {
44
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
45
+ }
46
+ }
47
+ }
48
+
49
+ dependencies {
50
+ implementation(libs.androidx.core.ktx)
51
+ implementation(libs.androidx.lifecycle.runtime.ktx)
52
+ implementation(libs.androidx.activity.compose)
53
+ implementation(platform(libs.androidx.compose.bom))
54
+ implementation(libs.androidx.ui)
55
+ implementation(libs.androidx.ui.graphics)
56
+ implementation(libs.androidx.ui.tooling.preview)
57
+ implementation(libs.androidx.material3)
58
+ testImplementation(libs.junit)
59
+ androidTestImplementation(libs.androidx.junit)
60
+ androidTestImplementation(libs.androidx.espresso.core)
61
+ androidTestImplementation(platform(libs.androidx.compose.bom))
62
+ androidTestImplementation(libs.androidx.ui.test.junit4)
63
+ debugImplementation(libs.androidx.ui.tooling)
64
+ debugImplementation(libs.androidx.ui.test.manifest)
65
+ }
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:tools="http://schemas.android.com/tools">
4
+
5
+ <application
6
+ android:allowBackup="true"
7
+ android:dataExtractionRules="@xml/data_extraction_rules"
8
+ android:fullBackupContent="@xml/backup_rules"
9
+ android:icon="@mipmap/ic_launcher"
10
+ android:label="@string/app_name"
11
+ android:roundIcon="@mipmap/ic_launcher_round"
12
+ android:supportsRtl="true"
13
+ android:theme="@style/Theme.MyApplication"
14
+ tools:targetApi="31">
15
+ <activity
16
+ android:name=".MainActivity"
17
+ android:exported="true"
18
+ android:label="@string/app_name"
19
+ android:theme="@style/Theme.MyApplication">
20
+ <intent-filter>
21
+ <action android:name="android.intent.action.MAIN" />
22
+
23
+ <category android:name="android.intent.category.LAUNCHER" />
24
+ </intent-filter>
25
+ </activity>
26
+ </application>
27
+
28
+ </manifest>
@@ -0,0 +1,46 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.os.Bundle
4
+ import androidx.activity.ComponentActivity
5
+ import androidx.activity.compose.setContent
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.foundation.layout.padding
8
+ import androidx.compose.material3.MaterialTheme
9
+ import androidx.compose.material3.Scaffold
10
+ import androidx.compose.material3.Surface
11
+ import androidx.compose.material3.Text
12
+ import androidx.compose.runtime.Composable
13
+ import androidx.compose.ui.Modifier
14
+ import androidx.compose.ui.tooling.preview.Preview
15
+
16
+ class MainActivity : ComponentActivity() {
17
+ override fun onCreate(savedInstanceState: Bundle?) {
18
+ super.onCreate(savedInstanceState)
19
+ setContent {
20
+ MaterialTheme {
21
+ Surface(
22
+ modifier = Modifier.fillMaxSize(),
23
+ color = MaterialTheme.colorScheme.background
24
+ ) {
25
+ Greeting("Android")
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ @Composable
33
+ fun Greeting(name: String, modifier: Modifier = Modifier) {
34
+ Text(
35
+ text = "Hello $name!",
36
+ modifier = modifier
37
+ )
38
+ }
39
+
40
+ @Preview(showBackground = true)
41
+ @Composable
42
+ fun GreetingPreview() {
43
+ MaterialTheme {
44
+ Greeting("Android")
45
+ }
46
+ }
@@ -0,0 +1,44 @@
1
+ plugins {
2
+ alias(libs.plugins.android.application)
3
+ alias(libs.plugins.jetbrains.kotlin.android)
4
+ }
5
+
6
+ android {
7
+ namespace = "{{APPLICATION_ID}}"
8
+ compileSdk = {{COMPILE_SDK}}
9
+
10
+ defaultConfig {
11
+ applicationId = "{{APPLICATION_ID}}"
12
+ minSdk = {{MIN_SDK}}
13
+ targetSdk = {{TARGET_SDK}}
14
+ versionCode = 1
15
+ versionName = "1.0"
16
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17
+ }
18
+
19
+ buildTypes {
20
+ release {
21
+ isMinifyEnabled = false
22
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
23
+ }
24
+ }
25
+ compileOptions {
26
+ sourceCompatibility = JavaVersion.VERSION_1_8
27
+ targetCompatibility = JavaVersion.VERSION_1_8
28
+ }
29
+ kotlinOptions {
30
+ jvmTarget = "1.8"
31
+ }
32
+ buildFeatures {
33
+ viewBinding = true
34
+ }
35
+ }
36
+
37
+ dependencies {
38
+ implementation(libs.androidx.core.ktx)
39
+ implementation(libs.androidx.appcompat)
40
+ implementation(libs.material)
41
+ testImplementation(libs.junit)
42
+ androidTestImplementation(libs.androidx.junit)
43
+ androidTestImplementation(libs.androidx.espresso.core)
44
+ }
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:tools="http://schemas.android.com/tools">
4
+
5
+ <application
6
+ android:allowBackup="true"
7
+ android:dataExtractionRules="@xml/data_extraction_rules"
8
+ android:fullBackupContent="@xml/backup_rules"
9
+ android:icon="@mipmap/ic_launcher"
10
+ android:label="@string/app_name"
11
+ android:roundIcon="@mipmap/ic_launcher_round"
12
+ android:supportsRtl="true"
13
+ android:theme="@style/Theme.MyApplication"
14
+ tools:targetApi="31">
15
+ <activity
16
+ android:name=".MainActivity"
17
+ android:exported="true"
18
+ android:label="@string/app_name"
19
+ android:theme="@style/Theme.MyApplication">
20
+ <intent-filter>
21
+ <action android:name="android.intent.action.MAIN" />
22
+
23
+ <category android:name="android.intent.category.LAUNCHER" />
24
+ </intent-filter>
25
+ </activity>
26
+ </application>
27
+
28
+ </manifest>
@@ -0,0 +1,19 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.os.Bundle
4
+ import androidx.appcompat.app.AppCompatActivity
5
+ import {{PACKAGE_NAME}}.databinding.ActivityMainBinding
6
+
7
+ class MainActivity : AppCompatActivity() {
8
+
9
+ private lateinit var binding: ActivityMainBinding
10
+
11
+ override fun onCreate(savedInstanceState: Bundle?) {
12
+ super.onCreate(savedInstanceState)
13
+
14
+ binding = ActivityMainBinding.inflate(layoutInflater)
15
+ setContentView(binding.root)
16
+
17
+ binding.textView.text = "Hello Android!"
18
+ }
19
+ }
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:app="http://schemas.android.com/apk/res-auto"
4
+ xmlns:tools="http://schemas.android.com/tools"
5
+ android:layout_width="match_parent"
6
+ android:layout_height="match_parent"
7
+ tools:context=".MainActivity">
8
+
9
+ <TextView
10
+ android:id="@+id/textView"
11
+ android:layout_width="wrap_content"
12
+ android:layout_height="wrap_content"
13
+ android:text="Hello World!"
14
+ app:layout_constraintBottom_toBottomOf="parent"
15
+ app:layout_constraintEnd_toEndOf="parent"
16
+ app:layout_constraintStart_toStartOf="parent"
17
+ app:layout_constraintTop_toTopOf="parent" />
18
+
19
+ </androidx.constraintlayout.widget.ConstraintLayout>