diff --git a/Dockerfile b/Dockerfile index 9c510ee7..a9268a22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,6 +103,7 @@ COPY config.example.json ./config.example.json COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh COPY --chmod=755 scripts/docker/healthcheck.sh ./scripts/docker/healthcheck.sh COPY --chmod=755 scripts/api/ ./scripts/api/ +COPY --chmod=644 scripts/env.js ./scripts/env.js COPY --chmod=644 scripts/package.json ./scripts/package.json COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/scripts/api/configEditor.js b/scripts/api/configEditor.js index 01d180b9..db8933a8 100644 --- a/scripts/api/configEditor.js +++ b/scripts/api/configEditor.js @@ -12,6 +12,18 @@ function resolveConfigPath(projectRoot) { return candidates.find(p => fs.existsSync(p)) ?? path.join(projectRoot, 'config.json') } +async function loadConfigSync(projectRoot, override) { + const modPath = override || path.join(projectRoot, 'dist', 'util', 'ConfigSync.js') + if (!override && !fs.existsSync(modPath)) return null + try { + return await import(pathToFileURL(path.resolve(modPath)).href) + } catch (error) { + throw new Error( + `Could not load ConfigSync module at ${modPath}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + async function loadBotValidator(projectRoot, override) { const modPath = override || path.join(projectRoot, 'dist', 'util', 'Validator.js') if (!override && !fs.existsSync(modPath)) return null @@ -212,6 +224,34 @@ export function deepMerge(base, patch) { return out } +// Compares the current config.json against config.example.json. +// Returns dotted key-paths present in the example but missing from the +// user's file - the same check entrypoint.sh runs on container start, now +// available on demand from the API/dashboard (e.g. GET /config/diff). +export async function diffConfig(projectRoot, { validatorModule } = {}) { + const mod = await loadConfigSync(projectRoot, validatorModule) + if (!mod) throw new Error('ConfigSync module not found - run `npm run build`.') + const { data: config } = readConfig(projectRoot) + const example = mod.readJson(mod.resolveExamplePath(projectRoot)) + return { addedKeys: mod.diffKeyPaths(config, example) } +} + +// Fills in any keys missing from config.json using config.example.json's +// values, without touching existing user values, and writes the result back +// (with a .bak backup via writeConfigAtomic). Intended for a gated endpoint +// like POST /config/sync, behind API_ALLOW_CONFIG_WRITE. +export async function syncMissingDefaults(projectRoot, { validatorModule } = {}) { + const mod = await loadConfigSync(projectRoot, validatorModule) + if (!mod) throw new Error('ConfigSync module not found - run `npm run build`.') + const { data: config } = readConfig(projectRoot) + const example = mod.readJson(mod.resolveExamplePath(projectRoot)) + const { merged, addedKeys } = mod.mergeMissingDefaults(config, example) + if (addedKeys.length > 0) { + writeConfigAtomic(projectRoot, merged) + } + return { addedKeys, patched: addedKeys.length > 0 } +} + export function readConfig(projectRoot) { const p = resolveConfigPath(projectRoot) return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) } diff --git a/scripts/api/server.js b/scripts/api/server.js index dba2f22c..d17724f0 100644 --- a/scripts/api/server.js +++ b/scripts/api/server.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { ProcessManager } from './processManager.js' import { buildExcludedAccountsEnv, buildSingleAccountEnv, loadAccounts, mergeAccountStats } from './accounts.js' -import { validateConfig, deepMerge, readConfig, writeConfigAtomic } from './configEditor.js' +import { validateConfig, deepMerge, readConfig, writeConfigAtomic, diffConfig, syncMissingDefaults } from './configEditor.js' import { readSchedule, writeSchedule } from './scheduleStore.js' import { deleteStoredSessions, listStoredSessions } from './sessionStore.js' import { resolveRunCommand } from './runCommand.js' @@ -293,6 +293,8 @@ const requestHandler = async (req, res) => { 'GET /diagnostics', 'GET /events', 'GET /config', + 'GET /config/diff', + 'POST /config/sync', 'GET /schedule', 'POST /start', 'POST /stop', @@ -444,6 +446,48 @@ const requestHandler = async (req, res) => { return sendJson(res, 200, { path: loaded.path, redacted: !reveal, config: data }) } + // conf diff - lists keys present in config.example.json but missing + // from the live config.json. Same check entrypoint.sh runs on + // container start, exposed here so a dashboard can surface it + // without waiting for the next restart. Always readable; no write + // gate needed since it doesn't touch the file. + if (method === 'GET' && pathname === '/config/diff') { + try { + const { addedKeys } = await diffConfig(projectRoot, { + validatorModule: envStr('API_VALIDATOR_MODULE') + }) + return sendJson(res, 200, { addedKeys, upToDate: addedKeys.length === 0 }) + } catch (err) { + return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }) + } + } + + // conf sync - patches missing keys into config.json using + // config.example.json's defaults, without touching values the user + // already set. Same trust level as PUT/PATCH /config, so it rides + // the same flag. + if (method === 'POST' && pathname === '/config/sync') { + if (!ALLOW_CONFIG_WRITE) { + return sendJson(res, 403, { + error: 'Config writes are disabled. Set API_ALLOW_CONFIG_WRITE=true to enable.' + }) + } + try { + const result = await syncMissingDefaults(projectRoot, { + validatorModule: envStr('API_VALIDATOR_MODULE') + }) + if (result.patched) { + pm.note( + 'info', + `config.json patched with ${result.addedKeys.length} missing key(s) via API /config/sync.` + ) + } + return sendJson(res, 200, { ...result, appliesOnNextRun: true }) + } catch (err) { + return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }) + } + } + // sched read if (method === 'GET' && pathname === '/schedule') { try { diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index 05dfd88c..eedeca21 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -54,107 +54,35 @@ else echo "[entrypoint] Found $acct_count account(s) in environment" fi -# 4. Config: generate and patch config.json +# 4. Config: generate/sync config.json +# +# Generation and drift-detection are delegated to dist/util/ConfigSync.js +# (built from src/util/ConfigSync.ts), the same module the API's config +# editor uses, so this logic lives in exactly one place. See that file for +# the diff/merge implementation. # # Behaviour: -# - No config.json → copy config.example.json as starting point -# - config.json exists → use as-is (whether user-edited or previously -# generated); CONFIG_* overrides always applied -# - Schema drift → warn with list of missing keys in both cases; -# never auto-modify the file +# - No config.json → generated from config.example.json +# - config.json exists → compared against config.example.json; +# CONFIG_* overrides always applied afterward +# - Schema drift → missing keys are reported. Set +# CONFIG_AUTO_SYNC=true to patch them into the +# file automatically (a .bak backup is kept); +# default is report-only, matching prior +# behaviour. +# - Corrupt config.json → fails loudly instead of being silently +# overwritten. # # headless is always forced true - it is not optional in Docker. # -# CONFIG_* env var overrides (applied on every startup): -# -# General: -# CONFIG_CLUSTERS=2 → .clusters -# CONFIG_DEBUG_LOGS=true → .debugLogs -# CONFIG_ERROR_DIAGNOSTICS=true → .errorDiagnostics -# CONFIG_ENSURE_STREAK_PROTECTION=true → .ensureStreakProtection -# CONFIG_AUTO_CLAIM_PUNCHCARD_REWARDS=false → .autoClaimPunchcardRewards -# CONFIG_SKIP_NON_POINT_TASKS=true → .skipNonPointTasks -# CONFIG_GLOBAL_TIMEOUT=30sec → .globalTimeout -# CONFIG_ACCOUNT_DELAY_MIN=1min → .accountDelay.min -# CONFIG_ACCOUNT_DELAY_MAX=3min → .accountDelay.max -# -# Workers (boolean): -# CONFIG_WORKER_DAILY_SET → .workers.doDailySet -# CONFIG_WORKER_CLAIM_BONUS_POINTS → .workers.doClaimBonusPoints -# CONFIG_WORKER_MORE_PROMOTIONS → .workers.doMorePromotions -# CONFIG_WORKER_PUNCH_CARDS → .workers.doPunchCards -# CONFIG_WORKER_APP_PROMOTIONS → .workers.doAppPromotions -# CONFIG_WORKER_DESKTOP_SEARCH → .workers.doDesktopSearch -# CONFIG_WORKER_MOBILE_SEARCH → .workers.doMobileSearch -# CONFIG_WORKER_BONUS_SEARCHES → .workers.doBonusSearches -# CONFIG_WORKER_DAILY_CHECKIN → .workers.doDailyCheckIn -# CONFIG_WORKER_READ_TO_EARN → .workers.doReadToEarn -# CONFIG_WORKER_ACTIVATE_SEARCH_PERK → .workers.doActivateSearchPerk -# CONFIG_WORKER_VISUAL_SEARCH → .workers.doVisualSearch -# -# Search settings: -# CONFIG_SEARCH_SCROLL_RANDOM → .searchSettings.scrollRandomResults -# CONFIG_SEARCH_CLICK_RANDOM → .searchSettings.clickRandomResults -# CONFIG_SEARCH_PARALLEL → .searchSettings.parallelSearching -# CONFIG_SEARCH_CLUSTER → .searchSettings.clusterSearch -# CONFIG_SEARCH_DELAY_MIN → .searchSettings.searchDelay.min -# CONFIG_SEARCH_DELAY_MAX → .searchSettings.searchDelay.max -# CONFIG_SEARCH_READ_DELAY_MIN → .searchSettings.readDelay.min -# CONFIG_SEARCH_READ_DELAY_MAX → .searchSettings.readDelay.max -# CONFIG_SEARCH_VISIT_TIME → .searchSettings.searchResultVisitTime -# CONFIG_SEARCH_RUN_ON_ZERO_POINTS → .searchSettings.runOnZeroPoints -# CONFIG_SEARCH_MAX_BONUS_SEARCHES → .searchSettings.maxBonusSearches -# CONFIG_SEARCH_QUERY_ENGINES → .searchSettings.queryEngines (comma-separated) -# CONFIG_SEARCH_ON_BING_LOCAL → .searchOnBingLocalQueries -# -# Activities: -# CONFIG_ACTIVITY_URL_REWARD → .activities.urlReward -# CONFIG_ACTIVITY_SEARCH_ON_BING → .activities.searchOnBing -# -# Experimental: -# CONFIG_EXPERIMENTAL_API_SEARCH → .experimental.apiSearch -# CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING → .experimental.apiSearchOnBing -# -# Proxy: -# CONFIG_PROXY_QUERY_ENGINE → .proxy.queryEngine -# -# Console log filter: -# CONFIG_LOG_FILTER_ENABLED → .consoleLogFilter.enabled -# CONFIG_LOG_FILTER_MODE → .consoleLogFilter.mode (whitelist|blacklist) -# CONFIG_LOG_FILTER_LEVELS → .consoleLogFilter.levels (comma-separated) -# CONFIG_LOG_FILTER_KEYWORDS → .consoleLogFilter.keywords (comma-separated) -# -# Webhooks: -# CONFIG_DISCORD_ENABLED / CONFIG_DISCORD_URL -# CONFIG_TELEGRAM_ENABLED / CONFIG_TELEGRAM_BOTTOKEN / CONFIG_TELEGRAM_CHATID -# CONFIG_NTFY_ENABLED / CONFIG_NTFY_URL / CONFIG_NTFY_TOPIC / CONFIG_NTFY_TOKEN -# CONFIG_NTFY_TITLE / CONFIG_NTFY_PRIORITY -# CONFIG_NTFY_TAGS → comma-separated e.g. "bot,notify" -# -# Webhook log filter: -# CONFIG_WEBHOOK_LOG_FILTER_ENABLED → .webhook.webhookLogFilter.enabled -# CONFIG_WEBHOOK_LOG_FILTER_MODE → .webhook.webhookLogFilter.mode -# CONFIG_WEBHOOK_LOG_FILTER_LEVELS → comma-separated -# CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS → comma-separated +# CONFIG_* env var overrides (applied on every startup) are defined once, +# in src/util/ConfigEnvOverrides.ts (ENV_OVERRIDES table) - not here. +# Run `node dist/util/ConfigEnvOverrides.js list` for the full current +# list of supported variables and the config path each maps to. # CONFIG_FILE="$SCRIPT_DIR/config/config.json" CONFIG_EXAMPLE="$SCRIPT_DIR/config.example.json" -# Returns 0 if config.json exists and is a valid JSON object -_config_file_is_valid() { - [ -f "$CONFIG_FILE" ] && \ - jq -e 'type == "object"' "$CONFIG_FILE" > /dev/null 2>&1 -} - -# Returns object key-paths present in example but missing from config. -_find_new_keys() { - local config_keys example_keys - local jq_expr='[path(..)] | map(select(all(. ; type == "string")) | join(".")) | sort[]' - config_keys=$(jq -r "$jq_expr" "$CONFIG_FILE" 2>/dev/null) - example_keys=$(jq -r "$jq_expr" "$CONFIG_EXAMPLE" 2>/dev/null) - comm -13 <(echo "$config_keys") <(echo "$example_keys") -} - if ! [ -f "$CONFIG_EXAMPLE" ]; then echo "ERROR: config.example.json not found at $CONFIG_EXAMPLE - image may be corrupt." >&2 exit 1 @@ -170,188 +98,23 @@ if [ -d "$CONFIG_FILE" ]; then exit 1 fi -if _config_file_is_valid; then - echo "[entrypoint] Using existing config.json." - new_keys=$(_find_new_keys) - if [ -n "$new_keys" ]; then - echo "" >&2 - echo "┌─────────────────────────────────────────────────────────┐" >&2 - echo "│ ⚠ CONFIG UPDATE AVAILABLE │" >&2 - echo "│ │" >&2 - echo "│ Your config.json is missing keys added in a recent │" >&2 - echo "│ update. The script will still run, but new features │" >&2 - echo "│ may not work correctly. │" >&2 - echo "│ │" >&2 - echo "│ Missing keys (see config.example.json for defaults): │" >&2 - echo "$new_keys" | while IFS= read -r key; do - printf "│ %-55s│\n" "+ $key" >&2 - done - echo "│ │" >&2 - echo "│ To fix: delete config.json (or empty it) and restart - │" >&2 - echo "│ it will be regenerated with all current defaults, │" >&2 - echo "│ then re-apply your CONFIG_* env vars. │" >&2 - echo "└─────────────────────────────────────────────────────────┘" >&2 - echo "" >&2 - fi -elif [ ! -e "$CONFIG_FILE" ] || [ ! -s "$CONFIG_FILE" ]; then - echo "[entrypoint] No config.json found - generating from config.example.json." - cp "$CONFIG_EXAMPLE" "$CONFIG_FILE" - echo "[entrypoint] config.json created. Customise via CONFIG_* env vars in compose.yaml." -else - echo "ERROR: Existing $CONFIG_FILE is not a valid JSON object." >&2 - echo " Fix it, or empty/delete it to regenerate from config.example.json." >&2 +SYNC_ARGS=(--config "$CONFIG_FILE" --example "$CONFIG_EXAMPLE") +if [ "${CONFIG_AUTO_SYNC:-false}" = "true" ]; then + SYNC_ARGS+=(--patch) +fi +if ! node "$SCRIPT_DIR/dist/util/ConfigSync.js" sync "${SYNC_ARGS[@]}"; then + echo "ERROR: config sync failed - see above." >&2 exit 1 fi -# Apply CONFIG_* env var overrides (always runs, regardless of config source) +# Apply CONFIG_* env var overrides (always runs, regardless of config +# source). Delegates to dist/util/ConfigEnvOverrides.js (built from +# src/util/ConfigEnvOverrides.ts) - see that file for the full mapping table. echo "[entrypoint] Applying CONFIG_* environment variable overrides..." -_cfg() { - # _cfg - local val="$1" path="$2" type="${3:-string}" - local json_value tmp="$CONFIG_FILE.tmp" - [ -z "$val" ] && return 0 - - case "$type" in - bool) - if [ "$val" != "true" ] && [ "$val" != "false" ]; then - echo "ERROR: $path expects true or false, got '$val'." >&2 - return 1 - fi - json_value="$val" - ;; - number) - if ! json_value=$(jq -en --arg raw "$val" '$raw | tonumber'); then - echo "ERROR: $path expects a JSON number, got '$val'." >&2 - return 1 - fi - ;; - string) - if ! jq --arg v "$val" "$path = \$v" "$CONFIG_FILE" > "$tmp"; then - rm -f "$tmp" - return 1 - fi - mv "$tmp" "$CONFIG_FILE" - echo "[entrypoint] $path = $val" - return 0 - ;; - *) - echo "ERROR: Internal config override type '$type' is not supported." >&2 - return 1 - ;; - esac - - if ! jq --argjson v "$json_value" "$path = \$v" "$CONFIG_FILE" > "$tmp"; then - rm -f "$tmp" - return 1 - fi - mv "$tmp" "$CONFIG_FILE" - echo "[entrypoint] $path = $val" -} - -_cfg_array() { - # _cfg_array - # Uses __UNSET__ sentinel to distinguish "var not set" from "var set to empty". - # An empty value writes [] to the config; an unset var is skipped entirely. - local val="$1" path="$2" - [ "$val" = "__UNSET__" ] && return 0 - local json_array - if [ -z "$val" ]; then - json_array="[]" - else - json_array=$(printf '%s' "$val" | jq -Rc '[split(",") | .[] | ltrimstr(" ") | rtrimstr(" ")]') - fi - if ! jq --argjson v "$json_array" "$path = \$v" "$CONFIG_FILE" > "$CONFIG_FILE.tmp"; then - rm -f "$CONFIG_FILE.tmp" - return 1 - fi - mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" - echo "[entrypoint] $path = [$val]" -} - -# headless is always forced true - cannot run headed inside Docker -_cfg 'true' '.headless' bool - -# Top-level -_cfg "${CONFIG_CLUSTERS:-}" '.clusters' number -_cfg "${CONFIG_DEBUG_LOGS:-}" '.debugLogs' bool -_cfg "${CONFIG_ERROR_DIAGNOSTICS:-}" '.errorDiagnostics' bool -_cfg "${CONFIG_ENSURE_STREAK_PROTECTION:-}" '.ensureStreakProtection' bool -_cfg "${CONFIG_AUTO_CLAIM_PUNCHCARD_REWARDS:-}" '.autoClaimPunchcardRewards' bool -_cfg "${CONFIG_SKIP_NON_POINT_TASKS:-}" '.skipNonPointTasks' bool -_cfg "${CONFIG_GLOBAL_TIMEOUT:-}" '.globalTimeout' string -_cfg "${CONFIG_ACCOUNT_DELAY_MIN:-}" '.accountDelay.min' string -_cfg "${CONFIG_ACCOUNT_DELAY_MAX:-}" '.accountDelay.max' string - -# Workers -_cfg "${CONFIG_WORKER_DAILY_SET:-}" '.workers.doDailySet' bool -_cfg "${CONFIG_WORKER_CLAIM_BONUS_POINTS:-}" '.workers.doClaimBonusPoints' bool -_cfg "${CONFIG_WORKER_MORE_PROMOTIONS:-}" '.workers.doMorePromotions' bool -_cfg "${CONFIG_WORKER_PUNCH_CARDS:-}" '.workers.doPunchCards' bool -_cfg "${CONFIG_WORKER_APP_PROMOTIONS:-}" '.workers.doAppPromotions' bool -_cfg "${CONFIG_WORKER_DESKTOP_SEARCH:-}" '.workers.doDesktopSearch' bool -_cfg "${CONFIG_WORKER_MOBILE_SEARCH:-}" '.workers.doMobileSearch' bool -_cfg "${CONFIG_WORKER_BONUS_SEARCHES:-}" '.workers.doBonusSearches' bool -_cfg "${CONFIG_WORKER_DAILY_CHECKIN:-}" '.workers.doDailyCheckIn' bool -_cfg "${CONFIG_WORKER_READ_TO_EARN:-}" '.workers.doReadToEarn' bool -_cfg "${CONFIG_WORKER_ACTIVATE_SEARCH_PERK:-}" '.workers.doActivateSearchPerk' bool -_cfg "${CONFIG_WORKER_VISUAL_SEARCH:-}" '.workers.doVisualSearch' bool - -# Search settings -_cfg "${CONFIG_SEARCH_SCROLL_RANDOM:-}" '.searchSettings.scrollRandomResults' bool -_cfg "${CONFIG_SEARCH_CLICK_RANDOM:-}" '.searchSettings.clickRandomResults' bool -_cfg "${CONFIG_SEARCH_PARALLEL:-}" '.searchSettings.parallelSearching' bool -_cfg "${CONFIG_SEARCH_CLUSTER:-}" '.searchSettings.clusterSearch' bool -_cfg "${CONFIG_SEARCH_DELAY_MIN:-}" '.searchSettings.searchDelay.min' string -_cfg "${CONFIG_SEARCH_DELAY_MAX:-}" '.searchSettings.searchDelay.max' string -_cfg "${CONFIG_SEARCH_READ_DELAY_MIN:-}" '.searchSettings.readDelay.min' string -_cfg "${CONFIG_SEARCH_READ_DELAY_MAX:-}" '.searchSettings.readDelay.max' string -_cfg "${CONFIG_SEARCH_VISIT_TIME:-}" '.searchSettings.searchResultVisitTime' string -_cfg "${CONFIG_SEARCH_RUN_ON_ZERO_POINTS:-}" '.searchSettings.runOnZeroPoints' bool -_cfg "${CONFIG_SEARCH_MAX_BONUS_SEARCHES:-}" '.searchSettings.maxBonusSearches' number -_cfg_array "${CONFIG_SEARCH_QUERY_ENGINES-__UNSET__}" '.searchSettings.queryEngines' -_cfg "${CONFIG_SEARCH_ON_BING_LOCAL:-}" '.searchOnBingLocalQueries' bool - -# Activities -_cfg "${CONFIG_ACTIVITY_URL_REWARD:-}" '.activities.urlReward' bool -_cfg "${CONFIG_ACTIVITY_SEARCH_ON_BING:-}" '.activities.searchOnBing' bool - -# Experimental -_cfg "${CONFIG_EXPERIMENTAL_API_SEARCH:-}" '.experimental.apiSearch' bool -_cfg "${CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING:-}" '.experimental.apiSearchOnBing' bool - -# Proxy -_cfg "${CONFIG_PROXY_QUERY_ENGINE:-}" '.proxy.queryEngine' bool - -# Console log filter -# Levels and keywords accept comma-separated values e.g. "error,warn" -_cfg "${CONFIG_LOG_FILTER_ENABLED:-}" '.consoleLogFilter.enabled' bool -_cfg "${CONFIG_LOG_FILTER_MODE:-}" '.consoleLogFilter.mode' string -_cfg_array "${CONFIG_LOG_FILTER_LEVELS-__UNSET__}" '.consoleLogFilter.levels' -_cfg_array "${CONFIG_LOG_FILTER_KEYWORDS-__UNSET__}" '.consoleLogFilter.keywords' - -# Discord webhook -_cfg "${CONFIG_DISCORD_ENABLED:-}" '.webhook.discord.enabled' bool -_cfg "${CONFIG_DISCORD_URL:-}" '.webhook.discord.url' string - -# Telegram webhook -_cfg "${CONFIG_TELEGRAM_ENABLED:-}" '.webhook.telegram.enabled' bool -_cfg "${CONFIG_TELEGRAM_BOTTOKEN:-}" '.webhook.telegram.botToken' string -_cfg "${CONFIG_TELEGRAM_CHATID:-}" '.webhook.telegram.chatId' string - -# ntfy webhook -_cfg "${CONFIG_NTFY_ENABLED:-}" '.webhook.ntfy.enabled' bool -_cfg "${CONFIG_NTFY_URL:-}" '.webhook.ntfy.url' string -_cfg "${CONFIG_NTFY_TOPIC:-}" '.webhook.ntfy.topic' string -_cfg "${CONFIG_NTFY_TOKEN:-}" '.webhook.ntfy.token' string -_cfg "${CONFIG_NTFY_TITLE:-}" '.webhook.ntfy.title' string -_cfg "${CONFIG_NTFY_PRIORITY:-}" '.webhook.ntfy.priority' number -_cfg_array "${CONFIG_NTFY_TAGS-__UNSET__}" '.webhook.ntfy.tags' - -# Webhook log filter -_cfg "${CONFIG_WEBHOOK_LOG_FILTER_ENABLED:-}" '.webhook.webhookLogFilter.enabled' bool -_cfg "${CONFIG_WEBHOOK_LOG_FILTER_MODE:-}" '.webhook.webhookLogFilter.mode' string -_cfg_array "${CONFIG_WEBHOOK_LOG_FILTER_LEVELS-__UNSET__}" '.webhook.webhookLogFilter.levels' -_cfg_array "${CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS-__UNSET__}" '.webhook.webhookLogFilter.keywords' +if ! node "$SCRIPT_DIR/dist/util/ConfigEnvOverrides.js" apply --config "$CONFIG_FILE"; then + echo "ERROR: applying CONFIG_* overrides failed - see above." >&2 + exit 1 +fi echo "[entrypoint] Config ready." diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts new file mode 100644 index 00000000..2a144a3b --- /dev/null +++ b/src/util/ConfigEnvOverrides.ts @@ -0,0 +1,272 @@ +/** + * ConfigEnvOverrides.ts + * + * Single source of truth for the CONFIG_* environment variable overrides + * applied to config.json on every docker container start. Previously this + * mapping existed twice in entrypoint.sh: once as a hand-written doc comment + * and once as the actual `_cfg`/`_cfg_array` call list - the two had to be + * kept in sync by hand. Now there's one table (ENV_OVERRIDES) that both the + * apply logic and `list` output are generated from. + * + * Not used by bare metal at all - Load.ts/Validator.ts are untouched. + * + * Usage: + * node dist/util/ConfigEnvOverrides.js apply --config + * node dist/util/ConfigEnvOverrides.js list [--format table|env] + */ + +import { readJson, writeConfigAtomic } from './ConfigSync' + +export type OverrideType = 'string' | 'bool' | 'number' | 'array' + +export interface EnvOverrideEntry { + env: string + path: string // dotted path into config.json + type: OverrideType +} + +export const ENV_OVERRIDES: EnvOverrideEntry[] = [ + // General + { env: 'CONFIG_CLUSTERS', path: 'clusters', type: 'number' }, + { env: 'CONFIG_DEBUG_LOGS', path: 'debugLogs', type: 'bool' }, + { env: 'CONFIG_ERROR_DIAGNOSTICS', path: 'errorDiagnostics', type: 'bool' }, + { env: 'CONFIG_ENSURE_STREAK_PROTECTION', path: 'ensureStreakProtection', type: 'bool' }, + { env: 'CONFIG_AUTO_CLAIM_PUNCHCARD_REWARDS', path: 'autoClaimPunchcardRewards', type: 'bool' }, + { env: 'CONFIG_SKIP_NON_POINT_TASKS', path: 'skipNonPointTasks', type: 'bool' }, + { env: 'CONFIG_GLOBAL_TIMEOUT', path: 'globalTimeout', type: 'string' }, + { env: 'CONFIG_ACCOUNT_DELAY_MIN', path: 'accountDelay.min', type: 'string' }, + { env: 'CONFIG_ACCOUNT_DELAY_MAX', path: 'accountDelay.max', type: 'string' }, + + // Workers + { env: 'CONFIG_WORKER_DAILY_SET', path: 'workers.doDailySet', type: 'bool' }, + { env: 'CONFIG_WORKER_CLAIM_BONUS_POINTS', path: 'workers.doClaimBonusPoints', type: 'bool' }, + { env: 'CONFIG_WORKER_MORE_PROMOTIONS', path: 'workers.doMorePromotions', type: 'bool' }, + { env: 'CONFIG_WORKER_PUNCH_CARDS', path: 'workers.doPunchCards', type: 'bool' }, + { env: 'CONFIG_WORKER_APP_PROMOTIONS', path: 'workers.doAppPromotions', type: 'bool' }, + { env: 'CONFIG_WORKER_DESKTOP_SEARCH', path: 'workers.doDesktopSearch', type: 'bool' }, + { env: 'CONFIG_WORKER_MOBILE_SEARCH', path: 'workers.doMobileSearch', type: 'bool' }, + { env: 'CONFIG_WORKER_BONUS_SEARCHES', path: 'workers.doBonusSearches', type: 'bool' }, + { env: 'CONFIG_WORKER_DAILY_CHECKIN', path: 'workers.doDailyCheckIn', type: 'bool' }, + { env: 'CONFIG_WORKER_READ_TO_EARN', path: 'workers.doReadToEarn', type: 'bool' }, + { env: 'CONFIG_WORKER_ACTIVATE_SEARCH_PERK', path: 'workers.doActivateSearchPerk', type: 'bool' }, + { env: 'CONFIG_WORKER_VISUAL_SEARCH', path: 'workers.doVisualSearch', type: 'bool' }, + + // Search settings + { env: 'CONFIG_SEARCH_SCROLL_RANDOM', path: 'searchSettings.scrollRandomResults', type: 'bool' }, + { env: 'CONFIG_SEARCH_CLICK_RANDOM', path: 'searchSettings.clickRandomResults', type: 'bool' }, + { env: 'CONFIG_SEARCH_PARALLEL', path: 'searchSettings.parallelSearching', type: 'bool' }, + { env: 'CONFIG_SEARCH_CLUSTER', path: 'searchSettings.clusterSearch', type: 'bool' }, + { env: 'CONFIG_SEARCH_DELAY_MIN', path: 'searchSettings.searchDelay.min', type: 'string' }, + { env: 'CONFIG_SEARCH_DELAY_MAX', path: 'searchSettings.searchDelay.max', type: 'string' }, + { env: 'CONFIG_SEARCH_READ_DELAY_MIN', path: 'searchSettings.readDelay.min', type: 'string' }, + { env: 'CONFIG_SEARCH_READ_DELAY_MAX', path: 'searchSettings.readDelay.max', type: 'string' }, + { env: 'CONFIG_SEARCH_VISIT_TIME', path: 'searchSettings.searchResultVisitTime', type: 'string' }, + { env: 'CONFIG_SEARCH_RUN_ON_ZERO_POINTS', path: 'searchSettings.runOnZeroPoints', type: 'bool' }, + { env: 'CONFIG_SEARCH_MAX_BONUS_SEARCHES', path: 'searchSettings.maxBonusSearches', type: 'number' }, + { env: 'CONFIG_SEARCH_QUERY_ENGINES', path: 'searchSettings.queryEngines', type: 'array' }, + { env: 'CONFIG_SEARCH_ON_BING_LOCAL', path: 'searchOnBingLocalQueries', type: 'bool' }, + + // Activities + { env: 'CONFIG_ACTIVITY_URL_REWARD', path: 'activities.urlReward', type: 'bool' }, + { env: 'CONFIG_ACTIVITY_SEARCH_ON_BING', path: 'activities.searchOnBing', type: 'bool' }, + + // Experimental + { env: 'CONFIG_EXPERIMENTAL_API_SEARCH', path: 'experimental.apiSearch', type: 'bool' }, + { env: 'CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING', path: 'experimental.apiSearchOnBing', type: 'bool' }, + + // Proxy + { env: 'CONFIG_PROXY_QUERY_ENGINE', path: 'proxy.queryEngine', type: 'bool' }, + + // Console log filter (levels/keywords are comma-separated) + { env: 'CONFIG_LOG_FILTER_ENABLED', path: 'consoleLogFilter.enabled', type: 'bool' }, + { env: 'CONFIG_LOG_FILTER_MODE', path: 'consoleLogFilter.mode', type: 'string' }, + { env: 'CONFIG_LOG_FILTER_LEVELS', path: 'consoleLogFilter.levels', type: 'array' }, + { env: 'CONFIG_LOG_FILTER_KEYWORDS', path: 'consoleLogFilter.keywords', type: 'array' }, + + // Discord webhook + { env: 'CONFIG_DISCORD_ENABLED', path: 'webhook.discord.enabled', type: 'bool' }, + { env: 'CONFIG_DISCORD_URL', path: 'webhook.discord.url', type: 'string' }, + + // Telegram webhook + { env: 'CONFIG_TELEGRAM_ENABLED', path: 'webhook.telegram.enabled', type: 'bool' }, + { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string' }, + { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string' }, + + // ntfy webhook (tags are comma-separated e.g. "bot,notify") + { env: 'CONFIG_NTFY_ENABLED', path: 'webhook.ntfy.enabled', type: 'bool' }, + { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string' }, + { env: 'CONFIG_NTFY_TOPIC', path: 'webhook.ntfy.topic', type: 'string' }, + { env: 'CONFIG_NTFY_TOKEN', path: 'webhook.ntfy.token', type: 'string' }, + { env: 'CONFIG_NTFY_TITLE', path: 'webhook.ntfy.title', type: 'string' }, + { env: 'CONFIG_NTFY_PRIORITY', path: 'webhook.ntfy.priority', type: 'number' }, + { env: 'CONFIG_NTFY_TAGS', path: 'webhook.ntfy.tags', type: 'array' }, + + // Webhook log filter + { env: 'CONFIG_WEBHOOK_LOG_FILTER_ENABLED', path: 'webhook.webhookLogFilter.enabled', type: 'bool' }, + { env: 'CONFIG_WEBHOOK_LOG_FILTER_MODE', path: 'webhook.webhookLogFilter.mode', type: 'string' }, + { env: 'CONFIG_WEBHOOK_LOG_FILTER_LEVELS', path: 'webhook.webhookLogFilter.levels', type: 'array' }, + { env: 'CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS', path: 'webhook.webhookLogFilter.keywords', type: 'array' } +] + +// headless is always forced true in docker - not env-var driven, so it isn't +// in the table above, but it's applied the same way every start. +const FORCED_OVERRIDES: { path: string; value: unknown }[] = [{ path: 'headless', value: true }] + +function setDeep(obj: Record, dottedPath: string, value: unknown): void { + const parts = dottedPath.split('.') + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i] as string + const next = cur[key] + if (typeof next !== 'object' || next === null || Array.isArray(next)) { + cur[key] = {} + } + cur = cur[key] as Record + } + cur[parts[parts.length - 1] as string] = value +} + +function coerceScalar(raw: string, type: 'bool' | 'number' | 'string', env: string): unknown { + switch (type) { + case 'bool': + if (raw !== 'true' && raw !== 'false') { + throw new Error(`${env} expects true or false, got '${raw}'.`) + } + return raw === 'true' + case 'number': { + const n = Number(raw) + if (!Number.isFinite(n)) throw new Error(`${env} expects a JSON number, got '${raw}'.`) + return n + } + case 'string': + default: + return raw + } +} + +export interface ComputedOverride { + env: string + path: string + value: unknown +} + +export interface OverrideError { + env: string + message: string +} + +/** + * Reads ENV_OVERRIDES against `env` and returns the values to apply. + * Matches the previous bash semantics: a scalar var that's unset OR set to + * an empty string is skipped (no way to distinguish the two in `_cfg`); an + * array var that's unset is skipped, but set-to-empty applies `[]`. + */ +export function computeOverrides(env: NodeJS.ProcessEnv = process.env): { + applied: ComputedOverride[] + errors: OverrideError[] +} { + const applied: ComputedOverride[] = [] + const errors: OverrideError[] = [] + + for (const entry of ENV_OVERRIDES) { + if (entry.type === 'array') { + if (!(entry.env in env)) continue + const raw = env[entry.env] ?? '' + const value = raw === '' ? [] : raw.split(',').map(s => s.trim()) + applied.push({ env: entry.env, path: entry.path, value }) + continue + } + + const raw = env[entry.env] + if (raw === undefined || raw === '') continue + try { + const value = coerceScalar(raw, entry.type, entry.env) + applied.push({ env: entry.env, path: entry.path, value }) + } catch (err) { + errors.push({ env: entry.env, message: err instanceof Error ? err.message : String(err) }) + } + } + + return { applied, errors } +} + +export interface ApplyReport { + configPath: string + forced: { path: string; value: unknown }[] + applied: ComputedOverride[] + errors: OverrideError[] +} + +/** + * Applies FORCED_OVERRIDES + ENV_OVERRIDES to config.json in one atomic + * write. Unlike the old bash version (which wrote incrementally per + * variable, so an invalid value left partial changes on disk), this + * validates everything first and only writes if there are no errors. + */ +export function applyEnvOverrides(configPath: string, env: NodeJS.ProcessEnv = process.env): ApplyReport { + const { applied, errors } = computeOverrides(env) + if (errors.length > 0) { + return { configPath, forced: [], applied: [], errors } + } + + const config = readJson(configPath) as Record + for (const { path: p, value } of FORCED_OVERRIDES) setDeep(config, p, value) + for (const { path: p, value } of applied) setDeep(config, p, value) + + // No .bak here - this runs on every container start, unlike the rarer + // default-sync patch, so a backup per boot would just be noise. + writeConfigAtomic(configPath, config, { backup: false }) + + return { configPath, forced: FORCED_OVERRIDES, applied, errors: [] } +} + +// ── CLI entry point, used by entrypoint.sh ── +if (require.main === module) { + const args = process.argv.slice(2) + const command = args[0] + const getArg = (flag: string) => { + const i = args.indexOf(flag) + return i !== -1 ? args[i + 1] : undefined + } + + if (command === 'list') { + const format = getArg('--format') ?? 'table' + if (format === 'env') { + for (const e of ENV_OVERRIDES) console.log(e.env) + } else { + console.log('ENV VAR'.padEnd(42) + 'CONFIG PATH'.padEnd(42) + 'TYPE') + for (const e of ENV_OVERRIDES) { + console.log(e.env.padEnd(42) + ('.' + e.path).padEnd(42) + e.type) + } + } + process.exit(0) + } + + if (command === 'apply') { + const configPath = getArg('--config') + if (!configPath) { + console.error('Usage: node dist/util/ConfigEnvOverrides.js apply --config ') + process.exit(1) + } + try { + const report = applyEnvOverrides(configPath) + if (report.errors.length > 0) { + console.error('[entrypoint] Invalid CONFIG_* override value(s) - no changes were written:') + report.errors.forEach(e => console.error(`[entrypoint] ${e.message}`)) + process.exit(1) + } + report.forced.forEach(f => console.log(`[entrypoint] .${f.path} = ${f.value} (forced)`)) + report.applied.forEach(a => console.log(`[entrypoint] .${a.path} = ${JSON.stringify(a.value)}`)) + console.log(`[entrypoint] Applied ${report.applied.length} override(s).`) + process.exit(0) + } catch (err) { + console.error(`[entrypoint] ERROR: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + } + + if (!command) { + console.error('Usage: node dist/util/ConfigEnvOverrides.js [options]') + process.exit(1) + } +} diff --git a/src/util/ConfigSync.ts b/src/util/ConfigSync.ts new file mode 100644 index 00000000..19d72342 --- /dev/null +++ b/src/util/ConfigSync.ts @@ -0,0 +1,226 @@ +/** + * ConfigSync.ts + * + * Single source of truth for comparing/merging config.json against + * config.example.json. Used by the Docker entrypoint (via CLI) and the + * API's configEditor.js (via dynamic import) - NOT by Load.ts. Load.ts / + * Validator.ts already backfill missing keys in memory on every start + * (bare metal and docker); this module is strictly about writing + * those defaults back to the *file on disk* for docker users whose + * config.json lives in a bind-mounted volume across image updates. + * + * Users: + * - entrypoint.sh -> `node dist/util/ConfigSync.js sync [--patch] --config --example ` + * - configEditor.js -> dynamic import of diffKeyPaths / mergeMissingDefaults / readJson / resolveExamplePath + */ + +import fs from 'fs' +import path from 'path' + +export interface SyncReport { + configPath: string + examplePath: string + created: boolean // true if config.json didn't exist and was seeded from example + addedKeys: string[] // dotted key-paths present in example but missing from config + patched: boolean // true if addedKeys were actually written into config.json + backupPath?: string +} + +// ── Path helpers (docker-side only; mirrors the search order Load.ts uses, +// kept separate/duplicated there deliberately - see note above) ── + +export function getProjectRoot(startDir: string = process.cwd()): string { + if (fs.existsSync(path.join(startDir, 'package.json'))) return startDir + let dir = startDir + while (dir !== path.parse(dir).root) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir + dir = path.dirname(dir) + } + return startDir +} + +function resolveProjectFile(filename: string, projectRoot: string): string | undefined { + const candidates = [ + path.join(process.cwd(), filename), + path.join(projectRoot, filename), + path.join(projectRoot, 'dist', filename), + path.join(projectRoot, 'src', filename) + ] + return candidates.find(p => fs.existsSync(p)) +} + +export function resolveConfigPath(projectRoot: string = getProjectRoot()): string { + return resolveProjectFile('config.json', projectRoot) ?? path.join(projectRoot, 'config.json') +} + +export function resolveExamplePath(projectRoot: string = getProjectRoot()): string { + return resolveProjectFile('config.example.json', projectRoot) ?? path.join(projectRoot, 'config.example.json') +} + +// ── Read/write ── + +export function readJson(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +export function writeConfigAtomic( + targetPath: string, + cfg: unknown, + opts: { backup?: boolean } = {} +): { backupPath?: string } { + const backup = opts.backup ?? true + let backupPath: string | undefined + if (backup && fs.existsSync(targetPath)) { + backupPath = `${targetPath}.bak` + try { + fs.copyFileSync(targetPath, backupPath) + } catch { + backupPath = undefined // best-effort backup; don't fail the sync over it + } + } + const tmp = `${targetPath}.${process.pid}.tmp` + fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n') + fs.renameSync(tmp, targetPath) + return { backupPath } +} + +// ── Diff / merge ── + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** + * Dotted key-paths present in `example` but absent from `config`. Arrays are + * leaves - their contents are never diffed, only presence/absence of the key. + */ +export function diffKeyPaths(config: unknown, example: unknown, prefix = ''): string[] { + if (!isPlainObject(example)) return [] + const cfgObj = isPlainObject(config) ? config : {} + const missing: string[] = [] + + for (const [key, exampleVal] of Object.entries(example)) { + const keyPath = prefix ? `${prefix}.${key}` : key + if (!Object.prototype.hasOwnProperty.call(cfgObj, key)) { + missing.push(keyPath) + continue + } + if (isPlainObject(exampleVal)) { + missing.push(...diffKeyPaths(cfgObj[key], exampleVal, keyPath)) + } + } + return missing +} + +/** + * Deep-copies `config`, filling in any key present in `example` but missing + * from `config` using the example's value. Existing user values are never + * overwritten. Returns the merged config plus the dotted paths that were added. + */ +export function mergeMissingDefaults(config: unknown, example: T): { merged: T; addedKeys: string[] } { + const addedKeys: string[] = [] + + function walk(cfg: unknown, ex: unknown, prefix: string): unknown { + if (!isPlainObject(ex)) return cfg === undefined ? ex : cfg + const cfgObj = isPlainObject(cfg) ? { ...cfg } : {} + for (const [key, exVal] of Object.entries(ex)) { + const keyPath = prefix ? `${prefix}.${key}` : key + if (!Object.prototype.hasOwnProperty.call(cfgObj, key)) { + cfgObj[key] = exVal + addedKeys.push(keyPath) + } else if (isPlainObject(exVal)) { + cfgObj[key] = walk(cfgObj[key], exVal, keyPath) + } + } + return cfgObj + } + + return { merged: walk(config, example, '') as T, addedKeys } +} + +// ── Orchestration (docker CLI path) ── + +export interface SyncOptions { + projectRoot?: string + configPath?: string + examplePath?: string + /** If true, missing keys are written into config.json (with a .bak backup). If false, only reported. */ + patch?: boolean +} + +export function syncConfig(opts: SyncOptions = {}): SyncReport { + const projectRoot = opts.projectRoot ?? getProjectRoot() + const configPath = opts.configPath ?? resolveConfigPath(projectRoot) + const examplePath = opts.examplePath ?? resolveExamplePath(projectRoot) + + if (!fs.existsSync(examplePath)) { + throw new Error(`config.example.json not found at ${examplePath} - image may be corrupt.`) + } + const example = readJson(examplePath) + + // No config.json (or an empty stub) yet -> seed it from the example. + if (!fs.existsSync(configPath) || fs.statSync(configPath).size < 10) { + writeConfigAtomic(configPath, example) + return { configPath, examplePath, created: true, addedKeys: [], patched: true } + } + + // Existing file: parse errors are surfaced as a thrown error rather than + // silently overwritten, so a corrupt user file fails loudly instead of + // being clobbered. + const config = readJson(configPath) + const addedKeys = diffKeyPaths(config, example) + + if (addedKeys.length === 0) { + return { configPath, examplePath, created: false, addedKeys: [], patched: false } + } + if (!opts.patch) { + return { configPath, examplePath, created: false, addedKeys, patched: false } + } + + const { merged } = mergeMissingDefaults(config, example) + const { backupPath } = writeConfigAtomic(configPath, merged) + return { configPath, examplePath, created: false, addedKeys, patched: true, backupPath } +} + +// ── CLI entry point, used by entrypoint.sh ── +// node dist/util/ConfigSync.js sync [--patch] [--config ] [--example ] +if (require.main === module) { + const args = process.argv.slice(2) + const patch = args.includes('--patch') + const getArg = (flag: string) => { + const i = args.indexOf(flag) + return i !== -1 ? args[i + 1] : undefined + } + + try { + const report = syncConfig({ + configPath: getArg('--config'), + examplePath: getArg('--example'), + patch + }) + + if (report.created) { + console.log(`[config-sync] No config.json found - generated from ${path.basename(report.examplePath)}.`) + } else if (report.addedKeys.length === 0) { + console.log('[config-sync] config.json is up to date.') + } else if (report.patched) { + console.log(`[config-sync] Added ${report.addedKeys.length} missing key(s) to config.json:`) + report.addedKeys.forEach(k => console.log(`[config-sync] + ${k}`)) + if (report.backupPath) console.log(`[config-sync] Backup saved to ${report.backupPath}`) + } else { + console.warn('') + console.warn('┌──────────────────────────────────────────────────────────┐') + console.warn('│ ⚠ CONFIG UPDATE AVAILABLE │') + console.warn('│ Missing keys (see config.example.json for defaults): │') + report.addedKeys.forEach(k => console.warn(('│ + ' + k).padEnd(60) + '│')) + console.warn('│ Set CONFIG_AUTO_SYNC=true to patch automatically, or │') + console.warn('│ delete config.json to regenerate it from scratch. │') + console.warn('└──────────────────────────────────────────────────────────┘') + console.warn('') + } + process.exit(0) + } catch (err) { + console.error(`[config-sync] ERROR: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } +}