codz-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// screen add <name> --api <endpoint> --form: a form that sends the endpoint's
|
|
2
|
+
// request body. One input per field of the request model, typed by field
|
|
3
|
+
// kind; submit calls the endpoint and shows saving, success and the error.
|
|
4
|
+
//
|
|
5
|
+
// Next.js a client page ('use client') with controlled inputs
|
|
6
|
+
// Flutter a StatefulWidget with a Form, validators and a SnackBar
|
|
7
|
+
// (Riverpod, get_it, Bloc and GetX projects)
|
|
8
|
+
//
|
|
9
|
+
// Field kinds: text, number (int/double), checkbox (bool), date (ISO text),
|
|
10
|
+
// list of strings (comma separated). A required field of another kind (a
|
|
11
|
+
// nested object) stops the command, since a form cannot fill it.
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { readMethod } from "../adapters/getx.js";
|
|
14
|
+
import { splitTsParams } from "../adapters/reactQuery.js";
|
|
15
|
+
import { prefillFor, tsInitial } from "./prefill.js";
|
|
16
|
+
import { addDartImport, importUri } from "../adapters/flutter.js";
|
|
17
|
+
import { importPath } from "../adapters/nextjs.js";
|
|
18
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
19
|
+
import { exists, readText } from "../core/fsutil.js";
|
|
20
|
+
import { ToolError } from "../core/types.js";
|
|
21
|
+
import { camel, DART_RESERVED, identifier, pascal } from "../input/naming.js";
|
|
22
|
+
import { findDeclaration } from "./apiEdit.js";
|
|
23
|
+
import { planRouteAdd } from "./route.js";
|
|
24
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
25
|
+
function kindOf(type) {
|
|
26
|
+
const t = type.replace(/\?$/, '').replace(/\s*\|\s*null$/, '').trim();
|
|
27
|
+
if (/^(string|String)$/.test(t))
|
|
28
|
+
return 'text';
|
|
29
|
+
if (/^int$/.test(t))
|
|
30
|
+
return 'int';
|
|
31
|
+
if (/^(double|num|number)$/.test(t))
|
|
32
|
+
return 'double';
|
|
33
|
+
if (/^(bool|boolean)$/.test(t))
|
|
34
|
+
return 'bool';
|
|
35
|
+
if (/^DateTime$/.test(t))
|
|
36
|
+
return 'date';
|
|
37
|
+
if (/^(string\[\]|List<String>|Array<string>)$/.test(t))
|
|
38
|
+
return 'strings';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function formFields(model, endpoint) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const cannot = [];
|
|
44
|
+
for (const f of model.fields) {
|
|
45
|
+
const kind = kindOf(f.type);
|
|
46
|
+
if (kind)
|
|
47
|
+
out.push({ json: f.name, kind, required: !f.nullable });
|
|
48
|
+
else if (!f.nullable)
|
|
49
|
+
cannot.push(`${f.name} (${f.type})`);
|
|
50
|
+
}
|
|
51
|
+
if (cannot.length)
|
|
52
|
+
throw new ToolError('form_fields', `${model.name} has required fields a form cannot fill: ${cannot.join(', ')}.`, `Write the form for ${endpoint} by hand, or make those fields optional.`);
|
|
53
|
+
if (!out.length)
|
|
54
|
+
throw new ToolError('form_fields', `${model.name} has no fields a form can fill.`);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function findMutation(map, name) {
|
|
58
|
+
const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
59
|
+
if (!ep)
|
|
60
|
+
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');
|
|
61
|
+
if (ep.method === 'GET' || ep.method === 'HEAD')
|
|
62
|
+
throw new ToolError('form_needs_body', `${ep.name} is a ${ep.method} endpoint; a form sends data with POST, PUT or PATCH.`, `For a screen that shows ${ep.name}'s data, leave out --form.`);
|
|
63
|
+
return ep;
|
|
64
|
+
}
|
|
65
|
+
const routeParams = (path) => [...(path ?? '').matchAll(/[:{]([\w-]+)\}?/g)].map((m) => camel(m[1]));
|
|
66
|
+
export async function planFormScreen(root, adapter, map, opts) {
|
|
67
|
+
if (!opts.api)
|
|
68
|
+
throw new ToolError('bad_args', '--form needs the endpoint to send to: --api <endpoint>.');
|
|
69
|
+
const ep = findMutation(map, opts.api);
|
|
70
|
+
if (adapter.id === 'nextjs')
|
|
71
|
+
return nextForm(root, adapter, map, opts, ep);
|
|
72
|
+
if (adapter.id === 'flutter')
|
|
73
|
+
return flutterForm(root, adapter, map, opts, ep);
|
|
74
|
+
throw new ToolError('not_supported', `screen add --form needs the Next.js or Flutter adapter; this project uses the ${adapter.id} adapter.`);
|
|
75
|
+
}
|
|
76
|
+
// ---------------- Next.js ----------------
|
|
77
|
+
async function nextForm(root, adapter, map, opts, ep) {
|
|
78
|
+
const c = map.conventions;
|
|
79
|
+
const text = readText(join(root, ep.file)) ?? '';
|
|
80
|
+
const decl = findDeclaration(text, ep.name, 'ts');
|
|
81
|
+
if (!decl)
|
|
82
|
+
throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
|
|
83
|
+
const params = decl.params.split(/,(?![^{]*\})/).map((p) => p.trim()).filter(Boolean);
|
|
84
|
+
const body = params.find((p) => /^body\s*:/.test(p));
|
|
85
|
+
if (!body)
|
|
86
|
+
throw new ToolError('form_needs_body', `${ep.name}() takes no request body, so there is nothing for a form to send.`);
|
|
87
|
+
const bodyType = body.replace(/^body\s*:\s*/, '').trim();
|
|
88
|
+
const model = map.models.find((m) => m.name === bodyType.replace(/^Partial<(.+)>$/, '$1'));
|
|
89
|
+
if (!model)
|
|
90
|
+
throw new ToolError('form_fields', `The request type ${bodyType} is not a model codz knows, so it cannot build the inputs.`);
|
|
91
|
+
const partial = /^Partial</.test(bodyType);
|
|
92
|
+
const fields = formFields(model, ep.name).map((f) => (partial ? { ...f, required: false } : f));
|
|
93
|
+
const pathArgs = params.filter((p) => !/^body\s*:/.test(p) && !/^params\??\s*:/.test(p)).map((p) => ({ name: p.split(/\??\s*:/)[0].trim(), number: /:\s*number$/.test(p) }));
|
|
94
|
+
const routeP = routeParams(opts.path);
|
|
95
|
+
const missing = pathArgs.filter((a) => !routeP.includes(a.name));
|
|
96
|
+
if (missing.length)
|
|
97
|
+
throw new ToolError('screen_args', `${ep.name}() needs ${missing.map((a) => a.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${opts.name}/:${missing[0].name}`);
|
|
98
|
+
const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
|
|
99
|
+
const page = route.changes.find((ch) => ch.before === null && /\/page\.(t|j)sx$/.test(ch.path));
|
|
100
|
+
if (!page?.after)
|
|
101
|
+
throw new ToolError('not_supported', 'screen add --form fills App Router pages only.');
|
|
102
|
+
const q = c.quote === 'double' ? '"' : "'";
|
|
103
|
+
const semi = c.semicolons === 'false' ? '' : ';';
|
|
104
|
+
const key = (n) => (IDENT.test(n) ? n : `${q}${n}${q}`);
|
|
105
|
+
const get = (n) => (IDENT.test(n) ? `values.${n}` : `values[${q}${n}${q}]`);
|
|
106
|
+
const comp = /export default (?:async )?function (\w+)/.exec(page.after)?.[1] ?? `${pascal(opts.name)}Page`;
|
|
107
|
+
const title = /<h1>([^<{]*)/.exec(page.after)?.[1]?.trim() ?? pascal(opts.name);
|
|
108
|
+
const dir = ep.file.slice(0, ep.file.lastIndexOf('/'));
|
|
109
|
+
const barrel = [`${dir}/index.ts`, `${dir}/index.js`].find((f) => exists(join(root, f)) && /export\s+\*\s+from|export\s*\{/.test(readText(join(root, f)) ?? ''));
|
|
110
|
+
const initial = fields.map((f) => `${key(f.json)}: ${f.kind === 'bool' ? 'false' : `${q}${q}`}`).join(', ');
|
|
111
|
+
const value = (f) => {
|
|
112
|
+
const v = get(f.json);
|
|
113
|
+
const conv = f.kind === 'int' || f.kind === 'double' ? `Number(${v})` : f.kind === 'strings' ? `${v}.split(${q},${q}).map((s) => s.trim()).filter(Boolean)` : v;
|
|
114
|
+
if (f.kind === 'bool' || f.required)
|
|
115
|
+
return conv;
|
|
116
|
+
return `${v} === ${q}${q} ? undefined : ${conv}`;
|
|
117
|
+
};
|
|
118
|
+
const input = (f) => {
|
|
119
|
+
const onChange = `(e) => setValues({ ...values, ${key(f.json)}: e.target.${f.kind === 'bool' ? 'checked' : 'value'} })`;
|
|
120
|
+
const label = f.json;
|
|
121
|
+
if (f.kind === 'bool')
|
|
122
|
+
return ` <label>\n <input type="checkbox" checked={${get(f.json)}} onChange={${onChange}} /> ${label}\n </label>`;
|
|
123
|
+
const type = f.kind === 'int' || f.kind === 'double' ? 'number' : f.kind === 'date' ? 'datetime-local' : /email/i.test(f.json) ? 'email' : 'text';
|
|
124
|
+
const extra = [f.kind === 'double' ? ' step="any"' : '', f.required ? ' required' : '', f.kind === 'strings' ? ' placeholder="a, b, c"' : ''].join('');
|
|
125
|
+
return ` <label>\n ${label}\n <input type="${type}" value={${get(f.json)}} onChange={${onChange}}${extra} />\n </label>`;
|
|
126
|
+
};
|
|
127
|
+
const callArgs = [...pathArgs.map((a) => (a.number ? `Number(params.${a.name})` : `params.${a.name}`)), 'body'].join(', ');
|
|
128
|
+
// Edit forms start with the current values (feature add passes the detail endpoint).
|
|
129
|
+
const pre = prefillFor(root, map, opts.load, fields.map((f) => f.json), routeP, 'ts');
|
|
130
|
+
const loadDecl = pre ? findDeclaration(readText(join(root, pre.ep.file)) ?? '', pre.ep.name, 'ts') : null;
|
|
131
|
+
const loadParams = loadDecl ? splitTsParams(loadDecl.params).filter((p) => !p.optional) : [];
|
|
132
|
+
const prefill = pre && loadDecl && loadParams.every((p) => routeP.includes(p.name)) ? pre : null;
|
|
133
|
+
const loadArgs = loadParams.map((p) => (/^number$/.test(p.type) ? `Number(params.${p.name})` : `params.${p.name}`)).join(', ');
|
|
134
|
+
const fromD = (f) => tsInitial(f.kind, IDENT.test(f.json) ? `d.${f.json}` : `d[${q}${f.json}${q}]`, prefill.field(f.json).nullable, q);
|
|
135
|
+
const prefillLines = prefill
|
|
136
|
+
? [
|
|
137
|
+
'',
|
|
138
|
+
' useEffect(() => {',
|
|
139
|
+
` ${prefill.ep.name}(${loadArgs})`,
|
|
140
|
+
` .then((d) => setValues((v) => ({ ...v, ${fields.filter((f) => prefill.shared.has(f.json)).map((f) => `${key(f.json)}: ${fromD(f)}`).join(', ')} })))`,
|
|
141
|
+
' .catch((err) => {',
|
|
142
|
+
` setError(err instanceof Error ? err.message : String(err))${semi}`,
|
|
143
|
+
` setStatus(${q}error${q})${semi}`,
|
|
144
|
+
` })${semi}`,
|
|
145
|
+
` }, [${loadParams.map((p) => `params.${p.name}`).join(', ')}])${semi}`,
|
|
146
|
+
]
|
|
147
|
+
: [];
|
|
148
|
+
const sameModule = prefill && prefill.ep.file === ep.file;
|
|
149
|
+
page.after = [
|
|
150
|
+
`${q}use client${q}${semi}`,
|
|
151
|
+
'',
|
|
152
|
+
`import { ${prefill ? 'useEffect, ' : ''}useState, type FormEvent } from ${q}react${q}${semi}`,
|
|
153
|
+
...(pathArgs.length ? [`import { useParams } from ${q}next/navigation${q}${semi}`] : []),
|
|
154
|
+
`import { ${[ep.name, ...(sameModule || (prefill && barrel) ? [prefill.ep.name] : [])].sort().join(', ')} } from ${q}${importPath(page.path, barrel ?? ep.file, c)}${q}${semi}`,
|
|
155
|
+
...(prefill && !sameModule && !barrel ? [`import { ${prefill.ep.name} } from ${q}${importPath(page.path, prefill.ep.file, c)}${q}${semi}`] : []),
|
|
156
|
+
`import type { ${model.name} } from ${q}${importPath(page.path, model.file, c)}${q}${semi}`,
|
|
157
|
+
'',
|
|
158
|
+
`export default function ${comp}() {`,
|
|
159
|
+
...(pathArgs.length ? [` const params = useParams<{ ${pathArgs.map((a) => `${a.name}: string`).join('; ')} }>()${semi}`] : []),
|
|
160
|
+
` const [values, setValues] = useState({ ${initial} })${semi}`,
|
|
161
|
+
` const [status, setStatus] = useState<${q}idle${q} | ${q}saving${q} | ${q}saved${q} | ${q}error${q}>(${q}idle${q})${semi}`,
|
|
162
|
+
` const [error, setError] = useState(${q}${q})${semi}`,
|
|
163
|
+
...prefillLines,
|
|
164
|
+
'',
|
|
165
|
+
' async function onSubmit(e: FormEvent<HTMLFormElement>) {',
|
|
166
|
+
` e.preventDefault()${semi}`,
|
|
167
|
+
` setStatus(${q}saving${q})${semi}`,
|
|
168
|
+
' try {',
|
|
169
|
+
` const body: ${partial ? `Partial<${model.name}>` : model.name} = {`,
|
|
170
|
+
...fields.map((f) => ` ${key(f.json)}: ${value(f)},`),
|
|
171
|
+
` }${semi}`,
|
|
172
|
+
` await ${ep.name}(${callArgs})${semi}`,
|
|
173
|
+
` setStatus(${q}saved${q})${semi}`,
|
|
174
|
+
' } catch (err) {',
|
|
175
|
+
` setError(err instanceof Error ? err.message : String(err))${semi}`,
|
|
176
|
+
` setStatus(${q}error${q})${semi}`,
|
|
177
|
+
' }',
|
|
178
|
+
' }',
|
|
179
|
+
'',
|
|
180
|
+
' return (',
|
|
181
|
+
' <main>',
|
|
182
|
+
` <h1>${title}</h1>`,
|
|
183
|
+
' <form onSubmit={onSubmit}>',
|
|
184
|
+
...fields.map(input),
|
|
185
|
+
` <button type="submit" disabled={status === ${q}saving${q}}>`,
|
|
186
|
+
` {status === ${q}saving${q} ? ${q}Saving…${q} : ${q}Save${q}}`,
|
|
187
|
+
' </button>',
|
|
188
|
+
` {status === ${q}saved${q} && <p>Saved.</p>}`,
|
|
189
|
+
` {status === ${q}error${q} && <p role="alert">{error}</p>}`,
|
|
190
|
+
' </form>',
|
|
191
|
+
' </main>',
|
|
192
|
+
` )${semi}`,
|
|
193
|
+
'}',
|
|
194
|
+
'',
|
|
195
|
+
].join('\n');
|
|
196
|
+
const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
|
|
197
|
+
const summary = [...route.summary, `${page.path} is a form for ${model.name} (${fields.length} field${fields.length === 1 ? '' : 's'}) that calls ${ep.name}() and shows saving, saved and the error`];
|
|
198
|
+
return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
|
|
199
|
+
}
|
|
200
|
+
// ---------------- Flutter ----------------
|
|
201
|
+
async function flutterForm(root, adapter, map, opts, ep) {
|
|
202
|
+
const c = map.conventions;
|
|
203
|
+
// GetX: the module (binding, controller, route) as usual; its view becomes the form, with route
|
|
204
|
+
// parameters from Get.parameters and the repository from Get.find.
|
|
205
|
+
const getx = c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx';
|
|
206
|
+
const repoText = readText(join(root, ep.file)) ?? '';
|
|
207
|
+
const sig = readMethod(repoText, ep.name);
|
|
208
|
+
if (!sig?.repoClass)
|
|
209
|
+
throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
|
|
210
|
+
const parts = sig.params.replace(/@Body\(\)\s*/g, '').replace(/@\w+\([^)]*\)\s*/g, '');
|
|
211
|
+
const bodyMatch = /([\w<>?]+)\s+body\b/.exec(parts);
|
|
212
|
+
if (!bodyMatch)
|
|
213
|
+
throw new ToolError('form_needs_body', `${ep.name}() takes no request body, so there is nothing for a form to send.`);
|
|
214
|
+
const model = map.models.find((m) => m.name === bodyMatch[1].replace(/\?$/, ''));
|
|
215
|
+
if (!model)
|
|
216
|
+
throw new ToolError('form_fields', `The request type ${bodyMatch[1]} is not a model codz knows, so it cannot build the inputs.`);
|
|
217
|
+
const fields = formFields(model, ep.name);
|
|
218
|
+
const pathArgs = [...parts.replace(/\{[^}]*\}/, '').split(',')].map((p) => p.trim()).filter((p) => p && !/\bbody$/.test(p)).map((p) => {
|
|
219
|
+
const m = /^([\w<>?]+)\s+(\w+)$/.exec(p);
|
|
220
|
+
return { type: m?.[1] ?? 'String', name: m?.[2] ?? p };
|
|
221
|
+
});
|
|
222
|
+
const routeP = routeParams(opts.path);
|
|
223
|
+
const missing = pathArgs.filter((a) => !routeP.includes(a.name));
|
|
224
|
+
if (missing.length)
|
|
225
|
+
throw new ToolError('screen_args', `${ep.name}() needs ${missing.map((a) => a.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${opts.name}/:${missing[0].name}`);
|
|
226
|
+
const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
|
|
227
|
+
const page = getx ? route.changes.find((ch) => ch.before === null && /\/views\/\w+_view\.dart$/.test(ch.path)) : route.changes.find((ch) => ch.before === null && ch.path.endsWith('.dart'));
|
|
228
|
+
const cls = page?.after ? new RegExp(`class\\s+(\\w+)\\s+extends\\s+${getx ? 'GetView<\\w+>' : 'StatelessWidget'}`).exec(page.after)?.[1] : undefined;
|
|
229
|
+
if (!page?.after || !cls)
|
|
230
|
+
throw new ToolError('not_supported', 'Could not create the page for the form.');
|
|
231
|
+
const riverpod = c.di === 'riverpod';
|
|
232
|
+
const getIt = c.di === 'get_it';
|
|
233
|
+
const repoExpr = getx ? `Get.find<${sig.repoClass}>()` : riverpod ? `ref.read(${camel(sig.repoClass)}Provider)` : getIt ? `${c.diInstance || 'getIt'}<${sig.repoClass}>()` : `context.read<${sig.repoClass}>()`;
|
|
234
|
+
if (!getx && !riverpod && !getIt && c.stateManagement !== 'bloc')
|
|
235
|
+
throw new ToolError('not_supported', `screen add --form needs a way to get ${sig.repoClass} (Riverpod, get_it or Bloc's RepositoryProvider); this project registers it with "${c.di || 'nothing'}".`);
|
|
236
|
+
if (!getx && !riverpod && !getIt)
|
|
237
|
+
route.warnings.push(`${page.path} reads ${sig.repoClass} with context.read, so provide it above this page with a RepositoryProvider.`);
|
|
238
|
+
const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? pascal(opts.name);
|
|
239
|
+
const prop = (f) => identifier(f.json, DART_RESERVED);
|
|
240
|
+
const ctl = (f) => `_${camel(prop(f))}`;
|
|
241
|
+
const texty = fields.filter((f) => f.kind !== 'bool');
|
|
242
|
+
const bools = fields.filter((f) => f.kind === 'bool');
|
|
243
|
+
const parse = (f) => {
|
|
244
|
+
const t = `${ctl(f)}.text.trim()`;
|
|
245
|
+
const conv = f.kind === 'int' ? `int.parse(${t})` : f.kind === 'double' ? `double.parse(${t})` : f.kind === 'date' ? `DateTime.parse(${t})` : f.kind === 'strings' ? `${t}.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList()` : t;
|
|
246
|
+
return f.required ? conv : `${t}.isEmpty ? null : ${conv}`;
|
|
247
|
+
};
|
|
248
|
+
const validator = (f) => {
|
|
249
|
+
const checks = [];
|
|
250
|
+
if (f.required)
|
|
251
|
+
checks.push("if (v == null || v.trim().isEmpty) return 'Required';");
|
|
252
|
+
// After the required check, v is known to be non-null and non-empty (a second null check is an analyzer warning).
|
|
253
|
+
const given = f.required ? '' : 'v != null && v.trim().isNotEmpty && ';
|
|
254
|
+
if (f.kind === 'int')
|
|
255
|
+
checks.push(`if (${given}int.tryParse(v.trim()) == null) return 'Enter a whole number';`);
|
|
256
|
+
if (f.kind === 'double')
|
|
257
|
+
checks.push(`if (${given}double.tryParse(v.trim()) == null) return 'Enter a number';`);
|
|
258
|
+
if (f.kind === 'date')
|
|
259
|
+
checks.push(`if (${given}DateTime.tryParse(v.trim()) == null) return 'Use YYYY-MM-DD';`);
|
|
260
|
+
return checks.length ? `(v) {\n ${checks.join('\n ')}\n return null;\n }` : '';
|
|
261
|
+
};
|
|
262
|
+
const argOf = (a) => (getx ? a.name : `widget.${a.name}`);
|
|
263
|
+
const callArgs = [...pathArgs.map((a) => (/^int\??$/.test(a.type) ? `int.parse(${argOf(a)})` : argOf(a))), 'body'].join(', ');
|
|
264
|
+
// Edit forms start with the current values: the detail endpoint, from the same repository.
|
|
265
|
+
const pre = prefillFor(root, map, opts.load, fields.map((f) => f.json), routeP, 'dart');
|
|
266
|
+
const loadSig = pre && pre.ep.file === ep.file ? readMethod(repoText, pre.ep.name) : null;
|
|
267
|
+
const loadArgs = loadSig
|
|
268
|
+
? loadSig.params
|
|
269
|
+
.replace(/@\w+\([^)]*\)\s*/g, '')
|
|
270
|
+
.split(',')
|
|
271
|
+
.map((x) => x.trim())
|
|
272
|
+
.filter((x) => x && !x.startsWith('{') && !x.startsWith('['))
|
|
273
|
+
.map((x) => /^([\w<>?]+)\s+(\w+)$/.exec(x))
|
|
274
|
+
: [];
|
|
275
|
+
const prefill = pre && loadSig && loadSig.repoClass === sig.repoClass && loadArgs.every((m) => m && routeP.includes(m[2])) ? pre : null;
|
|
276
|
+
const loadCall = prefill ? `${repoExpr}.${prefill.ep.name}(${loadArgs.map((m) => (/^int\??$/.test(m[1]) ? `int.parse(${argOf({ name: m[2] })})` : argOf({ name: m[2] }))).join(', ')})` : '';
|
|
277
|
+
const fromD = (f) => {
|
|
278
|
+
const n = prefill.field(f.json).nullable;
|
|
279
|
+
const d = `d.${camel(prop(f))}`;
|
|
280
|
+
if (f.kind === 'bool')
|
|
281
|
+
return n ? `${d} ?? false` : d;
|
|
282
|
+
if (f.kind === 'strings')
|
|
283
|
+
return n ? `(${d} ?? const []).join(', ')` : `${d}.join(', ')`;
|
|
284
|
+
if (f.kind === 'date')
|
|
285
|
+
return n ? `${d}?.toIso8601String() ?? ''` : `${d}.toIso8601String()`;
|
|
286
|
+
if (f.kind === 'int' || f.kind === 'double')
|
|
287
|
+
return n ? `${d}?.toString() ?? ''` : `'\${${d}}'`;
|
|
288
|
+
return n ? `${d} ?? ''` : d;
|
|
289
|
+
};
|
|
290
|
+
const prefillLines = prefill
|
|
291
|
+
? [
|
|
292
|
+
' @override',
|
|
293
|
+
' void initState() {',
|
|
294
|
+
' super.initState();',
|
|
295
|
+
' _load();',
|
|
296
|
+
' }',
|
|
297
|
+
'',
|
|
298
|
+
' /// Fills the form with the current values.',
|
|
299
|
+
' Future<void> _load() async {',
|
|
300
|
+
...(getx ? loadArgs.map((m) => ` final ${m[2]} = Get.parameters['${m[2]}']!;`) : []),
|
|
301
|
+
' try {',
|
|
302
|
+
` final d = await ${loadCall};`,
|
|
303
|
+
' if (!mounted) return;',
|
|
304
|
+
' setState(() {',
|
|
305
|
+
...fields.filter((f) => prefill.shared.has(f.json)).map((f) => (f.kind === 'bool' ? ` ${ctl(f)} = ${fromD(f)};` : ` ${ctl(f)}.text = ${fromD(f)};`)),
|
|
306
|
+
' });',
|
|
307
|
+
' } catch (e) {',
|
|
308
|
+
" if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));",
|
|
309
|
+
' }',
|
|
310
|
+
' }',
|
|
311
|
+
'',
|
|
312
|
+
]
|
|
313
|
+
: [];
|
|
314
|
+
const stateBase = riverpod ? 'ConsumerStatefulWidget' : 'StatefulWidget';
|
|
315
|
+
const stateCls = riverpod ? `ConsumerState<${cls}>` : `State<${cls}>`;
|
|
316
|
+
const ctor = pathArgs.length && !getx ? `const ${cls}({super.key, ${pathArgs.map((a) => `required this.${a.name}`).join(', ')}});` : `const ${cls}({super.key});`;
|
|
317
|
+
let text = [
|
|
318
|
+
"import 'package:flutter/material.dart';",
|
|
319
|
+
'',
|
|
320
|
+
`class ${cls} extends ${stateBase} {`,
|
|
321
|
+
` ${ctor}`,
|
|
322
|
+
...(pathArgs.length && !getx ? ['', ...pathArgs.map((a) => ` final String ${a.name};`)] : []),
|
|
323
|
+
'',
|
|
324
|
+
' @override',
|
|
325
|
+
` ${stateCls.replace(/<.*/, '')}<${cls}> createState() => _${cls}State();`,
|
|
326
|
+
'}',
|
|
327
|
+
'',
|
|
328
|
+
`class _${cls}State extends ${stateCls} {`,
|
|
329
|
+
' final _formKey = GlobalKey<FormState>();',
|
|
330
|
+
...texty.map((f) => ` final ${ctl(f)} = TextEditingController();`),
|
|
331
|
+
...bools.map((f) => ` bool ${ctl(f)} = false;`),
|
|
332
|
+
' bool _saving = false;',
|
|
333
|
+
'',
|
|
334
|
+
...prefillLines,
|
|
335
|
+
' @override',
|
|
336
|
+
' void dispose() {',
|
|
337
|
+
...texty.map((f) => ` ${ctl(f)}.dispose();`),
|
|
338
|
+
' super.dispose();',
|
|
339
|
+
' }',
|
|
340
|
+
'',
|
|
341
|
+
' Future<void> _submit() async {',
|
|
342
|
+
' if (!_formKey.currentState!.validate()) return;',
|
|
343
|
+
...(getx ? pathArgs.map((a) => ` final ${a.name} = Get.parameters['${a.name}']!;`) : []),
|
|
344
|
+
' setState(() => _saving = true);',
|
|
345
|
+
' final messenger = ScaffoldMessenger.of(context);',
|
|
346
|
+
' try {',
|
|
347
|
+
` final body = ${model.name}(`,
|
|
348
|
+
...fields.map((f) => ` ${camel(prop(f))}: ${f.kind === 'bool' ? ctl(f) : parse(f)},`),
|
|
349
|
+
' );',
|
|
350
|
+
` await ${repoExpr}.${ep.name}(${callArgs});`,
|
|
351
|
+
" messenger.showSnackBar(const SnackBar(content: Text('Saved')));",
|
|
352
|
+
' } catch (e) {',
|
|
353
|
+
" messenger.showSnackBar(SnackBar(content: Text('$e')));",
|
|
354
|
+
' } finally {',
|
|
355
|
+
' if (mounted) setState(() => _saving = false);',
|
|
356
|
+
' }',
|
|
357
|
+
' }',
|
|
358
|
+
'',
|
|
359
|
+
' @override',
|
|
360
|
+
' Widget build(BuildContext context) {',
|
|
361
|
+
' return Scaffold(',
|
|
362
|
+
` appBar: AppBar(title: const Text('${titleText}')),`,
|
|
363
|
+
' body: Form(',
|
|
364
|
+
' key: _formKey,',
|
|
365
|
+
' child: ListView(',
|
|
366
|
+
' padding: const EdgeInsets.all(16),',
|
|
367
|
+
' children: [',
|
|
368
|
+
...fields.map((f) => {
|
|
369
|
+
if (f.kind === 'bool')
|
|
370
|
+
return ` SwitchListTile(\n title: const Text('${f.json}'),\n value: ${ctl(f)},\n onChanged: (v) => setState(() => ${ctl(f)} = v),\n ),`;
|
|
371
|
+
const kb = f.kind === 'int' ? 'TextInputType.number' : f.kind === 'double' ? 'const TextInputType.numberWithOptions(decimal: true)' : f.kind === 'date' ? 'TextInputType.datetime' : '';
|
|
372
|
+
const v = validator(f);
|
|
373
|
+
return [
|
|
374
|
+
' TextFormField(',
|
|
375
|
+
` controller: ${ctl(f)},`,
|
|
376
|
+
` decoration: const InputDecoration(labelText: '${f.json}'${f.kind === 'strings' ? ", helperText: 'Separate items with commas'" : ''}),`,
|
|
377
|
+
...(kb ? [` keyboardType: ${kb},`] : []),
|
|
378
|
+
...(v ? [` validator: ${v},`] : []),
|
|
379
|
+
' ),',
|
|
380
|
+
].join('\n');
|
|
381
|
+
}),
|
|
382
|
+
' const SizedBox(height: 24),',
|
|
383
|
+
' FilledButton(',
|
|
384
|
+
' onPressed: _saving ? null : _submit,',
|
|
385
|
+
" child: Text(_saving ? 'Saving…' : 'Save'),",
|
|
386
|
+
' ),',
|
|
387
|
+
' ],',
|
|
388
|
+
' ),',
|
|
389
|
+
' ),',
|
|
390
|
+
' );',
|
|
391
|
+
' }',
|
|
392
|
+
'}',
|
|
393
|
+
'',
|
|
394
|
+
].join('\n');
|
|
395
|
+
if (getx)
|
|
396
|
+
text = addDartImport(text, 'package:get/get.dart');
|
|
397
|
+
if (riverpod)
|
|
398
|
+
text = addDartImport(text, 'package:flutter_riverpod/flutter_riverpod.dart');
|
|
399
|
+
if (!getx && !riverpod && !getIt)
|
|
400
|
+
text = addDartImport(text, 'package:flutter_bloc/flutter_bloc.dart');
|
|
401
|
+
text = addDartImport(text, importUri(page.path, ep.file, c));
|
|
402
|
+
if (getIt && c.diFile)
|
|
403
|
+
text = addDartImport(text, importUri(page.path, c.diFile, c));
|
|
404
|
+
if (model.file !== ep.file)
|
|
405
|
+
text = addDartImport(text, importUri(page.path, model.file, c));
|
|
406
|
+
page.after = text;
|
|
407
|
+
const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
|
|
408
|
+
const summary = [...route.summary, `${page.path} is a form for ${model.name} (${fields.length} field${fields.length === 1 ? '' : 's'}, validated) that calls ${sig.repoClass}.${ep.name}()`];
|
|
409
|
+
return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
|
|
410
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// setup: creates an API layer (network client, registration, entry-point
|
|
2
|
+
// wiring, dependencies and conventions) for a project that has none, as one
|
|
3
|
+
// change set the developer previews and can undo.
|
|
4
|
+
import { checkBaseUrl } from "../core/safe.js";
|
|
5
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
6
|
+
import { ToolError } from "../core/types.js";
|
|
7
|
+
import { initFiles } from "./init.js";
|
|
8
|
+
export function planSetup(root, adapter, map, choice) {
|
|
9
|
+
if (!adapter.planSetup || !adapter.architectures) {
|
|
10
|
+
throw new ToolError('not_supported', `setup is not available for the ${adapter.id} adapter.`, 'Supported: Next.js and Flutter.');
|
|
11
|
+
}
|
|
12
|
+
const options = adapter.architectures(map);
|
|
13
|
+
if (!options.length) {
|
|
14
|
+
throw new ToolError('already_set_up', 'This project already has an API layer, so setup is not needed.', 'Run codz explain to see it, then codz api add.');
|
|
15
|
+
}
|
|
16
|
+
if (!options.some((o) => o.id === choice.architecture)) {
|
|
17
|
+
throw new ToolError('bad_args', `Unknown architecture "${choice.architecture}". Choose one of: ${options.map((o) => o.id).join(', ')}.`);
|
|
18
|
+
}
|
|
19
|
+
// Checked strictly: the base URL is written into source files and config as a string literal.
|
|
20
|
+
if (choice.baseUrl)
|
|
21
|
+
choice = { ...choice, baseUrl: checkBaseUrl(choice.baseUrl) };
|
|
22
|
+
const plan = adapter.planSetup({ root, map }, { ...choice, baseUrl: choice.baseUrl.replace(/\/+$/, '') });
|
|
23
|
+
const files = new Map(plan.files);
|
|
24
|
+
for (const [path, content] of initFiles(root, adapter, plan.conventions))
|
|
25
|
+
files.set(path, content);
|
|
26
|
+
const changeSet = buildChangeSet(root, 'setup', { ...plan, files });
|
|
27
|
+
return { changeSet, plan };
|
|
28
|
+
}
|