codz-cli 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +525 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +638 -0
  77. package/dist/skills/createDemo.js +1026 -0
  78. package/dist/skills/doctor.js +304 -0
  79. package/dist/skills/doctorFix.js +214 -0
  80. package/dist/skills/explain.js +61 -0
  81. package/dist/skills/feature.js +418 -0
  82. package/dist/skills/featureEdit.js +225 -0
  83. package/dist/skills/featureLinks.js +179 -0
  84. package/dist/skills/init.js +66 -0
  85. package/dist/skills/iosUi.js +468 -0
  86. package/dist/skills/locale.js +335 -0
  87. package/dist/skills/mcp.js +175 -0
  88. package/dist/skills/mock.js +482 -0
  89. package/dist/skills/module.js +23 -0
  90. package/dist/skills/plans.js +66 -0
  91. package/dist/skills/prefill.js +84 -0
  92. package/dist/skills/reactNativeUi.js +388 -0
  93. package/dist/skills/reactUi.js +352 -0
  94. package/dist/skills/report.js +75 -0
  95. package/dist/skills/route.js +230 -0
  96. package/dist/skills/screen.js +597 -0
  97. package/dist/skills/screenForm.js +410 -0
  98. package/dist/skills/setup.js +28 -0
  99. package/dist/skills/setupDoctor.js +226 -0
  100. package/dist/skills/templatesCmd.js +32 -0
  101. package/dist/skills/testgen.js +331 -0
  102. package/dist/skills/theme.js +388 -0
  103. package/dist/starters/auth.js +144 -0
  104. package/dist/starters/commands.js +283 -0
  105. package/dist/starters/engine.js +315 -0
  106. package/dist/starters/home.js +42 -0
  107. package/dist/starters/http.js +93 -0
  108. package/dist/starters/manifest.js +285 -0
  109. package/dist/starters/paths.js +70 -0
  110. package/dist/starters/publish.js +150 -0
  111. package/dist/starters/registry.js +258 -0
  112. package/dist/starters/sign.js +72 -0
  113. package/dist/starters/source.js +98 -0
  114. package/dist/starters/strictJson.js +148 -0
  115. package/dist/starters/tar.js +180 -0
  116. package/docs/COMMANDS.md +527 -0
  117. package/examples/openapi.yaml +319 -0
  118. package/package.json +57 -4
