codz-cli 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.
- package/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Android adapter (Kotlin). Supported setup: an app module using Retrofit with
|
|
2
|
+
// suspend functions, data classes for models (kotlinx.serialization, Moshi or
|
|
3
|
+
// Gson), and Hilt, Koin or a hand-written service locator for registration;
|
|
4
|
+
// Jetpack Compose with Navigation Compose for screens.
|
|
5
|
+
//
|
|
6
|
+
// Kotlin is edited with brace-aware text edits, like Dart: a method at the end
|
|
7
|
+
// of an interface, a data class per model file, a provider in the network
|
|
8
|
+
// module. Gradle files are edited only by setup (dependencies and plugins).
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { detectEol, exists, isDir, readText, walk, withEol } from "../core/fsutil.js";
|
|
11
|
+
import { ToolError } from "../core/types.js";
|
|
12
|
+
import { pascal, singular } from "../input/naming.js";
|
|
13
|
+
import { emptyMap, fileStats, scanLocales, specRelativePath } from "./common.js";
|
|
14
|
+
import { addKotlinImports, kotlinType, renderDataClass, renderRetrofitFun } from "./kotlinGen.js";
|
|
15
|
+
import { referencedModels, resolveModels } from "./models.js";
|
|
16
|
+
import { androidArchitectures, planAndroidSetup } from "./androidSetup.js";
|
|
17
|
+
import { matchBrace } from "./source.js";
|
|
18
|
+
export const ANDROID_VERSION = '0.1.0';
|
|
19
|
+
export const ANDROID_CONVENTION_DOCS = {
|
|
20
|
+
module: 'the app module folder (usually app)',
|
|
21
|
+
namespace: 'the app package (namespace in build.gradle)',
|
|
22
|
+
srcDir: 'Kotlin source root of the app module',
|
|
23
|
+
http: 'HTTP client: retrofit (supported), ktor or none',
|
|
24
|
+
serialization: 'JSON library: kotlinx, moshi or gson',
|
|
25
|
+
di: 'how API interfaces are registered: hilt, koin, manual or none',
|
|
26
|
+
diFile: 'the Hilt module, Koin module or service locator that creates Retrofit',
|
|
27
|
+
ui: 'compose or views',
|
|
28
|
+
apiFolder: 'where Retrofit interfaces live',
|
|
29
|
+
modelsFolder: 'where data classes for API data live',
|
|
30
|
+
screensFolder: 'where Compose screens live',
|
|
31
|
+
navFile: 'the file with the Compose NavHost',
|
|
32
|
+
baseUrl: 'Retrofit base URL found in the code, if it is a literal',
|
|
33
|
+
versionCatalog: 'gradle/libs.versions.toml, when the project uses one',
|
|
34
|
+
indent: 'indentation used in Kotlin files',
|
|
35
|
+
};
|
|
36
|
+
const dirOf = (f) => f.slice(0, f.lastIndexOf('/'));
|
|
37
|
+
const mostCommon = (xs, fallback) => {
|
|
38
|
+
const n = new Map();
|
|
39
|
+
for (const x of xs)
|
|
40
|
+
n.set(x, (n.get(x) ?? 0) + 1);
|
|
41
|
+
return [...n.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? fallback;
|
|
42
|
+
};
|
|
43
|
+
/** The package a Kotlin file under srcDir belongs to. */
|
|
44
|
+
export function packageOf(srcDir, dir) {
|
|
45
|
+
return dir.slice(srcDir.length + 1).replace(/\//g, '.');
|
|
46
|
+
}
|
|
47
|
+
/** The app module: a folder whose build file applies the Android application plugin. */
|
|
48
|
+
export function findAppModule(root) {
|
|
49
|
+
for (const m of ['app', ...walk(root, '.', { exts: ['build.gradle.kts', 'build.gradle'], maxFiles: 2000 }).map(dirOf).filter((d) => d && !d.includes('/'))]) {
|
|
50
|
+
const t = readText(join(root, m, 'build.gradle.kts')) ?? readText(join(root, m, 'build.gradle'));
|
|
51
|
+
if (t && /com\.android\.application|android\.application/.test(t))
|
|
52
|
+
return m;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const RETROFIT_FUN = /@(GET|POST|PUT|PATCH|DELETE|HEAD)\(\s*"([^"]*)"\s*\)\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:suspend\s+)?fun\s+(\w+)\s*\(/g;
|
|
57
|
+
function parseDataClasses(text, file) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const m of text.matchAll(/\bdata\s+class\s+(\w+)\s*\(/g)) {
|
|
60
|
+
const open = m.index + m[0].length - 1;
|
|
61
|
+
const close = matchBrace(text, open, 'dart');
|
|
62
|
+
if (close === -1)
|
|
63
|
+
continue;
|
|
64
|
+
const fields = [];
|
|
65
|
+
for (const p of text.slice(open + 1, close).split(/,(?![^<(]*[>)])/)) {
|
|
66
|
+
const json = /@(?:SerialName|SerializedName)\(\s*"([^"]+)"\s*\)|@Json\(\s*name\s*=\s*"([^"]+)"\s*\)/.exec(p);
|
|
67
|
+
const f = /\b(?:val|var)\s+`?(\w+)`?\s*:\s*([\w<>?, .]+?)\s*(?:=|$)/.exec(p.replace(/\/\/[^\n]*/g, '').trim());
|
|
68
|
+
if (!f)
|
|
69
|
+
continue;
|
|
70
|
+
const type = f[2].trim();
|
|
71
|
+
fields.push({ name: json?.[1] ?? json?.[2] ?? f[1], type: type.replace(/\?$/, ''), nullable: type.endsWith('?') });
|
|
72
|
+
}
|
|
73
|
+
out.push({ name: m[1], file, fields });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
async function scan(root, configConventions) {
|
|
78
|
+
const map = emptyMap('android', `android@${ANDROID_VERSION}`);
|
|
79
|
+
const files = walk(root, '.', { extraIgnored: ['build', '.gradle', '.idea'] });
|
|
80
|
+
map.stats = fileStats(files);
|
|
81
|
+
const c = {};
|
|
82
|
+
const module = findAppModule(root) ?? 'app';
|
|
83
|
+
c.module = module;
|
|
84
|
+
const buildFile = ['build.gradle.kts', 'build.gradle'].map((f) => `${module}/${f}`).find((f) => exists(join(root, f))) ?? `${module}/build.gradle.kts`;
|
|
85
|
+
const build = readText(join(root, buildFile)) ?? '';
|
|
86
|
+
const catalog = readText(join(root, 'gradle/libs.versions.toml')) ?? '';
|
|
87
|
+
c.versionCatalog = catalog ? 'gradle/libs.versions.toml' : '';
|
|
88
|
+
const manifest = readText(join(root, module, 'src/main/AndroidManifest.xml')) ?? '';
|
|
89
|
+
c.namespace = /namespace\s*=?\s*["']([\w.]+)["']/.exec(build)?.[1] ?? /package\s*=\s*"([\w.]+)"/.exec(manifest)?.[1] ?? '';
|
|
90
|
+
c.srcDir = [`${module}/src/main/kotlin`, `${module}/src/main/java`].find((d) => isDir(join(root, d)) && (!c.namespace || isDir(join(root, d, ...c.namespace.split('.'))))) ?? `${module}/src/main/java`;
|
|
91
|
+
const pkgDir = c.namespace ? `${c.srcDir}/${c.namespace.replace(/\./g, '/')}` : c.srcDir;
|
|
92
|
+
const deps = `${build}\n${catalog}`;
|
|
93
|
+
c.http = /com\.squareup\.retrofit2/.test(deps) ? 'retrofit' : /io\.ktor/.test(deps) ? 'ktor' : 'none';
|
|
94
|
+
c.serialization = /org\.jetbrains\.kotlinx[:"].*serialization|kotlinx-serialization-json/.test(deps) ? 'kotlinx' : /com\.squareup\.moshi/.test(deps) ? 'moshi' : /com\.google\.code\.gson|converter-gson/.test(deps) ? 'gson' : 'kotlinx';
|
|
95
|
+
c.di = /com\.google\.dagger[:"].*hilt|com\.google\.dagger\.hilt\.android/.test(deps) ? 'hilt' : /io\.insert-koin/.test(deps) ? 'koin' : 'none';
|
|
96
|
+
c.ui = /compose\s*=\s*true|androidx\.compose|compose-bom/.test(deps) ? 'compose' : 'views';
|
|
97
|
+
const kt = files.filter((f) => f.startsWith(`${c.srcDir}/`) && f.endsWith('.kt'));
|
|
98
|
+
const text = (f) => readText(join(root, f)) ?? '';
|
|
99
|
+
const apiFiles = kt.filter((f) => /^\s*interface\s+\w+/m.test(text(f)) && RETROFIT_FUN.test(text(f)) && ((RETROFIT_FUN.lastIndex = 0), true));
|
|
100
|
+
c.apiFolder = mostCommon(apiFiles.map(dirOf), `${pkgDir}/data/remote`);
|
|
101
|
+
const modelFiles = kt.filter((f) => /\bdata\s+class\b/.test(text(f)) && /@Serializable|@JsonClass|@SerializedName|@SerialName/.test(text(f)));
|
|
102
|
+
c.modelsFolder = mostCommon(modelFiles.map(dirOf), `${pkgDir}/data/model`);
|
|
103
|
+
for (const f of modelFiles)
|
|
104
|
+
map.models.push(...parseDataClasses(text(f), f));
|
|
105
|
+
const retrofitFile = kt.find((f) => /Retrofit\.Builder\(\)/.test(text(f)));
|
|
106
|
+
c.diFile =
|
|
107
|
+
(c.di === 'hilt' ? kt.find((f) => /@Module\b/.test(text(f)) && /Retrofit/.test(text(f))) : c.di === 'koin' ? kt.find((f) => /\bmodule\s*\{/.test(text(f)) && /Retrofit/.test(text(f))) : retrofitFile) ?? '';
|
|
108
|
+
if (c.di === 'none' && retrofitFile && /\bobject\s+\w+/.test(text(retrofitFile)))
|
|
109
|
+
c.di = 'manual';
|
|
110
|
+
c.baseUrl = retrofitFile ? /\.baseUrl\(\s*"([^"]+)"\s*\)/.exec(text(retrofitFile))?.[1] ?? /BASE_URL\s*=\s*"([^"]+)"/.exec(text(retrofitFile))?.[1] ?? '' : '';
|
|
111
|
+
if (!c.baseUrl)
|
|
112
|
+
for (const f of kt) {
|
|
113
|
+
const m = /\bBASE_URL\s*=\s*"(https?:[^"]+)"/.exec(text(f));
|
|
114
|
+
if (m) {
|
|
115
|
+
c.baseUrl = m[1];
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!c.baseUrl)
|
|
120
|
+
c.baseUrl = /buildConfigField\(\s*"String"\s*,\s*"BASE_URL"\s*,\s*"\\"([^"\\]+)\\""\s*\)/.exec(build)?.[1] ?? '';
|
|
121
|
+
const screens = kt.filter((f) => /@Composable\s+fun\s+\w+Screen\s*\(/.test(text(f)));
|
|
122
|
+
c.screensFolder = mostCommon(screens.map(dirOf), `${pkgDir}/ui/screens`);
|
|
123
|
+
c.navFile = kt.find((f) => /\bNavHost\s*\(/.test(text(f))) ?? '';
|
|
124
|
+
c.indent = /\n {2}\S/.test(kt.slice(0, 10).map(text).join('\n')) && !/\n {4}\S/.test(kt.slice(0, 10).map(text).join('\n')) ? '2' : '4';
|
|
125
|
+
map.conventions = { ...c, ...configConventions };
|
|
126
|
+
const conv = map.conventions;
|
|
127
|
+
if (conv.http !== 'retrofit')
|
|
128
|
+
map.notes.push(conv.http === 'ktor' ? 'This project uses Ktor; api add writes Retrofit interfaces only so far.' : 'No HTTP client found; codz setup adds Retrofit.');
|
|
129
|
+
if (!conv.namespace)
|
|
130
|
+
map.notes.push('No namespace found in the app build file, so package names cannot be worked out.');
|
|
131
|
+
for (const f of apiFiles) {
|
|
132
|
+
const t = text(f);
|
|
133
|
+
for (const m of t.matchAll(RETROFIT_FUN)) {
|
|
134
|
+
const close = matchBrace(t, m.index + m[0].length - 1, 'dart');
|
|
135
|
+
const ret = close === -1 ? '' : /^\s*:\s*([\w<>?, .]+)/.exec(t.slice(close + 1))?.[1]?.trim() ?? 'Unit';
|
|
136
|
+
map.endpoints.push({ name: m[3], method: m[1], path: `/${m[2].replace(/^\//, '')}`, response: ret, file: f, calledFrom: [] });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const f of kt) {
|
|
140
|
+
const t = text(f);
|
|
141
|
+
for (const ep of map.endpoints)
|
|
142
|
+
if (ep.file !== f && new RegExp(`\\.${ep.name}\\s*\\(`).test(t))
|
|
143
|
+
ep.calledFrom.push(f);
|
|
144
|
+
}
|
|
145
|
+
// Compose routes: composable("orders/{id}") { ... }
|
|
146
|
+
for (const f of kt)
|
|
147
|
+
for (const m of text(f).matchAll(/\bcomposable\(\s*(?:route\s*=\s*)?"([^"]+)"/g))
|
|
148
|
+
map.routes.push({ name: m[1].split('/')[0], path: `/${m[1]}`, params: [...m[1].matchAll(/\{(\w+)\}/g)].map((x) => x[1]), file: f });
|
|
149
|
+
map.locales = scanLocales(root, files);
|
|
150
|
+
return map;
|
|
151
|
+
}
|
|
152
|
+
const resourceOf = (path) => path.split('/').filter((s) => s && !s.startsWith('{') && !/^(api|v\d+)$/i.test(s))[0] ?? 'root';
|
|
153
|
+
/** Registers a new Retrofit interface where Retrofit is created. */
|
|
154
|
+
function register(files, read, c, iface, ifacePkg, summary, warnings) {
|
|
155
|
+
const di = c.diFile;
|
|
156
|
+
const text = di ? read(di) : null;
|
|
157
|
+
if (!di || text === null) {
|
|
158
|
+
warnings.push(`No ${c.di === 'none' ? 'Retrofit setup' : `${c.di} module`} was found, so ${iface} is not registered. Create it with retrofit.create(${iface}::class.java), or run codz setup.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const fn = iface[0].toLowerCase() + iface.slice(1);
|
|
162
|
+
let t = text;
|
|
163
|
+
if (new RegExp(`\\b${iface}::class`).test(t))
|
|
164
|
+
return;
|
|
165
|
+
const pkg = /^package\s+([\w.]+)/m.exec(t)?.[1] ?? '';
|
|
166
|
+
if (c.di === 'hilt') {
|
|
167
|
+
const obj = /\bobject\s+\w+\s*\{/.exec(t);
|
|
168
|
+
if (!obj)
|
|
169
|
+
return void warnings.push(`Could not find the Hilt module object in ${di}; add @Provides fun provide${iface}(retrofit: Retrofit): ${iface} = retrofit.create(${iface}::class.java).`);
|
|
170
|
+
const close = matchBrace(t, obj.index + obj[0].length - 1, 'dart');
|
|
171
|
+
t = `${t.slice(0, close).replace(/\s*$/, '\n')}\n @Provides\n @Singleton\n fun provide${iface}(retrofit: Retrofit): ${iface} = retrofit.create(${iface}::class.java)\n${t.slice(close)}`;
|
|
172
|
+
t = addKotlinImports(t, [`${ifacePkg}.${iface}`, 'dagger.Provides', 'javax.inject.Singleton', 'retrofit2.Retrofit'], pkg);
|
|
173
|
+
}
|
|
174
|
+
else if (c.di === 'koin') {
|
|
175
|
+
const singles = [...t.matchAll(/^([ \t]*)single(?:<[^>]*>)?\s*\{[^\n]*\}\s*$/gm)];
|
|
176
|
+
if (!singles.length)
|
|
177
|
+
return void warnings.push(`Could not find a single { } in ${di}; add single { get<Retrofit>().create(${iface}::class.java) }.`);
|
|
178
|
+
const last = singles[singles.length - 1];
|
|
179
|
+
const at = last.index + last[0].length;
|
|
180
|
+
t = `${t.slice(0, at)}\n${last[1]}single { get<Retrofit>().create(${iface}::class.java) }${t.slice(at)}`;
|
|
181
|
+
t = addKotlinImports(t, [`${ifacePkg}.${iface}`, 'retrofit2.Retrofit'], pkg);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const obj = /\bobject\s+\w+\s*\{/.exec(t);
|
|
185
|
+
const retrofitVal = /\bval\s+(\w+)\s*:\s*Retrofit\b/.exec(t)?.[1] ?? /\bval\s+(\w+)\s*=\s*Retrofit\.Builder/.exec(t)?.[1] ?? 'retrofit';
|
|
186
|
+
if (!obj)
|
|
187
|
+
return void warnings.push(`Could not find the service locator object in ${di}; add val ${fn}: ${iface} = retrofit.create(${iface}::class.java).`);
|
|
188
|
+
const close = matchBrace(t, obj.index + obj[0].length - 1, 'dart');
|
|
189
|
+
t = `${t.slice(0, close).replace(/\s*$/, '\n')}\n val ${fn}: ${iface} by lazy { ${retrofitVal}.create(${iface}::class.java) }\n${t.slice(close)}`;
|
|
190
|
+
t = addKotlinImports(t, [`${ifacePkg}.${iface}`], pkg);
|
|
191
|
+
}
|
|
192
|
+
files.set(di, withEol(t, detectEol(text)));
|
|
193
|
+
summary.push(`Update ${di}: register ${iface} with ${c.di === 'manual' ? 'the service locator' : c.di}`);
|
|
194
|
+
}
|
|
195
|
+
async function planEndpoint(ctx, input, overlay, opts = {}) {
|
|
196
|
+
const { root, map } = ctx;
|
|
197
|
+
const c = map.conventions;
|
|
198
|
+
if (c.http !== 'retrofit')
|
|
199
|
+
throw new ToolError('unsupported_setup', c.http === 'ktor' ? 'This project uses Ktor; the Android adapter writes Retrofit interfaces only so far.' : 'This project has no Retrofit yet.', 'Run codz setup to add Retrofit, or add it yourself.');
|
|
200
|
+
if (!c.namespace)
|
|
201
|
+
throw new ToolError('no_package', 'No namespace was found in the app build file, so package names cannot be written.', 'Set namespace in codz.config.yaml.');
|
|
202
|
+
if (input.unwrapData)
|
|
203
|
+
throw new ToolError('unsupported_setup', 'This response wraps its data in {"data": ...}, and a Retrofit method returns the whole body.', 'Run again with --no-unwrap to generate a model for the whole body.');
|
|
204
|
+
const files = new Map(overlay ?? []);
|
|
205
|
+
const read = (p) => files.get(p) ?? readText(join(root, p));
|
|
206
|
+
const summary = [];
|
|
207
|
+
const warnings = [...input.warnings];
|
|
208
|
+
const { spec, reused, replaced, notes } = resolveModels(input, map.models, (n) => n, { replace: opts.replaceModels });
|
|
209
|
+
warnings.push(...notes);
|
|
210
|
+
const srcDir = c.srcDir || 'app/src/main/java';
|
|
211
|
+
// Path relative to the base URL.
|
|
212
|
+
let path = spec.path;
|
|
213
|
+
// Retrofit always has a base URL; an endpoint from an API spec is relative to it (the spec's server stands in for it).
|
|
214
|
+
const fromSpec = specRelativePath(spec, c.baseUrl);
|
|
215
|
+
if (fromSpec !== null) {
|
|
216
|
+
path = fromSpec;
|
|
217
|
+
if (c.baseUrl && !c.baseUrl.endsWith('/'))
|
|
218
|
+
warnings.push(`Retrofit needs the base URL to end with "/" (${c.baseUrl}).`);
|
|
219
|
+
}
|
|
220
|
+
else if (c.baseUrl) {
|
|
221
|
+
try {
|
|
222
|
+
const b = new URL(c.baseUrl);
|
|
223
|
+
const prefix = b.pathname.replace(/\/$/, '');
|
|
224
|
+
if ((!spec.origin || spec.origin === b.origin) && prefix && path.startsWith(prefix))
|
|
225
|
+
path = path.slice(prefix.length) || '/';
|
|
226
|
+
else if (spec.origin && spec.origin !== b.origin) {
|
|
227
|
+
path = `${spec.origin}${spec.path}`;
|
|
228
|
+
warnings.push(`The URL host ${spec.origin} differs from the Retrofit base URL (${c.baseUrl}), so the full URL is used for this call.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Not a literal URL.
|
|
233
|
+
}
|
|
234
|
+
if (!c.baseUrl.endsWith('/'))
|
|
235
|
+
warnings.push(`Retrofit needs the base URL to end with "/" (${c.baseUrl}).`);
|
|
236
|
+
}
|
|
237
|
+
const existing = map.endpoints.find((e) => e.name === spec.name);
|
|
238
|
+
if (existing) {
|
|
239
|
+
if (existing.method === spec.method && existing.path.replace(/\{\w+\}/g, '{}') === path.replace(/\{\w+\}/g, '{}'))
|
|
240
|
+
return { files, summary: [`${spec.name} already exists in ${existing.file}; nothing to do.`], warnings: [], generated: [] };
|
|
241
|
+
throw new ToolError('name_conflict', `A function called ${spec.name} already exists in ${existing.file} for ${existing.method} ${existing.path}.`, 'Pass --name <functionName> to use another name.');
|
|
242
|
+
}
|
|
243
|
+
// Models: one data class per file.
|
|
244
|
+
const modelFile = new Map();
|
|
245
|
+
for (const [name, info] of reused)
|
|
246
|
+
modelFile.set(name, info.file);
|
|
247
|
+
const modelsPkg = packageOf(srcDir, c.modelsFolder || `${srcDir}/${c.namespace.replace(/\./g, '/')}/data/model`);
|
|
248
|
+
for (const m of spec.models) {
|
|
249
|
+
const file = replaced.get(m.name)?.file ?? `${c.modelsFolder}/${m.name}.kt`;
|
|
250
|
+
modelFile.set(m.name, file);
|
|
251
|
+
const prev = read(file);
|
|
252
|
+
if (prev !== null && !replaced.has(m.name))
|
|
253
|
+
throw new ToolError('file_exists', `${file} already exists but does not define ${m.name}.`, 'Pass --model-name <Name> to use another name.');
|
|
254
|
+
let content = renderDataClass(m, c, packageOf(srcDir, dirOf(file)));
|
|
255
|
+
const deps = new Set();
|
|
256
|
+
for (const f of m.fields)
|
|
257
|
+
referencedModels(f.type, deps);
|
|
258
|
+
content = addKotlinImports(content, [...deps].map((d) => modelFile.get(d)).filter((f) => !!f && dirOf(f) !== dirOf(file)).map((f) => `${packageOf(srcDir, dirOf(f))}.${f.slice(f.lastIndexOf('/') + 1, -3)}`), packageOf(srcDir, dirOf(file)));
|
|
259
|
+
if (prev !== null && replaced.has(m.name)) {
|
|
260
|
+
// Rewrite just this class, keeping the rest of the file.
|
|
261
|
+
const start = prev.search(new RegExp(`(?:@\\w+(?:\\([^)]*\\))?\\s*\\n)*data\\s+class\\s+${m.name}\\b`));
|
|
262
|
+
const open = prev.indexOf('(', start);
|
|
263
|
+
const close = matchBrace(prev, open, 'dart');
|
|
264
|
+
let end = close + 1;
|
|
265
|
+
if (prev[end] === ' ' && prev[end + 1] === '{')
|
|
266
|
+
end = matchBrace(prev, end + 1, 'dart') + 1;
|
|
267
|
+
const cls = content.slice(content.search(/^(@Serializable|@JsonClass|data class)/m)).replace(/\n$/, '');
|
|
268
|
+
content = addKotlinImports(prev.slice(0, start) + cls + prev.slice(end), [...content.matchAll(/^import\s+([\w.]+)/gm)].map((x) => x[1]), packageOf(srcDir, dirOf(file)));
|
|
269
|
+
}
|
|
270
|
+
files.set(file, content);
|
|
271
|
+
summary.push(`${prev === null ? 'Create' : 'Update'} ${file}: data class ${m.name}${replaced.has(m.name) ? ' (rewritten)' : ''}`);
|
|
272
|
+
}
|
|
273
|
+
void modelsPkg;
|
|
274
|
+
// Retrofit interface.
|
|
275
|
+
const resource = resourceOf(spec.path);
|
|
276
|
+
const iface = `${pascal(singular(resource))}Api`;
|
|
277
|
+
const fromEndpoints = map.endpoints.find((e) => resourceOf(e.path) === resource)?.file;
|
|
278
|
+
let apiFile = opts.targetFile ?? fromEndpoints ?? `${c.apiFolder}/${iface}.kt`;
|
|
279
|
+
const apiPkg = packageOf(srcDir, dirOf(apiFile));
|
|
280
|
+
let apiText = read(apiFile);
|
|
281
|
+
const created = apiText === null;
|
|
282
|
+
if (created)
|
|
283
|
+
apiText = `package ${apiPkg}\n\ninterface ${iface} {\n}\n`;
|
|
284
|
+
const ifaceName = /\binterface\s+(\w+)/.exec(apiText)?.[1] ?? iface;
|
|
285
|
+
const fun = renderRetrofitFun(spec, c, path);
|
|
286
|
+
const head = /\binterface\s+\w+[^{]*\{/.exec(apiText);
|
|
287
|
+
const close = matchBrace(apiText, head.index + head[0].length - 1, 'dart');
|
|
288
|
+
const before = apiText.slice(0, close).replace(/\s*$/, '');
|
|
289
|
+
apiText = `${before}${before.endsWith('{') ? '\n' : '\n\n'}${fun.code}\n${apiText.slice(close)}`;
|
|
290
|
+
const typeRefs = new Set();
|
|
291
|
+
referencedModels(spec.response, typeRefs);
|
|
292
|
+
referencedModels(spec.request, typeRefs);
|
|
293
|
+
const modelImports = [...typeRefs].map((t) => modelFile.get(t) ?? map.models.find((m) => m.name === t)?.file).filter((f) => !!f).map((f) => `${packageOf(srcDir, dirOf(f))}.${f.slice(f.lastIndexOf('/') + 1, -3)}`);
|
|
294
|
+
apiText = addKotlinImports(apiText, [...fun.imports, ...modelImports], apiPkg);
|
|
295
|
+
files.set(apiFile, apiText);
|
|
296
|
+
summary.push(`${created ? 'Create' : 'Update'} ${apiFile}: ${ifaceName}.${spec.name}() for ${spec.method} ${spec.path}`);
|
|
297
|
+
if (created)
|
|
298
|
+
register(files, read, c, ifaceName, apiPkg, summary, warnings);
|
|
299
|
+
void apiFile;
|
|
300
|
+
return {
|
|
301
|
+
files,
|
|
302
|
+
summary,
|
|
303
|
+
warnings,
|
|
304
|
+
generated: [{ skill: 'api add', file: apiFile, symbol: spec.name, inputHash: '' }],
|
|
305
|
+
endpoints: [{ name: spec.name, method: spec.method, path: `/${path.replace(/^\//, '')}`, response: spec.method === 'DELETE' || spec.method === 'HEAD' ? 'Unit' : spec.response ? kotlinType(spec.response, c) : 'Any', file: apiFile, calledFrom: [] }],
|
|
306
|
+
models: spec.models.map((m) => ({ name: m.name, file: modelFile.get(m.name), fields: m.fields.map((f) => ({ name: f.jsonName, type: kotlinType(f.type, c), nullable: f.nullable })) })),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
export const androidAdapter = {
|
|
310
|
+
id: 'android',
|
|
311
|
+
version: ANDROID_VERSION,
|
|
312
|
+
capabilities: { explain: true, apiAdd: true },
|
|
313
|
+
supportedSetup: 'Android (Kotlin): Retrofit with suspend functions; kotlinx.serialization, Moshi or Gson data classes; Hilt, Koin or a service locator; screens in Jetpack Compose (Navigation Compose) or XML Views (Activities). Not yet: Ktor, Java sources, Fragments.',
|
|
314
|
+
scan,
|
|
315
|
+
planEndpoint,
|
|
316
|
+
architectures: androidArchitectures,
|
|
317
|
+
planSetup: planAndroidSetup,
|
|
318
|
+
afterSetup: () => null,
|
|
319
|
+
formatCommand: () => null,
|
|
320
|
+
verifyCommand: () => null,
|
|
321
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Android `setup`: Retrofit, OkHttp and kotlinx.serialization for an app that
|
|
2
|
+
// has no API layer yet, registered one of three ways:
|
|
3
|
+
//
|
|
4
|
+
// retrofit-hilt NetworkModule (@Module @InstallIn(SingletonComponent)),
|
|
5
|
+
// @HiltAndroidApp application, @AndroidEntryPoint activity
|
|
6
|
+
// retrofit-koin networkModule = module { ... }, startKoin in the application
|
|
7
|
+
// retrofit ServiceLocator object (no DI library)
|
|
8
|
+
//
|
|
9
|
+
// Gradle: dependencies and plugins go into gradle/libs.versions.toml and the
|
|
10
|
+
// module's build.gradle.kts when the project uses a version catalog (new
|
|
11
|
+
// Android Studio projects do), otherwise straight into build.gradle.kts.
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { readText } from "../core/fsutil.js";
|
|
14
|
+
import { ToolError } from "../core/types.js";
|
|
15
|
+
import { addKotlinImports } from "./kotlinGen.js";
|
|
16
|
+
/** Versions known to work together (Kotlin 2.0, AGP 8.7). */
|
|
17
|
+
export const ANDROID_VERSIONS = {
|
|
18
|
+
kotlin: '2.0.21',
|
|
19
|
+
ksp: '2.0.21-1.0.28',
|
|
20
|
+
retrofit: '2.11.0',
|
|
21
|
+
okhttp: '4.12.0',
|
|
22
|
+
serialization: '1.7.3',
|
|
23
|
+
hilt: '2.52',
|
|
24
|
+
hiltNavigationCompose: '1.2.0',
|
|
25
|
+
koin: '4.0.0',
|
|
26
|
+
lifecycle: '2.8.7',
|
|
27
|
+
navigationCompose: '2.8.4',
|
|
28
|
+
};
|
|
29
|
+
const LIBS = {
|
|
30
|
+
retrofit: { key: 'retrofit', coord: 'com.squareup.retrofit2:retrofit', version: 'retrofit' },
|
|
31
|
+
retrofitKotlinx: { key: 'retrofit-kotlinx-serialization', coord: 'com.squareup.retrofit2:converter-kotlinx-serialization', version: 'retrofit' },
|
|
32
|
+
okhttpLogging: { key: 'okhttp-logging', coord: 'com.squareup.okhttp3:logging-interceptor', version: 'okhttp' },
|
|
33
|
+
serialization: { key: 'kotlinx-serialization-json', coord: 'org.jetbrains.kotlinx:kotlinx-serialization-json', version: 'serialization' },
|
|
34
|
+
hilt: { key: 'hilt-android', coord: 'com.google.dagger:hilt-android', version: 'hilt' },
|
|
35
|
+
hiltCompiler: { key: 'hilt-compiler', coord: 'com.google.dagger:hilt-compiler', version: 'hilt', config: 'ksp' },
|
|
36
|
+
hiltNav: { key: 'hilt-navigation-compose', coord: 'androidx.hilt:hilt-navigation-compose', version: 'hiltNavigationCompose' },
|
|
37
|
+
koin: { key: 'koin-android', coord: 'io.insert-koin:koin-android', version: 'koin' },
|
|
38
|
+
koinCompose: { key: 'koin-androidx-compose', coord: 'io.insert-koin:koin-androidx-compose', version: 'koin' },
|
|
39
|
+
viewModelCompose: { key: 'androidx-lifecycle-viewmodel-compose', coord: 'androidx.lifecycle:lifecycle-viewmodel-compose', version: 'lifecycle' },
|
|
40
|
+
navigationCompose: { key: 'androidx-navigation-compose', coord: 'androidx.navigation:navigation-compose', version: 'navigationCompose' },
|
|
41
|
+
};
|
|
42
|
+
const PLUGINS = {
|
|
43
|
+
serialization: { key: 'kotlin-serialization', id: 'org.jetbrains.kotlin.plugin.serialization', version: 'kotlin', root: true },
|
|
44
|
+
hilt: { key: 'hilt', id: 'com.google.dagger.hilt.android', version: 'hilt', root: true },
|
|
45
|
+
ksp: { key: 'ksp', id: 'com.google.devtools.ksp', version: 'ksp', root: true },
|
|
46
|
+
};
|
|
47
|
+
export function androidArchitectures(map) {
|
|
48
|
+
if (map.endpoints.length > 0 || map.conventions.http === 'retrofit')
|
|
49
|
+
return [];
|
|
50
|
+
return [
|
|
51
|
+
{ id: 'retrofit-hilt', label: 'Retrofit + Hilt', description: 'NetworkModule provides Retrofit and every API interface; @HiltAndroidApp', recommended: true },
|
|
52
|
+
{ id: 'retrofit-koin', label: 'Retrofit + Koin', description: 'networkModule with single { } definitions; startKoin in the Application class', recommended: false },
|
|
53
|
+
{ id: 'retrofit', label: 'Retrofit only', description: 'A ServiceLocator object holds Retrofit and the API interfaces; no DI library', recommended: false },
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
const tomlKey = (k) => k.replace(/-/g, '.');
|
|
57
|
+
/** Adds entries to a [section] of libs.versions.toml, unless the key is there. */
|
|
58
|
+
export function tomlAdd(text, section, entries) {
|
|
59
|
+
let t = text;
|
|
60
|
+
const head = new RegExp(`^\\[${section}\\]\\s*$`, 'm').exec(t);
|
|
61
|
+
if (!head)
|
|
62
|
+
t = `${t.replace(/\s*$/, '\n')}\n[${section}]\n`;
|
|
63
|
+
for (const [k, v] of entries) {
|
|
64
|
+
const h = new RegExp(`^\\[${section}\\]\\s*$`, 'm').exec(t);
|
|
65
|
+
const next = t.slice(h.index + h[0].length).search(/^\[/m);
|
|
66
|
+
const at = next === -1 ? t.length : h.index + h[0].length + next;
|
|
67
|
+
// Only this section counts: [versions] often has a key with the same name.
|
|
68
|
+
if (new RegExp(`^${k.replace(/[.-]/g, '[.-]')}\\s*=`, 'm').test(t.slice(h.index, at)))
|
|
69
|
+
continue;
|
|
70
|
+
const before = t.slice(0, at).replace(/\n*$/, '\n');
|
|
71
|
+
t = `${before}${k} = ${v}\n${next === -1 ? '' : '\n'}${t.slice(at).replace(/^\n+/, '')}`;
|
|
72
|
+
}
|
|
73
|
+
return t;
|
|
74
|
+
}
|
|
75
|
+
/** Adds lines to the first `name { ... }` block of a Gradle Kotlin script. */
|
|
76
|
+
export function gradleBlockAdd(text, block, lines) {
|
|
77
|
+
const m = new RegExp(`^${block}\\s*\\{`, 'm').exec(text);
|
|
78
|
+
const want = lines.filter((l) => !text.includes(l.trim()));
|
|
79
|
+
if (!want.length)
|
|
80
|
+
return text;
|
|
81
|
+
if (!m)
|
|
82
|
+
return `${text.replace(/\s*$/, '\n')}\n${block} {\n${want.map((l) => ` ${l}`).join('\n')}\n}\n`;
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let i = m.index + m[0].length - 1;
|
|
85
|
+
for (; i < text.length; i++) {
|
|
86
|
+
if (text[i] === '{')
|
|
87
|
+
depth++;
|
|
88
|
+
else if (text[i] === '}' && --depth === 0)
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const before = text.slice(0, i).replace(/\s*$/, '\n');
|
|
92
|
+
return `${before}${want.map((l) => ` ${l}`).join('\n')}\n${text.slice(i)}`;
|
|
93
|
+
}
|
|
94
|
+
function networkFile(arch, pkg) {
|
|
95
|
+
const imports = ['kotlinx.serialization.json.Json', 'okhttp3.MediaType.Companion.toMediaType', 'okhttp3.OkHttpClient', 'okhttp3.logging.HttpLoggingInterceptor', 'retrofit2.Retrofit', 'retrofit2.converter.kotlinx.serialization.asConverterFactory'];
|
|
96
|
+
const logging = 'HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }';
|
|
97
|
+
let body;
|
|
98
|
+
if (arch === 'retrofit-hilt') {
|
|
99
|
+
imports.push('dagger.Module', 'dagger.Provides', 'dagger.hilt.InstallIn', 'dagger.hilt.components.SingletonComponent', 'javax.inject.Singleton');
|
|
100
|
+
body = [
|
|
101
|
+
'/** Creates Retrofit and every API interface. codz api add adds a provider here for each new interface. */',
|
|
102
|
+
'@Module',
|
|
103
|
+
'@InstallIn(SingletonComponent::class)',
|
|
104
|
+
'object NetworkModule {',
|
|
105
|
+
' @Provides',
|
|
106
|
+
' @Singleton',
|
|
107
|
+
' fun provideJson(): Json = Json { ignoreUnknownKeys = true }',
|
|
108
|
+
'',
|
|
109
|
+
' @Provides',
|
|
110
|
+
' @Singleton',
|
|
111
|
+
' fun provideOkHttpClient(): OkHttpClient =',
|
|
112
|
+
' OkHttpClient.Builder()',
|
|
113
|
+
` .addInterceptor(${logging})`,
|
|
114
|
+
' .build()',
|
|
115
|
+
'',
|
|
116
|
+
' @Provides',
|
|
117
|
+
' @Singleton',
|
|
118
|
+
' fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =',
|
|
119
|
+
' Retrofit.Builder()',
|
|
120
|
+
' .baseUrl(ApiConfig.BASE_URL)',
|
|
121
|
+
' .client(client)',
|
|
122
|
+
' .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))',
|
|
123
|
+
' .build()',
|
|
124
|
+
'}',
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
else if (arch === 'retrofit-koin') {
|
|
128
|
+
imports.push('org.koin.dsl.module');
|
|
129
|
+
body = [
|
|
130
|
+
'/** Retrofit and every API interface. codz api add adds a single { } here for each new interface. */',
|
|
131
|
+
'val networkModule = module {',
|
|
132
|
+
' single { Json { ignoreUnknownKeys = true } }',
|
|
133
|
+
` single { OkHttpClient.Builder().addInterceptor(${logging}).build() }`,
|
|
134
|
+
' single { Retrofit.Builder().baseUrl(ApiConfig.BASE_URL).client(get<OkHttpClient>()).addConverterFactory(get<Json>().asConverterFactory("application/json".toMediaType())).build() }',
|
|
135
|
+
'}',
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
body = [
|
|
140
|
+
'/** Holds Retrofit and the API interfaces. codz api add adds one val per new interface. */',
|
|
141
|
+
'object ServiceLocator {',
|
|
142
|
+
' private val json = Json { ignoreUnknownKeys = true }',
|
|
143
|
+
'',
|
|
144
|
+
' private val client: OkHttpClient by lazy {',
|
|
145
|
+
` OkHttpClient.Builder().addInterceptor(${logging}).build()`,
|
|
146
|
+
' }',
|
|
147
|
+
'',
|
|
148
|
+
' val retrofit: Retrofit by lazy {',
|
|
149
|
+
' Retrofit.Builder()',
|
|
150
|
+
' .baseUrl(ApiConfig.BASE_URL)',
|
|
151
|
+
' .client(client)',
|
|
152
|
+
' .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))',
|
|
153
|
+
' .build()',
|
|
154
|
+
' }',
|
|
155
|
+
'}',
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
return addKotlinImports([`package ${pkg}`, '', ...body, ''].join('\n'), imports, pkg);
|
|
159
|
+
}
|
|
160
|
+
export function planAndroidSetup(ctx, choice) {
|
|
161
|
+
const { root, map } = ctx;
|
|
162
|
+
if (!['retrofit-hilt', 'retrofit-koin', 'retrofit'].includes(choice.architecture))
|
|
163
|
+
throw new ToolError('bad_args', `Unknown architecture "${choice.architecture}". Choose retrofit-hilt, retrofit-koin or retrofit.`);
|
|
164
|
+
const c0 = map.conventions;
|
|
165
|
+
if (!c0.namespace)
|
|
166
|
+
throw new ToolError('no_package', 'No namespace was found in the app build file, so setup cannot write Kotlin packages.', 'Add namespace = "com.example.app" to the android { } block.');
|
|
167
|
+
const files = new Map();
|
|
168
|
+
const summary = [];
|
|
169
|
+
const warnings = [];
|
|
170
|
+
const read = (p) => files.get(p) ?? readText(join(root, p));
|
|
171
|
+
const module = c0.module || 'app';
|
|
172
|
+
const srcDir = c0.srcDir || `${module}/src/main/java`;
|
|
173
|
+
const pkg = c0.namespace;
|
|
174
|
+
const pkgDir = `${srcDir}/${pkg.replace(/\./g, '/')}`;
|
|
175
|
+
const baseUrl = (choice.baseUrl || 'https://api.example.com').replace(/\/?$/, '/');
|
|
176
|
+
if (!choice.baseUrl)
|
|
177
|
+
warnings.push(`No base URL was given, so ApiConfig.BASE_URL is https://api.example.com/. Change it before calling the API.`);
|
|
178
|
+
const arch = choice.architecture;
|
|
179
|
+
const create = (path, content, what) => {
|
|
180
|
+
if (read(path) !== null)
|
|
181
|
+
throw new ToolError('file_exists', `${path} already exists, so setup stopped without changing anything.`);
|
|
182
|
+
files.set(path, content);
|
|
183
|
+
summary.push(`Create ${path}: ${what}`);
|
|
184
|
+
};
|
|
185
|
+
const remotePkg = `${pkg}.data.remote`;
|
|
186
|
+
const diPkg = `${pkg}.di`;
|
|
187
|
+
create(`${pkgDir}/data/remote/ApiConfig.kt`, `package ${remotePkg}\n\nobject ApiConfig {\n /** Must end with "/": Retrofit paths are relative to it. */\n const val BASE_URL = "${baseUrl}"\n}\n`, `BASE_URL = ${baseUrl}`);
|
|
188
|
+
const netFile = arch === 'retrofit' ? `${pkgDir}/data/remote/ServiceLocator.kt` : `${pkgDir}/di/NetworkModule.kt`;
|
|
189
|
+
create(netFile, addKotlinImports(networkFile(arch, arch === 'retrofit' ? remotePkg : diPkg), arch === 'retrofit' ? [] : [`${remotePkg}.ApiConfig`], arch === 'retrofit' ? remotePkg : diPkg), arch === 'retrofit-hilt' ? 'Hilt NetworkModule: Json, OkHttpClient, Retrofit' : arch === 'retrofit-koin' ? 'Koin networkModule: Json, OkHttpClient, Retrofit' : 'ServiceLocator with Retrofit');
|
|
190
|
+
// Application class and manifest.
|
|
191
|
+
const manifestPath = `${module}/src/main/AndroidManifest.xml`;
|
|
192
|
+
let manifest = read(manifestPath);
|
|
193
|
+
if (arch !== 'retrofit') {
|
|
194
|
+
const appFile = `${pkgDir}/App.kt`;
|
|
195
|
+
const app = arch === 'retrofit-hilt'
|
|
196
|
+
? `package ${pkg}\n\nimport android.app.Application\nimport dagger.hilt.android.HiltAndroidApp\n\n@HiltAndroidApp\nclass App : Application()\n`
|
|
197
|
+
: `package ${pkg}\n\nimport android.app.Application\nimport ${diPkg}.networkModule\nimport org.koin.android.ext.koin.androidContext\nimport org.koin.core.context.startKoin\n\nclass App : Application() {\n override fun onCreate() {\n super.onCreate()\n startKoin {\n androidContext(this@App)\n modules(networkModule)\n }\n }\n}\n`;
|
|
198
|
+
if (manifest && /android:name="\.?\w*App\w*"/.test(manifest.split('<activity')[0]))
|
|
199
|
+
warnings.push(`${manifestPath} already names an Application class; ${arch === 'retrofit-hilt' ? 'annotate it with @HiltAndroidApp' : 'call startKoin { modules(networkModule) } from it'} instead of the new App.kt.`);
|
|
200
|
+
else {
|
|
201
|
+
create(appFile, app, arch === 'retrofit-hilt' ? '@HiltAndroidApp application' : 'application that starts Koin');
|
|
202
|
+
if (manifest)
|
|
203
|
+
manifest = manifest.replace(/<application\b/, '<application\n android:name=".App"');
|
|
204
|
+
}
|
|
205
|
+
if (arch === 'retrofit-hilt') {
|
|
206
|
+
const main = [`${pkgDir}/MainActivity.kt`].find((f) => read(f) !== null);
|
|
207
|
+
// Hilt injects only into ComponentActivity subclasses (AppCompatActivity, FragmentActivity), not android.app.Activity.
|
|
208
|
+
const hiltable = main ? /class\s+MainActivity\s*:\s*(ComponentActivity|AppCompatActivity|FragmentActivity)\b/.test(read(main)) : false;
|
|
209
|
+
if (main && !hiltable && !/@AndroidEntryPoint/.test(read(main)))
|
|
210
|
+
warnings.push(`${main} extends a class Hilt cannot inject into; if it needs injected objects, make it extend ComponentActivity and add @AndroidEntryPoint.`);
|
|
211
|
+
if (main && hiltable && !/@AndroidEntryPoint/.test(read(main))) {
|
|
212
|
+
files.set(main, addKotlinImports(read(main).replace(/^class\s+MainActivity/m, '@AndroidEntryPoint\nclass MainActivity'), ['dagger.hilt.android.AndroidEntryPoint'], pkg));
|
|
213
|
+
summary.push(`Update ${main}: @AndroidEntryPoint`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (manifest !== null) {
|
|
218
|
+
if (!/android\.permission\.INTERNET/.test(manifest))
|
|
219
|
+
manifest = manifest.replace(/(<manifest\b[^>]*>)/, '$1\n\n <uses-permission android:name="android.permission.INTERNET" />');
|
|
220
|
+
if (manifest !== readText(join(root, manifestPath))) {
|
|
221
|
+
files.set(manifestPath, manifest);
|
|
222
|
+
summary.push(`Update ${manifestPath}: INTERNET permission${arch !== 'retrofit' ? ' and android:name=".App"' : ''}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Gradle.
|
|
226
|
+
const libs = [LIBS.retrofit, LIBS.retrofitKotlinx, LIBS.okhttpLogging, LIBS.serialization, ...(c0.ui === 'compose' ? [LIBS.viewModelCompose, LIBS.navigationCompose] : []), ...(arch === 'retrofit-hilt' ? [LIBS.hilt, LIBS.hiltCompiler, ...(c0.ui === 'compose' ? [LIBS.hiltNav] : [])] : []), ...(arch === 'retrofit-koin' ? [LIBS.koin, ...(c0.ui === 'compose' ? [LIBS.koinCompose] : [])] : [])];
|
|
227
|
+
const plugins = [PLUGINS.serialization, ...(arch === 'retrofit-hilt' ? [PLUGINS.ksp, PLUGINS.hilt] : [])];
|
|
228
|
+
const catalogPath = c0.versionCatalog || '';
|
|
229
|
+
const moduleBuild = [`${module}/build.gradle.kts`, `${module}/build.gradle`].find((f) => read(f) !== null);
|
|
230
|
+
const rootBuild = ['build.gradle.kts', 'build.gradle'].find((f) => read(f) !== null);
|
|
231
|
+
let catalog = catalogPath ? read(catalogPath) : null;
|
|
232
|
+
const kotlinVersion = (catalog && /^kotlin\s*=\s*"([^"]+)"/m.exec(catalog)?.[1]) || ANDROID_VERSIONS.kotlin;
|
|
233
|
+
const version = (k) => (k === 'kotlin' ? kotlinVersion : ANDROID_VERSIONS[k]);
|
|
234
|
+
if (moduleBuild && !moduleBuild.endsWith('.kts'))
|
|
235
|
+
warnings.push(`${moduleBuild} is Groovy; add these dependencies yourself: ${libs.map((l) => `${l.coord}:${version(l.version)}`).join(', ')}.`);
|
|
236
|
+
else if (moduleBuild) {
|
|
237
|
+
let mb = read(moduleBuild);
|
|
238
|
+
if (catalog !== null) {
|
|
239
|
+
const versionKeys = [...new Set([...libs.map((l) => l.version), ...plugins.map((p) => p.version)])];
|
|
240
|
+
catalog = tomlAdd(catalog, 'versions', versionKeys.map((k) => [k === 'kotlin' ? 'kotlin' : k.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`), `"${version(k)}"`]));
|
|
241
|
+
catalog = tomlAdd(catalog, 'libraries', libs.map((l) => [l.key, `{ group = "${l.coord.split(':')[0]}", name = "${l.coord.split(':')[1]}", version.ref = "${l.version === 'kotlin' ? 'kotlin' : l.version.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}" }`]));
|
|
242
|
+
catalog = tomlAdd(catalog, 'plugins', plugins.map((p) => [p.key, `{ id = "${p.id}", version.ref = "${p.version === 'kotlin' ? 'kotlin' : p.version}" }`]));
|
|
243
|
+
files.set(catalogPath, catalog);
|
|
244
|
+
summary.push(`Update ${catalogPath}: ${libs.length} libraries and ${plugins.length} plugin${plugins.length === 1 ? '' : 's'}`);
|
|
245
|
+
mb = gradleBlockAdd(mb, 'plugins', plugins.map((p) => `alias(libs.plugins.${tomlKey(p.key)})`));
|
|
246
|
+
mb = gradleBlockAdd(mb, 'dependencies', libs.map((l) => `${l.config ?? 'implementation'}(libs.${tomlKey(l.key)})`));
|
|
247
|
+
if (rootBuild && rootBuild.endsWith('.kts')) {
|
|
248
|
+
const rb = gradleBlockAdd(read(rootBuild), 'plugins', plugins.filter((p) => p.root).map((p) => `alias(libs.plugins.${tomlKey(p.key)}) apply false`));
|
|
249
|
+
if (rb !== read(rootBuild)) {
|
|
250
|
+
files.set(rootBuild, rb);
|
|
251
|
+
summary.push(`Update ${rootBuild}: declare ${plugins.map((p) => p.key).join(', ')} (apply false)`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
mb = gradleBlockAdd(mb, 'plugins', plugins.map((p) => `id("${p.id}") version "${version(p.version)}"`));
|
|
257
|
+
mb = gradleBlockAdd(mb, 'dependencies', libs.map((l) => `${l.config ?? 'implementation'}("${l.coord}:${version(l.version)}")`));
|
|
258
|
+
}
|
|
259
|
+
files.set(moduleBuild, mb);
|
|
260
|
+
summary.push(`Update ${moduleBuild}: plugins (${plugins.map((p) => p.key).join(', ')}) and dependencies (Retrofit, OkHttp logging, kotlinx.serialization${arch === 'retrofit-hilt' ? ', Hilt' : arch === 'retrofit-koin' ? ', Koin' : ''})`);
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
warnings.push(`No ${module}/build.gradle.kts was found; add Retrofit, the kotlinx.serialization converter and OkHttp logging yourself.`);
|
|
264
|
+
const conventions = {
|
|
265
|
+
...c0,
|
|
266
|
+
http: 'retrofit',
|
|
267
|
+
serialization: 'kotlinx',
|
|
268
|
+
di: arch === 'retrofit-hilt' ? 'hilt' : arch === 'retrofit-koin' ? 'koin' : 'manual',
|
|
269
|
+
diFile: netFile,
|
|
270
|
+
apiFolder: `${pkgDir}/data/remote`,
|
|
271
|
+
modelsFolder: `${pkgDir}/data/model`,
|
|
272
|
+
screensFolder: c0.screensFolder || `${pkgDir}/ui/screens`,
|
|
273
|
+
baseUrl,
|
|
274
|
+
};
|
|
275
|
+
summary.push(`Save conventions: Retrofit interfaces in ${pkg}.data.remote, data classes in ${pkg}.data.model, registration with ${conventions.di}`);
|
|
276
|
+
return { files, summary, warnings, generated: [], conventions, dependenciesChanged: true };
|
|
277
|
+
}
|