geeknative 0.0.0-stage → 0.1.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.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +31 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +97 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +143 -0
  12. package/src/commands/create.js +78 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. package/README.md +0 -3
@@ -0,0 +1,143 @@
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 wanted = !targets.length || targets.includes('all') ? PLATFORMS : targets
19
+ for (const target of wanted) {
20
+ if (target !== 'contracts' && !PLATFORMS.includes(target)) {
21
+ throw new CliError(`Unknown check "${target}". Use: all, contracts, ${PLATFORMS.join(', ')}.`)
22
+ }
23
+ }
24
+
25
+ let failed = false
26
+ try {
27
+ gen(root, { check: true })
28
+ } catch (error) {
29
+ log.fail('contracts', /** @type {Error} */ (error).message)
30
+ failed = true
31
+ }
32
+
33
+ for (const platform of wanted.filter((t) => t !== 'contracts')) {
34
+ const started = Date.now()
35
+ const result = await CHECKS[platform](root)
36
+ const seconds = `${Math.round((Date.now() - started) / 1000)}s`
37
+ if (result.ok) {
38
+ log.ok(platform, `${result.summary} ${color.dim(`(${seconds})`)}`)
39
+ } else {
40
+ failed = true
41
+ log.fail(platform, `${result.summary} ${color.dim(`(${seconds}, full log: ${result.log})`)}`)
42
+ for (const line of result.details.slice(0, 25)) log.detail(line)
43
+ if (result.details.length > 25) log.detail(`… ${result.details.length - 25} more in the log`)
44
+ }
45
+ }
46
+ if (failed) process.exitCode = 1
47
+ }
48
+
49
+ /** @typedef {{ ok: boolean, summary: string, details: string[], log: string }} CheckResult */
50
+
51
+ /** @param {string} root @param {string} platform */
52
+ const logPath = (root, platform) => path.join(root, '.geeknative', 'logs', `${platform}.log`)
53
+
54
+ /** @param {string} root @param {string} file */
55
+ const shown = (root, file) => path.relative(root, file)
56
+
57
+ /** @type {Record<string, (root: string) => Promise<CheckResult>>} */
58
+ const CHECKS = {
59
+ async ios(root) {
60
+ const logFile = logPath(root, 'ios')
61
+ if (process.platform !== 'darwin') return fail('iOS builds need macOS with Xcode', [], root, logFile)
62
+ const simulator = pickSimulator()
63
+ if (!simulator) return fail('no iPhone simulator found; run `make doctor`', [], root, logFile)
64
+ const firstBuild = !existsSync(path.join(root, 'apps', 'ios', '.build'))
65
+ log.step('ios', `building and testing on ${simulator.name} (${simulator.runtime})…${firstBuild ? ' (first build: allow a few minutes)' : ''}`)
66
+ const { code, lines } = await runLogged(
67
+ 'xcodebuild',
68
+ [
69
+ 'test',
70
+ '-project', 'App.xcodeproj',
71
+ '-scheme', 'App',
72
+ '-destination', `id=${simulator.udid}`,
73
+ '-derivedDataPath', '.build/DerivedData',
74
+ '-quiet',
75
+ ],
76
+ { cwd: path.join(root, 'apps', 'ios'), logFile },
77
+ )
78
+ const passed = lines.filter((l) => /^Test case '.*' passed/.test(l)).length
79
+ if (code === 0) return { ok: true, summary: `build and ${passed} tests passed`, details: [], log: shown(root, logFile) }
80
+ const details = unique(
81
+ lines.filter((l) => /(error:|✘|failed|\*\* (BUILD|TEST) FAILED)/.test(l) && !/^\s*$/.test(l)).map((l) => l.trim()),
82
+ )
83
+ return fail(details.length ? 'build or tests failed' : `xcodebuild exited with ${code}`, details, root, logFile)
84
+ },
85
+
86
+ async android(root) {
87
+ const logFile = logPath(root, 'android')
88
+ const sdk = findAndroidSdk(root)
89
+ if (!sdk) return fail('Android SDK not found; run `make doctor`', [], root, logFile)
90
+ if (!existsSync(path.join(root, 'apps', 'android', 'local.properties'))) writeLocalProperties(root, sdk)
91
+ const firstBuild = !existsSync(path.join(root, 'apps', 'android', 'app', 'build'))
92
+ log.step('android', `building and running unit tests…${firstBuild ? ' (the first build downloads Gradle, JDK 21 and dependencies: allow several minutes)' : ''}`)
93
+ const { code, lines } = await runLogged(
94
+ './gradlew',
95
+ [':app:testDebugUnitTest', ':app:assembleDebug', '--console=plain', '--warning-mode=summary'],
96
+ { cwd: path.join(root, 'apps', 'android'), logFile },
97
+ )
98
+ if (code === 0) {
99
+ const tests = junitCount(path.join(root, 'apps', 'android', 'app', 'build', 'test-results', 'testDebugUnitTest'))
100
+ return { ok: true, summary: `build and ${tests} unit tests passed`, details: [], log: shown(root, logFile) }
101
+ }
102
+ const details = unique(
103
+ lines
104
+ .filter((l) => /^(e: |\s*[A-Za-z]+Test > .* FAILED|.*FAILED$|\* What went wrong|.*error:)/.test(l) || /^> /.test(l))
105
+ .map((l) => l.trim())
106
+ .filter((l) => !/^> Task /.test(l) && !/^> Configure project/.test(l)),
107
+ )
108
+ return fail('build or tests failed', details, root, logFile)
109
+ },
110
+
111
+ async web(root) {
112
+ const logFile = logPath(root, 'web')
113
+ if (!existsSync(path.join(root, 'node_modules'))) return fail('dependencies missing; run `npm install`', [], root, logFile)
114
+ log.step('web', 'type-checking, testing and building…')
115
+ const { code, lines } = await runLogged('npm', ['run', 'check', '--workspace', 'apps/web', '--silent'], {
116
+ cwd: root,
117
+ logFile,
118
+ env: { NO_COLOR: '1', CI: '1' },
119
+ })
120
+ const tests = lines.map((l) => l.match(/^\s*Tests\s+(\d+) passed/)?.[1]).filter(Boolean).pop()
121
+ if (code === 0) return { ok: true, summary: tests ? `types, ${tests} tests and build passed` : 'checks passed', details: [], log: shown(root, logFile) }
122
+ const details = unique(
123
+ lines.filter((l) => /(error TS\d+|✗|×|FAIL|Error:|AssertionError|expected)/.test(l)).map((l) => l.trim()),
124
+ )
125
+ return fail('type-check, tests or build failed', details, root, logFile)
126
+ },
127
+ }
128
+
129
+ /** @param {string} summary @param {string[]} details @param {string} root @param {string} logFile @returns {CheckResult} */
130
+ function fail(summary, details, root, logFile) {
131
+ return { ok: false, summary, details, log: shown(root, logFile) }
132
+ }
133
+
134
+ /** Total test count from Gradle's JUnit XML reports. @param {string} dir */
135
+ function junitCount(dir) {
136
+ if (!existsSync(dir)) return 0
137
+ return readdirSync(dir)
138
+ .filter((file) => file.endsWith('.xml'))
139
+ .reduce((sum, file) => sum + Number(readFileSync(path.join(dir, file), 'utf8').match(/<testsuite[^>]* tests="(\d+)"/)?.[1] ?? 0), 0)
140
+ }
141
+
142
+ /** @param {string[]} lines */
143
+ const unique = (lines) => [...new Set(lines)]
@@ -0,0 +1,78 @@
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 { gen } from './gen.js'
11
+
12
+ /**
13
+ * Creates a new project from the template.
14
+ * @param {string} dir
15
+ * @param {{ name?: string, id?: string, install?: boolean, git?: boolean }} options
16
+ */
17
+ export async function create(dir, { name, id, install = true, git = true }) {
18
+ if (!dir) throw new CliError('Usage: geeknative create <folder> [--name "My App"] [--id com.example.myapp]')
19
+ const root = path.resolve(dir)
20
+ if (existsSync(root) && readdirSync(root).length) throw new CliError(`${dir} already exists and isn't empty.`)
21
+
22
+ const slug = path.basename(root).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app'
23
+ const appName = name?.trim() || titleCase(slug)
24
+ const appId = id ?? `com.example.${slug.replace(/-/g, '')}`
25
+ const version = packageVersion()
26
+
27
+ log.step('create', `${appName} (${appId}) in ${dir}`)
28
+ copyTemplate(templateDir(), root)
29
+
30
+ // App identity: the one place the name and ID live. Everything else is generated from it.
31
+ const appFile = path.join(root, 'contracts', 'app.json')
32
+ const app = JSON.parse(readFileSync(appFile, 'utf8'))
33
+ writeFileSync(appFile, `${JSON.stringify({ ...app, name: appName, id: appId }, null, 2)}\n`)
34
+
35
+ const pkgFile = path.join(root, 'package.json')
36
+ const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
37
+ pkg.name = slug
38
+ pkg.devDependencies.geeknative = isLocalCheckout() ? `file:${PACKAGE_DIR}` : `^${version}`
39
+ writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`)
40
+
41
+ for (const doc of ['README.md']) {
42
+ const file = path.join(root, doc)
43
+ if (existsSync(file)) writeFileSync(file, readFileSync(file, 'utf8').replaceAll('{{APP_NAME}}', appName))
44
+ }
45
+
46
+ gen(root, { quiet: true })
47
+ writeLock(root, version)
48
+ const sdk = findAndroidSdk(root)
49
+ if (sdk) writeLocalProperties(root, sdk)
50
+ log.ok('create', 'project files written')
51
+
52
+ if (install) {
53
+ log.step('install', 'npm install…')
54
+ if ((await runInherit('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: root })) !== 0) {
55
+ log.warn('install', 'npm install failed; run it again inside the project')
56
+ } else log.ok('install', 'dependencies installed')
57
+ }
58
+
59
+ if (git && capture('git', ['--version'])) {
60
+ capture('git', ['init', '-q'], { cwd: root })
61
+ capture('git', ['add', '-A'], { cwd: root })
62
+ const committed = capture('git', ['commit', '-q', '-m', 'Initial commit from geeknative'], { cwd: root }) !== null
63
+ log.ok('git', committed ? 'repository initialized with a first commit' : 'repository initialized (commit skipped: set git user.name and user.email)')
64
+ }
65
+
66
+ log.info(`
67
+ ${color.bold('Next steps')}
68
+ cd ${dir}
69
+ make doctor ${color.dim('# checks Xcode, the Android SDK and Node; FIX=1 installs what is missing')}
70
+ make check ${color.dim('# builds and tests all three apps')}
71
+
72
+ Then open the folder in your coding agent (Claude Code, Codex, Cursor, Gemini CLI, ...)
73
+ and describe the app you want. AGENTS.md tells it how to build it for iOS, Android and web.
74
+ `)
75
+ }
76
+
77
+ /** @param {string} slug */
78
+ 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
+ }