From 25dc8c415130092d9c18f19f918f646ce9a7a29a Mon Sep 17 00:00:00 2001 From: Michael Cammarata <55518507+mgrimace@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:15:18 -0400 Subject: [PATCH 1/5] Update compose.yaml to use prebuilt image not build Use prebuilt image rather than building locally now that v4+ is pushed --- compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index 46eb7fbb..02521003 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,9 +1,9 @@ services: microsoft-rewards-script: - # image: ghcr.io/thenetsky/microsoft-rewards-script:latest + image: ghcr.io/thenetsky/microsoft-rewards-script:latest # To build locally instead of pulling the prebuilt image, use the build section below. - build: - context: . + #build: + # context: . container_name: microsoft-rewards-script restart: unless-stopped volumes: From 0411626ff3754044636f77d9b8b4d9497d744bb2 Mon Sep 17 00:00:00 2001 From: Michael Cammarata <55518507+mgrimace@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:18:00 -0400 Subject: [PATCH 2/5] Update README.md Tidy up formatting --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 37e0dde6..b14dfb04 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ ACCOUNT_1_PASSWORD=your_password > [!WARNING] > Do **not** skip this step if you are running the script bare metal. -- **Bare metal:**: Copy or rename `config.example.json` to `config.json` (in the project root) and customize your preferences. +- **Bare metal:** Copy or rename `config.example.json` to `config.json` (in the project root) and customize your preferences. - **Docker:** A valid `config.json` is automatically created on first run and saved locally to `./config/`. You can optionally manually create a `config.json` (e.g., if you need to specify regex values) using the provided `config.example.json` > [!CAUTION] @@ -100,11 +100,12 @@ ACCOUNT_1_PASSWORD=your_password ``` - Review `compose.yaml` to adjust scheduling, timezone, and config options. - > [!NOTE] - > A valid `config.json` is auto-generated on first run using default values, and saved locally to `./config/`. - > Optionally, use `CONFIG_*` variables in the `environment:` section of the `compose.yaml` to customise your options (e.g., clusters, webhook, etc.). - > A full list of available options are in the [table below](#configuration-options). - > `CONFIG_*` variables are applied on every startup and always take precedence over `./config/config.json`. + +> [!NOTE] +> A valid `config.json` is auto-generated on first run using default values, and saved locally to `./config/`. +> Optionally, use `CONFIG_*` variables in the `environment:` section of the `compose.yaml` to customise your options (e.g., clusters, webhook, etc.). +> A full list of available options are in the [table below](#configuration-options). +> `CONFIG_*` variables are applied on every startup and always take precedence over `./config/config.json`. > [!TIP] > If a new image adds config options you're missing, a warning will appear in the container logs. From a6d550d636edee12ed59e196b5bf239a8e683b31 Mon Sep 17 00:00:00 2001 From: Michael Cammarata <55518507+mgrimace@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:19:22 -0400 Subject: [PATCH 3/5] Update README.md callout formatting fixed another callout formatting --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b14dfb04..163835a3 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ ACCOUNT_1_PASSWORD=your_password > To update, delete `./config/config.json` and restart — a fresh one will be generated from the latest example, with your `compose.yaml` overrides re-applied. - Start the container: `docker compose up -d` - > [!TIP] - > Monitor logs with `docker logs microsoft-rewards-script`, useful for viewing passwordless login codes or diagnosing issues. - > You can also enable a webhook in `compose.yaml` for notifications. +> [!TIP] +> Monitor logs with `docker logs microsoft-rewards-script`, useful for viewing passwordless login codes or diagnosing issues. +> You can also enable a webhook in `compose.yaml` for notifications. --- From 84ade4ec6fcba3b1f54ed90f0f63c48515dbe2b8 Mon Sep 17 00:00:00 2001 From: Michael Cammarata <55518507+mgrimace@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:05:28 -0400 Subject: [PATCH 4/5] Pin v4 image to the 4 tag This pins the v4 version of the script to the 4 tag rather than latest --- compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 02521003..e0ab9160 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: microsoft-rewards-script: - image: ghcr.io/thenetsky/microsoft-rewards-script:latest + image: ghcr.io/thenetsky/microsoft-rewards-script:4 # To build locally instead of pulling the prebuilt image, use the build section below. #build: # context: . From ab387e24ba705c08eaf6504227502b25a377bb7a Mon Sep 17 00:00:00 2001 From: mgrimace <55518507+mgrimace@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:16:12 -0400 Subject: [PATCH 5/5] Update docker to use API Update dockerfile and supporting scripts to support API mode in docker with scheduler --- Dockerfile | 5 +- compose.yaml | 18 +++++-- env.example | 7 ++- scripts/api/server.js | 10 ++++ scripts/api/trigger.js | 95 +++++++++++++++++++++++++++++++++++ scripts/docker/entrypoint.sh | 51 ++++++++++++++++--- scripts/docker/healthcheck.sh | 7 +++ scripts/docker/run_daily.sh | 16 ++++-- 8 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 scripts/api/trigger.js create mode 100644 scripts/docker/healthcheck.sh diff --git a/Dockerfile b/Dockerfile index ac5b738e..9c510ee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,10 +101,13 @@ COPY config.example.json ./config.example.json # Copy runtime scripts with proper permissions from the start 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/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 # Entrypoint handles TZ, accounts/config generation, initial run toggle, -# cron templating & launch +# cron templating & launch, or API server startup when API_MODE=true ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["sh", "-c", "echo 'Container started; cron is running.'"] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index e0ab9160..56ef6f0b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,7 @@ services: # Load account credentials from the .env file (ACCOUNT_1_EMAIL, ACCOUNT_1_PASSWORD, etc.) - path: .env environment: - # Scheduling + # ── Scheduler ────────────────────────────────────── TZ: 'America/Toronto' NODE_ENV: 'production' CRON_SCHEDULE: '0 7 * * *' # use crontab.guru to customize your schedule @@ -23,15 +23,25 @@ services: #MAX_SLEEP_MINUTES: "50" #STUCK_PROCESS_TIMEOUT_HOURS: "8" - # Configuration + # ── API mode ────────────────────────────────────── + # Set API_MODE=true to run the API server for additional control options + #API_MODE: 'true' + #API_TOKEN: '${API_TOKEN}' # required for API mode, see example.env + + # ── Configuration ────────────────────────────────────── # Uncomment to override defaults, a full list of configuration options are in the README. #CONFIG_CLUSTERS: '1' #... add additional configuration overrides as needed + # ── API mode port mapping ────────────────────────────────────── + # Uncomment when API_MODE=true + #ports: + # - '3010:3010' + healthcheck: - test: ['CMD', 'sh', '-c', 'pgrep cron > /dev/null || exit 1'] + test: ['CMD-SHELL', 'pgrep -x node >/dev/null 2>&1 || pgrep -x cron >/dev/null 2>&1'] interval: 60s - timeout: 10s + timeout: 5s retries: 3 start_period: 30s diff --git a/env.example b/env.example index f8d508fe..c8f0d0b5 100644 --- a/env.example +++ b/env.example @@ -31,4 +31,9 @@ ACCOUNT_1_PASSWORD=your_password #ACCOUNT_2_PROXY_URL= #ACCOUNT_2_PROXY_PORT=0 #ACCOUNT_2_PROXY_USERNAME= -#ACCOUNT_2_PROXY_PASSWORD= \ No newline at end of file +#ACCOUNT_2_PROXY_PASSWORD= + +# Control API (used when API_MODE=true in compose.yaml) +# See scripts/api/README.md for full documentation. +# Generate a token: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +#API_TOKEN= \ No newline at end of file diff --git a/scripts/api/server.js b/scripts/api/server.js index 299f3960..3027b7ec 100644 --- a/scripts/api/server.js +++ b/scripts/api/server.js @@ -63,6 +63,16 @@ const pm = new ProcessManager({ const startedAt = Date.now() +// Forward bot stdout/stderr to the API server's own output streams so that +// container logs (docker logs) continue to show the bot's output regardless +// of which mode started the run. Controller messages (run start/stop lifecycle +// events from ProcessManager) are included — they are low-volume and useful. +pm.on('log', entry => { + const line = (entry.raw ?? entry.message ?? '') + '\n' + if (entry.source === 'stderr') process.stderr.write(line) + else process.stdout.write(line) +}) + function toHistoryRecord(entry) { return { startedAt: entry.startedAt, diff --git a/scripts/api/trigger.js b/scripts/api/trigger.js new file mode 100644 index 00000000..d2fa8bf6 --- /dev/null +++ b/scripts/api/trigger.js @@ -0,0 +1,95 @@ +/** + * Triggers a run via the local API server and waits for it to finish. + * + * Called by scripts/docker/run_daily.sh when API_MODE=true so that cron + * delegates to the API server rather than running npm start directly. The + * API server has full visibility over every run, scheduled or + * manually triggered, and the dashboard can stream logs, stop a run, or + * inspect history regardless of how it was started. + * + */ + +import http from 'node:http' + +const PORT = Number(process.env.API_PORT) || 3010 +const TOKEN = process.env.API_TOKEN || '' +const TIMEOUT_MS = (Number(process.env.STUCK_PROCESS_TIMEOUT_HOURS) || 8) * 60 * 60 * 1000 +const POLL_MS = 15_000 +const STARTUP_ATTEMPTS = 30 +const STARTUP_DELAY_MS = 2_000 + +function request(method, path) { + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json', 'Content-Length': '2' } + if (TOKEN) headers['Authorization'] = `Bearer ${TOKEN}` + const req = http.request( + { host: '127.0.0.1', port: PORT, path, method, headers }, + res => { + let raw = '' + res.on('data', c => (raw += c)) + res.on('end', () => { + try { resolve({ status: res.statusCode, body: JSON.parse(raw) }) } + catch { resolve({ status: res.statusCode, body: raw }) } + }) + } + ) + req.on('error', reject) + req.end('{}') + }) +} + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)) +} + +// Wait for the API server to be ready. Handles the RUN_ON_START race where +// trigger.js is launched in the background before the API server has started. +let ready = false +for (let i = 0; i < STARTUP_ATTEMPTS; i++) { + try { + const { status } = await request('GET', '/health') + if (status === 200) { ready = true; break } + } catch { /* server not up yet */ } + if (i < STARTUP_ATTEMPTS - 1) { + console.log(`[trigger] Waiting for API server (attempt ${i + 1}/${STARTUP_ATTEMPTS})…`) + await sleep(STARTUP_DELAY_MS) + } +} + +if (!ready) { + console.error(`[trigger] API server did not respond after ${STARTUP_ATTEMPTS} attempts. Is API_MODE=true?`) + process.exit(1) +} + +// Trigger the run. +const { status, body } = await request('POST', '/start') + +if (status === 409) { + // A run is already in progress — the dashboard or a previous cron invocation + // beat us to it. Exit cleanly so the lockfile is released. + console.log('[trigger] A run is already in progress (409 Conflict). Skipping.') + process.exit(0) +} + +if (status !== 202) { + console.error(`[trigger] POST /start failed (HTTP ${status}):`, JSON.stringify(body)) + process.exit(1) +} + +console.log('[trigger] Run started. Waiting for completion…') + +// Poll /status until the run finishes or the timeout is reached. +const deadline = Date.now() + TIMEOUT_MS +while (Date.now() < deadline) { + await sleep(POLL_MS) + try { + const { body: s } = await request('GET', '/status') + if (s?.state === 'idle') { + console.log('[trigger] Run completed.') + process.exit(0) + } + } catch { /* momentary blip — keep polling */ } +} + +console.error(`[trigger] Timed out after ${process.env.STUCK_PROCESS_TIMEOUT_HOURS || 8}h waiting for run to finish.`) +process.exit(1) diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index 62b64fb9..d8ca9f2c 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -12,11 +12,14 @@ ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime echo "$TZ" > /etc/timezone dpkg-reconfigure -f noninteractive tzdata -# 2. Validate CRON_SCHEDULE -if [ -z "${CRON_SCHEDULE:-}" ]; then - echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2 - echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2 - exit 1 +# 2. Validate CRON_SCHEDULE (not required in API mode) +if [ "${API_MODE:-false}" != "true" ]; then + if [ -z "${CRON_SCHEDULE:-}" ]; then + echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2 + echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2 + echo " To run the API server instead, set API_MODE=true." >&2 + exit 1 + fi fi # 3. Accounts: read directly from ACCOUNT_N_* env vars by the app at runtime. @@ -312,6 +315,9 @@ chmod 600 /etc/container_env # 5. Initial run without sleep if RUN_ON_START=true # ───────────────────────────────────────────────────────────────────────────── if [ "${RUN_ON_START:-false}" = "true" ]; then + # Always go through run_daily.sh so the lockfile is acquired and the same + # code path runs regardless of mode. In API mode, run_daily.sh calls + # trigger.js which waits for the API server to be ready before firing. echo "[entrypoint] Starting initial run in background at $(date)" ( cd "$SCRIPT_DIR" || { @@ -324,7 +330,40 @@ if [ "${RUN_ON_START:-false}" = "true" ]; then echo "[entrypoint] Background process started (PID: $!)" fi -# 6. Template and register cron file +# ───────────────────────────────────────────────────────────────────────────── +# 6. Start: scheduler-only (default) or API-integrated mode +# ───────────────────────────────────────────────────────────────────────────── +# Default API_HOST to 0.0.0.0 so Docker port-mapping works out of the box. +: "${API_HOST:=0.0.0.0}" +export API_HOST + +if [ "${API_MODE:-false}" = "true" ]; then + # API-integrated mode: + # - The API server is the main (foreground) process and becomes PID 1. + # - If CRON_SCHEDULE is set, cron also runs as a background daemon. + # run_daily.sh detects API_MODE=true and calls POST /start via + # scripts/api/trigger.js instead of running npm start directly, so the + # API server has full visibility and control over every run. + # - Without CRON_SCHEDULE, runs must be triggered manually via POST /start. + if [ -n "${CRON_SCHEDULE:-}" ]; then + if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then + echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2 + exit 1 + fi + export TZ + envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron + chmod 0644 /etc/cron.d/microsoft-rewards-cron + crontab /etc/cron.d/microsoft-rewards-cron + cron -f & + echo "[entrypoint] Cron started in background (schedule: $CRON_SCHEDULE, TZ: $TZ)" + else + echo "[entrypoint] No CRON_SCHEDULE set — runs must be triggered manually via POST /start" + fi + echo "[entrypoint] Starting control API on ${API_HOST}:${API_PORT:-3010} at $(date)" + exec node scripts/api/server.js +fi + +# Scheduler-only mode (default): cron calls npm start directly. if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2 exit 1 diff --git a/scripts/docker/healthcheck.sh b/scripts/docker/healthcheck.sh new file mode 100644 index 00000000..c22e6875 --- /dev/null +++ b/scripts/docker/healthcheck.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Health check used by compose.yaml and as a standalone diagnostic. +# +# node is always PID 1 in API mode (API_MODE=true); cron is always PID 1 in +# scheduler mode (API_MODE unset). Checking for either covers both modes +# without needing to inspect API_MODE. +pgrep -x node >/dev/null 2>&1 || pgrep -x cron >/dev/null 2>&1 diff --git a/scripts/docker/run_daily.sh b/scripts/docker/run_daily.sh index 93906672..c4e884a0 100644 --- a/scripts/docker/run_daily.sh +++ b/scripts/docker/run_daily.sh @@ -159,10 +159,20 @@ fi # Start the actual script echo "[$(date)] [run_daily.sh] Starting script..." -if npm start; then - echo "[$(date)] [run_daily.sh] Script completed successfully." +if [ "${API_MODE:-false}" = "true" ]; then + # API-integrated mode: delegate to the API server so the dashboard has full + # visibility and control. trigger.js calls POST /start and waits for idle. + if node scripts/api/trigger.js; then + echo "[$(date)] [run_daily.sh] Script completed successfully (via API)." + else + echo "[$(date)] [run_daily.sh] ERROR: Script failed (via API)!" >&2 + fi else - echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2 + if npm start; then + echo "[$(date)] [run_daily.sh] Script completed successfully." + else + echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2 + fi fi echo "[$(date)] [run_daily.sh] Script finished"