add config-driven channel label and dev-workstation bootstrap

A bleeding-edge container running next to a stable one on the same dev box is easy to confuse, and a branch-only badge would force perpetual rebase/cherry-pick drift between main and dev-be. Instead the badge is config-driven: the settings endpoint injects a synthetic env_label from SHOPDB_ENV_LABEL, the sidebar renders a red DEV tag only when it is non-empty, and stable simply leaves it unset. Same code on both channels, no divergence.

dev-bootstrap.ps1 makes a fresh Windows box turnkey: it checks git/Docker (winget-installs with a prompt), verifies the engine is actually running, then builds and starts an isolated per-channel stack so stable and edge coexist on distinct ports, projects, and volumes. docker-compose.channel.yml is a thin overlay so the base compose file stays channel-agnostic.
This commit is contained in:
benjamin.baker
2026-09-02 05:27:01 -04:00
parent ab4b9d7ec9
commit 0524ce7751
5 changed files with 377 additions and 2 deletions

View File

@@ -64,6 +64,40 @@ PUBLIC_SETTING_KEYS = {
'site_timezone',
}
# Environment channel label. Sourced from the SHOPDB_ENV_LABEL environment
# variable (set by the edge docker-compose override and the bleeding-edge
# installer, unset on stable/main), NOT from a settings row. It is injected as a
# synthetic public item into the settings list so the frontend picks it up
# through the same read-through it already uses for branding, with no extra
# request. Empty when unset, which is how a stable deployment renders no DEV
# badge. Public and read-only: it carries no secret and cannot be edited via the
# settings API, only by redeploying with a different environment value.
ENV_LABEL_KEY = 'env_label'
def _env_label() -> str:
"""The configured environment channel label (e.g. 'DEV'), or '' when unset."""
return (os.environ.get('SHOPDB_ENV_LABEL') or '').strip()
def _env_label_item() -> dict:
"""Synthetic, serialized settings item carrying the environment label.
Shaped like a real serialized Setting so the frontend read-through treats it
as just another public key. settingid is None because it is not a DB row.
"""
return {
'settingid': None,
'key': ENV_LABEL_KEY,
'value': _env_label(),
'valuetype': 'string',
'category': 'branding',
'description': 'Environment channel label (from SHOPDB_ENV_LABEL); '
'drives the DEV badge in the sidebar. Read-only.',
'createddate': None,
'modifieddate': None,
}
def _plugin_declared_settings() -> dict:
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
@@ -576,7 +610,19 @@ def list_settings():
# bootstrap keys); an authed principal sees everything (secrets masked).
if get_jwt_identity() is None:
settings = [s for s in settings if _is_public_setting(s)]
return success_response([_serialize_setting(s) for s in settings])
items = [_serialize_setting(s) for s in settings]
# Inject the environment channel label as a synthetic public item so the
# frontend read-through surfaces it alongside branding. It is env-sourced,
# not a DB row, and is always present (empty string when unset) so the
# public-settings contract can rely on the key existing. Drop any DB row that
# somehow shares the key so the env value is authoritative and appears once.
# Skip it when a category filter is active and does not select branding, so a
# scoped query is not polluted.
if not category or category == 'branding':
items = [i for i in items if i.get('key') != ENV_LABEL_KEY]
items.append(_env_label_item())
return success_response(items)
@settings_bp.route('/<key>', methods=['GET'])