Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"branding": {
"appName": "Compass",
"browserTabTitle": "Compass",
"metaDescription": "Welcome to Compass! An AI-powered career assistant that helps jobseekers identify and showcase their skills. Join now to create a digital profile and connect with new opportunities.",
"seo": {
"name": "Compass",
"url": "https://www.example.org/compass",
"image": "https://www.example.org/assets/logo.svg",
"description": "Compass is an AI-powered conversational tool that helps jobseekers discover and articulate their skills. Through natural dialogue, Compass guides users to identify abilities gained through all types of work-formal, informal, and unpaid. It creates standardized digital profiles that can connect to opportunities."
},
"assets": {
"logo": "/logo.svg",
"favicon": "/favicon.svg",
"appIcon": "/compass.svg"
},
"theme": {
"brand-primary": "0 255 145",
"brand-primary-light": "51 255 167",
"brand-primary-dark": "0 178 101",
"brand-primary-contrast-text": "0 0 0",
"brand-secondary": "30 113 102",
"brand-secondary-light": "77 154 143",
"brand-secondary-dark": "21 79 71",
"brand-secondary-contrast-text": "0 0 0",
"text-primary": "0 33 71",
"text-secondary": "65 64 61",
"text-accent": "38 94 167"
}
}
}
248 changes: 248 additions & 0 deletions config/inject-config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import json
import argparse
import sys
import re
from pathlib import Path
from typing import Dict, Any, Optional, Iterable, Tuple


DEFAULT_BACKEND_ENV_PATH = '../backend/.env'
DEFAULT_FRONTEND_ENV_PATH = '../frontend-new/public/data/env.js'

BACKEND_ENV_MAP: Dict[str, str] = {
'GLOBAL_PRODUCT_NAME': 'branding.appName',
# Add more backend env vars here
}

FRONTEND_ENV_MAP: Dict[str, str] = {
'GLOBAL_PRODUCT_NAME': 'branding.appName',
'FRONTEND_BROWSER_TAB_TITLE': 'branding.browserTabTitle',
'FRONTEND_META_DESCRIPTION': 'branding.metaDescription',
'FRONTEND_LOGO_URL': 'branding.assets.logo',
'FRONTEND_FAVICON_URL': 'branding.assets.favicon',
'FRONTEND_APP_ICON_URL': 'branding.assets.appIcon',
'FRONTEND_THEME_CSS_VARIABLES': 'branding.theme',
'FRONTEND_SEO': 'branding.seo',
# Add more frontend env.js keys here
}

FRONTEND_JSON_FIELDS = {
'FRONTEND_THEME_CSS_VARIABLES',
'FRONTEND_SEO',
# Add more JSON.stringify fields here
}


def read_json(path: Path) -> Dict[str, Any]:
try:
return json.loads(path.read_text())
except FileNotFoundError:
print(f"Error: Config file not found at '{path}'")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in config file: {e}")
sys.exit(1)


def get_by_dotted_path(obj: Any, dotted_path: str) -> Any:
"""Resolve a dotted path like 'branding.assets.logo' in a dict-like object."""
current = obj
for part in dotted_path.split('.'):
if current is None:
return None
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current


def coerce_env_value(value: Any) -> Optional[str]:
"""Coerce values for .env writing."""
if value is None:
return None
if isinstance(value, bool):
# Backend env conventions vary; keep simple true/false
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
v = value.strip()
return v if v != '' else None

# For dict/list: store JSON
if isinstance(value, (dict, list)):
return json.dumps(value)

return str(value)


def build_env_updates(config: Dict[str, Any], mapping: Dict[str, str], namespaces: Optional[Iterable[str]] = None) -> Dict[str, Any]:
"""Build env var updates based on selected namespaces.

If namespaces is provided, only mapping entries whose dotted-path starts with one of them are applied.
"""
selected = set(namespaces) if namespaces else None

updates: Dict[str, Any] = {}
for env_key, dotted_path in mapping.items():
top = dotted_path.split('.', 1)[0]
if selected is not None and top not in selected:
continue
value = get_by_dotted_path(config, dotted_path)
if value is None:
continue
updates[env_key] = value
return updates


def upsert_env_lines(lines: list[str], key: str, value: str) -> list[str]:
"""Upsert a KEY=VALUE line into .env content preserving other lines."""
new_line = f"{key}={value}\n"
out: list[str] = []
found = False

for line in lines:
if not found and line.lstrip().startswith(f"{key}="):
out.append(new_line)
found = True
else:
out.append(line)

if not found:
if out and not out[-1].endswith('\n'):
out[-1] += '\n'
out.append(new_line)

return out


