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.
Files changed (117) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +523 -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 +358 -0
  77. package/dist/skills/doctor.js +304 -0
  78. package/dist/skills/doctorFix.js +214 -0
  79. package/dist/skills/explain.js +61 -0
  80. package/dist/skills/feature.js +418 -0
  81. package/dist/skills/featureEdit.js +225 -0
  82. package/dist/skills/featureLinks.js +179 -0
  83. package/dist/skills/init.js +66 -0
  84. package/dist/skills/iosUi.js +468 -0
  85. package/dist/skills/locale.js +335 -0
  86. package/dist/skills/mcp.js +175 -0
  87. package/dist/skills/mock.js +482 -0
  88. package/dist/skills/module.js +23 -0
  89. package/dist/skills/plans.js +66 -0
  90. package/dist/skills/prefill.js +84 -0
  91. package/dist/skills/reactNativeUi.js +388 -0
  92. package/dist/skills/reactUi.js +352 -0
  93. package/dist/skills/report.js +75 -0
  94. package/dist/skills/route.js +230 -0
  95. package/dist/skills/screen.js +597 -0
  96. package/dist/skills/screenForm.js +410 -0
  97. package/dist/skills/setup.js +28 -0
  98. package/dist/skills/setupDoctor.js +226 -0
  99. package/dist/skills/templatesCmd.js +32 -0
  100. package/dist/skills/testgen.js +331 -0
  101. package/dist/skills/theme.js +388 -0
  102. package/dist/starters/auth.js +144 -0
  103. package/dist/starters/commands.js +283 -0
  104. package/dist/starters/engine.js +315 -0
  105. package/dist/starters/home.js +42 -0
  106. package/dist/starters/http.js +93 -0
  107. package/dist/starters/manifest.js +285 -0
  108. package/dist/starters/paths.js +70 -0
  109. package/dist/starters/publish.js +150 -0
  110. package/dist/starters/registry.js +258 -0
  111. package/dist/starters/sign.js +72 -0
  112. package/dist/starters/source.js +98 -0
  113. package/dist/starters/strictJson.js +148 -0
  114. package/dist/starters/tar.js +180 -0
  115. package/docs/COMMANDS.md +527 -0
  116. package/examples/openapi.yaml +319 -0
  117. package/package.json +57 -4
