codz-cli 0.1.2 → 0.1.8

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.
@@ -0,0 +1,512 @@
1
+ // mock serve: a local fake API so new screens show data before the backend exists.
2
+ //
3
+ // It answers every endpoint the project knows (or every operation in an
4
+ // OpenAPI / Swagger / Postman file) with sample data built from the response
5
+ // models, the same readable values mock add and test add use. Lists and items
6
+ // that share a path behave like a small in-memory database, so a create form
7
+ // adds a row the list then shows, an edit form changes it and delete removes
8
+ // it; restarting the server resets the data. Paths match by their trailing
9
+ // segments, so /v1/products and /products both reach GET /products.
10
+ import { createServer } from 'node:http';
11
+ import { networkInterfaces } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { readText } from "../core/fsutil.js";
14
+ import { ToolError } from "../core/types.js";
15
+ import { readApiFile } from "../input/apiSpec.js";
16
+ import { findDeclaration } from "./apiEdit.js";
17
+ import { exampleFor, unwrapsData } from "./mock.js";
18
+ // ------------------------------------------------------------ building the API
19
+ const typeOf = (t) => {
20
+ if (!t)
21
+ return '';
22
+ switch (t.kind) {
23
+ case 'string':
24
+ return 'string';
25
+ case 'date':
26
+ return 'DateTime';
27
+ case 'int':
28
+ return 'int';
29
+ case 'double':
30
+ return 'double';
31
+ case 'bool':
32
+ return 'bool';
33
+ case 'array':
34
+ return `${typeOf(t.item) || 'string'}[]`;
35
+ case 'object':
36
+ return t.model;
37
+ default:
38
+ return '';
39
+ }
40
+ };
41
+ const modelOf = (m) => ({ name: m.name, file: '', fields: m.fields.map((f) => ({ name: f.jsonName, type: typeOf(f.type) || 'string', nullable: f.nullable })) });
42
+ /** Routes from an OpenAPI, Swagger, Postman or HAR file. */
43
+ export function mockApiFromSpec(file) {
44
+ const text = readText(file);
45
+ if (text === null)
46
+ throw new ToolError('no_file', `Cannot read ${file}.`, 'Give the path to an OpenAPI, Swagger, Postman or HAR file.');
47
+ const r = readApiFile(text);
48
+ const models = new Map();
49
+ const routes = [];
50
+ for (const op of r.operations) {
51
+ for (const m of op.spec.models)
52
+ if (!models.has(m.name))
53
+ models.set(m.name, modelOf(m));
54
+ routes.push({ method: op.spec.method, path: op.spec.path, response: typeOf(op.spec.response), envelope: op.spec.unwrapData });
55
+ }
56
+ if (!routes.length)
57
+ throw new ToolError('no_endpoints', `${file} has no operations to mock.`);
58
+ return { routes, models: [...models.values()], source: `${file} (${r.title || r.format})` };
59
+ }
60
+ /** Routes from the endpoints and models codz found in the project. */
61
+ export function mockApiFromProject(root, map, lang) {
62
+ if (!map.endpoints.length)
63
+ throw new ToolError('no_endpoints', 'This project has no endpoints yet, so there is nothing to mock.', 'Add some first (codz feature add <name> --spec api.yaml, or codz api add), or serve a spec directly: codz mock serve --spec api.yaml');
64
+ const routes = map.endpoints.map((ep) => {
65
+ const response = (ep.response ?? '').replace(/^Promise<(.*)>$/, '$1').replace(/^(Future|Deferred)<(.*)>$/, '$2').replace(/^Response<(.*)>$/, '$1');
66
+ let envelope = false;
67
+ if (lang === 'ts' || lang === 'dart') {
68
+ const text = readText(join(root, ep.file)) ?? '';
69
+ const decl = findDeclaration(text, ep.name, lang);
70
+ envelope = !!decl && unwrapsData(text.slice(decl.start, decl.end), map.conventions);
71
+ }
72
+ return { method: ep.method, path: ep.path.replace(/^https?:\/\/[^/]+/, ''), response: /^(void|Unit|unknown|dynamic)$/.test(response) ? '' : response, envelope };
73
+ });
74
+ return { routes, models: map.models, source: `the project's ${routes.length} endpoint${routes.length === 1 ? '' : 's'}` };
75
+ }
76
+ // ------------------------------------------------------------ the server
77
+ /** How the mock answers, to check each state of a screen. */
78
+ export const SCENARIOS = ['normal', 'empty', 'error', 'unauthorized', 'slow', 'offline'];
79
+ export const SCENARIO_HELP = {
80
+ normal: 'sample data',
81
+ empty: 'lists come back empty (and their items 404)',
82
+ error: 'every request fails with 500',
83
+ unauthorized: 'every request fails with 401 (signed out)',
84
+ slow: 'answers take 3 seconds',
85
+ offline: 'the connection drops (a network error in the app)',
86
+ };
87
+ const segments = (p) => p.split('?')[0].split('/').filter(Boolean);
88
+ const isParam = (s) => /^\{[^/{}]+\}$/.test(s) || /^:\w+$/.test(s);
89
+ const listItemType = (t) => /^List<(.+)>$/.exec(t)?.[1] ?? /^(.+)\[\]$/.exec(t)?.[1] ?? /^Array<(.+)>$/.exec(t)?.[1];
90
+ /**
91
+ * The route that matches the request path, comparing from the end: the extra
92
+ * leading segments on either side (/v1, /api) are ignored. More matching
93
+ * literal segments win; then the closer length.
94
+ */
95
+ export function matchRoute(routes, method, path) {
96
+ const got = segments(path).map((s) => {
97
+ try {
98
+ return decodeURIComponent(s);
99
+ }
100
+ catch {
101
+ return s;
102
+ }
103
+ });
104
+ let best = null;
105
+ for (const route of routes) {
106
+ if (route.method !== method)
107
+ continue;
108
+ const want = segments(route.path);
109
+ const n = Math.min(want.length, got.length);
110
+ // Never match on parameters alone (GET /{id} for every path), and keep the item/collection difference.
111
+ if (n === 0 || (got.length < want.length && isParam(want[want.length - n - 1] ?? '')))
112
+ continue;
113
+ const w = want.slice(want.length - n);
114
+ const g = got.slice(got.length - n);
115
+ const params = {};
116
+ let literals = 0;
117
+ const ok = w.every((s, i) => {
118
+ if (isParam(s)) {
119
+ params[s.replace(/^\{|\}$|^:/g, '')] = g[i];
120
+ return true;
121
+ }
122
+ if (s !== g[i])
123
+ return false;
124
+ literals++;
125
+ return true;
126
+ });
127
+ if (!ok || literals === 0)
128
+ continue;
129
+ const score = literals * 100 - Math.abs(want.length - got.length);
130
+ if (!best || score > best.score)
131
+ best = { m: { route, params }, score };
132
+ }
133
+ return best?.m ?? null;
134
+ }
135
+ /** A running mock API: start() it, then stop() it. */
136
+ export class MockServer {
137
+ api;
138
+ opts;
139
+ map;
140
+ collections = new Map();
141
+ server = null;
142
+ /** The scenario for every route, and per-route overrides ("GET /v1/products"). */
143
+ scenario;
144
+ overrides = new Map();
145
+ /** The last requests, newest last. */
146
+ logEntries = [];
147
+ nextLogId = 1;
148
+ constructor(api, opts = {}) {
149
+ this.api = api;
150
+ this.opts = { port: opts.port ?? 4010, host: opts.host ?? '127.0.0.1', delay: opts.delay ?? 300, items: opts.items ?? 5, log: opts.log ?? (() => { }) };
151
+ this.map = { models: api.models, endpoints: [], conventions: {} };
152
+ this.scenario = opts.scenario ?? 'normal';
153
+ }
154
+ /** The scenario a request gets: its route's override, else the global one. */
155
+ scenarioFor(route) {
156
+ return (route && this.overrides.get(`${route.method} ${route.path}`)) || this.scenario;
157
+ }
158
+ setScenario(scenario, route) {
159
+ if (!SCENARIOS.includes(scenario))
160
+ throw new ToolError('bad_args', `Unknown scenario "${scenario}". Choose: ${SCENARIOS.join(', ')}.`);
161
+ if (!route)
162
+ return void (this.scenario = scenario);
163
+ if (!this.api.routes.some((r) => `${r.method} ${r.path}` === route))
164
+ throw new ToolError('bad_args', `No route "${route}".`);
165
+ if (scenario === this.scenario)
166
+ this.overrides.delete(route);
167
+ else
168
+ this.overrides.set(route, scenario);
169
+ }
170
+ /** Back to the starting data, scenario and an empty log. */
171
+ reset() {
172
+ this.collections.clear();
173
+ this.scenario = 'normal';
174
+ this.overrides.clear();
175
+ this.logEntries.length = 0;
176
+ }
177
+ /** The collection a route belongs to: GET /products and GET|PUT|DELETE /products/{id} share one. */
178
+ collectionFor(route) {
179
+ const segs = segments(route.path);
180
+ const itemRoute = segs.length > 0 && isParam(segs[segs.length - 1]);
181
+ const key = (itemRoute ? segs.slice(0, -1) : segs).join('/');
182
+ let col = this.collections.get(key);
183
+ if (!col) {
184
+ // Its item model: the list a GET on the collection returns, else what the item routes return.
185
+ const listRoute = this.api.routes.find((r) => r.method === 'GET' && segments(r.path).join('/') === key && listItemType(r.response));
186
+ const itemGet = this.api.routes.find((r) => r.method === 'GET' && segments(r.path).slice(0, -1).join('/') === key && isParam(segments(r.path).at(-1) ?? '') && r.response && !listItemType(r.response));
187
+ const model = (listRoute && listItemType(listRoute.response)) || itemGet?.response;
188
+ if (!model || !this.api.models.some((m) => m.name === model))
189
+ return null;
190
+ const fields = this.api.models.find((m) => m.name === model).fields;
191
+ const idField = fields.find((f) => f.name === 'id')?.name ?? fields.find((f) => /(^|_)id$|Id$/.test(f.name))?.name ?? null;
192
+ const items = Array.from({ length: this.opts.items }, (_, i) => this.sample(model, i + 1, idField));
193
+ col = { model, idField, items, nextId: this.opts.items + 1 };
194
+ this.collections.set(key, col);
195
+ }
196
+ return { key, col };
197
+ }
198
+ sample(model, index, idField) {
199
+ const value = vary(exampleFor(model, this.map, '', 0, index), index);
200
+ if (idField && value && typeof value === 'object') {
201
+ const f = this.api.models.find((m) => m.name === model)?.fields.find((x) => x.name === idField);
202
+ value[idField] = f && /string/i.test(f.type) ? String(index) : index;
203
+ }
204
+ return value;
205
+ }
206
+ /** The answer to one request: status and JSON body (undefined for no body). */
207
+ handle(method, path, body) {
208
+ const m = matchRoute(this.api.routes, method, path);
209
+ const scenario = this.scenarioFor(m?.route ?? null);
210
+ if (m && scenario === 'error')
211
+ return { status: 500, body: { error: 'Internal server error (codz mock scenario: error)' } };
212
+ if (m && scenario === 'unauthorized')
213
+ return { status: 401, body: { error: 'Unauthorized (codz mock scenario: unauthorized)' } };
214
+ if (!m)
215
+ return { status: 404, body: { error: `No mock for ${method} ${segments(path).length ? '/' + segments(path).join('/') : '/'}`, routes: this.api.routes.map((r) => `${r.method} ${r.path}`) } };
216
+ const { route, params } = m;
217
+ const wrap = (v) => (route.envelope ? { data: v } : v);
218
+ const segs = segments(route.path);
219
+ const last = segs.at(-1) ?? '';
220
+ const itemRoute = isParam(last);
221
+ const id = itemRoute ? params[last.replace(/^\{|\}$|^:/g, '')] : undefined;
222
+ const c = this.collectionFor(route);
223
+ const input = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
224
+ if (c) {
225
+ const { col } = c;
226
+ const find = () => col.items.findIndex((it) => col.idField && String(it[col.idField]) === id);
227
+ if (method === 'GET' && !itemRoute && listItemType(route.response))
228
+ return { status: 200, body: wrap(scenario === 'empty' ? [] : col.items) };
229
+ if (method === 'GET' && itemRoute && route.response === col.model) {
230
+ if (scenario === 'empty')
231
+ return { status: 404, body: { error: `No ${col.model} with ${col.idField} ${id} (codz mock scenario: empty)` } };
232
+ const i = find();
233
+ return i >= 0 ? { status: 200, body: wrap(col.items[i]) } : { status: 404, body: { error: `No ${col.model} with ${col.idField} ${id}` } };
234
+ }
235
+ if (method === 'POST' && !itemRoute) {
236
+ const created = { ...this.sample(col.model, col.nextId, col.idField), ...input };
237
+ if (col.idField)
238
+ created[col.idField] = this.sample(col.model, col.nextId, col.idField)[col.idField];
239
+ col.nextId++;
240
+ col.items.push(created);
241
+ return route.response ? { status: 201, body: wrap(route.response === col.model ? created : this.responseFor(route, input)) } : { status: 201 };
242
+ }
243
+ if ((method === 'PUT' || method === 'PATCH') && itemRoute) {
244
+ const i = find();
245
+ if (i < 0)
246
+ return { status: 404, body: { error: `No ${col.model} with ${col.idField} ${id}` } };
247
+ col.items[i] = method === 'PUT' ? { ...this.sample(col.model, i + 1, col.idField), ...input, ...(col.idField ? { [col.idField]: col.items[i][col.idField] } : {}) } : { ...col.items[i], ...input, ...(col.idField ? { [col.idField]: col.items[i][col.idField] } : {}) };
248
+ return route.response ? { status: 200, body: wrap(route.response === col.model ? col.items[i] : this.responseFor(route, input)) } : { status: 204 };
249
+ }
250
+ if (method === 'DELETE' && itemRoute) {
251
+ const i = find();
252
+ if (i < 0)
253
+ return { status: 404, body: { error: `No ${col.model} with ${col.idField} ${id}` } };
254
+ col.items.splice(i, 1);
255
+ return route.response ? { status: 200, body: wrap(this.responseFor(route, input)) } : { status: 204 };
256
+ }
257
+ }
258
+ // Anything else (an order's tracking, a search, an action): sample data of its response type.
259
+ if (!route.response)
260
+ return { status: method === 'POST' ? 201 : method === 'GET' ? 200 : 204, ...(method === 'GET' ? { body: wrap({}) } : {}) };
261
+ return { status: method === 'POST' ? 201 : 200, body: wrap(this.responseFor(route, input)) };
262
+ }
263
+ responseFor(route, input) {
264
+ const v = exampleFor(route.response, this.map, '', 0, 1);
265
+ if (v && typeof v === 'object' && !Array.isArray(v))
266
+ for (const k of Object.keys(v))
267
+ if (k in input)
268
+ v[k] = input[k];
269
+ return v;
270
+ }
271
+ async start() {
272
+ this.server = createServer((req, res) => void this.onRequest(req, res));
273
+ await new Promise((resolve, reject) => {
274
+ this.server.once('error', (e) => reject(e.code === 'EADDRINUSE' ? new ToolError('port_in_use', `Port ${this.opts.port} is already in use.`, `Stop what is using it, or pick another port: codz mock serve --port ${this.opts.port + 1}`) : e));
275
+ this.server.listen(this.opts.port, this.opts.host, () => resolve());
276
+ });
277
+ const addr = this.server.address();
278
+ return typeof addr === 'object' && addr ? addr.port : this.opts.port;
279
+ }
280
+ async stop() {
281
+ if (!this.server)
282
+ return;
283
+ await new Promise((resolve) => this.server.close(() => resolve()));
284
+ this.server = null;
285
+ }
286
+ async onRequest(req, res) {
287
+ // Web apps call it from another origin (localhost:3000, 5173).
288
+ res.setHeader('Access-Control-Allow-Origin', '*');
289
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
290
+ res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '*');
291
+ const method = (req.method ?? 'GET').toUpperCase();
292
+ const path = req.url ?? '/';
293
+ if (method === 'OPTIONS')
294
+ return void res.writeHead(204).end();
295
+ if (path === '/__codz' || path.startsWith('/__codz/') || path.startsWith('/__codz?'))
296
+ return this.control(req, res, method, path.split('?')[0]);
297
+ const chunks = [];
298
+ for await (const c of req)
299
+ chunks.push(c);
300
+ let body;
301
+ const raw = Buffer.concat(chunks).toString('utf8');
302
+ if (raw) {
303
+ try {
304
+ body = JSON.parse(raw);
305
+ }
306
+ catch {
307
+ body = undefined;
308
+ }
309
+ }
310
+ const started = Date.now();
311
+ const scenario = this.scenarioFor(matchRoute(this.api.routes, method, path)?.route ?? null);
312
+ const wait = scenario === 'slow' ? Math.max(3000, this.opts.delay) : this.opts.delay;
313
+ if (wait > 0)
314
+ await new Promise((r) => setTimeout(r, wait));
315
+ if (scenario === 'offline') {
316
+ this.record({ method, path, status: 0, ms: Date.now() - started, scenario, request: body });
317
+ this.opts.log(`${method} ${path.split('?')[0]} → connection dropped (offline)`);
318
+ return void req.socket.destroy();
319
+ }
320
+ const out = this.handle(method, path, body);
321
+ const n = Array.isArray(out.body) ? ` (${out.body.length} items)` : out.body && typeof out.body === 'object' && Array.isArray(out.body.data) ? ` (${(out.body.data).length} items)` : '';
322
+ this.record({ method, path, status: out.status, ms: Date.now() - started, scenario, request: body, response: out.body });
323
+ this.opts.log(`${method} ${path.split('?')[0]} → ${out.status}${n}${scenario !== 'normal' ? ` [${scenario}]` : ''}`);
324
+ if (out.body === undefined)
325
+ return void res.writeHead(out.status).end();
326
+ res.writeHead(out.status, { 'Content-Type': 'application/json' }).end(JSON.stringify(out.body));
327
+ }
328
+ record(e) {
329
+ this.logEntries.push({ id: this.nextLogId++, time: new Date().toISOString(), ...e });
330
+ if (this.logEntries.length > 200)
331
+ this.logEntries.shift();
332
+ }
333
+ /** The control page and its API: /__codz (page), /__codz/state, /__codz/scenario, /__codz/reset. */
334
+ async control(req, res, method, path) {
335
+ const json = (status, value) => void res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(value));
336
+ if (method === 'GET' && (path === '/__codz' || path === '/__codz/'))
337
+ return void res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(CONTROL_PAGE);
338
+ if (method === 'GET' && path === '/__codz/state')
339
+ return json(200, this.state());
340
+ if (method === 'POST' && (path === '/__codz/scenario' || path === '/__codz/reset')) {
341
+ const chunks = [];
342
+ for await (const c of req)
343
+ chunks.push(c);
344
+ let input = {};
345
+ try {
346
+ input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
347
+ }
348
+ catch {
349
+ return json(400, { error: 'Send JSON: {"scenario": "error", "route": "GET /v1/products"}' });
350
+ }
351
+ try {
352
+ if (path === '/__codz/reset')
353
+ this.reset();
354
+ else
355
+ this.setScenario(input.scenario, input.route);
356
+ }
357
+ catch (e) {
358
+ return json(400, { error: e.message });
359
+ }
360
+ this.opts.log(path === '/__codz/reset' ? 'Reset: starting data, normal answers, empty log' : `Scenario: ${input.route ? `${input.route} → ` : ''}${input.scenario}`);
361
+ return json(200, this.state());
362
+ }
363
+ return json(404, { error: 'Control routes: GET /__codz, GET /__codz/state, POST /__codz/scenario, POST /__codz/reset' });
364
+ }
365
+ state() {
366
+ return {
367
+ scenario: this.scenario,
368
+ scenarios: SCENARIOS.map((id) => ({ id, help: SCENARIO_HELP[id] })),
369
+ routes: this.api.routes.map((r) => ({ route: `${r.method} ${r.path}`, scenario: this.scenarioFor(r) })),
370
+ log: this.logEntries.slice(-100).reverse(),
371
+ };
372
+ }
373
+ }
374
+ /**
375
+ * Makes rows of a list look different from each other: the sample values are
376
+ * the same for every row of a model, so numbers, dates, flags and plain texts
377
+ * get the row number worked in (ids are set separately).
378
+ */
379
+ function vary(value, index, key = '') {
380
+ if (Array.isArray(value))
381
+ return value.map((v) => vary(v, index, key));
382
+ if (value && typeof value === 'object')
383
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, vary(v, index, k)]));
384
+ if (/(^|_)id$|Id$/.test(key))
385
+ return value;
386
+ if (typeof value === 'number')
387
+ return Number.isInteger(value) ? value + (index - 1) : Math.round((value + (index - 1) * (value < 10 ? 0.1 : 7.5)) * 100) / 100;
388
+ if (typeof value === 'boolean')
389
+ return index % 3 !== 0;
390
+ if (typeof value === 'string') {
391
+ if (/^\d{4}-\d{2}-\d{2}T/.test(value))
392
+ return new Date(Date.parse(value) + (index - 1) * 86_400_000).toISOString().replace('.000Z', 'Z');
393
+ if (/^Sample /.test(value) && !/\d$/.test(value))
394
+ return `${value} ${index}`;
395
+ }
396
+ return value;
397
+ }
398
+ /** This computer's address on the local network, for apps on a phone. */
399
+ export function lanAddress() {
400
+ for (const list of Object.values(networkInterfaces()))
401
+ for (const a of list ?? [])
402
+ if (a.family === 'IPv4' && !a.internal)
403
+ return a.address;
404
+ return null;
405
+ }
406
+ /** How to point the app at the mock server, by framework. */
407
+ export function connectHints(map, framework, port, onLan = false) {
408
+ const local = `http://localhost:${port}`;
409
+ const lan = onLan ? lanAddress() : null;
410
+ const phone = lan ? `http://${lan}:${port}` : null;
411
+ const c = map?.conventions ?? {};
412
+ const where = map?.baseUrls?.[0]?.file;
413
+ const lines = hintsFor();
414
+ if (!onLan && ['react-native', 'flutter', 'android', 'ios'].includes(framework ?? ''))
415
+ lines.push('To use it from a phone on the same Wi-Fi, start it with --lan (it then listens on your network, not only on this computer).');
416
+ return lines;
417
+ function hintsFor() {
418
+ switch (framework) {
419
+ case 'nextjs':
420
+ return [`Start the app with: ${c.baseUrlEnv || 'NEXT_PUBLIC_API_URL'}=${local} npm run dev`];
421
+ case 'react':
422
+ return [`Start the app with: ${c.bundler === 'cra' ? 'REACT_APP_API_URL' : 'VITE_API_URL'}=${local} npm ${c.bundler === 'cra' ? 'start' : 'run dev'}`];
423
+ case 'react-native':
424
+ return [`Set the API URL${c.configFile ? ` in ${c.configFile}` : ''} to ${local} (iOS simulator), http://10.0.2.2:${port} (Android emulator)${phone ? ` or ${phone} (a phone on the same Wi-Fi)` : ''}.`];
425
+ case 'flutter':
426
+ return [
427
+ `Set baseUrl${where ? ` in ${where}` : ''} to http://10.0.2.2:${port} (Android emulator), ${local} (iOS simulator, web, desktop)${phone ? ` or ${phone} (a phone on the same Wi-Fi)` : ''}.`,
428
+ 'Android blocks plain http by default: add android:usesCleartextTraffic="true" to <application> in android/app/src/main/AndroidManifest.xml while you use the mock.',
429
+ ];
430
+ case 'android':
431
+ return [`Set the base URL${where ? ` in ${where}` : ''} to http://10.0.2.2:${port}/ (emulator)${phone ? ` or ${phone}/ (a phone on the same Wi-Fi)` : ''}.`, 'Android blocks plain http by default: add android:usesCleartextTraffic="true" to <application> in AndroidManifest.xml while you use the mock.'];
432
+ case 'ios':
433
+ return [`Set baseURL${where ? ` in ${where}` : ''} to ${local} (simulator)${phone ? ` or ${phone} (an iPhone on the same Wi-Fi; allow it under App Transport Security)` : ''}.`];
434
+ default:
435
+ return [`Point the app's API base URL at ${local}${phone ? ` (from a phone on the same Wi-Fi: ${phone})` : ''}.`];
436
+ }
437
+ }
438
+ }
439
+ /** The page at /__codz: switch scenarios (for every route or one) and watch requests. */
440
+ const CONTROL_PAGE = `<!doctype html>
441
+ <html lang="en">
442
+ <head>
443
+ <meta charset="utf-8">
444
+ <meta name="viewport" content="width=device-width, initial-scale=1">
445
+ <title>codz mock</title>
446
+ <style>
447
+ :root { --bg: #fff; --fg: #1d1d1f; --muted: #6e6e73; --line: #e5e5ea; --accent: #0f766e; --bad: #b42318; --chip: #f2f2f7; }
448
+ @media (prefers-color-scheme: dark) { :root { --bg: #161618; --fg: #f2f2f7; --muted: #a1a1a6; --line: #2c2c2e; --accent: #2dd4bf; --bad: #f97066; --chip: #232326; } }
449
+ * { box-sizing: border-box; }
450
+ body { margin: 0; padding: 20px 16px 40px; font: 14px/1.45 system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); }
451
+ main { max-width: 1000px; margin: 0 auto; }
452
+ h1 { font-size: 20px; margin: 0 0 4px; }
453
+ h2 { font-size: 15px; margin: 28px 0 8px; }
454
+ p { margin: 0; color: var(--muted); }
455
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
456
+ button { font: inherit; border: 1px solid var(--line); background: var(--chip); color: var(--fg); padding: 6px 12px; border-radius: 999px; cursor: pointer; }
457
+ button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--bg); }
458
+ table { width: 100%; border-collapse: collapse; }
459
+ td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
460
+ th { color: var(--muted); font-weight: 500; }
461
+ code { font: 12px/1.4 ui-monospace, Menlo, monospace; }
462
+ select { font: inherit; background: var(--chip); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; }
463
+ .bad { color: var(--bad); }
464
+ details pre { margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; font: 12px/1.4 ui-monospace, Menlo, monospace; background: var(--chip); padding: 8px; border-radius: 6px; max-height: 260px; overflow: auto; }
465
+ .scroll { overflow-x: auto; }
466
+ </style>
467
+ </head>
468
+ <body>
469
+ <main>
470
+ <h1>codz mock</h1>
471
+ <p id="help">Switch how the mock answers, then use the app.</p>
472
+ <div class="chips" id="scenarios"></div>
473
+ <h2>Per endpoint</h2>
474
+ <div class="scroll"><table><thead><tr><th>Endpoint</th><th>Answers with</th></tr></thead><tbody id="routes"></tbody></table></div>
475
+ <h2>Requests <button id="reset" title="Starting data, normal answers, empty log">Reset</button></h2>
476
+ <div class="scroll"><table><thead><tr><th>Time</th><th>Request</th><th>Status</th><th>ms</th></tr></thead><tbody id="log"></tbody></table></div>
477
+ </main>
478
+ <script>
479
+ const $ = (id) => document.getElementById(id);
480
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
481
+ let state = null;
482
+ let open = new Set();
483
+ async function post(path, body) {
484
+ const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
485
+ state = await r.json();
486
+ render();
487
+ }
488
+ function render() {
489
+ if (!state || !state.scenarios) return;
490
+ $('scenarios').innerHTML = state.scenarios.map((s) => '<button data-s="' + s.id + '" aria-pressed="' + (s.id === state.scenario) + '" title="' + esc(s.help) + '">' + s.id + '</button>').join('');
491
+ const cur = state.scenarios.find((s) => s.id === state.scenario);
492
+ $('help').textContent = 'Every endpoint: ' + state.scenario + ' (' + (cur ? cur.help : '') + '). Endpoints set differently below win.';
493
+ $('routes').innerHTML = state.routes.map((r) => '<tr><td><code>' + esc(r.route) + '</code></td><td><select data-r="' + esc(r.route) + '">' + state.scenarios.map((s) => '<option' + (s.id === r.scenario ? ' selected' : '') + '>' + s.id + '</option>').join('') + '</select></td></tr>').join('');
494
+ $('log').innerHTML = state.log.map((e) => {
495
+ const body = (e.request !== undefined ? 'Request:\\n' + JSON.stringify(e.request, null, 2) + '\\n\\n' : '') + (e.response !== undefined ? 'Response:\\n' + JSON.stringify(e.response, null, 2) : '');
496
+ const label = esc(e.method + ' ' + e.path);
497
+ return '<tr><td>' + new Date(e.time).toLocaleTimeString() + '</td><td>' + (body ? '<details data-id="' + e.id + '"' + (open.has(String(e.id)) ? ' open' : '') + '><summary><code>' + label + '</code></summary><pre>' + esc(body) + '</pre></details>' : '<code>' + label + '</code>') + '</td><td class="' + (e.status >= 400 || e.status === 0 ? 'bad' : '') + '">' + (e.status || 'dropped') + (e.scenario !== 'normal' ? ' <small>(' + e.scenario + ')</small>' : '') + '</td><td>' + e.ms + '</td></tr>';
498
+ }).join('') || '<tr><td colspan="4"><p>No requests yet. Use the app.</p></td></tr>';
499
+ }
500
+ document.addEventListener('click', (ev) => {
501
+ const b = ev.target.closest('button[data-s]');
502
+ if (b) post('/__codz/scenario', { scenario: b.dataset.s });
503
+ if (ev.target.id === 'reset') post('/__codz/reset');
504
+ });
505
+ document.addEventListener('change', (ev) => { if (ev.target.dataset.r) post('/__codz/scenario', { scenario: ev.target.value, route: ev.target.dataset.r }); });
506
+ document.addEventListener('toggle', (ev) => { const d = ev.target; if (d.dataset && d.dataset.id) d.open ? open.add(d.dataset.id) : open.delete(d.dataset.id); }, true);
507
+ async function poll() { try { state = await (await fetch('/__codz/state')).json(); render(); } catch {} setTimeout(poll, 1000); }
508
+ poll();
509
+ </script>
510
+ </body>
511
+ </html>
512
+ `;
@@ -0,0 +1,100 @@
1
+ // codz model <Name>: a model and its nested models from a JSON sample, with no
2
+ // endpoint. The adapter writes models exactly as it does for api add (same
3
+ // folder, naming, serialization style); only the model files are kept.
4
+ import { posix } from 'node:path';
5
+ import { loadMap } from "../core/cache.js";
6
+ import { buildChangeSet } from "../core/changeset.js";
7
+ import { loadConfig } from "../core/config.js";
8
+ import { groupNewModels, modelLangOf } from "../core/modelGroup.js";
9
+ import { ToolError } from "../core/types.js";
10
+ import { buildEndpoint } from "../input/endpoint.js";
11
+ import { typeName } from "../input/naming.js";
12
+ /** JSON from pasted text: tolerates a "response:" label, code fences, and text around one JSON value. */
13
+ export function parseJsonText(text, from) {
14
+ let t = text.trim().replace(/^```\w*\s*/, '').replace(/```\s*$/, '').replace(/^response\s*:\s*/i, '').trim();
15
+ if (!t)
16
+ throw new ToolError('no_sample', `${from} is empty.`, 'Copy the JSON (a response, or one object), then run this again.');
17
+ try {
18
+ return JSON.parse(t);
19
+ }
20
+ catch {
21
+ const a = t.search(/[[{]/);
22
+ const b = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'));
23
+ if (a >= 0 && b > a) {
24
+ t = t.slice(a, b + 1);
25
+ try {
26
+ return JSON.parse(t);
27
+ }
28
+ catch (e) {
29
+ throw new ToolError('bad_sample', `${from} is not valid JSON: ${e.message}`);
30
+ }
31
+ }
32
+ throw new ToolError('bad_sample', `${from} does not contain JSON.`, 'Copy a JSON object like {"id": 1, "name": "Lamp"}.');
33
+ }
34
+ }
35
+ /** A --model-dir value: a folder inside the project, written with forward slashes. */
36
+ export function normalizeModelDir(dir, adapterId) {
37
+ const d = posix.normalize(dir.replace(/\\/g, '/')).replace(/^\.\//, '').replace(/\/+$/, '');
38
+ if (!d || d === '.' || d.startsWith('../') || d === '..' || posix.isAbsolute(d) || /^[A-Za-z]:/.test(d))
39
+ throw new ToolError('bad_args', `--model-dir must be a folder inside the project, relative to it (got "${dir}").`, 'Example: --model-dir lib/features/orders/models');
40
+ if (adapterId === 'flutter' && !d.startsWith('lib/'))
41
+ throw new ToolError('bad_args', `Dart models must be under lib/ (got "${d}").`);
42
+ if (adapterId === 'android' && !/\/(java|kotlin)\//.test(`/${d}/`))
43
+ throw new ToolError('bad_args', `Kotlin models must be in a source folder, like app/src/main/java/com/acme/app/feature/orders/model (got "${d}").`);
44
+ return d;
45
+ }
46
+ export async function planModel(root, adapter, name, sample, opts = {}) {
47
+ if (!adapter.planEndpoint)
48
+ throw new ToolError('not_supported', `model needs a framework adapter; this project uses the ${adapter.id} adapter.`);
49
+ if (!/^[A-Za-z][\w ]*$/.test(name))
50
+ throw new ToolError('bad_args', `"${name}" is not a model name.`, 'Example: codz model Order --from-clipboard');
51
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
52
+ const modelName = typeName(name);
53
+ const suffix = adapter.id === 'flutter' ? map.conventions.modelClassSuffix ?? '' : '';
54
+ const existing = map.models.find((m) => m.name === modelName || m.name === `${modelName}${suffix}`);
55
+ if (existing && !opts.force)
56
+ throw new ToolError('model_exists', `${existing.name} already exists (${existing.file}).`, `Add fields with codz field add ${existing.name} <name:type>, or pass --force to replace it from this JSON.`);
57
+ // A stand-in GET endpoint carries the sample; its function is dropped below.
58
+ const spec = buildEndpoint({ method: 'GET', url: new URL('https://api.invalid/codz-model-only'), responseSamples: [sample], name: 'codzModelOnly', modelName, unwrap: opts.noUnwrap ? 'never' : 'auto' });
59
+ // The model is what is inside {"data": ...}; the stand-in endpoint returns it as is (Retrofit refuses envelopes).
60
+ const unwrapped = spec.unwrapData;
61
+ spec.unwrapData = false;
62
+ spec.warnings = spec.warnings.filter((w) => !/wrapped in \{"data"/.test(w));
63
+ if (!spec.models.length)
64
+ throw new ToolError('bad_sample', 'The JSON has no object in it, so there is no model to write.', 'Copy an object like {"id": 1, "name": "Lamp"}, or a list of them.');
65
+ // iOS writes methods into an APIClient extension and wants one to exist; the stand-in is dropped with the method.
66
+ const overlay = adapter.id === 'ios' && !map.conventions.networkFile ? new Map([['codz-model-only/APIClient.swift', '']]) : undefined;
67
+ const edits = await adapter.planEndpoint({ root, map: withoutEndpoint(map) }, spec, overlay, { replaceModels: opts.force });
68
+ const keep = modelFiles(edits.models?.map((m) => m.file) ?? [], map, [...edits.files.keys()]);
69
+ let files = new Map([...edits.files].filter(([f]) => keep.has(f)));
70
+ if (!files.size)
71
+ throw new ToolError('not_supported', 'codz could not tell which files hold the models; nothing was written.');
72
+ // One file: the main model with its nested models below it (--split keeps one file each).
73
+ const rootName = suffix && !modelName.endsWith(suffix) ? `${modelName}${suffix}` : modelName;
74
+ const mainFile = edits.models?.find((m) => m.name === rootName)?.file ?? edits.models?.find((m) => m.name === modelName)?.file;
75
+ if (mainFile && files.has(mainFile))
76
+ files = groupNewModels(root, files, map, modelLangOf(adapter.id), opts.split);
77
+ const names = spec.models.map((m) => (suffix && !m.name.endsWith(suffix) ? `${m.name}${suffix}` : m.name));
78
+ const summary = [`Add model${names.length === 1 ? '' : 's'} ${names.join(', ')}`, ...[...files.keys()].map((f) => ` ${f}`)];
79
+ const warnings = edits.warnings.filter((w) => !/codzModelOnly|codz_model_only|codz-model-only|build_runner|response type|base URL|api\.invalid|APIClient/i.test(w)).map((w) => w.replace(/^Response: /, '').replace(/^root\b/, modelName));
80
+ if (unwrapped)
81
+ warnings.unshift('The JSON is wrapped in {"data": ...}, so the model is the inner object. Use --no-unwrap for the whole JSON.');
82
+ if (adapter.id === 'flutter' && [...files.values()].some((t) => /part\s+'[^']+\.(g|freezed)\.dart'/.test(t)))
83
+ warnings.push('Run dart run build_runner build to generate the .g.dart / .freezed.dart files.');
84
+ return buildChangeSet(root, 'model', { files, summary, warnings, generated: [], rewrites: opts.force ? [...files.keys()] : [] });
85
+ }
86
+ function withoutEndpoint(map) {
87
+ return { ...map, endpoints: map.endpoints.filter((e) => e.name !== 'codzModelOnly') };
88
+ }
89
+ /** The model files of a plan: the files of its models, and anything else in the models folder (an index file). */
90
+ function modelFiles(modelFilesPlanned, map, changed) {
91
+ const c = map.conventions;
92
+ const keep = new Set(modelFilesPlanned);
93
+ for (const f of changed) {
94
+ if (c.modelsFolder && f.startsWith(`${c.modelsFolder}/`))
95
+ keep.add(f);
96
+ if (c.modelsFile && f === c.modelsFile)
97
+ keep.add(f);
98
+ }
99
+ return keep;
100
+ }