#!/usr/bin/env python3 """Portable Redmine entry points. Configuration is data, never shell code.""" from __future__ import annotations import json import os from pathlib import Path import re import shlex import subprocess import sys from urllib.parse import urlsplit ROOT = Path(__file__).resolve().parent class ConfigError(ValueError): pass def env_values(path): values = {} try: lines = path.read_text(encoding='utf-8').splitlines() except OSError as exc: raise ConfigError(f'Cannot read env file {path}: {exc.strerror}') from exc for n, line in enumerate(lines, 1): line = line.strip() if not line or line.startswith('#'): continue match = re.fullmatch(r'(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)', line) if not match: raise ConfigError(f'Malformed env assignment in {path}:{n}; expected NAME=value (no shell commands).') key, value = match.groups() if value.startswith(('"', "'")): quote = value[0] end = value.find(quote, 1) if end < 0 or (value[end+1:].strip() and not value[end+1:].lstrip().startswith('#')): raise ConfigError(f'Malformed quoted value in {path}:{n}.') value = value[1:end] else: value = re.split(r'\s+#', value, maxsplit=1)[0].strip() values[key] = value return values def absolute(value, base=None): p = Path(str(value)).expanduser() return (p if p.is_absolute() else (base or Path.cwd()) / p).resolve() def split_options(argv): values, rest = {}, [] takes = {'--env-file','--base-cache','--base-url','--project','--cache-dir','--cache','--api-key-file','--mode'} switches = {'--show-config','--doctor','--doctor-nmi'} i, saw_command = 0, False while i < len(argv): token = argv[i] flag, sep, val = token.partition('=') if token == '--': rest.extend(argv[i:]); break if flag in switches: if sep: raise ConfigError(f'{flag} does not take a value.') values[flag] = True elif flag in takes or (flag == '--config' and not saw_command): if not sep: i += 1 if i >= len(argv) or argv[i].startswith('--'): raise ConfigError(f'{flag} requires a value.') val = argv[i] if not val.strip(): raise ConfigError(f'{flag} cannot be empty.') values[flag] = val else: rest.append(token) if not token.startswith('-'): saw_command = True i += 1 return values, rest def resolve(tool, lane, options, environ=None): env = dict(os.environ if environ is None else environ) if tool == 'contacts' and '--cache-dir' in options: raise ConfigError('Contacts uses --cache FILE, not --cache-dir. Use --base-cache DIR for a common root.') if tool == 'tickets' and '--cache' in options: raise ConfigError('Tickets uses --cache-dir DIR, not --cache. Use REDMINE_CONTACTS_CACHE for the full CRM handoff.') mode = options.get('--mode', lane) mode = {'production':'live','prod':'live','development':'dev'}.get(mode, mode) if mode not in {'live','dev'}: raise ConfigError('--mode must be production/live or dev.') home = Path(env.get('HOME', str(Path.home()))) candidates = ([ROOT/'.env.dev', ROOT/'bin/.env.dev', home/'.redmine.dev'] if mode == 'dev' else [ROOT/'.env', ROOT/'bin/.env', home/'.redmine.live']) explicit = options.get('--env-file') or env.get('REDMINE_ENV_FILE') selected = absolute(explicit) if explicit else next((p for p in candidates if p.is_file()), None) data = env_values(selected) if selected else {} sources = {} config = {} config_path = None if '--config' in options: if tool != 'tickets': raise ConfigError('--config is a tickets option; use --env-file for contacts.') config_path = absolute(options['--config']) try: text = config_path.read_text(encoding='utf-8') try: raw = json.loads(text) except json.JSONDecodeError: try: import yaml except ImportError as exc: raise ConfigError('YAML config needs existing PyYAML; use JSON instead.') from exc raw = yaml.safe_load(text) if not isinstance(raw, dict): raise ConfigError(f'Config must be an object: {config_path}') raw = raw.get('redmine_tickets', raw) if not isinstance(raw, dict): raise ConfigError(f'redmine_tickets must be an object: {config_path}') config = {str(k).replace('_','-'):v for k,v in raw.items()} except OSError as exc: raise ConfigError(f'Cannot read config {config_path}: {exc.strerror}') from exc except ValueError as exc: raise ConfigError(f'Invalid config file: {config_path}') from exc def pick(name, flag, keys, default=None, cfg=None): if flag and flag in options: sources[name] = f'CLI {flag}' return options[flag] for mapping, source in [(env,'environment'), (data,f'env file {selected}')]: for key in keys: if key in mapping: sources[name] = f'{source}: {key}' return mapping[key] if cfg and cfg in config: sources[name] = f'config {config_path}: {cfg}' return config[cfg] sources[name] = 'default' return default def path_value(name, value): if value is None or str(value).strip() == '': raise ConfigError(f'{name} cannot be empty; source: {sources.get(name)}') src = sources.get(name, '') base = selected.parent if src.startswith('env file ') else (config_path.parent if src.startswith('config ') else None) return absolute(value, base) url = str(pick('base_url','--base-url',['REDMINE_BASE_URL','REDMINE_ENDPOINT'], 'https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170', 'base-url')).rstrip('/') parsed = urlsplit(url) if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: raise ConfigError('REDMINE_BASE_URL/--base-url must be an http(s) URL without credentials/query/fragment.') project = str(pick('project','--project',['REDMINE_PROJECT'],'customer-service','project')) if not project.strip() or project in {'.','..'} or '/' in project or '\\' in project: raise ConfigError('REDMINE_PROJECT/--project must be a project identifier, not a path.') base = path_value('base_cache', pick('base_cache','--base-cache',['REDMINE_BASE_CACHE'],ROOT/('cache-dev' if mode == 'dev' else 'cache'))) slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+','_',s) host_project = slug(parsed.netloc + parsed.path) + '_' + slug(project) is_default = project == 'customer-service' and url == ('https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170') ticket_default = base / ('redmine_tickets' if is_default else 'redmine_tickets_' + slug(parsed.scheme + '_' + host_project)) tickets = path_value('ticket_cache', pick('ticket_cache','--cache-dir',['REDMINE_TICKETS_CACHE','REDMINE_CACHE_DIR'],ticket_default,'cache-dir')) # Preserve the historical single-file snapshot when it already exists. # Fresh installs get a namespaced file under redmine_contacts/ so different # hosts/projects never collide. This is compatibility, not a migration. legacy_contacts = base / 'redmine_contacts' contacts_default = legacy_contacts if legacy_contacts.is_file() and is_default else legacy_contacts / (host_project+'.json') contacts = path_value('contacts_cache', pick('contacts_cache','--cache',['REDMINE_CONTACTS_CACHE'],contacts_default)) if contacts.is_dir(): raise ConfigError(f'Contacts --cache expects a JSON file, not a directory: {contacts}') key = pick('api_key',None,['REDMINE_API_KEY']) key_file = pick('api_key_file','--api-key-file',['REDMINE_API_KEY_FILE'],None,'api-key-file') # Explicit key-file is a CLI credential override. Config key files are fallback. if key_file and ('--api-key-file' in options or not key): key_path = path_value('api_key_file',key_file) try: key = key_path.read_text(encoding='utf-8').strip() except OSError as exc: raise ConfigError(f'Cannot read API key file {key_path}: {exc.strerror}') from exc sources['api_key'] = f'key file {key_path}' key = str(key or '').strip() child_env = dict(env) for k,v in data.items(): child_env.setdefault(k,v) child_env.update(REDMINE_BASE_URL=url, REDMINE_PROJECT=project, REDMINE_BASE_CACHE=str(base), REDMINE_CONTACTS_CACHE=str(contacts), REDMINE_TICKETS_CACHE=str(tickets), REDMINE_API_KEY=key) if selected: child_env['REDMINE_ENV_FILE'] = str(selected) # Relative bridge files in env profiles are relative to that profile. for k in ['NMI_ENV_FILE','NMI_CLI']: if k not in env and data.get(k) and (k == 'NMI_ENV_FILE' or '/' in data[k]): child_env[k] = str(absolute(data[k],selected.parent)) child_env.setdefault('NMI_MODE','production' if mode == 'live' else 'dev') report = {'mode':mode,'base_url':url,'project':project,'base_cache':str(base), 'ticket_cache':str(tickets),'contacts_cache':str(contacts), 'ticket_contacts_cache':str(tickets/'contacts'), 'contacts_cache_layout':'legacy-single-file' if contacts == legacy_contacts else 'namespaced-file', 'cache_note':'Full CRM snapshot and ticket contacts mirror are different formats; existing legacy snapshot is preserved; no migration.', 'env_file':str(selected) if selected else None, 'searched_env_paths':[str(selected)] if explicit else [str(p) for p in candidates], 'config_file':str(config_path) if config_path else None, 'api_key_present':bool(key),'sources':sources, 'nmi_mode':child_env['NMI_MODE'], 'lock_timeout':child_env.get('REDMINE_LOCK_TIMEOUT','60')} injected = ['--base-url',url,'--project',project, '--cache' if tool=='contacts' else '--cache-dir',str(contacts if tool=='contacts' else tickets)] if tool == 'tickets' and config.get('allow-prod-writes') in (True,'true','yes',1,'1'): injected.append('--allow-prod-writes') return report, child_env, injected HELP = '''Wrapper configuration (same for rcontacts-live, rtickets-live / rtl, rtd): --env-file PATH Explicit NAME=value file; REDMINE_ENV_FILE also works --base-cache DIR Common root; REDMINE_BASE_CACHE (default checkout/cache) --show-config Redacted effective configuration and source paths (offline) --doctor Validate local config, key presence and cache access (offline) --doctor-nmi Run the actual NMI CLI's offline doctor --mode dev Explicit development; rtd defaults to this Precedence: CLI > exported environment > selected env file > ticket config > defaults. Live env search: checkout/.env, checkout/bin/.env, ~/.redmine.live (first existing). Dev env search: checkout/.env.dev, checkout/bin/.env.dev, ~/.redmine.dev. Required online: REDMINE_API_KEY (or tickets --api-key-file). Optional: REDMINE_BASE_URL, REDMINE_PROJECT, REDMINE_BASE_CACHE, REDMINE_CONTACTS_CACHE (JSON file), REDMINE_TICKETS_CACHE (directory). NMI: NMI_CLI, NMI_ENV_FILE, NMI_MODE; use --doctor-nmi for required credential names. Exact --cache (contacts file) / --cache-dir (tickets directory) override defaults. Relative CLI/exported paths use cwd; file-defined paths use that file's directory. Existing apply/customer-email/production-write safety gates are unchanged. ''' def contacts_handoff(args, contact_id): """Copy/paste-safe bridge to the full CRM, without putting secrets in argv.""" url = str(args.base_url) project = str(args.project) env_file = os.environ.get('REDMINE_ENV_FILE') cache = os.environ.get('REDMINE_CONTACTS_CACHE') if not cache: parsed = urlsplit(url) slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+', '_', s) base = Path(os.environ.get('REDMINE_BASE_CACHE') or Path(args.cache_dir).parent) cache = str(base / 'redmine_contacts' / (slug(parsed.netloc + parsed.path) + '_' + slug(project) + '.json')) cmd = [str(ROOT / 'bin/rcontacts-live')] if env_file: cmd.extend(['--env-file', env_file]) if getattr(args, 'api_key_file', None): cmd.extend(['--api-key-file', str(absolute(args.api_key_file))]) cmd.extend(['--base-url', url, '--project', project, '--cache', str(absolute(cache)), 'show', '--contact-id', str(contact_id)]) return shlex.join(cmd) def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) tool, lane, *args = argv script = ROOT / ('redmine_contacts.py' if tool == 'contacts' else 'redmine_tickets.py') try: opts, rest = split_options(args) if not args or '--help' in rest or '-h' in rest: if not args or rest in (['--help'],['-h']): print(HELP, flush=True) os.execv(sys.executable,[sys.executable,str(script),*(rest or ['--help'])]) report, child_env, injected = resolve(tool,lane,opts) if report['mode'] == 'dev': print(f"DEVELOPMENT runtime: {report['base_url']} (NMI_MODE={report['nmi_mode']})",file=sys.stderr) if opts.get('--doctor-nmi'): from redmine_ticket_nmi import discover_nmi_bin import shutil php = shutil.which('php',path=child_env.get('PATH')) if not php: raise ConfigError('PHP CLI is not on PATH; required by NMI.') binary = discover_nmi_bin(environ=child_env) if not binary.is_file(): raise ConfigError(f'NMI CLI not found: {binary}; set NMI_CLI.') try: proc = subprocess.run([php,str(binary),'--doctor','--json'],env=child_env,timeout=15) except subprocess.TimeoutExpired as exc: raise ConfigError('NMI offline doctor timed out after 15 seconds.') from exc return proc.returncode if opts.get('--show-config') or opts.get('--doctor'): problems = [] if not report['api_key_present']: problems.append('REDMINE_API_KEY is missing/empty; set it in the environment or a listed env file (or use --api-key-file).') import math try: seconds = float(report['lock_timeout']) if not math.isfinite(seconds) or seconds < 0: raise ValueError() except ValueError: problems.append('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.') for target in [Path(report['ticket_cache']),Path(report['contacts_cache']).parent]: existing=target while not existing.exists() and existing != existing.parent: existing=existing.parent if not existing.is_dir() or not os.access(existing,os.W_OK|os.X_OK): problems.append(f'Cache parent is not writable: {existing}') report.update(ready=not problems,problems=problems,network_checked=False) if selected: report['env_file_loaded'] = str(selected) report['env_file_data_keys'] = list(data.keys()) if data else [] print(json.dumps(report,indent=2)) return 2 if opts.get('--doctor') and problems else 0 if not report['api_key_present']: local = {'search','view','walk','browse','tui','textual-tui','contacts-search','ticket-contact','control-tag','draft','materialize-queues','saved-filters','entities'} command = next((a for a in rest if not a.startswith('-')), '') cache_only = (tool=='contacts' and ('--offline-cache' in rest or '--source=cache' in rest or ['--source','cache']==rest[-2:])) or (tool=='tickets' and command in local) if not cache_only: paths = ', '.join(report['searched_env_paths']) raise ConfigError(f'REDMINE_API_KEY is missing/empty. Checked environment and {paths}. Set REDMINE_API_KEY, --env-file, or --api-key-file; run --doctor.') os.execve(sys.executable,[sys.executable,str(script),*injected,*rest],child_env) except (ConfigError,OSError) as exc: print(f'Configuration error: {exc}',file=sys.stderr) return 2 if __name__=='__main__': raise SystemExit(main())