def update_backend_env(config: Dict[str, Any], env_path: Path, namespaces: Optional[Iterable[str]] = None):
if not env_path.exists():
print(f"Error: Backend .env file not found at '{env_path}'")
sys.exit(1)

updates_raw = build_env_updates(config, BACKEND_ENV_MAP, namespaces)

if not updates_raw:
return

lines = env_path.read_text().splitlines(keepends=True)

for key, raw_value in updates_raw.items():
value = coerce_env_value(raw_value)
if value is None:
continue
lines = upsert_env_lines(lines, key, value)

env_path.write_text(''.join(lines))


def extract_existing_frontend_json_value(content: str, key: str) -> Dict[str, Any]:
"""Extract the existing JSON.stringify payload for a key in env.js"""
pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\(\s*JSON\.stringify\((.*?)\)\s*\),'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The regex pattern in extract_existing_frontend_json_value uses non-greedy matching (.*?) which will fail if a JSON string value contains a closing parenthesis ).
Severity: HIGH

Suggested Fix

The regex needs to be more robust to handle nested parentheses. Instead of relying on a non-greedy match to the first parenthesis, the pattern should correctly parse the balanced parentheses of the JSON.stringify() call. This might involve a more complex regex or a different parsing strategy that doesn't rely on regex for structured data.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: config/inject-config.py#L143

Potential issue: The regex patterns at lines 143 and 202-204 use non-greedy matching
`(.*?)` to extract and replace JSON values. If a user-provided string value within the
JSON, such as a `description`, contains a closing parenthesis (e.g., "See our FAQ
(info)"), the regex will match incorrectly. This causes
`extract_existing_frontend_json_value()` to fail silently and return an empty
dictionary. Consequently, the script will fail to replace the existing configuration
line and will instead insert a new, duplicate line, leading to a corrupted `env.js` file
and potential data loss for existing keys.

match = re.search(pattern, content, re.DOTALL)
if not match:
return {}
try:
return json.loads(match.group(1).strip())
except Exception:
return {}


def insert_before_closing_brace(content: str, new_line: str) -> str:
content = content.rstrip()
if content.endswith('};'):
return content[:-2] + new_line + '};\n'
return content + new_line


def format_frontend_replacement(key: str, raw_value: Any, existing_content: str) -> Tuple[str, int]:
"""Return (replacement_string, regex_flags)."""
if key in FRONTEND_JSON_FIELDS:
# Merge dicts if possible so config can override but keep existing keys
existing = extract_existing_frontend_json_value(existing_content, key)
if isinstance(raw_value, dict) and isinstance(existing, dict):
merged = {**existing, **raw_value}
else:
merged = raw_value

formatted_json = json.dumps(merged, indent=2).replace('\n', '\n ')
return f'{key}: btoa(\n JSON.stringify({formatted_json})\n ),', re.DOTALL

if isinstance(raw_value, bool):
rendered = 'true' if raw_value else 'false'
elif raw_value is None:
rendered = ''
else:
rendered = str(raw_value)

rendered = rendered.replace('\\', '\\\\').replace('"', '\\"')
return f'{key}: btoa("{rendered}"),', 0

Comment thread
sentry[bot] marked this conversation as resolved.

def update_frontend_env(config: Dict[str, Any], env_js_path: Path, namespaces: Optional[Iterable[str]] = None):
if not env_js_path.exists():
print(f"Error: Frontend env.js file not found at '{env_js_path}'")
sys.exit(1)

content = env_js_path.read_text()

updates = build_env_updates(config, FRONTEND_ENV_MAP, namespaces)
if not updates:
return

# Clean up multiple consecutive empty lines
content = re.sub(r'\n\n+', '\n', content)

for key, raw_value in updates.items():
replacement, flags = format_frontend_replacement(key, raw_value, content)

if key in FRONTEND_JSON_FIELDS:
pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\(\s*JSON\.stringify\(.*?\)\s*\),'
else:
pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\([^)]+\),'

if re.search(pattern, content, flags):
content = re.sub(pattern, replacement, content, count=1, flags=flags)
else:
content = insert_before_closing_brace(content, f' {replacement}\n')

env_js_path.write_text(content)


