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,364 @@
|
|
|
1
|
+
// Next.js adapter, part 1: reading a project (conventions, endpoints, models,
|
|
2
|
+
// routes, locales) into a ProjectMap.
|
|
3
|
+
import { join, posix } from 'node:path';
|
|
4
|
+
import { isDir, readJson, readText, walk } from "../core/fsutil.js";
|
|
5
|
+
import {} from "../core/types.js";
|
|
6
|
+
import { detectFileNaming } from "../input/naming.js";
|
|
7
|
+
import { emptyMap, fileStats, listRootEnvFiles, scanEnvBaseUrls, scanLocales } from "./common.js";
|
|
8
|
+
import { detectTsStyle, matchBrace, skipString } from "./source.js";
|
|
9
|
+
import { VERSION } from "./nextjs.js";
|
|
10
|
+
const CODE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'];
|
|
11
|
+
/** Removes comments and trailing commas from JSONC (tsconfig.json), leaving strings untouched. */
|
|
12
|
+
export function stripJsonc(text) {
|
|
13
|
+
let out = '';
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < text.length) {
|
|
16
|
+
const c = text[i];
|
|
17
|
+
const n = text[i + 1];
|
|
18
|
+
if (c === '"') {
|
|
19
|
+
let j = i + 1;
|
|
20
|
+
while (j < text.length && text[j] !== '"')
|
|
21
|
+
j += text[j] === '\\' ? 2 : 1;
|
|
22
|
+
out += text.slice(i, j + 1);
|
|
23
|
+
i = j + 1;
|
|
24
|
+
}
|
|
25
|
+
else if (c === '/' && n === '/') {
|
|
26
|
+
while (i < text.length && text[i] !== '\n')
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
else if (c === '/' && n === '*') {
|
|
30
|
+
const end = text.indexOf('*/', i + 2);
|
|
31
|
+
i = end === -1 ? text.length : end + 2;
|
|
32
|
+
}
|
|
33
|
+
else if (c === ',') {
|
|
34
|
+
let j = i + 1;
|
|
35
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
36
|
+
j++;
|
|
37
|
+
if (text[j] !== '}' && text[j] !== ']')
|
|
38
|
+
out += c;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
out += c;
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function readTsconfig(root) {
|
|
49
|
+
const text = readText(join(root, 'tsconfig.json'));
|
|
50
|
+
if (!text)
|
|
51
|
+
return {};
|
|
52
|
+
const cleaned = stripJsonc(text);
|
|
53
|
+
try {
|
|
54
|
+
const json = JSON.parse(cleaned);
|
|
55
|
+
return json.compilerOptions ?? {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export const dirOf = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '.');
|
|
62
|
+
export const baseNoExt = (f) => f.slice(f.lastIndexOf('/') + 1).replace(/\.(tsx?|jsx?|mjs)$/, '');
|
|
63
|
+
export function mostCommon(items, fallback) {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const i of items)
|
|
66
|
+
counts.set(i, (counts.get(i) ?? 0) + 1);
|
|
67
|
+
let best = fallback;
|
|
68
|
+
let n = 0;
|
|
69
|
+
for (const [k, v] of counts)
|
|
70
|
+
if (v > n)
|
|
71
|
+
[best, n] = [k, v];
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
/** Resolves an import specifier to a project-relative path without extension. */
|
|
75
|
+
export function resolveImport(fromFile, spec, alias, aliasBase) {
|
|
76
|
+
if (alias && spec.startsWith(alias))
|
|
77
|
+
return posix.normalize(posix.join(aliasBase === '.' ? '' : aliasBase, spec.slice(alias.length)));
|
|
78
|
+
if (spec.startsWith('.'))
|
|
79
|
+
return posix.normalize(posix.join(dirOf(fromFile), spec));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function exportedFunctions(text) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const re = /export\s+(?:async\s+)?function\s+(\w+)\s*(?:<[^>()]*>)?\s*\(|export\s+const\s+(\w+)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>()]*>)?\s*\(/g;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = re.exec(text))) {
|
|
87
|
+
const name = m[1] ?? m[2];
|
|
88
|
+
const open = m.index + m[0].length - 1;
|
|
89
|
+
const close = matchBrace(text, open);
|
|
90
|
+
if (close === -1)
|
|
91
|
+
continue;
|
|
92
|
+
const params = text.slice(open + 1, close);
|
|
93
|
+
let i = close + 1;
|
|
94
|
+
const arrow = text.indexOf('=>', i);
|
|
95
|
+
const brace = text.indexOf('{', i);
|
|
96
|
+
let returnType = '';
|
|
97
|
+
let bodyStart;
|
|
98
|
+
if (m[2] !== undefined) {
|
|
99
|
+
if (arrow === -1)
|
|
100
|
+
continue;
|
|
101
|
+
returnType = text.slice(i, arrow).replace(/^\s*:\s*/, '').trim();
|
|
102
|
+
i = arrow + 2;
|
|
103
|
+
while (/\s/.test(text[i]))
|
|
104
|
+
i++;
|
|
105
|
+
bodyStart = i;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (brace === -1)
|
|
109
|
+
continue;
|
|
110
|
+
returnType = text.slice(i, brace).replace(/^\s*:\s*/, '').trim();
|
|
111
|
+
bodyStart = brace;
|
|
112
|
+
}
|
|
113
|
+
let bodyEnd;
|
|
114
|
+
if (text[bodyStart] === '{')
|
|
115
|
+
bodyEnd = matchBrace(text, bodyStart);
|
|
116
|
+
else {
|
|
117
|
+
const nl = text.indexOf('\n\n', bodyStart);
|
|
118
|
+
bodyEnd = nl === -1 ? text.length : nl;
|
|
119
|
+
}
|
|
120
|
+
if (bodyEnd === -1)
|
|
121
|
+
continue;
|
|
122
|
+
out.push({ name, params, returnType, body: text.slice(bodyStart, bodyEnd + 1) });
|
|
123
|
+
re.lastIndex = bodyEnd;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** Finds the URL argument of a fetch/helper call and turns it into a path with {params}. */
|
|
128
|
+
function callPath(body, callee) {
|
|
129
|
+
const m = callee.exec(body);
|
|
130
|
+
if (!m)
|
|
131
|
+
return null;
|
|
132
|
+
let i = m.index + m[0].length;
|
|
133
|
+
while (/\s/.test(body[i]))
|
|
134
|
+
i++;
|
|
135
|
+
const q = body[i];
|
|
136
|
+
if (q !== '`' && q !== "'" && q !== '"')
|
|
137
|
+
return null;
|
|
138
|
+
const end = skipString(body, i, 'ts');
|
|
139
|
+
const literal = body.slice(i + 1, end - 1);
|
|
140
|
+
// Walk interpolations with brace matching, so nested template literals are handled.
|
|
141
|
+
let raw = '';
|
|
142
|
+
let j = 0;
|
|
143
|
+
let first = true;
|
|
144
|
+
while (j < literal.length) {
|
|
145
|
+
if (q === '`' && literal[j] === '$' && literal[j + 1] === '{') {
|
|
146
|
+
const close = matchBrace(literal, j + 1);
|
|
147
|
+
if (close === -1)
|
|
148
|
+
return null;
|
|
149
|
+
const expr = literal.slice(j + 2, close).trim();
|
|
150
|
+
if (first && raw === '') {
|
|
151
|
+
// A leading interpolation is the base URL.
|
|
152
|
+
}
|
|
153
|
+
else if (/^[A-Za-z_$][\w$]*(\??\.[A-Za-z_$][\w$]*)*$/.test(expr)) {
|
|
154
|
+
raw += `{${expr.split('.').pop().replace('?', '')}}`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Anything else (a ternary, a call) builds the query string; it is not part of the path.
|
|
158
|
+
}
|
|
159
|
+
first = false;
|
|
160
|
+
j = close + 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
raw += literal[j];
|
|
164
|
+
first = false;
|
|
165
|
+
j++;
|
|
166
|
+
}
|
|
167
|
+
raw = raw.split('?')[0];
|
|
168
|
+
if (!raw.startsWith('/'))
|
|
169
|
+
return null;
|
|
170
|
+
return raw;
|
|
171
|
+
}
|
|
172
|
+
function parseInterfaces(text, file) {
|
|
173
|
+
const out = [];
|
|
174
|
+
const re = /export\s+(?:interface\s+(\w+)(?:<[^>]*>)?(?:\s+extends\s+[^{]+)?|type\s+(\w+)(?:<[^>]*>)?\s*=)\s*\{/g;
|
|
175
|
+
let m;
|
|
176
|
+
while ((m = re.exec(text))) {
|
|
177
|
+
const open = m.index + m[0].length - 1;
|
|
178
|
+
const close = matchBrace(text, open);
|
|
179
|
+
if (close === -1)
|
|
180
|
+
continue;
|
|
181
|
+
const body = text.slice(open + 1, close);
|
|
182
|
+
const fields = [];
|
|
183
|
+
let depth = 0;
|
|
184
|
+
for (const line of body.split('\n')) {
|
|
185
|
+
if (depth === 0) {
|
|
186
|
+
const f = line.match(/^\s*(?:readonly\s+)?['"]?([\w$-]+)['"]?(\?)?\s*:\s*([^;\n]+?)\s*[;,]?\s*$/);
|
|
187
|
+
if (f)
|
|
188
|
+
fields.push({ name: f[1], type: f[3].replace(/\s*\|\s*null/, '').replace(/\{$/, 'object').trim(), nullable: !!f[2] || /\|\s*null/.test(f[3]) });
|
|
189
|
+
}
|
|
190
|
+
depth += (line.match(/[{(]/g) ?? []).length - (line.match(/[})]/g) ?? []).length;
|
|
191
|
+
}
|
|
192
|
+
out.push({ name: m[1] ?? m[2], file, fields });
|
|
193
|
+
re.lastIndex = close;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
export async function scan(root, configConventions) {
|
|
198
|
+
const map = emptyMap('nextjs', `nextjs@${VERSION}`);
|
|
199
|
+
const files = walk(root);
|
|
200
|
+
map.stats = fileStats(files);
|
|
201
|
+
const code = files.filter((f) => CODE_EXTS.some((e) => f.endsWith(e)) && !f.endsWith('.d.ts') && !/(^|\/)(next|tailwind|postcss|eslint)\.config\./.test(f));
|
|
202
|
+
const texts = new Map();
|
|
203
|
+
const text = (f) => {
|
|
204
|
+
if (!texts.has(f))
|
|
205
|
+
texts.set(f, readText(join(root, f)) ?? '');
|
|
206
|
+
return texts.get(f);
|
|
207
|
+
};
|
|
208
|
+
const pkg = readJson(join(root, 'package.json')) ?? {};
|
|
209
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
210
|
+
const c = {};
|
|
211
|
+
// App Router.
|
|
212
|
+
c.appDir = isDir(join(root, 'src/app')) ? 'src/app' : isDir(join(root, 'app')) ? 'app' : '';
|
|
213
|
+
if (!c.appDir)
|
|
214
|
+
map.notes.push(isDir(join(root, 'pages')) || isDir(join(root, 'src/pages')) ? 'This project uses the Pages Router, which is not supported yet; routes are not mapped.' : 'No app folder found; routes are not mapped.');
|
|
215
|
+
const src = c.appDir.startsWith('src/') || isDir(join(root, 'src')) ? 'src/' : '';
|
|
216
|
+
const isUi = (f) => (c.appDir && f.startsWith(c.appDir + '/')) || /(^|\/)(components|hooks|pages|public|styles)\//.test(f) || /\.(tsx|jsx)$/.test(f);
|
|
217
|
+
// Path alias.
|
|
218
|
+
const tsc = readTsconfig(root);
|
|
219
|
+
const aliasKey = Object.keys(tsc.paths ?? {}).find((k) => k.endsWith('/*'));
|
|
220
|
+
c.importAlias = aliasKey ? aliasKey.slice(0, -1) : '';
|
|
221
|
+
c.aliasBase = aliasKey ? posix.normalize(posix.join(tsc.baseUrl ?? '.', (tsc.paths[aliasKey][0] ?? './*').replace(/\/?\*$/, ''))) : '.';
|
|
222
|
+
if (c.aliasBase === '')
|
|
223
|
+
c.aliasBase = '.';
|
|
224
|
+
// HTTP client and helper.
|
|
225
|
+
c.http = deps.axios && code.some((f) => /from ['"]axios['"]/.test(text(f))) ? 'axios' : 'fetch';
|
|
226
|
+
// axios: the shared instance (export const api = axios.create(...), or a default export).
|
|
227
|
+
if (c.http === 'axios') {
|
|
228
|
+
c.axiosInstance = '';
|
|
229
|
+
c.axiosInstanceFile = '';
|
|
230
|
+
for (const f of code) {
|
|
231
|
+
const t = text(f);
|
|
232
|
+
const m = t.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*axios\.create\s*\(/);
|
|
233
|
+
if (!m)
|
|
234
|
+
continue;
|
|
235
|
+
const exported = new RegExp(`export\\s+(?:const|let)\\s+${m[1]}\\b|export\\s*\\{[^}]*\\b${m[1]}\\b[^}]*\\}`).test(t);
|
|
236
|
+
const asDefault = new RegExp(`export\\s+default\\s+${m[1]}\\b`).test(t);
|
|
237
|
+
if (!exported && !asDefault)
|
|
238
|
+
continue;
|
|
239
|
+
c.axiosInstance = exported ? m[1] : `default:${m[1]}`;
|
|
240
|
+
c.axiosInstanceFile = f;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (!c.axiosInstance)
|
|
244
|
+
c.axiosInstance = 'axios';
|
|
245
|
+
}
|
|
246
|
+
const axiosName = (c.axiosInstance ?? '').replace(/^default:/, '');
|
|
247
|
+
// TanStack Query: hooks next to the API functions when the project already uses it.
|
|
248
|
+
const queryUsers = code.filter((f) => /from ['"]@tanstack\/react-query['"]/.test(text(f)));
|
|
249
|
+
if (deps['@tanstack/react-query'] && queryUsers.some((f) => /\buse(?:Query|Mutation|SuspenseQuery)\s*[<(]/.test(text(f)))) {
|
|
250
|
+
c.dataFetching = 'react-query';
|
|
251
|
+
const hookFiles = queryUsers.filter((f) => /\bexport\s+(?:function|const)\s+use[A-Z]/.test(text(f)));
|
|
252
|
+
c.hooksFolder = mostCommon(hookFiles.map(dirOf), `${src}hooks`);
|
|
253
|
+
// Feature-first projects keep hooks in many feature folders; none of them is "the" hooks folder.
|
|
254
|
+
const hookDirs = new Set(hookFiles.map(dirOf));
|
|
255
|
+
if (hookDirs.size >= 3 && hookFiles.filter((f) => dirOf(f) === c.hooksFolder).length * 2 < hookFiles.length)
|
|
256
|
+
c.hooksFolder = `${src}hooks`;
|
|
257
|
+
}
|
|
258
|
+
const axiosCall = axiosName && axiosName !== 'axios' ? new RegExp(`\\b${axiosName}\\.(?:get|post|put|patch|delete|head|request)\\s*[<(]`) : null;
|
|
259
|
+
const fetchFiles = code.filter((f) => !isUi(f) && f !== c.axiosInstanceFile && (/\bfetch\s*\(|\baxios\b/.test(text(f)) || (axiosCall?.test(text(f)) ?? false)));
|
|
260
|
+
let helper = '';
|
|
261
|
+
let helperFile = '';
|
|
262
|
+
for (const f of fetchFiles) {
|
|
263
|
+
const m = text(f).match(/export\s+(?:async\s+)?function\s+(\w+)\s*<[^>]*>\s*\(\s*(\w+)\s*:\s*string|export\s+const\s+(\w+)\s*=\s*(?:async\s*)?<[^>]*>\s*\(\s*(\w+)\s*:\s*string/);
|
|
264
|
+
if (m && /\bfetch\s*\(/.test(text(f))) {
|
|
265
|
+
helper = m[1] ?? m[3];
|
|
266
|
+
helperFile = f;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
c.httpHelper = helper || 'fetch';
|
|
271
|
+
c.httpHelperFile = helperFile;
|
|
272
|
+
const callerFiles = helper ? code.filter((f) => f !== helperFile && !isUi(f) && new RegExp(`\\b${helper}\\s*[<(]`).test(text(f))) : [];
|
|
273
|
+
const apiFiles = [...new Set([...fetchFiles, ...callerFiles])].filter((f) => f !== helperFile || !helper);
|
|
274
|
+
c.apiFolder = mostCommon(apiFiles.map(dirOf), helperFile ? dirOf(helperFile) : `${src}lib/api`);
|
|
275
|
+
const apiFolderFiles = code.filter((f) => dirOf(f) === c.apiFolder && !/\/index\.[tj]s$/.test('/' + f) && f !== helperFile && f !== c.axiosInstanceFile);
|
|
276
|
+
const apiBases = apiFolderFiles.map(baseNoExt);
|
|
277
|
+
c.apiFileNaming = detectFileNaming(apiBases, 'kebab');
|
|
278
|
+
const lastWords = apiBases.map((b) => b.split(/[-_]|(?=[A-Z])/).pop().toLowerCase());
|
|
279
|
+
const suffix = mostCommon(lastWords.filter((w) => ['service', 'api', 'client', 'requests', 'endpoints'].includes(w)), '');
|
|
280
|
+
const withSuffix = lastWords.filter((w) => w === suffix).length;
|
|
281
|
+
// One file called client.ts next to users.ts is not a naming pattern; two of three is.
|
|
282
|
+
c.apiFileSuffix = suffix && withSuffix * 2 >= lastWords.length && (withSuffix >= 2 || lastWords.length === 1) ? suffix : '';
|
|
283
|
+
const resourceWords = apiBases.map((b) => (c.apiFileSuffix ? b.replace(new RegExp(`[-_]?${c.apiFileSuffix}$`, 'i'), '') : b));
|
|
284
|
+
c.apiFileResource = resourceWords.length && resourceWords.filter((w) => /s$/i.test(w)).length * 2 < resourceWords.length ? 'singular' : 'plural';
|
|
285
|
+
// Base URL.
|
|
286
|
+
const envUse = apiFiles.concat(helperFile ? [helperFile] : []).map((f) => text(f).match(/process\.env\.(\w+)/)?.[1]).filter((x) => !!x);
|
|
287
|
+
c.baseUrlEnv = mostCommon(envUse, '');
|
|
288
|
+
map.baseUrls = scanEnvBaseUrls(root, listRootEnvFiles(root));
|
|
289
|
+
// Models.
|
|
290
|
+
const modelFiles = code.filter((f) => !isUi(f) && /export\s+(interface|type)\s+\w+/.test(text(f)));
|
|
291
|
+
const modelDirs = modelFiles.map(dirOf);
|
|
292
|
+
const preferred = modelDirs.filter((d) => /(^|\/)(types|models|interfaces|entities)$/.test(d));
|
|
293
|
+
c.modelsFolder = mostCommon(preferred.length ? preferred : modelDirs, `${src}types`);
|
|
294
|
+
const inFolder = modelFiles.filter((f) => dirOf(f) === c.modelsFolder);
|
|
295
|
+
const multi = inFolder.find((f) => (text(f).match(/export\s+(interface|type)\s+\w+/g) ?? []).length >= 2);
|
|
296
|
+
c.modelsLayout = multi && inFolder.length <= 2 ? 'single' : 'per-file';
|
|
297
|
+
c.modelsFile = c.modelsLayout === 'single' ? multi : '';
|
|
298
|
+
c.modelFileNaming = detectFileNaming(inFolder.map(baseNoExt).filter((b) => b !== 'index'), 'kebab');
|
|
299
|
+
for (const f of modelFiles)
|
|
300
|
+
map.models.push(...parseInterfaces(text(f), f));
|
|
301
|
+
// Import style and code style.
|
|
302
|
+
const apiTexts = apiFiles.map(text);
|
|
303
|
+
const aliasImports = c.importAlias ? apiTexts.join('\n').split(`from '${c.importAlias}`).length + apiTexts.join('\n').split(`from "${c.importAlias}`).length - 2 : 0;
|
|
304
|
+
const relImports = (apiTexts.join('\n').match(/from ['"]\.\.\//g) ?? []).length;
|
|
305
|
+
c.importStyle = c.importAlias && (aliasImports >= relImports || apiTexts.length === 0) ? 'alias' : 'relative';
|
|
306
|
+
const style = detectTsStyle(apiTexts.length ? apiTexts : code.slice(0, 20).map(text));
|
|
307
|
+
c.quote = style.quote === '"' ? 'double' : 'single';
|
|
308
|
+
c.semicolons = String(style.semi);
|
|
309
|
+
c.indent = style.indent === '\t' ? 'tab' : String(style.indent.length);
|
|
310
|
+
c.i18n = deps['next-intl'] ? 'next-intl' : deps['next-i18next'] ? 'next-i18next' : deps.i18next ? 'i18next' : '';
|
|
311
|
+
c.styling = deps.tailwindcss ? 'tailwind' : deps['styled-components'] ? 'styled-components' : '';
|
|
312
|
+
// Confirmed values in codz.config.yaml win.
|
|
313
|
+
map.conventions = { ...c, ...configConventions };
|
|
314
|
+
const conv = map.conventions;
|
|
315
|
+
// Endpoints.
|
|
316
|
+
const callee = new RegExp(`(?:\\bfetch|\\b${conv.httpHelper !== 'fetch' ? conv.httpHelper : 'fetch'}|\\b\\w+\\.(?:get|post|put|patch|delete))\\s*(?:<[^>]*>)?\\(`);
|
|
317
|
+
for (const f of apiFiles) {
|
|
318
|
+
for (const fn of exportedFunctions(text(f))) {
|
|
319
|
+
if (fn.name === conv.httpHelper)
|
|
320
|
+
continue;
|
|
321
|
+
const path = callPath(fn.body, new RegExp(callee.source));
|
|
322
|
+
if (!path || /^\/\{\w+\}$/.test(path))
|
|
323
|
+
continue;
|
|
324
|
+
const method = (fn.body.match(/method:\s*['"`](\w+)['"`]/)?.[1] ?? fn.body.match(/\.(get|post|put|patch|delete)\s*[<(]/)?.[1] ?? 'GET').toUpperCase();
|
|
325
|
+
const response = fn.returnType.match(/^Promise<(.+)>$/)?.[1]?.trim();
|
|
326
|
+
map.endpoints.push({ name: fn.name, method, path, response, file: f, calledFrom: [] });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Call sites.
|
|
330
|
+
for (const f of code) {
|
|
331
|
+
const t = text(f);
|
|
332
|
+
const re = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
333
|
+
let m;
|
|
334
|
+
while ((m = re.exec(t))) {
|
|
335
|
+
const target = resolveImport(f, m[2], conv.importAlias, conv.aliasBase);
|
|
336
|
+
if (!target)
|
|
337
|
+
continue;
|
|
338
|
+
const names = m[1].split(',').map((s) => s.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, ''));
|
|
339
|
+
for (const ep of map.endpoints) {
|
|
340
|
+
if (ep.file === f || !names.includes(ep.name))
|
|
341
|
+
continue;
|
|
342
|
+
const epNoExt = ep.file.replace(/\.(tsx?|jsx?|mjs)$/, '');
|
|
343
|
+
if (target === epNoExt || target === dirOf(ep.file))
|
|
344
|
+
ep.calledFrom.push(f);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Routes.
|
|
349
|
+
if (conv.appDir) {
|
|
350
|
+
for (const f of files.filter((x) => x.startsWith(conv.appDir + '/') && /\/page\.(tsx|ts|jsx|js)$/.test(x))) {
|
|
351
|
+
const segs = f.slice(conv.appDir.length + 1).split('/').slice(0, -1);
|
|
352
|
+
const urlSegs = segs.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith('@'));
|
|
353
|
+
const params = urlSegs.filter((s) => s.startsWith('[')).map((s) => s.replace(/[[\].]/g, ''));
|
|
354
|
+
const path = '/' + urlSegs.map((s) => (s.startsWith('[') ? `{${s.replace(/[[\].]/g, '')}}` : s)).join('/');
|
|
355
|
+
const named = urlSegs.filter((s) => !s.startsWith('['));
|
|
356
|
+
const route = { name: named.length ? named.join('-') : 'home', path: path === '/' ? '/' : path, params, file: f };
|
|
357
|
+
map.routes.push(route);
|
|
358
|
+
}
|
|
359
|
+
map.routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
360
|
+
}
|
|
361
|
+
map.locales = scanLocales(root, files);
|
|
362
|
+
return map;
|
|
363
|
+
}
|
|
364
|
+
// ---------------- Generation ----------------
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Next.js `setup`: creates an API layer for a project that has none yet.
|
|
2
|
+
// fetch-helper lib/api/client.ts with apiFetch<T>() and ApiError (recommended)
|
|
3
|
+
// plain-fetch each generated function calls fetch itself
|
|
4
|
+
// Both store the base URL in NEXT_PUBLIC_API_URL (.env.example and .env.local).
|
|
5
|
+
import { jsString } from "../core/safe.js";
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { detectEol, readText, withEol } from "../core/fsutil.js";
|
|
8
|
+
import { ToolError } from "../core/types.js";
|
|
9
|
+
const ENV = 'NEXT_PUBLIC_API_URL';
|
|
10
|
+
export function nextArchitectures(map) {
|
|
11
|
+
const c = map.conventions;
|
|
12
|
+
if (map.endpoints.length > 0 || (c.httpHelper && c.httpHelper !== 'fetch') || c.http === 'axios')
|
|
13
|
+
return [];
|
|
14
|
+
return [
|
|
15
|
+
{ id: 'fetch-helper', label: 'Fetch helper', description: 'lib/api/client.ts with apiFetch<T>() and ApiError; one API file per resource; interfaces in types/', recommended: true },
|
|
16
|
+
{ id: 'plain-fetch', label: 'Plain fetch', description: 'Each API function calls fetch directly; no shared helper', recommended: false },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
function clientFile(q, semi, baseUrl) {
|
|
20
|
+
return [
|
|
21
|
+
`// ${ENV} comes from .env.local; the fallback is the URL setup was given.`,
|
|
22
|
+
`const BASE_URL = process.env.${ENV} ?? ${jsString(baseUrl, q)}${semi}`,
|
|
23
|
+
'',
|
|
24
|
+
'/** Thrown when the API answers with a status outside 200-299. */',
|
|
25
|
+
'export class ApiError extends Error {',
|
|
26
|
+
` status: number${semi}`,
|
|
27
|
+
'',
|
|
28
|
+
' constructor(status: number, message: string) {',
|
|
29
|
+
` super(message)${semi}`,
|
|
30
|
+
` this.status = status${semi}`,
|
|
31
|
+
' }',
|
|
32
|
+
'}',
|
|
33
|
+
'',
|
|
34
|
+
'/** Calls the API and parses the JSON response. Add auth headers here. */',
|
|
35
|
+
'export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {',
|
|
36
|
+
' const res = await fetch(`${BASE_URL}${path}`, {',
|
|
37
|
+
' ...init,',
|
|
38
|
+
` headers: { ${q}Content-Type${q}: ${q}application/json${q}, ...init.headers },`,
|
|
39
|
+
` })${semi}`,
|
|
40
|
+
` if (!res.ok) throw new ApiError(res.status, await res.text())${semi}`,
|
|
41
|
+
` if (res.status === 204) return undefined as T${semi}`,
|
|
42
|
+
` return (await res.json()) as T${semi}`,
|
|
43
|
+
'}',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
/** Sets NAME=value in an env file, keeping everything else. */
|
|
48
|
+
function setEnv(text, value) {
|
|
49
|
+
if (text === null)
|
|
50
|
+
return `${ENV}=${value}\n`;
|
|
51
|
+
const eol = detectEol(text);
|
|
52
|
+
const re = new RegExp(`^${ENV}=.*$`, 'm');
|
|
53
|
+
if (re.test(text))
|
|
54
|
+
return text;
|
|
55
|
+
return withEol(`${text.replace(/\s*$/, '')}${text.trim() ? '\n' : ''}${ENV}=${value}\n`, eol);
|
|
56
|
+
}
|
|
57
|
+
export function planNextSetup(ctx, choice) {
|
|
58
|
+
const { root, map } = ctx;
|
|
59
|
+
if (!['fetch-helper', 'plain-fetch'].includes(choice.architecture)) {
|
|
60
|
+
throw new ToolError('bad_args', `Unknown architecture "${choice.architecture}". Choose fetch-helper or plain-fetch.`);
|
|
61
|
+
}
|
|
62
|
+
const c = map.conventions;
|
|
63
|
+
const files = new Map();
|
|
64
|
+
const summary = [];
|
|
65
|
+
const warnings = [];
|
|
66
|
+
const read = (p) => readText(join(root, p));
|
|
67
|
+
const src = (c.appDir ?? '').startsWith('src/') ? 'src/' : '';
|
|
68
|
+
const apiFolder = `${src}lib/api`;
|
|
69
|
+
const q = c.quote === 'double' ? '"' : "'";
|
|
70
|
+
const semi = c.semicolons === 'false' ? '' : ';';
|
|
71
|
+
const baseUrl = choice.baseUrl || 'https://api.example.com';
|
|
72
|
+
if (!choice.baseUrl)
|
|
73
|
+
warnings.push(`No base URL was given, so ${ENV} is set to https://api.example.com. Change it in .env.example (and your .env.local).`);
|
|
74
|
+
const conventions = {
|
|
75
|
+
...c,
|
|
76
|
+
http: 'fetch',
|
|
77
|
+
apiFolder,
|
|
78
|
+
apiFileNaming: 'kebab',
|
|
79
|
+
apiFileSuffix: '',
|
|
80
|
+
apiFileResource: 'plural',
|
|
81
|
+
baseUrlEnv: ENV,
|
|
82
|
+
modelsFolder: `${src}types`,
|
|
83
|
+
modelsLayout: 'per-file',
|
|
84
|
+
modelsFile: '',
|
|
85
|
+
modelFileNaming: 'kebab',
|
|
86
|
+
importStyle: c.importAlias ? 'alias' : 'relative',
|
|
87
|
+
httpHelper: 'fetch',
|
|
88
|
+
httpHelperFile: '',
|
|
89
|
+
};
|
|
90
|
+
if (choice.architecture === 'fetch-helper') {
|
|
91
|
+
const client = `${apiFolder}/client.ts`;
|
|
92
|
+
const index = `${apiFolder}/index.ts`;
|
|
93
|
+
for (const p of [client, index]) {
|
|
94
|
+
if (read(p) !== null)
|
|
95
|
+
throw new ToolError('file_exists', `${p} already exists, so setup stopped without changing anything.`, 'Remove or rename it, or run codz init again to use it as it is.');
|
|
96
|
+
}
|
|
97
|
+
files.set(client, clientFile(q, semi, baseUrl));
|
|
98
|
+
summary.push(`Create ${client}: apiFetch<T>() and ApiError, reading the base URL from ${ENV}`);
|
|
99
|
+
files.set(index, `export * from ${q}./client${q}${semi}\n`);
|
|
100
|
+
summary.push(`Create ${index}: exports every API file`);
|
|
101
|
+
conventions.httpHelper = 'apiFetch';
|
|
102
|
+
conventions.httpHelperFile = client;
|
|
103
|
+
}
|
|
104
|
+
// .env.example only: .env.local is the developer's own file and may hold secrets, so codz never writes it.
|
|
105
|
+
{
|
|
106
|
+
const envFile = '.env.example';
|
|
107
|
+
const before = read(envFile);
|
|
108
|
+
const after = setEnv(before, baseUrl);
|
|
109
|
+
if (after !== before) {
|
|
110
|
+
files.set(envFile, after);
|
|
111
|
+
summary.push(`${before === null ? 'Create' : 'Update'} ${envFile}: ${ENV}=${baseUrl}`);
|
|
112
|
+
}
|
|
113
|
+
const local = read('.env.local');
|
|
114
|
+
if (local === null || !new RegExp(`^${ENV}=`, 'm').test(local))
|
|
115
|
+
summary.push(`Copy ${ENV} from .env.example into .env.local (codz does not write .env.local)${choice.architecture === 'fetch-helper' ? `; until then lib/api/client.ts falls back to ${baseUrl}` : ': the API functions read it'}`);
|
|
116
|
+
}
|
|
117
|
+
summary.push(`Save conventions: API files in ${apiFolder}, interfaces in ${conventions.modelsFolder}`);
|
|
118
|
+
return { files, summary, warnings, generated: [], conventions, dependenciesChanged: false };
|
|
119
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// React web adapter (Vite or Create React App, TypeScript), for apps that are
|
|
2
|
+
// not Next.js. API code is TypeScript like in Next.js, so scanning and writing
|
|
3
|
+
// endpoints reuse the Next.js adapter: fetch (directly or through a helper),
|
|
4
|
+
// axios, TanStack Query hooks, interfaces for models. On top of that this
|
|
5
|
+
// adapter knows the web app's own parts: the bundler (for the env variable
|
|
6
|
+
// prefix), React Router (a <Routes> tree or createBrowserRouter), the pages
|
|
7
|
+
// folder, and the config file that holds the API URL.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { exists, isDir, readJson, readText, walk } from "../core/fsutil.js";
|
|
10
|
+
import { NEXT_CONVENTION_DOCS, nextjsAdapter } from "./nextjs.js";
|
|
11
|
+
import { mostCommon } from "./nextjsScan.js";
|
|
12
|
+
import { planReactSetup, reactArchitectures } from "./reactSetup.js";
|
|
13
|
+
export const REACT_VERSION = '0.1.0';
|
|
14
|
+
const { appDir: _appDir, ...SHARED_DOCS } = NEXT_CONVENTION_DOCS;
|
|
15
|
+
export const REACT_CONVENTION_DOCS = {
|
|
16
|
+
...SHARED_DOCS,
|
|
17
|
+
bundler: 'vite or cra (Create React App): decides the env variable prefix',
|
|
18
|
+
router: 'react-router or none',
|
|
19
|
+
routerStyle: 'jsx (<Routes><Route .../></Routes>) or data (createBrowserRouter([...]))',
|
|
20
|
+
routerFile: 'the file that lists the routes',
|
|
21
|
+
pagesFolder: 'where page components live',
|
|
22
|
+
configFile: 'file that exports API_URL',
|
|
23
|
+
};
|
|
24
|
+
/** <Route path="/team/:teamId" element={<TeamPage />} /> and { path: '/x', element: <X /> }. */
|
|
25
|
+
function reactRoutes(file, text) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const add = (path, comp) => out.push({ name: comp.replace(/Page$/, '') || 'home', path: path.replace(/:(\w+)/g, '{$1}'), params: [...path.matchAll(/:(\w+)/g)].map((m) => m[1]), file });
|
|
28
|
+
for (const m of text.matchAll(/<Route\b[^>]*?\bpath=["']([^"']+)["'][^>]*?element=\{<(\w+)/g))
|
|
29
|
+
add(m[1], m[2]);
|
|
30
|
+
for (const m of text.matchAll(/\{\s*path:\s*["']([^"']+)["']\s*,\s*element:\s*<(\w+)/g))
|
|
31
|
+
add(m[1], m[2]);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
async function scan(root, configConventions) {
|
|
35
|
+
const map = await nextjsAdapter.scan(root, configConventions);
|
|
36
|
+
map.framework = 'react';
|
|
37
|
+
map.adapter = `react@${REACT_VERSION}`;
|
|
38
|
+
map.notes = map.notes.filter((n) => !/app folder|Pages Router|App Router/.test(n));
|
|
39
|
+
map.routes = [];
|
|
40
|
+
const pkg = readJson(join(root, 'package.json')) ?? {};
|
|
41
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
42
|
+
const files = walk(root, '.', { exts: ['.ts', '.tsx', '.js', '.jsx'], extraIgnored: ['public', 'build', 'dist'] });
|
|
43
|
+
const text = (f) => readText(join(root, f)) ?? '';
|
|
44
|
+
const src = isDir(join(root, 'src')) ? 'src/' : '';
|
|
45
|
+
const c = {};
|
|
46
|
+
c.bundler = deps['react-scripts'] ? 'cra' : 'vite';
|
|
47
|
+
const routerFile = files.find((f) => /createBrowserRouter\s*\(/.test(text(f))) ?? files.find((f) => /<Routes\b/.test(text(f))) ?? '';
|
|
48
|
+
c.router = deps['react-router-dom'] || deps['react-router'] ? 'react-router' : 'none';
|
|
49
|
+
c.routerFile = c.router === 'react-router' ? routerFile || `${src}router.tsx` : '';
|
|
50
|
+
c.routerStyle = routerFile && /createBrowserRouter\s*\(/.test(text(routerFile)) ? 'data' : c.router === 'react-router' && routerFile ? 'jsx' : 'data';
|
|
51
|
+
const pageFiles = files.filter((f) => /(Page|View)\.(tsx|jsx)$/.test(f));
|
|
52
|
+
c.pagesFolder = mostCommon(pageFiles.map((f) => f.slice(0, f.lastIndexOf('/'))), `${src}pages`);
|
|
53
|
+
c.configFile = files.find((f) => /export\s+const\s+API_URL\b/.test(text(f))) ?? `${src}config/env.ts`;
|
|
54
|
+
// A guessed API folder follows web-app layouts (src/api), not Next.js (lib/api).
|
|
55
|
+
if (!map.endpoints.length && !configConventions.apiFolder && !exists(join(root, map.conventions.apiFolder ?? '')))
|
|
56
|
+
c.apiFolder = `${src}api`;
|
|
57
|
+
if (!map.models.length && !configConventions.modelsFolder && !exists(join(root, map.conventions.modelsFolder ?? '')))
|
|
58
|
+
c.modelsFolder = `${src}types`;
|
|
59
|
+
if (map.conventions.dataFetching === 'react-query' && !configConventions.hooksFolder && !exists(join(root, map.conventions.hooksFolder ?? '')))
|
|
60
|
+
c.hooksFolder = `${src}hooks`;
|
|
61
|
+
const conv = (map.conventions = { ...map.conventions, ...c, ...configConventions });
|
|
62
|
+
delete conv.appDir;
|
|
63
|
+
// The API URL in the config file (a literal, or the fallback after ??) is a base URL too.
|
|
64
|
+
const cfgText = conv.configFile ? readText(join(root, conv.configFile)) ?? '' : '';
|
|
65
|
+
const cfgUrl = /export\s+const\s+API_URL\s*=\s*(?:[^'"\n]*\?\?\s*)?['"](https?:\/\/[^'"]+)['"]/.exec(cfgText)?.[1];
|
|
66
|
+
if (cfgUrl && !map.baseUrls.some((b) => b.value === cfgUrl))
|
|
67
|
+
map.baseUrls.push({ name: 'API_URL', value: cfgUrl, file: conv.configFile });
|
|
68
|
+
if (conv.routerFile && exists(join(root, conv.routerFile)))
|
|
69
|
+
map.routes = reactRoutes(conv.routerFile, text(conv.routerFile));
|
|
70
|
+
map.routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
71
|
+
if (conv.router === 'none')
|
|
72
|
+
map.notes.push('No React Router found; screen add and route add need it (codz setup adds React Router).');
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
export const reactAdapter = {
|
|
76
|
+
id: 'react',
|
|
77
|
+
version: REACT_VERSION,
|
|
78
|
+
capabilities: { explain: true, apiAdd: true },
|
|
79
|
+
supportedSetup: 'React web apps (Vite or Create React App), TypeScript, fetch (directly or through a helper) or axios, optionally TanStack Query; React Router.',
|
|
80
|
+
scan,
|
|
81
|
+
planEndpoint: nextjsAdapter.planEndpoint,
|
|
82
|
+
architectures: reactArchitectures,
|
|
83
|
+
planSetup: planReactSetup,
|
|
84
|
+
afterSetup(root) {
|
|
85
|
+
return exists(join(root, 'package.json')) ? { cmd: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: ['install'], label: 'npm install' } : null;
|
|
86
|
+
},
|
|
87
|
+
formatCommand: nextjsAdapter.formatCommand,
|
|
88
|
+
verifyCommand: nextjsAdapter.verifyCommand,
|
|
89
|
+
};
|