kctl-react 0.6.2__py3-none-any.whl

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 (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,155 @@
1
+ """Environment variable management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_react.core.callbacks import AppContext
11
+ from kctl_react.core.discovery import get_app_dir
12
+
13
+ app = typer.Typer(help="Environment variable management.")
14
+
15
+
16
+ def _find_env_files(app_dir: Path) -> list[Path]:
17
+ """Find all .env* files in an app directory."""
18
+ return sorted(f for f in app_dir.iterdir() if f.name.startswith(".env") and f.is_file())
19
+
20
+
21
+ def _parse_env_file(path: Path) -> dict[str, str]:
22
+ """Parse a .env file into key-value pairs."""
23
+ result: dict[str, str] = {}
24
+ if not path.exists():
25
+ return result
26
+ for line in path.read_text().splitlines():
27
+ line = line.strip()
28
+ if not line or line.startswith("#"):
29
+ continue
30
+ if "=" in line:
31
+ key, _, value = line.partition("=")
32
+ result[key.strip()] = value.strip().strip('"').strip("'")
33
+ return result
34
+
35
+
36
+ @app.command()
37
+ def show(
38
+ ctx: typer.Context,
39
+ app_name: Annotated[str, typer.Argument(help="App name")],
40
+ ) -> None:
41
+ """Show environment variables for an app."""
42
+ actx: AppContext = ctx.obj
43
+ out = actx.output
44
+ root = actx.project_root
45
+
46
+ actx.validate_app(app_name)
47
+ app_dir = get_app_dir(root, app_name)
48
+
49
+ env_files = _find_env_files(app_dir)
50
+ if not env_files:
51
+ out.warn(f"No .env files found in apps/{app_name}/")
52
+ return
53
+
54
+ for env_file in env_files:
55
+ env_vars = _parse_env_file(env_file)
56
+ sections: list[tuple[str, list[tuple[str, str]]]] = [
57
+ (env_file.name, [(k, v) for k, v in sorted(env_vars.items())]),
58
+ ]
59
+ out.detail(f"{app_name} — {env_file.name}", sections, data_for_json=env_vars)
60
+
61
+
62
+ @app.command()
63
+ def diff(
64
+ ctx: typer.Context,
65
+ app1: Annotated[str, typer.Argument(help="First app name")],
66
+ app2: Annotated[str, typer.Argument(help="Second app name")],
67
+ ) -> None:
68
+ """Compare .env files between two apps."""
69
+ actx: AppContext = ctx.obj
70
+ out = actx.output
71
+ root = actx.project_root
72
+
73
+ actx.validate_app(app1)
74
+ actx.validate_app(app2)
75
+
76
+ env1 = _parse_env_file(get_app_dir(root, app1) / ".env")
77
+ env2 = _parse_env_file(get_app_dir(root, app2) / ".env")
78
+
79
+ if not env1:
80
+ env1 = _parse_env_file(get_app_dir(root, app1) / ".env.local")
81
+ if not env2:
82
+ env2 = _parse_env_file(get_app_dir(root, app2) / ".env.local")
83
+
84
+ all_keys = sorted(set(env1.keys()) | set(env2.keys()))
85
+
86
+ rows: list[list[str]] = []
87
+ for key in all_keys:
88
+ v1 = env1.get(key, "[dim]--[/dim]")
89
+ v2 = env2.get(key, "[dim]--[/dim]")
90
+ match = "[green]=[/green]" if v1 == v2 else "[yellow]![/yellow]"
91
+ rows.append([key, v1, v2, match])
92
+
93
+ out.table(
94
+ f"Env Diff: {app1} vs {app2}",
95
+ [("Key", "cyan"), (app1, ""), (app2, ""), ("Match", "")],
96
+ rows,
97
+ )
98
+
99
+
100
+ @app.command()
101
+ def validate(
102
+ ctx: typer.Context,
103
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
104
+ ) -> None:
105
+ """Validate .env files exist and have required keys."""
106
+ actx: AppContext = ctx.obj
107
+ out = actx.output
108
+ root = actx.project_root
109
+
110
+ apps_to_check = [app_name] if app_name else actx.app_names
111
+ if app_name:
112
+ actx.validate_app(app_name)
113
+
114
+ # Common expected keys for Kodemeio React apps
115
+ expected_keys = {"VITE_ODOO_URL", "VITE_ODOO_DB", "VITE_AUTH_MODE"}
116
+
117
+ rows: list[list[str]] = []
118
+ issues = 0
119
+
120
+ for name in apps_to_check:
121
+ app_dir = get_app_dir(root, name)
122
+ env_files = _find_env_files(app_dir)
123
+
124
+ if not env_files:
125
+ rows.append([name, "[red]missing[/red]", "[dim]--[/dim]", "[red]no .env file[/red]"])
126
+ issues += 1
127
+ continue
128
+
129
+ env = _parse_env_file(env_files[0])
130
+ missing = expected_keys - set(env.keys())
131
+ file_name = env_files[0].name
132
+
133
+ if missing:
134
+ rows.append(
135
+ [
136
+ name,
137
+ f"[green]{file_name}[/green]",
138
+ str(len(env)),
139
+ f"[yellow]missing: {', '.join(sorted(missing))}[/yellow]",
140
+ ]
141
+ )
142
+ issues += 1
143
+ else:
144
+ rows.append([name, f"[green]{file_name}[/green]", str(len(env)), "[green]OK[/green]"])
145
+
146
+ out.table(
147
+ "Env Validation",
148
+ [("App", "cyan"), ("File", ""), ("Keys", ""), ("Status", "")],
149
+ rows,
150
+ )
151
+
152
+ if issues:
153
+ out.warn(f"{issues} app(s) have env issues")
154
+ else:
155
+ out.success("All apps have valid env files")
@@ -0,0 +1,310 @@
1
+ """Translation management for react-i18next apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_react.core.analyzers import check_interpolation_vars
12
+ from kctl_react.core.callbacks import AppContext
13
+
14
+ app = typer.Typer(help="Translation management (react-i18next).")
15
+
16
+
17
+ def _find_i18n_dir(app_dir: Path) -> Path | None:
18
+ """Find the i18n directory — src/i18n/ (react-i18next) or messages/ (next-intl)."""
19
+ for candidate in (app_dir / "src" / "i18n", app_dir / "messages"):
20
+ if candidate.is_dir() and any(candidate.glob("*.json")):
21
+ return candidate
22
+ return None
23
+
24
+
25
+ def _load_translations(app_dir: Path) -> dict[str, dict]:
26
+ """Load translation JSON files from app's i18n directory."""
27
+ i18n_dir = _find_i18n_dir(app_dir)
28
+ if i18n_dir is None:
29
+ return {}
30
+ result = {}
31
+ for f in i18n_dir.glob("*.json"):
32
+ lang = f.stem
33
+ try:
34
+ result[lang] = json.loads(f.read_text())
35
+ except json.JSONDecodeError:
36
+ result[lang] = {}
37
+ return result
38
+
39
+
40
+ def _flatten_keys(d: dict, prefix: str = "") -> set[str]:
41
+ """Flatten nested dict into dot-separated key set."""
42
+ keys = set()
43
+ for k, v in d.items():
44
+ full_key = f"{prefix}.{k}" if prefix else k
45
+ if isinstance(v, dict):
46
+ keys.update(_flatten_keys(v, full_key))
47
+ else:
48
+ keys.add(full_key)
49
+ return keys
50
+
51
+
52
+ @app.command()
53
+ def coverage(
54
+ ctx: typer.Context,
55
+ app_name: Annotated[str | None, typer.Argument(help="App name")] = None,
56
+ all_apps: Annotated[bool, typer.Option("--all", help="All apps")] = False,
57
+ ) -> None:
58
+ """Translation coverage — compare en.json vs id.json."""
59
+ actx: AppContext = ctx.obj
60
+ out = actx.output
61
+ apps_to_check = list(actx.apps.keys()) if all_apps else ([app_name] if app_name else [])
62
+ if not apps_to_check:
63
+ out.error("Specify app name or --all")
64
+ raise typer.Exit(1) from None
65
+ rows, json_data = [], []
66
+ for name in apps_to_check:
67
+ actx.validate_app(name)
68
+ translations = _load_translations(actx.get_app_dir(name))
69
+ en_keys = _flatten_keys(translations.get("en", {}))
70
+ id_keys = _flatten_keys(translations.get("id", {}))
71
+ total = len(en_keys)
72
+ translated = len(en_keys & id_keys)
73
+ pct = (translated / total * 100) if total > 0 else 0
74
+ missing = len(en_keys - id_keys)
75
+ rows.append([name, str(total), str(translated), f"{pct:.0f}%", str(missing)])
76
+ json_data.append(
77
+ {
78
+ "app": name,
79
+ "total_keys": total,
80
+ "translated": translated,
81
+ "coverage_pct": round(pct, 1),
82
+ "missing": missing,
83
+ }
84
+ )
85
+ out.table(
86
+ "i18n Coverage",
87
+ [("App", "cyan"), ("Total", ""), ("Translated", "green"), ("Coverage", ""), ("Missing", "red")],
88
+ rows,
89
+ data_for_json=json_data,
90
+ )
91
+
92
+
93
+ @app.command()
94
+ def missing(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
95
+ """List keys in en.json but not in id.json."""
96
+ actx: AppContext = ctx.obj
97
+ out = actx.output
98
+ actx.validate_app(app_name)
99
+ translations = _load_translations(actx.get_app_dir(app_name))
100
+ en_keys = _flatten_keys(translations.get("en", {}))
101
+ id_keys = _flatten_keys(translations.get("id", {}))
102
+ missing_keys = sorted(en_keys - id_keys)
103
+ if not missing_keys:
104
+ out.success("All keys translated")
105
+ return
106
+ rows = [[k] for k in missing_keys]
107
+ out.table(f"Missing Translations — {app_name}", [("Key", "yellow")], rows)
108
+
109
+
110
+ @app.command()
111
+ def unused(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
112
+ """Find translation keys not referenced in source code."""
113
+ actx: AppContext = ctx.obj
114
+ out = actx.output
115
+ actx.validate_app(app_name)
116
+ app_dir = actx.get_app_dir(app_name)
117
+ translations = _load_translations(app_dir)
118
+ all_keys = _flatten_keys(translations.get("en", {}))
119
+ # Grep source for t("key") or t('key') patterns
120
+ import re
121
+
122
+ used_keys: set[str] = set()
123
+ src_dir = app_dir / "src"
124
+ for f in src_dir.rglob("*.tsx"):
125
+ content = f.read_text(errors="ignore")
126
+ used_keys.update(re.findall(r't\(["\']([^"\']+)["\']\)', content))
127
+ unused_keys = sorted(all_keys - used_keys)
128
+ if not unused_keys:
129
+ out.success("No unused keys found")
130
+ return
131
+ rows = [[k] for k in unused_keys[:50]]
132
+ out.table(
133
+ f"Unused Keys — {app_name} (showing {min(50, len(unused_keys))}/{len(unused_keys)})", [("Key", "dim")], rows
134
+ )
135
+
136
+
137
+ @app.command()
138
+ def sort(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
139
+ """Sort translation JSON keys alphabetically."""
140
+ actx: AppContext = ctx.obj
141
+ out = actx.output
142
+ actx.validate_app(app_name)
143
+ i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
144
+ if i18n_dir is None:
145
+ out.error(f"No i18n directory found for {app_name}")
146
+ raise typer.Exit(1) from None
147
+ for f in i18n_dir.glob("*.json"):
148
+ data = json.loads(f.read_text())
149
+ sorted_data = json.dumps(dict(sorted(data.items())), indent=2, ensure_ascii=False) + "\n"
150
+ f.write_text(sorted_data)
151
+ out.success(f"Sorted {f.name}")
152
+
153
+
154
+ def _sync_stub_recursive(en: dict, id_: dict) -> int:
155
+ """Recursively add missing keys from en to id with [ID] prefix."""
156
+ added = 0
157
+ for key, value in en.items():
158
+ if key not in id_:
159
+ if isinstance(value, dict):
160
+ id_[key] = {}
161
+ added += _sync_stub_recursive(value, id_[key])
162
+ else:
163
+ id_[key] = f"[ID] {value}"
164
+ added += 1
165
+ elif isinstance(value, dict) and isinstance(id_[key], dict):
166
+ added += _sync_stub_recursive(value, id_[key])
167
+ return added
168
+
169
+
170
+ @app.command()
171
+ def diff(
172
+ ctx: typer.Context,
173
+ app_name: Annotated[str, typer.Argument(help="App name")],
174
+ base: Annotated[str, typer.Option("--base", help="Git ref to compare")] = "HEAD~1",
175
+ ) -> None:
176
+ """Show keys added/removed between git refs."""
177
+ actx: AppContext = ctx.obj
178
+ out = actx.output
179
+ actx.validate_app(app_name)
180
+ from kctl_react.core.runner import run_quiet
181
+
182
+ i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
183
+ if i18n_dir is None:
184
+ out.info("No i18n directory found")
185
+ return
186
+ en_path = str((i18n_dir / "en.json").relative_to(actx.project_root))
187
+ result = run_quiet(["git", "diff", base, "--", en_path], cwd=actx.project_root)
188
+ if result.returncode != 0 or not result.stdout.strip():
189
+ out.info("No translation changes")
190
+ return
191
+ out.text(result.stdout)
192
+
193
+
194
+ @app.command()
195
+ def validate(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
196
+ """Validate all translation JSON files for syntax and required keys."""
197
+ actx: AppContext = ctx.obj
198
+ out = actx.output
199
+ actx.validate_app(app_name)
200
+ i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
201
+
202
+ if i18n_dir is None:
203
+ out.error(f"No i18n directory found for {app_name} (checked src/i18n/ and messages/)")
204
+ raise typer.Exit(1) from None
205
+
206
+ issues: list[list[str]] = []
207
+ json_data: list[dict] = []
208
+
209
+ for f in sorted(i18n_dir.glob("*.json")):
210
+ try:
211
+ data = json.loads(f.read_text())
212
+ if not isinstance(data, dict):
213
+ issues.append([f.name, "root is not an object"])
214
+ json_data.append({"file": f.name, "valid": False, "issue": "root is not an object"})
215
+ else:
216
+ json_data.append({"file": f.name, "valid": True, "issue": ""})
217
+ except json.JSONDecodeError as exc:
218
+ issues.append([f.name, str(exc)])
219
+ json_data.append({"file": f.name, "valid": False, "issue": str(exc)})
220
+
221
+ # Check required files
222
+ for required in ("en.json", "id.json"):
223
+ if not (i18n_dir / required).exists():
224
+ issues.append([required, "file missing"])
225
+ json_data.append({"file": required, "valid": False, "issue": "file missing"})
226
+
227
+ if issues:
228
+ out.table(
229
+ f"i18n Validation Issues — {app_name}",
230
+ [("File", "yellow"), ("Issue", "red")],
231
+ issues,
232
+ data_for_json=json_data,
233
+ )
234
+ raise typer.Exit(1) from None
235
+
236
+ out.success(f"All translation files valid for {app_name}")
237
+
238
+
239
+ @app.command()
240
+ def interpolation(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
241
+ """Check {{var}} placeholder consistency between en.json and id.json."""
242
+ actx: AppContext = ctx.obj
243
+ out = actx.output
244
+ actx.validate_app(app_name)
245
+ app_dir = actx.get_app_dir(app_name)
246
+
247
+ translations = _load_translations(app_dir)
248
+ en = translations.get("en", {})
249
+ id_ = translations.get("id", {})
250
+
251
+ mismatches = check_interpolation_vars(en, id_)
252
+
253
+ if not mismatches:
254
+ out.success(f"No interpolation mismatches in {app_name}")
255
+ return
256
+
257
+ rows = [
258
+ [
259
+ m["key"],
260
+ ", ".join(sorted(m["en_vars"])) or "(none)", # type: ignore[arg-type]
261
+ ", ".join(sorted(m["id_vars"])) or "(none)", # type: ignore[arg-type]
262
+ ", ".join(sorted(m["missing_in_id"])) or "", # type: ignore[arg-type]
263
+ ]
264
+ for m in mismatches
265
+ ]
266
+ json_data = [
267
+ {
268
+ "key": m["key"],
269
+ "en_vars": sorted(m["en_vars"]), # type: ignore[arg-type]
270
+ "id_vars": sorted(m["id_vars"]), # type: ignore[arg-type]
271
+ "missing_in_id": sorted(m["missing_in_id"]), # type: ignore[arg-type]
272
+ }
273
+ for m in mismatches
274
+ ]
275
+ out.table(
276
+ f"Interpolation Mismatches — {app_name}",
277
+ [("Key", "cyan"), ("EN vars", ""), ("ID vars", ""), ("Missing in ID", "red")],
278
+ rows,
279
+ data_for_json=json_data,
280
+ )
281
+
282
+
283
+ @app.command(name="sync-stub")
284
+ def sync_stub(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
285
+ """Generate stub entries in id.json for keys missing from en.json."""
286
+ actx: AppContext = ctx.obj
287
+ out = actx.output
288
+ actx.validate_app(app_name)
289
+ i18n_dir = _find_i18n_dir(actx.get_app_dir(app_name))
290
+ if i18n_dir is None:
291
+ out.error(f"No i18n directory found for {app_name}")
292
+ raise typer.Exit(1) from None
293
+ en_file = i18n_dir / "en.json"
294
+ id_file = i18n_dir / "id.json"
295
+
296
+ if not en_file.exists():
297
+ out.error(f"en.json not found: {en_file}")
298
+ raise typer.Exit(1) from None
299
+
300
+ en = json.loads(en_file.read_text())
301
+ id_ = json.loads(id_file.read_text()) if id_file.exists() else {}
302
+
303
+ added = _sync_stub_recursive(en, id_)
304
+
305
+ id_file.write_text(json.dumps(id_, indent=2, ensure_ascii=False) + "\n")
306
+
307
+ if added == 0:
308
+ out.success(f"No missing keys in {app_name} — id.json is complete")
309
+ else:
310
+ out.success(f"Added {added} stub entr{'y' if added == 1 else 'ies'} to id.json for {app_name}")