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.
@@ -0,0 +1,509 @@
1
+ // codz env add <name> --base-url <url>, and codz env list: named API
2
+ // environments (staging, qa, ...) in each platform's own way.
3
+ //
4
+ // Next.js .env.<name> + dev:<name> / build:<name> scripts (dotenv-cli; development, production and test need none)
5
+ // React (Vite) .env.<name> + dev:<name> / build:<name> with --mode <name>
6
+ // Expo .env.<name> + start:<name> (dotenv-cli)
7
+ // RN CLI an API_URLS table in src/config/env.ts; APP_ENV picks one
8
+ // Flutter env/<name>.json for --dart-define-from-file; the Dio base URL reads API_BASE_URL
9
+ // Android an apiUrls table in app/build.gradle(.kts); -PapiEnv=<name> picks one, into BuildConfig.BASE_URL
10
+ // iOS an AppEnvironment enum; the scheme's APP_ENV variable picks one
11
+ //
12
+ // The URL you have now becomes the "default" environment. Never touches
13
+ // .env.local; base URLs are not secrets, so the files are meant to be committed.
14
+ import { join, posix } from 'node:path';
15
+ import { loadMap } from "../core/cache.js";
16
+ import { buildChangeSet } from "../core/changeset.js";
17
+ import { loadConfig } from "../core/config.js";
18
+ import { exists, readText, walk } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ const DOTENV_CLI = '^7.4.2';
21
+ function checkName(name) {
22
+ if (!/^[a-z][a-z0-9]{0,30}$/.test(name))
23
+ throw new ToolError('bad_args', `"${name}" is not an environment name.`, 'Use lower-case letters and digits, starting with a letter: staging, qa, dev2.');
24
+ if (name === 'default' || name === 'local' || name === 'example')
25
+ throw new ToolError('bad_args', `"${name}" is reserved.`, name === 'default' ? 'default is the base URL you have now.' : 'Pick another name, like staging or qa.');
26
+ }
27
+ function checkUrl(url) {
28
+ let u;
29
+ try {
30
+ u = new URL(url);
31
+ }
32
+ catch {
33
+ throw new ToolError('bad_args', `"${url}" is not a URL.`, 'Example: --base-url https://staging.example.com');
34
+ }
35
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
36
+ throw new ToolError('bad_args', 'The base URL must start with https:// or http://.');
37
+ if (u.username || u.password)
38
+ throw new ToolError('bad_args', 'The base URL contains a user name or password; codz never writes those into code.', 'Use the project\'s auth setup for credentials.');
39
+ return url.replace(/\/+$/, '');
40
+ }
41
+ const isEnvFile = (f) => /^\.env\.[a-z][a-z0-9]*$/.test(f) && !/\.(example|local|sample|template)$/.test(f);
42
+ /** The env variable the app reads its base URL from (Next.js, Vite, Expo), from the config or the env files. */
43
+ function envVar(map, prefix) {
44
+ const fromConfig = map.conventions.baseUrlEnv;
45
+ if (fromConfig && prefix.test(fromConfig))
46
+ return fromConfig;
47
+ return map.baseUrls.find((b) => prefix.test(b.name) && /^\.env/.test(posix.basename(b.file)))?.name ?? null;
48
+ }
49
+ function setEnvLine(text, key, value) {
50
+ const line = `${key}=${value}`;
51
+ if (text === null || !text.trim())
52
+ return `# API base URL for this environment (codz env add). Not for secrets: this file is meant to be committed.\n${line}\n`;
53
+ const re = new RegExp(`^${key}=.*$`, 'm');
54
+ return re.test(text) ? text.replace(re, line) : `${text.replace(/\s*$/, '\n')}${line}\n`;
55
+ }
56
+ /** package.json with scripts (and devDependencies) added, keeping its indentation; null when nothing changes. */
57
+ function withScripts(text, scripts, devDeps = {}) {
58
+ let pkg;
59
+ try {
60
+ pkg = JSON.parse(text);
61
+ }
62
+ catch {
63
+ throw new ToolError('bad_config', 'package.json is not valid JSON.');
64
+ }
65
+ const indent = /\n(\s+)"/.exec(text)?.[1] ?? ' ';
66
+ let changed = false;
67
+ pkg.scripts ??= {};
68
+ for (const [k, v] of Object.entries(scripts)) {
69
+ if (pkg.scripts[k] === v)
70
+ continue;
71
+ pkg.scripts[k] = v;
72
+ changed = true;
73
+ }
74
+ for (const [k, v] of Object.entries(devDeps)) {
75
+ if (pkg.devDependencies?.[k] || pkg.dependencies?.[k])
76
+ continue;
77
+ pkg.devDependencies = { ...(pkg.devDependencies ?? {}), [k]: v };
78
+ changed = true;
79
+ }
80
+ return changed ? `${JSON.stringify(pkg, null, indent)}\n` : null;
81
+ }
82
+ // ---------- env files: Next.js, Vite, Expo ----------
83
+ function planDotenv(root, map, kind, name, url, force, p) {
84
+ const prefix = kind === 'nextjs' ? /^NEXT_PUBLIC_\w+$/ : kind === 'vite' ? /^VITE_\w+$/ : /^EXPO_PUBLIC_\w+$/;
85
+ const key = envVar(map, prefix);
86
+ if (!key) {
87
+ const want = kind === 'nextjs' ? 'NEXT_PUBLIC_API_URL' : kind === 'vite' ? 'VITE_API_URL' : 'EXPO_PUBLIC_API_URL';
88
+ throw new ToolError('no_base_url', `codz could not find the env variable your app reads its base URL from (like ${want}).`, 'Run codz setup, or set baseUrlEnv in codz.config.yaml to its name.');
89
+ }
90
+ const file = `.env.${name}`;
91
+ const before = readText(join(root, file));
92
+ const old = before && new RegExp(`^${key}=(.*)$`, 'm').exec(before)?.[1];
93
+ if (old && old !== url && !force)
94
+ throw new ToolError('env_exists', `${file} already sets ${key}=${old}.`, 'Pass --force to change it.');
95
+ const after = setEnvLine(before, key, url);
96
+ if (after !== before) {
97
+ p.files.set(file, after);
98
+ p.summary.push(`${before === null ? 'Create' : 'Update'} ${file}: ${key}=${url}`);
99
+ }
100
+ const builtIn = kind === 'nextjs' ? ['development', 'production', 'test'] : kind === 'vite' ? ['development', 'production'] : [];
101
+ const pkgText = readText(join(root, 'package.json'));
102
+ if (builtIn.includes(name)) {
103
+ p.summary.push(kind === 'nextjs' ? `Next.js reads ${file} by itself in ${name === 'development' ? 'next dev' : name === 'production' ? 'next build and next start' : 'tests'}; no script needed.` : `Vite reads ${file} by itself in ${name === 'development' ? 'vite (dev)' : 'vite build'}; no script needed.`);
104
+ }
105
+ else if (pkgText !== null) {
106
+ const pkg = JSON.parse(pkgText);
107
+ let scripts;
108
+ let deps = {};
109
+ if (kind === 'vite') {
110
+ const build = pkg.scripts?.build && /\bvite build\b/.test(pkg.scripts.build) ? pkg.scripts.build.replace(/\bvite build\b/, `vite build --mode ${name}`) : `vite build --mode ${name}`;
111
+ scripts = { [`dev:${name}`]: `vite --mode ${name}`, [`build:${name}`]: build };
112
+ }
113
+ else if (kind === 'nextjs') {
114
+ scripts = { [`dev:${name}`]: `dotenv -e ${file} -- next dev`, [`build:${name}`]: `dotenv -e ${file} -- next build` };
115
+ deps = { 'dotenv-cli': DOTENV_CLI };
116
+ }
117
+ else {
118
+ scripts = { [`start:${name}`]: `dotenv -e ${file} -- expo start` };
119
+ deps = { 'dotenv-cli': DOTENV_CLI };
120
+ }
121
+ const next = withScripts(pkgText, scripts, deps);
122
+ if (next) {
123
+ p.files.set('package.json', next);
124
+ p.summary.push(`Update package.json: ${Object.keys(scripts).join(', ')}${Object.keys(deps).length && !/"dotenv-cli"/.test(pkgText) ? ' (and dotenv-cli, to load the file)' : ''}`);
125
+ if (Object.keys(deps).length && !/"dotenv-cli"/.test(pkgText))
126
+ p.warnings.push('Run npm install to get dotenv-cli.');
127
+ }
128
+ const run = kind === 'expo' ? `npm run start:${name}` : `npm run dev:${name}`;
129
+ p.summary.push(`Run against ${name}: ${run}`);
130
+ }
131
+ if (kind === 'nextjs' && exists(join(root, '.env.local')))
132
+ p.warnings.push(`.env.local overrides ${file} in next dev when it sets ${key} too; the ${name} scripts set ${key} before Next.js starts, so they win.`);
133
+ }
134
+ function listDotenv(root, map, prefix) {
135
+ const key = envVar(map, prefix);
136
+ if (!key)
137
+ return [];
138
+ const out = [];
139
+ const def = map.baseUrls.find((b) => b.name === key);
140
+ if (def)
141
+ out.push({ name: 'default', baseUrl: def.value, where: def.file });
142
+ for (const f of walk(root, '.', { maxFiles: 400 }).filter((x) => !x.includes('/') && isEnvFile(x))) {
143
+ const v = new RegExp(`^${key}=(.*)$`, 'm').exec(readText(join(root, f)) ?? '')?.[1];
144
+ if (v)
145
+ out.push({ name: f.slice(5), baseUrl: v, where: f });
146
+ }
147
+ return out;
148
+ }
149
+ // ---------- React Native CLI: a table in env.ts ----------
150
+ function planRnTable(root, map, name, url, force, p) {
151
+ const file = map.baseUrls.find((b) => b.name === 'API_URL')?.file ?? `${map.conventions.srcDir ?? 'src'}/config/env.ts`;
152
+ const text = readText(join(root, file));
153
+ if (text === null)
154
+ throw new ToolError('no_base_url', `Cannot read ${file}, where the API URL should be.`, 'Run codz setup first.');
155
+ const q = /'/.test(text) ? "'" : '"';
156
+ let next;
157
+ const table = /const API_URLS = \{([^}]*)\}/.exec(text);
158
+ if (table) {
159
+ const has = new RegExp(`^\\s*${name}:\\s*['"]([^'"]*)['"]`, 'm').exec(table[1]);
160
+ if (has && has[1] !== url && !force)
161
+ throw new ToolError('env_exists', `${name} is already ${has[1]}.`, 'Pass --force to change it.');
162
+ const body = has ? table[1].replace(has[0], has[0].replace(has[1], url)) : `${table[1].replace(/\s*$/, '')}\n ${name}: ${q}${url}${q},\n`;
163
+ next = text.replace(table[0], `const API_URLS = {${body}}`);
164
+ }
165
+ else {
166
+ const m = /^export const API_URL(?::\s*string)?\s*=\s*['"]([^'"]*)['"];?[ \t]*$/m.exec(text);
167
+ if (!m)
168
+ throw new ToolError('not_supported', `${file} does not set API_URL to a plain string, so codz left it alone.`, `Add the table yourself: const API_URLS = { default: '...', ${name}: '${url}' }.`);
169
+ const semi = m[0].trimEnd().endsWith(';') ? ';' : '';
170
+ next = text.replace(m[0], [
171
+ `/** The API for each environment (codz env add). APP_ENV picks one. */`,
172
+ `const API_URLS = {`,
173
+ ` default: ${q}${m[1]}${q},`,
174
+ ` ${name}: ${q}${url}${q},`,
175
+ `} as const${semi}`,
176
+ '',
177
+ `export type AppEnv = keyof typeof API_URLS${semi}`,
178
+ `export const APP_ENV: AppEnv = ${q}default${q}${semi}`,
179
+ `export const API_URL: string = API_URLS[APP_ENV]${semi}`,
180
+ ].join('\n'));
181
+ }
182
+ if (next !== text) {
183
+ p.files.set(file, next);
184
+ p.summary.push(`Update ${file}: ${name} → ${url} in API_URLS`);
185
+ }
186
+ p.summary.push(`Switch with: codz env use ${name} (sets APP_ENV in ${file})`);
187
+ }
188
+ function listRnTable(root, map) {
189
+ const b = map.baseUrls.find((x) => x.name === 'API_URL');
190
+ const file = b?.file ?? 'src/config/env.ts';
191
+ const text = readText(join(root, file)) ?? '';
192
+ const table = /const API_URLS = \{([^}]*)\}/.exec(text);
193
+ if (!table)
194
+ return b ? [{ name: 'default', baseUrl: b.value, where: file }] : [];
195
+ return [...table[1].matchAll(/^\s*(\w+):\s*['"]([^'"]*)['"]/gm)].map((m) => ({ name: m[1], baseUrl: m[2], where: file }));
196
+ }
197
+ // ---------- Flutter: env/<name>.json and String.fromEnvironment ----------
198
+ const FLUTTER_KEY = 'API_BASE_URL';
199
+ function planFlutter(root, map, name, url, force, p) {
200
+ const b = map.baseUrls.find((x) => /baseUrl/i.test(x.name) && x.file.endsWith('.dart'));
201
+ const dioFile = b?.file ?? map.conventions.networkFile;
202
+ const text = dioFile ? readText(join(root, dioFile)) : null;
203
+ if (!dioFile || text === null)
204
+ throw new ToolError('no_base_url', 'codz could not find where Dio gets its base URL.', 'Run codz setup, or set networkFile in codz.config.yaml.');
205
+ let key = /String\.fromEnvironment\(\s*'(\w+)'/.exec(text)?.[1];
206
+ if (!key) {
207
+ const m = /baseUrl:\s*(['"])([^'"]+)\1/.exec(text);
208
+ if (!m)
209
+ throw new ToolError('not_supported', `${dioFile} does not set baseUrl to a plain string, so codz left it alone.`, `Make it read the environment: baseUrl: const String.fromEnvironment('${FLUTTER_KEY}', defaultValue: '...').`);
210
+ key = FLUTTER_KEY;
211
+ p.files.set(dioFile, text.replace(m[0], `baseUrl: const String.fromEnvironment('${key}', defaultValue: ${m[1]}${m[2]}${m[1]})`));
212
+ p.summary.push(`Update ${dioFile}: the base URL comes from ${key} (default ${m[2]})`);
213
+ }
214
+ const file = `env/${name}.json`;
215
+ const before = readText(join(root, file));
216
+ let data = {};
217
+ if (before !== null) {
218
+ try {
219
+ data = JSON.parse(before);
220
+ }
221
+ catch {
222
+ throw new ToolError('bad_config', `${file} is not valid JSON.`);
223
+ }
224
+ if (typeof data[key] === 'string' && data[key] !== url && !force)
225
+ throw new ToolError('env_exists', `${file} already sets ${key} to ${data[key]}.`, 'Pass --force to change it.');
226
+ }
227
+ const after = `${JSON.stringify({ ...data, [key]: url }, null, 2)}\n`;
228
+ if (after !== before) {
229
+ p.files.set(file, after);
230
+ p.summary.push(`${before === null ? 'Create' : 'Update'} ${file}: ${key}=${url}`);
231
+ }
232
+ // A VS Code launch configuration, when there is no launch.json yet or it is plain JSON.
233
+ const launchPath = '.vscode/launch.json';
234
+ const launchText = readText(join(root, launchPath));
235
+ const entry = { name: `${posix.basename(root)} (${name})`, request: 'launch', type: 'dart', args: [`--dart-define-from-file=${file}`] };
236
+ try {
237
+ const launch = launchText === null ? { version: '0.2.0', configurations: [] } : JSON.parse(launchText);
238
+ launch.configurations ??= [];
239
+ if (!launch.configurations.some((c) => c.args?.includes(`--dart-define-from-file=${file}`))) {
240
+ launch.configurations.push(entry);
241
+ p.files.set(launchPath, `${JSON.stringify(launch, null, 2)}\n`);
242
+ p.summary.push(`${launchText === null ? 'Create' : 'Update'} ${launchPath}: a "${entry.name}" run configuration`);
243
+ }
244
+ }
245
+ catch {
246
+ p.warnings.push(`${launchPath} has comments, so codz did not change it. Add "args": ["--dart-define-from-file=${file}"] to a configuration for ${name}.`);
247
+ }
248
+ p.summary.push(`Run against ${name}: flutter run --dart-define-from-file=${file} (Flutter 3.7 or newer)`);
249
+ }
250
+ function listFlutter(root, map) {
251
+ const out = [];
252
+ const b = map.baseUrls.find((x) => /baseUrl/i.test(x.name) && x.file.endsWith('.dart'));
253
+ if (b)
254
+ out.push({ name: 'default', baseUrl: b.value, where: b.file });
255
+ for (const f of walk(root, 'env', { exts: ['.json'] })) {
256
+ try {
257
+ const v = JSON.parse(readText(join(root, f)) ?? '{}')[FLUTTER_KEY];
258
+ if (typeof v === 'string')
259
+ out.push({ name: posix.basename(f, '.json'), baseUrl: v, where: f });
260
+ }
261
+ catch {
262
+ // not ours
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+ // ---------- Android: an apiUrls table in the app build file ----------
268
+ function androidFiles(root, map) {
269
+ const module = map.conventions.appModule || 'app';
270
+ const gradle = [`${module}/build.gradle.kts`, `${module}/build.gradle`].find((f) => exists(join(root, f)));
271
+ if (!gradle)
272
+ throw new ToolError('not_supported', `No ${module}/build.gradle(.kts) was found.`);
273
+ const src = map.conventions.srcDir || `${module}/src/main/java`;
274
+ const apiConfig = walk(root, src, { exts: ['.kt'] }).find((f) => /^\s*(const\s+)?val\s+BASE_URL(\s*:\s*String)?\s*=/m.test(readText(join(root, f)) ?? '')) ?? null;
275
+ return { gradle, apiConfig };
276
+ }
277
+ function planAndroid(root, map, name, url, force, p) {
278
+ const { gradle, apiConfig } = androidFiles(root, map);
279
+ const kts = gradle.endsWith('.kts');
280
+ let g = readText(join(root, gradle));
281
+ const withSlash = `${url}/`;
282
+ const table = kts ? /val apiUrls = mapOf\(([^)]*)\)/.exec(g) : /def apiUrls = \[([^\]]*)\]/.exec(g);
283
+ if (table) {
284
+ const entryRe = kts ? new RegExp(`"${name}" to "([^"]*)"`) : new RegExp(`'${name}'\\s*:\\s*'([^']*)'`);
285
+ const has = entryRe.exec(table[1]);
286
+ if (has && has[1] !== withSlash && !force)
287
+ throw new ToolError('env_exists', `${name} is already ${has[1]}.`, 'Pass --force to change it.');
288
+ const line = kts ? ` "${name}" to "${withSlash}",` : ` '${name}': '${withSlash}',`;
289
+ const body = has ? table[1].replace(has[0], kts ? `"${name}" to "${withSlash}"` : `'${name}': '${withSlash}'`) : `${table[1].replace(/\s*$/, '')}\n${line}\n`;
290
+ g = g.replace(table[0], kts ? `val apiUrls = mapOf(${body})` : `def apiUrls = [${body}]`);
291
+ }
292
+ else {
293
+ if (!apiConfig)
294
+ throw new ToolError('no_base_url', 'codz could not find BASE_URL in your Kotlin code (like ApiConfig.BASE_URL).', 'Run codz setup, which writes data/remote/ApiConfig.kt.');
295
+ const cfg = readText(join(root, apiConfig));
296
+ const m = /^(\s*)(?:const\s+)?val\s+BASE_URL(?:\s*:\s*String)?\s*=\s*"([^"]*)"\s*$/m.exec(cfg);
297
+ if (!m)
298
+ throw new ToolError('not_supported', `BASE_URL in ${apiConfig} is not a plain string, so codz left it alone.`);
299
+ const ns = map.conventions.namespace || /namespace\s*=?\s*["']([\w.]+)["']/.exec(g)?.[1];
300
+ if (!ns)
301
+ throw new ToolError('no_package', 'No namespace was found in the app build file.', 'Set namespace in codz.config.yaml.');
302
+ const current = m[2].endsWith('/') ? m[2] : `${m[2]}/`;
303
+ const head = kts
304
+ ? ['// API base URLs (codz env add). Pick one with -PapiEnv=staging, or apiEnv=staging in gradle.properties.', 'val apiUrls = mapOf(', ` "default" to "${current}",`, ` "${name}" to "${withSlash}",`, ')', 'val apiEnv = (findProperty("apiEnv") as String?) ?: "default"']
305
+ : ['// API base URLs (codz env add). Pick one with -PapiEnv=staging, or apiEnv=staging in gradle.properties.', 'def apiUrls = [', ` 'default': '${current}',`, ` '${name}': '${withSlash}',`, ']', "def apiEnv = project.findProperty('apiEnv') ?: 'default'"];
306
+ const plugins = /^plugins\s*\{[^]*?^\}\s*$/m.exec(g);
307
+ if (!plugins)
308
+ throw new ToolError('not_supported', `${gradle} has no plugins { } block where codz expects one.`);
309
+ g = g.replace(plugins[0], `${plugins[0].trimEnd()}\n\n${head.join('\n')}\n\n`);
310
+ const field = kts ? ` buildConfigField("String", "BASE_URL", "\\"\${apiUrls.getValue(apiEnv)}\\"")` : ` buildConfigField 'String', 'BASE_URL', "\\"\${apiUrls[apiEnv]}\\""`;
311
+ const dc = /^(\s*)defaultConfig\s*\{\s*$/m.exec(g);
312
+ if (!dc)
313
+ throw new ToolError('not_supported', `${gradle} has no defaultConfig { } block.`);
314
+ g = g.replace(dc[0], `${dc[0]}\n${field}`);
315
+ const bf = /^(\s*)buildFeatures\s*\{\s*$/m.exec(g);
316
+ if (bf) {
317
+ if (!/buildConfig\s*=?\s*true/.test(g))
318
+ g = g.replace(bf[0], `${bf[0]}\n${bf[1]} buildConfig ${kts ? '= ' : ''}true`);
319
+ }
320
+ else {
321
+ const android = /^android\s*\{\s*$/m.exec(g);
322
+ if (!android)
323
+ throw new ToolError('not_supported', `${gradle} has no android { } block.`);
324
+ g = g.replace(android[0], `${android[0]}\n buildFeatures {\n buildConfig ${kts ? '= ' : ''}true\n }`);
325
+ }
326
+ let nextCfg = cfg.replace(m[0], `${m[1]}val BASE_URL: String = BuildConfig.BASE_URL`);
327
+ if (!new RegExp(`^import ${ns.replace(/\./g, '\\.')}\\.BuildConfig$`, 'm').test(nextCfg))
328
+ nextCfg = nextCfg.replace(/^(package [\w.]+)\s*\n/m, `$1\n\nimport ${ns}.BuildConfig\n\n`);
329
+ p.files.set(apiConfig, nextCfg);
330
+ p.summary.push(`Update ${apiConfig}: BASE_URL comes from BuildConfig.BASE_URL (default ${current})`);
331
+ }
332
+ p.files.set(gradle, g);
333
+ p.summary.push(`Update ${gradle}: ${name} → ${withSlash} in apiUrls`);
334
+ p.summary.push(`Build against ${name}: ./gradlew installDebug -PapiEnv=${name} (or put apiEnv=${name} in gradle.properties for Android Studio)`);
335
+ }
336
+ function listAndroid(root, map) {
337
+ const { gradle, apiConfig } = androidFiles(root, map);
338
+ const g = readText(join(root, gradle)) ?? '';
339
+ const table = /val apiUrls = mapOf\(([^)]*)\)/.exec(g) ?? /def apiUrls = \[([^\]]*)\]/.exec(g);
340
+ if (table)
341
+ return [...table[1].matchAll(/["'](\w+)["']\s*(?:to|:)\s*["']([^"']*)["']/g)].map((m) => ({ name: m[1], baseUrl: m[2], where: gradle }));
342
+ const v = apiConfig && /BASE_URL(?:\s*:\s*String)?\s*=\s*"([^"]*)"/.exec(readText(join(root, apiConfig)) ?? '')?.[1];
343
+ return v ? [{ name: 'default', baseUrl: v, where: apiConfig }] : [];
344
+ }
345
+ // ---------- iOS: an AppEnvironment enum ----------
346
+ function planIos(root, map, name, url, force, p) {
347
+ const client = map.conventions.networkFile || map.baseUrls.find((b) => /baseURL/.test(b.name))?.file;
348
+ const text = client ? readText(join(root, client)) : null;
349
+ if (!client || text === null)
350
+ throw new ToolError('no_base_url', 'codz could not find APIClient.swift.', 'Run codz setup, which writes Networking/APIClient.swift.');
351
+ const envFile = `${posix.dirname(client)}/AppEnvironment.swift`;
352
+ const I = /\n( +)\S/.exec(text)?.[1] ?? ' ';
353
+ let env = readText(join(root, envFile));
354
+ if (env === null) {
355
+ const m = /URL\(string:\s*"([^"]+)"\)!/.exec(text);
356
+ if (!m)
357
+ throw new ToolError('not_supported', `${client} does not set its base URL with URL(string: "...")!, so codz left it alone.`);
358
+ p.files.set(client, text.replace(m[0], 'AppEnvironment.current.baseURL'));
359
+ p.summary.push(`Update ${client}: the base URL comes from AppEnvironment.current (default ${m[1]})`);
360
+ env = [
361
+ 'import Foundation',
362
+ '',
363
+ '/// The API each environment talks to (codz env add). Pick one with the APP_ENV environment variable',
364
+ '/// (Product → Scheme → Edit Scheme → Run → Arguments → Environment Variables), or change `fallback`.',
365
+ 'enum AppEnvironment: String, CaseIterable {',
366
+ `${I}case \`default\``,
367
+ '',
368
+ `${I}static let fallback: AppEnvironment = .default`,
369
+ '',
370
+ `${I}static var current: AppEnvironment {`,
371
+ `${I}${I}ProcessInfo.processInfo.environment["APP_ENV"].flatMap(AppEnvironment.init(rawValue:)) ?? fallback`,
372
+ `${I}}`,
373
+ '',
374
+ `${I}var baseURL: URL {`,
375
+ `${I}${I}switch self {`,
376
+ `${I}${I}case .default: return URL(string: "${m[1]}")!`,
377
+ `${I}${I}}`,
378
+ `${I}}`,
379
+ '}',
380
+ '',
381
+ ].join('\n');
382
+ }
383
+ const has = new RegExp(`case \\.${name}: return URL\\(string: "([^"]*)"\\)!`).exec(env);
384
+ if (has && has[1] !== url && !force)
385
+ throw new ToolError('env_exists', `${name} is already ${has[1]}.`, 'Pass --force to change it.');
386
+ let next = env;
387
+ if (has)
388
+ next = env.replace(has[0], `case .${name}: return URL(string: "${url}")!`);
389
+ else {
390
+ const lastCase = [...env.matchAll(/^( +)case [`\w, ]+$/gm)].pop();
391
+ const lastReturn = [...env.matchAll(/^( +)case \.\w+: return URL\(string: "[^"]*"\)!$/gm)].pop();
392
+ if (!lastCase || !lastReturn)
393
+ throw new ToolError('not_supported', `${envFile} is not laid out the way codz writes it; add the ${name} case yourself.`);
394
+ // Later positions first, so the earlier offset stays valid.
395
+ next = next.slice(0, lastReturn.index + lastReturn[0].length) + `\n${lastReturn[1]}case .${name}: return URL(string: "${url}")!` + next.slice(lastReturn.index + lastReturn[0].length);
396
+ next = next.slice(0, lastCase.index + lastCase[0].length) + `\n${lastCase[1]}case ${name}` + next.slice(lastCase.index + lastCase[0].length);
397
+ }
398
+ if (next !== readText(join(root, envFile))) {
399
+ p.files.set(envFile, next);
400
+ p.summary.push(`${readText(join(root, envFile)) === null ? 'Create' : 'Update'} ${envFile}: ${name} → ${url}`);
401
+ }
402
+ p.summary.push(`Run against ${name}: set APP_ENV=${name} in the scheme's environment variables (Run → Arguments), or codz env use ${name}`);
403
+ }
404
+ function listIos(root, map) {
405
+ const client = map.conventions.networkFile || map.baseUrls.find((b) => /baseURL/.test(b.name))?.file;
406
+ if (!client)
407
+ return [];
408
+ const envFile = `${posix.dirname(client)}/AppEnvironment.swift`;
409
+ const env = readText(join(root, envFile));
410
+ if (env === null) {
411
+ const b = map.baseUrls.find((x) => /baseURL/.test(x.name));
412
+ return b ? [{ name: 'default', baseUrl: b.value, where: b.file }] : [];
413
+ }
414
+ return [...env.matchAll(/case \.(\w+): return URL\(string: "([^"]*)"\)!/g)].map((m) => ({ name: m[1], baseUrl: m[2], where: envFile }));
415
+ }
416
+ function kindOf(adapter, map) {
417
+ if (adapter.id === 'nextjs')
418
+ return 'nextjs';
419
+ if (adapter.id === 'react')
420
+ return 'vite';
421
+ if (adapter.id === 'react-native')
422
+ return envVar(map, /^EXPO_PUBLIC_\w+$/) ? 'expo' : 'rn-cli';
423
+ if (adapter.id === 'flutter' || adapter.id === 'android' || adapter.id === 'ios')
424
+ return adapter.id;
425
+ throw new ToolError('not_supported', `env add needs a framework adapter; this project uses the ${adapter.id} adapter.`);
426
+ }
427
+ export async function planEnvAdd(root, adapter, name, baseUrl, opts = {}) {
428
+ if (!name)
429
+ throw new ToolError('bad_args', 'Which environment? codz env add <name> --base-url <url>', 'Example: codz env add staging --base-url https://staging.example.com');
430
+ checkName(name);
431
+ if (!baseUrl)
432
+ throw new ToolError('bad_args', `Give the base URL for ${name}: --base-url https://...`);
433
+ const url = checkUrl(baseUrl);
434
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
435
+ const kind = kindOf(adapter, map);
436
+ const p = { files: new Map(), summary: [], warnings: [] };
437
+ const force = Boolean(opts.force);
438
+ if (kind === 'nextjs' || kind === 'vite' || kind === 'expo')
439
+ planDotenv(root, map, kind, name, url, force, p);
440
+ else if (kind === 'rn-cli')
441
+ planRnTable(root, map, name, url, force, p);
442
+ else if (kind === 'flutter')
443
+ planFlutter(root, map, name, url, force, p);
444
+ else if (kind === 'android')
445
+ planAndroid(root, map, name, url, force, p);
446
+ else
447
+ planIos(root, map, name, url, force, p);
448
+ if (!p.files.size)
449
+ p.summary.unshift(`${name} is already set to ${url}; nothing to change.`);
450
+ return buildChangeSet(root, 'env add', { files: p.files, summary: p.summary, warnings: p.warnings, generated: [] });
451
+ }
452
+ export async function listEnvs(root, adapter) {
453
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
454
+ const kind = kindOf(adapter, map);
455
+ if (kind === 'nextjs')
456
+ return listDotenv(root, map, /^NEXT_PUBLIC_\w+$/);
457
+ if (kind === 'vite')
458
+ return listDotenv(root, map, /^VITE_\w+$/);
459
+ if (kind === 'expo')
460
+ return listDotenv(root, map, /^EXPO_PUBLIC_\w+$/);
461
+ if (kind === 'rn-cli')
462
+ return listRnTable(root, map);
463
+ if (kind === 'flutter')
464
+ return listFlutter(root, map);
465
+ if (kind === 'android')
466
+ return listAndroid(root, map);
467
+ return listIos(root, map);
468
+ }
469
+ /** codz env use <name>: where the choice lives in the code (RN CLI's APP_ENV, iOS's fallback, Android's gradle.properties). */
470
+ export async function planEnvUse(root, adapter, name) {
471
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
472
+ const kind = kindOf(adapter, map);
473
+ const envs = await listEnvs(root, adapter);
474
+ if (!envs.some((e) => e.name === name))
475
+ throw new ToolError('no_env', `No environment called ${name}.`, envs.length ? `Environments: ${envs.map((e) => e.name).join(', ')}` : 'Add one with: codz env add <name> --base-url <url>');
476
+ const files = new Map();
477
+ const summary = [];
478
+ if (kind === 'rn-cli') {
479
+ const file = envs[0].where;
480
+ const text = readText(join(root, file));
481
+ const next = text.replace(/(export const APP_ENV: AppEnv = )(['"])\w+\2/, `$1$2${name}$2`);
482
+ if (next === text && !new RegExp(`APP_ENV: AppEnv = ['"]${name}['"]`).test(text))
483
+ throw new ToolError('not_supported', `Could not find APP_ENV in ${file}.`);
484
+ if (next !== text)
485
+ files.set(file, next);
486
+ summary.push(`The app now uses ${name} (APP_ENV in ${file}). Reload the app.`);
487
+ }
488
+ else if (kind === 'ios') {
489
+ const file = envs[0].where;
490
+ const text = readText(join(root, file));
491
+ const next = text.replace(/(static let fallback: AppEnvironment = )\.[`\w]+/, `$1.${name}`);
492
+ if (next !== text)
493
+ files.set(file, next);
494
+ summary.push(`Builds now use ${name} unless the scheme sets APP_ENV (fallback in ${file}).`);
495
+ }
496
+ else if (kind === 'android') {
497
+ const before = readText(join(root, 'gradle.properties'));
498
+ const line = `apiEnv=${name}`;
499
+ const next = before === null ? `${line}\n` : /^apiEnv=.*$/m.test(before) ? before.replace(/^apiEnv=.*$/m, line) : `${before.replace(/\s*$/, '\n')}${line}\n`;
500
+ if (next !== before)
501
+ files.set('gradle.properties', next);
502
+ summary.push(`Builds now use ${name} (apiEnv in gradle.properties); -PapiEnv=<name> still overrides it. Sync Gradle in Android Studio.`);
503
+ }
504
+ else {
505
+ const hint = kind === 'flutter' ? `flutter run --dart-define-from-file=env/${name}.json` : kind === 'expo' ? `npm run start:${name}` : `npm run dev:${name}`;
506
+ throw new ToolError('not_needed', `In this project the environment is chosen when you run the app, not in the code.`, `Run: ${hint}`);
507
+ }
508
+ return buildChangeSet(root, 'env use', { files, summary, warnings: [], generated: [] });
509
+ }
@@ -127,7 +127,7 @@ export async function planFeatureAdd(root, adapter, opts) {
127
127
  // 1. Endpoints.
128
128
  if (specFile) {
129
129
  // A half-built feature is worse than none: by default a clash stops everything (nothing is written).
130
- const imp = await planApiImport(ws.root, adapter, { file: specFile, only: endpointNames, baseUrl: opts.baseUrl, skipConflicts: opts.skipConflicts === true, yes: true });
130
+ const imp = await planApiImport(ws.root, adapter, { file: specFile, only: endpointNames, baseUrl: opts.baseUrl, skipConflicts: opts.skipConflicts === true, yes: true, split: opts.split });
131
131
  ws.apply(imp.changeSet);
132
132
  summary.push(`Endpoints: ${imp.added.length} added${imp.existing.length ? `, ${imp.existing.length} already in the project` : ''}${imp.skipped.length ? `, ${imp.skipped.length} skipped` : ''}`);
133
133
  for (const s of imp.skipped)