codz-cli 0.0.0-stage → 0.1.2

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 (118) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +525 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +638 -0
  77. package/dist/skills/createDemo.js +1026 -0
  78. package/dist/skills/doctor.js +304 -0
  79. package/dist/skills/doctorFix.js +214 -0
  80. package/dist/skills/explain.js +61 -0
  81. package/dist/skills/feature.js +418 -0
  82. package/dist/skills/featureEdit.js +225 -0
  83. package/dist/skills/featureLinks.js +179 -0
  84. package/dist/skills/init.js +66 -0
  85. package/dist/skills/iosUi.js +468 -0
  86. package/dist/skills/locale.js +335 -0
  87. package/dist/skills/mcp.js +175 -0
  88. package/dist/skills/mock.js +482 -0
  89. package/dist/skills/module.js +23 -0
  90. package/dist/skills/plans.js +66 -0
  91. package/dist/skills/prefill.js +84 -0
  92. package/dist/skills/reactNativeUi.js +388 -0
  93. package/dist/skills/reactUi.js +352 -0
  94. package/dist/skills/report.js +75 -0
  95. package/dist/skills/route.js +230 -0
  96. package/dist/skills/screen.js +597 -0
  97. package/dist/skills/screenForm.js +410 -0
  98. package/dist/skills/setup.js +28 -0
  99. package/dist/skills/setupDoctor.js +226 -0
  100. package/dist/skills/templatesCmd.js +32 -0
  101. package/dist/skills/testgen.js +331 -0
  102. package/dist/skills/theme.js +388 -0
  103. package/dist/starters/auth.js +144 -0
  104. package/dist/starters/commands.js +283 -0
  105. package/dist/starters/engine.js +315 -0
  106. package/dist/starters/home.js +42 -0
  107. package/dist/starters/http.js +93 -0
  108. package/dist/starters/manifest.js +285 -0
  109. package/dist/starters/paths.js +70 -0
  110. package/dist/starters/publish.js +150 -0
  111. package/dist/starters/registry.js +258 -0
  112. package/dist/starters/sign.js +72 -0
  113. package/dist/starters/source.js +98 -0
  114. package/dist/starters/strictJson.js +148 -0
  115. package/dist/starters/tar.js +180 -0
  116. package/docs/COMMANDS.md +527 -0
  117. package/examples/openapi.yaml +319 -0
  118. package/package.json +57 -4
