codz-cli 0.1.3 → 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.
@@ -29,6 +29,8 @@ export const FLAGS = {
29
29
  url: { value: "'<url>'", desc: 'A URL (or "POST https://...")' },
30
30
  method: { value: '<GET|POST|...>', desc: 'HTTP method (api add), or only these methods (api import)', choices: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
31
31
  clipboard: { desc: 'Read the cURL command (and response) from the clipboard' },
32
+ 'from-clipboard': { desc: 'Read the JSON from the clipboard' },
33
+ split: { desc: 'One file per model, instead of nested models in the file of the model that uses them' },
32
34
  sample: { value: '<file.json>', desc: 'Response sample; repeat for several samples', repeat: true, path: true },
33
35
  'request-sample': { value: '<file.json>', desc: 'Request body sample', path: true },
34
36
  name: { value: '<functionName>', desc: 'Override the derived function name' },
@@ -56,6 +58,12 @@ export const FLAGS = {
56
58
  spec: { value: '<file>', desc: 'OpenAPI, Swagger, Postman or HAR file to take the endpoints from', path: true },
57
59
  'no-forms': { desc: 'No create or edit form screens' },
58
60
  'no-tests': { desc: 'Do not generate tests' },
61
+ // mock serve
62
+ port: { value: '<n>', desc: 'Port for the mock API (default 4010)' },
63
+ delay: { value: '<ms>', desc: 'Wait before each answer, so loading states show (default 300)' },
64
+ items: { value: '<n>', desc: 'Items in each list to start with (default 5)' },
65
+ lan: { desc: 'Listen on your network too, for a phone on the same Wi-Fi (default: this computer only)' },
66
+ scenario: { value: '<name>', desc: 'How the mock answers at the start; switch live at /__codz', choices: ['normal', 'empty', 'error', 'unauthorized', 'slow', 'offline'] },
59
67
  // api diff
60
68
  format: { value: '<text|md|json|sarif>', desc: 'Report format (default text; md for a pull request comment, sarif for code scanning)', choices: ['text', 'md', 'json', 'sarif'] },
61
69
  report: { value: '<file>', desc: 'Write the report to a file', path: true },
@@ -108,6 +116,9 @@ export const FLAGS = {
108
116
  refresh: { desc: 'Download the catalog again even if it has not changed' },
109
117
  clear: { desc: 'Delete every downloaded starter' },
110
118
  'no-browser': { desc: 'Do not open the login page; print it only' },
119
+ paginate: { desc: 'List screens: load page after page as you scroll (the endpoint needs a page or offset parameter)' },
120
+ search: { desc: 'List screens: a search box (on the server with a q/search parameter, otherwise over the loaded items)' },
121
+ 'model-dir': { value: '<folder>', desc: 'Write new models into this folder for this command only (e.g. lib/features/orders/models); set modelsFolder in codz.config.yaml to change it for good', path: true },
111
122
  };
112
123
  export const GLOBAL_FLAGS = ['json', 'cwd', 'root', 'no-color', 'help', 'h', 'version', 'v'];
113
124
  export const CHANGE_FLAGS = ['dry-run', 'plan', 'yes', 'y', 'allow-dirty', 'no-format', 'no-verify', 'commit', 'commit-message', 'open'];
@@ -116,9 +127,9 @@ export const COMMAND_LIST = [
116
127
  { group: 'Start', cmd: 'init', usage: '[--root <folder>] [--force]', summary: 'Detect the framework, scan, write codz.config.yaml; offers to set up an API layer when there is none', flags: ['force', 'architecture', 'base-url', 'yes', 'y'] },
117
128
  { group: 'Start', cmd: 'setup', usage: '[--architecture <id>] [--base-url <url>]', summary: 'Create the API layer for a fresh project', flags: ['architecture', 'base-url'], change: true },
118
129
  { group: 'Start', cmd: 'explain', usage: '[--rescan]', summary: 'Show routes, endpoints, models, locales and conventions', flags: ['rescan'] },
119
- { group: 'Endpoints', cmd: 'api', sub: 'add', usage: '[input] [options]', summary: 'Add an endpoint: model, function and registration', flags: ['curl', 'url', 'method', 'clipboard', 'sample', 'request-sample', 'name', 'path', 'model-name', 'no-unwrap', 'into', 'no-load'], change: true, notes: ['Input: --curl, --url, --clipboard, or a cURL command or URL pasted on stdin, optionally followed by "response:" and JSON (and "request:" and JSON). A response sample is strongly recommended.'] },
120
- { group: 'Endpoints', cmd: 'api', sub: 'import', usage: '<file> [filters]', summary: 'Add every endpoint from an OpenAPI 3, Swagger 2, Postman or HAR file', flags: ['tag', 'only', 'path', 'method', 'max', 'list', 'base-url', 'include-errors', 'skip-conflicts'], change: true, args: 'file' },
121
- { group: 'Endpoints', cmd: 'api', sub: 'sync', usage: '<file> [--add] [--remove] [--check]', summary: 'Bring endpoints in line with an API spec: update changed ones, report breaking changes', flags: ['add', 'remove', 'check', 'tag', 'path', 'base-url'], change: true, args: 'file' },
130
+ { group: 'Endpoints', cmd: 'api', sub: 'add', usage: '[input] [options]', summary: 'Add an endpoint: model, function and registration', flags: ['curl', 'url', 'method', 'clipboard', 'sample', 'request-sample', 'name', 'path', 'model-name', 'no-unwrap', 'into', 'no-load', 'model-dir', 'split'], change: true, notes: ['Input: --curl, --url, --clipboard, or a cURL command or URL pasted on stdin, optionally followed by "response:" and JSON (and "request:" and JSON). A response sample is strongly recommended.'] },
131
+ { group: 'Endpoints', cmd: 'api', sub: 'import', usage: '<file> [filters]', summary: 'Add every endpoint from an OpenAPI 3, Swagger 2, Postman or HAR file', flags: ['tag', 'only', 'path', 'method', 'max', 'list', 'base-url', 'include-errors', 'skip-conflicts', 'model-dir', 'split'], change: true, args: 'file' },
132
+ { group: 'Endpoints', cmd: 'api', sub: 'sync', usage: '<file> [--add] [--remove] [--check]', summary: 'Bring endpoints in line with an API spec: update changed ones, report breaking changes', flags: ['add', 'remove', 'check', 'tag', 'path', 'base-url', 'model-dir', 'split'], change: true, args: 'file' },
122
133
  { group: 'Endpoints', cmd: 'api', sub: 'diff', usage: '<old spec> <new spec> [--format md] [--fail-on breaking]', summary: 'What changed between two API specs, what breaks, and the lines in this project that use it', flags: ['format', 'report', 'fail-on', 'tag', 'path'], notes: ['Breaking: an operation removed, a response field removed or retyped, a new path parameter, a new required request field. Warning: a field that can now be null, a removed query parameter, a retyped request field.', 'Inside a project each change lists the files and lines that use it. Works outside a project too.', 'CI: codz api diff main.yaml pr.yaml --format md --report api-diff.md (exit 1 on breaking changes); --format sarif for GitHub code scanning.'] },
123
134
  { group: 'Endpoints', cmd: 'api', sub: 'update', usage: '<endpoint> --sample <file>', summary: 'Rebuild the response model from new samples', flags: ['sample', 'no-unwrap'], change: true, args: 'endpoint' },
124
135
  { group: 'Endpoints', cmd: 'api', sub: 'remove', usage: '<endpoint>', summary: 'Remove an endpoint and list callers that break', flags: [], change: true, args: 'endpoint' },
@@ -126,14 +137,23 @@ export const COMMAND_LIST = [
126
137
  { group: 'Endpoints', cmd: 'api', sub: 'wire', usage: '<endpoint> --into <module>', summary: "Call an endpoint from a module's controller (GetX)", flags: ['into', 'no-load'], change: true, args: 'endpoint' },
127
138
  { group: 'Endpoints', cmd: 'api', sub: 'list', usage: '[filter]', summary: 'Every endpoint: method, path, function, response, file', flags: [] },
128
139
  { group: 'Endpoints', cmd: 'api', sub: 'show', usage: '<endpoint>', summary: 'One endpoint: signature, models, every place that uses it', flags: [], args: 'endpoint' },
140
+ { group: 'Screens and app', cmd: 'assets', usage: '', summary: 'Typed names for images, icons and fonts, kept in step with the files; lists unused ones', flags: [], change: true, notes: ['Flutter: lib/gen/assets.dart (AppImages.logo, AppFonts.inter) and every asset folder and font family in pubspec.yaml. React Native: src/assets/index.ts with require() for each file. Next.js and Vite: paths for public/ (and imports for src/assets in Vite). Android and iOS already have typed resources; codz checks names and lists unused drawables.', 'Run it again after adding or removing files.'] },
141
+ { group: 'Screens and app', cmd: 'env', sub: 'add', usage: '<name> --base-url <url>', summary: 'Add an API environment (staging, qa, ...) the app can run against', flags: ['base-url', 'force'], change: true, notes: ['Next.js and Expo: .env.<name> plus npm scripts that load it (dotenv-cli). Vite: .env.<name> and --mode <name>. React Native CLI: an API_URLS table in env.ts. Flutter: env/<name>.json for --dart-define-from-file, and a VS Code run configuration. Android: an apiUrls table in the app build file, picked with -PapiEnv=<name> into BuildConfig.BASE_URL. iOS: an AppEnvironment enum, picked with APP_ENV in the scheme.', 'The base URL you have now becomes the default environment. Never writes .env.local.'] },
142
+ { group: 'Screens and app', cmd: 'env', sub: 'list', usage: '', summary: 'The API environments and their base URLs', flags: [] },
143
+ { group: 'Screens and app', cmd: 'env', sub: 'use', usage: '<name>', summary: 'Switch the app to an environment (React Native CLI, Android, iOS)', flags: [], change: true },
144
+ { group: 'Endpoints', cmd: 'model', usage: '<Name> --from-clipboard | --sample <file.json>', summary: 'A model (and its nested models) from JSON, without an endpoint', flags: ['from-clipboard', 'sample', 'model-dir', 'no-unwrap', 'force', 'split'], change: true, notes: ['Reads the JSON from --from-clipboard, --sample, or standard input (pipe or paste, then Ctrl+D). Nested objects become their own classes (Order → OrderItem, OrderAddress), written in the same file as the main model; --split gives each its own file. A list gives the model of its items; {"data": ...} is unwrapped unless --no-unwrap.', 'Writes into the models folder, or --model-dir <folder> for this model only. An existing model with the same name is replaced only with --force.'] },
145
+ { group: 'Endpoints', cmd: 'field', sub: 'add', usage: '<Model> <name:type> [...]', summary: 'Add a field to a model, in every place the model spells it', flags: [], change: true, notes: ['Types: string, int, double, bool, date, any, a model name, or a list (string[], list<Category>). End with ? for an optional field: rating:double?. The name is the JSON key (created_at); codz writes the property name the project uses (createdAt).', 'TypeScript interfaces, Dart classes (plain, json_serializable, freezed: constructor, fromJson, field and toJson), Kotlin data classes and Swift Codable structs (CodingKeys too). Only the new lines are added; then it lists the files that use the model.'] },
146
+ { group: 'Endpoints', cmd: 'field', sub: 'remove', usage: '<Model> <field> [...]', summary: 'Remove a field from a model and list the lines that still use it', flags: [], change: true },
147
+ { group: 'Endpoints', cmd: 'mock', sub: 'serve', usage: '[--spec <file>] [--port 4010] [--delay 300] [--items 5] [--scenario normal] [--lan]', summary: 'A local fake API with sample data, so screens show data before the backend exists', flags: ['spec', 'port', 'delay', 'items', 'scenario', 'lan'], notes: ["Answers the project's endpoints, or every operation in --spec (OpenAPI, Swagger, Postman, HAR), with sample data from the response models. A list and its items share in-memory data, so create, edit and delete work and the list shows the change; restarting resets it. Paths match by their last segments, so /v1/products reaches GET /products. Prints how to point the app at it (emulator, simulator, phone).", 'Scenarios check every screen state: empty (empty lists), error (500), unauthorized (401), slow (3 s), offline (connection drops). Open http://localhost:4010/__codz to switch them, for every endpoint or one, reset the data, and see each request with its body.'] },
129
148
  { group: 'Endpoints', cmd: 'mock', sub: 'add', usage: '<endpoint>... | --all', summary: 'Mock responses for building screens before the backend', flags: ['all', 'sample', 'keep-values', 'force'], change: true, args: 'endpoints', notes: ['Next.js, React Native and Flutter. Not yet on Android.'] },
130
149
  { group: 'Endpoints', cmd: 'test', sub: 'add', usage: '<endpoint>... | --all', summary: 'Unit tests for API functions with the network faked', flags: ['all'], change: true, args: 'endpoints', notes: ['Next.js: Vitest or Jest. React Native: Jest. Flutter: flutter_test with a fake Dio adapter. Android: JUnit with MockWebServer (adds the test dependencies).'] },
131
- { group: 'Screens and app', cmd: 'screen', sub: 'add', usage: '<name> --api <endpoint> [--path /x/:id] [--form]', summary: 'A working screen (route, page, the call, loading, error, data), or a form with --form', flags: ['api', 'path', 'initial', 'form'], change: true, notes: ['Android: a Compose screen plus a ViewModel (Hilt, Koin or the service locator) with a UiState StateFlow, registered in the NavHost.'] },
150
+ { group: 'Screens and app', cmd: 'screen', sub: 'add', usage: '<name> --api <endpoint> [--path /x/:id] [--paginate] [--search] [--form]', summary: 'A working screen (route, page, the call, loading, error, data), or a form with --form', flags: ['api', 'path', 'initial', 'form', 'paginate', 'search'], change: true, notes: ['Android: a Compose screen plus a ViewModel (Hilt, Koin or the service locator) with a UiState StateFlow, registered in the NavHost.'] },
132
151
  { group: 'Screens and app', cmd: 'route', sub: 'add', usage: '<name> [--path /x/:id]', summary: 'Add a screen and its route (GetX module, GoRoute, Next.js page, React Navigation or Expo Router screen, Compose NavHost entry)', flags: ['path', 'initial'], change: true },
133
- { group: 'Screens and app', cmd: 'feature', sub: 'add', usage: '<name> --spec <file> | --api <endpoint>...', summary: 'A whole feature in one change: endpoints, list, detail, create and edit screens, routes and tests', flags: ['spec', 'tag', 'path', 'api', 'base-url', 'architecture', 'initial', 'no-forms', 'no-tests', 'skip-conflicts', 'no-install'], change: true, notes: ['A project with no API layer yet is set up first, in the same change: the recommended architecture (or --architecture), with the base URL from --base-url or the spec.', 'Without --tag or --path, the endpoints are those whose path or tag names the feature (orders: /orders, /orders/{id}, /users/{id}/orders).', 'Screens: a list for a GET that returns a list, a detail for a GET with an id, a create form for a POST with a body, an edit form for a PUT or PATCH with a body. Other endpoints are added and listed.', 'Everything is planned in a private copy of the project and shown as one diff: one apply, one undo.'] },
152
+ { group: 'Screens and app', cmd: 'feature', sub: 'add', usage: '<name> --spec <file> | --api <endpoint>...', summary: 'A whole feature in one change: endpoints, list, detail, create and edit screens, routes and tests', flags: ['spec', 'tag', 'path', 'api', 'base-url', 'architecture', 'initial', 'no-forms', 'no-tests', 'skip-conflicts', 'no-install', 'model-dir', 'split'], change: true, notes: ['A project with no API layer yet is set up first, in the same change: the recommended architecture (or --architecture), with the base URL from --base-url or the spec.', 'Without --tag or --path, the endpoints are those whose path or tag names the feature (orders: /orders, /orders/{id}, /users/{id}/orders).', 'Screens: a list for a GET that returns a list, a detail for a GET with an id, a create form for a POST with a body, an edit form for a PUT or PATCH with a body. Other endpoints are added and listed.', 'Everything is planned in a private copy of the project and shown as one diff: one apply, one undo.'] },
134
153
  { group: 'Screens and app', cmd: 'auth', sub: 'add', usage: '[--login-path /auth/login] [--me-path /auth/me]', summary: 'Sign-in for this project: secure token storage, the token on every request, sign out on 401, a login screen and a guard', flags: ['login-path', 'me-path', 'logout-path', 'refresh-path', 'token-field', 'refresh-field', 'public'], change: true, notes: ['Next.js: server actions, an HttpOnly session cookie, middleware that sends signed-out visitors to /login, withAuth() for server calls.', 'React Native: expo-secure-store (or react-native-keychain), the fetch helper or axios sends the token, AuthProvider and useAuth().', 'Flutter: flutter_secure_storage, a Dio interceptor (refresh or sign out on 401), AuthService, a go_router redirect.', 'Android (Hilt): EncryptedSharedPreferences, an OkHttp interceptor, AuthApi, AuthRepository, a login screen with a ViewModel.'] },
135
154
  { group: 'Screens and app', cmd: 'module', sub: 'add', usage: '<name> [--api <endpoint>]', summary: 'Create a GetX module: binding, controller, view, route', flags: ['api', 'initial', 'no-load'], change: true },
136
155
  { group: 'Screens and app', cmd: 'locale', sub: 'add', usage: '<lang> [--from en]', summary: 'Add a language: copies every key from the default one', flags: ['from'], change: true },
156
+ { group: 'Screens and app', cmd: 'locale', sub: 'extract', usage: '[files...]', summary: 'Move the text in screens into the locale files and read it back', flags: [], change: true, args: 'file', notes: ['Finds Text(...) and hint, label and tooltip text (Flutter), JSX text and placeholder, title, alt and aria-label (React, Next.js, React Native), and Text("...") in Compose. Writes AppLocalizations.of(context)!.key (ARB, wiring it into MaterialApp the first time), \'key\'.tr (GetX), t(\'key\') with next-intl or react-i18next, or stringResource(R.string.key) with strings.xml.', 'Keys are named after the screen (products.nothingHereYet); text in several screens goes under common, and text that already has a key reuses it. Without files, every screen.'] },
137
157
  { group: 'Screens and app', cmd: 'locale', sub: 'key', usage: '<key> "<text>" [--value hi="..."]', summary: 'Add a text key to every language file', flags: ['value', 'force'], change: true },
138
158
  { group: 'Screens and app', cmd: 'theme', sub: 'set', usage: '<name> <value> [...]', summary: 'Set a color (#hex), size (number) or font in the app theme', flags: [], change: true, args: 'theme', notes: ['Example: codz theme set primary "#6750A4" radius 12 font Inter'] },
139
159
  { group: 'Screens and app', cmd: 'templates', sub: 'list', usage: '', summary: 'Which templates the project customizes', flags: [] },
@@ -145,7 +165,6 @@ export const COMMAND_LIST = [
145
165
  { group: 'Setup', cmd: 'config', sub: 'get', usage: '<key>', summary: 'One convention', flags: [], args: 'configKey' },
146
166
  { group: 'Setup', cmd: 'config', sub: 'set', usage: '<key> <value>', summary: "Change one convention in codz.config.yaml ('' = detect)", flags: ['force'], change: true, args: 'configKey' },
147
167
  { group: 'Setup', cmd: 'config', sub: 'check', usage: '', summary: 'Find problems in codz.config.yaml (exit 1 on errors)', flags: [] },
148
- { group: 'Setup', cmd: 'mcp', usage: '[--cwd <project>]', summary: 'Serve codz to AI agents and editors over the Model Context Protocol (stdio)', flags: [], notes: ['Every command becomes a tool (api add → api_add). Tools that edit code return a diff and a plan id; the apply tool applies it. Resources: codz://project and codz://commands.'] },
149
168
  { group: 'Setup', cmd: 'completion', usage: 'bash|zsh|fish', summary: 'Print the shell completion script', flags: [], args: 'shell' },
150
169
  { group: 'Starters (online)', cmd: 'new', usage: '[name] --template <id[@version]|folder>', summary: 'Create a project from a ready-made starter: from the catalog (free or bought) or a template folder', flags: ['template', 'org', 'dir', 'set', 'framework', 'state', 'router', 'http', 'cached', 'no-steps', 'dry-run', 'plan', 'yes', 'y'], notes: ['Shows every file and step first, then creates the folder in one step; codz undo inside the new project removes it again while it is unchanged. Catalog starters are checked against the signed catalog, and template scripts never run. Next: cd <name> && codz init.', 'This and the other starter commands are the only ones that use the network, and only to reach the starter catalog.'] },
151
170
  { group: 'Starters (online)', cmd: 'starters', sub: 'list', usage: '[--framework flutter] [--free] [--owned]', summary: 'Starters in the catalog: id, latest version, framework, price, api add support', flags: ['framework', 'state', 'router', 'http', 'owned', 'free', 'refresh', 'cached'] },
@@ -0,0 +1,180 @@
1
+ // One file per top-level model: nested models a change creates go into the
2
+ // file of the model that uses them (order.dart holds Order, Customer,
3
+ // OrderItem), instead of one file each. A nested model that several top-level
4
+ // models use keeps its own file. Only files the change creates are touched;
5
+ // imports of the merged files in the other planned files point at the main
6
+ // file instead.
7
+ //
8
+ // Turn it off with --split, or modelFiles: split in codz.config.yaml.
9
+ import { join, posix } from 'node:path';
10
+ import { exists } from "./fsutil.js";
11
+ export const modelLangOf = (adapterId) => (adapterId === 'flutter' ? 'dart' : adapterId === 'android' ? 'kt' : adapterId === 'ios' ? 'swift' : 'ts');
12
+ const EXT = { ts: /\.tsx?$/, dart: /\.dart$/, kt: /\.kt$/, swift: /\.swift$/ };
13
+ const DECL = {
14
+ ts: /^export\s+(?:interface|type)\s+(\w+)/gm,
15
+ dart: /^(?:abstract\s+|sealed\s+|final\s+)*class\s+(\w+)/gm,
16
+ kt: /^(?:@\w+(?:\([^)]*\))?\s*)*data\s+class\s+(\w+)/gm,
17
+ swift: /^(?:public\s+)?struct\s+(\w+)/gm,
18
+ };
19
+ /** The file's leading package/import/part lines, and the rest. */
20
+ function splitHeader(text, lang) {
21
+ const lines = text.split('\n');
22
+ const isHead = (l) => (lang === 'ts' ? /^import\s/.test(l) : lang === 'dart' ? /^(import|part|export|library)\s/.test(l) : lang === 'kt' ? /^(package|import)\s/.test(l) : /^import\s/.test(l));
23
+ let i = 0;
24
+ const header = [];
25
+ while (i < lines.length && (isHead(lines[i]) || lines[i].trim() === '')) {
26
+ if (lines[i].trim())
27
+ header.push(lines[i]);
28
+ i++;
29
+ }
30
+ return { header, body: lines.slice(i).join('\n').replace(/\s+$/, '') };
31
+ }
32
+ const stem = (f) => posix.basename(f).replace(/\.[^.]+$/, '');
33
+ const importTarget = (l) => /['"]([^'"]+)['"]/.exec(l)?.[1];
34
+ const targetStem = (t) => posix.basename(t).replace(/\.(dart|ts|tsx|js)$/, '').replace(/\.(g|freezed)$/, '');
35
+ /** Is this a file of models the project keeps models in? */
36
+ function inModelsFolder(f, map) {
37
+ const c = map.conventions;
38
+ return Boolean((c.modelsFolder && f.startsWith(`${c.modelsFolder}/`)) || (c.modelsFile && f === c.modelsFile));
39
+ }
40
+ /**
41
+ * Groups the new model files of a planned change (--model-dir has already set
42
+ * modelsFolder for this run).
43
+ */
44
+ export function groupNewModels(root, files, map, lang, split = false) {
45
+ if (split || map.conventions.modelFiles === 'split' || (lang === 'ts' && map.conventions.modelsLayout === 'single'))
46
+ return files;
47
+ // New model files and the models each declares.
48
+ const declared = new Map();
49
+ for (const [f, t] of files) {
50
+ if (!EXT[lang].test(f) || /(^|\/)index\.ts$/.test(f) || exists(join(root, f)) || !inModelsFolder(f, map))
51
+ continue;
52
+ const names = [...t.matchAll(DECL[lang])].map((m) => m[1]).filter((n) => !n.startsWith('_'));
53
+ if (names.length)
54
+ declared.set(f, names);
55
+ }
56
+ if (declared.size < 2)
57
+ return files;
58
+ const fileOf = new Map();
59
+ for (const [f, ns] of declared)
60
+ for (const n of ns)
61
+ fileOf.set(n, f);
62
+ // Which new files each new file uses.
63
+ const uses = new Map();
64
+ for (const f of declared.keys()) {
65
+ const body = splitHeader(files.get(f), lang).body;
66
+ const own = new Set(declared.get(f));
67
+ const used = new Set();
68
+ for (const [n, g] of fileOf)
69
+ if (g !== f && !own.has(n) && new RegExp(`\\b${n}\\b`).test(body))
70
+ used.add(g);
71
+ uses.set(f, used);
72
+ }
73
+ const usedBy = new Map();
74
+ for (const [f, us] of uses)
75
+ for (const u of us)
76
+ usedBy.set(u, (usedBy.get(u) ?? new Set()).add(f));
77
+ // Top-level: new files no other new file uses.
78
+ const tops = [...declared.keys()].filter((f) => !usedBy.has(f));
79
+ const reach = new Map();
80
+ for (const t of tops) {
81
+ const seen = new Set();
82
+ const stack = [...(uses.get(t) ?? [])];
83
+ while (stack.length) {
84
+ const f = stack.pop();
85
+ if (seen.has(f) || tops.includes(f))
86
+ continue;
87
+ seen.add(f);
88
+ stack.push(...(uses.get(f) ?? []));
89
+ }
90
+ for (const f of seen)
91
+ reach.set(f, (reach.get(f) ?? new Set()).add(t));
92
+ }
93
+ // Each nested file reached by exactly one top-level file goes into it.
94
+ const into = new Map();
95
+ for (const [f, owners] of reach)
96
+ if (owners.size === 1)
97
+ into.set(f, [...owners][0]);
98
+ if (!into.size)
99
+ return files;
100
+ const groups = new Map();
101
+ for (const [f, main] of into)
102
+ groups.set(main, [...(groups.get(main) ?? []), f]);
103
+ // Keep the order the models were planned in.
104
+ const order = [...files.keys()];
105
+ for (const list of groups.values())
106
+ list.sort((a, b) => order.indexOf(a) - order.indexOf(b));
107
+ const out = new Map(files);
108
+ const mergedStem = new Map(); // merged file stem → main file stem
109
+ for (const [main, nested] of groups) {
110
+ const head = splitHeader(out.get(main), lang);
111
+ const header = [...head.header];
112
+ const bodies = [head.body];
113
+ const nestedStems = new Set(nested.map(stem));
114
+ for (const f of nested) {
115
+ const part = splitHeader(out.get(f), lang);
116
+ for (const l of part.header)
117
+ if (!/^(package|part)\s/.test(l) && !header.includes(l))
118
+ header.push(l);
119
+ bodies.push(part.body);
120
+ out.delete(f);
121
+ mergedStem.set(stem(f), stem(main));
122
+ }
123
+ const kept = header.filter((l) => /^part\s/.test(l) || !nestedStems.has(targetStem(importTarget(l) ?? '')) || !importTarget(l));
124
+ const pkg = kept.filter((l) => /^package\s/.test(l));
125
+ let imports = kept.filter((l) => /^(import|export|library)\s/.test(l));
126
+ if (lang === 'kt')
127
+ imports = [...new Set(imports)].sort();
128
+ const parts = kept.filter((l) => /^part\s/.test(l));
129
+ const top = [pkg, imports, parts].filter((g) => g.length).map((g) => g.join('\n')).join('\n\n');
130
+ out.set(main, `${top ? `${top}\n\n` : ''}${bodies.join('\n\n')}\n`);
131
+ }
132
+ // Other planned files: imports of a merged file point at its main file; a models index stops exporting it.
133
+ for (const [f, t] of out) {
134
+ const lines = t.split('\n');
135
+ let changed = false;
136
+ const seen = new Set();
137
+ const next = [];
138
+ for (const l of lines) {
139
+ const target = /^(import|export)\s/.test(l) ? importTarget(l) : undefined;
140
+ const s = target ? targetStem(target) : '';
141
+ if (target && mergedStem.has(s) && !/\.(g|freezed)\.dart$/.test(target)) {
142
+ changed = true;
143
+ if (/(^|\/)index\.ts$/.test(f) && /^export\s+\*/.test(l))
144
+ continue;
145
+ const repl = l.replace(target, target.replace(new RegExp(`${s}(\\.(?:dart|ts|tsx|js))?$`), `${mergedStem.get(s)}$1`));
146
+ const key = repl.replace(/\s+/g, ' ');
147
+ if (lang === 'dart' && seen.has(key))
148
+ continue;
149
+ seen.add(key);
150
+ next.push(repl);
151
+ continue;
152
+ }
153
+ if (target)
154
+ seen.add(l.replace(/\s+/g, ' '));
155
+ next.push(l);
156
+ }
157
+ if (!changed)
158
+ continue;
159
+ // A file that imported two merged files now imports the main file twice.
160
+ const once = new Set();
161
+ out.set(f, next.filter((l) => !/^import\s/.test(l) || (once.has(l.trim()) ? false : (once.add(l.trim()), true))).join('\n'));
162
+ }
163
+ return out;
164
+ }
165
+ /** groupNewModels for a whole plan: generated-code records follow their model into its new file. */
166
+ export function groupPlanned(root, files, generated, map, lang, split = false) {
167
+ const out = groupNewModels(root, files, map, lang, split);
168
+ if (out === files)
169
+ return { files, generated };
170
+ const declares = (t, sym) => new RegExp(`\\b(?:interface|type|class|struct)\\s+${sym}\\b`).test(t);
171
+ return {
172
+ files: out,
173
+ generated: generated.map((g) => {
174
+ if (out.has(g.file) || exists(join(root, g.file)))
175
+ return g;
176
+ const home = [...out].find(([, t]) => declares(t, g.symbol))?.[0];
177
+ return home ? { ...g, file: home } : g;
178
+ }),
179
+ };
180
+ }
@@ -14,6 +14,7 @@ const FRAMEWORKS = ['nextjs', 'flutter', 'react-native', 'react', 'android', 'io
14
14
  /** Allowed values for keys that take one of a few words. Empty is always allowed (= detect). */
15
15
  export const ALLOWED = {
16
16
  nextjs: {
17
+ modelFiles: ['grouped', 'split'],
17
18
  apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
18
19
  modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
19
20
  apiFileResource: ['plural', 'singular'],
@@ -26,6 +27,7 @@ export const ALLOWED = {
26
27
  dataFetching: ['react-query', 'none'],
27
28
  },
28
29
  'react-native': {
30
+ modelFiles: ['grouped', 'split'],
29
31
  apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
30
32
  modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
31
33
  apiFileResource: ['plural', 'singular'],
@@ -40,6 +42,7 @@ export const ALLOWED = {
40
42
  router: ['react-navigation', 'expo-router', 'none'],
41
43
  },
42
44
  react: {
45
+ modelFiles: ['grouped', 'split'],
43
46
  apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
44
47
  modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
45
48
  apiFileResource: ['plural', 'singular'],
@@ -55,11 +58,13 @@ export const ALLOWED = {
55
58
  routerStyle: ['jsx', 'data'],
56
59
  },
57
60
  ios: {
61
+ modelFiles: ['grouped', 'split'],
58
62
  ui: ['swiftui', 'uikit'],
59
63
  fileSync: ['synchronized', 'groups'],
60
64
  indent: ['2', '4'],
61
65
  },
62
66
  android: {
67
+ modelFiles: ['grouped', 'split'],
63
68
  http: ['retrofit', 'ktor', 'none'],
64
69
  serialization: ['kotlinx', 'moshi', 'gson'],
65
70
  di: ['hilt', 'koin', 'manual', 'none'],
@@ -67,6 +72,7 @@ export const ALLOWED = {
67
72
  indent: ['2', '4'],
68
73
  },
69
74
  flutter: {
75
+ modelFiles: ['grouped', 'split'],
70
76
  http: ['dio', 'http'],
71
77
  serialization: ['manual', 'json_serializable', 'freezed'],
72
78
  freezedClass: ['abstract', 'plain'],
@@ -23,6 +23,7 @@ import { camel, kebab, pascal, words } from "../input/naming.js";
23
23
  import { responseShape, titleField } from "./screen.js";
24
24
  import { prefillFor } from "./prefill.js";
25
25
  import { planViewsRoute, planViewsScreen } from "./androidViews.js";
26
+ import { detectPaging, namedPagingArgs, PAGE_SIZE, pagingSummary, pagingWarnings } from "./paging.js";
26
27
  /** A Kotlin `fun name(...)`: its parameters and return type. */
27
28
  export function kotlinFun(text, name) {
28
29
  const m = new RegExp(`((?:^[ \\t]*(?:/\\*\\*(?:(?!\\*/)[^])*\\*/|//[^\\n]*|@\\w+(?:\\([^\\n]*\\))?)[ \\t]*\\n)*)[ \\t]*(?:suspend\\s+)?fun\\s+${name}\\s*\\(`, 'm').exec(text);
@@ -237,7 +238,6 @@ export function planAndroidScreen(root, map, opts) {
237
238
  if (x.read(f) !== null)
238
239
  throw new ToolError('route_exists', `${f} already exists.`);
239
240
  const pkg = packageOf(x.srcDir, screensDir);
240
- const uiState = ensureUiState(x, screensDir);
241
241
  const T = a.returnType;
242
242
  const shape = responseShape(T, map);
243
243
  const model = shape.model;
@@ -248,6 +248,103 @@ export function planAndroidScreen(root, map, opts) {
248
248
  const v = p.name.replace(/`/g, '');
249
249
  return /^(Int|Long)$/.test(p.type) ? `${v}.to${p.type}()` : v;
250
250
  });
251
+ if (opts.paginate || opts.search) {
252
+ const paging = androidPaging(a, shape, opts, T);
253
+ const item = /^List<(.+)>\??$/.exec(T)?.[1] ?? 'Any';
254
+ const routeNamed = required.map((p, i) => `${p.name} = ${args[i]}`);
255
+ x.files.set(vmFile, pagedViewModel(x, a, n.vm, pkg, params, routeNamed, inj, item, paging, modelImports));
256
+ x.summary.push(`Create ${vmFile}: ${n.vm} with items, loading, error${paging.search || paging.clientSearch ? ', query' : ''}${paging.paginate ? ', hasMore' : ''}, calling ${a.iface}.${a.ep.name}()`);
257
+ const tf = titleField(model);
258
+ const searchable = Boolean(paging.search || paging.clientSearch);
259
+ const pg = paging.paginate;
260
+ const reset = pg ? 'reset = true' : '';
261
+ const stringFields = paging.searchFields.map((f) => (model?.fields.find((m) => m.name === f)?.nullable ? `${access('item', f)}.orEmpty()` : access('item', f)));
262
+ const screen = [
263
+ `package ${pkg}`,
264
+ '',
265
+ '@Composable',
266
+ `fun ${n.screen}(viewModel: ${n.vm} = ${inj.screenDefault}) {`,
267
+ ' val s by viewModel.state.collectAsState()',
268
+ ...(pg
269
+ ? [
270
+ ' val listState = rememberLazyListState()',
271
+ ' // Near the end of the list: ask for the next page.',
272
+ ' val nearEnd by remember {',
273
+ ' derivedStateOf {',
274
+ ' val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0',
275
+ ' last >= listState.layoutInfo.totalItemsCount - 3',
276
+ ' }',
277
+ ' }',
278
+ ' LaunchedEffect(nearEnd, s.items.size) {',
279
+ ' if (nearEnd) viewModel.load()',
280
+ ' }',
281
+ ]
282
+ : []),
283
+ ` val shown = ${paging.clientSearch ? `if (s.query.isBlank()) s.items else s.items.filter { item -> listOf(${stringFields.join(', ')}).any { it.contains(s.query.trim(), ignoreCase = true) } }` : 's.items'}`,
284
+ ' val error = s.error',
285
+ ' Scaffold { padding ->',
286
+ ' Column(Modifier.fillMaxSize().padding(padding)) {',
287
+ ...(searchable ? [' OutlinedTextField(', ' value = s.query,', ' onValueChange = viewModel::search,', ' placeholder = { Text("Search") },', ' singleLine = true,', ' modifier = Modifier.fillMaxWidth().padding(12.dp),', ' )'] : []),
288
+ ' Box(Modifier.fillMaxSize()) {',
289
+ ' when {',
290
+ ' s.items.isEmpty() && s.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))',
291
+ ' s.items.isEmpty() && error != null -> Column(Modifier.align(Alignment.Center).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {',
292
+ ' Text(error, color = MaterialTheme.colorScheme.error)',
293
+ ` Button(onClick = { viewModel.load(${reset}) }) { Text("Retry") }`,
294
+ ' }',
295
+ ` shown.isEmpty() && !s.loading -> Text(${searchable ? 'if (s.query.isBlank()) "Nothing here yet." else "No matches."' : '"Nothing here yet."'}, Modifier.align(Alignment.Center))`,
296
+ ` else -> LazyColumn(Modifier.fillMaxSize()${pg ? ', state = listState' : ''}) {`,
297
+ ' items(shown) { item ->',
298
+ ` Text(${tf ? `${access('item', tf)}.toString()` : 'item.toString()'}, Modifier.fillMaxWidth().padding(16.dp))`,
299
+ ' HorizontalDivider()',
300
+ ' }',
301
+ ...(pg
302
+ ? [
303
+ ' if (s.loading) {',
304
+ ' item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } }',
305
+ ' } else if (error != null) {',
306
+ ' item { TextButton(onClick = { viewModel.load() }, modifier = Modifier.fillMaxWidth()) { Text("Retry") } }',
307
+ ' }',
308
+ ]
309
+ : []),
310
+ ' }',
311
+ ' }',
312
+ ' }',
313
+ ' }',
314
+ ' }',
315
+ '}',
316
+ '',
317
+ ].join('\n');
318
+ const composeImports = [
319
+ 'androidx.compose.foundation.layout.Box',
320
+ 'androidx.compose.foundation.layout.Column',
321
+ 'androidx.compose.foundation.layout.fillMaxSize',
322
+ 'androidx.compose.foundation.layout.fillMaxWidth',
323
+ 'androidx.compose.foundation.layout.padding',
324
+ 'androidx.compose.foundation.lazy.LazyColumn',
325
+ 'androidx.compose.foundation.lazy.items',
326
+ 'androidx.compose.material3.Button',
327
+ 'androidx.compose.material3.CircularProgressIndicator',
328
+ 'androidx.compose.material3.HorizontalDivider',
329
+ 'androidx.compose.material3.MaterialTheme',
330
+ 'androidx.compose.material3.Scaffold',
331
+ 'androidx.compose.material3.Text',
332
+ 'androidx.compose.runtime.Composable',
333
+ 'androidx.compose.runtime.collectAsState',
334
+ 'androidx.compose.runtime.getValue',
335
+ 'androidx.compose.ui.Alignment',
336
+ 'androidx.compose.ui.Modifier',
337
+ 'androidx.compose.ui.unit.dp',
338
+ ...(searchable ? ['androidx.compose.material3.OutlinedTextField'] : []),
339
+ ...(pg ? ['androidx.compose.foundation.lazy.rememberLazyListState', 'androidx.compose.material3.TextButton', 'androidx.compose.runtime.LaunchedEffect', 'androidx.compose.runtime.derivedStateOf', 'androidx.compose.runtime.remember'] : []),
340
+ ];
341
+ x.files.set(screenFile, addKotlinImports(screen, [...composeImports, ...inj.screenImports], pkg));
342
+ x.summary.push(`Create ${screenFile}: ${n.screen} lists them and ${pagingSummary(paging)}`);
343
+ x.warnings.push(...pagingWarnings(paging));
344
+ registerRoute(x, route, `${n.screen}()`, `${pkg}.${n.screen}`, params, false);
345
+ return finish(x, 'screen add', navHint(route, params));
346
+ }
347
+ const uiState = ensureUiState(x, screensDir);
251
348
  // ViewModel.
252
349
  x.files.set(vmFile, dataViewModel(x, a, n.vm, pkg, params, args, inj, uiState, T, modelImports));
253
350
  x.summary.push(`Create ${vmFile}: ${n.vm} loads ${a.iface}.${a.ep.name}()${params.length ? ` with ${params.join(', ')} from the route` : ''}`);
@@ -455,6 +552,75 @@ function planAndroidForm(x, opts, a) {
455
552
  registerRoute(x, route, `${n.screen}()`, `${pkg}.${n.screen}`, params, false);
456
553
  return finish(x, 'screen add', navHint(route, params));
457
554
  }
555
+ /** Paging for a Kotlin list endpoint. */
556
+ export function androidPaging(a, shape, opts, T) {
557
+ if (!shape.list)
558
+ throw new ToolError('screen_not_list', `${a.ep.name}() returns ${T}, not a list; --paginate and --search are for list screens.`);
559
+ return detectPaging(a.params.filter((p) => p.annotation !== 'Body').map((p) => ({ name: p.name.replace(/`/g, ''), type: p.type })), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'String').map((f) => f.name), titleField: titleField(shape.model) });
560
+ }
561
+ /** The ViewModel of a paged or searchable list (Compose and Views share it): one state, and load() / search(). A new search or refresh cancels the request still running. */
562
+ export function pagedViewModel(x, a, vmName, pkg, params, routeNamed, inj, item, p, modelImports) {
563
+ const stateName = `${vmName.replace(/ViewModel$/, '')}ListState`;
564
+ const searchable = Boolean(p.search || p.clientSearch);
565
+ const pg = p.paginate;
566
+ const first = p.page?.mode === 'offset' ? '0' : '1';
567
+ const call = [...routeNamed, ...namedPagingArgs(p, { page: pg ? 'next' : undefined, search: p.search ? '_state.value.query.trim().ifBlank { null }' : undefined }, ' = ', 'PAGE_SIZE')].join(', ');
568
+ const vm = [
569
+ `package ${pkg}`,
570
+ '',
571
+ ...(pg && p.size ? [`private const val PAGE_SIZE = ${PAGE_SIZE}`, ''] : []),
572
+ `data class ${stateName}(`,
573
+ ` val items: List<${item}> = emptyList(),`,
574
+ ' val loading: Boolean = false,',
575
+ ' val error: String? = null,',
576
+ ...(pg ? [' val hasMore: Boolean = true,'] : []),
577
+ ...(searchable ? [' val query: String = "",'] : []),
578
+ ')',
579
+ '',
580
+ ...inj.header,
581
+ ...params.map((x) => ` private val ${x}: String = checkNotNull(savedStateHandle["${x}"])`),
582
+ ...(params.length ? [''] : []),
583
+ ` private val _state = MutableStateFlow(${stateName}())`,
584
+ ` val state: StateFlow<${stateName}> = _state.asStateFlow()`,
585
+ ...(pg && p.page.mode === 'page' ? [' private var page = 0'] : []),
586
+ ' private var job: Job? = null',
587
+ ...(p.search ? [' private var searchJob: Job? = null'] : []),
588
+ '',
589
+ ' init {',
590
+ ` load(${pg ? 'reset = true' : ''})`,
591
+ ' }',
592
+ '',
593
+ ` /** Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. */`,
594
+ ` fun load(${pg ? 'reset: Boolean = false' : ''}) {`,
595
+ ...(pg ? [' val now = _state.value', ' if (!reset && (now.loading || !now.hasMore)) return'] : []),
596
+ ' job?.cancel()',
597
+ ' job = viewModelScope.launch {',
598
+ ' _state.update { it.copy(loading = true, error = null) }',
599
+ ' try {',
600
+ ...(pg ? [` val next = if (reset) ${first} else ${p.page.mode === 'offset' ? '_state.value.items.size' : 'page + 1'}`] : []),
601
+ ` val rows = api.${a.ep.name}(${call})`,
602
+ ...(pg && p.page.mode === 'page' ? [' page = next'] : []),
603
+ pg
604
+ ? ` _state.update { it.copy(items = if (reset) rows else it.items + rows, hasMore = ${p.size ? 'rows.size == PAGE_SIZE' : 'rows.isNotEmpty()'}, loading = false) }`
605
+ : ' _state.update { it.copy(items = rows, loading = false) }',
606
+ ' } catch (e: CancellationException) {',
607
+ ' throw e',
608
+ ' } catch (e: Exception) {',
609
+ ' _state.update { it.copy(loading = false, error = e.message ?: "Something went wrong") }',
610
+ ' }',
611
+ ' }',
612
+ ' }',
613
+ ...(p.search
614
+ ? ['', ' /** Searches after a short pause in typing. */', ' fun search(text: String) {', ' _state.update { it.copy(query = text) }', ' searchJob?.cancel()', ' searchJob = viewModelScope.launch {', ' delay(400)', ` load(${pg ? 'reset = true' : ''})`, ' }', ' }']
615
+ : p.clientSearch
616
+ ? ['', ' fun search(text: String) {', ' _state.update { it.copy(query = text) }', ' }']
617
+ : []),
618
+ ...inj.companion,
619
+ '}',
620
+ '',
621
+ ].join('\n');
622
+ return addKotlinImports(vm, ['androidx.lifecycle.ViewModel', 'androidx.lifecycle.viewModelScope', 'kotlinx.coroutines.CancellationException', 'kotlinx.coroutines.Job', 'kotlinx.coroutines.flow.MutableStateFlow', 'kotlinx.coroutines.flow.StateFlow', 'kotlinx.coroutines.flow.asStateFlow', 'kotlinx.coroutines.flow.update', 'kotlinx.coroutines.launch', ...(p.search ? ['kotlinx.coroutines.delay'] : []), a.ifaceClass, ...modelImports, ...inj.imports], pkg);
623
+ }
458
624
  /** The ViewModel of a data screen (Compose and Views share it): loads the endpoint into StateFlow<UiState<T>>. */
459
625
  export function dataViewModel(x, a, vmName, pkg, params, args, inj, uiState, T, modelImports) {
460
626
  const vm = [