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,352 @@
1
+ // React web pages: route add, screen add (data) and screen add --form.
2
+ //
3
+ // <Name>Page.tsx in the pages folder, registered in the router file:
4
+ // data style { path: '/orders/:id', element: <OrderPage /> } in createBrowserRouter([...])
5
+ // jsx style <Route path="/orders/:id" element={<OrderPage />} /> inside <Routes>
6
+ // Path parameters (:id) are read with useParams().
7
+ //
8
+ // A data page loads the endpoint (through its TanStack Query hook when the
9
+ // project has one) and shows loading, the error with Retry, and the data (a
10
+ // list, or the fields). A form page has one input per request field and goes
11
+ // back after saving.
12
+ import { join, posix } from 'node:path';
13
+ import { importPath } from "../adapters/nextjs.js";
14
+ import { splitTsParams } from "../adapters/reactQuery.js";
15
+ import { buildChangeSet } from "../core/changeset.js";
16
+ import { exists, readText, walk } from "../core/fsutil.js";
17
+ import { ToolError } from "../core/types.js";
18
+ import { camel, kebab, pascal, words } from "../input/naming.js";
19
+ import { findDeclaration } from "./apiEdit.js";
20
+ import { responseShape, titleField } from "./screen.js";
21
+ import { reactPrefill } from "./prefill.js";
22
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
23
+ function routeParamsOf(path) {
24
+ return [...(path ?? '').matchAll(/[:{]([\w-]+)\}?/g)].map((m) => camel(m[1]));
25
+ }
26
+ function names(name) {
27
+ const base = pascal(name.replace(/[-_ ]?(page|screen)$/i, ''));
28
+ return { base, component: `${base}Page`, title: words(base).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') };
29
+ }
30
+ /** Index of the bracket that closes the one at `open`, skipping strings and template literals. */
31
+ function closing(text, open) {
32
+ const pairs = { '[': ']', '(': ')', '{': '}' };
33
+ const stack = [];
34
+ for (let i = open; i < text.length; i++) {
35
+ const ch = text[i];
36
+ if (ch === '"' || ch === "'" || ch === '`') {
37
+ for (i++; i < text.length && text[i] !== ch; i++)
38
+ if (text[i] === '\\')
39
+ i++;
40
+ continue;
41
+ }
42
+ if (pairs[ch])
43
+ stack.push(pairs[ch]);
44
+ else if (ch === stack[stack.length - 1]) {
45
+ stack.pop();
46
+ if (!stack.length)
47
+ return i;
48
+ }
49
+ }
50
+ return -1;
51
+ }
52
+ function place(root, map, opts) {
53
+ const c = map.conventions;
54
+ if (c.router !== 'react-router')
55
+ throw new ToolError('not_supported', 'This project has no React Router, so codz cannot add a page to it.', 'Run codz setup (it adds React Router), or add react-router-dom.');
56
+ const routerFile = c.routerFile;
57
+ const routerText = routerFile ? readText(join(root, routerFile)) : null;
58
+ if (!routerFile || routerText === null)
59
+ throw new ToolError('no_router', `The router file ${routerFile || '(none)'} was not found.`, 'Set routerFile in codz.config.yaml to the file with createBrowserRouter or <Routes>.');
60
+ const n = names(opts.name);
61
+ const route = (opts.path ?? `/${kebab(n.base)}`).replace(/\{([\w-]+)\}/g, ':$1');
62
+ return { file: `${c.pagesFolder || 'src/pages'}/${n.component}.tsx`, route, routerFile, routerText };
63
+ }
64
+ /** Adds the page to the router file (createBrowserRouter array or <Routes>). */
65
+ function register(p, map, opts) {
66
+ const n = names(opts.name);
67
+ const c = map.conventions;
68
+ const q = c.quote === 'double' ? '"' : "'";
69
+ const semi = c.semicolons === 'false' ? '' : ';';
70
+ let t = p.routerText;
71
+ if (new RegExp(`path[=:]\\s*\\{?["'\`]${p.route.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["'\`]`).test(t))
72
+ throw new ToolError('route_exists', `${p.routerFile} already has a route for ${p.route}.`);
73
+ const data = /createBrowserRouter\s*\(/.exec(t);
74
+ if (data) {
75
+ const open = t.indexOf('[', data.index);
76
+ const close = open >= 0 ? closing(t, open) : -1;
77
+ if (close < 0)
78
+ throw new ToolError('no_router', `Could not read the route list in ${p.routerFile}.`);
79
+ const before = t.slice(0, close).replace(/\s*$/, '');
80
+ const indent = /\n([ \t]+)\{\s*path/.exec(t.slice(open))?.[1] ?? ' ';
81
+ const comma = /[,[]\s*$/.test(before) ? '' : ',';
82
+ const entry = `{ path: ${q}${p.route}${q}, element: <${n.component} /> },`;
83
+ t = opts.initial ? t.slice(0, open + 1) + `\n${indent}${entry}` + t.slice(open + 1) : `${before}${comma}\n${indent}${entry}\n` + t.slice(close);
84
+ }
85
+ else {
86
+ const end = t.lastIndexOf('</Routes>');
87
+ if (end < 0)
88
+ throw new ToolError('no_router', `Could not find createBrowserRouter or </Routes> in ${p.routerFile}.`);
89
+ const lineStart = t.lastIndexOf('\n', end) + 1;
90
+ const indent = /\n([ \t]+)<Route\b[^\n]*\n[ \t]*<\/Routes>/.exec(t)?.[1] ?? `${t.slice(lineStart, end)} `;
91
+ t = `${t.slice(0, lineStart)}${indent}<Route path="${p.route}" element={<${n.component} />} />\n${t.slice(lineStart)}`;
92
+ }
93
+ const imp = `import ${n.component} from ${q}${importPath(p.routerFile, p.file, { ...c, importStyle: 'relative' }).replace(/\.tsx$/, '')}${q}${semi}`;
94
+ const imports = [...t.matchAll(/^import[^\n]*\n/gm)];
95
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
96
+ return t.slice(0, at) + imp + '\n' + t.slice(at);
97
+ }
98
+ function paramsCode(params) {
99
+ if (!params.length)
100
+ return { imports: [], lines: [] };
101
+ return { imports: [`import { useParams } from 'react-router-dom';`], lines: [` const { ${params.map((p) => `${p} = ''`).join(', ')} } = useParams();`] };
102
+ }
103
+ function finish(root, skill, p, page, map, opts, summary, warnings) {
104
+ const files = new Map([
105
+ [p.file, page],
106
+ [p.routerFile, register(p, map, opts)],
107
+ ]);
108
+ summary.push(`Update ${p.routerFile}: add ${p.route}`);
109
+ summary.push(`Open it with: <Link to=${p.route.includes(':') ? `{\`${p.route.replace(/:(\w+)/g, '${$1}')}\`}` : `"${p.route}"`}> or navigate(...)`);
110
+ return buildChangeSet(root, skill, { files, summary, warnings, generated: [] });
111
+ }
112
+ export function planReactRoute(root, map, opts) {
113
+ const p = place(root, map, opts);
114
+ if (exists(join(root, p.file)))
115
+ throw new ToolError('route_exists', `${p.file} already exists.`);
116
+ const n = names(opts.name);
117
+ const params = routeParamsOf(p.route);
118
+ const pc = paramsCode(params);
119
+ const page = [...pc.imports, ...(pc.imports.length ? [''] : []), `export default function ${n.component}() {`, ...pc.lines, ' return (', ' <main>', ` <h1>${n.title}${params.length ? ` {${params[0]}}` : ''}</h1>`, ' </main>', ' );', '}', ''].join('\n');
120
+ return finish(root, 'route add', p, page, map, opts, [`Create ${p.file}: ${n.component}`], []);
121
+ }
122
+ function findEp(map, name) {
123
+ const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
124
+ if (!ep)
125
+ throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, map.endpoints.length ? `Endpoints: ${map.endpoints.map((e) => e.name).join(', ')}` : 'Add one first with: codz api add');
126
+ return ep;
127
+ }
128
+ function callArgs(root, ep, params, skip = () => false) {
129
+ const decl = findDeclaration(readText(join(root, ep.file)) ?? '', ep.name, 'ts');
130
+ if (!decl)
131
+ throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
132
+ const ps = splitTsParams(decl.params);
133
+ const missing = ps.filter((x) => !x.optional && !skip(x.name) && !params.includes(x.name)).map((x) => x.name);
134
+ const args = ps.filter((x) => !skip(x.name) && params.includes(x.name)).map((x) => (/^number$/.test(x.type) ? `Number(${x.name})` : x.name));
135
+ return { args: args.join(', '), missing, decl, ps };
136
+ }
137
+ function hookFor(root, map, ep) {
138
+ if (map.conventions.dataFetching !== 'react-query' || !map.conventions.hooksFolder)
139
+ return null;
140
+ for (const f of walk(root, map.conventions.hooksFolder, { exts: ['.ts', '.tsx'] })) {
141
+ const t = readText(join(root, f)) ?? '';
142
+ const m = new RegExp(`export function (use\\w+)\\([^)]*\\)\\s*\\{\\s*return useQuery\\(\\{[^}]*?queryFn: \\(\\) => ${ep.name}\\(`).exec(t);
143
+ if (m)
144
+ return { name: m[1], file: f };
145
+ }
146
+ return null;
147
+ }
148
+ function apiImport(root, map, from, ep) {
149
+ const dir = posix.dirname(ep.file);
150
+ const barrel = [`${dir}/index.ts`].find((f) => exists(join(root, f)) && /export\s+\*\s+from/.test(readText(join(root, f)) ?? ''));
151
+ return importPath(from, barrel ?? ep.file, map.conventions).replace(/\.tsx?$/, '');
152
+ }
153
+ export function planReactScreen(root, map, opts) {
154
+ if (!opts.api)
155
+ return planReactRoute(root, map, opts);
156
+ const ep = findEp(map, opts.api);
157
+ if (opts.form)
158
+ return planReactForm(root, map, opts, ep);
159
+ if (ep.method !== 'GET')
160
+ throw new ToolError('screen_needs_get', `${ep.name} is a ${ep.method} endpoint; a page loads data with a GET.`, 'For a form that sends it, add --form.');
161
+ const p = place(root, map, opts);
162
+ if (exists(join(root, p.file)))
163
+ throw new ToolError('route_exists', `${p.file} already exists.`);
164
+ const n = names(opts.name);
165
+ const params = routeParamsOf(p.route);
166
+ const { args, missing, decl } = callArgs(root, ep, params);
167
+ if (missing.length)
168
+ throw new ToolError('screen_args', `${ep.name}() needs ${missing.join(', ')}, which the page does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0]}`);
169
+ const shape = responseShape(decl.returnType, map);
170
+ const tf = titleField(shape.model);
171
+ const idf = shape.model?.fields.find((f) => /^(id|uuid|key|slug)$/.test(f.name))?.name;
172
+ const pc = paramsCode(params);
173
+ const hook = hookFor(root, map, ep);
174
+ const c = map.conventions;
175
+ const T = shape.raw || 'unknown';
176
+ const access = (v, f) => (IDENT.test(f) ? `${v}.${f}` : `${v}['${f}']`);
177
+ const view = shape.list
178
+ ? [
179
+ ' {data.length === 0 ? (',
180
+ ' <p>Nothing here yet.</p>',
181
+ ' ) : (',
182
+ ' <ul>',
183
+ ` {data.map((${idf ? 'item' : 'item, index'}) => (`,
184
+ ` <li key={${idf ? `String(${access('item', idf)})` : 'index'}}>{${tf ? `String(${access('item', tf)})` : 'JSON.stringify(item)'}}</li>`,
185
+ ' ))}',
186
+ ' </ul>',
187
+ ' )}',
188
+ ]
189
+ : [
190
+ ' <dl>',
191
+ ...(shape.model?.fields.length ? shape.model.fields.flatMap((f) => [` <dt>${f.name}</dt>`, ` <dd>{String(${access('data', f.name)} ?? '')}</dd>`]) : [' <pre>{JSON.stringify(data, null, 2)}</pre>']),
192
+ ' </dl>',
193
+ ];
194
+ const stateLines = hook
195
+ ? [` const { data, isLoading: loading, error, refetch: load } = ${hook.name}(${args});`]
196
+ : [
197
+ ` const [data, setData] = useState<${T} | null>(null);`,
198
+ ' const [loading, setLoading] = useState(true);',
199
+ ' const [error, setError] = useState<unknown>(null);',
200
+ '',
201
+ ' const load = useCallback(async () => {',
202
+ ' setLoading(true);',
203
+ ' setError(null);',
204
+ ' try {',
205
+ ` setData(await ${ep.name}(${args}));`,
206
+ ' } catch (e) {',
207
+ ' setError(e);',
208
+ ' } finally {',
209
+ ' setLoading(false);',
210
+ ' }',
211
+ ` }, [${params.join(', ')}]);`,
212
+ '',
213
+ ' useEffect(() => {',
214
+ ' load();',
215
+ ' }, [load]);',
216
+ ];
217
+ const page = [
218
+ ...(hook ? [] : [`import { useCallback, useEffect, useState } from 'react';`]),
219
+ ...pc.imports,
220
+ hook ? `import { ${hook.name} } from '${importPath(p.file, hook.file, c).replace(/\.tsx?$/, '')}';` : `import { ${ep.name} } from '${apiImport(root, map, p.file, ep)}';`,
221
+ ...(shape.model && !hook ? [`import type { ${shape.model.name} } from '${importPath(p.file, shape.model.file, c).replace(/\.tsx?$/, '')}';`] : []),
222
+ '',
223
+ `export default function ${n.component}() {`,
224
+ ...pc.lines,
225
+ ...stateLines,
226
+ '',
227
+ ' if (loading && !data) return <p>Loading…</p>;',
228
+ ' if (error) {',
229
+ ' return (',
230
+ ' <div role="alert">',
231
+ ' <p>{error instanceof Error ? error.message : String(error)}</p>',
232
+ ' <button type="button" onClick={() => load()}>Retry</button>',
233
+ ' </div>',
234
+ ' );',
235
+ ' }',
236
+ ' if (!data) return null;',
237
+ ' return (',
238
+ ' <main>',
239
+ ` <h1>${n.title}</h1>`,
240
+ ...view,
241
+ ' </main>',
242
+ ' );',
243
+ '}',
244
+ '',
245
+ ].join('\n');
246
+ return finish(root, 'screen add', p, page, map, opts, [`Create ${p.file}: ${n.component} loads ${hook ? `${hook.name}()` : `${ep.name}()`} and shows loading, the error with Retry, and the data`], []);
247
+ }
248
+ function kind(type) {
249
+ const t = type.replace(/\s*\|\s*null$/, '').trim();
250
+ if (t === 'string')
251
+ return 'text';
252
+ if (t === 'number')
253
+ return 'number';
254
+ if (t === 'boolean')
255
+ return 'bool';
256
+ if (t === 'string[]')
257
+ return 'strings';
258
+ return null;
259
+ }
260
+ function planReactForm(root, map, opts, ep) {
261
+ if (ep.method === 'GET' || ep.method === 'HEAD')
262
+ throw new ToolError('form_needs_body', `${ep.name} is a ${ep.method} endpoint; a form sends data with POST, PUT or PATCH.`);
263
+ const p = place(root, map, opts);
264
+ if (exists(join(root, p.file)))
265
+ throw new ToolError('route_exists', `${p.file} already exists.`);
266
+ const n = names(opts.name);
267
+ const params = routeParamsOf(p.route);
268
+ const { args, missing, ps } = callArgs(root, ep, params, (x) => x === 'body');
269
+ if (missing.length)
270
+ throw new ToolError('screen_args', `${ep.name}() needs ${missing.join(', ')}, which the page does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0]}`);
271
+ const body = ps.find((x) => x.name === 'body');
272
+ if (!body)
273
+ throw new ToolError('form_needs_body', `${ep.name}() takes no request body, so there is nothing for a form to send.`);
274
+ const partial = /^Partial</.test(body.type);
275
+ const model = map.models.find((m) => m.name === body.type.replace(/^Partial<(.+)>$/, '$1'));
276
+ if (!model)
277
+ throw new ToolError('form_fields', `The request type ${body.type} is not a model codz knows, so it cannot build the inputs.`);
278
+ const fields = model.fields.map((f) => ({ f, k: kind(f.type), required: !partial && !f.nullable }));
279
+ const cannot = fields.filter((x) => !x.k && x.required).map((x) => `${x.f.name} (${x.f.type})`);
280
+ if (cannot.length)
281
+ throw new ToolError('form_fields', `${model.name} has required fields a form cannot fill: ${cannot.join(', ')}.`, `Write the form for ${ep.name} by hand, or make those fields optional.`);
282
+ const usable = fields.filter((x) => x.k);
283
+ const key = (s) => (IDENT.test(s) ? s : `'${s}'`);
284
+ const get = (s) => (IDENT.test(s) ? `values.${s}` : `values['${s}']`);
285
+ const value = (x) => {
286
+ const v = get(x.f.name);
287
+ const conv = x.k === 'number' ? `Number(${v})` : x.k === 'strings' ? `${v}.split(',').map((s) => s.trim()).filter(Boolean)` : v;
288
+ if (x.k === 'bool' || x.required)
289
+ return conv;
290
+ return `${v} === '' ? undefined : ${conv}`;
291
+ };
292
+ const pc = paramsCode(params);
293
+ const c = map.conventions;
294
+ const input = (x) => {
295
+ const id = kebab(x.f.name);
296
+ if (x.k === 'bool')
297
+ return [` <label>`, ` <input type="checkbox" checked={${get(x.f.name)}} onChange={(e) => setValues({ ...values, ${key(x.f.name)}: e.target.checked })} />`, ` ${x.f.name}`, ' </label>'].join('\n');
298
+ const type = x.k === 'number' ? 'number' : /email/i.test(x.f.name) ? 'email' : 'text';
299
+ return [
300
+ ` <label htmlFor="${id}">${x.f.name}${x.required ? ' *' : ''}</label>`,
301
+ ` <input id="${id}" type="${type}"${x.required ? ' required' : ''}${x.k === 'strings' ? ' placeholder="a, b, c"' : ''} value={${get(x.f.name)}} onChange={(e) => setValues({ ...values, ${key(x.f.name)}: e.target.value })} />`,
302
+ ].join('\n');
303
+ };
304
+ const pre = reactPrefill(root, map, opts.load, usable.map((x) => ({ json: x.f.name, kind: x.k })), params, key);
305
+ const apiFrom = apiImport(root, map, p.file, ep);
306
+ const preFrom = pre ? apiImport(root, map, p.file, pre.ep) : '';
307
+ const page = [
308
+ `import { ${pre ? 'useEffect, ' : ''}useState, type FormEvent } from 'react';`,
309
+ `import { ${params.length ? 'useNavigate, useParams' : 'useNavigate'} } from 'react-router-dom';`,
310
+ ...(pre && preFrom === apiFrom ? [`import { ${[ep.name, pre.ep.name].sort().join(', ')} } from '${apiFrom}';`] : [`import { ${ep.name} } from '${apiFrom}';`, ...(pre ? [`import { ${pre.ep.name} } from '${preFrom}';`] : [])]),
311
+ `import type { ${model.name} } from '${importPath(p.file, model.file, c).replace(/\.tsx?$/, '')}';`,
312
+ '',
313
+ `export default function ${n.component}() {`,
314
+ ...pc.lines,
315
+ ' const navigate = useNavigate();',
316
+ ` const [values, setValues] = useState({ ${usable.map((x) => `${key(x.f.name)}: ${x.k === 'bool' ? 'false' : "''"}`).join(', ')} });`,
317
+ ' const [saving, setSaving] = useState(false);',
318
+ ' const [error, setError] = useState<string | null>(null);',
319
+ ...(pre?.lines ?? []),
320
+ '',
321
+ ' async function submit(e: FormEvent<HTMLFormElement>) {',
322
+ ' e.preventDefault();',
323
+ ' setSaving(true);',
324
+ ' setError(null);',
325
+ ' try {',
326
+ ` const body: ${partial ? `Partial<${model.name}>` : model.name} = {`,
327
+ ...usable.map((x) => ` ${key(x.f.name)}: ${value(x)},`),
328
+ ' };',
329
+ ` await ${ep.name}(${[args, 'body'].filter(Boolean).join(', ')});`,
330
+ ' navigate(-1);',
331
+ ' } catch (err) {',
332
+ ' setError(err instanceof Error ? err.message : String(err));',
333
+ ' } finally {',
334
+ ' setSaving(false);',
335
+ ' }',
336
+ ' }',
337
+ '',
338
+ ' return (',
339
+ ' <main>',
340
+ ` <h1>${n.title}</h1>`,
341
+ ' <form onSubmit={submit}>',
342
+ ...usable.map(input),
343
+ ' {error && <p role="alert">{error}</p>}',
344
+ ` <button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>`,
345
+ ' </form>',
346
+ ' </main>',
347
+ ' );',
348
+ '}',
349
+ '',
350
+ ].join('\n');
351
+ return finish(root, 'screen add', p, page, map, opts, [`Create ${p.file}: a form for ${model.name} (${usable.length} field${usable.length === 1 ? '' : 's'}) that calls ${ep.name}() and goes back after saving`], []);
352
+ }
@@ -0,0 +1,75 @@
1
+ // codz report: what codz did in this project, for beta feedback. It is
2
+ // built from codz's own records (.codz/history.json and events.jsonl) and
3
+ // holds counts, dates and the setup (framework, HTTP client, router, ...), never
4
+ // code, file names, endpoint paths or URLs, so it can be pasted into a form.
5
+ import { readEvents } from "../core/events.js";
6
+ import { readHistory } from "../core/changeset.js";
7
+ /** Conventions that describe the setup without naming anything in the project. */
8
+ const SETUP_KEYS = ['http', 'dataFetching', 'stateManagement', 'di', 'router', 'ui', 'flavor', 'bundler', 'serialization', 'apiStyle', 'fileSync', 'routerStyle'];
9
+ const day = (iso) => iso.slice(0, 10);
10
+ /** History ids start with the UTC time: 20261029-143012-ab12cd. */
11
+ const idDay = (id) => (/^(\d{4})(\d{2})(\d{2})-/.exec(id) ? id.replace(/^(\d{4})(\d{2})(\d{2})-.*$/, '$1-$2-$3') : '');
12
+ export function buildReport(root, adapter, map, version) {
13
+ const events = readEvents(root);
14
+ const history = readHistory(root);
15
+ const setup = {};
16
+ for (const k of SETUP_KEYS) {
17
+ const v = map.conventions[k];
18
+ if (typeof v === 'string' && v && /^[\w-]+$/.test(v))
19
+ setup[k] = v;
20
+ }
21
+ const byCmd = new Map();
22
+ const byCode = new Map();
23
+ for (const e of events) {
24
+ const c = byCmd.get(e.cmd) ?? { runs: 0, failed: 0 };
25
+ c.runs++;
26
+ if (!e.ok) {
27
+ c.failed++;
28
+ byCode.set(e.code ?? 'unknown', (byCode.get(e.code ?? 'unknown') ?? 0) + 1);
29
+ }
30
+ byCmd.set(e.cmd, c);
31
+ }
32
+ const bySkill = new Map();
33
+ for (const h of history) {
34
+ const s = bySkill.get(h.skill) ?? { applied: 0, undone: 0 };
35
+ s.applied++;
36
+ if (h.undone)
37
+ s.undone++;
38
+ bySkill.set(h.skill, s);
39
+ }
40
+ const days = new Set([...events.map((e) => day(e.t)), ...history.map((h) => idDay(h.id)).filter(Boolean)]);
41
+ const sorted = [...days].sort();
42
+ const applied = history.length;
43
+ const undone = history.filter((h) => h.undone).length;
44
+ return {
45
+ codz: version,
46
+ node: process.versions.node,
47
+ os: process.platform,
48
+ framework: adapter.id,
49
+ setup,
50
+ size: { endpoints: map.endpoints.length, models: map.models.length, routes: map.routes.length },
51
+ activity: { first: sorted[0], last: sorted.at(-1), activeDays: sorted.length },
52
+ commands: [...byCmd].map(([name, c]) => ({ name, ...c })).sort((a, b) => b.runs - a.runs),
53
+ errors: [...byCode].map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count),
54
+ changes: { applied, undone, undoRate: applied ? `${Math.round((undone / applied) * 100)}%` : '-', bySkill: [...bySkill].map(([skill, s]) => ({ skill, ...s })).sort((a, b) => b.applied - a.applied) },
55
+ };
56
+ }
57
+ export function renderReport(r) {
58
+ const out = [
59
+ `codz ${r.codz} · Node ${r.node} · ${r.os}`,
60
+ `Project: ${r.framework}${Object.keys(r.setup).length ? ` (${Object.entries(r.setup).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`,
61
+ `Size: ${r.size.endpoints} endpoints, ${r.size.models} models, ${r.size.routes} routes`,
62
+ `Used on ${r.activity.activeDays} day${r.activity.activeDays === 1 ? '' : 's'}${r.activity.first ? `, ${r.activity.first} to ${r.activity.last}` : ''}`,
63
+ '',
64
+ `Changes: ${r.changes.applied} applied, ${r.changes.undone} undone (undo rate ${r.changes.undoRate})`,
65
+ ...r.changes.bySkill.map((s) => ` ${s.skill.padEnd(16)} ${String(s.applied).padStart(4)} applied ${String(s.undone).padStart(3)} undone`),
66
+ '',
67
+ 'Commands:',
68
+ ...(r.commands.length ? r.commands.map((c) => ` ${c.name.padEnd(16)} ${String(c.runs).padStart(4)} runs ${c.failed ? `${c.failed} failed` : ''}`.trimEnd()) : [' none recorded yet']),
69
+ ...(r.errors.length ? ['', 'Errors:', ...r.errors.map((e) => ` ${e.code.padEnd(24)} ${e.count}`)] : []),
70
+ '',
71
+ 'This report has counts and setup names only: no code, file names, endpoint paths or URLs.',
72
+ 'Paste it into the beta feedback form, or share the JSON from codz report --json.',
73
+ ];
74
+ return out.join('\n');
75
+ }
@@ -0,0 +1,230 @@
1
+ // route add <name> [--path /x/:id]: a new screen and its route.
2
+ //
3
+ // GetX → a module (binding, controller, view, GetPage). go_router → a page
4
+ // widget plus a GoRoute in the router's routes list. Navigator → a page plus an
5
+ // entry in MaterialApp routes: {}. Next.js → app/<path>/page.tsx (or pages/).
6
+ import { join, posix } from 'node:path';
7
+ import { addDartImport, importUri } from "../adapters/flutter.js";
8
+ import { matchBrace } from "../adapters/source.js";
9
+ import { loadMap } from "../core/cache.js";
10
+ import { buildChangeSet } from "../core/changeset.js";
11
+ import { loadConfig } from "../core/config.js";
12
+ import { detectEol, exists, readJson, readText, walk, withEol } from "../core/fsutil.js";
13
+ import { ToolError } from "../core/types.js";
14
+ import { camel, kebab, pascal, snake, words } from "../input/naming.js";
15
+ import { planModuleAdd } from "./module.js";
16
+ function normPath(name, path) {
17
+ let p = path ?? `/${kebab(name)}`;
18
+ if (!p.startsWith('/'))
19
+ p = `/${p}`;
20
+ p = p.replace(/\[(\w+)\]/g, ':$1').replace(/\{([^/{}]+)\}/g, ':$1');
21
+ return { path: p, params: [...p.matchAll(/:(\w+)/g)].map((m) => m[1]) };
22
+ }
23
+ // ---------------- Flutter ----------------
24
+ function pageSuffix(map) {
25
+ const screens = map.routes.filter((r) => /Screen$/.test(r.name)).length;
26
+ const pages = map.routes.filter((r) => /Page$/.test(r.name)).length;
27
+ return screens > pages ? 'Screen' : 'Page';
28
+ }
29
+ /** Where pages live: next to an existing page as features/<name>/ or screens/. */
30
+ function pageFile(root, map, cls, base) {
31
+ const dart = walk(root, 'lib', { exts: ['.dart'] });
32
+ for (const r of map.routes) {
33
+ const f = dart.find((x) => new RegExp(`class\\s+${r.name}\\s+extends`).test(readText(join(root, x)) ?? ''));
34
+ if (!f)
35
+ continue;
36
+ const dir = posix.dirname(f);
37
+ const parent = posix.dirname(dir);
38
+ // features/home/home_page.dart → features/<name>/<name>_page.dart
39
+ if (/\/(features|modules|pages|screens|views)$/.test(parent) || parent.endsWith('/features'))
40
+ return `${parent}/${base}/${snake(cls)}.dart`;
41
+ return `${dir}/${snake(cls)}.dart`;
42
+ }
43
+ return exists(join(root, 'lib/features')) ? `lib/features/${base}/${snake(cls)}.dart` : `lib/screens/${snake(cls)}.dart`;
44
+ }
45
+ function pageWidget(cls, title, params) {
46
+ const fields = params.map((p) => ` final String ${camel(p)};\n`).join('');
47
+ const ctor = params.length ? `const ${cls}({super.key, ${params.map((p) => `required this.${camel(p)}`).join(', ')}});` : `const ${cls}({super.key});`;
48
+ return [
49
+ "import 'package:flutter/material.dart';",
50
+ '',
51
+ `class ${cls} extends StatelessWidget {`,
52
+ ` ${ctor}`,
53
+ fields ? `\n${fields.trimEnd()}` : '',
54
+ '',
55
+ ' @override',
56
+ ' Widget build(BuildContext context) {',
57
+ ' return Scaffold(',
58
+ ` appBar: AppBar(title: const Text('${title}')),`,
59
+ ` body: ${params.length ? `Center(child: Text('${title} \$${camel(params[0])}'))` : `const Center(child: Text('${title}'))`},`,
60
+ ' );',
61
+ ' }',
62
+ '}',
63
+ '',
64
+ ]
65
+ .filter((l, i, a) => !(l === '' && a[i - 1] === ''))
66
+ .join('\n');
67
+ }
68
+ function planFlutterRoute(root, map, opts, files, summary, warnings) {
69
+ const c = map.conventions;
70
+ const { path, params } = normPath(opts.name, opts.path);
71
+ const cls = pascal(`${opts.name} ${pageSuffix(map)}`).replace(/(Page|Screen)(Page|Screen)$/, '$1');
72
+ if (map.routes.some((r) => r.path.replace(/\{([^/{}]+)\}/g, ':$1') === path))
73
+ throw new ToolError('route_exists', `There is already a route for ${path}.`);
74
+ const file = pageFile(root, map, cls, snake(opts.name.replace(/(page|screen)$/i, '')) || snake(opts.name));
75
+ if (exists(join(root, file)))
76
+ throw new ToolError('file_exists', `${file} already exists.`);
77
+ const title = words(opts.name.replace(/(page|screen)$/i, '')).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
78
+ files.set(file, pageWidget(cls, title, params));
79
+ summary.push(`Create ${file}: ${cls}`);
80
+ const args = params.map((p) => `${camel(p)}: state.pathParameters['${p}']!`).join(', ');
81
+ if (c.router === 'go_router') {
82
+ const dart = walk(root, 'lib', { exts: ['.dart'] });
83
+ const rf = dart.find((f) => /\bGoRouter\s*\(/.test(readText(join(root, f)) ?? ''));
84
+ if (!rf) {
85
+ warnings.push(`No GoRouter(...) found; add GoRoute(path: '${path}', builder: (context, state) => ${params.length ? '' : 'const '}${cls}(${args})) to your routes.`);
86
+ return;
87
+ }
88
+ const orig = readText(join(root, rf));
89
+ let t = orig.replace(/\r\n/g, '\n');
90
+ const g = /\bGoRouter\s*\(/.exec(t);
91
+ const gClose = matchBrace(t, g.index + g[0].length - 1, 'dart');
92
+ const routes = /\broutes\s*:\s*(?:<[^>]*>\s*)?\[/.exec(t.slice(g.index, gClose));
93
+ if (!routes) {
94
+ warnings.push(`${rf} has no routes: [...] list; add the GoRoute for ${path} yourself.`);
95
+ return;
96
+ }
97
+ const open = g.index + routes.index + routes[0].length - 1;
98
+ const close = matchBrace(t, open, 'dart');
99
+ const closeIndent = t.slice(t.lastIndexOf('\n', close) + 1, close).match(/^[ \t]*/)[0];
100
+ const ind = `${closeIndent} `;
101
+ const name = camel(opts.name.replace(/(page|screen)$/i, '')) || camel(opts.name);
102
+ const entry = [
103
+ `${ind}GoRoute(`,
104
+ `${ind} path: '${path}',`,
105
+ `${ind} name: '${name}',`,
106
+ `${ind} builder: (context, state) => ${params.length ? '' : 'const '}${cls}(${args}),`,
107
+ `${ind}),`,
108
+ ].join('\n');
109
+ const before = t.slice(0, close).replace(/\s*$/, '');
110
+ t = `${before}${before.endsWith('[') || before.endsWith(',') ? '' : ','}\n${entry}\n${closeIndent}${t.slice(close)}`;
111
+ t = addDartImport(t, importUri(rf, file, c));
112
+ files.set(rf, withEol(t, detectEol(orig)));
113
+ summary.push(`Update ${rf}: GoRoute '${path}' → ${cls}`);
114
+ summary.push(`Open it with: context.go('${path.replace(/:(\w+)/g, '\${$1}')}') or context.goNamed('${name}'${params.length ? `, pathParameters: {${params.map((p) => `'${p}': ${camel(p)}`).join(', ')}}` : ''})`);
115
+ return;
116
+ }
117
+ if (c.router === 'auto_route') {
118
+ warnings.push(`auto_route needs @RoutePage() and a build_runner run; add ${cls} to your AppRouter routes and run dart run build_runner build.`);
119
+ return;
120
+ }
121
+ // Navigator: MaterialApp(routes: {...}).
122
+ const mainFile = 'lib/main.dart';
123
+ const orig = readText(join(root, mainFile));
124
+ if (orig === null)
125
+ return;
126
+ let t = orig.replace(/\r\n/g, '\n');
127
+ if (params.length) {
128
+ warnings.push(`Navigator named routes cannot read ${params.map((p) => `:${p}`).join(', ')} from the path; open ${cls} with Navigator.push(context, MaterialPageRoute(builder: (_) => ${cls}(...))).`);
129
+ return;
130
+ }
131
+ const app = /\bMaterialApp\s*\(/.exec(t);
132
+ if (!app)
133
+ return;
134
+ const aClose = matchBrace(t, app.index + app[0].length - 1, 'dart');
135
+ const routes = /\broutes\s*:\s*(?:<[^>]*>\s*)?\{/.exec(t.slice(app.index, aClose));
136
+ const entry = `'${path}': (context) => const ${cls}(),`;
137
+ if (routes) {
138
+ const open = app.index + routes.index + routes[0].length - 1;
139
+ const close = matchBrace(t, open, 'dart');
140
+ const closeIndent = t.slice(t.lastIndexOf('\n', close) + 1, close).match(/^[ \t]*/)[0];
141
+ const before = t.slice(0, close).replace(/\s*$/, '');
142
+ t = `${before}${before.endsWith('{') || before.endsWith(',') ? '' : ','}\n${closeIndent} ${entry}\n${closeIndent}${t.slice(close)}`;
143
+ }
144
+ else {
145
+ const indent = t.slice(t.lastIndexOf('\n', app.index) + 1).match(/^[ \t]*/)[0];
146
+ const at = app.index + app[0].length;
147
+ t = `${t.slice(0, at)}\n${indent} routes: {\n${indent} ${entry}\n${indent} },${t.slice(at)}`;
148
+ }
149
+ t = addDartImport(t, importUri(mainFile, file, c));
150
+ files.set(mainFile, withEol(t, detectEol(orig)));
151
+ summary.push(`Update ${mainFile}: route '${path}' → ${cls}`);
152
+ summary.push(`Open it with: Navigator.pushNamed(context, '${path}')`);
153
+ }
154
+ // ---------------- Next.js ----------------
155
+ function nextMajor(root) {
156
+ const pkg = readJson(join(root, 'package.json'));
157
+ const v = pkg?.dependencies?.next ?? pkg?.devDependencies?.next ?? '';
158
+ return Number(/\d+/.exec(v)?.[0] ?? 15);
159
+ }
160
+ function planNextRoute(root, map, opts, files, summary) {
161
+ const { path, params } = normPath(opts.name, opts.path);
162
+ const appDir = ['src/app', 'app'].find((d) => exists(join(root, d)));
163
+ const pagesDir = ['src/pages', 'pages'].find((d) => exists(join(root, d)));
164
+ const segs = path.split('/').filter(Boolean).map((s) => s.replace(/^:(\w+)$/, '[$1]'));
165
+ const comp = pascal(`${opts.name} page`).replace(/PagePage$/, 'Page');
166
+ const title = words(opts.name.replace(/page$/i, '')).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
167
+ const tsx = exists(join(root, 'tsconfig.json'));
168
+ const typed = (s) => (tsx ? s : '');
169
+ let file;
170
+ let body;
171
+ if (appDir || !pagesDir) {
172
+ file = posix.join(appDir ?? 'app', ...segs, `page.${tsx ? 'tsx' : 'jsx'}`);
173
+ if (!params.length) {
174
+ body = `export default function ${comp}() {\n return (\n <main>\n <h1>${title}</h1>\n </main>\n );\n}\n`;
175
+ }
176
+ else if (nextMajor(root) >= 15) {
177
+ const t = `{ ${params.map((p) => `${p}: string`).join('; ')} }`;
178
+ body = `export default async function ${comp}({ params }${typed(`: { params: Promise<${t}> }`)}) {\n const { ${params.join(', ')} } = await params;\n return (\n <main>\n <h1>${title} {${params[0]}}</h1>\n </main>\n );\n}\n`;
179
+ }
180
+ else {
181
+ const t = `{ ${params.map((p) => `${p}: string`).join('; ')} }`;
182
+ body = `export default function ${comp}({ params }${typed(`: { params: ${t} }`)}) {\n return (\n <main>\n <h1>${title} {params.${params[0]}}</h1>\n </main>\n );\n}\n`;
183
+ }
184
+ }
185
+ else {
186
+ file = posix.join(pagesDir, ...(segs.length ? segs : ['index'])) + `.${tsx ? 'tsx' : 'jsx'}`;
187
+ body = params.length
188
+ ? `import { useRouter } from 'next/router';\n\nexport default function ${comp}() {\n const { ${params.join(', ')} } = useRouter().query;\n return (\n <main>\n <h1>${title} {${params[0]}}</h1>\n </main>\n );\n}\n`
189
+ : `export default function ${comp}() {\n return (\n <main>\n <h1>${title}</h1>\n </main>\n );\n}\n`;
190
+ }
191
+ if (exists(join(root, file)))
192
+ throw new ToolError('route_exists', `${file} already exists.`);
193
+ const clash = map.routes.find((r) => r.path.replace(/\{([^/{}]+)\}/g, ':$1').replace(/\[(\w+)\]/g, ':$1') === path);
194
+ if (clash)
195
+ throw new ToolError('route_exists', `There is already a page for ${path} (${clash.file}).`);
196
+ files.set(file, body);
197
+ summary.push(`Create ${file}: ${comp} at ${path.replace(/:(\w+)/g, '[$1]')}`);
198
+ summary.push(params.length ? `Link to it with: <Link href={\`${path.replace(/:(\w+)/g, '${$1}')}\`}>` : `Link to it with: <Link href="${path}">`);
199
+ }
200
+ export async function planRouteAdd(root, adapter, opts) {
201
+ if (!/^[A-Za-z][\w-]*$/.test(opts.name))
202
+ throw new ToolError('bad_args', `"${opts.name}" is not a valid route name.`, 'Use letters, digits, - or _, e.g. order-details.');
203
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
204
+ const c = map.conventions;
205
+ if (adapter.id === 'flutter' && (c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx')) {
206
+ const cs = await planModuleAdd(root, adapter, { name: opts.name, initial: opts.initial });
207
+ cs.skill = 'route add';
208
+ if (opts.path && /:/.test(opts.path))
209
+ cs.warnings.push(`GetX routes are /${kebab(opts.name)}; pass ${normPath(opts.name, opts.path).params.join(', ')} with Get.toNamed(Routes.X, parameters: {...}) and read Get.parameters in the controller.`);
210
+ return cs;
211
+ }
212
+ if (adapter.id === 'android')
213
+ return (await import("./androidUi.js")).planAndroidRoute(root, map, opts);
214
+ if (adapter.id === 'react-native')
215
+ return (await import("./reactNativeUi.js")).planRnRoute(root, map, opts);
216
+ if (adapter.id === 'react')
217
+ return (await import("./reactUi.js")).planReactRoute(root, map, opts);
218
+ if (adapter.id === 'ios')
219
+ return (await import("./iosUi.js")).planIosRoute(root, map, opts);
220
+ const files = new Map();
221
+ const summary = [];
222
+ const warnings = [];
223
+ if (adapter.id === 'flutter')
224
+ planFlutterRoute(root, map, opts, files, summary, warnings);
225
+ else if (adapter.id === 'nextjs')
226
+ planNextRoute(root, map, opts, files, summary);
227
+ else
228
+ throw new ToolError('not_supported', `route add needs the Next.js, Flutter, React Native or Android adapter; this project uses the ${adapter.id} adapter.`);
229
+ return buildChangeSet(root, 'route add', { files, summary, warnings, generated: [] });
230
+ }