def parse_args(argv: Optional[list[str]] = None):
parser = argparse.ArgumentParser(
description='Inject namespaced config into backend .env and frontend env.js',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 inject-config.py --config default.json
python3 inject-config.py --config default.json --namespaces branding auth
python3 inject-config.py --config default.json --backend-env backend/.env --frontend-env frontend-new/public/data/env.js
"""
)

parser.add_argument('--config', required=True, metavar='CONFIG_FILE', help='Path to config JSON (e.g., default.json)')
parser.add_argument('--backend-env', default=DEFAULT_BACKEND_ENV_PATH, help='Path to backend .env')
parser.add_argument('--frontend-env', default=DEFAULT_FRONTEND_ENV_PATH, help='Path to frontend env.js')
parser.add_argument('--namespaces', nargs='*', default=None,
help='Only apply these top-level namespaces (e.g., branding, cvUpload). Omit to apply all mapped keys.')

return parser.parse_args(argv)


def main():
args = parse_args()

config_path = Path(args.config)
config = read_json(config_path)

update_backend_env(config, Path(args.backend_env), args.namespaces)
update_frontend_env(config, Path(args.frontend_env), args.namespaces)

print('Configuration injected successfully')


if __name__ == '__main__':
main()
5 changes: 5 additions & 0 deletions frontend-new/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import "./preview.css";
// If the font references by the application are not loaded via the above mechanism and they are not found locally on the system,
// the browser will use a default font.

import "../src/styles/variables.css"

import type { Preview, StoryFn } from "@storybook/react";
import { IsOnlineContext } from "../src/app/isOnlineProvider/IsOnlineProvider";
import { IChatMessage } from "../src/chat/Chat.types";
Expand All @@ -26,6 +28,9 @@ import i18n from "../src/i18n/i18n";
import { initSentry } from "../src/sentryInit";
import SnackbarProvider from "../src/theme/SnackbarProvider/SnackbarProvider";
import { LocalesLabels, Locale } from "../src/i18n/constants";
import { applyBrandingFromEnv } from "../src/branding/branding";

applyBrandingFromEnv();

type StorybookSelectOption = {
value: string;
Expand Down
6 changes: 3 additions & 3 deletions frontend-new/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.16",
"@mui/material": "^5.15.12",
"@mui/system": "^5.15.15",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@mui/system": "^7.3.7",
"@react-pdf/renderer": "^3.4.5",
"@sentry/cli": "^2.39.1",
"@sentry/react": "^9.41.0",
Expand Down
18 changes: 18 additions & 0 deletions frontend-new/public/data/env.example.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,22 @@ window.tabiyaConfig = {
description: "Compass is an AI-powered career assistant that helps jobseekers discover and describe their skills.",
})
),
FRONTEND_LOGO_URL: btoa("/logo.svg"),
FRONTEND_FAVICON_URL: btoa("/favicon.svg"),
FRONTEND_APP_ICON_URL: btoa("/compass.svg"),
FRONTEND_THEME_CSS_VARIABLES: btoa(
JSON.stringify({
"brand-primary": "0 255 145",
"brand-primary-light": "51 255 167",
"brand-primary-dark": "0 178 101",
"brand-primary-contrast-text": "0 0 0",
"brand-secondary": "30 113 102",
"brand-secondary-light": "77 154 143",
"brand-secondary-dark": "21 79 71",
"brand-secondary-contrast-text": "0 0 0",
"text-primary": "0 33 71",
"text-secondary": "65 64 61",
"text-accent": "38 94 167",
})
),
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ exports[`CVTypingChatMessage should render the CV Typing Chat message correctly
class="MuiBox-root css-x1sij0"
>
<p
class="MuiTypography-root MuiTypography-body1 css-1czz4sp-MuiTypography-root"
class="MuiTypography-root MuiTypography-body1 css-19s6r5h-MuiTypography-root"
>
Your CV content is in the text field. Review it and send when ready.
</p>
Expand All @@ -35,25 +35,25 @@ exports[`CVTypingChatMessage should render the CV Typing Chat message correctly
class="MuiBox-root css-x1sij0"
>
<p
class="MuiTypography-root MuiTypography-body1 css-1czz4sp-MuiTypography-root"
class="MuiTypography-root MuiTypography-body1 css-19s6r5h-MuiTypography-root"
>
Please wait while I upload and parse your CV
</p>
<span
class="MuiBox-root css-sz63p1"
>
<span
class="MuiTypography-root MuiTypography-body1 css-18f2if6-MuiTypography-root"
class="MuiTypography-root MuiTypography-body1 css-aqob4v-MuiTypography-root"
>
.
</span>
<span
class="MuiTypography-root MuiTypography-body1 css-1dvtvwe-MuiTypography-root"
class="MuiTypography-root MuiTypography-body1 css-1kbk25g-MuiTypography-root"
>
.
</span>
<span
class="MuiTypography-root MuiTypography-body1 css-4a4pgt-MuiTypography-root"
class="MuiTypography-root MuiTypography-body1 css-1cya8iq-MuiTypography-root"
>
.
</span>
Expand Down
Loading