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,440 @@
1
+ // iOS adapter (Swift, SwiftUI): an Xcode project with a SwiftUI App.
2
+ //
3
+ // API layer APIClient (URLSession, async/await, JSONDecoder) in Networking/;
4
+ // each endpoint is a method in an `extension APIClient` file per
5
+ // resource (APIClient+Orders.swift)
6
+ // Models Codable structs, one per file in Models/, with CodingKeys for
7
+ // JSON names that are not Swift names (created_at → createdAt)
8
+ // Screens SwiftUI views and @MainActor ObservableObject view models
9
+ // (see skills/iosUi.ts)
10
+ //
11
+ // Xcode 16 projects keep their files in synchronized folders, so new files are
12
+ // part of the app as soon as they exist. Older projects list every file in
13
+ // project.pbxproj; codz does not edit that file, and says which files to add
14
+ // to the target in Xcode.
15
+ import { readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { commentText, encodePathSegment } from "../core/safe.js";
18
+ import { exists, readText, walk } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ import { camel, pascal } from "../input/naming.js";
21
+ import { emptyMap, fileStats, specRelativePath } from "./common.js";
22
+ import { resourceOf } from "./flutterGen.js";
23
+ import { referencedModels, resolveModels } from "./models.js";
24
+ import { mostCommon } from "./nextjsScan.js";
25
+ export const IOS_VERSION = '0.1.0';
26
+ export const IOS_CONVENTION_DOCS = {
27
+ appName: 'the Xcode project (and app target) name',
28
+ sourceDir: 'the folder with the app sources (where the @main App is)',
29
+ appFile: 'the file with the @main SwiftUI App',
30
+ ui: 'swiftui (screens are written as SwiftUI views)',
31
+ fileSync: 'synchronized (Xcode 16 folders: new files join the target by themselves) or groups (add new files to the target in Xcode)',
32
+ networkFile: 'the file that defines APIClient',
33
+ apiFolder: 'where the APIClient extensions with the endpoints live',
34
+ modelsFolder: 'where Codable model structs live',
35
+ screensFolder: 'where SwiftUI screens and their view models live',
36
+ baseUrl: "APIClient's base URL",
37
+ indent: 'indentation: 2 or 4',
38
+ };
39
+ const SWIFT_KEYWORDS = new Set(['associatedtype', 'class', 'deinit', 'enum', 'extension', 'fileprivate', 'func', 'import', 'init', 'inout', 'internal', 'let', 'open', 'operator', 'private', 'protocol', 'public', 'rethrows', 'static', 'struct', 'subscript', 'typealias', 'var', 'break', 'case', 'continue', 'default', 'defer', 'do', 'else', 'fallthrough', 'for', 'guard', 'if', 'in', 'repeat', 'return', 'switch', 'where', 'while', 'as', 'catch', 'false', 'is', 'nil', 'super', 'self', 'Self', 'throw', 'throws', 'true', 'try', 'Type', 'Any', 'await', 'async']);
40
+ /** A Swift identifier for a JSON key: created_at → createdAt, `default` in backticks. */
41
+ export function swiftName(json) {
42
+ let n = camel(json.replace(/[^A-Za-z0-9_]+/g, '_'));
43
+ if (!n || /^[0-9]/.test(n))
44
+ n = `_${n}`;
45
+ return SWIFT_KEYWORDS.has(n) ? `\`${n}\`` : n;
46
+ }
47
+ /** A Swift string literal's content: escapes \ and " and line breaks. */
48
+ export function swiftText(s) {
49
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
50
+ }
51
+ export function swiftType(t) {
52
+ if (!t)
53
+ return null;
54
+ switch (t.kind) {
55
+ case 'string':
56
+ case 'date':
57
+ return 'String';
58
+ case 'int':
59
+ return 'Int';
60
+ case 'double':
61
+ return 'Double';
62
+ case 'bool':
63
+ return 'Bool';
64
+ case 'array': {
65
+ const inner = swiftType(t.item);
66
+ return inner ? `[${inner}]` : null;
67
+ }
68
+ case 'object':
69
+ return t.model;
70
+ default:
71
+ return null;
72
+ }
73
+ }
74
+ const dirOf = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '.');
75
+ /** Index of the brace that closes the one at `open` (Swift strings and comments skipped). */
76
+ export function swiftClose(text, open) {
77
+ let depth = 0;
78
+ for (let i = open; i < text.length; i++) {
79
+ const ch = text[i];
80
+ if (ch === '/' && text[i + 1] === '/') {
81
+ i = text.indexOf('\n', i);
82
+ if (i < 0)
83
+ return -1;
84
+ continue;
85
+ }
86
+ if (ch === '"') {
87
+ for (i++; i < text.length && text[i] !== '"'; i++)
88
+ if (text[i] === '\\')
89
+ i++;
90
+ continue;
91
+ }
92
+ if (ch === '{')
93
+ depth++;
94
+ else if (ch === '}' && --depth === 0)
95
+ return i;
96
+ }
97
+ return -1;
98
+ }
99
+ export function xcodeProject(root) {
100
+ try {
101
+ return readdirSync(root).find((n) => n.endsWith('.xcodeproj') && exists(join(root, n, 'project.pbxproj'))) ?? null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /** Codable structs: name, fields (JSON name from CodingKeys), file. */
108
+ function parseModels(file, text) {
109
+ const out = [];
110
+ for (const m of text.matchAll(/\bstruct\s+(\w+)\s*:\s*([^{]*)\{/g)) {
111
+ if (!/\b(Codable|Decodable|Encodable)\b/.test(m[2]))
112
+ continue;
113
+ const open = m.index + m[0].length - 1;
114
+ const close = swiftClose(text, open);
115
+ if (close < 0)
116
+ continue;
117
+ const body = text.slice(open + 1, close);
118
+ const keys = new Map();
119
+ const ck = /enum\s+CodingKeys\s*:\s*String[^{]*\{([^}]*)\}/.exec(body)?.[1] ?? '';
120
+ for (const line of ck.split('\n')) {
121
+ const c = /^\s*case\s+(.+)$/.exec(line);
122
+ if (!c)
123
+ continue;
124
+ for (const part of c[1].split(',')) {
125
+ const kv = /^\s*`?(\w+)`?\s*(?:=\s*"([^"]*)")?\s*$/.exec(part);
126
+ if (kv)
127
+ keys.set(kv[1], kv[2] ?? kv[1]);
128
+ }
129
+ }
130
+ const fields = [];
131
+ // Only stored properties at the struct's own level.
132
+ let depth = 0;
133
+ for (const line of body.split('\n')) {
134
+ if (depth === 0) {
135
+ const f = /^\s*(?:public\s+|private\(set\)\s+)?(?:let|var)\s+`?(\w+)`?\s*:\s*([^=\n{]+?)\s*(?:=.*)?$/.exec(line);
136
+ if (f)
137
+ fields.push({ name: keys.get(f[1]) ?? f[1], type: f[2].replace(/\?$/, '').trim(), nullable: f[2].trim().endsWith('?') });
138
+ }
139
+ depth += (line.match(/\{/g)?.length ?? 0) - (line.match(/\}/g)?.length ?? 0);
140
+ }
141
+ out.push({ name: m[1], file, fields });
142
+ }
143
+ return out;
144
+ }
145
+ /** func getOrder(orderId: Int) async throws -> Order { try await send("GET", "/orders/\(orderId)") } */
146
+ function parseEndpoints(file, text) {
147
+ const out = [];
148
+ for (const m of text.matchAll(/\bfunc\s+(\w+)\s*\(([^)]*)\)\s*async\s+throws(?:\s*->\s*([^{]+?))?\s*\{/g)) {
149
+ const open = m.index + m[0].length - 1;
150
+ const close = swiftClose(text, open);
151
+ const body = close > 0 ? text.slice(open, close) : '';
152
+ const call = /\bsend(?:Empty)?\s*\(\s*"(GET|POST|PUT|PATCH|DELETE|HEAD)"\s*,\s*"((?:[^"\\]|\\.)*)"/.exec(body);
153
+ if (!call)
154
+ continue;
155
+ const path = call[2].replace(/\\\((\w+)\)/g, '{$1}');
156
+ out.push({ name: m[1], method: call[1], path: path.startsWith('/') || /^https?:/.test(path) ? path : `/${path}`, response: m[3]?.trim() ?? 'Void', file, calledFrom: [] });
157
+ }
158
+ return out;
159
+ }
160
+ async function scan(root, configConventions) {
161
+ const map = emptyMap('ios', `ios@${IOS_VERSION}`);
162
+ const files = walk(root, '.', { exts: ['.swift'], extraIgnored: ['Pods', 'DerivedData', 'build', '.build', 'Carthage'] });
163
+ map.stats = fileStats(walk(root));
164
+ const text = (f) => readText(join(root, f)) ?? '';
165
+ const proj = xcodeProject(root);
166
+ const c = {};
167
+ c.appName = proj ? proj.replace(/\.xcodeproj$/, '') : '';
168
+ const appFile = files.find((f) => /@main\b[\s\S]{0,200}?\bstruct\s+\w+\s*:\s*App\b/.test(text(f))) ?? '';
169
+ c.appFile = appFile;
170
+ c.ui = appFile ? 'swiftui' : 'uikit';
171
+ c.sourceDir = appFile ? dirOf(appFile) : c.appName && exists(join(root, c.appName)) ? c.appName : '.';
172
+ const pbx = proj ? readText(join(root, proj, 'project.pbxproj')) ?? '' : '';
173
+ c.fileSync = /PBXFileSystemSynchronizedRootGroup/.test(pbx) ? 'synchronized' : 'groups';
174
+ const networkFile = files.find((f) => /\b(?:final\s+)?(?:class|struct|actor)\s+APIClient\b/.test(text(f))) ?? '';
175
+ c.networkFile = networkFile;
176
+ c.apiFolder = networkFile ? dirOf(networkFile) : `${c.sourceDir}/Networking`;
177
+ c.baseUrl = networkFile ? /URL\(string:\s*"(https?:\/\/[^"]+)"\)/.exec(text(networkFile))?.[1] ?? '' : '';
178
+ const sample = text(files[0] ?? '');
179
+ c.indent = /\n {2}\S/.test(sample) && !/\n {4}\S/.test(sample) ? '2' : '4';
180
+ for (const f of files) {
181
+ const t = text(f);
182
+ map.models.push(...parseModels(f, t));
183
+ if (/\bextension\s+APIClient\b|\b(?:class|struct|actor)\s+APIClient\b/.test(t))
184
+ map.endpoints.push(...parseEndpoints(f, t));
185
+ }
186
+ c.modelsFolder = mostCommon(map.models.map((m) => dirOf(m.file)), `${c.sourceDir}/Models`);
187
+ c.screensFolder = `${c.sourceDir}/Views`;
188
+ for (const ep of map.endpoints)
189
+ ep.calledFrom = files.filter((f) => f !== ep.file && new RegExp(`\\.${ep.name}\\(`).test(text(f)));
190
+ for (const f of files)
191
+ for (const v of text(f).matchAll(/\bstruct\s+(\w+)\s*:\s*View\b/g))
192
+ map.routes.push({ name: v[1], path: v[1], params: [], file: f });
193
+ if (c.baseUrl)
194
+ map.baseUrls.push({ name: 'APIClient baseURL', value: c.baseUrl, file: networkFile });
195
+ map.conventions = { ...c, ...configConventions };
196
+ if (!proj)
197
+ map.notes.push('No .xcodeproj was found next to the sources; run codz from the folder that holds YourApp.xcodeproj.');
198
+ if (map.conventions.ui !== 'swiftui')
199
+ map.notes.push('No SwiftUI App (@main struct ...: App) was found; screens are written for SwiftUI, so screen add and feature add will not add views.');
200
+ if (map.conventions.fileSync === 'groups')
201
+ map.notes.push('This Xcode project lists its files in project.pbxproj (not Xcode 16 synchronized folders); add the files codz creates to the app target in Xcode.');
202
+ return map;
203
+ }
204
+ // ---------- Setup ----------
205
+ export function iosArchitectures(map) {
206
+ if (map.conventions.networkFile || map.endpoints.length)
207
+ return [];
208
+ return [{ id: 'urlsession', label: 'URLSession + async/await', description: 'APIClient with send<T>() over URLSession and JSONDecoder; endpoints in extensions', recommended: true }];
209
+ }
210
+ function apiClientFile(baseUrl, I) {
211
+ const L = [
212
+ 'import Foundation',
213
+ '',
214
+ '/// Thrown when the API answers with a status outside 200-299.',
215
+ 'struct APIError: LocalizedError {',
216
+ `${I}let status: Int`,
217
+ `${I}let body: String`,
218
+ '',
219
+ `${I}var errorDescription: String? { "HTTP \\(status): \\(body)" }`,
220
+ '}',
221
+ '',
222
+ '/// Calls the API with URLSession and decodes JSON. Endpoints are methods in',
223
+ '/// `extension APIClient` files (codz api add writes them). Add auth headers in `send`.',
224
+ 'final class APIClient {',
225
+ `${I}static let shared = APIClient()`,
226
+ '',
227
+ `${I}let baseURL: URL`,
228
+ `${I}private let session: URLSession`,
229
+ `${I}private let decoder = JSONDecoder()`,
230
+ `${I}private let encoder = JSONEncoder()`,
231
+ '',
232
+ `${I}init(baseURL: URL = URL(string: "${swiftText(baseUrl)}")!, session: URLSession = .shared) {`,
233
+ `${I}${I}self.baseURL = baseURL`,
234
+ `${I}${I}self.session = session`,
235
+ `${I}}`,
236
+ '',
237
+ `${I}/// Sends a request and decodes the JSON answer.`,
238
+ `${I}func send<T: Decodable>(_ method: String, _ path: String, query: [String: String?] = [:], body: Data? = nil) async throws -> T {`,
239
+ `${I}${I}let data = try await data(method, path, query: query, body: body)`,
240
+ `${I}${I}return try decoder.decode(T.self, from: data)`,
241
+ `${I}}`,
242
+ '',
243
+ `${I}/// Sends a request whose answer has no body (204, or one that is not needed).`,
244
+ `${I}func sendEmpty(_ method: String, _ path: String, query: [String: String?] = [:], body: Data? = nil) async throws {`,
245
+ `${I}${I}_ = try await data(method, path, query: query, body: body)`,
246
+ `${I}}`,
247
+ '',
248
+ `${I}/// JSON for a request body.`,
249
+ `${I}func encode<B: Encodable>(_ body: B) throws -> Data {`,
250
+ `${I}${I}try encoder.encode(body)`,
251
+ `${I}}`,
252
+ '',
253
+ `${I}private func data(_ method: String, _ path: String, query: [String: String?], body: Data?) async throws -> Data {`,
254
+ `${I}${I}// A path is relative to baseURL; a full URL (another host) is used as it is.`,
255
+ `${I}${I}let base = path.hasPrefix("http") ? URL(string: path)! : baseURL.appendingPathComponent(path)`,
256
+ `${I}${I}var components = URLComponents(url: base, resolvingAgainstBaseURL: false)!`,
257
+ `${I}${I}let items = query.compactMap { key, value in value.map { URLQueryItem(name: key, value: $0) } }`,
258
+ `${I}${I}if !items.isEmpty { components.queryItems = items }`,
259
+ `${I}${I}var request = URLRequest(url: components.url!)`,
260
+ `${I}${I}request.httpMethod = method`,
261
+ `${I}${I}request.setValue("application/json", forHTTPHeaderField: "Accept")`,
262
+ `${I}${I}if let body {`,
263
+ `${I}${I}${I}request.httpBody = body`,
264
+ `${I}${I}${I}request.setValue("application/json", forHTTPHeaderField: "Content-Type")`,
265
+ `${I}${I}}`,
266
+ `${I}${I}let (data, response) = try await session.data(for: request)`,
267
+ `${I}${I}let status = (response as? HTTPURLResponse)?.statusCode ?? 0`,
268
+ `${I}${I}guard (200..<300).contains(status) else {`,
269
+ `${I}${I}${I}throw APIError(status: status, body: String(decoding: data, as: UTF8.self))`,
270
+ `${I}${I}}`,
271
+ `${I}${I}return data`,
272
+ `${I}}`,
273
+ '}',
274
+ '',
275
+ ];
276
+ return L.join('\n');
277
+ }
278
+ export function planIosSetup(ctx, choice) {
279
+ const { root, map } = ctx;
280
+ if (choice.architecture !== 'urlsession')
281
+ throw new ToolError('bad_args', `Unknown architecture "${choice.architecture}". Choose urlsession.`);
282
+ const c0 = map.conventions;
283
+ if (!c0.appName)
284
+ throw new ToolError('no_project', 'No .xcodeproj was found here.', 'Run codz from the folder that holds YourApp.xcodeproj.');
285
+ const I = ' '.repeat(Number(c0.indent) || 4);
286
+ const src = c0.sourceDir || c0.appName;
287
+ const baseUrl = (choice.baseUrl || 'https://api.example.com').replace(/\/+$/, '');
288
+ const files = new Map();
289
+ const summary = [];
290
+ const warnings = [];
291
+ if (!choice.baseUrl)
292
+ warnings.push('No base URL was given, so APIClient uses https://api.example.com. Change it in APIClient.swift before calling the API.');
293
+ const networkFile = `${src}/Networking/APIClient.swift`;
294
+ if (readText(join(root, networkFile)) !== null)
295
+ throw new ToolError('file_exists', `${networkFile} already exists, so setup stopped without changing anything.`);
296
+ files.set(networkFile, apiClientFile(baseUrl, I));
297
+ summary.push(`Create ${networkFile}: APIClient (URLSession, async/await, JSON), baseURL ${baseUrl}`);
298
+ if (c0.fileSync === 'groups')
299
+ warnings.push(`Add ${networkFile} to the ${c0.appName} target in Xcode (this project does not use synchronized folders).`);
300
+ const conventions = { ...c0, networkFile, apiFolder: `${src}/Networking`, modelsFolder: `${src}/Models`, screensFolder: `${src}/Views`, baseUrl };
301
+ summary.push(`Save conventions: endpoints in ${src}/Networking, models in ${src}/Models, screens in ${src}/Views`);
302
+ return { files, summary, warnings, generated: [], conventions, dependenciesChanged: false };
303
+ }
304
+ // ---------- Endpoints ----------
305
+ function renderStruct(m, I, warnings) {
306
+ const fields = m.fields.map((f) => ({ f, type: swiftType(f.type), name: swiftName(f.jsonName) }));
307
+ for (const x of fields.filter((x) => !x.type))
308
+ warnings.push(`${m.name}.${x.f.jsonName} has no type codz can map to Swift, so it is left out of the struct.`);
309
+ const kept = fields.filter((x) => x.type);
310
+ const bare = (n) => n.replace(/`/g, '');
311
+ const identifiable = kept.some((x) => bare(x.name) === 'id' && !x.f.nullable);
312
+ const needKeys = kept.some((x) => bare(x.name) !== x.f.jsonName);
313
+ return [
314
+ 'import Foundation',
315
+ '',
316
+ `struct ${m.name}: Codable, Hashable${identifiable ? ', Identifiable' : ''} {`,
317
+ ...kept.map((x) => `${I}let ${x.name}: ${x.type}${x.f.nullable ? '?' : ''}`),
318
+ ...(needKeys
319
+ ? ['', `${I}enum CodingKeys: String, CodingKey {`, ...kept.map((x) => (bare(x.name) === x.f.jsonName ? `${I}${I}case ${x.name}` : `${I}${I}case ${x.name} = "${swiftText(x.f.jsonName)}"`)), `${I}}`]
320
+ : []),
321
+ '}',
322
+ '',
323
+ ].join('\n');
324
+ }
325
+ /** "/orders/{orderId}/items" → "/orders/\(orderId)/items" with literal parts escaped. */
326
+ function swiftPath(path, params) {
327
+ return path
328
+ .split('/')
329
+ .map((seg) => {
330
+ const p = /^\{([^}]+)\}$/.exec(seg);
331
+ if (p)
332
+ return `\\(${params.get(p[1]) ?? swiftName(p[1])})`;
333
+ return swiftText(seg.includes('%') ? seg : encodePathSegment(seg));
334
+ })
335
+ .join('/');
336
+ }
337
+ async function planEndpoint(ctx, input, overlay, opts = {}) {
338
+ const { root, map } = ctx;
339
+ const c = map.conventions;
340
+ if (!c.networkFile && !(overlay && [...overlay.keys()].some((k) => /APIClient\.swift$/.test(k))))
341
+ throw new ToolError('unsupported_setup', 'This project has no APIClient yet.', 'Run codz setup (it writes Networking/APIClient.swift), or codz feature add, which sets it up first.');
342
+ const I = ' '.repeat(Number(c.indent) || 4);
343
+ const files = new Map(overlay ?? []);
344
+ const read = (p) => files.get(p) ?? readText(join(root, p));
345
+ const summary = [];
346
+ const warnings = [...input.warnings];
347
+ const { spec, reused, replaced, notes } = resolveModels(input, map.models, (n) => n, { replace: opts.replaceModels });
348
+ warnings.push(...notes);
349
+ // Path relative to APIClient.baseURL.
350
+ let path = spec.path;
351
+ const fromSpec = specRelativePath(spec, c.baseUrl);
352
+ if (fromSpec !== null)
353
+ path = fromSpec;
354
+ else if (c.baseUrl) {
355
+ try {
356
+ const b = new URL(c.baseUrl);
357
+ const prefix = b.pathname.replace(/\/$/, '');
358
+ if ((!spec.origin || spec.origin === b.origin) && prefix && path.startsWith(`${prefix}/`))
359
+ path = path.slice(prefix.length);
360
+ else if (spec.origin && spec.origin !== b.origin) {
361
+ path = `${spec.origin}${spec.path}`;
362
+ warnings.push(`The URL host ${spec.origin} differs from APIClient's baseURL (${c.baseUrl}), so the full URL is used for this call.`);
363
+ }
364
+ }
365
+ catch {
366
+ // Not a literal URL.
367
+ }
368
+ }
369
+ const existing = map.endpoints.find((e) => e.name === spec.name);
370
+ if (existing) {
371
+ if (existing.method === spec.method && existing.path.replace(/\{\w+\}/g, '{}') === path.replace(/\{\w+\}/g, '{}'))
372
+ return { files, summary: [`${spec.name} already exists in ${existing.file}; nothing to do.`], warnings: [], generated: [] };
373
+ throw new ToolError('name_conflict', `A method called ${spec.name} already exists in ${existing.file} for ${existing.method} ${existing.path}.`, 'Pass --name <methodName> to use another name.');
374
+ }
375
+ // Models: one struct per file.
376
+ const modelFile = new Map();
377
+ for (const [name, info] of reused)
378
+ modelFile.set(name, info.file);
379
+ for (const m of spec.models) {
380
+ const file = replaced.get(m.name)?.file ?? `${c.modelsFolder}/${m.name}.swift`;
381
+ modelFile.set(m.name, file);
382
+ const prev = read(file);
383
+ if (prev !== null && !replaced.has(m.name))
384
+ throw new ToolError('file_exists', `${file} already exists but does not define ${m.name}.`, 'Pass --model-name <Name> to use another name.');
385
+ files.set(file, renderStruct(m, I, warnings));
386
+ summary.push(`${prev === null ? 'Create' : 'Update'} ${file}: struct ${m.name}${replaced.has(m.name) ? ' (rewritten)' : ''}`);
387
+ }
388
+ // The method, in the resource's APIClient extension.
389
+ const resource = resourceOf(spec.path);
390
+ const fromEndpoints = map.endpoints.find((e) => resourceOf(e.path) === resource)?.file;
391
+ const apiFile = opts.targetFile ?? fromEndpoints ?? `${c.apiFolder}/APIClient+${pascal(resource)}.swift`;
392
+ let apiText = read(apiFile);
393
+ const created = apiText === null;
394
+ if (created)
395
+ apiText = 'import Foundation\n\nextension APIClient {\n}\n';
396
+ const params = new Map(spec.pathParams.map((p) => [p.name, swiftName(p.name)]));
397
+ const sig = [
398
+ ...spec.pathParams.map((p) => `${swiftName(p.name)}: ${p.type === 'int' ? 'Int' : 'String'}`),
399
+ ...spec.queryParams.map((q) => `${swiftName(q.name)}: ${swiftType(q.type) ?? 'String'}? = nil`),
400
+ ...(spec.request ? [`body: ${swiftType(spec.request) ?? 'Data'}`] : []),
401
+ ];
402
+ const resp = spec.method === 'DELETE' || spec.method === 'HEAD' ? null : swiftType(spec.response);
403
+ if (spec.response && !resp && spec.method !== 'DELETE' && spec.method !== 'HEAD')
404
+ warnings.push(`${spec.name}: the response has no type codz can map to Swift, so the method returns nothing. Change it to return a model when you know the shape.`);
405
+ const query = spec.queryParams.length ? `, query: [${spec.queryParams.map((q) => `"${swiftText(q.name)}": ${(swiftType(q.type) ?? 'String') === 'String' ? swiftName(q.name) : `${swiftName(q.name)}.map { String(describing: $0) }`}`).join(', ')}]` : '';
406
+ const body = spec.request ? ', body: try encode(body)' : '';
407
+ const call = `${resp ? 'try await send' : 'try await sendEmpty'}("${spec.method}", "${swiftPath(path, params)}"${query}${body})`;
408
+ const fun = [`${I}/// ${spec.method} ${commentText(spec.path)}`, `${I}func ${spec.name}(${sig.join(', ')}) async throws${resp ? ` -> ${resp}` : ''} {`, `${I}${I}${call}`, `${I}}`].join('\n');
409
+ const head = /\bextension\s+APIClient\b[^{]*\{/.exec(apiText) ?? /\b(?:class|struct|actor)\s+APIClient\b[^{]*\{/.exec(apiText);
410
+ if (!head)
411
+ throw new ToolError('no_api_client', `${apiFile} has no extension APIClient { }.`);
412
+ const close = swiftClose(apiText, head.index + head[0].length - 1);
413
+ const before = apiText.slice(0, close).replace(/\s*$/, '');
414
+ apiText = `${before}${before.endsWith('{') ? '\n' : '\n\n'}${fun}\n${apiText.slice(close)}`;
415
+ files.set(apiFile, apiText);
416
+ summary.push(`${created ? 'Create' : 'Update'} ${apiFile}: APIClient.${spec.name}() for ${spec.method} ${spec.path}`);
417
+ const added = [...files.keys()].filter((f) => f.endsWith('.swift') && readText(join(root, f)) === null && !(overlay?.has(f)));
418
+ if (c.fileSync === 'groups' && added.length)
419
+ warnings.push(`Add ${added.join(', ')} to the ${c.appName || 'app'} target in Xcode (this project does not use synchronized folders).`);
420
+ const refs = new Set();
421
+ referencedModels(spec.response, refs);
422
+ return {
423
+ files,
424
+ summary,
425
+ warnings,
426
+ generated: [{ skill: 'api add', file: apiFile, symbol: spec.name, inputHash: '' }],
427
+ endpoints: [{ name: spec.name, method: spec.method, path: path.startsWith('/') || /^https?:/.test(path) ? path : `/${path}`, response: resp ?? 'Void', file: apiFile, calledFrom: [] }],
428
+ models: spec.models.map((m) => ({ name: m.name, file: modelFile.get(m.name), fields: m.fields.filter((f) => swiftType(f.type)).map((f) => ({ name: f.jsonName, type: swiftType(f.type), nullable: f.nullable })) })),
429
+ };
430
+ }
431
+ export const iosAdapter = {
432
+ id: 'ios',
433
+ version: IOS_VERSION,
434
+ capabilities: { explain: true, apiAdd: true },
435
+ supportedSetup: 'iOS apps in Swift with SwiftUI: an APIClient over URLSession with async/await, Codable structs, SwiftUI views with ObservableObject view models. Not yet: UIKit screens, Alamofire, tests.',
436
+ scan,
437
+ planEndpoint,
438
+ architectures: iosArchitectures,
439
+ planSetup: planIosSetup,
440
+ };
@@ -0,0 +1,110 @@
1
+ // Kotlin code generation for the Android adapter: data classes for models
2
+ // (kotlinx.serialization, Moshi or Gson), Retrofit interface methods, imports
3
+ // and package lines.
4
+ import { commentText, ktString } from "../core/safe.js";
5
+ import { camel } from "../input/naming.js";
6
+ export const KOTLIN_RESERVED = new Set([
7
+ 'as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', 'if', 'in', 'interface', 'is', 'null', 'object',
8
+ 'package', 'return', 'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val', 'var', 'when', 'while',
9
+ ]);
10
+ export function kotlinName(jsonKey) {
11
+ let id = camel(jsonKey) || 'field';
12
+ if (/^[0-9]/.test(id))
13
+ id = `n${id}`;
14
+ return KOTLIN_RESERVED.has(id) ? `\`${id}\`` : id;
15
+ }
16
+ export function kotlinType(t, c) {
17
+ switch (t.kind) {
18
+ case 'string':
19
+ case 'date':
20
+ return 'String';
21
+ case 'int':
22
+ return 'Int';
23
+ case 'double':
24
+ return 'Double';
25
+ case 'bool':
26
+ return 'Boolean';
27
+ case 'unknown':
28
+ return c.serialization === 'kotlinx' ? 'JsonElement' : 'Any';
29
+ case 'object':
30
+ return t.model;
31
+ case 'array':
32
+ return `List<${kotlinType(t.item, c)}>`;
33
+ }
34
+ }
35
+ const usesUnknown = (t) => t.kind === 'unknown' || (t.kind === 'array' && usesUnknown(t.item));
36
+ export function renderDataClass(m, c, pkg) {
37
+ const ser = c.serialization || 'kotlinx';
38
+ const imports = new Set();
39
+ const fieldAnn = (json, name) => {
40
+ if (name.replace(/`/g, '') === json)
41
+ return '';
42
+ if (ser === 'kotlinx')
43
+ return `@SerialName(${ktString(json)}) `;
44
+ if (ser === 'moshi')
45
+ return `@Json(name = ${ktString(json)}) `;
46
+ return `@SerializedName(${ktString(json)}) `;
47
+ };
48
+ if (ser === 'kotlinx')
49
+ imports.add('kotlinx.serialization.Serializable');
50
+ if (ser === 'moshi')
51
+ imports.add('com.squareup.moshi.JsonClass');
52
+ const lines = m.fields.map((f) => {
53
+ const name = kotlinName(f.jsonName);
54
+ const ann = fieldAnn(f.jsonName, name);
55
+ if (ann)
56
+ imports.add(ser === 'kotlinx' ? 'kotlinx.serialization.SerialName' : ser === 'moshi' ? 'com.squareup.moshi.Json' : 'com.google.gson.annotations.SerializedName');
57
+ if (usesUnknown(f.type) && ser === 'kotlinx')
58
+ imports.add('kotlinx.serialization.json.JsonElement');
59
+ const optional = f.nullable || f.type.kind === 'unknown';
60
+ const type = kotlinType(f.type, c) + (optional ? '?' : '');
61
+ const unknown = usesUnknown(f.type) ? ' // codz: type unknown from the samples; set it by hand' : '';
62
+ return ` ${ann}val ${name}: ${type}${optional ? ' = null' : ''},${unknown}`;
63
+ });
64
+ const classAnn = ser === 'kotlinx' ? '@Serializable\n' : ser === 'moshi' ? '@JsonClass(generateAdapter = true)\n' : '';
65
+ const head = [`package ${pkg}`, '', ...[...imports].sort().map((i) => `import ${i}`), ...(imports.size ? [''] : [])];
66
+ return [...head, `${classAnn}data class ${m.name}(`, ...lines, ')', ''].join('\n');
67
+ }
68
+ /** A Retrofit method. Paths are relative to the base URL (no leading slash), which must end in "/". */
69
+ export function renderRetrofitFun(spec, c, path) {
70
+ const imports = new Set([`retrofit2.http.${spec.method}`]);
71
+ const params = spec.pathParams.map((p) => {
72
+ imports.add('retrofit2.http.Path');
73
+ return `@Path(${ktString(p.name)}) ${kotlinName(p.name)}: ${p.type === 'int' ? 'Int' : 'String'}`;
74
+ });
75
+ if (spec.request) {
76
+ imports.add('retrofit2.http.Body');
77
+ params.push(`@Body body: ${kotlinType(spec.request, c)}`);
78
+ }
79
+ for (const q of spec.queryParams) {
80
+ imports.add('retrofit2.http.Query');
81
+ params.push(`@Query(${ktString(q.name)}) ${kotlinName(q.name)}: ${kotlinType(q.type, c)}? = null`);
82
+ }
83
+ const noBody = spec.method === 'DELETE' || spec.method === 'HEAD';
84
+ const result = noBody ? 'Unit' : spec.response ? kotlinType(spec.response, c) : c.serialization === 'kotlinx' ? 'JsonElement' : 'Any';
85
+ if (!noBody && spec.response && usesUnknown(spec.response) && c.serialization === 'kotlinx')
86
+ imports.add('kotlinx.serialization.json.JsonElement');
87
+ if (!noBody && !spec.response && c.serialization === 'kotlinx')
88
+ imports.add('kotlinx.serialization.json.JsonElement');
89
+ const rel = path.replace(/^\//, '');
90
+ const sig = params.length > 2 ? `(\n ${params.join(',\n ')},\n )` : `(${params.join(', ')})`;
91
+ const code = [` /** ${spec.method} ${commentText(spec.path)} */`, ` @${spec.method}(${ktString(rel)})`, ` suspend fun ${spec.name}${sig}${result === 'Unit' ? '' : `: ${result}`}`].join('\n');
92
+ return { code, imports: [...imports] };
93
+ }
94
+ /** Adds `import x` lines to a Kotlin file, sorted, after the package line. */
95
+ export function addKotlinImports(text, imports, pkg) {
96
+ const own = (i) => i.slice(0, i.lastIndexOf('.')) === pkg;
97
+ const want = [...new Set(imports)].filter((i) => !own(i) && !new RegExp(`^import\\s+${i.replace(/\./g, '\\.')}\\s*$`, 'm').test(text));
98
+ if (!want.length)
99
+ return text;
100
+ const lines = text.split('\n');
101
+ const existing = lines.map((l, i) => ({ l, i })).filter((x) => /^import\s+/.test(x.l));
102
+ if (existing.length) {
103
+ const all = [...new Set([...existing.map((x) => x.l), ...want.map((w) => `import ${w}`)])].sort();
104
+ lines.splice(existing[0].i, existing[existing.length - 1].i - existing[0].i + 1, ...all);
105
+ return lines.join('\n');
106
+ }
107
+ const p = lines.findIndex((l) => /^package\s+/.test(l));
108
+ lines.splice(p + 1, 0, '', ...want.sort().map((w) => `import ${w}`));
109
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
110
+ }
@@ -0,0 +1,116 @@
1
+ // Decides, for each inferred model, whether the project already has it.
2
+ // Same name + same field names → reuse the existing model (no new code).
3
+ // Same name + different fields → nested models get a prefixed name; the root
4
+ // model is a conflict the developer resolves with --model-name.
5
+ import { ToolError } from "../core/types.js";
6
+ import { pascal } from "../input/naming.js";
7
+ const norm = (s) => s.replace(/[_\-\s]/g, '').toLowerCase();
8
+ function renameType(t, from, to) {
9
+ if (t.kind === 'object' && t.model === from)
10
+ return { kind: 'object', model: to };
11
+ if (t.kind === 'array')
12
+ return { kind: 'array', item: renameType(t.item, from, to) };
13
+ return t;
14
+ }
15
+ function rootModels(spec) {
16
+ const roots = new Set();
17
+ const add = (t) => {
18
+ if (!t)
19
+ return;
20
+ if (t.kind === 'object')
21
+ roots.add(t.model);
22
+ if (t.kind === 'array')
23
+ add(t.item);
24
+ };
25
+ add(spec.response);
26
+ add(spec.request);
27
+ return roots;
28
+ }
29
+ /**
30
+ * @param className maps an inferred model name to the class/interface name the adapter would generate.
31
+ */
32
+ export function resolveModels(input, existing, className, opts = {}) {
33
+ const spec = structuredClone(input);
34
+ const reused = new Map();
35
+ const replaced = new Map();
36
+ const notes = [];
37
+ const roots = rootModels(spec);
38
+ const byName = new Map(existing.map((m) => [m.name, m]));
39
+ const generatedNames = new Set(spec.models.map((m) => className(m.name)));
40
+ for (const model of [...spec.models]) {
41
+ const cls = className(model.name);
42
+ const found = byName.get(cls);
43
+ if (!found)
44
+ continue;
45
+ const wanted = new Set(model.fields.map((f) => norm(f.jsonName)));
46
+ const have = new Set(found.fields.map((f) => norm(f.name)));
47
+ const same = wanted.size === have.size && [...wanted].every((f) => have.has(f));
48
+ if (same) {
49
+ reused.set(model.name, found);
50
+ notes.push(`Reusing the existing model ${cls} (${found.file}).`);
51
+ continue;
52
+ }
53
+ if (opts.replace) {
54
+ replaced.set(model.name, found);
55
+ notes.push(`Updating the model ${cls} (${found.file}).`);
56
+ continue;
57
+ }
58
+ if (roots.has(model.name)) {
59
+ throw new ToolError('model_conflict', `The project already has a model called ${cls} (${found.file}) with different fields than this response.`, `Pass --model-name <Name> to generate the response model under another name, or update ${found.file} first.`);
60
+ }
61
+ // Nested model: prefix with the root response model name.
62
+ const rootName = [...roots][0] ?? spec.name;
63
+ let to = pascal(`${rootName} ${model.name}`);
64
+ let n = 2;
65
+ while (byName.has(className(to)) || generatedNames.has(className(to)))
66
+ to = `${pascal(`${rootName} ${model.name}`)}${n++}`;
67
+ notes.push(`The project already has a different ${cls}; the new nested model is called ${className(to)}.`);
68
+ for (const m of spec.models)
69
+ m.fields = m.fields.map((f) => ({ ...f, type: renameType(f.type, model.name, to) }));
70
+ if (spec.response)
71
+ spec.response = renameType(spec.response, model.name, to);
72
+ if (spec.request)
73
+ spec.request = renameType(spec.request, model.name, to);
74
+ generatedNames.add(className(to));
75
+ model.name = to;
76
+ }
77
+ // Keep only models still reachable from the request/response without going
78
+ // through a reused model (a reused model brings its own nested types).
79
+ const byInferred = new Map(spec.models.map((m) => [m.name, m]));
80
+ const reachable = new Set();
81
+ const visit = (name) => {
82
+ if (reachable.has(name) || reused.has(name))
83
+ return;
84
+ const m = byInferred.get(name);
85
+ if (!m)
86
+ return;
87
+ reachable.add(name);
88
+ for (const f of m.fields)
89
+ for (const r of referencedModels(f.type))
90
+ visit(r);
91
+ };
92
+ for (const r of referencedModels(spec.response))
93
+ visit(r);
94
+ for (const r of referencedModels(spec.request))
95
+ visit(r);
96
+ spec.models = spec.models.filter((m) => reachable.has(m.name));
97
+ // Types that refer to an existing model without describing it (api update keeps the request type) reuse it.
98
+ for (const name of [...referencedModels(spec.response), ...referencedModels(spec.request)]) {
99
+ if (byInferred.has(name) || reused.has(name))
100
+ continue;
101
+ const found = byName.get(className(name));
102
+ if (found)
103
+ reused.set(name, found);
104
+ }
105
+ return { spec, reused, replaced, notes };
106
+ }
107
+ /** Collects the model names a type refers to. */
108
+ export function referencedModels(t, out = new Set()) {
109
+ if (!t)
110
+ return out;
111
+ if (t.kind === 'object')
112
+ out.add(t.model);
113
+ if (t.kind === 'array')
114
+ referencedModels(t.item, out);
115
+ return out;
116
+ }