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,395 @@
|
|
|
1
|
+
// Reads API description files and turns every operation into the same
|
|
2
|
+
// EndpointSpec that `api add` builds from a cURL command.
|
|
3
|
+
//
|
|
4
|
+
// OpenAPI 3.x schemas become models directly (components and inline)
|
|
5
|
+
// Swagger 2.0 the same, from definitions
|
|
6
|
+
// Postman 2.x requests plus their saved example responses (as samples)
|
|
7
|
+
import { parseYamlDocument } from "../core/yamlDoc.js";
|
|
8
|
+
import { ToolError } from "../core/types.js";
|
|
9
|
+
import { stripSecretQuery } from "./curl.js";
|
|
10
|
+
import { analyzePath, buildEndpoint, deriveName } from "./endpoint.js";
|
|
11
|
+
import { signature } from "./infer.js";
|
|
12
|
+
import { isHar, readHar } from "./har.js";
|
|
13
|
+
import { camel, functionName, pascal, singular, typeName } from "./naming.js";
|
|
14
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
15
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
export function readApiFile(text, opts = {}) {
|
|
17
|
+
let doc;
|
|
18
|
+
const t = text.trim();
|
|
19
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
20
|
+
try {
|
|
21
|
+
doc = JSON.parse(t);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new ToolError('bad_spec', `The file looks like JSON but does not parse: ${e.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
doc = parseYamlDocument(text);
|
|
29
|
+
}
|
|
30
|
+
if (!isObj(doc))
|
|
31
|
+
throw new ToolError('bad_spec', 'The file is not an OpenAPI, Swagger or Postman document.');
|
|
32
|
+
if (isHar(doc))
|
|
33
|
+
return readHar(doc, opts);
|
|
34
|
+
if (typeof doc.openapi === 'string')
|
|
35
|
+
return readOpenApi(doc, 'openapi');
|
|
36
|
+
if (doc.swagger === '2.0' || doc.swagger === 2)
|
|
37
|
+
return readOpenApi(doc, 'swagger');
|
|
38
|
+
if (isObj(doc.info) && Array.isArray(doc.item))
|
|
39
|
+
return readPostman(doc);
|
|
40
|
+
throw new ToolError('bad_spec', 'Could not tell what this file is.', 'Supported: OpenAPI 3.x and Swagger 2.0 (JSON or YAML), Postman collections (v2.0 or v2.1 JSON) and HAR files from browser DevTools.');
|
|
41
|
+
}
|
|
42
|
+
// ---------------- OpenAPI / Swagger ----------------
|
|
43
|
+
class SchemaConverter {
|
|
44
|
+
models = new Map();
|
|
45
|
+
order = [];
|
|
46
|
+
refNames = new Map();
|
|
47
|
+
inProgress = new Set();
|
|
48
|
+
warnings = [];
|
|
49
|
+
doc;
|
|
50
|
+
constructor(doc) {
|
|
51
|
+
this.doc = doc;
|
|
52
|
+
}
|
|
53
|
+
resolve(ref) {
|
|
54
|
+
if (!ref.startsWith('#/')) {
|
|
55
|
+
this.warnings.push(`External reference ${ref} is not supported; its type is unknown.`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
let cur = this.doc;
|
|
59
|
+
for (const part of ref.slice(2).split('/'))
|
|
60
|
+
cur = isObj(cur) ? cur[part.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
|
|
61
|
+
if (!isObj(cur)) {
|
|
62
|
+
this.warnings.push(`Reference ${ref} was not found; its type is unknown.`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return { name: ref.split('/').pop(), schema: cur };
|
|
66
|
+
}
|
|
67
|
+
uniqueName(base) {
|
|
68
|
+
let name = typeName(base);
|
|
69
|
+
if (!this.models.has(name) && !this.inProgress.has(name))
|
|
70
|
+
return name;
|
|
71
|
+
let n = 2;
|
|
72
|
+
while (this.models.has(`${name}${n}`) || this.inProgress.has(`${name}${n}`))
|
|
73
|
+
n++;
|
|
74
|
+
return `${name}${n}`;
|
|
75
|
+
}
|
|
76
|
+
/** Converts a schema; `nameHint` names inline object models. Returns the type and whether it is nullable. */
|
|
77
|
+
type(schema, nameHint) {
|
|
78
|
+
if (!isObj(schema))
|
|
79
|
+
return { type: { kind: 'unknown' }, nullable: true };
|
|
80
|
+
if (typeof schema.$ref === 'string') {
|
|
81
|
+
const ref = schema.$ref;
|
|
82
|
+
const known = this.refNames.get(ref);
|
|
83
|
+
if (known)
|
|
84
|
+
return { type: { kind: 'object', model: known }, nullable: false };
|
|
85
|
+
const r = this.resolve(ref);
|
|
86
|
+
if (!r)
|
|
87
|
+
return { type: { kind: 'unknown' }, nullable: true };
|
|
88
|
+
const target = Array.isArray(r.schema.allOf) && r.schema.allOf.length > 1 ? this.mergeAllOf(r.schema.allOf) : r.schema;
|
|
89
|
+
// A referenced schema that is not an object (an enum, a string alias) is inlined.
|
|
90
|
+
if (!this.isObjectSchema(target))
|
|
91
|
+
return this.type(target, r.name);
|
|
92
|
+
const name = this.uniqueName(r.name);
|
|
93
|
+
this.refNames.set(ref, name);
|
|
94
|
+
this.objectModel(target, name, false);
|
|
95
|
+
return { type: { kind: 'object', model: name }, nullable: !!r.schema.nullable };
|
|
96
|
+
}
|
|
97
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [String(schema.type)] : [];
|
|
98
|
+
const nullable = !!schema.nullable || types.includes('null') || schema['x-nullable'] === true;
|
|
99
|
+
const t = types.filter((x) => x !== 'null')[0];
|
|
100
|
+
for (const key of ['oneOf', 'anyOf']) {
|
|
101
|
+
if (Array.isArray(schema[key])) {
|
|
102
|
+
const opts = schema[key].filter((o) => !(isObj(o) && o.type === 'null'));
|
|
103
|
+
if (opts.length === 1) {
|
|
104
|
+
const inner = this.type(opts[0], nameHint);
|
|
105
|
+
return { type: inner.type, nullable: true };
|
|
106
|
+
}
|
|
107
|
+
this.warnings.push(`${nameHint} uses ${key} with several options; its type is unknown.`);
|
|
108
|
+
return { type: { kind: 'unknown' }, nullable: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(schema.allOf)) {
|
|
112
|
+
const parts = schema.allOf;
|
|
113
|
+
if (parts.length === 1)
|
|
114
|
+
return this.type(parts[0], nameHint);
|
|
115
|
+
const merged = this.mergeAllOf(parts);
|
|
116
|
+
return { type: { kind: 'object', model: this.objectModel(merged, this.uniqueName(singular(pascal(nameHint))), true) }, nullable };
|
|
117
|
+
}
|
|
118
|
+
if (t === 'array' || schema.items) {
|
|
119
|
+
const item = this.type(schema.items, singular(nameHint) === nameHint ? `${nameHint} Item` : singular(nameHint));
|
|
120
|
+
return { type: { kind: 'array', item: item.type }, nullable };
|
|
121
|
+
}
|
|
122
|
+
if (this.isObjectSchema(schema)) {
|
|
123
|
+
if (!isObj(schema.properties) || Object.keys(schema.properties).length === 0)
|
|
124
|
+
return { type: { kind: 'unknown' }, nullable };
|
|
125
|
+
return { type: { kind: 'object', model: this.objectModel(schema, this.uniqueName(singular(pascal(nameHint))), true) }, nullable };
|
|
126
|
+
}
|
|
127
|
+
if (t === 'string')
|
|
128
|
+
return { type: { kind: schema.format === 'date-time' || schema.format === 'date' ? 'date' : 'string' }, nullable };
|
|
129
|
+
if (t === 'integer')
|
|
130
|
+
return { type: { kind: 'int' }, nullable };
|
|
131
|
+
if (t === 'number')
|
|
132
|
+
return { type: { kind: 'double' }, nullable };
|
|
133
|
+
if (t === 'boolean')
|
|
134
|
+
return { type: { kind: 'bool' }, nullable };
|
|
135
|
+
return { type: { kind: 'unknown' }, nullable: true };
|
|
136
|
+
}
|
|
137
|
+
/** Merges allOf parts (resolving references, including nested allOf) into one object schema. */
|
|
138
|
+
mergeAllOf(parts, depth = 0) {
|
|
139
|
+
const merged = { type: 'object', properties: {}, required: [] };
|
|
140
|
+
for (const part of parts) {
|
|
141
|
+
let p = isObj(part) && typeof part.$ref === 'string' ? this.resolve(part.$ref)?.schema : part;
|
|
142
|
+
if (isObj(p) && Array.isArray(p.allOf) && depth < 5)
|
|
143
|
+
p = this.mergeAllOf(p.allOf, depth + 1);
|
|
144
|
+
if (!isObj(p))
|
|
145
|
+
continue;
|
|
146
|
+
Object.assign(merged.properties, p.properties ?? {});
|
|
147
|
+
merged.required.push(...(p.required ?? []));
|
|
148
|
+
}
|
|
149
|
+
return merged;
|
|
150
|
+
}
|
|
151
|
+
isObjectSchema(s) {
|
|
152
|
+
return s.type === 'object' || isObj(s.properties) || (Array.isArray(s.type) && s.type.includes('object'));
|
|
153
|
+
}
|
|
154
|
+
/** `inline` models may merge into an identical earlier model; named (component) models never do. */
|
|
155
|
+
objectModel(schema, name, inline) {
|
|
156
|
+
if (this.models.has(name) || this.inProgress.has(name))
|
|
157
|
+
return name;
|
|
158
|
+
this.inProgress.add(name);
|
|
159
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
160
|
+
const fields = [];
|
|
161
|
+
for (const [key, prop] of Object.entries(isObj(schema.properties) ? schema.properties : {})) {
|
|
162
|
+
const generic = ['item', 'data', 'detail', 'info', 'meta', 'value', 'result', 'attribute', 'entry', 'option', 'setting'].includes(singular(key.toLowerCase()));
|
|
163
|
+
const hint = generic ? `${name} ${key}` : key;
|
|
164
|
+
const { type, nullable } = this.type(prop, hint);
|
|
165
|
+
fields.push({ jsonName: key, type, nullable: nullable || !required.has(key) });
|
|
166
|
+
}
|
|
167
|
+
this.inProgress.delete(name);
|
|
168
|
+
// Identical inline shapes share one model.
|
|
169
|
+
const sig = signature(fields);
|
|
170
|
+
if (inline)
|
|
171
|
+
for (const [other, m] of this.models)
|
|
172
|
+
if (other !== name && signature(m.fields) === sig)
|
|
173
|
+
return other;
|
|
174
|
+
this.models.set(name, { name, fields });
|
|
175
|
+
this.order.push(name);
|
|
176
|
+
return name;
|
|
177
|
+
}
|
|
178
|
+
/** Models needed by the given types, dependencies first. */
|
|
179
|
+
modelsFor(types) {
|
|
180
|
+
const need = new Set();
|
|
181
|
+
const visit = (t) => {
|
|
182
|
+
if (!t)
|
|
183
|
+
return;
|
|
184
|
+
if (t.kind === 'array')
|
|
185
|
+
return visit(t.item);
|
|
186
|
+
if (t.kind !== 'object' || need.has(t.model))
|
|
187
|
+
return;
|
|
188
|
+
need.add(t.model);
|
|
189
|
+
for (const f of this.models.get(t.model)?.fields ?? [])
|
|
190
|
+
visit(f.type);
|
|
191
|
+
};
|
|
192
|
+
types.forEach(visit);
|
|
193
|
+
return this.order.filter((n) => need.has(n)).map((n) => structuredClone(this.models.get(n)));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function serverBase(doc, format) {
|
|
197
|
+
let url = '';
|
|
198
|
+
if (format === 'openapi' && Array.isArray(doc.servers) && isObj(doc.servers[0])) {
|
|
199
|
+
url = String(doc.servers[0].url ?? '');
|
|
200
|
+
const vars = isObj(doc.servers[0].variables) ? doc.servers[0].variables : {};
|
|
201
|
+
url = url.replace(/\{([^/{}]+)\}/g, (_m, v) => String(isObj(vars[v]) ? vars[v].default ?? '' : ''));
|
|
202
|
+
}
|
|
203
|
+
else if (format === 'swagger') {
|
|
204
|
+
const scheme = Array.isArray(doc.schemes) && doc.schemes.length ? String(doc.schemes.includes('https') ? 'https' : doc.schemes[0]) : 'https';
|
|
205
|
+
url = doc.host ? `${scheme}://${doc.host}${doc.basePath ?? ''}` : String(doc.basePath ?? '');
|
|
206
|
+
}
|
|
207
|
+
if (/^https?:\/\//.test(url)) {
|
|
208
|
+
try {
|
|
209
|
+
const u = new URL(url);
|
|
210
|
+
// A host with quotes, backticks or $ is never written into code: treat the paths as relative instead.
|
|
211
|
+
if (!/^[A-Za-z0-9.-]+$|^\[[0-9A-Fa-f:.]+\]$/.test(u.hostname))
|
|
212
|
+
return { origin: '', prefix: '' };
|
|
213
|
+
return { origin: u.origin, prefix: u.pathname.replace(/\/$/, '') };
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// A server URL that is not a URL: treat the paths as relative.
|
|
217
|
+
return { origin: '', prefix: '' };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { origin: '', prefix: url.replace(/\/$/, '') };
|
|
221
|
+
}
|
|
222
|
+
function jsonContent(content) {
|
|
223
|
+
if (!isObj(content))
|
|
224
|
+
return undefined;
|
|
225
|
+
const key = Object.keys(content).find((k) => /json/.test(k)) ?? Object.keys(content).find((k) => k === '*/*');
|
|
226
|
+
return key && isObj(content[key]) ? content[key].schema : undefined;
|
|
227
|
+
}
|
|
228
|
+
function readOpenApi(doc, format) {
|
|
229
|
+
const conv = new SchemaConverter(doc);
|
|
230
|
+
const warnings = [];
|
|
231
|
+
const { origin, prefix } = serverBase(doc, format);
|
|
232
|
+
const operations = [];
|
|
233
|
+
const usedNames = new Set();
|
|
234
|
+
const paths = isObj(doc.paths) ? doc.paths : {};
|
|
235
|
+
for (const [rawPath, item] of Object.entries(paths)) {
|
|
236
|
+
if (!isObj(item))
|
|
237
|
+
continue;
|
|
238
|
+
const shared = Array.isArray(item.parameters) ? item.parameters : [];
|
|
239
|
+
for (const method of METHODS) {
|
|
240
|
+
const op = item[method];
|
|
241
|
+
if (!isObj(op))
|
|
242
|
+
continue;
|
|
243
|
+
const source = `${method.toUpperCase()} ${rawPath}`;
|
|
244
|
+
const params = [...shared, ...(Array.isArray(op.parameters) ? op.parameters : [])]
|
|
245
|
+
.map((p) => (isObj(p) && typeof p.$ref === 'string' ? (() => {
|
|
246
|
+
let cur = doc;
|
|
247
|
+
for (const part of p.$ref.slice(2).split('/'))
|
|
248
|
+
cur = isObj(cur) ? cur[part] : undefined;
|
|
249
|
+
return cur;
|
|
250
|
+
})() : p))
|
|
251
|
+
.filter(isObj);
|
|
252
|
+
const fullPath = `${prefix}${rawPath}`;
|
|
253
|
+
const analysis = analyzePath(fullPath.replace(/\{([^/{}]+)\}/g, ':$1'));
|
|
254
|
+
const derived = deriveName(method.toUpperCase(), analysis).name;
|
|
255
|
+
let name = typeof op.operationId === 'string' && op.operationId ? functionName(op.operationId, method, derived) : derived;
|
|
256
|
+
if (usedNames.has(name)) {
|
|
257
|
+
let n = 2;
|
|
258
|
+
while (usedNames.has(`${name}${n}`))
|
|
259
|
+
n++;
|
|
260
|
+
warnings.push(`${source}: the name ${name} is used twice; this one is ${name}${n}.`);
|
|
261
|
+
name = `${name}${n}`;
|
|
262
|
+
}
|
|
263
|
+
usedNames.add(name);
|
|
264
|
+
const pathParams = analysis.params.map((pp) => {
|
|
265
|
+
const decl = params.find((p) => p.in === 'path' && camel(String(p.name)) === pp.name);
|
|
266
|
+
const schema = isObj(decl?.schema) ? decl.schema : decl;
|
|
267
|
+
const kind = isObj(schema) && (schema.type === 'integer' || schema.type === 'number') ? 'int' : 'string';
|
|
268
|
+
return { name: pp.name, example: '', type: kind };
|
|
269
|
+
});
|
|
270
|
+
const queryParams = params
|
|
271
|
+
.filter((p) => p.in === 'query' && typeof p.name === 'string')
|
|
272
|
+
.map((p) => ({ name: String(p.name), type: conv.type(isObj(p.schema) ? p.schema : p, String(p.name)).type }))
|
|
273
|
+
.filter((q) => q.type.kind !== 'object' && q.type.kind !== 'array');
|
|
274
|
+
const base = pascal(name);
|
|
275
|
+
// Request body.
|
|
276
|
+
let requestSchema;
|
|
277
|
+
if (format === 'openapi' && isObj(op.requestBody)) {
|
|
278
|
+
const rb = typeof op.requestBody.$ref === 'string' ? undefined : op.requestBody;
|
|
279
|
+
requestSchema = jsonContent(rb?.content);
|
|
280
|
+
if (!requestSchema && isObj(op.requestBody.content))
|
|
281
|
+
warnings.push(`${source}: the request body is not JSON (for example a file upload); add it by hand.`);
|
|
282
|
+
}
|
|
283
|
+
else if (format === 'swagger') {
|
|
284
|
+
requestSchema = params.find((p) => p.in === 'body')?.schema;
|
|
285
|
+
if (params.some((p) => p.in === 'formData'))
|
|
286
|
+
warnings.push(`${source}: form data parameters are not generated; add them by hand.`);
|
|
287
|
+
}
|
|
288
|
+
const request = requestSchema ? conv.type(requestSchema, `${base} Request`).type : undefined;
|
|
289
|
+
// Response: the first 2xx with JSON.
|
|
290
|
+
const responses = isObj(op.responses) ? op.responses : {};
|
|
291
|
+
const okKey = Object.keys(responses).sort().find((k) => /^2\d\d$|^2XX$/i.test(k)) ?? (responses.default ? 'default' : undefined);
|
|
292
|
+
let responseSchema;
|
|
293
|
+
if (okKey && isObj(responses[okKey])) {
|
|
294
|
+
const r = responses[okKey];
|
|
295
|
+
responseSchema = format === 'openapi' ? jsonContent(r.content) : r.schema;
|
|
296
|
+
}
|
|
297
|
+
const resourceName = analysis.nouns[analysis.nouns.length - 1] ?? 'item';
|
|
298
|
+
// A list response is named from the plural resource so its items are singular (users → User).
|
|
299
|
+
const response = responseSchema ? conv.type(responseSchema, resourceName).type : undefined;
|
|
300
|
+
const spec = {
|
|
301
|
+
name,
|
|
302
|
+
method: method.toUpperCase(),
|
|
303
|
+
origin,
|
|
304
|
+
serverPrefix: prefix,
|
|
305
|
+
path: analysis.path,
|
|
306
|
+
resource: analysis.nouns[0] ?? 'root',
|
|
307
|
+
pathParams,
|
|
308
|
+
queryParams,
|
|
309
|
+
response,
|
|
310
|
+
unwrapData: false,
|
|
311
|
+
request,
|
|
312
|
+
models: conv.modelsFor([response, request]),
|
|
313
|
+
warnings: [],
|
|
314
|
+
};
|
|
315
|
+
operations.push({ spec, tags: Array.isArray(op.tags) ? op.tags.map(String) : [], source });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const title = isObj(doc.info) ? String(doc.info.title ?? 'API') : 'API';
|
|
319
|
+
return { format, title, operations, warnings: [...warnings, ...new Set(conv.warnings)] };
|
|
320
|
+
}
|
|
321
|
+
function readPostman(doc) {
|
|
322
|
+
const vars = new Map();
|
|
323
|
+
for (const v of Array.isArray(doc.variable) ? doc.variable : [])
|
|
324
|
+
if (v.key)
|
|
325
|
+
vars.set(String(v.key), String(v.value ?? ''));
|
|
326
|
+
const warnings = [];
|
|
327
|
+
const operations = [];
|
|
328
|
+
const used = new Set();
|
|
329
|
+
const visit = (items, folder) => {
|
|
330
|
+
for (const it of items) {
|
|
331
|
+
if (Array.isArray(it.item)) {
|
|
332
|
+
visit(it.item, [...folder, String(it.name ?? '')]);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (!it.request)
|
|
336
|
+
continue;
|
|
337
|
+
const req = typeof it.request === 'string' ? { url: it.request, method: 'GET' } : it.request;
|
|
338
|
+
const method = String(req.method ?? 'GET').toUpperCase();
|
|
339
|
+
const urlRaw = typeof req.url === 'string' ? req.url : isObj(req.url) ? String(req.url.raw ?? '') : '';
|
|
340
|
+
const source = `${it.name ?? method} (${method} ${urlRaw})`;
|
|
341
|
+
// Resolve {{variables}}; an unresolved leading variable is the base URL.
|
|
342
|
+
let resolved = urlRaw.replace(/\{\{(\w+)\}\}/g, (m, v) => vars.get(v) ?? m);
|
|
343
|
+
resolved = resolved.replace(/^\{\{\w+\}\}/, '');
|
|
344
|
+
if (/\{\{\w+\}\}/.test(resolved))
|
|
345
|
+
warnings.push(`${source}: unresolved variables were left in the URL.`);
|
|
346
|
+
resolved = resolved.replace(/\{\{(\w+)\}\}/g, ':$1');
|
|
347
|
+
let url;
|
|
348
|
+
try {
|
|
349
|
+
url = new URL(/^https?:\/\//.test(resolved) ? resolved : `https://api.invalid${resolved.startsWith('/') ? '' : '/'}${resolved}`);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
warnings.push(`${source}: the URL could not be read, so it was skipped.`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
stripSecretQuery(url, []);
|
|
356
|
+
const body = isObj(req.body) && req.body.mode === 'raw' ? String(req.body.raw ?? '') : undefined;
|
|
357
|
+
const samples = [];
|
|
358
|
+
for (const r of it.response ?? []) {
|
|
359
|
+
const code = Number(r.code ?? 200);
|
|
360
|
+
if (code < 200 || code >= 300 || typeof r.body !== 'string')
|
|
361
|
+
continue;
|
|
362
|
+
try {
|
|
363
|
+
samples.push(JSON.parse(r.body));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Not JSON; ignore.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const nameFromItem = it.name && /[A-Za-z]/.test(it.name) ? camel(it.name) : undefined;
|
|
370
|
+
let name = nameFromItem && nameFromItem.length <= 40 ? nameFromItem : undefined;
|
|
371
|
+
if (name && used.has(name))
|
|
372
|
+
name = undefined;
|
|
373
|
+
try {
|
|
374
|
+
const spec = buildEndpoint({ method, url, body: body && body.trim() ? body : undefined, responseSamples: samples, name });
|
|
375
|
+
if (used.has(spec.name)) {
|
|
376
|
+
let n = 2;
|
|
377
|
+
while (used.has(`${spec.name}${n}`))
|
|
378
|
+
n++;
|
|
379
|
+
spec.name = `${spec.name}${n}`;
|
|
380
|
+
}
|
|
381
|
+
used.add(spec.name);
|
|
382
|
+
if (!samples.length && method !== 'DELETE') {
|
|
383
|
+
spec.warnings = spec.warnings.filter((w) => !w.startsWith('No response sample'));
|
|
384
|
+
spec.warnings.push(`The collection has no saved example response, so the response type is unknown. Save an example in Postman, or run codz api update ${spec.name} --sample <file> later.`);
|
|
385
|
+
}
|
|
386
|
+
operations.push({ spec, tags: folder.filter(Boolean), source });
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
warnings.push(`${source}: ${e.message}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
visit(doc.item, []);
|
|
394
|
+
return { format: 'postman', title: isObj(doc.info) ? String(doc.info.name ?? 'Collection') : 'Collection', operations, warnings };
|
|
395
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Parses a pasted cURL command into a neutral request description.
|
|
2
|
+
// Secrets (auth headers, cookies, credentials, tokens in the query) are
|
|
3
|
+
// detected here and never passed on to code generation.
|
|
4
|
+
import { ToolError } from "../core/types.js";
|
|
5
|
+
/** decodeURIComponent that keeps text such as "100%" as it is instead of throwing. */
|
|
6
|
+
export function safeDecode(s) {
|
|
7
|
+
try {
|
|
8
|
+
return decodeURIComponent(s.replace(/\+/g, ' '));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return s;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Splits a shell command line into words, handling quotes, escapes and line continuations. */
|
|
15
|
+
export function shellSplit(input) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let cur = '';
|
|
18
|
+
let hasToken = false;
|
|
19
|
+
let i = 0;
|
|
20
|
+
const s = input.replace(/\\\r?\n/g, ' ');
|
|
21
|
+
while (i < s.length) {
|
|
22
|
+
const c = s[i];
|
|
23
|
+
if (c === "'") {
|
|
24
|
+
const end = s.indexOf("'", i + 1);
|
|
25
|
+
if (end === -1)
|
|
26
|
+
throw new ToolError('curl_parse', 'Unclosed single quote in the cURL command.');
|
|
27
|
+
cur += s.slice(i + 1, end);
|
|
28
|
+
hasToken = true;
|
|
29
|
+
i = end + 1;
|
|
30
|
+
}
|
|
31
|
+
else if (c === '$' && s[i + 1] === "'") {
|
|
32
|
+
// ANSI-C quoting: $'...'
|
|
33
|
+
let j = i + 2;
|
|
34
|
+
while (j < s.length && s[j] !== "'") {
|
|
35
|
+
if (s[j] === '\\' && j + 1 < s.length) {
|
|
36
|
+
const n = s[j + 1];
|
|
37
|
+
cur += n === 'n' ? '\n' : n === 't' ? '\t' : n;
|
|
38
|
+
j += 2;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
cur += s[j++];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
hasToken = true;
|
|
45
|
+
i = j + 1;
|
|
46
|
+
}
|
|
47
|
+
else if (c === '"') {
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
while (j < s.length && s[j] !== '"') {
|
|
50
|
+
if (s[j] === '\\' && j + 1 < s.length && '"\\$`'.includes(s[j + 1])) {
|
|
51
|
+
cur += s[j + 1];
|
|
52
|
+
j += 2;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
cur += s[j++];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (j >= s.length)
|
|
59
|
+
throw new ToolError('curl_parse', 'Unclosed double quote in the cURL command.');
|
|
60
|
+
hasToken = true;
|
|
61
|
+
i = j + 1;
|
|
62
|
+
}
|
|
63
|
+
else if (c === '\\' && i + 1 < s.length) {
|
|
64
|
+
cur += s[i + 1];
|
|
65
|
+
hasToken = true;
|
|
66
|
+
i += 2;
|
|
67
|
+
}
|
|
68
|
+
else if (/\s/.test(c)) {
|
|
69
|
+
if (hasToken)
|
|
70
|
+
out.push(cur);
|
|
71
|
+
cur = '';
|
|
72
|
+
hasToken = false;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
cur += c;
|
|
77
|
+
hasToken = true;
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (hasToken)
|
|
82
|
+
out.push(cur);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
const SECRET_HEADERS = /^(authorization|proxy-authorization|cookie|x-api-key|api-key|apikey|x-auth-token|x-access-token|x-csrf-token|x-xsrf-token|x-amz-security-token)$/i;
|
|
86
|
+
const SECRET_QUERY = /^(api[_-]?key|key|token|access[_-]?token|auth|signature|sig|secret|password|client[_-]?secret)$/i;
|
|
87
|
+
const HARMLESS_HEADERS = /^(content-type|accept|accept-encoding|accept-language|user-agent|connection|cache-control|pragma|origin|referer|sec-.*|dnt|host|content-length|priority|if-none-match|if-modified-since)$/i;
|
|
88
|
+
/** Short options that take a value (so -XPOST means -X POST). */
|
|
89
|
+
const SHORT_WITH_VALUE = new Set(['X', 'H', 'd', 'F', 'u', 'b', 'o', 'w', 'm', 'A', 'e', 'x', 'r', 'c', 'T', 'E', 'K', 'Y', 'y', 'z', 'C', 'P', 'Q', 'U']);
|
|
90
|
+
export const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
91
|
+
function asMethod(value) {
|
|
92
|
+
const m = value.toUpperCase();
|
|
93
|
+
if (!METHODS.includes(m))
|
|
94
|
+
throw new ToolError('curl_parse', `Unsupported HTTP method "${value}".`);
|
|
95
|
+
return m;
|
|
96
|
+
}
|
|
97
|
+
export function parseUrl(raw) {
|
|
98
|
+
let text = raw.trim();
|
|
99
|
+
if (!/^https?:\/\//i.test(text)) {
|
|
100
|
+
if (text.startsWith('/'))
|
|
101
|
+
text = `https://api.invalid${text}`;
|
|
102
|
+
else
|
|
103
|
+
text = `https://${text}`;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return new URL(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new ToolError('bad_url', `"${raw}" is not a valid URL.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Strips secret query parameters from a URL, recording what was removed. */
|
|
113
|
+
export function stripSecretQuery(url, removed) {
|
|
114
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
115
|
+
if (SECRET_QUERY.test(key)) {
|
|
116
|
+
url.searchParams.delete(key);
|
|
117
|
+
removed.push(`query parameter "${key}"`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function parseCurl(command) {
|
|
122
|
+
const words = shellSplit(command.trim());
|
|
123
|
+
if (words[0] !== 'curl')
|
|
124
|
+
throw new ToolError('curl_parse', 'Expected the command to start with "curl".');
|
|
125
|
+
let method;
|
|
126
|
+
let url;
|
|
127
|
+
const headers = [];
|
|
128
|
+
const dataParts = [];
|
|
129
|
+
let forceGet = false;
|
|
130
|
+
const removedSecrets = [];
|
|
131
|
+
const ignoredHeaders = [];
|
|
132
|
+
const form = [];
|
|
133
|
+
const notes = [];
|
|
134
|
+
const takesValue = new Set(['-o', '--output', '-w', '--write-out', '-m', '--max-time', '--connect-timeout', '-A', '--user-agent', '-e', '--referer', '--retry', '-x', '--proxy', '--cacert', '--cert', '--key', '-r', '--range', '--limit-rate', '-c', '--cookie-jar']);
|
|
135
|
+
for (let i = 1; i < words.length; i++) {
|
|
136
|
+
const w = words[i];
|
|
137
|
+
const next = () => {
|
|
138
|
+
const v = words[++i];
|
|
139
|
+
if (v === undefined)
|
|
140
|
+
throw new ToolError('curl_parse', `Missing value after ${w}.`);
|
|
141
|
+
return v;
|
|
142
|
+
};
|
|
143
|
+
// Support --flag=value and short flags with the value attached (-XPOST, -HAccept:x, -sSX POST).
|
|
144
|
+
const eq = w.startsWith('--') ? w.indexOf('=') : -1;
|
|
145
|
+
let flag = eq > 0 ? w.slice(0, eq) : w;
|
|
146
|
+
let inline = eq > 0 ? w.slice(eq + 1) : undefined;
|
|
147
|
+
if (/^-[A-Za-z]{2,}/.test(w) || /^-[A-Za-z]./.test(w)) {
|
|
148
|
+
// Walk the cluster: boolean letters first, then one letter that takes a value.
|
|
149
|
+
let k = 1;
|
|
150
|
+
while (k < w.length && !SHORT_WITH_VALUE.has(w[k]))
|
|
151
|
+
k++;
|
|
152
|
+
if (k < w.length) {
|
|
153
|
+
flag = `-${w[k]}`;
|
|
154
|
+
inline = w.length > k + 1 ? w.slice(k + 1) : undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const value = () => inline ?? next();
|
|
158
|
+
switch (flag) {
|
|
159
|
+
case '-X':
|
|
160
|
+
case '--request':
|
|
161
|
+
method = asMethod(value());
|
|
162
|
+
break;
|
|
163
|
+
case '-H':
|
|
164
|
+
case '--header': {
|
|
165
|
+
const h = value();
|
|
166
|
+
const idx = h.indexOf(':');
|
|
167
|
+
if (idx > 0)
|
|
168
|
+
headers.push({ name: h.slice(0, idx).trim(), value: h.slice(idx + 1).trim() });
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case '-d':
|
|
172
|
+
case '--data':
|
|
173
|
+
case '--data-raw':
|
|
174
|
+
case '--data-binary':
|
|
175
|
+
case '--data-ascii':
|
|
176
|
+
case '--data-urlencode':
|
|
177
|
+
dataParts.push(value());
|
|
178
|
+
break;
|
|
179
|
+
case '--json':
|
|
180
|
+
dataParts.push(value());
|
|
181
|
+
headers.push({ name: 'Content-Type', value: 'application/json' });
|
|
182
|
+
break;
|
|
183
|
+
case '-F':
|
|
184
|
+
case '--form':
|
|
185
|
+
case '--form-string': {
|
|
186
|
+
const f = value();
|
|
187
|
+
const i2 = f.indexOf('=');
|
|
188
|
+
const name = i2 > 0 ? f.slice(0, i2) : f;
|
|
189
|
+
const isFile = flag !== '--form-string' && i2 > 0 && /^[@<]/.test(f.slice(i2 + 1));
|
|
190
|
+
form.push({ name, file: isFile });
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case '-G':
|
|
194
|
+
case '--get':
|
|
195
|
+
forceGet = true;
|
|
196
|
+
break;
|
|
197
|
+
case '-u':
|
|
198
|
+
case '--user':
|
|
199
|
+
value();
|
|
200
|
+
removedSecrets.push('basic auth credentials (-u)');
|
|
201
|
+
break;
|
|
202
|
+
case '-b':
|
|
203
|
+
case '--cookie':
|
|
204
|
+
value();
|
|
205
|
+
removedSecrets.push('cookies (-b)');
|
|
206
|
+
break;
|
|
207
|
+
case '--oauth2-bearer':
|
|
208
|
+
value();
|
|
209
|
+
removedSecrets.push('bearer token (--oauth2-bearer)');
|
|
210
|
+
break;
|
|
211
|
+
case '--url':
|
|
212
|
+
url = value();
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
if (w.startsWith('-')) {
|
|
216
|
+
if (takesValue.has(flag) && inline === undefined)
|
|
217
|
+
i++;
|
|
218
|
+
// Other flags (-s, -L, -k, -v, --compressed, -i) do not affect the endpoint.
|
|
219
|
+
}
|
|
220
|
+
else if (!url) {
|
|
221
|
+
url = w;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!url)
|
|
226
|
+
throw new ToolError('curl_parse', 'No URL found in the cURL command.');
|
|
227
|
+
const parsed = parseUrl(url);
|
|
228
|
+
const kept = [];
|
|
229
|
+
for (const h of headers) {
|
|
230
|
+
if (SECRET_HEADERS.test(h.name))
|
|
231
|
+
removedSecrets.push(`header "${h.name}"`);
|
|
232
|
+
else if (HARMLESS_HEADERS.test(h.name))
|
|
233
|
+
kept.push(h);
|
|
234
|
+
else
|
|
235
|
+
ignoredHeaders.push(h.name);
|
|
236
|
+
}
|
|
237
|
+
stripSecretQuery(parsed, removedSecrets);
|
|
238
|
+
let body = dataParts.length ? dataParts.join('&') : undefined;
|
|
239
|
+
if (forceGet && body) {
|
|
240
|
+
for (const pair of body.split('&')) {
|
|
241
|
+
const eqAt = pair.indexOf('=');
|
|
242
|
+
const k = eqAt === -1 ? pair : pair.slice(0, eqAt);
|
|
243
|
+
const v = eqAt === -1 ? '' : pair.slice(eqAt + 1);
|
|
244
|
+
if (k)
|
|
245
|
+
parsed.searchParams.append(safeDecode(k), safeDecode(v));
|
|
246
|
+
}
|
|
247
|
+
stripSecretQuery(parsed, removedSecrets);
|
|
248
|
+
body = undefined;
|
|
249
|
+
}
|
|
250
|
+
if (form.length) {
|
|
251
|
+
if (body !== undefined)
|
|
252
|
+
notes.push('The command has both -d and -F; only the -F form fields were used.');
|
|
253
|
+
body = undefined;
|
|
254
|
+
const files = form.filter((f) => f.file).map((f) => f.name);
|
|
255
|
+
notes.push(`This is a multipart form upload (${form.map((f) => f.name + (f.file ? ' (file)' : '')).join(', ')}). The generated function sends no body yet${files.length ? `; send ${files.join(', ')} with FormData (Next.js) or Dio's FormData (Flutter)` : ''}.`);
|
|
256
|
+
}
|
|
257
|
+
const finalMethod = method ?? (forceGet ? 'GET' : body !== undefined || form.length ? 'POST' : 'GET');
|
|
258
|
+
return { method: finalMethod, url: parsed, headers: kept, body, removedSecrets, ignoredHeaders, form, notes };
|
|
259
|
+
}
|