andrud 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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Test suite for validation utilities
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import {
8
+ validateAppName,
9
+ validatePackageNameInput,
10
+ validateDirectoryPath,
11
+ camelCase,
12
+ pascalCase,
13
+ kebabCase,
14
+ snakeCase
15
+ } from '../utils/validation.js';
16
+
17
+ test('Validation - App Name', async (t) => {
18
+ await t.test('should accept valid app name', () => {
19
+ const result = validateAppName('MyAwesomeApp');
20
+ assert.strictEqual(result.valid, true);
21
+ });
22
+
23
+ await t.test('should reject app name starting with number', () => {
24
+ const result = validateAppName('1App');
25
+ assert.strictEqual(result.valid, false);
26
+ assert.ok(result.errors.length > 0);
27
+ });
28
+
29
+ await t.test('should reject app name with spaces', () => {
30
+ const result = validateAppName('My App');
31
+ assert.strictEqual(result.valid, false);
32
+ });
33
+
34
+ await t.test('should reject empty app name', () => {
35
+ const result = validateAppName('');
36
+ assert.strictEqual(result.valid, false);
37
+ });
38
+ });
39
+
40
+ test('Validation - Package Name', async (t) => {
41
+ await t.test('should accept valid package name', () => {
42
+ const result = validatePackageNameInput('com.example.myapp');
43
+ assert.strictEqual(result.valid, true);
44
+ });
45
+
46
+ await t.test('should reject reserved prefix android', () => {
47
+ const result = validatePackageNameInput('android.example.app');
48
+ assert.strictEqual(result.valid, false);
49
+ });
50
+
51
+ await t.test('should reject reserved prefix kotlin', () => {
52
+ const result = validatePackageNameInput('kotlin.example.app');
53
+ assert.strictEqual(result.valid, false);
54
+ });
55
+
56
+ await t.test('should reject package with single segment', () => {
57
+ const result = validatePackageNameInput('myapp');
58
+ assert.strictEqual(result.valid, false);
59
+ });
60
+
61
+ await t.test('should warn on generic com.example prefix', () => {
62
+ const result = validatePackageNameInput('com.example.app');
63
+ assert.strictEqual(result.valid, true);
64
+ assert.ok(result.warnings && result.warnings.length > 0);
65
+ });
66
+ });
67
+
68
+ test('Validation - Directory Path', async (t) => {
69
+ await t.test('should accept valid directory path', () => {
70
+ const result = validateDirectoryPath('./my-project');
71
+ assert.strictEqual(result.valid, true);
72
+ });
73
+
74
+ await t.test('should reject empty directory path', () => {
75
+ const result = validateDirectoryPath('');
76
+ assert.strictEqual(result.valid, false);
77
+ });
78
+
79
+ await t.test('should reject directory traversal', () => {
80
+ const result = validateDirectoryPath('../../../etc/passwd');
81
+ assert.strictEqual(result.valid, false);
82
+ });
83
+ });
84
+
85
+ test('String Case Transformations', async (t) => {
86
+ await t.test('camelCase should transform correctly', () => {
87
+ const result = camelCase('my awesome app');
88
+ assert.strictEqual(result, 'myAwesomeApp');
89
+ });
90
+
91
+ await t.test('pascalCase should transform correctly', () => {
92
+ const result = pascalCase('my awesome app');
93
+ assert.strictEqual(result, 'MyAwesomeApp');
94
+ });
95
+
96
+ await t.test('kebabCase should transform correctly', () => {
97
+ const result = kebabCase('my awesome app');
98
+ assert.strictEqual(result, 'my-awesome-app');
99
+ });
100
+
101
+ await t.test('snakeCase should transform correctly', () => {
102
+ const result = snakeCase('my awesome app');
103
+ assert.strictEqual(result, 'my_awesome_app');
104
+ });
105
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Clean, minimal Vite-style project creation command
3
+ */
4
+
5
+ import {
6
+ text,
7
+ select,
8
+ confirm,
9
+ isCancel,
10
+ cancel,
11
+ spinner
12
+ } from '@clack/prompts';
13
+ import { generateProject } from '../../core/generator.js';
14
+ import { buildDefaultProjectContext, buildTemplateContext } from '../../core/context.js';
15
+ import { exists, getAbsolutePath } from '../../utils/filesystem.js';
16
+ import { getAllTemplates, getTemplateMetadata } from '../../templates/index.js';
17
+ import type { TemplateType } from '../../core/types.js';
18
+ import pc from 'picocolors';
19
+ import { resolve, isAbsolute } from 'path';
20
+ import { platform } from 'os';
21
+
22
+ /**
23
+ * Normalize path for cross-platform compatibility
24
+ */
25
+ function normalizePath(path: string): string {
26
+ if (!path || path.trim() === '') {
27
+ return path;
28
+ }
29
+
30
+ const trimmed = path.trim();
31
+
32
+ // On Windows, convert Unix-style paths like /d/... to D:\...
33
+ if (platform() === 'win32') {
34
+ const unixPathMatch = trimmed.match(/^\/([a-zA-Z])\/(.*)$/);
35
+ if (unixPathMatch && unixPathMatch[1] && unixPathMatch[2]) {
36
+ return `${unixPathMatch[1].toUpperCase()}:\\${unixPathMatch[2].replace(/\//g, '\\')}`;
37
+ }
38
+ }
39
+
40
+ // Force relative paths to be relative to current directory
41
+ if (!isAbsolute(trimmed)) {
42
+ return resolve(trimmed);
43
+ }
44
+
45
+ return trimmed;
46
+ }
47
+
48
+ /**
49
+ * Create a new Android project with clean minimal UI
50
+ */
51
+ export async function createCommand(options: { force?: boolean } = {}): Promise<void> {
52
+ // ============================================
53
+ // Step 1: App Name
54
+ // ============================================
55
+ console.log('');
56
+
57
+ let appName: string;
58
+ const nameResult = await text({
59
+ message: ' ? Project name:',
60
+ placeholder: 'MyApp',
61
+ defaultValue: 'MyApp',
62
+ });
63
+
64
+ if (isCancel(nameResult)) {
65
+ cancel();
66
+ return;
67
+ }
68
+
69
+ appName = nameResult.trim();
70
+ if (!appName || !/^[a-zA-Z]/.test(appName)) {
71
+ console.log(pc.red('\n ✘ Project name must start with a letter\n'));
72
+ return;
73
+ }
74
+
75
+ // ============================================
76
+ // Step 2: Template Selection
77
+ // ============================================
78
+ const templates = getAllTemplates();
79
+
80
+ const templateOptions = templates.map(t => ({
81
+ label: t.name,
82
+ value: t.id,
83
+ hint: t.language === 'kotlin' ? 'Kotlin' : 'Java'
84
+ }));
85
+
86
+ const templateResult = await select({
87
+ message: ' ? Select template:',
88
+ options: templateOptions
89
+ });
90
+
91
+ if (isCancel(templateResult)) {
92
+ cancel();
93
+ return;
94
+ }
95
+
96
+ const selectedTemplate = templateResult as TemplateType;
97
+ const selectedMeta = getTemplateMetadata(selectedTemplate);
98
+
99
+ // ============================================
100
+ // Step 3: Package Name
101
+ // ============================================
102
+ const defaultPackage = `com.example.${appName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
103
+
104
+ const packageResult = await text({
105
+ message: ' ? Package name:',
106
+ placeholder: defaultPackage,
107
+ defaultValue: defaultPackage,
108
+ });
109
+
110
+ if (isCancel(packageResult)) {
111
+ cancel();
112
+ return;
113
+ }
114
+
115
+ const packageName = packageResult.trim().toLowerCase();
116
+ if (!packageName || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(packageName)) {
117
+ console.log(pc.red('\n ✘ Invalid package name format (e.g., com.example.myapp)\n'));
118
+ return;
119
+ }
120
+
121
+ // ============================================
122
+ // Step 4: Directory
123
+ // ============================================
124
+ const dirResult = await text({
125
+ message: ' ? Where to create the project:',
126
+ placeholder: 'D:\\Projects\\Android',
127
+ defaultValue: 'D:\\Projects\\Android',
128
+ });
129
+
130
+ if (isCancel(dirResult)) {
131
+ cancel();
132
+ return;
133
+ }
134
+
135
+ // Normalize and append app name as project folder
136
+ let baseDir = dirResult.trim();
137
+
138
+ if (!baseDir) {
139
+ console.log(pc.red('\n ✘ Directory path cannot be empty\n'));
140
+ return;
141
+ }
142
+
143
+ baseDir = normalizePath(baseDir);
144
+
145
+ // Ensure the directory path ends properly (no trailing slash issues)
146
+ let projectDirectory: string;
147
+
148
+ // If the path is ./something, append app name
149
+ if (baseDir.startsWith('./') || baseDir === '.') {
150
+ const baseName = baseDir === '.' ? '' : baseDir.slice(1).replace(/^[\\\/]/, '');
151
+ projectDirectory = baseName
152
+ ? `${baseName}/${appName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
153
+ : `./${appName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
154
+ } else {
155
+ // For absolute paths, append app name + sanitize
156
+ projectDirectory = `${baseDir}\\${appName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
157
+ }
158
+
159
+ // Check if directory exists
160
+ const dirExists = await exists(projectDirectory);
161
+ if (dirExists) {
162
+ if (!options.force) {
163
+ console.log(pc.red(`\n ✘ Directory already exists: ${projectDirectory}`));
164
+ console.log(pc.gray(' Use --force to overwrite\n'));
165
+ return;
166
+ }
167
+ }
168
+
169
+ // ============================================
170
+ // Step 5: Summary & Confirm
171
+ // ============================================
172
+ console.log('');
173
+ console.log(pc.gray('─').repeat(50));
174
+ console.log('');
175
+ console.log(` ${pc.cyan('Project:')} ${pc.bold(appName)}`);
176
+ console.log(` ${pc.cyan('Template:')} ${selectedMeta?.name}`);
177
+ console.log(` ${pc.cyan('Package:')} ${packageName}`);
178
+ console.log(` ${pc.cyan('Path:')} ${projectDirectory}`);
179
+ console.log('');
180
+
181
+ const shouldCreate = await confirm({
182
+ message: ' Create project?',
183
+ initialValue: true
184
+ });
185
+
186
+ if (isCancel(shouldCreate)) {
187
+ cancel();
188
+ return;
189
+ }
190
+
191
+ if (!shouldCreate) {
192
+ console.log(pc.gray('\n Cancelled.\n'));
193
+ return;
194
+ }
195
+
196
+ // ============================================
197
+ // Generate Project
198
+ // ============================================
199
+ console.log('');
200
+
201
+ const s = spinner();
202
+ s.start(' Creating project...');
203
+
204
+ // Build context
205
+ const baseContext = buildDefaultProjectContext(
206
+ appName,
207
+ packageName,
208
+ projectDirectory,
209
+ selectedTemplate,
210
+ { git: true, readme: true, androidX: true, kotlinDsl: true }
211
+ );
212
+
213
+ const context = buildTemplateContext({
214
+ appName: baseContext.appName,
215
+ packageName: baseContext.packageName,
216
+ projectDirectory: baseContext.projectDirectory,
217
+ template: baseContext.template,
218
+ uiFramework: baseContext.uiFramework,
219
+ language: baseContext.language,
220
+ android: baseContext.android,
221
+ gradle: baseContext.gradle,
222
+ features: baseContext as unknown as Record<string, boolean>,
223
+ nativeCpp: baseContext.nativeCpp
224
+ });
225
+
226
+ // Generate the project
227
+ const result = await generateProject(context, {
228
+ overwrite: options.force ?? false,
229
+ dryRun: false,
230
+ skipInstall: false,
231
+ verbose: false
232
+ });
233
+
234
+ s.stop(' Done!');
235
+
236
+ if (result.success) {
237
+ console.log('');
238
+ console.log(pc.green(` ✓ Project "${appName}" created`));
239
+ console.log(pc.gray('─'.repeat(50)));
240
+ console.log('');
241
+ console.log(pc.gray(` cd ${projectDirectory}`));
242
+ console.log(pc.gray(' ./gradlew assembleDebug'));
243
+ console.log(pc.gray(' studio .'));
244
+ console.log('');
245
+ } else {
246
+ console.log(pc.red('\n ✘ Failed to create project'));
247
+ result.errors.forEach(err => {
248
+ console.log(` ${pc.red('•')} ${err.file ? `${err.file}: ` : ''}${err.message}`);
249
+ });
250
+ console.log('');
251
+ }
252
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Template info command - shows detailed information about templates
3
+ */
4
+
5
+ import { printSection, printKeyValue } from '../../ui/output.js';
6
+ import { gradientTeen, bold, primary, muted } from '../../ui/colors.js';
7
+ import { getTemplateMetadata, getAllTemplates, getTemplatePreview } from '../../templates/index.js';
8
+ import { GRADLE_VERSIONS, TEMPLATE_CONFIGS } from '../../core/config.js';
9
+ import pc from 'picocolors';
10
+ import type { TemplateType, TemplateMetadata } from '../../core/types.js';
11
+
12
+ interface InfoCommandOptions {
13
+ template?: string;
14
+ json: boolean;
15
+ }
16
+
17
+ /**
18
+ * Show detailed info about a template
19
+ */
20
+ export async function createInfoCommand(
21
+ template?: string,
22
+ options: InfoCommandOptions = { json: false }
23
+ ): Promise<void> {
24
+ if (options.json) {
25
+ if (template) {
26
+ const meta = getTemplateMetadata(template as TemplateType);
27
+ if (!meta) {
28
+ console.log(JSON.stringify({ error: `Template "${template}" not found` }, null, 2));
29
+ return;
30
+ }
31
+ printInfoJson(meta);
32
+ } else {
33
+ // Show info for all templates
34
+ const templates = getAllTemplates();
35
+ console.log(JSON.stringify(templates, null, 2));
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (template) {
41
+ await showTemplateInfo(template as TemplateType);
42
+ } else {
43
+ // Show brief info for all templates
44
+ console.log('');
45
+ console.log(gradientTeen('Available Templates'));
46
+ console.log('');
47
+
48
+ const templates = getAllTemplates();
49
+ const maxNameLength = Math.max(...templates.map(t => t.name.length));
50
+ const maxIdLength = Math.max(...templates.map(t => t.id.length));
51
+
52
+ templates.forEach((t, index) => {
53
+ const paddedName = t.name.padEnd(maxNameLength);
54
+ const paddedId = t.id.padEnd(maxIdLength);
55
+
56
+ console.log(` ${primary(`${index + 1}.`)} ${bold(paddedName)} ${muted(`(${paddedId})`)}`);
57
+ console.log(` ${t.description}`);
58
+ console.log('');
59
+ });
60
+
61
+ console.log('Usage: andrud info <template-name>');
62
+ console.log('Example: andrud info kotlin-compose');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Show detailed info for a specific template
68
+ */
69
+ async function showTemplateInfo(templateId: TemplateType): Promise<void> {
70
+ const meta = getTemplateMetadata(templateId);
71
+ if (!meta) {
72
+ console.log(pc.red(`Template "${templateId}" not found.`));
73
+ console.log('Available templates:');
74
+ getAllTemplates().forEach(t => console.log(` - ${t.id}`));
75
+ return;
76
+ }
77
+
78
+ // Get template config for version info
79
+ const config = TEMPLATE_CONFIGS[templateId];
80
+ if (!config) {
81
+ console.log(pc.red(`Template configuration not found for "${templateId}".`));
82
+ return;
83
+ }
84
+ const isCompose = templateId === 'kotlin-compose';
85
+
86
+ console.log('');
87
+ console.log(gradientTeen(`═══ ${meta.name} ═══`));
88
+ console.log('');
89
+ console.log(bold(meta.description));
90
+ console.log('');
91
+
92
+ // Key information
93
+ printSection('Configuration');
94
+ printKeyValue([
95
+ { key: 'Template ID', value: meta.id },
96
+ { key: 'Language', value: config.language === 'kotlin' ? pc.green('Kotlin') : pc.yellow('Java') },
97
+ { key: 'UI Framework', value: isCompose ? pc.cyan('Jetpack Compose') : pc.gray('XML Layouts') },
98
+ { key: 'Min SDK', value: `API ${config.minSdk}` },
99
+ { key: 'Target SDK', value: `API ${config.targetSdk}` },
100
+ { key: 'Compile SDK', value: `API ${config.compileSdk}` }
101
+ ]);
102
+
103
+ // Version information
104
+ printSection('Versions');
105
+ printKeyValue([
106
+ { key: 'Gradle', value: config.gradleVersion },
107
+ { key: 'Android Gradle Plugin', value: config.agpVersion },
108
+ { key: 'Kotlin', value: config.kotlinVersion || 'N/A' },
109
+ ...(isCompose ? [
110
+ { key: 'Compose BOM', value: GRADLE_VERSIONS.COMPOSE_BOM },
111
+ { key: 'Compose Compiler', value: GRADLE_VERSIONS.COMPOSE_COMPILER }
112
+ ] : []),
113
+ ...(templateId === 'native-cpp' ? [
114
+ { key: 'NDK', value: config.ndkVersion || GRADLE_VERSIONS.NDK }
115
+ ] : [])
116
+ ]);
117
+
118
+ // Features
119
+ printSection('Features');
120
+ meta.features.forEach((feature, i) => {
121
+ console.log(` ${pc.green('+')} ${feature}`);
122
+ });
123
+
124
+ // Keywords
125
+ printSection('Keywords');
126
+ console.log(` ${meta.keywords.map(k => pc.cyan(k)).join(', ')}`);
127
+
128
+ // Code preview
129
+ const preview = getTemplatePreview(templateId);
130
+ if (preview) {
131
+ printSection('Code Preview');
132
+ console.log(pc.gray('─'.repeat(60)));
133
+ console.log(pc.dim(preview.split('\n').slice(0, 15).join('\n')));
134
+ if (preview.split('\n').length > 15) {
135
+ console.log(pc.gray('...'));
136
+ }
137
+ }
138
+
139
+ console.log('');
140
+ }
141
+
142
+ /**
143
+ * Print template info as JSON
144
+ */
145
+ function printInfoJson(meta: TemplateMetadata): void {
146
+ const config = TEMPLATE_CONFIGS[meta.id];
147
+ if (!config) return;
148
+
149
+ const isCompose = meta.id === 'kotlin-compose';
150
+
151
+ const info = {
152
+ id: meta.id,
153
+ name: meta.name,
154
+ description: meta.description,
155
+ language: config.language,
156
+ uiFramework: config.uiFramework === 'compose' ? 'Jetpack Compose' : 'XML Layouts',
157
+ keywords: meta.keywords,
158
+ features: meta.features,
159
+ versions: {
160
+ gradle: config.gradleVersion,
161
+ agp: config.agpVersion,
162
+ kotlin: config.kotlinVersion || null,
163
+ compose: config.composeEnabled ? {
164
+ bom: GRADLE_VERSIONS.COMPOSE_BOM,
165
+ compiler: GRADLE_VERSIONS.COMPOSE_COMPILER
166
+ } : null,
167
+ ndk: config.ndkVersion || null
168
+ },
169
+ sdk: {
170
+ min: config.minSdk,
171
+ target: config.targetSdk,
172
+ compile: config.compileSdk
173
+ },
174
+ codePreview: getTemplatePreview(meta.id)
175
+ };
176
+
177
+ console.log(JSON.stringify(info, null, 2));
178
+ }