@@ -0,0 +1,536 @@
1
+ // Android screens for apps built with XML Views (no Compose): route add,
2
+ // screen add (data) and screen add --form.
3
+ //
4
+ // <Name>Activity.kt a ComponentActivity (works with any app theme)
5
+ // res/layout/activity_x.xml progress, error with Retry, and the content
6
+ // <Name>ViewModel.kt the same ViewModel the Compose screens use: the API
7
+ // interface from Hilt, Koin or the service locator;
8
+ // route parameters arrive as Intent extras, which
9
+ // the SavedStateHandle reads
10
+ // AndroidManifest.xml an <activity> entry
11
+ //
12
+ // A data screen shows a spinner, the error with Retry, then a RecyclerView
13
+ // (lists) or the model's fields. A form screen has one input per request field
14
+ // and closes after saving. The libraries these need (RecyclerView, activity-ktx,
15
+ // lifecycle-runtime-ktx, lifecycle-viewmodel-ktx, core-ktx) are added to Gradle
16
+ // when the app does not have them.
17
+ import { packageOf } from "../adapters/android.js";
18
+ import { ANDROID_VERSIONS, gradleBlockAdd, tomlAdd } from "../adapters/androidSetup.js";
19
+ import { addKotlinImports, kotlinName } from "../adapters/kotlinGen.js";
20
+ import { ToolError } from "../core/types.js";
21
+ import { kebab, snake } from "../input/naming.js";
22
+ import { access, androidPrefill, apiRef, classOfFile, ktInitial, context, dataViewModel, ensureUiState, fileOf, finish, formViewModel, injection, names, routeOf } from "./androidUi.js";
23
+ import { responseShape, titleField } from "./screen.js";
24
+ /** Libraries the generated Activities use, with versions known to work together. */
25
+ const VIEW_LIBS = [
26
+ { key: 'androidx-core-ktx', coord: 'androidx.core:core-ktx', version: '1.15.0', vkey: 'core-ktx' },
27
+ { key: 'androidx-activity-ktx', coord: 'androidx.activity:activity-ktx', version: '1.9.3', vkey: 'activity' },
28
+ { key: 'androidx-lifecycle-runtime-ktx', coord: 'androidx.lifecycle:lifecycle-runtime-ktx', version: ANDROID_VERSIONS.lifecycle, vkey: 'lifecycle' },
29
+ { key: 'androidx-lifecycle-viewmodel-ktx', coord: 'androidx.lifecycle:lifecycle-viewmodel-ktx', version: ANDROID_VERSIONS.lifecycle, vkey: 'lifecycle' },
30
+ { key: 'androidx-recyclerview', coord: 'androidx.recyclerview:recyclerview', version: '1.3.2', vkey: 'recyclerview' },
31
+ ];
32
+ function viewNames(name) {
33
+ const n = names(name);
34
+ return { ...n, activity: `${n.base}Activity`, layout: `activity_${snake(n.base)}` };
35
+ }
36
+ function views(root, map) {
37
+ const x = context(root, map, 'views');
38
+ const module = x.c.module || 'app';
39
+ const screensDir = x.c.screensFolder || `${x.srcDir}/${x.c.namespace.replace(/\./g, '/')}/ui/screens`;
40
+ return { x, module, screensDir, layoutDir: `${module}/src/main/res/layout`, manifest: `${module}/src/main/AndroidManifest.xml`, rClass: `${x.c.namespace}.R` };
41
+ }
42
+ /** Adds the libraries the Activities need to the version catalog and build file, when missing. */
43
+ function ensureLibs(x, module, needed) {
44
+ const buildFile = [`${module}/build.gradle.kts`, `${module}/build.gradle`].find((f) => x.read(f) !== null);
45
+ if (!buildFile)
46
+ return;
47
+ const catalogPath = x.c.versionCatalog || '';
48
+ let catalog = catalogPath ? x.read(catalogPath) : null;
49
+ let build = x.read(buildFile);
50
+ const have = `${build}\n${catalog ?? ''}`;
51
+ const missing = VIEW_LIBS.filter((l) => needed.includes(l.coord) && !have.includes(l.coord) && !new RegExp(`group\\s*=\\s*"${l.coord.split(':')[0].replace(/\./g, '\\.')}"\\s*,\\s*name\\s*=\\s*"${l.coord.split(':')[1]}"`).test(have) && !have.includes(`module = "${l.coord}"`));
52
+ if (!missing.length)
53
+ return;
54
+ if (!buildFile.endsWith('.kts')) {
55
+ x.warnings.push(`${buildFile} is Groovy; add these dependencies yourself: ${missing.map((l) => `${l.coord}:${l.version}`).join(', ')}.`);
56
+ return;
57
+ }
58
+ if (catalog !== null) {
59
+ catalog = tomlAdd(catalog, 'versions', missing.map((l) => [l.vkey, `"${l.version}"`]));
60
+ catalog = tomlAdd(catalog, 'libraries', missing.map((l) => [l.key, `{ group = "${l.coord.split(':')[0]}", name = "${l.coord.split(':')[1]}", version.ref = "${l.vkey}" }`]));
61
+ x.files.set(catalogPath, catalog);
62
+ build = gradleBlockAdd(build, 'dependencies', missing.map((l) => `implementation(libs.${l.key.replace(/-/g, '.')})`));
63
+ }
64
+ else {
65
+ build = gradleBlockAdd(build, 'dependencies', missing.map((l) => `implementation("${l.coord}:${l.version}")`));
66
+ }
67
+ x.files.set(buildFile, build);
68
+ x.summary.push(`Update ${buildFile}${catalog !== null ? ` and ${catalogPath}` : ''}: add ${missing.map((l) => l.coord.split(':')[1]).join(', ')}`);
69
+ }
70
+ /** <activity android:name=".ui.screens.OrdersActivity" android:exported="false" /> */
71
+ function registerActivity(x, manifestPath, activityClass) {
72
+ const text = x.read(manifestPath);
73
+ if (text === null)
74
+ return void x.warnings.push(`${manifestPath} was not found; add <activity android:name="${activityClass}" /> to it.`);
75
+ const ns = x.c.namespace;
76
+ const name = activityClass.startsWith(`${ns}.`) ? activityClass.slice(ns.length) : activityClass;
77
+ if (text.includes(`android:name="${name}"`) || text.includes(`android:name="${activityClass}"`))
78
+ return;
79
+ const close = text.lastIndexOf('</application>');
80
+ if (close < 0)
81
+ return void x.warnings.push(`${manifestPath} has no <application> element; add <activity android:name="${name}" /> to it.`);
82
+ const lineStart = text.lastIndexOf('\n', close) + 1;
83
+ const indent = /^([ \t]*)/.exec(text.slice(lineStart))[1];
84
+ x.files.set(manifestPath, `${text.slice(0, lineStart)}${indent} <activity android:name="${name}" android:exported="false" />\n${text.slice(lineStart)}`);
85
+ x.summary.push(`Update ${manifestPath}: register ${name}`);
86
+ }
87
+ function hint(activity, params) {
88
+ return `Open it with: startActivity(Intent(this, ${activity}::class.java)${params.map((p) => `.putExtra("${p}", ${p})`).join('')})`;
89
+ }
90
+ const XML_HEAD = '<?xml version="1.0" encoding="utf-8"?>';
91
+ const NS = 'xmlns:android="http://schemas.android.com/apk/res/android"';
92
+ export function planViewsRoute(root, map, opts) {
93
+ const v = views(root, map);
94
+ const { x } = v;
95
+ const n = viewNames(opts.name);
96
+ const { params } = routeOf(opts.name, opts.path);
97
+ const file = fileOf(v.screensDir, n.activity);
98
+ const layout = `${v.layoutDir}/${n.layout}.xml`;
99
+ for (const f of [file, layout])
100
+ if (x.read(f) !== null)
101
+ throw new ToolError('route_exists', `${f} already exists.`);
102
+ const pkg = packageOf(x.srcDir, v.screensDir);
103
+ x.files.set(layout, [XML_HEAD, `<LinearLayout ${NS}`, ' android:layout_width="match_parent"', ' android:layout_height="match_parent"', ' android:orientation="vertical"', ' android:padding="16dp">', '', ' <TextView', ' android:id="@+id/title"', ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ` android:text="${n.title}"`, ' android:textSize="22sp" />', '', '</LinearLayout>', ''].join('\n'));
104
+ const activity = [
105
+ `package ${pkg}`,
106
+ '',
107
+ `class ${n.activity} : ComponentActivity() {`,
108
+ ' override fun onCreate(savedInstanceState: Bundle?) {',
109
+ ' super.onCreate(savedInstanceState)',
110
+ ` setContentView(R.layout.${n.layout})`,
111
+ ...(params.length ? [` findViewById<TextView>(R.id.title).text = "${n.title} " + intent.getStringExtra("${params[0]}").orEmpty()`] : []),
112
+ ' }',
113
+ '}',
114
+ '',
115
+ ].join('\n');
116
+ x.files.set(file, addKotlinImports(activity, ['android.os.Bundle', 'androidx.activity.ComponentActivity', v.rClass, ...(params.length ? ['android.widget.TextView'] : [])], pkg));
117
+ x.summary.push(`Create ${file} and ${layout}: ${n.activity}`);
118
+ ensureLibs(x, v.module, ['androidx.activity:activity-ktx']);
119
+ registerActivity(x, v.manifest, `${pkg}.${n.activity}`);
120
+ return finish(x, 'route add', hint(n.activity, params));
121
+ }
122
+ export function planViewsScreen(root, map, opts) {
123
+ if (!opts.api)
124
+ return planViewsRoute(root, map, opts);
125
+ const v = views(root, map);
126
+ const { x } = v;
127
+ const a = apiRef(x, opts.api);
128
+ if (opts.form)
129
+ return planViewsForm(v, opts, a);
130
+ if (a.ep.method !== 'GET')
131
+ throw new ToolError('screen_needs_get', `${a.ep.name} is a ${a.ep.method} endpoint; a screen loads data with a GET.`, 'For a form that sends it, add --form.');
132
+ const n = viewNames(opts.name);
133
+ const { params } = routeOf(opts.name, opts.path);
134
+ const required = a.params.filter((p) => !p.optional && p.annotation !== 'Body');
135
+ const missing = required.filter((p) => !params.includes(p.name.replace(/`/g, '')));
136
+ if (missing.length)
137
+ throw new ToolError('screen_args', `${a.ep.name}() needs ${missing.map((p) => p.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0].name}`);
138
+ const file = fileOf(v.screensDir, n.activity);
139
+ const vmFile = fileOf(v.screensDir, n.vm);
140
+ const layout = `${v.layoutDir}/${n.layout}.xml`;
141
+ for (const f of [file, vmFile, layout])
142
+ if (x.read(f) !== null)
143
+ throw new ToolError('route_exists', `${f} already exists.`);
144
+ const pkg = packageOf(x.srcDir, v.screensDir);
145
+ const uiState = ensureUiState(x, v.screensDir);
146
+ const T = a.returnType;
147
+ const shape = responseShape(T, map);
148
+ const model = shape.model;
149
+ const modelImports = model ? [classOfFile(x.srcDir, model.file)] : [];
150
+ const inj = injection(x, a, n.vm, pkg, params.length > 0);
151
+ const args = required.map((p) => {
152
+ const name = p.name.replace(/`/g, '');
153
+ return /^(Int|Long)$/.test(p.type) ? `${name}.to${p.type}()` : name;
154
+ });
155
+ x.files.set(vmFile, dataViewModel(x, a, n.vm, pkg, params, args, inj, uiState, T, modelImports));
156
+ x.summary.push(`Create ${vmFile}: ${n.vm} loads ${a.iface}.${a.ep.name}()${params.length ? ` with ${params.join(', ')} from the Intent extras` : ''}`);
157
+ // Layout.
158
+ const content = shape.list
159
+ ? [' <androidx.recyclerview.widget.RecyclerView', ' android:id="@+id/list"', ' android:layout_width="match_parent"', ' android:layout_height="0dp"', ' android:layout_weight="1" />', '', ' <TextView', ' android:id="@+id/empty"', ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ' android:padding="16dp"', ' android:text="Nothing here yet."', ' android:visibility="gone" />']
160
+ : [' <ScrollView', ' android:layout_width="match_parent"', ' android:layout_height="0dp"', ' android:layout_weight="1">', '', ' <LinearLayout', ' android:id="@+id/details"', ' android:layout_width="match_parent"', ' android:layout_height="wrap_content"', ' android:orientation="vertical"', ' android:padding="16dp" />', ' </ScrollView>'];
161
+ x.files.set(layout, [
162
+ XML_HEAD,
163
+ `<FrameLayout ${NS}`,
164
+ ' android:layout_width="match_parent"',
165
+ ' android:layout_height="match_parent">',
166
+ '',
167
+ ' <LinearLayout',
168
+ ' android:id="@+id/content"',
169
+ ' android:layout_width="match_parent"',
170
+ ' android:layout_height="match_parent"',
171
+ ' android:orientation="vertical"',
172
+ ' android:visibility="gone">',
173
+ '',
174
+ ' <TextView',
175
+ ' android:id="@+id/title"',
176
+ ' android:layout_width="wrap_content"',
177
+ ' android:layout_height="wrap_content"',
178
+ ' android:padding="16dp"',
179
+ ` android:text="${n.title}"`,
180
+ ' android:textSize="22sp" />',
181
+ ...(shape.list ? ['', ' <Button', ' android:id="@+id/new_button"', ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ' android:layout_marginHorizontal="16dp"', ' android:text="New"', ' android:visibility="gone" />'] : []),
182
+ '',
183
+ ...content,
184
+ ' </LinearLayout>',
185
+ '',
186
+ ' <ProgressBar',
187
+ ' android:id="@+id/progress"',
188
+ ' android:layout_width="wrap_content"',
189
+ ' android:layout_height="wrap_content"',
190
+ ' android:layout_gravity="center" />',
191
+ '',
192
+ ' <LinearLayout',
193
+ ' android:id="@+id/error"',
194
+ ' android:layout_width="wrap_content"',
195
+ ' android:layout_height="wrap_content"',
196
+ ' android:layout_gravity="center"',
197
+ ' android:gravity="center"',
198
+ ' android:orientation="vertical"',
199
+ ' android:padding="16dp"',
200
+ ' android:visibility="gone">',
201
+ '',
202
+ ' <TextView',
203
+ ' android:id="@+id/error_text"',
204
+ ' android:layout_width="wrap_content"',
205
+ ' android:layout_height="wrap_content" />',
206
+ '',
207
+ ' <Button',
208
+ ' android:id="@+id/retry"',
209
+ ' android:layout_width="wrap_content"',
210
+ ' android:layout_height="wrap_content"',
211
+ ' android:text="Retry" />',
212
+ ' </LinearLayout>',
213
+ '</FrameLayout>',
214
+ '',
215
+ ].join('\n'));
216
+ // Activity.
217
+ const item = model?.name ?? 'Any';
218
+ const tf = titleField(model);
219
+ const success = shape.list
220
+ ? [' is UiState.Success -> {', ' adapter.submit(state.data)', ' findViewById<View>(R.id.empty).isVisible = state.data.isEmpty()', ' }']
221
+ : [
222
+ ' is UiState.Success -> {',
223
+ ' val details = findViewById<LinearLayout>(R.id.details)',
224
+ ' details.removeAllViews()',
225
+ ...(model?.fields.length ? model.fields.map((f) => ` field(details, "${f.name}", ${access('state.data', f.name)}?.toString().orEmpty())`) : [' field(details, "", state.data.toString())']),
226
+ ' }',
227
+ ];
228
+ const activity = [
229
+ `package ${pkg}`,
230
+ '',
231
+ ...inj.activity.annotations,
232
+ `class ${n.activity} : ComponentActivity() {`,
233
+ ` private val viewModel: ${n.vm} ${inj.activity.delegate}`,
234
+ ...(shape.list ? [' private val adapter = ItemAdapter { item -> openItem(item) }'] : []),
235
+ '',
236
+ ' override fun onCreate(savedInstanceState: Bundle?) {',
237
+ ' super.onCreate(savedInstanceState)',
238
+ ` setContentView(R.layout.${n.layout})`,
239
+ ...(shape.list
240
+ ? [
241
+ ' val list = findViewById<RecyclerView>(R.id.list)',
242
+ ' list.layoutManager = LinearLayoutManager(this)',
243
+ ' list.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))',
244
+ ' list.adapter = adapter',
245
+ ' findViewById<Button>(R.id.new_button).setOnClickListener { openNew() }',
246
+ ]
247
+ : []),
248
+ ' findViewById<Button>(R.id.retry).setOnClickListener { viewModel.load() }',
249
+ ' lifecycleScope.launch {',
250
+ ' repeatOnLifecycle(Lifecycle.State.STARTED) {',
251
+ ' viewModel.state.collect { render(it) }',
252
+ ' }',
253
+ ' }',
254
+ ' }',
255
+ ...(shape.list ? ['', ' /** Back from a screen this one opened: show what changed. */', ' override fun onRestart() {', ' super.onRestart()', ' viewModel.load()', ' }'] : []),
256
+ '',
257
+ ` private fun render(state: UiState<${T}>) {`,
258
+ ' findViewById<View>(R.id.progress).isVisible = state is UiState.Loading',
259
+ ' findViewById<View>(R.id.error).isVisible = state is UiState.Error',
260
+ ' findViewById<View>(R.id.content).isVisible = state is UiState.Success',
261
+ ' when (state) {',
262
+ ' is UiState.Loading -> Unit',
263
+ ' is UiState.Error -> findViewById<TextView>(R.id.error_text).text = state.message',
264
+ ...success,
265
+ ' }',
266
+ ' }',
267
+ ...(shape.list
268
+ ? [
269
+ '',
270
+ ` private fun openItem(item: ${item}) {`,
271
+ ' // codz: open item',
272
+ ' }',
273
+ '',
274
+ ' private fun openNew() {',
275
+ ' // codz: open new',
276
+ ' }',
277
+ '',
278
+ ` private class ItemAdapter(private val onClick: (${item}) -> Unit) : RecyclerView.Adapter<ItemAdapter.Holder>() {`,
279
+ ` private var items: List<${item}> = emptyList()`,
280
+ '',
281
+ ' class Holder(val text: TextView) : RecyclerView.ViewHolder(text)',
282
+ '',
283
+ ' @SuppressLint("NotifyDataSetChanged")',
284
+ ` fun submit(data: List<${item}>) {`,
285
+ ' items = data',
286
+ ' notifyDataSetChanged()',
287
+ ' }',
288
+ '',
289
+ ' override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder =',
290
+ ' Holder(LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false) as TextView)',
291
+ '',
292
+ ' override fun getItemCount(): Int = items.size',
293
+ '',
294
+ ' override fun onBindViewHolder(holder: Holder, position: Int) {',
295
+ ' val item = items[position]',
296
+ ` holder.text.text = ${tf ? `${access('item', tf)}.toString()` : 'item.toString()'}`,
297
+ ' holder.itemView.setOnClickListener { onClick(item) }',
298
+ ' }',
299
+ ' }',
300
+ ]
301
+ : [
302
+ '',
303
+ ' private fun field(parent: LinearLayout, label: String, value: String) {',
304
+ ' parent.addView(TextView(this).apply { text = label; setTextAppearance(android.R.style.TextAppearance_Small) })',
305
+ ' parent.addView(TextView(this).apply { text = value; setTextAppearance(android.R.style.TextAppearance_Medium); setPadding(0, 0, 0, 24) })',
306
+ ' }',
307
+ ]),
308
+ '}',
309
+ '',
310
+ ].join('\n');
311
+ const imports = [
312
+ 'android.os.Bundle',
313
+ 'android.view.View',
314
+ 'android.widget.Button',
315
+ 'android.widget.TextView',
316
+ 'androidx.activity.ComponentActivity',
317
+ 'androidx.core.view.isVisible',
318
+ 'androidx.lifecycle.Lifecycle',
319
+ 'androidx.lifecycle.lifecycleScope',
320
+ 'androidx.lifecycle.repeatOnLifecycle',
321
+ 'kotlinx.coroutines.launch',
322
+ v.rClass,
323
+ uiState,
324
+ ...modelImports,
325
+ ...inj.activity.imports,
326
+ ...(shape.list ? ['android.annotation.SuppressLint', 'android.view.LayoutInflater', 'android.view.ViewGroup', 'androidx.recyclerview.widget.DividerItemDecoration', 'androidx.recyclerview.widget.LinearLayoutManager', 'androidx.recyclerview.widget.RecyclerView'] : ['android.widget.LinearLayout']),
327
+ ];
328
+ x.files.set(file, addKotlinImports(activity, imports, pkg));
329
+ x.summary.push(`Create ${file} and ${layout}: ${n.activity} (loading, error with Retry, ${shape.list ? 'RecyclerView' : 'details'})`);
330
+ ensureLibs(x, v.module, ['androidx.core:core-ktx', 'androidx.activity:activity-ktx', 'androidx.lifecycle:lifecycle-runtime-ktx', 'androidx.lifecycle:lifecycle-viewmodel-ktx', ...(shape.list ? ['androidx.recyclerview:recyclerview'] : [])]);
331
+ registerActivity(x, v.manifest, `${pkg}.${n.activity}`);
332
+ return finish(x, 'screen add', hint(n.activity, params));
333
+ }
334
+ function planViewsForm(v, opts, a) {
335
+ const { x } = v;
336
+ if (!['POST', 'PUT', 'PATCH'].includes(a.ep.method))
337
+ throw new ToolError('form_needs_body', `${a.ep.name} is a ${a.ep.method} endpoint; a form sends a request body (POST, PUT or PATCH).`);
338
+ const body = a.params.find((p) => p.annotation === 'Body');
339
+ if (!body)
340
+ throw new ToolError('form_needs_body', `${a.ep.name}() takes no @Body, so there is nothing for a form to send.`);
341
+ const bodyModel = x.map.models.find((m) => m.name === body.type.replace(/\?$/, ''));
342
+ if (!bodyModel)
343
+ throw new ToolError('form_needs_body', `The request type ${body.type} was not found among the data classes.`);
344
+ const n = viewNames(opts.name);
345
+ const { params } = routeOf(opts.name, opts.path);
346
+ const required = a.params.filter((p) => !p.optional && p.annotation !== 'Body');
347
+ const missing = required.filter((p) => !params.includes(p.name.replace(/`/g, '')));
348
+ if (missing.length)
349
+ throw new ToolError('screen_args', `${a.ep.name}() needs ${missing.map((p) => p.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0].name}`);
350
+ const file = fileOf(v.screensDir, n.activity);
351
+ const vmFile = fileOf(v.screensDir, n.vm);
352
+ const layout = `${v.layoutDir}/${n.layout}.xml`;
353
+ for (const f of [file, vmFile, layout])
354
+ if (x.read(f) !== null)
355
+ throw new ToolError('route_exists', `${f} already exists.`);
356
+ const pkg = packageOf(x.srcDir, v.screensDir);
357
+ const uiState = ensureUiState(x, v.screensDir);
358
+ const R = a.returnType;
359
+ const inj = injection(x, a, n.vm, pkg, params.length > 0);
360
+ const args = [...required.map((p) => (/^(Int|Long)$/.test(p.type) ? `${p.name}.to${p.type}()` : p.name)), 'body'];
361
+ const bodyClass = classOfFile(x.srcDir, bodyModel.file);
362
+ const result = responseShape(R, x.map).model;
363
+ const prefill = androidPrefill(x, a, opts.load, bodyModel.fields.map((f) => f.name), params);
364
+ x.files.set(vmFile, formViewModel(x, a, n.vm, pkg, params, args, inj, uiState, R, bodyModel.name, bodyClass, result ? [classOfFile(x.srcDir, result.file)] : [], prefill));
365
+ x.summary.push(`Create ${vmFile}: ${n.vm}.submit() sends ${a.iface}.${a.ep.name}()${prefill ? `; the form starts with ${prefill.call.replace(/^api\./, `${a.iface}.`)}` : ''}`);
366
+ const fields = bodyModel.fields.map((f) => {
367
+ const t = f.type.replace(/\?$/, '');
368
+ const prop = kotlinName(f.name);
369
+ return { json: f.name, prop, v: `${prop.replace(/`/g, '')}Input`, t, nullable: f.nullable, bool: t === 'Boolean', num: /^(Int|Long|Double|Float)$/.test(t), text: t === 'String' };
370
+ });
371
+ const usable = fields.filter((f) => f.bool || f.num || f.text);
372
+ const unsupported = fields.filter((f) => !f.bool && !f.num && !f.text);
373
+ if (unsupported.some((f) => !f.nullable))
374
+ throw new ToolError('form_fields', `${bodyModel.name} has required fields a form cannot fill: ${unsupported.filter((f) => !f.nullable).map((f) => `${f.json} (${f.t})`).join(', ')}.`, `Write the form for ${a.ep.name} by hand, or make those fields optional.`);
375
+ if (unsupported.length)
376
+ x.warnings.push(`The form has no input for ${unsupported.map((f) => `${f.json} (${f.t})`).join(', ')}; they are sent as null.`);
377
+ const zero = (t) => (t === 'Double' ? '0.0' : t === 'Float' ? '0f' : t === 'Long' ? '0L' : '0');
378
+ const value = (f) => {
379
+ if (f.bool)
380
+ return `${f.v}.isChecked`;
381
+ const s = `${f.v}.text.toString().trim()`;
382
+ if (f.text)
383
+ return f.nullable ? `${s}.ifBlank { null }` : s;
384
+ if (f.num)
385
+ return `${s}.to${f.t}OrNull()${f.nullable ? '' : ` ?: ${zero(f.t)}`}`;
386
+ return 'null';
387
+ };
388
+ const inputType = (f) => (f.num ? (/Double|Float/.test(f.t) ? 'InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED' : 'InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED') : /email/i.test(f.json) ? 'InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS' : 'InputType.TYPE_CLASS_TEXT');
389
+ x.files.set(layout, [
390
+ XML_HEAD,
391
+ `<ScrollView ${NS}`,
392
+ ' android:layout_width="match_parent"',
393
+ ' android:layout_height="match_parent">',
394
+ '',
395
+ ' <LinearLayout',
396
+ ' android:layout_width="match_parent"',
397
+ ' android:layout_height="wrap_content"',
398
+ ' android:orientation="vertical"',
399
+ ' android:padding="16dp">',
400
+ '',
401
+ ' <TextView',
402
+ ' android:layout_width="wrap_content"',
403
+ ' android:layout_height="wrap_content"',
404
+ ' android:paddingBottom="8dp"',
405
+ ` android:text="${n.title}"`,
406
+ ' android:textSize="22sp" />',
407
+ '',
408
+ ' <LinearLayout',
409
+ ' android:id="@+id/fields"',
410
+ ' android:layout_width="match_parent"',
411
+ ' android:layout_height="wrap_content"',
412
+ ' android:orientation="vertical" />',
413
+ '',
414
+ ' <TextView',
415
+ ' android:id="@+id/error_text"',
416
+ ' android:layout_width="wrap_content"',
417
+ ' android:layout_height="wrap_content"',
418
+ ' android:textColor="#B3261E"',
419
+ ' android:visibility="gone" />',
420
+ '',
421
+ ' <Button',
422
+ ' android:id="@+id/save"',
423
+ ' android:layout_width="match_parent"',
424
+ ' android:layout_height="wrap_content"',
425
+ ' android:layout_marginTop="16dp"',
426
+ ' android:text="Save" />',
427
+ ' </LinearLayout>',
428
+ '</ScrollView>',
429
+ '',
430
+ ].join('\n'));
431
+ const activity = [
432
+ `package ${pkg}`,
433
+ '',
434
+ ...inj.activity.annotations,
435
+ `class ${n.activity} : ComponentActivity() {`,
436
+ ` private val viewModel: ${n.vm} ${inj.activity.delegate}`,
437
+ ...usable.map((f) => ` private lateinit var ${f.v}: ${f.bool ? 'CheckBox' : 'EditText'}`),
438
+ '',
439
+ ' override fun onCreate(savedInstanceState: Bundle?) {',
440
+ ' super.onCreate(savedInstanceState)',
441
+ ` setContentView(R.layout.${n.layout})`,
442
+ ' val fields = findViewById<LinearLayout>(R.id.fields)',
443
+ ...usable.map((f) => (f.bool ? ` ${f.v} = CheckBox(this).apply { text = "${f.json}" }.also(fields::addView)` : ` ${f.v} = input(fields, "${f.json}${f.nullable ? '' : ' *'}", ${inputType(f)})`)),
444
+ ' findViewById<Button>(R.id.save).setOnClickListener { save() }',
445
+ ...(prefill
446
+ ? [
447
+ ' // Start with the current values (once they have loaded).',
448
+ ' lifecycleScope.launch {',
449
+ ' viewModel.initial.filterNotNull().first().let {',
450
+ ...usable
451
+ .filter((f) => (f.bool || f.num || f.text) && prefill.shared.has(f.json) && prefill.model.fields.find((m) => m.name === f.json).type.replace(/\?$/, '') === f.t)
452
+ .map((f) => {
453
+ const init = ktInitial(f.bool ? 'bool' : f.num ? 'num' : 'text', `it.${f.prop}`, prefill.model.fields.find((m) => m.name === f.json).nullable);
454
+ return f.bool ? ` ${f.v}.isChecked = ${init}` : ` ${f.v}.setText(${init})`;
455
+ }),
456
+ ' }',
457
+ ' }',
458
+ ]
459
+ : []),
460
+ ' lifecycleScope.launch {',
461
+ ' repeatOnLifecycle(Lifecycle.State.STARTED) {',
462
+ ' viewModel.state.collect { state ->',
463
+ ' findViewById<Button>(R.id.save).isEnabled = state !is UiState.Loading',
464
+ ' findViewById<TextView>(R.id.error_text).apply {',
465
+ ' isVisible = state is UiState.Error',
466
+ ' text = (state as? UiState.Error)?.message',
467
+ ' }',
468
+ ' if (state is UiState.Success) {',
469
+ ' setResult(RESULT_OK)',
470
+ ' finish()',
471
+ ' }',
472
+ ' }',
473
+ ' }',
474
+ ' }',
475
+ ' }',
476
+ '',
477
+ ' private fun save() {',
478
+ ...usable.filter((f) => !f.nullable && !f.bool).map((f) => ` if (${f.v}.text.isBlank()) return run { ${f.v}.error = "Required" }`),
479
+ ' viewModel.submit(',
480
+ ` ${bodyModel.name}(`,
481
+ ...fields.map((f) => ` ${f.prop} = ${value(f)},`),
482
+ ' ),',
483
+ ' )',
484
+ ' }',
485
+ '',
486
+ ' private fun input(parent: LinearLayout, label: String, type: Int): EditText =',
487
+ ' EditText(this).apply {',
488
+ ' hint = label',
489
+ ' inputType = type',
490
+ ' }.also(parent::addView)',
491
+ '}',
492
+ '',
493
+ ].join('\n');
494
+ const imports = [
495
+ 'android.os.Bundle',
496
+ 'android.text.InputType',
497
+ 'android.widget.Button',
498
+ 'android.widget.EditText',
499
+ 'android.widget.LinearLayout',
500
+ 'android.widget.TextView',
501
+ 'androidx.activity.ComponentActivity',
502
+ 'androidx.core.view.isVisible',
503
+ 'androidx.lifecycle.Lifecycle',
504
+ 'androidx.lifecycle.lifecycleScope',
505
+ 'androidx.lifecycle.repeatOnLifecycle',
506
+ 'kotlinx.coroutines.launch',
507
+ v.rClass,
508
+ uiState,
509
+ bodyClass,
510
+ ...(usable.some((f) => f.bool) ? ['android.widget.CheckBox'] : []),
511
+ ...(prefill ? ['kotlinx.coroutines.flow.filterNotNull', 'kotlinx.coroutines.flow.first'] : []),
512
+ ...inj.activity.imports,
513
+ ];
514
+ x.files.set(file, addKotlinImports(activity, imports, pkg));
515
+ x.summary.push(`Create ${file} and ${layout}: ${n.activity}, a form with ${usable.length} field${usable.length === 1 ? '' : 's'} for ${bodyModel.name}; it closes after saving`);
516
+ ensureLibs(x, v.module, ['androidx.core:core-ktx', 'androidx.activity:activity-ktx', 'androidx.lifecycle:lifecycle-runtime-ktx', 'androidx.lifecycle:lifecycle-viewmodel-ktx']);
517
+ registerActivity(x, v.manifest, `${pkg}.${n.activity}`);
518
+ return finish(x, 'screen add', hint(n.activity, params));
519
+ }
520
+ /** feature add: rows of the list Activity open the detail Activity; the New button opens the form. */
521
+ export function linkViewsList(text, pkg, t) {
522
+ let out = text;
523
+ let changed = false;
524
+ if (t.detail && t.idField && out.includes(' // codz: open item')) {
525
+ out = out.replace(' // codz: open item', ` startActivity(Intent(this, ${t.detail.activity}::class.java).putExtra("${t.detail.param}", ${access('item', t.idField)}.toString()))`);
526
+ changed = true;
527
+ }
528
+ if (t.create && out.includes(' // codz: open new')) {
529
+ out = out.replace(' // codz: open new', ` startActivity(Intent(this, ${t.create.activity}::class.java))`);
530
+ out = out.replace(' findViewById<Button>(R.id.new_button).setOnClickListener { openNew() }', ` findViewById<Button>(R.id.new_button).apply {\n text = "New ${t.create.noun}"\n isVisible = true\n setOnClickListener { openNew() }\n }`);
531
+ changed = true;
532
+ }
533
+ if (!changed)
534
+ return null;
535
+ return addKotlinImports(out, ['android.content.Intent'], pkg);
536
+ }
@@ -0,0 +1,99 @@
1
+ // api add: cURL / URL / JSON samples → model, function and registration, as a change set.
2
+ import { join } from 'node:path';
3
+ import { loadMap } from "../core/cache.js";
4
+ import { buildChangeSet } from "../core/changeset.js";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { hash, readText } from "../core/fsutil.js";
7
+ import { ToolError } from "../core/types.js";
8
+ import { METHODS, parseCurl, parseUrl, stripSecretQuery } from "../input/curl.js";
9
+ import { buildEndpoint } from "../input/endpoint.js";
10
+ import { detectPaste } from "../input/paste.js";
11
+ function readSample(root, file) {
12
+ const text = readText(join(root, file)) ?? readText(file);
13
+ if (text === null)
14
+ throw new ToolError('no_sample', `Cannot read the sample file ${file}.`);
15
+ try {
16
+ return JSON.parse(text);
17
+ }
18
+ catch (e) {
19
+ throw new ToolError('bad_sample', `${file} is not valid JSON: ${e.message}`);
20
+ }
21
+ }
22
+ export function specFromInput(root, opts) {
23
+ const warnings = [];
24
+ let curl = opts.curl;
25
+ let url = opts.url;
26
+ const responseSamples = (opts.samples ?? []).map((f) => readSample(root, f));
27
+ let requestSample = opts.requestSample ? readSample(root, opts.requestSample) : undefined;
28
+ if (opts.paste) {
29
+ const p = detectPaste(opts.paste);
30
+ curl ??= p.curl;
31
+ url ??= p.url;
32
+ responseSamples.push(...p.responseSamples);
33
+ requestSample ??= p.requestSample;
34
+ }
35
+ if (opts.method !== undefined && !METHODS.includes(opts.method.toUpperCase())) {
36
+ throw new ToolError('bad_method', `"${opts.method}" is not an HTTP method.`, `Use one of: ${METHODS.join(', ')}.`);
37
+ }
38
+ let method;
39
+ let parsedUrl;
40
+ let body;
41
+ if (curl) {
42
+ const r = parseCurl(curl);
43
+ method = opts.method ? opts.method.toUpperCase() : r.method;
44
+ parsedUrl = r.url;
45
+ body = r.body;
46
+ if (r.removedSecrets.length)
47
+ warnings.push(`Removed from the input and never written anywhere: ${r.removedSecrets.join(', ')}. Use your project's auth mechanism for these.`);
48
+ warnings.push(...r.notes);
49
+ if (r.ignoredHeaders.length)
50
+ warnings.push(`These headers were not added to the code: ${r.ignoredHeaders.join(', ')}. Add them in your request helper if every call needs them.`);
51
+ }
52
+ else if (url) {
53
+ const m = url.trim().match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i);
54
+ method = (opts.method ?? m?.[1] ?? 'GET').toUpperCase();
55
+ parsedUrl = parseUrl(m ? m[2] : url);
56
+ const removed = [];
57
+ stripSecretQuery(parsedUrl, removed);
58
+ if (removed.length)
59
+ warnings.push(`Removed from the input and never written anywhere: ${removed.join(', ')}.`);
60
+ }
61
+ else {
62
+ throw new ToolError('no_input', 'No endpoint was given.', 'Pass --curl "<command>", --url <url>, or paste a cURL command (optionally followed by the response JSON) on standard input.');
63
+ }
64
+ return buildEndpoint({
65
+ method,
66
+ url: parsedUrl,
67
+ body,
68
+ responseSamples,
69
+ requestSample,
70
+ name: opts.name,
71
+ path: opts.path,
72
+ modelName: opts.modelName,
73
+ unwrap: opts.noUnwrap ? 'never' : 'auto',
74
+ warnings,
75
+ });
76
+ }
77
+ export async function planApiAdd(root, adapter, opts) {
78
+ if (!adapter.capabilities.apiAdd || !adapter.planEndpoint) {
79
+ throw new ToolError('not_supported', `api add needs a framework adapter; this project uses the ${adapter.id} adapter.`, 'Supported setups: Next.js (App Router, fetch) and Flutter (Dio).');
80
+ }
81
+ const spec = specFromInput(root, opts);
82
+ const cfg = loadConfig(root);
83
+ const { map } = await loadMap(root, adapter, cfg.conventions);
84
+ let edits = await adapter.planEndpoint({ root, map }, spec);
85
+ const inputHash = hash(JSON.stringify({ m: spec.method, p: spec.path, r: spec.response, q: spec.request }));
86
+ edits.generated = edits.generated.map((g) => ({ ...g, inputHash }));
87
+ if (opts.into) {
88
+ if (!adapter.planWire)
89
+ throw new ToolError('not_supported', `--into is not available for the ${adapter.id} adapter yet.`, 'Supported: Flutter with GetX.');
90
+ // The endpoint may only exist in this plan so far; describe it to the wiring step.
91
+ const existing = map.endpoints.find((e) => e.name === spec.name);
92
+ const file = existing?.file ?? edits.generated[0]?.file;
93
+ const planned = existing ? map : { ...map, endpoints: [...map.endpoints, { name: spec.name, method: spec.method, path: spec.path, file, calledFrom: [] }] };
94
+ const wire = adapter.planWire({ root, map: planned }, { endpoint: spec.name, module: opts.into, noLoad: opts.noLoad }, edits.files);
95
+ edits = { files: wire.files, summary: [...edits.summary, ...wire.summary], warnings: [...edits.warnings, ...wire.warnings], generated: [...edits.generated, ...wire.generated] };
96
+ }
97
+ const changeSet = buildChangeSet(root, 'api add', edits);
98
+ return { changeSet, spec };
99
+ }