codz-cli 0.0.0-stage → 0.1.2

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.
Files changed (118) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +525 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +638 -0
  77. package/dist/skills/createDemo.js +1026 -0
  78. package/dist/skills/doctor.js +304 -0
  79. package/dist/skills/doctorFix.js +214 -0
  80. package/dist/skills/explain.js +61 -0
  81. package/dist/skills/feature.js +418 -0
  82. package/dist/skills/featureEdit.js +225 -0
  83. package/dist/skills/featureLinks.js +179 -0
  84. package/dist/skills/init.js +66 -0
  85. package/dist/skills/iosUi.js +468 -0
  86. package/dist/skills/locale.js +335 -0
  87. package/dist/skills/mcp.js +175 -0
  88. package/dist/skills/mock.js +482 -0
  89. package/dist/skills/module.js +23 -0
  90. package/dist/skills/plans.js +66 -0
  91. package/dist/skills/prefill.js +84 -0
  92. package/dist/skills/reactNativeUi.js +388 -0
  93. package/dist/skills/reactUi.js +352 -0
  94. package/dist/skills/report.js +75 -0
  95. package/dist/skills/route.js +230 -0
  96. package/dist/skills/screen.js +597 -0
  97. package/dist/skills/screenForm.js +410 -0
  98. package/dist/skills/setup.js +28 -0
  99. package/dist/skills/setupDoctor.js +226 -0
  100. package/dist/skills/templatesCmd.js +32 -0
  101. package/dist/skills/testgen.js +331 -0
  102. package/dist/skills/theme.js +388 -0
  103. package/dist/starters/auth.js +144 -0
  104. package/dist/starters/commands.js +283 -0
  105. package/dist/starters/engine.js +315 -0
  106. package/dist/starters/home.js +42 -0
  107. package/dist/starters/http.js +93 -0
  108. package/dist/starters/manifest.js +285 -0
  109. package/dist/starters/paths.js +70 -0
  110. package/dist/starters/publish.js +150 -0
  111. package/dist/starters/registry.js +258 -0
  112. package/dist/starters/sign.js +72 -0
  113. package/dist/starters/source.js +98 -0
  114. package/dist/starters/strictJson.js +148 -0
  115. package/dist/starters/tar.js +180 -0
  116. package/docs/COMMANDS.md +527 -0
  117. package/examples/openapi.yaml +319 -0
  118. package/package.json +57 -4
