geeknative 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/bin/geeknative.js +12 -0
- package/package.json +42 -4
- package/src/cli.js +61 -0
- package/src/codegen/app.js +105 -0
- package/src/codegen/icons.js +87 -0
- package/src/codegen/index.js +44 -0
- package/src/codegen/strings.js +308 -0
- package/src/codegen/tokens.js +449 -0
- package/src/codegen/util.js +122 -0
- package/src/commands/check.js +148 -0
- package/src/commands/create.js +82 -0
- package/src/commands/doctor.js +108 -0
- package/src/commands/gen.js +44 -0
- package/src/commands/parity.js +134 -0
- package/src/commands/run.js +91 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/fsutil.js +92 -0
- package/src/lib/log.js +37 -0
- package/src/lib/managed.js +80 -0
- package/src/lib/project.js +52 -0
- package/src/lib/toolchains.js +126 -0
- package/template/.claude/agents/android-engineer.md +15 -0
- package/template/.claude/agents/ios-engineer.md +15 -0
- package/template/.claude/agents/web-engineer.md +15 -0
- package/template/.claude/settings.json +65 -0
- package/template/.geeknative/framework.mk +41 -0
- package/template/.geeknative/hooks/after-edit.mjs +28 -0
- package/template/.geeknative/hooks/parity-guard.mjs +49 -0
- package/template/.geeknative/hooks/subagent-context.mjs +24 -0
- package/template/.geeknative/playbooks/bug.md +7 -0
- package/template/.geeknative/playbooks/design.md +10 -0
- package/template/.geeknative/playbooks/engineering.md +9 -0
- package/template/.geeknative/playbooks/feature.md +11 -0
- package/template/.geeknative/playbooks/platform-specific.md +8 -0
- package/template/.geeknative/playbooks/product.md +12 -0
- package/template/.geeknative/templates/feature.md +49 -0
- package/template/.gemini/settings.json +5 -0
- package/template/.github/dependabot.yml +18 -0
- package/template/AGENTS.md +97 -0
- package/template/CLAUDE.md +9 -0
- package/template/DESIGN.md +65 -0
- package/template/Makefile +6 -0
- package/template/PRODUCT.md +40 -0
- package/template/README.md +28 -0
- package/template/_gitignore +33 -0
- package/template/apps/android/AGENTS.md +66 -0
- package/template/apps/android/CLAUDE.md +1 -0
- package/template/apps/android/app/build.gradle.kts +68 -0
- package/template/apps/android/app/identity.properties +4 -0
- package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
- package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
- package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
- package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
- package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
- package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
- package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
- package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
- package/template/apps/android/build.gradle.kts +5 -0
- package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
- package/template/apps/android/gradle/libs.versions.toml +37 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/template/apps/android/gradle.properties +7 -0
- package/template/apps/android/gradlew +248 -0
- package/template/apps/android/gradlew.bat +82 -0
- package/template/apps/android/settings.gradle.kts +29 -0
- package/template/apps/ios/AGENTS.md +70 -0
- package/template/apps/ios/App/AppMain.swift +13 -0
- package/template/apps/ios/App/Core/AppContainer.swift +22 -0
- package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
- package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
- package/template/apps/ios/App/Core/RootView.swift +32 -0
- package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
- package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
- package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
- package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
- package/template/apps/ios/App/Generated/Icons.swift +8 -0
- package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
- package/template/apps/ios/App/Generated/Strings.swift +40 -0
- package/template/apps/ios/App/Generated/Tokens.swift +65 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
- package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
- package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
- package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
- package/template/apps/ios/CLAUDE.md +1 -0
- package/template/apps/ios/Config/App.xcconfig +23 -0
- package/template/apps/ios/Config/AppTests.xcconfig +10 -0
- package/template/apps/ios/Config/Base.xcconfig +25 -0
- package/template/apps/ios/Config/Debug.xcconfig +9 -0
- package/template/apps/ios/Config/Identity.xcconfig +5 -0
- package/template/apps/ios/Config/Release.xcconfig +6 -0
- package/template/apps/web/AGENTS.md +63 -0
- package/template/apps/web/CLAUDE.md +1 -0
- package/template/apps/web/package.json +38 -0
- package/template/apps/web/public/favicon.svg +6 -0
- package/template/apps/web/src/core/AppContainer.ts +18 -0
- package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
- package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
- package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
- package/template/apps/web/src/features/home/home.test.tsx +22 -0
- package/template/apps/web/src/features/settings/Appearance.ts +29 -0
- package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
- package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
- package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
- package/template/apps/web/src/features/settings/settings.test.ts +45 -0
- package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
- package/template/apps/web/src/generated/app.ts +9 -0
- package/template/apps/web/src/generated/icons.ts +9 -0
- package/template/apps/web/src/generated/strings.ts +88 -0
- package/template/apps/web/src/generated/tokens.css +86 -0
- package/template/apps/web/src/routeTree.gen.ts +86 -0
- package/template/apps/web/src/router.tsx +23 -0
- package/template/apps/web/src/routes/__root.tsx +72 -0
- package/template/apps/web/src/routes/index.tsx +6 -0
- package/template/apps/web/src/routes/settings.tsx +9 -0
- package/template/apps/web/src/styles.css +9 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/tsr.config.json +3 -0
- package/template/apps/web/vite.config.ts +9 -0
- package/template/apps/web/vitest.config.ts +9 -0
- package/template/contracts/app.json +7 -0
- package/template/contracts/icons.json +5 -0
- package/template/contracts/strings/en.json +13 -0
- package/template/contracts/tokens.json +40 -0
- package/template/package.json +17 -0
- package/template/spec/decisions.md +10 -0
- package/template/spec/features/home.md +39 -0
- package/template/spec/features/settings.md +50 -0
- package/template/spec/navigation.md +16 -0
- package/template/spec/parity.md +29 -0
- package/README.md +0 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { runLogged } from '../lib/exec.js'
|
|
5
|
+
import { CliError, color, log } from '../lib/log.js'
|
|
6
|
+
import { findAndroidSdk, pickSimulator, writeLocalProperties } from '../lib/toolchains.js'
|
|
7
|
+
import { gen } from './gen.js'
|
|
8
|
+
|
|
9
|
+
export const PLATFORMS = ['ios', 'android', 'web']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Verifies the contracts, then builds and tests the requested apps.
|
|
13
|
+
* Prints a short summary per platform; full output goes to .geeknative/logs/<platform>.log.
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {string[]} targets "all", "contracts", or platform names
|
|
16
|
+
*/
|
|
17
|
+
export async function check(root, targets) {
|
|
18
|
+
const everything = !targets.length || targets.includes('all')
|
|
19
|
+
const wanted = everything ? PLATFORMS : targets
|
|
20
|
+
for (const target of wanted) {
|
|
21
|
+
if (target !== 'contracts' && !PLATFORMS.includes(target)) {
|
|
22
|
+
throw new CliError(`Unknown check "${target}". Use: all, contracts, ${PLATFORMS.join(', ')}.`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let failed = false
|
|
27
|
+
try {
|
|
28
|
+
gen(root, { check: true })
|
|
29
|
+
} catch (error) {
|
|
30
|
+
log.fail('contracts', /** @type {Error} */ (error).message)
|
|
31
|
+
failed = true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const platform of wanted.filter((t) => t !== 'contracts')) {
|
|
35
|
+
if (everything && platform === 'ios' && process.platform !== 'darwin') {
|
|
36
|
+
log.warn('ios', 'skipped: iOS builds need macOS with Xcode')
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const started = Date.now()
|
|
40
|
+
const result = await CHECKS[platform](root)
|
|
41
|
+
const seconds = `${Math.round((Date.now() - started) / 1000)}s`
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
log.ok(platform, `${result.summary} ${color.dim(`(${seconds})`)}`)
|
|
44
|
+
} else {
|
|
45
|
+
failed = true
|
|
46
|
+
log.fail(platform, `${result.summary} ${color.dim(`(${seconds}, full log: ${result.log})`)}`)
|
|
47
|
+
for (const line of result.details.slice(0, 25)) log.detail(line)
|
|
48
|
+
if (result.details.length > 25) log.detail(`… ${result.details.length - 25} more in the log`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (failed) process.exitCode = 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @typedef {{ ok: boolean, summary: string, details: string[], log: string }} CheckResult */
|
|
55
|
+
|
|
56
|
+
/** @param {string} root @param {string} platform */
|
|
57
|
+
const logPath = (root, platform) => path.join(root, '.geeknative', 'logs', `${platform}.log`)
|
|
58
|
+
|
|
59
|
+
/** @param {string} root @param {string} file */
|
|
60
|
+
const shown = (root, file) => path.relative(root, file)
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, (root: string) => Promise<CheckResult>>} */
|
|
63
|
+
const CHECKS = {
|
|
64
|
+
async ios(root) {
|
|
65
|
+
const logFile = logPath(root, 'ios')
|
|
66
|
+
if (process.platform !== 'darwin') return fail('iOS builds need macOS with Xcode', [], root, logFile)
|
|
67
|
+
const simulator = pickSimulator()
|
|
68
|
+
if (!simulator) return fail('no iPhone simulator found; run `make doctor`', [], root, logFile)
|
|
69
|
+
const firstBuild = !existsSync(path.join(root, 'apps', 'ios', '.build'))
|
|
70
|
+
log.step('ios', `building and testing on ${simulator.name} (${simulator.runtime})…${firstBuild ? ' (first build: allow a few minutes)' : ''}`)
|
|
71
|
+
const { code, lines } = await runLogged(
|
|
72
|
+
'xcodebuild',
|
|
73
|
+
[
|
|
74
|
+
'test',
|
|
75
|
+
'-project', 'App.xcodeproj',
|
|
76
|
+
'-scheme', 'App',
|
|
77
|
+
'-destination', `id=${simulator.udid}`,
|
|
78
|
+
'-derivedDataPath', '.build/DerivedData',
|
|
79
|
+
'-quiet',
|
|
80
|
+
],
|
|
81
|
+
{ cwd: path.join(root, 'apps', 'ios'), logFile },
|
|
82
|
+
)
|
|
83
|
+
const passed = lines.filter((l) => /^Test case '.*' passed/.test(l)).length
|
|
84
|
+
if (code === 0) return { ok: true, summary: `build and ${passed} tests passed`, details: [], log: shown(root, logFile) }
|
|
85
|
+
const details = unique(
|
|
86
|
+
lines.filter((l) => /(error:|✘|failed|\*\* (BUILD|TEST) FAILED)/.test(l) && !/^\s*$/.test(l)).map((l) => l.trim()),
|
|
87
|
+
)
|
|
88
|
+
return fail(details.length ? 'build or tests failed' : `xcodebuild exited with ${code}`, details, root, logFile)
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async android(root) {
|
|
92
|
+
const logFile = logPath(root, 'android')
|
|
93
|
+
const sdk = findAndroidSdk(root)
|
|
94
|
+
if (!sdk) return fail('Android SDK not found; run `make doctor`', [], root, logFile)
|
|
95
|
+
if (!existsSync(path.join(root, 'apps', 'android', 'local.properties'))) writeLocalProperties(root, sdk)
|
|
96
|
+
const firstBuild = !existsSync(path.join(root, 'apps', 'android', 'app', 'build'))
|
|
97
|
+
log.step('android', `building and running unit tests…${firstBuild ? ' (the first build downloads Gradle, JDK 21 and dependencies: allow several minutes)' : ''}`)
|
|
98
|
+
const { code, lines } = await runLogged(
|
|
99
|
+
'./gradlew',
|
|
100
|
+
[':app:testDebugUnitTest', ':app:assembleDebug', '--console=plain', '--warning-mode=summary'],
|
|
101
|
+
{ cwd: path.join(root, 'apps', 'android'), logFile },
|
|
102
|
+
)
|
|
103
|
+
if (code === 0) {
|
|
104
|
+
const tests = junitCount(path.join(root, 'apps', 'android', 'app', 'build', 'test-results', 'testDebugUnitTest'))
|
|
105
|
+
return { ok: true, summary: `build and ${tests} unit tests passed`, details: [], log: shown(root, logFile) }
|
|
106
|
+
}
|
|
107
|
+
const details = unique(
|
|
108
|
+
lines
|
|
109
|
+
.filter((l) => /^(e: |\s*[A-Za-z]+Test > .* FAILED|.*FAILED$|\* What went wrong|.*error:)/.test(l) || /^> /.test(l))
|
|
110
|
+
.map((l) => l.trim())
|
|
111
|
+
.filter((l) => !/^> Task /.test(l) && !/^> Configure project/.test(l)),
|
|
112
|
+
)
|
|
113
|
+
return fail('build or tests failed', details, root, logFile)
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async web(root) {
|
|
117
|
+
const logFile = logPath(root, 'web')
|
|
118
|
+
if (!existsSync(path.join(root, 'node_modules'))) return fail('dependencies missing; run `npm install`', [], root, logFile)
|
|
119
|
+
log.step('web', 'type-checking, testing and building…')
|
|
120
|
+
const { code, lines } = await runLogged('npm', ['run', 'check', '--workspace', 'apps/web', '--silent'], {
|
|
121
|
+
cwd: root,
|
|
122
|
+
logFile,
|
|
123
|
+
env: { NO_COLOR: '1', CI: '1' },
|
|
124
|
+
})
|
|
125
|
+
const tests = lines.map((l) => l.match(/^\s*Tests\s+(\d+) passed/)?.[1]).filter(Boolean).pop()
|
|
126
|
+
if (code === 0) return { ok: true, summary: tests ? `types, ${tests} tests and build passed` : 'checks passed', details: [], log: shown(root, logFile) }
|
|
127
|
+
const details = unique(
|
|
128
|
+
lines.filter((l) => /(error TS\d+|✗|×|FAIL|Error:|AssertionError|expected)/.test(l)).map((l) => l.trim()),
|
|
129
|
+
)
|
|
130
|
+
return fail('type-check, tests or build failed', details, root, logFile)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {string} summary @param {string[]} details @param {string} root @param {string} logFile @returns {CheckResult} */
|
|
135
|
+
function fail(summary, details, root, logFile) {
|
|
136
|
+
return { ok: false, summary, details, log: shown(root, logFile) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Total test count from Gradle's JUnit XML reports. @param {string} dir */
|
|
140
|
+
function junitCount(dir) {
|
|
141
|
+
if (!existsSync(dir)) return 0
|
|
142
|
+
return readdirSync(dir)
|
|
143
|
+
.filter((file) => file.endsWith('.xml'))
|
|
144
|
+
.reduce((sum, file) => sum + Number(readFileSync(path.join(dir, file), 'utf8').match(/<testsuite[^>]* tests="(\d+)"/)?.[1] ?? 0), 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {string[]} lines */
|
|
148
|
+
const unique = (lines) => [...new Set(lines)]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { capture, runInherit } from '../lib/exec.js'
|
|
5
|
+
import { copyTemplate } from '../lib/fsutil.js'
|
|
6
|
+
import { CliError, color, log } from '../lib/log.js'
|
|
7
|
+
import { writeLock } from '../lib/managed.js'
|
|
8
|
+
import { isLocalCheckout, PACKAGE_DIR, packageVersion, templateDir } from '../lib/project.js'
|
|
9
|
+
import { findAndroidSdk, writeLocalProperties } from '../lib/toolchains.js'
|
|
10
|
+
import { validateApp } from '../codegen/app.js'
|
|
11
|
+
import { gen } from './gen.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new project from the template.
|
|
15
|
+
* @param {string} dir
|
|
16
|
+
* @param {{ name?: string, id?: string, install?: boolean, git?: boolean }} options
|
|
17
|
+
*/
|
|
18
|
+
export async function create(dir, { name, id, install = true, git = true }) {
|
|
19
|
+
if (!dir) throw new CliError('Usage: geeknative create <folder> [--name "My App"] [--id com.example.myapp]')
|
|
20
|
+
const root = path.resolve(dir)
|
|
21
|
+
if (existsSync(root) && readdirSync(root).length) throw new CliError(`${dir} already exists and isn't empty.`)
|
|
22
|
+
|
|
23
|
+
const slug = path.basename(root).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app'
|
|
24
|
+
const appName = name?.trim() || titleCase(slug)
|
|
25
|
+
const appId = id ?? `com.example.${slug.replace(/-/g, '')}`
|
|
26
|
+
const version = packageVersion()
|
|
27
|
+
|
|
28
|
+
// Fail before writing anything if the name or ID can't work on every platform.
|
|
29
|
+
validateApp({ name: appName, id: appId, version: '1.0.0', build: 1 })
|
|
30
|
+
|
|
31
|
+
log.step('create', `${appName} (${appId}) in ${dir}`)
|
|
32
|
+
copyTemplate(templateDir(), root)
|
|
33
|
+
|
|
34
|
+
// App identity: the one place the name and ID live. Everything else is generated from it.
|
|
35
|
+
const appFile = path.join(root, 'contracts', 'app.json')
|
|
36
|
+
const app = JSON.parse(readFileSync(appFile, 'utf8'))
|
|
37
|
+
writeFileSync(appFile, `${JSON.stringify({ ...app, name: appName, id: appId }, null, 2)}\n`)
|
|
38
|
+
|
|
39
|
+
const pkgFile = path.join(root, 'package.json')
|
|
40
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
|
|
41
|
+
pkg.name = slug
|
|
42
|
+
pkg.devDependencies.geeknative = isLocalCheckout() ? `file:${PACKAGE_DIR}` : `^${version}`
|
|
43
|
+
writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
44
|
+
|
|
45
|
+
for (const doc of ['README.md']) {
|
|
46
|
+
const file = path.join(root, doc)
|
|
47
|
+
if (existsSync(file)) writeFileSync(file, readFileSync(file, 'utf8').replaceAll('{{APP_NAME}}', appName))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
gen(root, { quiet: true })
|
|
51
|
+
writeLock(root, version)
|
|
52
|
+
const sdk = findAndroidSdk(root)
|
|
53
|
+
if (sdk) writeLocalProperties(root, sdk)
|
|
54
|
+
log.ok('create', 'project files written')
|
|
55
|
+
|
|
56
|
+
if (install) {
|
|
57
|
+
log.step('install', 'npm install…')
|
|
58
|
+
if ((await runInherit('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: root })) !== 0) {
|
|
59
|
+
log.warn('install', 'npm install failed; run it again inside the project')
|
|
60
|
+
} else log.ok('install', 'dependencies installed')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (git && capture('git', ['--version'])) {
|
|
64
|
+
capture('git', ['init', '-q'], { cwd: root })
|
|
65
|
+
capture('git', ['add', '-A'], { cwd: root })
|
|
66
|
+
const committed = capture('git', ['commit', '-q', '-m', 'Initial commit from geeknative'], { cwd: root }) !== null
|
|
67
|
+
log.ok('git', committed ? 'repository initialized with a first commit' : 'repository initialized (commit skipped: set git user.name and user.email)')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log.info(`
|
|
71
|
+
${color.bold('Next steps')}
|
|
72
|
+
cd ${dir}
|
|
73
|
+
make doctor ${color.dim('# checks Xcode, the Android SDK and Node; FIX=1 installs what is missing')}
|
|
74
|
+
make check ${color.dim('# builds and tests all three apps')}
|
|
75
|
+
|
|
76
|
+
Then open the folder in your coding agent (Claude Code, Codex, Cursor, Gemini CLI, ...)
|
|
77
|
+
and describe the app you want. AGENTS.md tells it how to build it for iOS, Android and web.
|
|
78
|
+
`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {string} slug */
|
|
82
|
+
const titleCase = (slug) => slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ')
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { capture, runInherit, runLogged } from '../lib/exec.js'
|
|
5
|
+
import { log } from '../lib/log.js'
|
|
6
|
+
import {
|
|
7
|
+
ANDROID_COMPILE_SDK,
|
|
8
|
+
findAndroidSdk,
|
|
9
|
+
hasCompilePlatform,
|
|
10
|
+
pickSimulator,
|
|
11
|
+
sdkManager,
|
|
12
|
+
writeLocalProperties,
|
|
13
|
+
xcodeMajorVersion,
|
|
14
|
+
} from '../lib/toolchains.js'
|
|
15
|
+
import { gen } from './gen.js'
|
|
16
|
+
|
|
17
|
+
const MIN_XCODE = 26
|
|
18
|
+
const MIN_NODE = 22
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks everything the three apps need to build. With `fix`, installs what it safely can.
|
|
22
|
+
* @param {string} root
|
|
23
|
+
* @param {{ fix?: boolean }} options
|
|
24
|
+
*/
|
|
25
|
+
export async function doctor(root, { fix = false }) {
|
|
26
|
+
let problems = 0
|
|
27
|
+
/** @param {string} label @param {string} message @param {string} [hint] */
|
|
28
|
+
const problem = (label, message, hint) => {
|
|
29
|
+
problems++
|
|
30
|
+
log.fail(label, message)
|
|
31
|
+
if (hint) log.detail(hint)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Node and dependencies
|
|
35
|
+
const nodeMajor = Number(process.versions.node.split('.')[0])
|
|
36
|
+
if (nodeMajor >= MIN_NODE) log.ok('node', `v${process.versions.node}`)
|
|
37
|
+
else problem('node', `v${process.versions.node} is too old`, `Install Node ${MIN_NODE} or later (24 LTS recommended).`)
|
|
38
|
+
|
|
39
|
+
if (existsSync(path.join(root, 'node_modules'))) log.ok('deps', 'installed')
|
|
40
|
+
else if (fix) {
|
|
41
|
+
log.step('deps', 'running npm install…')
|
|
42
|
+
if ((await runInherit('npm', ['install'], { cwd: root })) === 0) log.ok('deps', 'installed')
|
|
43
|
+
else problem('deps', 'npm install failed')
|
|
44
|
+
} else problem('deps', 'not installed', 'Run `npm install` (or `make doctor FIX=1`).')
|
|
45
|
+
|
|
46
|
+
// Contracts
|
|
47
|
+
try {
|
|
48
|
+
gen(root, { check: true })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
problem('contracts', /** @type {Error} */ (error).message)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// iOS
|
|
54
|
+
if (process.platform !== 'darwin') {
|
|
55
|
+
log.warn('ios', 'skipped: iOS builds need macOS with Xcode')
|
|
56
|
+
} else {
|
|
57
|
+
const xcode = xcodeMajorVersion()
|
|
58
|
+
if (!xcode) problem('xcode', 'not found', 'Install Xcode from the App Store, then run `sudo xcode-select -s /Applications/Xcode.app`.')
|
|
59
|
+
else if (xcode < MIN_XCODE) problem('xcode', `Xcode ${xcode} is too old`, `Install Xcode ${MIN_XCODE} or later.`)
|
|
60
|
+
else {
|
|
61
|
+
log.ok('xcode', capture('xcodebuild', ['-version'])?.split('\n')[0] ?? `Xcode ${xcode}`)
|
|
62
|
+
const simulator = pickSimulator()
|
|
63
|
+
if (simulator) log.ok('simulator', `${simulator.name} (${simulator.runtime})`)
|
|
64
|
+
else problem('simulator', 'no iPhone simulator', 'Run `xcodebuild -downloadPlatform iOS` to install the iOS simulator runtime.')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Android
|
|
69
|
+
const java = capture('java', ['-version'], { input: '' }) !== null || existsSync(process.env.JAVA_HOME ?? '/nonexistent')
|
|
70
|
+
if (java) log.ok('java', 'found (Gradle downloads JDK 21 for the build itself)')
|
|
71
|
+
else problem('java', 'no JDK found', 'Install a JDK 17 or later (for example `brew install --cask temurin`) so Gradle can start.')
|
|
72
|
+
|
|
73
|
+
const sdk = findAndroidSdk(root)
|
|
74
|
+
if (!sdk) {
|
|
75
|
+
problem('android', 'Android SDK not found', 'Install Android Studio, or `brew install --cask android-commandlinetools`, then rerun.')
|
|
76
|
+
} else {
|
|
77
|
+
writeLocalProperties(root, sdk)
|
|
78
|
+
log.ok('android', `SDK at ${sdk}`)
|
|
79
|
+
if (hasCompilePlatform(sdk)) log.ok('platform', `Android API ${ANDROID_COMPILE_SDK} installed`)
|
|
80
|
+
else if (fix) {
|
|
81
|
+
const pkg = compilePlatformPackage(sdk)
|
|
82
|
+
log.step('platform', `installing ${pkg}…`)
|
|
83
|
+
const manager = sdkManager(sdk)
|
|
84
|
+
const logFile = path.join(root, '.geeknative', 'logs', 'sdkmanager.log')
|
|
85
|
+
await runLogged('sh', ['-c', `yes | "${manager}" --sdk_root="${sdk}" --licenses`], { logFile })
|
|
86
|
+
if ((await runLogged(manager, [`--sdk_root=${sdk}`, pkg], { logFile })).code === 0) {
|
|
87
|
+
log.ok('platform', `Android API ${ANDROID_COMPILE_SDK} installed`)
|
|
88
|
+
} else problem('platform', `could not install ${pkg}`, `See ${path.relative(root, logFile)}.`)
|
|
89
|
+
} else {
|
|
90
|
+
problem('platform', `Android API ${ANDROID_COMPILE_SDK} is not installed`, 'Run `make doctor FIX=1` to install it.')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
log.info('')
|
|
95
|
+
if (problems) {
|
|
96
|
+
log.info(`${problems} problem${problems === 1 ? '' : 's'} found.${fix ? '' : ' `make doctor FIX=1` fixes what it can.'}`)
|
|
97
|
+
process.exitCode = 1
|
|
98
|
+
} else {
|
|
99
|
+
log.info('All set. Try `make check`, then describe what you want to build to your coding agent.')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The sdkmanager package for the compile SDK: newer platforms carry a minor version ("android-37.0"). @param {string} sdk */
|
|
104
|
+
function compilePlatformPackage(sdk) {
|
|
105
|
+
const listing = capture(sdkManager(sdk), [`--sdk_root=${sdk}`, '--list'], { timeout: 120_000 }) ?? ''
|
|
106
|
+
const exact = `platforms;android-${ANDROID_COMPILE_SDK}.0`
|
|
107
|
+
return listing.includes(exact) ? exact : `platforms;android-${ANDROID_COMPILE_SDK}`
|
|
108
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { generate, staleFiles } from '../codegen/index.js'
|
|
5
|
+
import { writeIfChanged } from '../lib/fsutil.js'
|
|
6
|
+
import { CliError, log } from '../lib/log.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regenerates platform code from contracts/. With `check`, writes nothing and fails if
|
|
10
|
+
* anything is out of date (used by `geeknative check`).
|
|
11
|
+
* @param {string} root
|
|
12
|
+
* @param {{ check?: boolean, quiet?: boolean }} [options]
|
|
13
|
+
* @returns {{ changed: string[], removed: string[] }}
|
|
14
|
+
*/
|
|
15
|
+
export function gen(root, { check = false, quiet = false } = {}) {
|
|
16
|
+
const outputs = generate(root)
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const changed = []
|
|
19
|
+
for (const output of outputs) {
|
|
20
|
+
const file = path.join(root, output.path)
|
|
21
|
+
if (check) {
|
|
22
|
+
const next = typeof output.content === 'string' ? Buffer.from(output.content) : output.content
|
|
23
|
+
if (!existsSync(file) || !readFileSync(file).equals(next)) changed.push(output.path)
|
|
24
|
+
} else if (writeIfChanged(file, output.content)) {
|
|
25
|
+
changed.push(output.path)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const removed = staleFiles(root, new Set(outputs.map((o) => o.path)))
|
|
29
|
+
if (!check) for (const file of removed) rmSync(path.join(root, file))
|
|
30
|
+
|
|
31
|
+
if (check && (changed.length || removed.length)) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
`Generated files are out of date:\n${[...changed, ...removed].map((f) => ` ${f}`).join('\n')}\nRun \`make gen\`, and never edit generated files by hand.`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
if (!quiet) {
|
|
37
|
+
if (check) log.ok('contracts', 'valid, generated files up to date')
|
|
38
|
+
else if (changed.length || removed.length) {
|
|
39
|
+
log.ok('gen', `${changed.length} file${changed.length === 1 ? '' : 's'} updated${removed.length ? `, ${removed.length} removed` : ''}`)
|
|
40
|
+
for (const file of [...changed, ...removed]) log.detail(file)
|
|
41
|
+
} else log.ok('gen', 'already up to date')
|
|
42
|
+
}
|
|
43
|
+
return { changed, removed }
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { walk } from '../lib/fsutil.js'
|
|
5
|
+
import { color, log } from '../lib/log.js'
|
|
6
|
+
import { rel } from '../lib/project.js'
|
|
7
|
+
|
|
8
|
+
const PLATFORMS = /** @type {const} */ (['ios', 'android', 'web'])
|
|
9
|
+
const LABELS = { ios: 'iOS', android: 'Android', web: 'Web' }
|
|
10
|
+
const STATUS = { done: '✅ done', 'in-progress': '🚧 in progress', planned: '⏳ planned', 'n/a': '➖ n/a' }
|
|
11
|
+
const AC_ID = /AC-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/g
|
|
12
|
+
|
|
13
|
+
/** Where each app's tests live. */
|
|
14
|
+
const TEST_FILE = {
|
|
15
|
+
ios: /^apps\/ios\/[^/]*Tests\/.*\.swift$/,
|
|
16
|
+
android: /^apps\/android\/app\/src\/(test|androidTest)\/.*\.kt$/,
|
|
17
|
+
web: /^apps\/web\/(src\/.*\.(test|spec)\.tsx?|e2e\/.*\.tsx?)$/,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{ id: string, text: string }} Criterion
|
|
22
|
+
* @typedef {{ id: string, title: string, file: string, status: Record<string, string>, criteria: Criterion[] }} Feature
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds spec/parity.md: each feature's status per platform, and which acceptance
|
|
27
|
+
* criteria each platform's tests cover (tests mention AC IDs in their names).
|
|
28
|
+
* @param {string} root
|
|
29
|
+
*/
|
|
30
|
+
export function parity(root) {
|
|
31
|
+
const features = readFeatures(root)
|
|
32
|
+
/** @type {Record<string, Set<string>>} */
|
|
33
|
+
const tested = { ios: new Set(), android: new Set(), web: new Set() }
|
|
34
|
+
for (const file of walk(path.join(root, 'apps'))) {
|
|
35
|
+
const relative = rel(root, file)
|
|
36
|
+
for (const platform of PLATFORMS) {
|
|
37
|
+
if (!TEST_FILE[platform].test(relative)) continue
|
|
38
|
+
for (const id of readFileSync(file, 'utf8').match(AC_ID) ?? []) tested[platform].add(id)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rows = features.map((f) => {
|
|
43
|
+
const cells = PLATFORMS.map((p) => {
|
|
44
|
+
const status = STATUS[/** @type {keyof typeof STATUS} */ (f.status[p])] ?? f.status[p] ?? '—'
|
|
45
|
+
if (f.status[p] === 'n/a' || !f.criteria.length) return status
|
|
46
|
+
const covered = f.criteria.filter((c) => tested[p].has(c.id)).length
|
|
47
|
+
return `${status} · ${covered}/${f.criteria.length} tested`
|
|
48
|
+
})
|
|
49
|
+
return `| [${f.title}](features/${f.file}) | ${cells.join(' | ')} |`
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const coverage = features
|
|
53
|
+
.filter((f) => f.criteria.length)
|
|
54
|
+
.map((f) => {
|
|
55
|
+
const lines = f.criteria.map((c) => {
|
|
56
|
+
const cells = PLATFORMS.map((p) => (f.status[p] === 'n/a' ? '➖' : tested[p].has(c.id) ? '✅' : '❌'))
|
|
57
|
+
return `| ${c.id}: ${c.text.replace(/\|/g, '\\|')} | ${cells.join(' | ')} |`
|
|
58
|
+
})
|
|
59
|
+
return `### ${f.title}\n\n| Criterion | iOS | Android | Web |\n|---|---|---|---|\n${lines.join('\n')}`
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const markdown = `<!-- GENERATED by \`make parity\` from spec/features and each app's tests. Do not edit. -->
|
|
63
|
+
# Parity
|
|
64
|
+
|
|
65
|
+
Status comes from each feature spec's front matter. "Tested" counts acceptance criteria whose ID
|
|
66
|
+
(like \`AC-settings-2\`) appears in that platform's tests.
|
|
67
|
+
|
|
68
|
+
| Feature | iOS | Android | Web |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
${rows.join('\n')}
|
|
71
|
+
|
|
72
|
+
## Acceptance criteria coverage
|
|
73
|
+
|
|
74
|
+
${coverage.join('\n\n')}
|
|
75
|
+
`
|
|
76
|
+
writeFileSync(path.join(root, 'spec', 'parity.md'), markdown)
|
|
77
|
+
|
|
78
|
+
for (const f of features) {
|
|
79
|
+
const parts = PLATFORMS.map((p) => {
|
|
80
|
+
const covered = f.criteria.filter((c) => tested[p].has(c.id)).length
|
|
81
|
+
return `${LABELS[p]} ${f.status[p] ?? '?'} ${f.criteria.length ? `${covered}/${f.criteria.length}` : ''}`.trim()
|
|
82
|
+
})
|
|
83
|
+
const gaps = PLATFORMS.flatMap((p) =>
|
|
84
|
+
f.status[p] === 'n/a' ? [] : f.criteria.filter((c) => !tested[p].has(c.id)).map((c) => `${c.id} untested on ${LABELS[p]}`),
|
|
85
|
+
)
|
|
86
|
+
;(gaps.length ? log.warn : log.ok)(f.id, parts.join(color.dim(' · ')))
|
|
87
|
+
for (const gap of gaps) log.detail(color.dim(gap))
|
|
88
|
+
}
|
|
89
|
+
log.info(color.dim('Wrote spec/parity.md'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {string} root @returns {Feature[]} */
|
|
93
|
+
function readFeatures(root) {
|
|
94
|
+
const dir = path.join(root, 'spec', 'features')
|
|
95
|
+
if (!existsSync(dir)) return []
|
|
96
|
+
return readdirSync(dir)
|
|
97
|
+
.filter((f) => f.endsWith('.md') && !f.startsWith('_'))
|
|
98
|
+
.sort()
|
|
99
|
+
.map((file) => {
|
|
100
|
+
const text = readFileSync(path.join(dir, file), 'utf8')
|
|
101
|
+
const meta = frontMatter(text)
|
|
102
|
+
const id = meta.id ?? file.replace(/\.md$/, '')
|
|
103
|
+
/** @type {Criterion[]} */
|
|
104
|
+
const criteria = []
|
|
105
|
+
for (const match of text.matchAll(/^\s*[-*]\s*\**(AC-[a-z0-9]+(?:-[a-z0-9]+)*-\d+)\**\s*[:.]?\s*(.*)$/gm)) {
|
|
106
|
+
if (!criteria.some((c) => c.id === match[1])) criteria.push({ id: match[1], text: match[2].trim() })
|
|
107
|
+
}
|
|
108
|
+
return { id, title: meta.title ?? id, file, status: meta.status ?? {}, criteria }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reads the small YAML subset used in spec front matter: `key: value` and one level of nesting.
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @returns {Record<string, any>}
|
|
116
|
+
*/
|
|
117
|
+
function frontMatter(text) {
|
|
118
|
+
const match = text.match(/^---\n([\s\S]*?)\n---/)
|
|
119
|
+
/** @type {Record<string, any>} */
|
|
120
|
+
const meta = {}
|
|
121
|
+
if (!match) return meta
|
|
122
|
+
let parent = ''
|
|
123
|
+
for (const raw of match[1].split('\n')) {
|
|
124
|
+
const line = raw.replace(/\s+#.*$/, '')
|
|
125
|
+
const nested = line.match(/^\s+([\w-]+):\s*(.+)$/)
|
|
126
|
+
const top = line.match(/^([\w-]+):\s*(.*)$/)
|
|
127
|
+
if (nested && parent) meta[parent][nested[1]] = nested[2].trim()
|
|
128
|
+
else if (top) {
|
|
129
|
+
parent = top[2].trim() ? '' : top[1]
|
|
130
|
+
meta[top[1]] = top[2].trim() || {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return meta
|
|
134
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readApp } from '../codegen/app.js'
|
|
5
|
+
import { capture, runInherit, runLogged } from '../lib/exec.js'
|
|
6
|
+
import { CliError, log } from '../lib/log.js'
|
|
7
|
+
import { adb, emulator, findAndroidSdk, pickSimulator, writeLocalProperties } from '../lib/toolchains.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds and launches one app: on a simulator, an emulator or device, or the web dev server.
|
|
11
|
+
* @param {string} root
|
|
12
|
+
* @param {string} platform
|
|
13
|
+
*/
|
|
14
|
+
export async function run(root, platform) {
|
|
15
|
+
if (platform === 'web') return runWeb(root)
|
|
16
|
+
if (platform === 'ios') return runIos(root)
|
|
17
|
+
if (platform === 'android') return runAndroid(root)
|
|
18
|
+
throw new CliError('Usage: geeknative run <ios|android|web>')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {string} root */
|
|
22
|
+
async function runWeb(root) {
|
|
23
|
+
process.exitCode = await runInherit('npm', ['run', 'dev', '--workspace', 'apps/web'], { cwd: root })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} root */
|
|
27
|
+
async function runIos(root) {
|
|
28
|
+
const simulator = pickSimulator()
|
|
29
|
+
if (!simulator) throw new CliError('No iPhone simulator found. Run `make doctor`.')
|
|
30
|
+
const { id } = readApp(root)
|
|
31
|
+
const logFile = path.join(root, '.geeknative', 'logs', 'ios-run.log')
|
|
32
|
+
|
|
33
|
+
if (simulator.state !== 'Booted') {
|
|
34
|
+
log.step('ios', `booting ${simulator.name}…`)
|
|
35
|
+
capture('xcrun', ['simctl', 'boot', simulator.udid])
|
|
36
|
+
}
|
|
37
|
+
capture('open', ['-a', 'Simulator'])
|
|
38
|
+
|
|
39
|
+
log.step('ios', 'building…')
|
|
40
|
+
const build = await runLogged(
|
|
41
|
+
'xcodebuild',
|
|
42
|
+
['build', '-project', 'App.xcodeproj', '-scheme', 'App', '-destination', `id=${simulator.udid}`, '-derivedDataPath', '.build/DerivedData', '-quiet'],
|
|
43
|
+
{ cwd: path.join(root, 'apps', 'ios'), logFile },
|
|
44
|
+
)
|
|
45
|
+
if (build.code !== 0) throw new CliError(`Build failed. Run \`make check-ios\` for details (log: ${path.relative(root, logFile)}).`)
|
|
46
|
+
|
|
47
|
+
const app = path.join(root, 'apps', 'ios', '.build', 'DerivedData', 'Build', 'Products', 'Debug-iphonesimulator', 'App.app')
|
|
48
|
+
if (capture('xcrun', ['simctl', 'install', simulator.udid, app]) === null) throw new CliError('Could not install the app on the simulator.')
|
|
49
|
+
if (capture('xcrun', ['simctl', 'launch', simulator.udid, id]) === null) throw new CliError(`Could not launch ${id}.`)
|
|
50
|
+
log.ok('ios', `running on ${simulator.name} (${simulator.runtime})`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {string} root */
|
|
54
|
+
async function runAndroid(root) {
|
|
55
|
+
const sdk = findAndroidSdk(root)
|
|
56
|
+
if (!sdk) throw new CliError('Android SDK not found. Run `make doctor`.')
|
|
57
|
+
writeLocalProperties(root, sdk)
|
|
58
|
+
const { id } = readApp(root)
|
|
59
|
+
const adbPath = adb(sdk)
|
|
60
|
+
|
|
61
|
+
if (!connectedDevice(adbPath)) {
|
|
62
|
+
const avds = (capture(emulator(sdk), ['-list-avds']) ?? '').split('\n').filter(Boolean)
|
|
63
|
+
if (!avds.length) {
|
|
64
|
+
throw new CliError(
|
|
65
|
+
'No device or emulator. Connect a device with USB debugging on, or create an emulator in Android Studio (Device Manager), then rerun.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
log.step('android', `starting emulator ${avds[0]}…`)
|
|
69
|
+
spawn(emulator(sdk), ['-avd', avds[0]], { detached: true, stdio: 'ignore' }).unref()
|
|
70
|
+
capture(adbPath, ['wait-for-device'], { timeout: 180_000 })
|
|
71
|
+
for (let i = 0; i < 90 && capture(adbPath, ['shell', 'getprop', 'sys.boot_completed']) !== '1'; i++) {
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log.step('android', 'building and installing…')
|
|
77
|
+
const logFile = path.join(root, '.geeknative', 'logs', 'android-run.log')
|
|
78
|
+
const install = await runLogged('./gradlew', [':app:installDebug', '--console=plain'], {
|
|
79
|
+
cwd: path.join(root, 'apps', 'android'),
|
|
80
|
+
logFile,
|
|
81
|
+
})
|
|
82
|
+
if (install.code !== 0) throw new CliError(`Install failed. Run \`make check-android\` for details (log: ${path.relative(root, logFile)}).`)
|
|
83
|
+
capture(adbPath, ['shell', 'am', 'start', '-n', `${id}/app.MainActivity`])
|
|
84
|
+
log.ok('android', `running ${id}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} adbPath */
|
|
88
|
+
function connectedDevice(adbPath) {
|
|
89
|
+
const out = capture(adbPath, ['devices']) ?? ''
|
|
90
|
+
return out.split('\n').slice(1).some((line) => /\tdevice$/.test(line))
|
|
91
|
+
}
|