@@ -0,0 +1,130 @@
1
+ // Detects what a pasted block contains: a cURL command, a URL, and JSON
2
+ // samples labelled as request or response. Decisions come from the shape of
3
+ // the data, never from free-text intent.
4
+ import { ToolError } from "../core/types.js";
5
+ const REQUEST_LABEL = /^(request|request body|body|payload|input)\s*:?\s*$/i;
6
+ const RESPONSE_LABEL = /^(response|response body|sample response|result|output)\s*:?\s*$/i;
7
+ /** Splits text at newlines that are outside shell quotes. */
8
+ function splitOutsideQuotes(text) {
9
+ const parts = [];
10
+ let cur = '';
11
+ let quote = null;
12
+ for (let i = 0; i < text.length; i++) {
13
+ const c = text[i];
14
+ if (quote) {
15
+ if (c === quote && text[i - 1] !== '\\')
16
+ quote = null;
17
+ cur += c;
18
+ }
19
+ else if (c === "'" || c === '"') {
20
+ quote = c;
21
+ cur += c;
22
+ }
23
+ else if (c === '\n') {
24
+ parts.push(cur);
25
+ cur = '';
26
+ }
27
+ else {
28
+ cur += c;
29
+ }
30
+ }
31
+ parts.push(cur);
32
+ return parts;
33
+ }
34
+ /** Reads consecutive JSON values from text, with optional label lines before each. */
35
+ function readJsonBlocks(text) {
36
+ const out = [];
37
+ let rest = text.trim();
38
+ let label = null;
39
+ while (rest.length) {
40
+ const firstLine = rest.split('\n', 1)[0].trim();
41
+ if (REQUEST_LABEL.test(firstLine) || RESPONSE_LABEL.test(firstLine)) {
42
+ label = REQUEST_LABEL.test(firstLine) ? 'request' : 'response';
43
+ rest = rest.slice(rest.indexOf('\n') + 1 || rest.length).trim();
44
+ continue;
45
+ }
46
+ if (rest[0] !== '{' && rest[0] !== '[') {
47
+ throw new ToolError('paste_parse', `Could not understand this part of the pasted input: "${firstLine.slice(0, 60)}"`, 'Paste a cURL command, a URL, or JSON samples. Label JSON with a "request:" or "response:" line when it is not obvious.');
48
+ }
49
+ const end = matchingBracket(rest);
50
+ const chunk = rest.slice(0, end + 1);
51
+ try {
52
+ out.push({ label, value: JSON.parse(chunk) });
53
+ }
54
+ catch (e) {
55
+ throw new ToolError('paste_parse', `A pasted JSON block is not valid JSON: ${e.message}`);
56
+ }
57
+ label = null;
58
+ rest = rest.slice(end + 1).trim();
59
+ }
60
+ return out;
61
+ }
62
+ function matchingBracket(text) {
63
+ let depth = 0;
64
+ let inString = false;
65
+ for (let i = 0; i < text.length; i++) {
66
+ const c = text[i];
67
+ if (inString) {
68
+ if (c === '\\')
69
+ i++;
70
+ else if (c === '"')
71
+ inString = false;
72
+ }
73
+ else if (c === '"')
74
+ inString = true;
75
+ else if (c === '{' || c === '[')
76
+ depth++;
77
+ else if (c === '}' || c === ']') {
78
+ depth--;
79
+ if (depth === 0)
80
+ return i;
81
+ }
82
+ }
83
+ throw new ToolError('paste_parse', 'A pasted JSON block is not closed.');
84
+ }
85
+ export function detectPaste(input) {
86
+ const text = input.replace(/\r\n/g, '\n').trim();
87
+ const result = { responseSamples: [] };
88
+ if (!text)
89
+ throw new ToolError('paste_empty', 'Nothing was pasted.');
90
+ let jsonPart = text;
91
+ if (/^curl\s/.test(text)) {
92
+ const lines = splitOutsideQuotes(text);
93
+ let cut = lines.length;
94
+ for (let i = 1; i < lines.length; i++) {
95
+ const prev = lines[i - 1].trimEnd();
96
+ const t = lines[i].trim();
97
+ if (prev.endsWith('\\'))
98
+ continue;
99
+ if (t === '' || t.startsWith('{') || t.startsWith('[') || REQUEST_LABEL.test(t) || RESPONSE_LABEL.test(t)) {
100
+ cut = i;
101
+ break;
102
+ }
103
+ if (t.startsWith('-'))
104
+ continue; // a flag on its own line without a trailing backslash
105
+ cut = i;
106
+ break;
107
+ }
108
+ result.curl = lines.slice(0, cut).join('\n');
109
+ jsonPart = lines.slice(cut).join('\n');
110
+ }
111
+ else {
112
+ const firstLine = text.split('\n', 1)[0].trim();
113
+ const urlMatch = firstLine.match(/^(?:(GET|POST|PUT|PATCH|DELETE)\s+)?(https?:\/\/\S+)$/i);
114
+ if (urlMatch) {
115
+ result.url = firstLine;
116
+ jsonPart = text.slice(firstLine.length);
117
+ }
118
+ }
119
+ for (const block of readJsonBlocks(jsonPart)) {
120
+ if (block.label === 'request') {
121
+ if (result.requestSample !== undefined)
122
+ throw new ToolError('paste_parse', 'More than one request sample was pasted.');
123
+ result.requestSample = block.value;
124
+ }
125
+ else {
126
+ result.responseSamples.push(block.value);
127
+ }
128
+ }
129
+ return result;
130
+ }
@@ -0,0 +1,292 @@
1
+ // codz's minimal Android project: what Android Studio's "Empty Activity"
2
+ // template gives you (Kotlin, Jetpack Compose, a version catalog), plus
3
+ // Navigation Compose with a Home screen so screens can be added right away.
4
+ // Android has no command-line project creator, so create always uses this.
5
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { runTool } from "../core/proc.js";
10
+ export const ANDROID_SKELETON_VERSIONS = {
11
+ agp: '8.7.3',
12
+ kotlin: '2.0.21',
13
+ gradle: '8.11.1',
14
+ coreKtx: '1.15.0',
15
+ lifecycle: '2.8.7',
16
+ activityCompose: '1.9.3',
17
+ composeBom: '2024.10.01',
18
+ navigationCompose: '2.8.4',
19
+ junit: '4.13.2',
20
+ };
21
+ /** com.acme + shop-app → com.acme.shopapp */
22
+ export const androidPackage = (org, name) => `${org}.${name.replace(/[^a-z0-9]/g, '')}`;
23
+ const title = (name) => name.split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
24
+ const pascalName = (name) => title(name).replace(/\s/g, '');
25
+ export function androidSkeleton(o) {
26
+ const v = ANDROID_SKELETON_VERSIONS;
27
+ const pkg = androidPackage(o.org, o.name);
28
+ const dir = `app/src/main/java/${pkg.replace(/\./g, '/')}`;
29
+ const theme = `${pascalName(o.name)}Theme`;
30
+ const primary = (o.primary ?? '#6750A4').replace(/^#/, '').toUpperCase();
31
+ const f = new Map();
32
+ f.set('settings.gradle.kts', `pluginManagement {
33
+ repositories {
34
+ google {
35
+ content {
36
+ includeGroupByRegex("com\\\\.android.*")
37
+ includeGroupByRegex("com\\\\.google.*")
38
+ includeGroupByRegex("androidx.*")
39
+ }
40
+ }
41
+ mavenCentral()
42
+ gradlePluginPortal()
43
+ }
44
+ }
45
+ dependencyResolutionManagement {
46
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
47
+ repositories {
48
+ google()
49
+ mavenCentral()
50
+ }
51
+ }
52
+
53
+ rootProject.name = "${title(o.name)}"
54
+ include(":app")
55
+ `);
56
+ f.set('build.gradle.kts', `// Top-level build file: plugins are declared here and applied in the modules.
57
+ plugins {
58
+ alias(libs.plugins.android.application) apply false
59
+ alias(libs.plugins.kotlin.android) apply false
60
+ alias(libs.plugins.kotlin.compose) apply false
61
+ }
62
+ `);
63
+ f.set('gradle/libs.versions.toml', `[versions]
64
+ agp = "${v.agp}"
65
+ kotlin = "${v.kotlin}"
66
+ core-ktx = "${v.coreKtx}"
67
+ lifecycle = "${v.lifecycle}"
68
+ activity-compose = "${v.activityCompose}"
69
+ compose-bom = "${v.composeBom}"
70
+ navigation-compose = "${v.navigationCompose}"
71
+ junit = "${v.junit}"
72
+
73
+ [libraries]
74
+ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
75
+ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
76
+ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
77
+ androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
78
+ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
79
+ androidx-ui = { group = "androidx.compose.ui", name = "ui" }
80
+ androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
81
+ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
82
+ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
83
+ androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
84
+ androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
85
+ junit = { group = "junit", name = "junit", version.ref = "junit" }
86
+
87
+ [plugins]
88
+ android-application = { id = "com.android.application", version.ref = "agp" }
89
+ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
90
+ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
91
+ `);
92
+ f.set('gradle.properties', 'org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.useAndroidX=true\nkotlin.code.style=official\nandroid.nonTransitiveRClass=true\n');
93
+ f.set('gradle/wrapper/gradle-wrapper.properties', `distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${v.gradle}-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n`);
94
+ f.set('.gitignore', '*.iml\n.gradle/\n/local.properties\n/.idea/\n.DS_Store\n/build/\n/app/build/\n/captures/\n.externalNativeBuild/\n.cxx/\n.kotlin/\n');
95
+ f.set('app/.gitignore', '/build\n');
96
+ f.set('app/proguard-rules.pro', '# Project-specific ProGuard rules go here.\n');
97
+ f.set('app/build.gradle.kts', `plugins {
98
+ alias(libs.plugins.android.application)
99
+ alias(libs.plugins.kotlin.android)
100
+ alias(libs.plugins.kotlin.compose)
101
+ }
102
+
103
+ android {
104
+ namespace = "${pkg}"
105
+ compileSdk = 35
106
+
107
+ defaultConfig {
108
+ applicationId = "${pkg}"
109
+ minSdk = 24
110
+ targetSdk = 35
111
+ versionCode = 1
112
+ versionName = "1.0"
113
+ }
114
+
115
+ buildTypes {
116
+ release {
117
+ isMinifyEnabled = false
118
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
119
+ }
120
+ }
121
+ compileOptions {
122
+ sourceCompatibility = JavaVersion.VERSION_11
123
+ targetCompatibility = JavaVersion.VERSION_11
124
+ }
125
+ kotlinOptions {
126
+ jvmTarget = "11"
127
+ }
128
+ buildFeatures {
129
+ compose = true
130
+ }
131
+ }
132
+
133
+ dependencies {
134
+ implementation(libs.androidx.core.ktx)
135
+ implementation(libs.androidx.lifecycle.runtime.ktx)
136
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
137
+ implementation(libs.androidx.activity.compose)
138
+ implementation(platform(libs.androidx.compose.bom))
139
+ implementation(libs.androidx.ui)
140
+ implementation(libs.androidx.ui.graphics)
141
+ implementation(libs.androidx.ui.tooling.preview)
142
+ implementation(libs.androidx.material3)
143
+ implementation(libs.androidx.navigation.compose)
144
+ debugImplementation(libs.androidx.ui.tooling)
145
+ testImplementation(libs.junit)
146
+ }
147
+ `);
148
+ f.set('app/src/main/AndroidManifest.xml', `<?xml version="1.0" encoding="utf-8"?>
149
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
150
+
151
+ <application
152
+ android:allowBackup="true"
153
+ android:label="@string/app_name"
154
+ android:supportsRtl="true"
155
+ android:theme="@style/Theme.${pascalName(o.name)}">
156
+ <activity
157
+ android:name=".MainActivity"
158
+ android:exported="true"
159
+ android:theme="@style/Theme.${pascalName(o.name)}">
160
+ <intent-filter>
161
+ <action android:name="android.intent.action.MAIN" />
162
+
163
+ <category android:name="android.intent.category.LAUNCHER" />
164
+ </intent-filter>
165
+ </activity>
166
+ </application>
167
+
168
+ </manifest>
169
+ `);
170
+ f.set('app/src/main/res/values/strings.xml', `<resources>\n <string name="app_name">${title(o.name)}</string>\n</resources>\n`);
171
+ f.set('app/src/main/res/values/themes.xml', `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <style name="Theme.${pascalName(o.name)}" parent="android:Theme.Material.Light.NoActionBar" />\n</resources>\n`);
172
+ f.set(`${dir}/MainActivity.kt`, `package ${pkg}
173
+
174
+ import android.os.Bundle
175
+ import androidx.activity.ComponentActivity
176
+ import androidx.activity.compose.setContent
177
+ import androidx.activity.enableEdgeToEdge
178
+ import ${pkg}.ui.navigation.AppNavHost
179
+ import ${pkg}.ui.theme.${theme}
180
+
181
+ class MainActivity : ComponentActivity() {
182
+ override fun onCreate(savedInstanceState: Bundle?) {
183
+ super.onCreate(savedInstanceState)
184
+ enableEdgeToEdge()
185
+ setContent {
186
+ ${theme} {
187
+ AppNavHost()
188
+ }
189
+ }
190
+ }
191
+ }
192
+ `);
193
+ f.set(`${dir}/ui/navigation/AppNavHost.kt`, `package ${pkg}.ui.navigation
194
+
195
+ import androidx.compose.runtime.Composable
196
+ import androidx.navigation.NavHostController
197
+ import androidx.navigation.compose.NavHost
198
+ import androidx.navigation.compose.composable
199
+ import androidx.navigation.compose.rememberNavController
200
+ import ${pkg}.ui.screens.HomeScreen
201
+
202
+ /** Every screen of the app. codz screen add and route add register new screens here. */
203
+ @Composable
204
+ fun AppNavHost(navController: NavHostController = rememberNavController()) {
205
+ NavHost(navController = navController, startDestination = "home") {
206
+ composable("home") {
207
+ HomeScreen()
208
+ }
209
+ }
210
+ }
211
+ `);
212
+ f.set(`${dir}/ui/screens/HomeScreen.kt`, `package ${pkg}.ui.screens
213
+
214
+ import androidx.compose.foundation.layout.Box
215
+ import androidx.compose.foundation.layout.fillMaxSize
216
+ import androidx.compose.foundation.layout.padding
217
+ import androidx.compose.material3.Scaffold
218
+ import androidx.compose.material3.Text
219
+ import androidx.compose.runtime.Composable
220
+ import androidx.compose.ui.Alignment
221
+ import androidx.compose.ui.Modifier
222
+
223
+ @Composable
224
+ fun HomeScreen() {
225
+ Scaffold { padding ->
226
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
227
+ Text("${title(o.name)}")
228
+ }
229
+ }
230
+ }
231
+ `);
232
+ f.set(`${dir}/ui/theme/Color.kt`, `package ${pkg}.ui.theme\n\nimport androidx.compose.ui.graphics.Color\n\nval Primary = Color(0xFF${primary})\n`);
233
+ f.set(`${dir}/ui/theme/Theme.kt`, `package ${pkg}.ui.theme
234
+
235
+ import androidx.compose.foundation.isSystemInDarkTheme
236
+ import androidx.compose.material3.MaterialTheme
237
+ import androidx.compose.material3.darkColorScheme
238
+ import androidx.compose.material3.lightColorScheme
239
+ import androidx.compose.runtime.Composable
240
+
241
+ @Composable
242
+ fun ${theme}(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
243
+ val colors = if (darkTheme) darkColorScheme(primary = Primary) else lightColorScheme(primary = Primary)
244
+ MaterialTheme(colorScheme = colors, content = content)
245
+ }
246
+ `);
247
+ return f;
248
+ }
249
+ /** gradlew, gradlew.bat and gradle-wrapper.jar shipped with codz (assets/gradle-wrapper). */
250
+ const BUNDLED_WRAPPER = fileURLToPath(new URL('../../assets/gradle-wrapper/', import.meta.url));
251
+ /**
252
+ * Adds gradlew, gradlew.bat and the wrapper jar: the copies shipped with codz,
253
+ * so Gradle does not need to be installed (the wrapper downloads the version in
254
+ * gradle-wrapper.properties on first run). Falls back to a local Gradle, run in
255
+ * an empty folder so it does not try to resolve the project's plugins. Returns
256
+ * false when neither is available.
257
+ */
258
+ export function addGradleWrapper(dir) {
259
+ if (['gradlew', 'gradlew.bat', 'gradle-wrapper.jar'].every((f) => existsSync(join(BUNDLED_WRAPPER, f)))) {
260
+ mkdirSync(join(dir, 'gradle/wrapper'), { recursive: true });
261
+ copyFileSync(join(BUNDLED_WRAPPER, 'gradlew'), join(dir, 'gradlew'));
262
+ copyFileSync(join(BUNDLED_WRAPPER, 'gradlew.bat'), join(dir, 'gradlew.bat'));
263
+ copyFileSync(join(BUNDLED_WRAPPER, 'gradle-wrapper.jar'), join(dir, 'gradle/wrapper/gradle-wrapper.jar'));
264
+ try {
265
+ chmodSync(join(dir, 'gradlew'), 0o755);
266
+ }
267
+ catch {
268
+ // Windows.
269
+ }
270
+ return true;
271
+ }
272
+ const tmp = mkdtempSync(join(tmpdir(), 'codz-wrapper-'));
273
+ try {
274
+ writeFileSync(join(tmp, 'settings.gradle.kts'), 'rootProject.name = "wrapper"\n');
275
+ const r = runTool('gradle', ['wrapper', '--gradle-version', ANDROID_SKELETON_VERSIONS.gradle, '--distribution-type', 'bin', '-q', '--offline'], { cwd: tmp, timeout: 5 * 60_000 });
276
+ if (r.status !== 0 || !existsSync(join(tmp, 'gradlew')))
277
+ return false;
278
+ mkdirSync(join(dir, 'gradle/wrapper'), { recursive: true });
279
+ for (const p of ['gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar'])
280
+ copyFileSync(join(tmp, p), join(dir, p));
281
+ try {
282
+ chmodSync(join(dir, 'gradlew'), 0o755);
283
+ }
284
+ catch {
285
+ // Windows.
286
+ }
287
+ return true;
288
+ }
289
+ finally {
290
+ rmSync(tmp, { recursive: true, force: true });
291
+ }
292
+ }
@@ -0,0 +1,221 @@
1
+ // test add for Android: JUnit tests for Retrofit interfaces against OkHttp's
2
+ // MockWebServer, so they run on the JVM without a device or network.
3
+ //
4
+ // One test class per API interface (app/src/test/java/<package>/<Api>Test.kt).
5
+ // Each test enqueues example JSON built from the models, calls the method, and
6
+ // checks the request method and path and that the body parsed. A second test
7
+ // per interface checks that an HTTP 500 throws retrofit2.HttpException.
8
+ // testImplementation dependencies (mockwebserver, coroutines-test, junit) are
9
+ // added to the version catalog or build.gradle.kts when missing.
10
+ import { join } from 'node:path';
11
+ import { packageOf } from "../adapters/android.js";
12
+ import { gradleBlockAdd, tomlAdd } from "../adapters/androidSetup.js";
13
+ import { addKotlinImports, kotlinName } from "../adapters/kotlinGen.js";
14
+ import { buildChangeSet } from "../core/changeset.js";
15
+ import { readText } from "../core/fsutil.js";
16
+ import {} from "../core/types.js";
17
+ import { kotlinFun } from "./androidUi.js";
18
+ import { exampleFor } from "./mock.js";
19
+ const TEST_LIBS = [
20
+ { key: 'okhttp-mockwebserver', group: 'com.squareup.okhttp3', name: 'mockwebserver', version: 'okhttp', value: '4.12.0' },
21
+ { key: 'kotlinx-coroutines-test', group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-test', version: 'coroutines', value: '1.9.0' },
22
+ { key: 'junit', group: 'junit', name: 'junit', version: 'junit', value: '4.13.2' },
23
+ ];
24
+ const dirOf = (f) => f.slice(0, f.lastIndexOf('/'));
25
+ /** A Kotlin expression for an example value of a type (models become constructor calls). */
26
+ function kotlinValue(type, value, map, indent) {
27
+ const t = type.replace(/\?$/, '').trim();
28
+ if (value === null || value === undefined)
29
+ return type.endsWith('?') ? 'null' : t === 'String' ? '""' : t === 'Boolean' ? 'false' : /^(Int|Long|Double|Float)$/.test(t) ? '0' : 'TODO()';
30
+ const list = /^List<(.+)>$/.exec(t);
31
+ if (list)
32
+ return Array.isArray(value) && value.length ? `listOf(${value.map((v) => kotlinValue(list[1], v, map, indent)).join(', ')})` : 'emptyList()';
33
+ const model = map.models.find((m) => m.name === t);
34
+ if (model && typeof value === 'object') {
35
+ const inner = `${indent} `;
36
+ const args = model.fields.map((f) => `${inner}${kotlinName(f.name)} = ${kotlinValue(f.type + (f.nullable ? '?' : ''), value[f.name], map, inner)},`);
37
+ return `${t}(\n${args.join('\n')}\n${indent})`;
38
+ }
39
+ if (t === 'String')
40
+ return JSON.stringify(String(value)).replace(/\$/g, '\\$');
41
+ if (t === 'Long')
42
+ return `${Math.trunc(Number(value))}L`;
43
+ if (t === 'Double')
44
+ return Number.isInteger(value) ? `${value}.0` : String(value);
45
+ if (t === 'Float')
46
+ return `${value}f`;
47
+ return String(value);
48
+ }
49
+ function converter(ser) {
50
+ if (ser === 'moshi')
51
+ return { expr: 'MoshiConverterFactory.create(Moshi.Builder().build())', imports: ['com.squareup.moshi.Moshi', 'retrofit2.converter.moshi.MoshiConverterFactory'] };
52
+ if (ser === 'gson')
53
+ return { expr: 'GsonConverterFactory.create()', imports: ['retrofit2.converter.gson.GsonConverterFactory'] };
54
+ return { expr: 'Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType())', imports: ['kotlinx.serialization.json.Json', 'okhttp3.MediaType.Companion.toMediaType', 'retrofit2.converter.kotlinx.serialization.asConverterFactory'] };
55
+ }
56
+ export function androidTests(root, map, eps) {
57
+ const c = map.conventions;
58
+ const srcDir = c.srcDir || 'app/src/main/java';
59
+ const testDir = srcDir.replace(/\/main\//, '/test/');
60
+ const files = new Map();
61
+ const summary = [];
62
+ const warnings = [];
63
+ const read = (p) => files.get(p) ?? readText(join(root, p));
64
+ const conv = converter(c.serialization || 'kotlinx');
65
+ const byFile = new Map();
66
+ for (const ep of eps)
67
+ byFile.set(ep.file, [...(byFile.get(ep.file) ?? []), ep]);
68
+ for (const [apiFile, list] of byFile) {
69
+ const apiText = read(apiFile) ?? '';
70
+ const iface = /\binterface\s+(\w+)/.exec(apiText)?.[1];
71
+ if (!iface) {
72
+ warnings.push(`No interface found in ${apiFile}; skipped ${list.map((e) => e.name).join(', ')}.`);
73
+ continue;
74
+ }
75
+ const pkg = packageOf(srcDir, dirOf(apiFile));
76
+ const testFile = `${testDir}/${pkg.replace(/\./g, '/')}/${iface}Test.kt`;
77
+ const existing = read(testFile);
78
+ const tests = [];
79
+ const imports = new Set();
80
+ for (const ep of list) {
81
+ if (existing && new RegExp(`fun\\s+\`?${ep.name}\\b`).test(existing)) {
82
+ summary.push(`${ep.name} already has a test in ${testFile}; skipped`);
83
+ continue;
84
+ }
85
+ const fun = kotlinFun(apiText, ep.name);
86
+ if (!fun) {
87
+ warnings.push(`Could not read ${ep.name} in ${apiFile}; skipped.`);
88
+ continue;
89
+ }
90
+ const values = new Map();
91
+ const args = [];
92
+ for (const p of fun.params) {
93
+ const name = p.name.replace(/`/g, '');
94
+ if (p.annotation === 'Body') {
95
+ const bodyType = p.type.replace(/\?$/, '');
96
+ const model = map.models.find((m) => m.name === bodyType);
97
+ if (model)
98
+ imports.add(`${packageOf(srcDir, dirOf(model.file))}.${model.name}`);
99
+ args.push(`${p.name} = ${kotlinValue(p.type, exampleFor(bodyType, map, name), map, ' ')}`);
100
+ }
101
+ else if (p.annotation === 'Path') {
102
+ const v = /^(Int|Long)$/.test(p.type) ? '1' : '"abc"';
103
+ values.set(p.wire ?? name, v.replace(/"/g, ''));
104
+ args.push(`${p.name} = ${v}`);
105
+ }
106
+ else if (!p.optional)
107
+ args.push(`${p.name} = ${kotlinValue(p.type, exampleFor(p.type, map, name), map, ' ')}`);
108
+ }
109
+ const unit = fun.returnType === 'Unit';
110
+ const body = unit ? '' : JSON.stringify(exampleFor(fun.returnType, map, ''));
111
+ const rel = /@(?:GET|POST|PUT|PATCH|DELETE|HEAD)\(\s*"([^"]*)"/.exec(apiText.slice(fun.start, fun.end))?.[1] ?? ep.path.replace(/^\//, '');
112
+ const reqPath = '/' + rel.replace(/^\//, '').replace(/\{([^}]+)\}/g, (_m, n) => values.get(n) ?? '1');
113
+ const call = `api.${ep.name}(${args.length > 1 || args.some((a) => a.includes('\n')) ? `\n ${args.join(',\n ')},\n ` : args.join('')})`;
114
+ tests.push([
115
+ ' @Test',
116
+ ` fun ${ep.name}() = runTest {`,
117
+ ` server.enqueue(MockResponse().setResponseCode(200)${unit ? '' : `.setHeader("Content-Type", "application/json").setBody(${JSON.stringify(body).replace(/\$/g, '\\$')})`})`,
118
+ '',
119
+ unit ? ` ${call}` : ` val result = ${call}`,
120
+ '',
121
+ ' val request = server.takeRequest()',
122
+ ` assertEquals("${ep.method}", request.method)`,
123
+ ` assertEquals("${reqPath}", request.path?.substringBefore('?'))`,
124
+ ...(unit ? [] : [Array.isArray(JSON.parse(body)) ? ' assertEquals(2, result.size)' : ' assertNotNull(result)']),
125
+ ' }',
126
+ ].join('\n'));
127
+ summary.push(`Test ${ep.name}(): ${ep.method} ${reqPath}${unit ? '' : ', parses the example body'}`);
128
+ }
129
+ if (!tests.length)
130
+ continue;
131
+ const first = list[0];
132
+ const firstFun = kotlinFun(apiText, first.name);
133
+ if (!existing && firstFun) {
134
+ const args = firstFun.params.filter((p) => p.annotation === 'Path' || (p.annotation !== 'Body' && !p.optional)).map((p) => `${p.name} = ${/^(Int|Long)$/.test(p.type) ? '1' : '"abc"'}`);
135
+ const hasBody = firstFun.params.find((p) => p.annotation === 'Body');
136
+ if (!hasBody) {
137
+ tests.push([
138
+ ' @Test',
139
+ ` fun \`${first.name} throws HttpException on a server error\`() = runTest {`,
140
+ ' server.enqueue(MockResponse().setResponseCode(500))',
141
+ '',
142
+ ` val error = runCatching { api.${first.name}(${args.join(', ')}) }.exceptionOrNull()`,
143
+ '',
144
+ ' assertTrue(error is HttpException)',
145
+ ' assertEquals(500, (error as HttpException).code())',
146
+ ' }',
147
+ ].join('\n'));
148
+ imports.add('retrofit2.HttpException');
149
+ imports.add('org.junit.Assert.assertTrue');
150
+ }
151
+ }
152
+ const allImports = [...imports, ...conv.imports, `${pkg}.${iface}`, 'kotlinx.coroutines.test.runTest', 'okhttp3.mockwebserver.MockResponse', 'okhttp3.mockwebserver.MockWebServer', 'org.junit.After', 'org.junit.Assert.assertEquals', 'org.junit.Assert.assertNotNull', 'org.junit.Before', 'org.junit.Test', 'retrofit2.Retrofit'];
153
+ let text;
154
+ if (existing) {
155
+ const close = existing.lastIndexOf('}');
156
+ text = `${existing.slice(0, close).replace(/\s*$/, '\n')}\n${tests.join('\n\n')}\n}\n`;
157
+ }
158
+ else {
159
+ text = [
160
+ `package ${pkg}`,
161
+ '',
162
+ `class ${iface}Test {`,
163
+ ' private val server = MockWebServer()',
164
+ ` private lateinit var api: ${iface}`,
165
+ '',
166
+ ' @Before',
167
+ ' fun setUp() {',
168
+ ' server.start()',
169
+ ' api = Retrofit.Builder()',
170
+ ' .baseUrl(server.url("/"))',
171
+ ` .addConverterFactory(${conv.expr})`,
172
+ ' .build()',
173
+ ` .create(${iface}::class.java)`,
174
+ ' }',
175
+ '',
176
+ ' @After',
177
+ ' fun tearDown() {',
178
+ ' server.shutdown()',
179
+ ' }',
180
+ '',
181
+ tests.join('\n\n'),
182
+ '}',
183
+ '',
184
+ ].join('\n');
185
+ }
186
+ text = addKotlinImports(text, allImports, pkg);
187
+ // assertNotNull only when used.
188
+ if (!/assertNotNull\(/.test(text.replace(/^import .*$/gm, '')))
189
+ text = text.replace(/^import org\.junit\.Assert\.assertNotNull\n/m, '');
190
+ files.set(testFile, text);
191
+ summary.unshift(`${existing ? 'Update' : 'Create'} ${testFile}`);
192
+ }
193
+ // testImplementation dependencies.
194
+ const module = c.module || 'app';
195
+ const build = [`${module}/build.gradle.kts`].find((f) => read(f) !== null);
196
+ const catalogPath = c.versionCatalog || '';
197
+ const catalog = catalogPath ? read(catalogPath) : null;
198
+ if (files.size && build) {
199
+ let b = read(build);
200
+ if (catalog !== null) {
201
+ let t = tomlAdd(catalog, 'versions', TEST_LIBS.map((l) => [l.version, `"${l.value}"`]));
202
+ t = tomlAdd(t, 'libraries', TEST_LIBS.map((l) => [l.key, `{ group = "${l.group}", name = "${l.name}", version.ref = "${l.version}" }`]));
203
+ if (t !== catalog) {
204
+ files.set(catalogPath, t);
205
+ summary.push(`Update ${catalogPath}: test libraries`);
206
+ }
207
+ b = gradleBlockAdd(b, 'dependencies', TEST_LIBS.map((l) => `testImplementation(libs.${l.key.replace(/-/g, '.')})`));
208
+ }
209
+ else
210
+ b = gradleBlockAdd(b, 'dependencies', TEST_LIBS.map((l) => `testImplementation("${l.group}:${l.name}:${l.value}")`));
211
+ if (b !== read(build)) {
212
+ files.set(build, b);
213
+ summary.push(`Update ${build}: testImplementation MockWebServer, coroutines-test, JUnit`);
214
+ }
215
+ }
216
+ else if (files.size)
217
+ warnings.push(`Add testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0"), kotlinx-coroutines-test and JUnit to your app module.`);
218
+ if (files.size)
219
+ summary.push(`Run them with: ./gradlew :${module}:testDebugUnitTest`);
220
+ return buildChangeSet(root, 'test add', { files, summary, warnings, generated: [] });
221
+ }