@@ -0,0 +1,388 @@
1
+ // theme set <token> <value>: one place for colors, sizes and the font.
2
+ //
3
+ // The kind comes from the value: #hex → color, a number → size, anything else
4
+ // (or the token "font") → font family.
5
+ //
6
+ // Flutter: AppColors / AppSizes classes and an AppTheme built from the seed
7
+ // color (lib/app/theme for GetX, lib/core/theme otherwise, or wherever the
8
+ // project already keeps AppColors), wired into MaterialApp / GetMaterialApp.
9
+ // Next.js: Tailwind v4 @theme variables, Tailwind v3 theme.extend, or plain
10
+ // CSS variables in globals.css.
11
+ import { importPath } from "../adapters/nextjs.js";
12
+ import { join, posix } from 'node:path';
13
+ import { addDartImport, importUri } from "../adapters/flutter.js";
14
+ import { matchBrace } from "../adapters/source.js";
15
+ import { loadMap } from "../core/cache.js";
16
+ import { buildChangeSet } from "../core/changeset.js";
17
+ import { loadConfig } from "../core/config.js";
18
+ import { detectEol, exists, readText, walk, withEol } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ import { camel, kebab } from "../input/naming.js";
21
+ /** CSS hex is RRGGBB or RRGGBBAA; Flutter's Color(0x...) is AARRGGBB. */
22
+ export function dartArgb(hex) {
23
+ return hex.length === 8 ? `${hex.slice(6)}${hex.slice(0, 6)}` : `FF${hex}`;
24
+ }
25
+ export function parseToken(name, value) {
26
+ const v = value.trim();
27
+ if (/^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v) && (v.startsWith('#') || /color|primary|secondary|accent|background|surface|error|text/i.test(name))) {
28
+ let hex = v.replace('#', '').toUpperCase();
29
+ if (hex.length === 3 || hex.length === 4)
30
+ hex = hex.split('').map((c) => c + c).join('');
31
+ return { name, kind: 'color', value: hex };
32
+ }
33
+ if (/^\d+(\.\d+)?(px)?$/.test(v))
34
+ return { name, kind: 'size', value: v.replace(/px$/, '') };
35
+ if (/^(font|fontFamily|font-family|font-sans)$/i.test(name) || /^[A-Za-z][\w '-]*$/.test(v))
36
+ return { name, kind: 'font', value: v.replace(/^['"]|['"]$/g, '') };
37
+ throw new ToolError('bad_value', `Cannot tell what "${value}" is.`, 'Use a hex color (#6750A4), a number (12) or a font name (Inter).');
38
+ }
39
+ // ---------------- Flutter ----------------
40
+ function dartReplaceOrAdd(text, cls, decl, line) {
41
+ const head = new RegExp(`class\\s+${cls}\\b[^{]*\\{`).exec(text);
42
+ const open = head.index + head[0].length - 1;
43
+ const close = matchBrace(text, open, 'dart');
44
+ const body = text.slice(open, close);
45
+ const m = decl.exec(body);
46
+ if (m)
47
+ return text.slice(0, open + m.index) + line.trim() + text.slice(open + m.index + m[0].length);
48
+ const before = text.slice(0, close).replace(/\s*$/, '');
49
+ return `${before}\n ${line.trim()}\n${text.slice(close)}`;
50
+ }
51
+ function findClassFile(root, files, cls) {
52
+ for (const [f, t] of files)
53
+ if (new RegExp(`class\\s+${cls}\\b`).test(t))
54
+ return f;
55
+ return walk(root, 'lib', { exts: ['.dart'] }).find((f) => new RegExp(`class\\s+${cls}\\b`).test(readText(join(root, f)) ?? ''));
56
+ }
57
+ function planFlutterTheme(root, map, token, files, summary, warnings) {
58
+ const c = map.conventions;
59
+ const read = (p) => files.get(p) ?? readText(join(root, p));
60
+ const existingColors = findClassFile(root, files, 'AppColors');
61
+ const folder = existingColors
62
+ ? posix.dirname(existingColors)
63
+ : c.modulesFolder || c.di === 'getx' || c.stateManagement === 'getx'
64
+ ? `${(c.modulesFolder || 'lib/app/modules').replace(/\/modules$/, '')}/theme`
65
+ : 'lib/core/theme';
66
+ const colorsFile = existingColors ?? `${folder}/app_colors.dart`;
67
+ const sizesFile = findClassFile(root, files, 'AppSizes') ?? `${folder}/app_sizes.dart`;
68
+ const themeFile = findClassFile(root, files, 'AppTheme') ?? `${folder}/app_theme.dart`;
69
+ const name = camel(token.name);
70
+ const ensure = (file, cls, doc) => {
71
+ const t = read(file);
72
+ if (t !== null)
73
+ return t.replace(/\r\n/g, '\n');
74
+ summary.push(`Create ${file}: ${cls}`);
75
+ return `${cls === 'AppColors' ? "import 'package:flutter/material.dart';\n\n" : ''}/// ${doc}\nclass ${cls} {\n ${cls}._();\n}\n`;
76
+ };
77
+ const save = (file, text) => {
78
+ const orig = readText(join(root, file));
79
+ files.set(file, orig ? withEol(text, detectEol(orig)) : text);
80
+ };
81
+ if (token.kind === 'color') {
82
+ const t = ensure(colorsFile, 'AppColors', 'App colors. Change them with: codz theme set <name> <#hex>');
83
+ const had = new RegExp(`\\bstatic\\s+const\\s+(?:Color\\s+)?${name}\\s*=`).test(t);
84
+ save(colorsFile, dartReplaceOrAdd(t, 'AppColors', new RegExp(`static\\s+const\\s+(?:Color\\s+)?${name}\\s*=[^;]*;`), `static const Color ${name} = Color(0x${dartArgb(token.value)});`));
85
+ summary.push(`${had ? 'Update' : 'Add'} AppColors.${name} = #${token.value} in ${colorsFile}`);
86
+ }
87
+ else if (token.kind === 'size') {
88
+ const t = ensure(sizesFile, 'AppSizes', 'Spacing, radius and other sizes. Change them with: codz theme set <name> <number>');
89
+ const had = new RegExp(`\\bstatic\\s+const\\s+(?:double\\s+)?${name}\\s*=`).test(t);
90
+ const v = token.value.includes('.') ? token.value : `${token.value}.0`;
91
+ save(sizesFile, dartReplaceOrAdd(t, 'AppSizes', new RegExp(`static\\s+const\\s+(?:double\\s+)?${name}\\s*=[^;]*;`), `static const double ${name} = ${v};`));
92
+ summary.push(`${had ? 'Update' : 'Add'} AppSizes.${name} = ${v} in ${sizesFile}`);
93
+ }
94
+ // AppTheme: seed color from AppColors.primary (or the first color), and the font.
95
+ const themeExists = read(themeFile) !== null;
96
+ if (token.kind === 'font' || !themeExists) {
97
+ let t = read(themeFile)?.replace(/\r\n/g, '\n');
98
+ if (t === undefined || t === null) {
99
+ const colorsText = files.get(colorsFile) ?? read(colorsFile) ?? '';
100
+ const seed = /static\s+const\s+(?:Color\s+)?primary\b/.test(colorsText) ? 'primary' : colorsText.match(/static\s+const\s+(?:Color\s+)?(\w+)\s*=/)?.[1];
101
+ t = [
102
+ "import 'package:flutter/material.dart';",
103
+ '',
104
+ '/// The app theme. Colors come from AppColors; change them with codz theme set.',
105
+ 'class AppTheme {',
106
+ ' AppTheme._();',
107
+ '',
108
+ ' static ThemeData get light => ThemeData(',
109
+ ` colorScheme: ColorScheme.fromSeed(seedColor: ${seed ? `AppColors.${seed}` : 'Colors.deepPurple'}),`,
110
+ ' useMaterial3: true,',
111
+ ' );',
112
+ '',
113
+ ' static ThemeData get dark => ThemeData(',
114
+ ` colorScheme: ColorScheme.fromSeed(seedColor: ${seed ? `AppColors.${seed}` : 'Colors.deepPurple'}, brightness: Brightness.dark),`,
115
+ ' useMaterial3: true,',
116
+ ' );',
117
+ '}',
118
+ '',
119
+ ].join('\n');
120
+ if (seed)
121
+ t = addDartImport(t, importUri(themeFile, colorsFile, c));
122
+ summary.push(`Create ${themeFile}: AppTheme.light / AppTheme.dark${seed ? ` seeded from AppColors.${seed}` : ''}`);
123
+ if (!seed)
124
+ warnings.push('AppTheme uses Colors.deepPurple until you set a color: codz theme set primary #6750A4');
125
+ }
126
+ if (token.kind === 'font') {
127
+ const font = `fontFamily: '${token.value.replace(/'/g, "\\'")}',`;
128
+ if (/fontFamily:\s*'[^']*',/.test(t))
129
+ t = t.replace(/fontFamily:\s*'[^']*',/g, font);
130
+ else
131
+ t = t.replace(/(ThemeData\(\n)([ \t]*)/g, (_m, a, ind) => `${a}${ind}${font}\n${ind}`);
132
+ summary.push(`Use the font ${token.value} in ${themeFile}`);
133
+ warnings.push(`Add the ${token.value} font files under fonts: in pubspec.yaml (or use the google_fonts package), or Flutter falls back to the default font.`);
134
+ }
135
+ save(themeFile, t);
136
+ }
137
+ else if (token.kind === 'color') {
138
+ // An AppTheme that seeds from a literal color switches to this one when it is the primary.
139
+ const t = read(themeFile);
140
+ if (name === 'primary' && /seedColor:\s*Colors\.\w+/.test(t)) {
141
+ let next = t.replace(/seedColor:\s*Colors\.\w+/g, 'seedColor: AppColors.primary');
142
+ next = addDartImport(next.replace(/\r\n/g, '\n'), importUri(themeFile, colorsFile, c));
143
+ save(themeFile, next);
144
+ summary.push(`Seed ${themeFile} from AppColors.primary`);
145
+ }
146
+ }
147
+ // Wire AppTheme into the app.
148
+ const mainFile = 'lib/main.dart';
149
+ const main = read(mainFile);
150
+ if (main === null)
151
+ return;
152
+ if (/\bAppTheme\.light\b/.test(main))
153
+ return;
154
+ const app = /\b(?:GetMaterialApp|MaterialApp)(?:\.router)?\s*\(/.exec(main);
155
+ if (!app) {
156
+ warnings.push(`No MaterialApp in ${mainFile}; pass theme: AppTheme.light to it yourself.`);
157
+ return;
158
+ }
159
+ const open = app.index + app[0].length - 1;
160
+ const close = matchBrace(main, open, 'dart');
161
+ const args = main.slice(open, close);
162
+ const indent = main.slice(main.lastIndexOf('\n', app.index) + 1).match(/^[ \t]*/)[0];
163
+ let next = main.replace(/\r\n/g, '\n');
164
+ const themeArg = /\n?([ \t]*)theme:\s*/.exec(args);
165
+ if (themeArg) {
166
+ // Only the template theme (fromSeed + useMaterial3) is replaced; a hand-built one stays.
167
+ const vStart = open + themeArg.index + themeArg[0].length;
168
+ const vEnd = /^ThemeData\s*\(/.test(main.slice(vStart)) ? matchBrace(main, main.indexOf('(', vStart), 'dart') + 1 : -1;
169
+ const value = vEnd > 0 ? main.slice(vStart, vEnd) : '';
170
+ const stripped = value.replace(/\s+/g, '');
171
+ if (/^ThemeData\(colorScheme:ColorScheme\.fromSeed\(seedColor:[\w.]+\),(useMaterial3:true,?)?\)$/.test(stripped)) {
172
+ next = main.slice(0, vStart) + 'AppTheme.light' + main.slice(vEnd);
173
+ next = next.replace(/\r\n/g, '\n');
174
+ summary.push(`Update ${mainFile}: theme: AppTheme.light (replaces the template theme)`);
175
+ }
176
+ else {
177
+ warnings.push(`${mainFile} already builds its own theme; switch it to theme: AppTheme.light to use these tokens.`);
178
+ return;
179
+ }
180
+ }
181
+ else {
182
+ const inner = main.slice(open + 1, close);
183
+ const add = `\n${indent} theme: AppTheme.light,\n${indent} darkTheme: AppTheme.dark,`;
184
+ next = inner.includes('\n')
185
+ ? main.slice(0, open + 1) + add + main.slice(open + 1)
186
+ : `${main.slice(0, open + 1)}${add}${inner.trim() ? `\n${indent} ${inner.trim().replace(/,$/, '')},` : ''}\n${indent}${main.slice(close)}`;
187
+ next = next.replace(/\r\n/g, '\n');
188
+ summary.push(`Update ${mainFile}: theme: AppTheme.light, darkTheme: AppTheme.dark`);
189
+ }
190
+ next = addDartImport(next, importUri(mainFile, themeFile, c));
191
+ files.set(mainFile, withEol(next, detectEol(main)));
192
+ }
193
+ // ---------------- Next.js ----------------
194
+ const CSS_CANDIDATES = ['app/globals.css', 'src/app/globals.css', 'styles/globals.css', 'src/styles/globals.css'];
195
+ function tailwindConfig(root) {
196
+ return ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'].find((f) => exists(join(root, f)));
197
+ }
198
+ function cssVarName(token, v4) {
199
+ const k = kebab(token.name).replace(/^(color|font|radius|spacing)-/, '');
200
+ if (token.kind === 'color')
201
+ return `--color-${k}`;
202
+ if (token.kind === 'font')
203
+ return v4 ? `--font-${/^(font|font-family|fontfamily)$/i.test(token.name) ? 'sans' : k}` : `--font-${/^(font|font-family|fontfamily)$/i.test(token.name) ? 'sans' : k}`;
204
+ if (/radius/i.test(token.name))
205
+ return `--radius-${k === 'radius' ? 'DEFAULT' : k}`.replace('--radius-DEFAULT', '--radius');
206
+ return `--spacing-${k}`;
207
+ }
208
+ function cssValue(token) {
209
+ if (token.kind === 'color')
210
+ return `#${token.value.toLowerCase()}`;
211
+ if (token.kind === 'size')
212
+ return `${token.value}px`;
213
+ return `"${token.value}", ui-sans-serif, system-ui, sans-serif`;
214
+ }
215
+ /** Sets a custom property inside the block that starts at `open`. */
216
+ function setCssVar(css, open, name, value) {
217
+ const close = css.indexOf('}', open);
218
+ const body = css.slice(open, close);
219
+ const re = new RegExp(`(^|\\n)([ \\t]*)${name.replace(/[-]/g, '\\-')}\\s*:[^;]*;`);
220
+ const m = re.exec(body);
221
+ if (m)
222
+ return { text: css.slice(0, open + m.index) + `${m[1]}${m[2]}${name}: ${value};` + css.slice(open + m.index + m[0].length), updated: true };
223
+ const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? ' ';
224
+ const before = css.slice(0, close).replace(/[ \t]*$/, '');
225
+ return { text: `${before}${before.endsWith('\n') ? '' : '\n'}${indent}${name}: ${value};\n${css.slice(close)}`, updated: false };
226
+ }
227
+ function planNextTheme(root, map, token, files, summary, warnings) {
228
+ const cssFile = CSS_CANDIDATES.find((f) => exists(join(root, f))) ?? (exists(join(root, 'src/app')) ? 'src/app/globals.css' : 'app/globals.css');
229
+ const orig = readText(join(root, cssFile));
230
+ const created = orig === null && !files.has(cssFile);
231
+ let css = (files.get(cssFile) ?? orig ?? '').replace(/\r\n/g, '\n');
232
+ const v4 = /@import\s+["']tailwindcss["']/.test(css);
233
+ const twConfig = !v4 ? tailwindConfig(root) : undefined;
234
+ if (twConfig) {
235
+ // Tailwind v3: theme.extend.<group>.<name>
236
+ const group = token.kind === 'color' ? 'colors' : token.kind === 'font' ? 'fontFamily' : /radius/i.test(token.name) ? 'borderRadius' : 'spacing';
237
+ const key = token.kind === 'font' && /^(font|font-family|fontfamily)$/i.test(token.name) ? 'sans' : camel(token.name).replace(/Radius$|^radius$/, (m) => (m === 'radius' ? 'DEFAULT' : ''));
238
+ const value = token.kind === 'color' ? `'#${token.value.toLowerCase()}'` : token.kind === 'size' ? `'${token.value}px'` : `['${token.value}', 'ui-sans-serif', 'system-ui', 'sans-serif']`;
239
+ const t = (files.get(twConfig) ?? readText(join(root, twConfig))).replace(/\r\n/g, '\n');
240
+ const theme = /\btheme\s*:\s*\{/.exec(t);
241
+ if (!theme)
242
+ throw new ToolError('no_theme', `${twConfig} has no theme: {} section.`, 'Add theme: { extend: {} } to it, then run this again.');
243
+ let next = t;
244
+ const ensureObj = (text, open, prop) => {
245
+ const close = matchBrace(text, open, 'ts');
246
+ const inner = text.slice(open, close);
247
+ const m = new RegExp(`\\b${prop}\\s*:\\s*\\{`).exec(inner);
248
+ if (m)
249
+ return [text, open + m.index + m[0].length - 1];
250
+ const indent = text.slice(text.lastIndexOf('\n', open) + 1).match(/^[ \t]*/)[0] + ' ';
251
+ const ins = `\n${indent}${prop}: {\n${indent}},`;
252
+ const n = text.slice(0, open + 1) + ins + text.slice(open + 1);
253
+ return [n, open + 1 + ins.indexOf('{')];
254
+ };
255
+ let pos;
256
+ [next, pos] = ensureObj(next, theme.index + theme[0].length - 1, 'extend');
257
+ [next, pos] = ensureObj(next, pos, group);
258
+ const close = matchBrace(next, pos, 'ts');
259
+ const inner = next.slice(pos, close);
260
+ const keyRe = new RegExp(`(\\n[ \\t]*)['"]?${key}['"]?\\s*:\\s*(\\[[^\\]]*\\]|'[^']*'|"[^"]*"|[^,\\n]+),?`);
261
+ const m = keyRe.exec(inner);
262
+ const prop = /^[A-Za-z_]\w*$/.test(key) ? key : `'${key}'`;
263
+ if (m)
264
+ next = next.slice(0, pos + m.index) + `${m[1]}${prop}: ${value},` + next.slice(pos + m.index + m[0].length);
265
+ else {
266
+ const indent = next.slice(next.lastIndexOf('\n', pos) + 1).match(/^[ \t]*/)[0];
267
+ const before = next.slice(0, close).replace(/\s*$/, '');
268
+ next = `${before}${before.endsWith('{') || before.endsWith(',') ? '' : ','}\n${indent} ${prop}: ${value},\n${indent}${next.slice(close)}`;
269
+ }
270
+ files.set(twConfig, withEol(next, detectEol(readText(join(root, twConfig)))));
271
+ summary.push(`${m ? 'Update' : 'Add'} theme.extend.${group}.${key} = ${value} in ${twConfig}`);
272
+ summary.push(`Use it as a class: ${token.kind === 'color' ? `bg-${kebab(token.name)} text-${kebab(token.name)}` : token.kind === 'font' ? `font-${key === 'sans' ? 'sans' : kebab(key)}` : group === 'borderRadius' ? (key === 'DEFAULT' ? 'rounded' : `rounded-${kebab(key)}`) : `p-${kebab(key)} gap-${kebab(key)}`}`);
273
+ return;
274
+ }
275
+ const name = cssVarName(token, v4);
276
+ const value = cssValue(token);
277
+ let updated = false;
278
+ if (v4) {
279
+ // Tailwind v4: @theme { --color-primary: ... } makes bg-primary, text-primary, etc.
280
+ let theme = /@theme(?:\s+inline)?\s*\{/.exec(css);
281
+ if (!theme) {
282
+ const imp = /@import\s+["']tailwindcss["'];?\n?/.exec(css);
283
+ const at = imp.index + imp[0].length;
284
+ css = `${css.slice(0, at)}${imp[0].endsWith('\n') ? '' : '\n'}\n@theme {\n}\n${css.slice(at)}`;
285
+ theme = /@theme\s*\{/.exec(css);
286
+ }
287
+ ({ text: css, updated } = setCssVar(css, theme.index + theme[0].length - 1, name, value));
288
+ summary.push(`${updated ? 'Update' : 'Add'} ${name}: ${value} in the @theme block of ${cssFile}`);
289
+ const util = name.replace(/^--color-/, 'bg-').replace(/^--font-/, 'font-').replace(/^--radius-?/, 'rounded-').replace(/^--spacing-/, 'p-').replace(/-$/, '');
290
+ summary.push(`Use it as a class: ${util}`);
291
+ }
292
+ else {
293
+ let rootSel = /:root\s*\{/.exec(css);
294
+ if (!rootSel) {
295
+ css = `:root {\n}\n${css ? `\n${css}` : ''}`;
296
+ rootSel = /:root\s*\{/.exec(css);
297
+ }
298
+ ({ text: css, updated } = setCssVar(css, rootSel.index + rootSel[0].length - 1, name, value));
299
+ summary.push(`${created ? `Create ${cssFile}: ` : `${updated ? 'Update' : 'Add'} `}${name}: ${value} in :root`);
300
+ summary.push(`Use it in CSS: var(${name})`);
301
+ if (created) {
302
+ const layout = ['app/layout.tsx', 'src/app/layout.tsx', 'app/layout.jsx', 'src/app/layout.jsx'].find((f) => exists(join(root, f)));
303
+ if (layout) {
304
+ const lt = files.get(layout) ?? readText(join(root, layout));
305
+ if (!/globals\.css/.test(lt)) {
306
+ const rel = posix.relative(posix.dirname(layout), cssFile);
307
+ const imp = `import '${rel.startsWith('.') ? rel : `./${rel}`}';\n`;
308
+ const lastImport = [...lt.matchAll(/^import[^\n]*\n/gm)].pop();
309
+ const at = lastImport ? lastImport.index + lastImport[0].length : 0;
310
+ files.set(layout, lt.slice(0, at) + imp + lt.slice(at));
311
+ summary.push(`Update ${layout}: import the global styles`);
312
+ }
313
+ }
314
+ }
315
+ }
316
+ if (token.kind === 'font')
317
+ warnings.push(`Load ${token.value} too (next/font/google or a <link>), or the browser uses the fallback font.`);
318
+ files.set(cssFile, orig ? withEol(css, detectEol(orig)) : css);
319
+ void map;
320
+ }
321
+ // ---------------- React Native ----------------
322
+ /** src/theme/index.ts: colors, sizes and fonts objects that screens import. */
323
+ function planRnTheme(root, map, token, files, summary) {
324
+ const src = map.conventions.screensFolder?.startsWith('src/') || exists(join(root, 'src')) ? 'src/' : '';
325
+ const file = `${src}theme/index.ts`;
326
+ const semi = map.conventions.semicolons === 'false' ? '' : ';';
327
+ let t = files.get(file) ?? readText(join(root, file)) ?? `export const colors = {\n}${semi}\n\nexport const sizes = {\n}${semi}\n\nexport const fonts = {\n}${semi}\n`;
328
+ const obj = token.kind === 'color' ? 'colors' : token.kind === 'size' ? 'sizes' : 'fonts';
329
+ const key = token.kind === 'font' && /^(font|fontFamily|font-family|font-sans)$/i.test(token.name) ? 'body' : token.name.replace(/[-_](\w)/g, (_m, ch) => ch.toUpperCase());
330
+ const value = token.kind === 'color' ? `'#${token.value}'` : token.kind === 'size' ? token.value : `'${token.value}'`;
331
+ const block = new RegExp(`(export const ${obj}\\s*=\\s*\\{)([\\s\\S]*?)(\\n\\}\\s*(?:as const)?;?)`).exec(t);
332
+ if (!block)
333
+ t = `${t.replace(/\s*$/, '\n')}\nexport const ${obj} = {\n ${key}: ${value},\n}${semi}\n`;
334
+ else {
335
+ const line = new RegExp(`^(\\s*)${key}\\s*:.*$`, 'm');
336
+ const inner = line.test(block[2]) ? block[2].replace(line, `$1${key}: ${value},`) : `${block[2]}\n ${key}: ${value},`;
337
+ t = t.replace(block[0], `${block[1]}${inner}${block[3]}`);
338
+ }
339
+ files.set(file, t);
340
+ summary.push(`${readText(join(root, file)) === null ? 'Create' : 'Update'} ${file}: ${obj}.${key} = ${value}`);
341
+ summary.push(`Use it in a screen: import { ${obj} } from '${importPath(`${map.conventions.screensFolder || `${src}screens`}/X.tsx`, file, { ...map.conventions, importStyle: 'relative' }).replace(/\.ts$/, '')}'`);
342
+ }
343
+ /** Android: Color.kt / Dimens.kt values in ui/theme; primary also feeds the color scheme when Theme.kt uses Primary. */
344
+ function planAndroidTheme(root, map, token, files, summary, warnings) {
345
+ const c = map.conventions;
346
+ if (token.kind === 'font')
347
+ return void warnings.push(`Fonts are not set by codz on Android yet; add a FontFamily for ${token.value} to ui/theme/Type.kt.`);
348
+ const srcDir = c.srcDir || 'app/src/main/java';
349
+ const dir = `${srcDir}/${(c.namespace || 'app').replace(/\./g, '/')}/ui/theme`;
350
+ const pkg = `${c.namespace}.ui.theme`;
351
+ const file = `${dir}/${token.kind === 'color' ? 'Color' : 'Dimens'}.kt`;
352
+ const before = files.get(file) ?? readText(join(root, file));
353
+ let t = before ?? (token.kind === 'color' ? `package ${pkg}\n\nimport androidx.compose.ui.graphics.Color\n` : `package ${pkg}\n\nimport androidx.compose.ui.unit.dp\n`);
354
+ const name = token.name.replace(/[-_](\w)/g, (_m, ch) => ch.toUpperCase()).replace(/^\w/, (ch) => ch.toUpperCase());
355
+ const value = token.kind === 'color' ? `Color(0xFF${token.value.toUpperCase()})` : `${token.value}.dp`;
356
+ const line = new RegExp(`^val\\s+${name}\\s*=.*$`, 'm');
357
+ t = line.test(t) ? t.replace(line, `val ${name} = ${value}`) : `${t.replace(/\s*$/, '\n')}\nval ${name} = ${value}\n`;
358
+ if (token.kind === 'size' && !/import androidx\.compose\.ui\.unit\.dp/.test(t))
359
+ t = t.replace(/^(package [^\n]+\n)/, '$1\nimport androidx.compose.ui.unit.dp\n');
360
+ files.set(file, t);
361
+ summary.push(`${before === null ? 'Create' : 'Update'} ${file}: ${name} = ${value}`);
362
+ const themeKt = readText(join(root, dir, 'Theme.kt'));
363
+ if (token.kind === 'color' && name === 'Primary' && themeKt !== null && !/\bprimary\s*=\s*Primary\b/.test(themeKt))
364
+ warnings.push(`${dir}/Theme.kt does not use Primary in its color scheme; pass primary = Primary to lightColorScheme(...).`);
365
+ }
366
+ export async function planThemeSet(root, adapter, tokens) {
367
+ if (!tokens.length)
368
+ throw new ToolError('bad_args', 'Usage: codz theme set <name> <value> [<name> <value> ...]');
369
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
370
+ const files = new Map();
371
+ const summary = [];
372
+ const warnings = [];
373
+ // Colors first so a new AppTheme seeds from them.
374
+ const parsed = tokens.map((t) => parseToken(t.name, t.value)).sort((a, b) => (a.kind === 'color' ? -1 : 0) - (b.kind === 'color' ? -1 : 0));
375
+ for (const token of parsed) {
376
+ if (adapter.id === 'flutter')
377
+ planFlutterTheme(root, map, token, files, summary, warnings);
378
+ else if (adapter.id === 'nextjs')
379
+ planNextTheme(root, map, token, files, summary, warnings);
380
+ else if (adapter.id === 'react-native')
381
+ planRnTheme(root, map, token, files, summary);
382
+ else if (adapter.id === 'android')
383
+ planAndroidTheme(root, map, token, files, summary, warnings);
384
+ else
385
+ throw new ToolError('not_supported', `theme set needs the Next.js, Flutter, React Native or Android adapter; this project uses the ${adapter.id} adapter.`, adapter.id === 'react' ? 'For React web apps, set the color in your CSS (a :root variable) or your Tailwind config; theme set does not edit them yet.' : adapter.id === 'ios' ? 'For iOS, set the color in Assets.xcassets (AccentColor); theme set does not edit it yet.' : undefined);
386
+ }
387
+ return buildChangeSet(root, 'theme set', { files, summary: [...new Set(summary)], warnings: [...new Set(warnings)], generated: [] });
388
+ }
@@ -0,0 +1,144 @@
1
+ // Login for paid starters: the OAuth device-code flow (codz prints a short
2
+ // code, you approve it in the browser), a short-lived access token and a
3
+ // rotating refresh token in ~/.codz/auth.json (owner-only). CI uses
4
+ // CODZ_TOKEN instead. Tokens never appear in output, history or plans.
5
+ import { spawn } from 'node:child_process';
6
+ import { ToolError } from "../core/types.js";
7
+ import { readHomeFile, removeHome, writeHomeFile } from "./home.js";
8
+ import { apiError, checkUrl, request } from "./http.js";
9
+ import { registryConfig } from "./registry.js";
10
+ import { parseStrictJson, printable } from "./strictJson.js";
11
+ const api = (path) => new URL(path, `${registryConfig().api.href.replace(/\/?$/, '/')}`);
12
+ const json = (b, what) => parseStrictJson(b.toString('utf8'), what, { maxBytes: 64 * 1024 });
13
+ function load() {
14
+ const b = readHomeFile('auth.json');
15
+ if (!b)
16
+ return null;
17
+ try {
18
+ const a = JSON.parse(b.toString('utf8'));
19
+ return a.registry === registryConfig().api.href ? a : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function save(a) {
26
+ writeHomeFile(JSON.stringify(a), { secret: true }, 'auth.json');
27
+ }
28
+ function tokens(body, account) {
29
+ const access = body.access_token;
30
+ const refresh = body.refresh_token;
31
+ const ttl = Number(body.expires_in ?? 900);
32
+ if (typeof access !== 'string' || typeof refresh !== 'string' || !access || !refresh)
33
+ throw new ToolError('login_failed', 'The login server did not return tokens.', 'Try codz login again.');
34
+ return { accessToken: access, refreshToken: refresh, expiresAt: Date.now() + Math.max(30, ttl) * 1000, account: typeof body.account === 'string' ? printable(body.account).slice(0, 200) : account, registry: registryConfig().api.href };
35
+ }
36
+ export const auth = {
37
+ /** A usable access token, refreshed when it is about to expire; null when not logged in. */
38
+ async accessToken() {
39
+ if (process.env.CODZ_TOKEN)
40
+ return process.env.CODZ_TOKEN;
41
+ const a = load();
42
+ if (!a)
43
+ return undefined;
44
+ if (a.expiresAt - 60_000 > Date.now())
45
+ return a.accessToken;
46
+ const r = await request(api('v1/token/refresh'), { method: 'POST', json: { refresh_token: a.refreshToken }, maxBytes: 64 * 1024 });
47
+ if (r.status === 400 || r.status === 401) {
48
+ removeHome('auth.json');
49
+ throw new ToolError('token_expired', 'Your session has expired.', 'Run codz login again.');
50
+ }
51
+ if (r.status !== 200)
52
+ throw apiError(r, { hadToken: true, what: 'refreshing the login' });
53
+ const next = tokens(json(r.body, 'the refresh answer'), a.account);
54
+ save(next);
55
+ return next.accessToken;
56
+ },
57
+ account() {
58
+ if (process.env.CODZ_TOKEN)
59
+ return 'CODZ_TOKEN';
60
+ return load()?.account ?? null;
61
+ },
62
+ async login(opts) {
63
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
64
+ const r = await request(api('v1/device/code'), { method: 'POST', json: { client: 'codz-cli' }, maxBytes: 64 * 1024 });
65
+ if (r.status !== 200)
66
+ throw apiError(r, { hadToken: false, what: 'login' });
67
+ const d = json(r.body, 'the login answer');
68
+ const deviceCode = String(d.device_code ?? '');
69
+ const userCode = printable(String(d.user_code ?? '')).slice(0, 20);
70
+ const verify = checkUrl(String(d.verification_uri ?? ''), 'the login page');
71
+ let interval = Math.max(0, Number(d.interval ?? 5));
72
+ const deadline = Date.now() + Math.min(Number(d.expires_in ?? 900), 1800) * 1000;
73
+ if (!deviceCode || !userCode)
74
+ throw new ToolError('login_failed', 'The login server did not return a code.', 'Try codz login again.');
75
+ // The page must be on the catalog's own hosts; codz never opens a link a server made up elsewhere.
76
+ const cfg = registryConfig();
77
+ if (verify.host !== cfg.api.host && verify.host !== cfg.base.host && !verify.host.endsWith(`.${cfg.base.host}`))
78
+ throw new ToolError('login_failed', `The login page ${verify.host} is not on the catalog's site.`, 'Do not continue; report it.');
79
+ opts.print(`Open ${verify.href} and enter code: ${userCode}`);
80
+ if (opts.browser)
81
+ openBrowser(verify.href);
82
+ opts.print('Waiting for approval...');
83
+ for (;;) {
84
+ if (Date.now() > deadline)
85
+ throw new ToolError('login_expired', 'The login code expired before it was approved.', 'Run codz login again.');
86
+ await sleep(interval * 1000);
87
+ const p = await request(api('v1/device/token'), { method: 'POST', json: { device_code: deviceCode }, maxBytes: 64 * 1024 });
88
+ const body = p.body.length ? json(p.body, 'the login answer') : {};
89
+ if (p.status === 200) {
90
+ const a = tokens(body, 'your account');
91
+ save(a);
92
+ return { account: a.account };
93
+ }
94
+ const err = String(body.error ?? '');
95
+ if (err === 'authorization_pending')
96
+ continue;
97
+ if (err === 'slow_down') {
98
+ interval += 5;
99
+ continue;
100
+ }
101
+ if (err === 'access_denied')
102
+ throw new ToolError('login_denied', 'The login was declined in the browser.', 'Run codz login again to retry.');
103
+ if (err === 'expired_token')
104
+ throw new ToolError('login_expired', 'The login code expired before it was approved.', 'Run codz login again.');
105
+ throw apiError(p, { hadToken: false, what: 'login' });
106
+ }
107
+ },
108
+ async logout() {
109
+ const a = load();
110
+ let revoked = false;
111
+ if (a) {
112
+ try {
113
+ const r = await request(api('v1/token/revoke'), { method: 'POST', json: { refresh_token: a.refreshToken }, maxBytes: 64 * 1024 });
114
+ revoked = r.status === 200 || r.status === 204;
115
+ }
116
+ catch {
117
+ // Offline: the local copy is still deleted; the token can be revoked from the web account.
118
+ }
119
+ }
120
+ return { revoked, removed: removeHome('auth.json') };
121
+ },
122
+ async whoami() {
123
+ const token = await auth.accessToken();
124
+ if (!token)
125
+ throw new ToolError('not_logged_in', 'You are not logged in.', 'Run codz login.');
126
+ const r = await request(api('v1/me'), { token, maxBytes: 256 * 1024 });
127
+ if (r.status !== 200)
128
+ throw apiError(r, { hadToken: true, what: 'your account' });
129
+ const b = json(r.body, 'the account answer');
130
+ const owned = Array.isArray(b.owned) ? b.owned.filter((o) => typeof o?.id === 'string').map((o) => ({ id: printable(String(o.id)).slice(0, 64), updatesUntil: typeof o.updatesUntil === 'string' ? printable(o.updatesUntil).slice(0, 10) : undefined })) : [];
131
+ return { account: printable(String(b.account ?? auth.account() ?? '')).slice(0, 200), owned };
132
+ },
133
+ };
134
+ function openBrowser(url) {
135
+ if (process.env.CI || (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && process.platform === 'linux'))
136
+ return;
137
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
138
+ try {
139
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
140
+ }
141
+ catch {
142
+ // The URL is printed; the user can open it.
143
+ }
144
+ }