@@ -0,0 +1,482 @@
1
+ // mock add: canned responses so screens can be built before the backend is ready.
2
+ //
3
+ // Flutter: lib/.../network/mocks/mock_responses.dart (the JSON) and
4
+ // mock_interceptor.dart (a Dio interceptor), added to createDio()
5
+ // and active only with: flutter run --dart-define=USE_MOCKS=true
6
+ // Next.js: lib/api/mocks.ts, checked at the top of the request helper when
7
+ // NEXT_PUBLIC_USE_MOCKS=true
8
+ //
9
+ // The JSON comes from --sample, or is generated from the response model with
10
+ // fixed, readable values (same model, same mock).
11
+ import { join, posix } from 'node:path';
12
+ import { addDartImport, importUri } from "../adapters/flutter.js";
13
+ import { matchBrace } from "../adapters/source.js";
14
+ import { importPath } from "../adapters/nextjs.js";
15
+ import { loadMap } from "../core/cache.js";
16
+ import { buildChangeSet } from "../core/changeset.js";
17
+ import { loadConfig } from "../core/config.js";
18
+ import { detectEol, hash, readText, withEol } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ import { findDeclaration } from "./apiEdit.js";
21
+ /** Marker above each generated mock: the hash of the value codz wrote, to notice hand edits. */
22
+ const markerFor = (value) => `// codz:${hash(value.trim()).slice(0, 10)}`;
23
+ const MARKER = /^[ \t]*\/\/ codz:([0-9a-f]{10})[ \t]*\n/;
24
+ /** Was the mock at `entryStart` edited since codz wrote it? No marker counts as edited. */
25
+ function handEdited(text, entryStart, value) {
26
+ const lineStart = text.lastIndexOf('\n', entryStart - 1);
27
+ const prevStart = text.lastIndexOf('\n', lineStart - 1) + 1;
28
+ const m = MARKER.exec(text.slice(prevStart, lineStart + 1));
29
+ return !m || m[1] !== hash(value.trim()).slice(0, 10);
30
+ }
31
+ const SECRET_KEY = /(token|secret|passw(or)?d|pwd|api[_-]?key|apikey|auth|cookie|session|jwt|otp|(^|_)pin$|ssn|card|cvv|cvc|iban|account[_-]?(no|number)|signature|private|credential)/i;
32
+ const PERSONAL_KEY = /(email|phone|mobile|address|street|city|zip|postal|birth|dob|first[_-]?name|last[_-]?name|full[_-]?name|^name$|user[_-]?name|display[_-]?name|national|passport|ip[_-]?addr|lat(itude)?$|lng$|lon(gitude)?$)/i;
33
+ const SECRET_VALUE = /^(eyJ[\w-]+\.[\w-]+\.[\w-]+|[A-Fa-f0-9]{32,}|[A-Za-z0-9+/_-]{40,}={0,2}|(sk|pk|rk)_(live|test)_\w+|gh[pousr]_\w{20,}|xox[abprs]-[\w-]+)$/;
34
+ /**
35
+ * A copy of a real response that is safe to commit: values under secret-looking
36
+ * keys, values that look like tokens, and personal data are replaced with
37
+ * generated examples. Structure, types and every other value stay.
38
+ */
39
+ export function scrubSample(value, map, replaced, key = '', index = 1) {
40
+ if (Array.isArray(value))
41
+ return value.map((v, i) => scrubSample(v, map, replaced, key, i + 1));
42
+ if (value && typeof value === 'object') {
43
+ const out = {};
44
+ for (const [k, v] of Object.entries(value))
45
+ out[k] = scrubSample(v, map, replaced, k, index);
46
+ return out;
47
+ }
48
+ if (value === null || typeof value === 'boolean')
49
+ return value;
50
+ const sensitiveKey = SECRET_KEY.test(key) || PERSONAL_KEY.test(key);
51
+ const sensitiveValue = typeof value === 'string' && SECRET_VALUE.test(value);
52
+ if (!sensitiveKey && !sensitiveValue)
53
+ return value;
54
+ replaced.add(key || '(value)');
55
+ if (typeof value === 'number')
56
+ return SECRET_KEY.test(key) ? 0 : exampleFor(Number.isInteger(value) ? 'int' : 'double', map, key, 0, index);
57
+ if (SECRET_KEY.test(key) || sensitiveValue)
58
+ return `mock-${(key || 'value').replace(/[^\w-]/g, '').toLowerCase()}`;
59
+ return exampleFor('string', map, key, 0, index);
60
+ }
61
+ /** Builds deterministic example data from a type name as the project writes it. */
62
+ export function exampleFor(type, map, fieldName = '', depth = 0, index = 1) {
63
+ const t = type.replace(/\?$/, '').replace(/\s*\|\s*null$/, '').trim();
64
+ const list = /^List<(.+)>$/.exec(t) ?? /^(.+)\[\]$/.exec(t) ?? /^Array<(.+)>$/.exec(t);
65
+ if (list)
66
+ return depth > 3 ? [] : [exampleFor(list[1], map, fieldName, depth + 1, 1), exampleFor(list[1], map, fieldName, depth + 1, 2)];
67
+ const model = map.models.find((m) => m.name === t);
68
+ if (model)
69
+ return depth > 3 ? {} : exampleObject(model, map, depth + 1, index);
70
+ const n = fieldName.toLowerCase();
71
+ if (/^(int|number|num|Int|Long)$/.test(t)) {
72
+ if (/(^|_)id$|^id$/.test(n) || n.endsWith('id'))
73
+ return index;
74
+ if (/(count|qty|quantity|total|page|limit|age)/.test(n))
75
+ return 2;
76
+ return 10;
77
+ }
78
+ if (/^(double|Double|Float)$/.test(t))
79
+ return /(price|amount|total|cost|fee|balance)/.test(n) ? 19.99 : 4.5;
80
+ if (/^(bool|boolean|Boolean)$/.test(t))
81
+ return true;
82
+ if (t === 'DateTime' || (t === 'string' && /(_at|date|time)$/.test(n)))
83
+ return '2026-01-01T09:00:00Z';
84
+ if (/^(String|string)$/.test(t)) {
85
+ if (/email/.test(n))
86
+ return `user${index}@example.com`;
87
+ if (/(url|avatar|image|photo|link)/.test(n))
88
+ return 'https://example.com/image.png';
89
+ if (/phone|mobile/.test(n))
90
+ return '+1 555 0100';
91
+ if (/(^|_)id$|^id$/.test(n))
92
+ return `id_${index}`;
93
+ if (/status|state/.test(n))
94
+ return 'active';
95
+ if (/name|title/.test(n))
96
+ return `Sample ${fieldName.replace(/_/g, ' ')} ${index}`;
97
+ return `Sample ${fieldName.replace(/_/g, ' ') || 'text'}`;
98
+ }
99
+ return null;
100
+ }
101
+ function exampleObject(model, map, depth, index) {
102
+ const out = {};
103
+ for (const f of model.fields)
104
+ out[f.name] = exampleFor(f.type, map, f.name, depth, index);
105
+ return out;
106
+ }
107
+ function mockKey(ep) {
108
+ return `${ep.method} ${ep.path}`;
109
+ }
110
+ function bodyFor(root, ep, map, sample, lang, keepValues = false, warnings = []) {
111
+ if (sample) {
112
+ const text = readText(join(root, sample)) ?? readText(sample);
113
+ if (text === null)
114
+ throw new ToolError('no_sample', `Cannot read the sample file ${sample}.`);
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(text);
118
+ }
119
+ catch (e) {
120
+ throw new ToolError('bad_sample', `${sample} is not valid JSON: ${e.message}`);
121
+ }
122
+ if (keepValues) {
123
+ warnings.push(`The mock for ${ep.name} holds the sample's values exactly as given (--keep-values). Check it for tokens or personal data before committing.`);
124
+ return parsed;
125
+ }
126
+ const replaced = new Set();
127
+ const safe = scrubSample(parsed, map, replaced);
128
+ if (replaced.size)
129
+ warnings.push(`Replaced values that look like secrets or personal data in the ${ep.name} mock (${[...replaced].slice(0, 8).join(', ')}${replaced.size > 8 ? ', …' : ''}) with examples. Pass --keep-values to keep them.`);
130
+ return safe;
131
+ }
132
+ const type = (ep.response ?? '').replace(/^Promise<(.*)>$/, '$1');
133
+ if (!type || /^(void|unknown|dynamic)$/.test(type))
134
+ return ep.method === 'DELETE' ? null : {};
135
+ const value = exampleFor(type, map);
136
+ // Keep the envelope the code unwraps.
137
+ const text = readText(join(root, ep.file)) ?? '';
138
+ const decl = findDeclaration(text, ep.name, lang);
139
+ const unwraps = decl && unwrapsData(text.slice(decl.start, decl.end), map.conventions);
140
+ return unwraps ? { data: value } : value;
141
+ }
142
+ /** Does this function return the `data` field of a {"data": ...} envelope? (axios functions read res.data either way.) */
143
+ export function unwrapsData(declText, c) {
144
+ if (c.http === 'axios')
145
+ return /res\.data\.data\b/.test(declText);
146
+ return /res\.data\b|json\.data\b|\['data'\]/.test(declText);
147
+ }
148
+ // Path matching compares segments ({id} matches one segment), so dots and other
149
+ // characters in a path are literal. Older versions built a RegExp without escaping.
150
+ const tsMatcher = (q, semi) => [
151
+ ` const want = pattern.split(${q}/${q})${semi}`,
152
+ ` const got = pathname.split(${q}/${q})${semi}`,
153
+ ` if (want.length === got.length && want.every((s, i) => /^\\{[^/{}]+\\}$/.test(s) || s === got[i])) return value${semi}`,
154
+ ];
155
+ const OLD_TS_MATCHER = /^[ \t]*if \(new RegExp\(`\^\$\{pattern\.replace\(.*\)\.test\(pathname\)\) return value;?[ \t]*$/m;
156
+ const DART_MATCHER = [
157
+ " final want = entry.key.substring(space + 1).split('/');",
158
+ " final got = path.split('/');",
159
+ ' if (want.length != got.length) continue;',
160
+ ' var same = true;',
161
+ ' for (var i = 0; i < want.length; i++) {',
162
+ " final param = want[i].startsWith('{') && want[i].endsWith('}');",
163
+ ' if (!param && want[i] != got[i]) same = false;',
164
+ ' }',
165
+ ' if (same) return entry.value;',
166
+ ];
167
+ const OLD_DART_MATCHER = /^[ \t]*final pattern = entry\.key\.substring\(space \+ 1\)\.replaceAll\(.*\n[ \t]*if \(RegExp\('\^\$pattern.*\.hasMatch\(path\)\) return entry\.value;[ \t]*$/m;
168
+ function indentJson(value, indent) {
169
+ return JSON.stringify(value, null, 2).split('\n').join(`\n${indent}`);
170
+ }
171
+ // ---------------- Flutter ----------------
172
+ function flutterMocks(root, map, eps, sample, keepValues, force = false) {
173
+ const kept = [];
174
+ const c = map.conventions;
175
+ const files = new Map();
176
+ const summary = [];
177
+ const warnings = [];
178
+ const network = c.networkFile || '';
179
+ const dir = network ? `${posix.dirname(network)}/mocks` : 'lib/core/network/mocks';
180
+ const responsesFile = `${dir}/mock_responses.dart`;
181
+ const interceptorFile = `${dir}/mock_interceptor.dart`;
182
+ let responses = readText(join(root, responsesFile)) ?? [
183
+ '// Mock responses, used when the app runs with --dart-define=USE_MOCKS=true.',
184
+ '// Managed by `codz mock add`; edit the JSON freely.',
185
+ 'const mockResponses = <String, String>{',
186
+ '};',
187
+ '',
188
+ ].join('\n');
189
+ const eol = detectEol(responses);
190
+ responses = responses.replace(/\r\n/g, '\n');
191
+ for (const ep of eps) {
192
+ const key = mockKey(ep);
193
+ const escKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
+ const existing = new RegExp(`(?:[ \\t]*// codz:[0-9a-f]{10}[ \\t]*\\n)? '${escKey}': r'''\\n([\\s\\S]*?)\\n''',`).exec(responses);
195
+ if (existing && !sample && !force) {
196
+ const at = responses.indexOf(` '${key}': r`, existing.index);
197
+ if (handEdited(responses, at, existing[1])) {
198
+ kept.push(`${ep.name} (${key})`);
199
+ continue;
200
+ }
201
+ }
202
+ // Inside a Dart raw string r'''…''': a quote in the data is written as \u0027 (still valid JSON), so ''' can never end the string early.
203
+ const json = JSON.stringify(bodyFor(root, ep, map, sample, 'dart', keepValues, warnings), null, 2).replace(/'/g, '\\u0027');
204
+ const entry = ` ${markerFor(json)}\n '${key}': r'''\n${json}\n''',`;
205
+ if (existing) {
206
+ responses = responses.slice(0, existing.index) + entry + responses.slice(existing.index + existing[0].length);
207
+ summary.push(`Update the mock for ${ep.name} (${key})`);
208
+ }
209
+ else {
210
+ const close = responses.lastIndexOf('};');
211
+ responses = `${responses.slice(0, close)}${entry}\n${responses.slice(close)}`;
212
+ summary.push(`Add a mock for ${ep.name} (${key})`);
213
+ }
214
+ }
215
+ if (kept.length)
216
+ warnings.push(`Kept mocks you edited by hand: ${kept.join(', ')}. Pass --force to regenerate them.`);
217
+ files.set(responsesFile, withEol(responses, eol));
218
+ const oldInterceptor = readText(join(root, interceptorFile));
219
+ if (oldInterceptor !== null && OLD_DART_MATCHER.test(oldInterceptor.replace(/\r\n/g, '\n'))) {
220
+ files.set(interceptorFile, withEol(oldInterceptor.replace(/\r\n/g, '\n').replace(OLD_DART_MATCHER, DART_MATCHER.join('\n')), detectEol(oldInterceptor)));
221
+ summary.push(`Update ${interceptorFile}: match paths segment by segment (a "." in a path no longer matches any character)`);
222
+ }
223
+ if (oldInterceptor === null) {
224
+ let interceptor = [
225
+ '/// Answers requests from [mockResponses] instead of the network.',
226
+ '///',
227
+ '/// Turn it on with: flutter run --dart-define=USE_MOCKS=true',
228
+ 'class MockInterceptor extends Interceptor {',
229
+ " static const enabled = bool.fromEnvironment('USE_MOCKS');",
230
+ '',
231
+ ' @override',
232
+ ' void onRequest(RequestOptions options, RequestInterceptorHandler handler) {',
233
+ ' final body = _find(options.method, Uri.parse(options.path).path);',
234
+ ' if (body == null) {',
235
+ ' handler.next(options);',
236
+ ' return;',
237
+ ' }',
238
+ ' handler.resolve(Response<dynamic>(requestOptions: options, statusCode: 200, data: jsonDecode(body)));',
239
+ ' }',
240
+ '',
241
+ ' String? _find(String method, String path) {',
242
+ ' for (final entry in mockResponses.entries) {',
243
+ " final space = entry.key.indexOf(' ');",
244
+ ' if (entry.key.substring(0, space) != method.toUpperCase()) continue;',
245
+ ...DART_MATCHER,
246
+ ' }',
247
+ ' return null;',
248
+ ' }',
249
+ '}',
250
+ ].join('\n') + '\n';
251
+ interceptor = addDartImport(interceptor, 'dart:convert');
252
+ interceptor = addDartImport(interceptor, 'package:dio/dio.dart');
253
+ interceptor = addDartImport(interceptor, importUri(interceptorFile, responsesFile, c));
254
+ files.set(interceptorFile, interceptor);
255
+ summary.push(`Create ${interceptorFile}: MockInterceptor, on only with --dart-define=USE_MOCKS=true`);
256
+ const net = network ? readText(join(root, network)) : null;
257
+ if (net !== null && !/MockInterceptor/.test(net)) {
258
+ if (/^([ \t]*)return\s+dio\s*;/m.test(net)) {
259
+ let t = net.replace(/\r\n/g, '\n');
260
+ const r2 = /^([ \t]*)return\s+dio\s*;/m.exec(t);
261
+ t = `${t.slice(0, r2.index)}${r2[1]}if (MockInterceptor.enabled) dio.interceptors.add(MockInterceptor());\n${t.slice(r2.index)}`;
262
+ t = addDartImport(t, importUri(network, interceptorFile, c));
263
+ files.set(network, withEol(t, detectEol(net)));
264
+ summary.push(`Update ${network}: add MockInterceptor when USE_MOCKS is set`);
265
+ }
266
+ else if (/(?:=>|=)\s*Dio\s*\(/.test(net)) {
267
+ // Dio created in an expression (a get_it factory, a field): add the interceptor with a cascade.
268
+ let t = net.replace(/\r\n/g, '\n');
269
+ const m = /(?:=>|=)\s*Dio\s*\(/.exec(t);
270
+ const close = matchBrace(t, m.index + m[0].length - 1, 'dart');
271
+ t = `${t.slice(0, close + 1)}..interceptors.addAll([if (MockInterceptor.enabled) MockInterceptor()])${t.slice(close + 1)}`;
272
+ t = addDartImport(t, importUri(network, interceptorFile, c));
273
+ files.set(network, withEol(t, detectEol(net)));
274
+ summary.push(`Update ${network}: add MockInterceptor when USE_MOCKS is set`);
275
+ }
276
+ else {
277
+ warnings.push(`Could not find "return dio;" in ${network}. Add this where Dio is created: if (MockInterceptor.enabled) dio.interceptors.add(MockInterceptor());`);
278
+ }
279
+ }
280
+ else if (net === null) {
281
+ warnings.push('No file that creates Dio was found. Add this where Dio is created: if (MockInterceptor.enabled) dio.interceptors.add(MockInterceptor());');
282
+ }
283
+ }
284
+ summary.push('Run with mocks: flutter run --dart-define=USE_MOCKS=true');
285
+ return { files, summary, warnings };
286
+ }
287
+ // ---------------- Next.js ----------------
288
+ function nextMocks(root, map, eps, sample, keepValues, force = false) {
289
+ const kept = [];
290
+ const c = map.conventions;
291
+ const axiosMode = c.http === 'axios';
292
+ if (axiosMode && !c.axiosInstanceFile) {
293
+ throw new ToolError('not_supported', 'Mocks hook into a shared axios instance, and this project calls axios directly.', 'Create one (export const api = axios.create({ baseURL: ... })) and call it from your API functions, then run mock add again.');
294
+ }
295
+ const hookFile = axiosMode ? c.axiosInstanceFile : c.httpHelperFile;
296
+ if (!axiosMode && (!c.httpHelper || c.httpHelper === 'fetch' || !c.httpHelperFile)) {
297
+ throw new ToolError('not_supported', 'Mocks need a shared request helper (such as apiFetch) to hook into, and this project calls fetch directly.', 'A fresh project gets one with: codz setup --architecture fetch-helper');
298
+ }
299
+ const files = new Map();
300
+ const summary = [];
301
+ const warnings = [];
302
+ const q = c.quote === 'double' ? '"' : "'";
303
+ const semi = c.semicolons === 'false' ? '' : ';';
304
+ const mocksFile = `${posix.dirname(hookFile)}/mocks.ts`;
305
+ const rnFlag0 = map.framework === 'react-native' && !!c.configFile;
306
+ // The env variable that turns mocks on: Next.js, Vite and Create React App each have their own prefix.
307
+ const mockEnv = map.framework === 'react' ? (c.bundler === 'cra' ? 'REACT_APP_USE_MOCKS' : 'VITE_USE_MOCKS') : 'NEXT_PUBLIC_USE_MOCKS';
308
+ const mockEnvRead = map.framework === 'react' && c.bundler !== 'cra' ? `import.meta.env.${mockEnv}` : `process.env.${mockEnv}`;
309
+ let mocks = readText(join(root, mocksFile)) ?? [
310
+ rnFlag0 ? '// Mock responses, used when USE_MOCKS is true.' : `// Mock responses, used when ${mockEnv}=true.`,
311
+ '// Managed by `codz mock add`; edit the values freely.',
312
+ `export const mockResponses: Record<string, unknown> = {`,
313
+ `}${semi}`,
314
+ '',
315
+ '/** Returns the mock for a request, or undefined when there is none. */',
316
+ 'export function findMock(method: string, path: string): unknown {',
317
+ ` const pathname = path.split(${q}?${q})[0]${semi}`,
318
+ ' for (const [key, value] of Object.entries(mockResponses)) {',
319
+ ` const [m, pattern] = key.split(${q} ${q})${semi}`,
320
+ ` if (m !== method.toUpperCase()) continue${semi}`,
321
+ ...tsMatcher(q, semi),
322
+ ' }',
323
+ ` return undefined${semi}`,
324
+ '}',
325
+ '',
326
+ ].join('\n');
327
+ const eol = detectEol(mocks);
328
+ mocks = mocks.replace(/\r\n/g, '\n');
329
+ if (OLD_TS_MATCHER.test(mocks)) {
330
+ mocks = mocks.replace(OLD_TS_MATCHER, tsMatcher(q, semi).join('\n'));
331
+ summary.push(`Update ${mocksFile}: match paths segment by segment (a "." in a path no longer matches any character)`);
332
+ }
333
+ for (const ep of eps) {
334
+ const key = mockKey(ep);
335
+ const start = mocks.indexOf(` ${q}${key}${q}: `);
336
+ let vEnd = -1;
337
+ if (start !== -1) {
338
+ const vStart = start + ` ${q}${key}${q}: `.length;
339
+ const ch = mocks[vStart];
340
+ vEnd = ch === '{' || ch === '[' ? matchBrace(mocks, vStart) + 1 : mocks.indexOf('\n', vStart);
341
+ if (mocks[vEnd - 1] === ',')
342
+ vEnd--;
343
+ if (!sample && !force && handEdited(mocks, start, mocks.slice(vStart, vEnd))) {
344
+ kept.push(`${ep.name} (${key})`);
345
+ continue;
346
+ }
347
+ }
348
+ const value = indentJson(bodyFor(root, ep, map, sample, 'ts', keepValues, warnings), ' ');
349
+ const entry = ` ${markerFor(value)}\n ${q}${key}${q}: ${value},`;
350
+ if (start !== -1) {
351
+ // Replace the marker line above (if any), the key and the value.
352
+ const lineStart = mocks.lastIndexOf('\n', start - 1);
353
+ const prevStart = mocks.lastIndexOf('\n', lineStart - 1) + 1;
354
+ const from = MARKER.test(mocks.slice(prevStart, lineStart + 1)) ? prevStart : start;
355
+ let end = vEnd;
356
+ if (mocks[end] === ',')
357
+ end++;
358
+ mocks = mocks.slice(0, from) + entry + mocks.slice(end);
359
+ summary.push(`Update the mock for ${ep.name} (${key})`);
360
+ }
361
+ else {
362
+ const open = mocks.indexOf('mockResponses: Record<string, unknown> = {');
363
+ const close = matchBrace(mocks, mocks.indexOf('{', open));
364
+ mocks = `${mocks.slice(0, close).replace(/\s*$/, '\n')}${entry}\n${mocks.slice(close)}`;
365
+ summary.push(`Add a mock for ${ep.name} (${key})`);
366
+ }
367
+ }
368
+ if (kept.length)
369
+ warnings.push(`Kept mocks you edited by hand: ${kept.join(', ')}. Pass --force to regenerate them.`);
370
+ files.set(mocksFile, withEol(mocks, eol));
371
+ const helper = readText(join(root, hookFile)) ?? '';
372
+ // React Native keeps the switch in its config file (USE_MOCKS); Next.js reads NEXT_PUBLIC_USE_MOCKS.
373
+ const rnFlag = map.framework === 'react-native' && c.configFile ? c.configFile : '';
374
+ const flag = rnFlag ? 'USE_MOCKS' : `${mockEnvRead} === ${q}true${q}`;
375
+ const flagLabel = rnFlag ? `USE_MOCKS (${rnFlag}) is true` : `${mockEnv}=true`;
376
+ const addImport = (t) => {
377
+ const configPath = rnFlag ? importPath(hookFile, rnFlag, { ...c, importStyle: 'relative' }).replace(/\.tsx?$/, '') : '';
378
+ let extra = '';
379
+ if (rnFlag && !/import\s*\{[^}]*\bUSE_MOCKS\b/.test(t)) {
380
+ // Join an existing import from the config file (import { API_URL } ...), or add one.
381
+ const existing = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${configPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"]`).exec(t);
382
+ if (existing)
383
+ t = t.replace(existing[0], existing[0].replace(existing[1], `${existing[1].trim()}, USE_MOCKS `.replace(/^/, ' ')));
384
+ else
385
+ extra = `\nimport { USE_MOCKS } from ${q}${configPath}${q}${semi}`;
386
+ }
387
+ const importLine = `import { findMock } from ${q}./mocks${q}${semi}${extra}`;
388
+ const imports = [...t.matchAll(/^import[^\n]*\n/gm)];
389
+ if (!imports.length)
390
+ return `${importLine}\n\n${t}`;
391
+ const at = imports[imports.length - 1].index + imports[imports.length - 1][0].length;
392
+ return t.slice(0, at) + importLine + '\n' + t.slice(at);
393
+ };
394
+ if (axiosMode && !/codz: mock responses/.test(helper)) {
395
+ // A request interceptor answers from the mocks (matched requests only) with a one-off adapter.
396
+ const inst = (c.axiosInstance || '').replace(/^default:/, '');
397
+ const create = new RegExp(`(?:export\\s+)?(?:const|let)\\s+${inst}\\s*=\\s*axios\\.create\\s*\\(`).exec(helper);
398
+ const close = create ? matchBrace(helper, create.index + create[0].length - 1) : -1;
399
+ if (!create || close === -1) {
400
+ warnings.push(`Could not find ${inst} = axios.create(...) in ${hookFile}; add a request interceptor that calls findMock() yourself.`);
401
+ }
402
+ else {
403
+ let t = helper.replace(/\r\n/g, '\n');
404
+ let end = close + 1;
405
+ if (t[end] === ';')
406
+ end++;
407
+ const block = [
408
+ '',
409
+ '',
410
+ '// codz: mock responses',
411
+ `if (${flag}) {`,
412
+ ` ${inst}.interceptors.request.use((config) => {`,
413
+ ` const mock = findMock(config.method ?? ${q}get${q}, config.url ?? ${q}${q})${semi}`,
414
+ ` if (mock !== undefined) config.adapter = async () => ({ data: mock, status: 200, statusText: ${q}OK${q}, headers: {}, config })${semi}`,
415
+ ` return config${semi}`,
416
+ ` })${semi}`,
417
+ '}',
418
+ ].join('\n');
419
+ t = addImport(`${t.slice(0, end)}${block}${t.slice(end)}`);
420
+ files.set(hookFile, withEol(t, detectEol(helper)));
421
+ summary.push(`Update ${hookFile}: answer from mocks when ${flagLabel}`);
422
+ }
423
+ }
424
+ if (!axiosMode && !/codz: mock responses/.test(helper)) {
425
+ const m = new RegExp(`export\\s+(?:async\\s+)?function\\s+${c.httpHelper}\\s*<[^>]*>\\s*\\(\\s*(\\w+)\\s*:[^,)]*(?:,\\s*(\\w+))?`).exec(helper);
426
+ if (!m) {
427
+ warnings.push(`Could not find ${c.httpHelper}() in ${c.httpHelperFile}; call findMock() from it yourself.`);
428
+ }
429
+ else {
430
+ const method = m[2] ? `${m[2]}?.method ?? ${q}GET${q}` : `${q}GET${q}`;
431
+ const block = [
432
+ ` // codz: mock responses`,
433
+ ` if (${flag}) {`,
434
+ ` const mock = findMock(${method}, ${m[1]})${semi}`,
435
+ ` if (mock !== undefined) return mock as T${semi}`,
436
+ ' }',
437
+ ].join('\n');
438
+ let t = helper.replace(/\r\n/g, '\n');
439
+ const at = t.indexOf('{', matchBrace(t, t.indexOf('(', m.index)) + 1) + 1;
440
+ t = `${t.slice(0, at)}\n${block}${t.slice(at)}`;
441
+ t = addImport(t);
442
+ files.set(c.httpHelperFile, withEol(t, detectEol(helper)));
443
+ summary.push(`Update ${c.httpHelperFile}: answer from mocks when ${flagLabel}`);
444
+ }
445
+ }
446
+ if (!rnFlag && !/codz: mock responses/.test(helper)) {
447
+ // .env.example only: codz never writes .env.local, which may hold secrets.
448
+ for (const envFile of ['.env.example']) {
449
+ const env = readText(join(root, envFile));
450
+ if (env !== null && !new RegExp(`${mockEnv}=`).test(env)) {
451
+ files.set(envFile, `${env.replace(/\s*$/, '\n')}${mockEnv}=false\n`);
452
+ summary.push(`Update ${envFile}: ${mockEnv}=false`);
453
+ }
454
+ }
455
+ }
456
+ summary.push(rnFlag ? (c.flavor === 'expo' ? 'Turn mocks on with EXPO_PUBLIC_USE_MOCKS=true in .env, then restart Expo.' : `Turn mocks on by setting USE_MOCKS = true in ${rnFlag}.`) : `Turn mocks on with ${mockEnv}=true in .env.local, then restart the dev server.`);
457
+ return { files, summary, warnings };
458
+ }
459
+ export async function planMockAdd(root, adapter, opts) {
460
+ if (adapter.id === 'generic')
461
+ throw new ToolError('not_supported', 'mock add needs the Next.js, Flutter or React Native adapter.');
462
+ if (adapter.id === 'android')
463
+ throw new ToolError('not_supported', 'mock add does not support Android yet.', 'For offline tests, codz test add writes MockWebServer tests; for the app, point ApiConfig.BASE_URL at a local mock server.');
464
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
465
+ let eps;
466
+ if (opts.all)
467
+ eps = map.endpoints;
468
+ else {
469
+ eps = opts.endpoints.map((n) => {
470
+ const ep = map.endpoints.find((e) => e.name === n) ?? map.endpoints.find((e) => e.name.toLowerCase() === n.toLowerCase());
471
+ if (!ep)
472
+ throw new ToolError('no_endpoint', `No endpoint called ${n} was found.`, `Endpoints in this project: ${map.endpoints.map((e) => e.name).join(', ') || 'none yet'}`);
473
+ return ep;
474
+ });
475
+ }
476
+ if (!eps.length)
477
+ throw new ToolError('bad_args', 'Which endpoints? Usage: codz mock add <endpoint> [<endpoint>...] or codz mock add --all');
478
+ if (opts.sample && eps.length > 1)
479
+ throw new ToolError('bad_args', '--sample works with one endpoint at a time.');
480
+ const r = adapter.id === 'flutter' ? flutterMocks(root, map, eps, opts.sample, opts.keepValues === true, opts.force === true) : nextMocks(root, map, eps, opts.sample, opts.keepValues === true, opts.force === true);
481
+ return buildChangeSet(root, 'mock add', { ...r, generated: [] });
482
+ }
@@ -0,0 +1,23 @@
1
+ // module add and api wire, as change sets.
2
+ import { loadMap } from "../core/cache.js";
3
+ import { buildChangeSet } from "../core/changeset.js";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { ToolError } from "../core/types.js";
6
+ function merge(a, b) {
7
+ return { files: b.files, summary: [...a.summary, ...b.summary], warnings: [...a.warnings, ...b.warnings], generated: [...a.generated, ...b.generated] };
8
+ }
9
+ export async function planModuleAdd(root, adapter, opts) {
10
+ if (!adapter.planModule || !adapter.planWire)
11
+ throw new ToolError('not_supported', `module add is not available for the ${adapter.id} adapter yet.`, 'Supported: Flutter with GetX.');
12
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
13
+ let edits = adapter.planModule({ root, map }, { name: opts.name, initial: opts.initial });
14
+ for (const endpoint of opts.apis ?? [])
15
+ edits = merge(edits, adapter.planWire({ root, map }, { endpoint, module: opts.name, noLoad: opts.noLoad }, edits.files));
16
+ return buildChangeSet(root, 'module add', edits);
17
+ }
18
+ export async function planApiWire(root, adapter, opts) {
19
+ if (!adapter.planWire)
20
+ throw new ToolError('not_supported', `api wire is not available for the ${adapter.id} adapter yet.`, 'Supported: Flutter with GetX.');
21
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
22
+ return buildChangeSet(root, 'api wire', adapter.planWire({ root, map }, opts));
23
+ }
@@ -0,0 +1,66 @@
1
+ // plans list / show / clean: the change sets saved with --plan.
2
+ //
3
+ // A plan is "ready" when every file it touches is still as it was when the plan
4
+ // was made, "applied" when history has it, and "stale" when a file changed since
5
+ // (codz apply would refuse it).
6
+ import { readdirSync, rmSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { TOOL_DIR } from "../core/cache.js";
9
+ import { loadPlan, readHistory } from "../core/changeset.js";
10
+ import { hash, readText } from "../core/fsutil.js";
11
+ const plansDir = (root) => join(root, TOOL_DIR, 'plans');
12
+ export function planStatus(root, cs, applied) {
13
+ if (applied.has(cs.id))
14
+ return 'applied';
15
+ for (const c of cs.changes) {
16
+ const now = readText(join(root, c.path));
17
+ if ((now === null ? null : hash(now)) !== c.baseHash)
18
+ return 'stale';
19
+ }
20
+ return 'ready';
21
+ }
22
+ export function listPlans(root) {
23
+ let names = [];
24
+ try {
25
+ names = readdirSync(plansDir(root)).filter((n) => n.endsWith('.json'));
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ const applied = new Set(readHistory(root).filter((h) => !h.undone).map((h) => h.id));
31
+ const rows = [];
32
+ for (const n of names) {
33
+ let cs;
34
+ try {
35
+ cs = loadPlan(root, n.slice(0, -5));
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ rows.push({
41
+ id: cs.id,
42
+ skill: cs.skill,
43
+ created: statSync(join(plansDir(root), n)).mtime.toISOString(),
44
+ status: planStatus(root, cs, applied),
45
+ files: cs.changes.map((c) => c.path),
46
+ summary: cs.summary[0] ?? '',
47
+ });
48
+ }
49
+ return rows.sort((a, b) => b.created.localeCompare(a.created));
50
+ }
51
+ export function showPlan(root, id) {
52
+ const plan = loadPlan(root, id);
53
+ const applied = new Set(readHistory(root).filter((h) => !h.undone).map((h) => h.id));
54
+ return { plan, status: planStatus(root, plan, applied) };
55
+ }
56
+ /** Deletes plans that were applied or went stale (or every plan with all). */
57
+ export function cleanPlans(root, opts = {}) {
58
+ const removed = [];
59
+ for (const p of listPlans(root)) {
60
+ if (opts.all || p.status !== 'ready') {
61
+ rmSync(join(plansDir(root), `${p.id}.json`), { force: true });
62
+ removed.push(p.id);
63
+ }
64
+ }
65
+ return removed;
66
+ }
@@ -0,0 +1,84 @@
1
+ // Edit forms that start with the current values: feature add passes the detail
2
+ // endpoint (getOrder) to the edit form (updateOrder), and the form loads it on
3
+ // open and fills every input whose field is in both models. These helpers work
4
+ // out the call and the field mapping; each form generator writes them in its
5
+ // own language.
6
+ import { join } from 'node:path';
7
+ import { readText } from "../core/fsutil.js";
8
+ import { splitTsParams } from "../adapters/reactQuery.js";
9
+ import { findDeclaration } from "./apiEdit.js";
10
+ import { responseShape } from "./screen.js";
11
+ /**
12
+ * The detail endpoint to load into a form, when it can be called with the
13
+ * form's route parameters and returns a model sharing fields with the body.
14
+ * Returns null otherwise (the form then starts empty).
15
+ */
16
+ export function prefillFor(root, map, loadName, bodyFields, routeParams, lang) {
17
+ if (!loadName)
18
+ return null;
19
+ const ep = map.endpoints.find((e) => e.name === loadName);
20
+ if (!ep || ep.method !== 'GET')
21
+ return null;
22
+ let returnType = ep.response ?? '';
23
+ if (lang === 'ts' || lang === 'dart') {
24
+ const decl = findDeclaration(readText(join(root, ep.file)) ?? '', ep.name, lang === 'ts' ? 'ts' : 'dart');
25
+ if (decl?.returnType)
26
+ returnType = decl.returnType;
27
+ }
28
+ const shape = responseShape(returnType, map);
29
+ if (shape.list || !shape.model)
30
+ return null;
31
+ // Its path parameters must all be route parameters of the form.
32
+ const params = [...ep.path.matchAll(/\{([^/}]+)\}/g)].map((m) => m[1]);
33
+ if (params.length !== routeParams.length)
34
+ return null;
35
+ const model = shape.model;
36
+ const have = new Set(model.fields.map((f) => f.name));
37
+ const shared = new Set(bodyFields.filter((f) => have.has(f)));
38
+ if (!shared.size)
39
+ return null;
40
+ return { ep, model, shared, field: (json) => model.fields.find((f) => f.name === json) };
41
+ }
42
+ /** TypeScript: the value an input starts with, from the loaded object d. */
43
+ export function tsInitial(kind, access, nullable, q = "'") {
44
+ const empty = `${q}${q}`;
45
+ if (kind === 'bool')
46
+ return `Boolean(${access})`;
47
+ if (kind === 'strings')
48
+ return `(${access} ?? []).join(${q}, ${q})`;
49
+ if (kind === 'int' || kind === 'double' || kind === 'number')
50
+ return `${access} == null ? ${empty} : String(${access})`;
51
+ if (kind === 'date')
52
+ return `String(${access} ?? ${empty}).slice(0, 16)`;
53
+ return nullable ? `${access} ?? ${empty}` : `String(${access} ?? ${empty})`;
54
+ }
55
+ /**
56
+ * React and React Native forms: a useEffect that loads the detail endpoint
57
+ * with the route parameters (plain variables named like them) and fills the
58
+ * inputs. Returns null when there is nothing to load.
59
+ */
60
+ export function reactPrefill(root, map, loadName, fields, params, key) {
61
+ const pre = prefillFor(root, map, loadName, fields.map((f) => f.json), params, 'ts');
62
+ if (!pre)
63
+ return null;
64
+ const decl = findDeclaration(readText(join(root, pre.ep.file)) ?? '', pre.ep.name, 'ts');
65
+ if (!decl)
66
+ return null;
67
+ const ps = splitTsParams(decl.params).filter((p) => !p.optional);
68
+ if (!ps.every((p) => params.includes(p.name)))
69
+ return null;
70
+ const args = ps.map((p) => (/^number$/.test(p.type) ? `Number(${p.name})` : p.name)).join(', ');
71
+ const set = fields.filter((f) => pre.shared.has(f.json)).map((f) => `${key(f.json)}: ${tsInitial(f.kind, /^[A-Za-z_$][\w$]*$/.test(f.json) ? `d.${f.json}` : `d['${f.json}']`, pre.field(f.json).nullable)}`);
72
+ return {
73
+ ep: pre.ep,
74
+ lines: [
75
+ '',
76
+ ' // Start with the current values.',
77
+ ' useEffect(() => {',
78
+ ` ${pre.ep.name}(${args})`,
79
+ ` .then((d) => setValues((v) => ({ ...v, ${set.join(', ')} })))`,
80
+ ' .catch((e) => setError(e instanceof Error ? e.message : String(e)));',
81
+ ` }, [${ps.map((p) => p.name).join(', ')}]);`,
82
+ ],
83
+ };
84
+ }