@craft-native/craft 0.0.5

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/bin/cli.ts ADDED
@@ -0,0 +1,899 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Craft CLI - Build desktop apps with web languages
5
+ */
6
+
7
+ import { CLI } from '@stacksjs/clapp'
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync } from 'node:fs'
10
+ import process from 'node:process'
11
+ import { version } from '../package.json'
12
+
13
+ const cli = new CLI('craft')
14
+
15
+ // Helper to find and run the Craft binary
16
+ async function runCraftBinary(args: string[]): Promise<void> {
17
+ const craftPath = await findCraftBinary()
18
+
19
+ return new Promise((resolve, reject) => {
20
+ const proc = spawn(craftPath, args, {
21
+ stdio: 'inherit',
22
+ })
23
+
24
+ proc.on('exit', (code) => {
25
+ if (code === 0 || code === null) {
26
+ resolve()
27
+ }
28
+ else {
29
+ reject(new Error(`Craft exited with code ${code}`))
30
+ }
31
+ })
32
+
33
+ proc.on('error', (error) => {
34
+ reject(error)
35
+ })
36
+ })
37
+ }
38
+
39
+ async function findCraftBinary(): Promise<string> {
40
+ // Craft native binary is installed via pantry and available in PATH
41
+ return 'craft'
42
+ }
43
+
44
+ // Default command - launch app with URL
45
+ cli
46
+ .command('[url]', 'Launch a Craft desktop app')
47
+ .option('--title <title>', 'Window title')
48
+ .option('--width <width>', 'Window width', { default: 800 })
49
+ .option('--height <height>', 'Window height', { default: 600 })
50
+ .option('--x <x>', 'Window x position')
51
+ .option('--y <y>', 'Window y position')
52
+ .option('--frameless', 'Frameless window')
53
+ .option('--transparent', 'Transparent window')
54
+ .option('--always-on-top', 'Always on top')
55
+ .option('--fullscreen', 'Start in fullscreen')
56
+ .option('--dark-mode', 'Enable dark mode')
57
+ .option('--hot-reload', 'Enable hot reload')
58
+ .option('--dev-tools', 'Enable developer tools')
59
+ .option('--no-resize', 'Disable window resizing')
60
+ .example('craft http://localhost:3000')
61
+ .example('craft http://localhost:3000 --title "My App" --width 1200 --height 800')
62
+ .example('craft http://localhost:3000 --frameless --transparent --always-on-top')
63
+ .action(async (url?: string, options?: any) => {
64
+ const args: string[] = []
65
+
66
+ if (url) {
67
+ args.push('--url', url)
68
+ }
69
+
70
+ if (options?.title)
71
+ args.push('--title', options.title)
72
+ if (options?.width)
73
+ args.push('--width', String(options.width))
74
+ if (options?.height)
75
+ args.push('--height', String(options.height))
76
+ if (options?.x)
77
+ args.push('--x', String(options.x))
78
+ if (options?.y)
79
+ args.push('--y', String(options.y))
80
+ if (options?.frameless)
81
+ args.push('--frameless')
82
+ if (options?.transparent)
83
+ args.push('--transparent')
84
+ if (options?.alwaysOnTop)
85
+ args.push('--always-on-top')
86
+ if (options?.fullscreen)
87
+ args.push('--fullscreen')
88
+ if (options?.darkMode)
89
+ args.push('--dark-mode')
90
+ if (options?.hotReload)
91
+ args.push('--hot-reload')
92
+ if (options?.devTools)
93
+ args.push('--dev-tools')
94
+ if (options?.noResize)
95
+ args.push('--no-resize')
96
+
97
+ try {
98
+ await runCraftBinary(args)
99
+ }
100
+ catch (error: any) {
101
+ console.error('Error:', error.message)
102
+ process.exit(1)
103
+ }
104
+ })
105
+
106
+ // Version command
107
+ cli
108
+ .command('version', 'Show the version')
109
+ .action(() => {
110
+ console.log(`v${version}`)
111
+ })
112
+
113
+ // Build command
114
+ cli
115
+ .command('build', 'Build the Craft application')
116
+ .option('--release', 'Build in release mode', { default: false })
117
+ .option('--platform <platforms>', 'Target platforms (ios,android,macos,windows,linux)', { default: 'current' })
118
+ .option('--config <path>', 'Path to craft.config.ts')
119
+ .example('craft build')
120
+ .example('craft build --release')
121
+ .example('craft build --platform ios,android')
122
+ .example('craft build --platform macos,windows,linux')
123
+ .action(async (options?: any) => {
124
+ const platforms = options?.platform === 'current'
125
+ ? [process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux']
126
+ : options?.platform.split(',').map((p: string) => p.trim())
127
+
128
+ console.log('\n⚔ Craft Build\n')
129
+ console.log(`Platforms: ${platforms.join(', ')}`)
130
+ console.log(`Mode: ${options?.release ? 'Release' : 'Debug'}\n`)
131
+
132
+ const { existsSync } = await import('node:fs')
133
+ const { spawn } = await import('node:child_process')
134
+
135
+ for (const platform of platforms) {
136
+ console.log(`šŸ“¦ Building for ${platform}...`)
137
+
138
+ try {
139
+ if (platform === 'ios') {
140
+ if (process.platform !== 'darwin') {
141
+ console.log(` āš ļø iOS builds require macOS`)
142
+ continue
143
+ }
144
+ const iosDir = './ios'
145
+ if (existsSync(iosDir)) {
146
+ const buildType = options?.release ? 'Release' : 'Debug'
147
+ await new Promise<void>((resolve, reject) => {
148
+ const proc = spawn('xcodebuild', [
149
+ '-project', `${iosDir}/App.xcodeproj`,
150
+ '-scheme', 'App',
151
+ '-configuration', buildType,
152
+ '-sdk', 'iphoneos',
153
+ 'build'
154
+ ], { stdio: 'inherit' })
155
+ proc.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Exit ${code}`)))
156
+ proc.on('error', reject)
157
+ })
158
+ console.log(` āœ… iOS ${buildType} build complete`)
159
+ }
160
+ else {
161
+ console.log(` āš ļø No iOS project found. Run: craft ios init`)
162
+ }
163
+ }
164
+
165
+ else if (platform === 'android') {
166
+ const androidDir = './android'
167
+ if (existsSync(androidDir)) {
168
+ const task = options?.release ? 'assembleRelease' : 'assembleDebug'
169
+ await new Promise<void>((resolve, reject) => {
170
+ const proc = spawn('./gradlew', [task], {
171
+ cwd: androidDir,
172
+ stdio: 'inherit'
173
+ })
174
+ proc.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Exit ${code}`)))
175
+ proc.on('error', reject)
176
+ })
177
+ console.log(` āœ… Android ${options?.release ? 'Release' : 'Debug'} build complete`)
178
+ }
179
+ else {
180
+ console.log(` āš ļø No Android project found. Run: craft android init`)
181
+ }
182
+ }
183
+
184
+ else if (platform === 'macos' || platform === 'windows' || platform === 'linux') {
185
+ const zigDir = existsSync('./packages/zig') ? './packages/zig' : '.'
186
+ const optimizeFlag = options?.release ? '-Doptimize=ReleaseSafe' : ''
187
+ await new Promise<void>((resolve, reject) => {
188
+ const args = ['build']
189
+ if (optimizeFlag) args.push(optimizeFlag)
190
+ const proc = spawn('zig', args, {
191
+ cwd: zigDir,
192
+ stdio: 'inherit'
193
+ })
194
+ proc.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Exit ${code}`)))
195
+ proc.on('error', reject)
196
+ })
197
+ console.log(` āœ… ${platform} build complete`)
198
+ }
199
+
200
+ }
201
+ catch (error: any) {
202
+ console.error(` āŒ ${platform} build failed: ${error.message}`)
203
+ }
204
+ }
205
+
206
+ console.log('\n✨ Build complete\n')
207
+ })
208
+
209
+ // Dev command - launch with hot reload and dev tools enabled
210
+ cli
211
+ .command('dev [url]', 'Launch in development mode with hot reload')
212
+ .option('--title <title>', 'Window title', { default: 'Craft Dev' })
213
+ .option('--width <width>', 'Window width', { default: 1200 })
214
+ .option('--height <height>', 'Window height', { default: 800 })
215
+ .example('craft dev http://localhost:3000')
216
+ .action(async (url?: string, options?: any) => {
217
+ const args = [
218
+ '--url',
219
+ url || 'http://localhost:3000',
220
+ '--title',
221
+ options?.title || 'Craft Dev',
222
+ '--width',
223
+ String(options?.width || 1200),
224
+ '--height',
225
+ String(options?.height || 800),
226
+ '--hot-reload',
227
+ '--dev-tools',
228
+ '--dark-mode',
229
+ ]
230
+
231
+ try {
232
+ await runCraftBinary(args)
233
+ }
234
+ catch (error: any) {
235
+ console.error('Error:', error.message)
236
+ process.exit(1)
237
+ }
238
+ })
239
+
240
+ // Package command - create installers
241
+ cli
242
+ .command('package', 'Create installers for your Craft application')
243
+ .option('--name <name>', 'Application name')
244
+ .option('--version <version>', 'Application version')
245
+ .option('--binary <path>', 'Path to application binary')
246
+ .option('--description <text>', 'Application description')
247
+ .option('--author <name>', 'Author/Maintainer name')
248
+ .option('--bundle-id <id>', 'Bundle identifier (macOS/iOS)')
249
+ .option('--out <dir>', 'Output directory (default: ./dist)')
250
+ .option('--icon <path>', 'Application icon path')
251
+ .option('--platforms <list>', 'Comma-separated platforms (macos,windows,linux)')
252
+ .option('--config <path>', 'Load config from JSON file')
253
+ .option('--dmg', 'Create DMG installer (macOS)')
254
+ .option('--pkg', 'Create PKG installer (macOS)')
255
+ .option('--msi', 'Create MSI installer (Windows)')
256
+ .option('--zip', 'Create ZIP archive (Windows)')
257
+ .option('--deb', 'Create DEB package (Linux)')
258
+ .option('--rpm', 'Create RPM package (Linux)')
259
+ .option('--appimage', 'Create AppImage (Linux)')
260
+ .example('craft package --name "My App" --version "1.0.0" --binary ./build/myapp')
261
+ .example('craft package --config package.json')
262
+ .example('craft package --name "My App" --version "1.0.0" --binary ./build/myapp --platforms macos,windows,linux')
263
+ .action(async (options?: any) => {
264
+ const packageModule = await import('../src/package.js')
265
+ const { packageApp } = packageModule
266
+
267
+ let config: any
268
+
269
+ // Load config from file or CLI args
270
+ if (options?.config) {
271
+ if (!existsSync(options.config)) {
272
+ console.error(`āŒ Config file not found: ${options.config}`)
273
+ process.exit(1)
274
+ }
275
+
276
+ const configContent = await Bun.file(options.config).text()
277
+ config = JSON.parse(configContent)
278
+ }
279
+ else {
280
+ // Build config from CLI options
281
+ if (!options?.name) {
282
+ console.error('āŒ --name is required')
283
+ process.exit(1)
284
+ }
285
+ if (!options?.version) {
286
+ console.error('āŒ --version is required')
287
+ process.exit(1)
288
+ }
289
+ if (!options?.binary) {
290
+ console.error('āŒ --binary is required')
291
+ process.exit(1)
292
+ }
293
+
294
+ config = {
295
+ name: options.name,
296
+ version: options.version,
297
+ binaryPath: options.binary,
298
+ description: options.description,
299
+ author: options.author,
300
+ bundleId: options.bundleId,
301
+ outDir: options.out,
302
+ iconPath: options.icon,
303
+ platforms: options.platforms?.split(','),
304
+ }
305
+
306
+ // Platform-specific options
307
+ if (options.dmg || options.pkg) {
308
+ config.macos = {
309
+ dmg: options.dmg,
310
+ pkg: options.pkg,
311
+ }
312
+ }
313
+
314
+ if (options.msi || options.zip) {
315
+ config.windows = {
316
+ msi: options.msi,
317
+ zip: options.zip,
318
+ }
319
+ }
320
+
321
+ if (options.deb || options.rpm || options.appimage) {
322
+ config.linux = {
323
+ deb: options.deb,
324
+ rpm: options.rpm,
325
+ appImage: options.appimage,
326
+ }
327
+ }
328
+ }
329
+
330
+ console.log('šŸ“¦ Craft Packaging Tool\n')
331
+ console.log(`Application: ${config.name} v${config.version}`)
332
+ console.log(`Platforms: ${(config.platforms || ['current']).join(', ')}\n`)
333
+
334
+ try {
335
+ const results = await packageApp(config)
336
+
337
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
338
+ console.log('šŸ“Š Packaging Results\n')
339
+
340
+ for (const result of results) {
341
+ const status = result.success ? 'āœ…' : 'āŒ'
342
+ const format = result.format.toUpperCase()
343
+
344
+ console.log(`${status} ${result.platform}/${format}`)
345
+
346
+ if (result.success && result.outputPath) {
347
+ console.log(` šŸ“ ${result.outputPath}`)
348
+ }
349
+ else if (result.error) {
350
+ console.log(` āš ļø ${result.error}`)
351
+ }
352
+ }
353
+
354
+ const successCount = results.filter(r => r.success).length
355
+ const totalCount = results.length
356
+
357
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
358
+ console.log(`\n✨ Complete: ${successCount}/${totalCount} packages created\n`)
359
+
360
+ process.exit(successCount === totalCount ? 0 : 1)
361
+ }
362
+ catch (error: any) {
363
+ console.error(`\nāŒ Error: ${error.message}\n`)
364
+ process.exit(1)
365
+ }
366
+ })
367
+
368
+ // iOS commands
369
+ cli
370
+ .command('ios init <name>', 'Initialize a new iOS project')
371
+ .option('--bundle-id <id>', 'Bundle identifier (e.g., com.example.app)')
372
+ .option('--team-id <id>', 'Apple Developer Team ID')
373
+ .option('-o, --output <dir>', 'Output directory', { default: './ios' })
374
+ .example('craft ios init MyApp')
375
+ .example('craft ios init MyApp --bundle-id com.example.myapp')
376
+ .action(async (name: string, options?: any) => {
377
+ // @ts-ignore -- sibling package may not exist at typecheck time
378
+ const iosModule = await import('../../ios/dist/index.js')
379
+ await iosModule.init({
380
+ name,
381
+ bundleId: options?.bundleId,
382
+ teamId: options?.teamId,
383
+ output: options?.output || './ios',
384
+ })
385
+ })
386
+
387
+ cli
388
+ .command('ios build', 'Build iOS project')
389
+ .option('--html-path <path>', 'Path to HTML file')
390
+ .option('-d, --dev-server <url>', 'Development server URL')
391
+ .option('-o, --output <dir>', 'iOS project directory', { default: './ios' })
392
+ .option('-w, --watch', 'Watch for file changes and rebuild')
393
+ .example('craft ios build')
394
+ .example('craft ios build --html-path ./dist/index.html')
395
+ .example('craft ios build --dev-server http://localhost:3456')
396
+ .example('craft ios build --watch')
397
+ .action(async (options?: any) => {
398
+ // @ts-ignore -- sibling package may not exist at typecheck time
399
+ const iosModule = await import('../../ios/dist/index.js')
400
+
401
+ const doBuild = async () => {
402
+ await iosModule.build({
403
+ htmlPath: options?.htmlPath,
404
+ devServer: options?.devServer,
405
+ output: options?.output || './ios',
406
+ })
407
+ }
408
+
409
+ await doBuild()
410
+
411
+ if (options?.watch) {
412
+ console.log('\nšŸ‘€ Watching for changes...\n')
413
+ const { watch } = await import('node:fs')
414
+ const { dirname } = await import('node:path')
415
+
416
+ const watchPath = options?.htmlPath ? dirname(options.htmlPath) : '.'
417
+ watch(watchPath, { recursive: true }, async (event, filename) => {
418
+ if (filename && (filename.endsWith('.html') || filename.endsWith('.js') || filename.endsWith('.css'))) {
419
+ console.log(`\nšŸ“ ${filename} changed, rebuilding...`)
420
+ await doBuild()
421
+ }
422
+ })
423
+
424
+ // Keep process running
425
+ await new Promise(() => {})
426
+ }
427
+ })
428
+
429
+ cli
430
+ .command('ios open', 'Open iOS project in Xcode')
431
+ .option('-o, --output <dir>', 'iOS project directory', { default: './ios' })
432
+ .action(async (options?: any) => {
433
+ // @ts-ignore -- sibling package may not exist at typecheck time
434
+ const iosModule = await import('../../ios/dist/index.js')
435
+ await iosModule.open({
436
+ output: options?.output || './ios',
437
+ })
438
+ })
439
+
440
+ cli
441
+ .command('ios run', 'Build and run on iOS device or simulator')
442
+ .option('-s, --simulator', 'Run on simulator instead of device')
443
+ .option('-o, --output <dir>', 'iOS project directory', { default: './ios' })
444
+ .example('craft ios run')
445
+ .example('craft ios run --simulator')
446
+ .action(async (options?: any) => {
447
+ // @ts-ignore -- sibling package may not exist at typecheck time
448
+ const iosModule = await import('../../ios/dist/index.js')
449
+ await iosModule.run({
450
+ simulator: options?.simulator || false,
451
+ output: options?.output || './ios',
452
+ })
453
+ })
454
+
455
+ // Android commands
456
+ cli
457
+ .command('android init <name>', 'Initialize a new Android project')
458
+ .option('--package <name>', 'Package name (e.g., com.example.app)')
459
+ .option('-o, --output <dir>', 'Output directory', { default: './android' })
460
+ .example('craft android init MyApp')
461
+ .example('craft android init MyApp --package com.example.myapp')
462
+ .action(async (name: string, options?: any) => {
463
+ // @ts-ignore -- sibling package may not exist at typecheck time
464
+ const androidModule = await import('../../android/dist/index.js')
465
+ await androidModule.init({
466
+ name,
467
+ packageName: options?.package,
468
+ output: options?.output || './android',
469
+ })
470
+ })
471
+
472
+ cli
473
+ .command('android build', 'Build Android project')
474
+ .option('--html-path <path>', 'Path to HTML file')
475
+ .option('-d, --dev-server <url>', 'Development server URL')
476
+ .option('-o, --output <dir>', 'Android project directory', { default: './android' })
477
+ .option('--release', 'Build release APK')
478
+ .option('-w, --watch', 'Watch for file changes and rebuild')
479
+ .example('craft android build')
480
+ .example('craft android build --release')
481
+ .example('craft android build --watch')
482
+ .action(async (options?: any) => {
483
+ // @ts-ignore -- sibling package may not exist at typecheck time
484
+ const androidModule = await import('../../android/dist/index.js')
485
+
486
+ const doBuild = async () => {
487
+ await androidModule.build({
488
+ htmlPath: options?.htmlPath,
489
+ devServer: options?.devServer,
490
+ output: options?.output || './android',
491
+ release: options?.release || false,
492
+ })
493
+ }
494
+
495
+ await doBuild()
496
+
497
+ if (options?.watch) {
498
+ console.log('\nšŸ‘€ Watching for changes...\n')
499
+ const { watch } = await import('node:fs')
500
+ const { dirname } = await import('node:path')
501
+
502
+ const watchPath = options?.htmlPath ? dirname(options.htmlPath) : '.'
503
+ watch(watchPath, { recursive: true }, async (event, filename) => {
504
+ if (filename && (filename.endsWith('.html') || filename.endsWith('.js') || filename.endsWith('.css'))) {
505
+ console.log(`\nšŸ“ ${filename} changed, rebuilding...`)
506
+ await doBuild()
507
+ }
508
+ })
509
+
510
+ // Keep process running
511
+ await new Promise(() => {})
512
+ }
513
+ })
514
+
515
+ cli
516
+ .command('android open', 'Open Android project in Android Studio')
517
+ .option('-o, --output <dir>', 'Android project directory', { default: './android' })
518
+ .action(async (options?: any) => {
519
+ // @ts-ignore -- sibling package may not exist at typecheck time
520
+ const androidModule = await import('../../android/dist/index.js')
521
+ await androidModule.open({
522
+ output: options?.output || './android',
523
+ })
524
+ })
525
+
526
+ cli
527
+ .command('android run', 'Build and run on Android device or emulator')
528
+ .option('-d, --device <id>', 'Target device ID')
529
+ .option('-o, --output <dir>', 'Android project directory', { default: './android' })
530
+ .example('craft android run')
531
+ .example('craft android run --device emulator-5554')
532
+ .action(async (options?: any) => {
533
+ // @ts-ignore -- sibling package may not exist at typecheck time
534
+ const androidModule = await import('../../android/dist/index.js')
535
+ await androidModule.run({
536
+ device: options?.device,
537
+ output: options?.output || './android',
538
+ })
539
+ })
540
+
541
+ // Preview command - preview app in browser
542
+ cli
543
+ .command('preview [path]', 'Preview app in browser before building native')
544
+ .option('-p, --port <port>', 'Port to serve on', { default: 3456 })
545
+ .option('--host <host>', 'Host to bind to', { default: 'localhost' })
546
+ .example('craft preview ./dist')
547
+ .example('craft preview ./index.html --port 8080')
548
+ .action(async (path?: string, options?: any) => {
549
+ const servePath = path || '.'
550
+ const port = options?.port || 3456
551
+ const host = options?.host || 'localhost'
552
+
553
+ console.log(`\n🌐 Starting preview server...`)
554
+ console.log(` Path: ${servePath}`)
555
+ console.log(` URL: http://${host}:${port}\n`)
556
+
557
+ const { spawn } = await import('node:child_process')
558
+
559
+ // Use bunx serve for simple static file serving
560
+ const proc = spawn('bunx', ['--bun', 'serve', '-p', String(port), servePath], {
561
+ stdio: 'inherit',
562
+ })
563
+
564
+ proc.on('error', () => {
565
+ console.log('Falling back to python http.server...')
566
+ spawn('python3', ['-m', 'http.server', String(port), '--directory', servePath], {
567
+ stdio: 'inherit',
568
+ })
569
+ })
570
+ })
571
+
572
+ // Publish command - publish to App Store / Play Store
573
+ cli
574
+ .command('publish', 'Publish app to App Store or Play Store')
575
+ .option('--ios', 'Publish to App Store (TestFlight)')
576
+ .option('--android', 'Publish to Play Store')
577
+ .option('--api-key <path>', 'App Store Connect API key path')
578
+ .option('--service-account <path>', 'Google Play service account JSON')
579
+ .option('-o, --output <dir>', 'Project directory', { default: '.' })
580
+ .example('craft publish --ios')
581
+ .example('craft publish --android')
582
+ .action(async (options?: any) => {
583
+ const { existsSync } = await import('node:fs')
584
+ const { join } = await import('node:path')
585
+ const { $ } = await import('bun')
586
+
587
+ if (options?.ios) {
588
+ console.log('\nšŸ“± Publishing to App Store (TestFlight)...\n')
589
+
590
+ const iosDir = join(options?.output || '.', 'ios')
591
+ if (!existsSync(iosDir)) {
592
+ console.error('āŒ No iOS project found. Run: craft ios init')
593
+ process.exit(1)
594
+ }
595
+
596
+ // Find xcarchive or build it
597
+ console.log('1. Building archive...')
598
+ try {
599
+ await $`cd ${iosDir} && xcodebuild -scheme App -configuration Release -archivePath ./build/App.xcarchive archive`
600
+ console.log('āœ… Archive created')
601
+
602
+ console.log('2. Exporting IPA...')
603
+ await $`cd ${iosDir} && xcodebuild -exportArchive -archivePath ./build/App.xcarchive -exportPath ./build -exportOptionsPlist ExportOptions.plist`
604
+ console.log('āœ… IPA exported')
605
+
606
+ console.log('3. Uploading to TestFlight...')
607
+ if (options?.apiKey) {
608
+ await $`xcrun altool --upload-app -f ${iosDir}/build/*.ipa --apiKey ${options.apiKey} --type ios`
609
+ }
610
+ else {
611
+ await $`xcrun altool --upload-app -f ${iosDir}/build/*.ipa --type ios`
612
+ }
613
+ console.log('āœ… Uploaded to TestFlight!')
614
+ }
615
+ catch (error) {
616
+ console.error('āŒ Publish failed. Make sure you have:')
617
+ console.error(' - Valid signing certificates')
618
+ console.error(' - App Store Connect API key (--api-key)')
619
+ console.error(' - ExportOptions.plist in your ios directory')
620
+ }
621
+ }
622
+
623
+ if (options?.android) {
624
+ console.log('\nšŸ¤– Publishing to Play Store...\n')
625
+
626
+ const androidDir = join(options?.output || '.', 'android')
627
+ if (!existsSync(androidDir)) {
628
+ console.error('āŒ No Android project found. Run: craft android init')
629
+ process.exit(1)
630
+ }
631
+
632
+ console.log('1. Building release AAB...')
633
+ try {
634
+ await $`cd ${androidDir} && ./gradlew bundleRelease`
635
+ console.log('āœ… AAB created')
636
+
637
+ const aabPath = join(androidDir, 'app/build/outputs/bundle/release/app-release.aab')
638
+
639
+ if (options?.serviceAccount) {
640
+ console.log('2. Uploading to Play Store...')
641
+ // Would use Google Play Developer API here
642
+ console.log('āš ļø Automatic Play Store upload requires fastlane or Google Play API integration.')
643
+ console.log(` AAB file: ${aabPath}`)
644
+ console.log(' Upload manually via Play Console or use:')
645
+ console.log(' fastlane supply --aab ' + aabPath)
646
+ }
647
+ else {
648
+ console.log('āœ… Release AAB ready:')
649
+ console.log(` ${aabPath}`)
650
+ console.log('')
651
+ console.log('Upload manually via Play Console, or use fastlane:')
652
+ console.log(' fastlane supply --aab ' + aabPath)
653
+ }
654
+ }
655
+ catch (error) {
656
+ console.error('āŒ Build failed. Make sure you have:')
657
+ console.error(' - Valid signing key (keystore)')
658
+ console.error(' - Release signing config in build.gradle.kts')
659
+ }
660
+ }
661
+
662
+ if (!options?.ios && !options?.android) {
663
+ console.log('Please specify a platform:')
664
+ console.log(' craft publish --ios # Publish to App Store')
665
+ console.log(' craft publish --android # Publish to Play Store')
666
+ }
667
+ })
668
+
669
+ // Init command - initialize a new Craft project
670
+ cli
671
+ .command('init <name>', 'Initialize a new Craft project')
672
+ .option('--template <type>', 'Project template (blank, tabs, drawer, dashboard, desktop, ios, android, all)', { default: 'blank' })
673
+ .option('--bundle-id <id>', 'Bundle identifier for mobile')
674
+ .example('craft init MyApp')
675
+ .example('craft init MyApp --template tabs')
676
+ .example('craft init MyApp --template dashboard')
677
+ .example('craft init MyApp --template all')
678
+ .action(async (name: string, options?: any) => {
679
+ console.log(`\n⚔ Creating new Craft project: ${name}\n`)
680
+
681
+ const template = options?.template || 'blank'
682
+ const bundleId = options?.bundleId || `com.example.${name.toLowerCase().replace(/[^a-z0-9]/g, '')}`
683
+ const appNameSlug = name.toLowerCase().replace(/[^a-z0-9]/g, '-')
684
+
685
+ const { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, cpSync } = await import('node:fs')
686
+ const { join, dirname } = await import('node:path')
687
+
688
+ // Helper to replace template variables
689
+ const replaceVars = (content: string): string => {
690
+ return content
691
+ .replace(/\{\{APP_NAME\}\}/g, name)
692
+ .replace(/\{\{APP_NAME_SLUG\}\}/g, appNameSlug)
693
+ .replace(/\{\{BUNDLE_ID\}\}/g, bundleId)
694
+ .replace(/\{\{AUTHOR\}\}/g, 'Developer')
695
+ }
696
+
697
+ // Helper to copy template directory
698
+ const copyTemplate = async (templateName: string, destDir: string) => {
699
+ const templateDir = join(import.meta.dir, '../../../templates/projects', templateName)
700
+
701
+ if (!existsSync(templateDir)) {
702
+ console.log(` āš ļø Template '${templateName}' not found, using blank template`)
703
+ return false
704
+ }
705
+
706
+ const copyRecursive = (src: string, dest: string) => {
707
+ if (!existsSync(dest)) {
708
+ mkdirSync(dest, { recursive: true })
709
+ }
710
+
711
+ const entries = readdirSync(src, { withFileTypes: true })
712
+
713
+ for (const entry of entries) {
714
+ const srcPath = join(src, entry.name)
715
+ const destPath = join(dest, entry.name)
716
+
717
+ if (entry.isDirectory()) {
718
+ copyRecursive(srcPath, destPath)
719
+ }
720
+ else {
721
+ const content = readFileSync(srcPath, 'utf-8')
722
+ const processedContent = replaceVars(content)
723
+ writeFileSync(destPath, processedContent)
724
+ }
725
+ }
726
+ }
727
+
728
+ copyRecursive(templateDir, destDir)
729
+ return true
730
+ }
731
+
732
+ // Create project from template
733
+ if (['blank', 'tabs', 'drawer', 'dashboard'].includes(template)) {
734
+ console.log(`šŸ“ Creating ${template} project from template...`)
735
+
736
+ if (!existsSync(name)) {
737
+ mkdirSync(name, { recursive: true })
738
+ }
739
+
740
+ const copied = await copyTemplate(template, name)
741
+
742
+ if (!copied) {
743
+ // Fallback: create basic project inline
744
+ mkdirSync(join(name, 'src'), { recursive: true })
745
+
746
+ writeFileSync(join(name, 'index.html'), replaceVars(`<!DOCTYPE html>
747
+ <html lang="en">
748
+ <head>
749
+ <meta charset="UTF-8">
750
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
751
+ <title>{{APP_NAME}}</title>
752
+ </head>
753
+ <body>
754
+ <div id="app">
755
+ <h1>{{APP_NAME}}</h1>
756
+ <p>Built with Craft</p>
757
+ </div>
758
+ </body>
759
+ </html>`))
760
+
761
+ writeFileSync(join(name, 'package.json'), replaceVars(JSON.stringify({
762
+ name: '{{APP_NAME_SLUG}}',
763
+ version: '1.0.0',
764
+ private: true,
765
+ type: 'module',
766
+ scripts: {
767
+ dev: 'craft dev',
768
+ build: 'craft build'
769
+ },
770
+ dependencies: {
771
+ '@craft-native/craft': 'workspace:*'
772
+ }
773
+ }, null, 2)))
774
+ }
775
+
776
+ console.log(`āœ… ${template} project created`)
777
+ }
778
+
779
+ if (template === 'desktop' || template === 'all') {
780
+ console.log('šŸ“ Creating desktop project structure...')
781
+
782
+ if (!existsSync(name)) {
783
+ mkdirSync(name, { recursive: true })
784
+ }
785
+
786
+ // Create craft.config.ts
787
+ const configContent = `import type { CraftConfig } from '@craft-native/craft'
788
+
789
+ export default {
790
+ name: '${name}',
791
+ window: {
792
+ title: '${name}',
793
+ width: 1200,
794
+ height: 800,
795
+ darkMode: true,
796
+ },
797
+ } satisfies CraftConfig
798
+ `
799
+ writeFileSync(`${name}/craft.config.ts`, configContent)
800
+
801
+ // Create index.html
802
+ const htmlContent = `<!DOCTYPE html>
803
+ <html lang="en">
804
+ <head>
805
+ <meta charset="UTF-8">
806
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
807
+ <title>${name}</title>
808
+ <style>
809
+ * { margin: 0; padding: 0; box-sizing: border-box; }
810
+ body {
811
+ font-family: -apple-system, system-ui, sans-serif;
812
+ background: #1a1a2e;
813
+ color: white;
814
+ min-height: 100vh;
815
+ display: flex;
816
+ justify-content: center;
817
+ align-items: center;
818
+ }
819
+ .container { text-align: center; }
820
+ h1 { font-size: 3rem; margin-bottom: 1rem; }
821
+ p { opacity: 0.7; }
822
+ </style>
823
+ </head>
824
+ <body>
825
+ <div class="container">
826
+ <h1>⚔ ${name}</h1>
827
+ <p>Built with Craft</p>
828
+ </div>
829
+ </body>
830
+ </html>
831
+ `
832
+ writeFileSync(`${name}/index.html`, htmlContent)
833
+
834
+ // Create package.json
835
+ const packageJson = {
836
+ name: appNameSlug,
837
+ version: '0.1.0',
838
+ private: true,
839
+ scripts: {
840
+ dev: 'craft dev http://localhost:3000',
841
+ build: 'craft build',
842
+ 'ios:init': 'craft ios init ' + name,
843
+ 'ios:build': 'craft ios build',
844
+ 'ios:open': 'craft ios open',
845
+ },
846
+ devDependencies: {
847
+ '@craft-native/craft': '*',
848
+ },
849
+ }
850
+ writeFileSync(`${name}/package.json`, JSON.stringify(packageJson, null, 2))
851
+
852
+ console.log('āœ… Desktop project created')
853
+ }
854
+
855
+ if (template === 'ios' || template === 'all') {
856
+ console.log('šŸ“± Creating iOS project...')
857
+ // @ts-ignore -- sibling package may not exist at typecheck time
858
+ const iosModule = await import('../../ios/dist/index.js')
859
+ await iosModule.init({
860
+ name,
861
+ bundleId: options?.bundleId,
862
+ output: template === 'all' ? `${name}/ios` : './ios',
863
+ })
864
+ console.log('āœ… iOS project created')
865
+ }
866
+
867
+ if (template === 'android' || template === 'all') {
868
+ console.log('šŸ¤– Creating Android project...')
869
+ // @ts-ignore -- sibling package may not exist at typecheck time
870
+ const androidModule = await import('../../android/dist/index.js')
871
+ await androidModule.init({
872
+ name,
873
+ packageName: options?.bundleId,
874
+ output: template === 'all' ? `${name}/android` : './android',
875
+ })
876
+ console.log('āœ… Android project created')
877
+ }
878
+
879
+ console.log('')
880
+ console.log('Next steps:')
881
+ console.log(` cd ${name}`)
882
+ console.log(' bun install')
883
+ if (template === 'desktop' || template === 'all') {
884
+ console.log(' craft dev http://localhost:3000')
885
+ }
886
+ if (template === 'ios' || template === 'all') {
887
+ console.log(' craft ios build')
888
+ console.log(' craft ios open')
889
+ }
890
+ if (template === 'android' || template === 'all') {
891
+ console.log(' craft android build')
892
+ console.log(' craft android open')
893
+ }
894
+ console.log('')
895
+ })
896
+
897
+ cli.version(version)
898
+ cli.help()
899
+ cli.parse()