Init
This commit is contained in:
Executable
+353
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy.sh — build and start the forge stack on the Synology NAS, streaming
|
||||
# every stage live and ending with a clear PASS/FAIL result.
|
||||
#
|
||||
# forge has NO database migration step: the schema self-bootstraps in initDB()
|
||||
# when the app boots; the app + bundled Postgres come up together. Build -> up
|
||||
# -> health, with no migrate one-shot to wait on (unlike the utility runner).
|
||||
#
|
||||
# What it does (4 stages, each with a banner you can watch):
|
||||
# [1/4] Pull — `git pull` (only with --pull, and only in a git repo)
|
||||
# [2/4] Build — `<compose> build` (full streaming output, not quiet)
|
||||
# [3/4] Up — `<compose> up -d --remove-orphans`
|
||||
# (--remove-orphans cleans up stale containers left under
|
||||
# project `forge`)
|
||||
# [4/4] Health — poll the app container until Docker reports `healthy`
|
||||
# (≤120s), tailing app logs; fail with
|
||||
# "APP DID NOT BECOME HEALTHY" on timeout
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker + Compose available on the NAS. Prefers the v2 plugin
|
||||
# (`docker compose`, DSM 7.2+); falls back to `docker-compose`.
|
||||
# - `.env` present next to docker-compose.yml on the NAS (SESSION_SECRET + AUTH_USER +
|
||||
# AUTH_PASS + POSTGRES_PASSWORD). It is gitignored and NOT
|
||||
# baked into the image.
|
||||
# - Run from the repo root, or from anywhere — the script cd's to its own
|
||||
# parent directory (the repo root) before doing anything, so paths with
|
||||
# spaces and odd CWDs are fine.
|
||||
#
|
||||
# How to run:
|
||||
# ./scripts/deploy.sh # build + up + watch (no git pull)
|
||||
# ./scripts/deploy.sh --pull # git pull first, then build + up
|
||||
# ./scripts/deploy.sh --fresh # tear down stack first, then build + up
|
||||
# ./scripts/deploy.sh --help # show usage
|
||||
#
|
||||
# Or via DSM Task Scheduler as a user-defined script (runs head-less —
|
||||
# ANSI colour is auto-disabled when stdout is not a TTY):
|
||||
# /volume1/docker/forge/scripts/deploy.sh --pull \
|
||||
# >> /volume1/docker/forge/logs/deploy.log 2>&1
|
||||
#
|
||||
# Flags:
|
||||
# --pull Run `git pull` before building (skipped by default, since you
|
||||
# may deploy from a copied folder rather than a git checkout).
|
||||
# --fresh Tear the stack down (`down --remove-orphans`, NEVER `-v`) BEFORE
|
||||
# building, for a clean recreate. The bundled Postgres volume
|
||||
# (`forge-db`) is PRESERVED — `--fresh` never passes `-v`, so ticket
|
||||
# data survives. Parsed locally; never forwarded as a compose argument.
|
||||
# --help Print usage and exit 0.
|
||||
#
|
||||
# Ownership:
|
||||
# This script is the single source of truth for compose project `forge`.
|
||||
# `docker compose` here adopts/recreates the very same containers a
|
||||
# Container Manager GUI "Project" of that name would show — so you never
|
||||
# need the GUI to deploy. See SETUP.md ("Container Manager coexistence").
|
||||
#
|
||||
# Safety:
|
||||
# - Idempotent: safe to re-run; `up -d` only recreates containers whose
|
||||
# config/image changed.
|
||||
# - No destructive ops: never runs `down -v`, volume/image prune, or
|
||||
# anything that could drop data. `--fresh` runs `down --remove-orphans`
|
||||
# (no `-v`) which only removes containers/networks; the forge-db volume is kept.
|
||||
# - No secrets echoed.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 deploy OK (app healthy)
|
||||
# 1 precondition / build / up failure, or health timeout
|
||||
# 2 usage error (bad flag)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- PATH hardening (Synology-aware) --------------------------------------
|
||||
# Non-interactive SSH on DSM 7.2 ships a minimal PATH (/usr/bin:/bin:...)
|
||||
# that does NOT include docker. Container Manager installs docker /
|
||||
# docker-compose under these locations. Only prepend when docker is missing,
|
||||
# so this is a no-op on dev/CI hosts where docker is already on PATH.
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
export PATH="/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:$PATH"
|
||||
fi
|
||||
|
||||
# --- Resolve repo root (works regardless of CWD; tolerates spaces) --------
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# --- Config ---------------------------------------------------------------
|
||||
APP_SERVICE="app"
|
||||
HEALTH_TIMEOUT_SECS=120 # max wait for app to become healthy
|
||||
HEALTH_POLL_SECS=3 # gap between health polls
|
||||
HOST_PORT_FALLBACK="3089" # used if we can't derive the published port
|
||||
|
||||
# Prefix prepended to every docker / compose invocation. Empty on hosts where
|
||||
# the current user can talk to the docker daemon directly (dev, CI); set to
|
||||
# "sudo" on the Synology NAS, where docker requires root. The
|
||||
# ${DOCKER_SUDO:+sudo} array idiom expands to nothing (NOT an empty arg) when
|
||||
# DOCKER_SUDO is empty, so the no-sudo path is byte-for-byte unchanged.
|
||||
DOCKER_SUDO=""
|
||||
|
||||
# --- Flags ----------------------------------------------------------------
|
||||
# --pull and --fresh are parsed here and consumed locally; neither is ever
|
||||
# forwarded to compose. (push-to-nas.sh forwards extra args verbatim to this
|
||||
# script, so `npm run deploy -- --fresh` / `-- --pull` reach this loop.)
|
||||
DO_PULL=0
|
||||
DO_FRESH=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--pull) DO_PULL=1 ;;
|
||||
--fresh) DO_FRESH=1 ;;
|
||||
--help|-h)
|
||||
sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'ERROR: unknown flag: %s (try --help)\n' "$arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Colour (only on a TTY; degrade to empty strings head-less) -----------
|
||||
if [[ -t 1 ]]; then
|
||||
C_RESET=$'\033[0m'
|
||||
C_RED=$'\033[31m'
|
||||
C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'
|
||||
C_BOLD=$'\033[1m'
|
||||
else
|
||||
C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD=''
|
||||
fi
|
||||
|
||||
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
banner() {
|
||||
# banner "[1/4]" "Pulling latest code"
|
||||
printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET"
|
||||
}
|
||||
|
||||
log() { printf '[deploy %s] %s\n' "$(ts)" "$*"; }
|
||||
warn() { printf '%s[deploy %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; }
|
||||
|
||||
# Print a FAILED banner with a stage label, then exit non-zero.
|
||||
fail() {
|
||||
# fail "<stage>" "<message>"
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
printf '%s%s DEPLOY FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET"
|
||||
printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect compose command (prefer v2 plugin, fall back to v1) -----------
|
||||
# COMPOSE is an array so quoting survives word-splitting and "docker compose"
|
||||
# (two words) is handled correctly. The ${DOCKER_SUDO:+sudo} prefix is folded
|
||||
# into the array so every compose call inherits root when needed; it expands
|
||||
# to nothing when DOCKER_SUDO is empty.
|
||||
detect_compose() {
|
||||
if ${DOCKER_SUDO:+sudo} docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE=(${DOCKER_SUDO:+sudo} docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE=(${DOCKER_SUDO:+sudo} docker-compose)
|
||||
else
|
||||
fail "preflight" "Neither 'docker compose' (v2) nor 'docker-compose' (v1) is available. Install Docker via Synology Package Center."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Stage 0: preflight ----------------------------------------------------
|
||||
preflight() {
|
||||
banner "[0/4]" "Preflight checks"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fail "preflight" "docker not found on PATH (looked in /usr/local/bin and Container Manager's target dir too)."
|
||||
fi
|
||||
|
||||
# Determine whether docker needs root. On the Synology NAS the deploy user
|
||||
# cannot reach the docker daemon directly, so we route every call through
|
||||
# sudo. We only commit to sudo when we have a way to authenticate:
|
||||
# - `sudo -n true` succeeds -> passwordless sudo / cached credential, OR
|
||||
# - stdout is a TTY ([ -t 1 ]) -> interactive `sudo` can prompt (caller
|
||||
# ran us via `ssh -t`); sudo caches it.
|
||||
# With neither, an interactive prompt would hang head-less, so we bail early.
|
||||
if ! docker version >/dev/null 2>&1; then
|
||||
if sudo -n true >/dev/null 2>&1 || [[ -t 1 ]]; then
|
||||
DOCKER_SUDO="sudo"
|
||||
log "docker requires sudo on this host — you may be prompted for your password once"
|
||||
else
|
||||
fail "preflight" "docker requires root and no TTY/passwordless sudo is available. Run via 'ssh -t' or configure NOPASSWD sudo for docker."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$REPO_ROOT/.env" ]]; then
|
||||
fail "preflight" ".env not found at $REPO_ROOT/.env — create it on the NAS (SESSION_SECRET + AUTH_USER + AUTH_PASS + POSTGRES_PASSWORD) before deploying. It is gitignored and not baked into the image."
|
||||
fi
|
||||
|
||||
detect_compose
|
||||
log "repo root: $REPO_ROOT"
|
||||
log "compose command: ${COMPOSE[*]}"
|
||||
log ".env: present"
|
||||
log "this script manages compose project 'forge' directly; any Container"
|
||||
log "Manager GUI 'Project' of the same name is cosmetic — these are its containers."
|
||||
}
|
||||
|
||||
# --- Stage 1: git pull (optional) -----------------------------------------
|
||||
stage_pull() {
|
||||
banner "[1/4]" "Pulling latest code"
|
||||
if [[ "$DO_PULL" -ne 1 ]]; then
|
||||
log "skipped (no --pull flag); deploying current working tree as-is"
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$REPO_ROOT/.git" ]]; then
|
||||
warn "--pull requested but $REPO_ROOT is not a git repo; skipping pull"
|
||||
return 0
|
||||
fi
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
warn "--pull requested but git not found on PATH; skipping pull"
|
||||
return 0
|
||||
fi
|
||||
log "running git pull..."
|
||||
git pull
|
||||
log "git pull complete"
|
||||
}
|
||||
|
||||
# --- Stage 1b: optional fresh teardown (only with --fresh) ----------------
|
||||
stage_fresh() {
|
||||
if [[ "$DO_FRESH" -ne 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
banner "[1/4]" "Fresh teardown of existing stack (--fresh)"
|
||||
# NEVER pass -v: this compose HAS a named volume (forge-db) holding the ticket
|
||||
# database — dropping it would wipe all data. --remove-orphans sweeps up any
|
||||
# stale container lingering under project 'forge'; the volume is untouched.
|
||||
log "fresh mode: tearing down existing stack (forge-db volume PRESERVED)"
|
||||
"${COMPOSE[@]}" down --remove-orphans || true
|
||||
}
|
||||
|
||||
# --- Stage 2: build (full streaming output) -------------------------------
|
||||
stage_build() {
|
||||
banner "[2/4]" "Building images (this can take a few minutes on NAS hardware)"
|
||||
if ! "${COMPOSE[@]}" build; then
|
||||
fail "build" "Image build failed. See the build output above."
|
||||
fi
|
||||
log "build complete"
|
||||
}
|
||||
|
||||
# --- Stage 3: up (starts app; no migrate one-shot for forge) --------------
|
||||
stage_up() {
|
||||
banner "[3/4]" "Starting stack (app)"
|
||||
# --remove-orphans cleans up any stale container still pinned to project
|
||||
# 'forge' (e.g. left by the GUI or an old compose config). Build ran first,
|
||||
# so the app keeps serving until this quick recreate — minimal downtime.
|
||||
if ! "${COMPOSE[@]}" up -d --remove-orphans; then
|
||||
fail "up" "'up -d' failed. See the output above."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Stage 4: wait for app health -----------------------------------------
|
||||
stage_health() {
|
||||
banner "[4/4]" "Waiting for app to become healthy (timeout ${HEALTH_TIMEOUT_SECS}s)"
|
||||
|
||||
local cid
|
||||
cid="$("${COMPOSE[@]}" ps -q "$APP_SERVICE" 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -z "$cid" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP DID NOT BECOME HEALTHY — no '$APP_SERVICE' container is running. 'up' may have failed. See logs above."
|
||||
fi
|
||||
|
||||
# Show recent startup logs so the user sees progress while we poll.
|
||||
log "recent app startup logs:"
|
||||
"${COMPOSE[@]}" logs --no-color --tail 20 "$APP_SERVICE" 2>/dev/null || true
|
||||
|
||||
local deadline status running polls
|
||||
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
|
||||
polls=0
|
||||
|
||||
while true; do
|
||||
# If the container has a healthcheck, .State.Health.Status is one of
|
||||
# starting|healthy|unhealthy. If it has none, the field is empty — fall
|
||||
# back to .State.Status == running.
|
||||
status="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo "unknown")"
|
||||
running="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.State.Running}}' "$cid" 2>/dev/null || echo "false")"
|
||||
|
||||
if [[ "$status" == "healthy" ]]; then
|
||||
log "app is healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$status" == "none" && "$running" == "true" ]]; then
|
||||
warn "app container has no healthcheck; treating 'running' as healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# HARD FAIL — only a TERMINAL healthcheck verdict aborts early. 'unhealthy'
|
||||
# means the healthcheck retries are exhausted. We deliberately do NOT infer
|
||||
# failure from running!=true: under `restart: unless-stopped`, a crash makes
|
||||
# 'running' transiently false while Docker restarts the container, so we
|
||||
# keep waiting (until the overall timeout) through transient states.
|
||||
if [[ "$status" == "unhealthy" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP IS UNHEALTHY — '$APP_SERVICE' healthcheck reported 'unhealthy' (retries exhausted). See app logs above."
|
||||
fi
|
||||
|
||||
if [[ "$(date +%s)" -ge "$deadline" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP DID NOT BECOME HEALTHY within ${HEALTH_TIMEOUT_SECS}s (last status: ${status}/${running}). See app logs above."
|
||||
fi
|
||||
|
||||
# Heartbeat: emit a timestamped, status-bearing line every few polls so
|
||||
# head-less logs (DSM Task Scheduler, which only flushes at exit) stay
|
||||
# useful — bare dots would otherwise appear only when the script ends.
|
||||
polls=$(( polls + 1 ))
|
||||
if (( polls % 5 == 1 )); then
|
||||
log "still waiting (status=${status}/${running})..."
|
||||
fi
|
||||
sleep "$HEALTH_POLL_SECS"
|
||||
done
|
||||
}
|
||||
|
||||
# --- Derive the published host port (best effort) -------------------------
|
||||
derive_host_port() {
|
||||
local port
|
||||
port="$("${COMPOSE[@]}" port "$APP_SERVICE" 3000 2>/dev/null | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1 || true)"
|
||||
if [[ -n "$port" ]]; then
|
||||
printf '%s' "$port"
|
||||
else
|
||||
printf '%s' "$HOST_PORT_FALLBACK"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Final OK banner -------------------------------------------------------
|
||||
success_banner() {
|
||||
local host_port image
|
||||
host_port="$(derive_host_port)"
|
||||
image="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.Config.Image}}' "$("${COMPOSE[@]}" ps -q "$APP_SERVICE" | head -n 1)" 2>/dev/null || echo "forge-app:latest")"
|
||||
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
log "app image: $image"
|
||||
log "app health: healthy"
|
||||
log "app URL: http://<nas-host>:${host_port} (LAN; or via your reverse proxy)"
|
||||
}
|
||||
|
||||
# --- Main ------------------------------------------------------------------
|
||||
main() {
|
||||
preflight
|
||||
stage_pull
|
||||
stage_fresh
|
||||
stage_build
|
||||
stage_up
|
||||
stage_health
|
||||
success_banner
|
||||
}
|
||||
|
||||
main
|
||||
@@ -0,0 +1,158 @@
|
||||
// Convert a Let it Snow chrome.storage.local dump into FORGE's seed archives +
|
||||
// a config bundle. Run: node scripts/dump-to-archives.mjs [path/to/storage-dump.json]
|
||||
// then reload the DB: npm run reseed
|
||||
//
|
||||
// Outputs (server/data/):
|
||||
// active_archive.json — { tickets:[sn_tickets], jira:{num:info} } (operational, live board)
|
||||
// closed_archive.json — { tickets:{num:merged} } (analytics master)
|
||||
// config.json — thresholds, FX, size thresholds, SLA norms, palettes
|
||||
//
|
||||
// The closed archive MERGES two dump sources by RITM number:
|
||||
// - analytics_data.ticketsMeta (954) → analytics fields: openedBy, openedDate,
|
||||
// closedDate, toDoAt, inUatAt, jiraKey, year, businessUnit, SLA minutes, cost
|
||||
// - analytics_meta_cache (965) → operational fields: lastActivityAt/By,
|
||||
// stateChangedAt/By, requestedFor, openedAt (datetime), _updated
|
||||
// ticketsMeta wins for analytics; meta_cache fills operational gaps; union of both.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dumpPath = process.argv[2] || path.join(root, 'storage-dump.json');
|
||||
const dataDir = path.join(root, 'server', 'data');
|
||||
|
||||
const dump = JSON.parse(readFileSync(dumpPath, 'utf-8'));
|
||||
const cs = dump.chromeStorageLocal ?? {};
|
||||
const wl = dump.windowLocalStorage ?? {};
|
||||
|
||||
const sn = cs.sn_tickets ?? [];
|
||||
const metaCache = cs.analytics_meta_cache ?? {};
|
||||
const ticketsMeta = cs.analytics_data?.ticketsMeta ?? [];
|
||||
const jiraRaw = cs.jira_status_map ?? {};
|
||||
|
||||
// --- active (unchanged) ----------------------------------------------------
|
||||
const jira = {};
|
||||
for (const [num, j] of Object.entries(jiraRaw)) {
|
||||
if (j && typeof j === 'object') {
|
||||
jira[num] = { status: j.status ?? null, statusChangedAt: j.statusChangedAt ?? null,
|
||||
key: j.key ?? null, url: j.url ?? null, assignee: j.assignee ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich jira with per-status durations (ms) from the board-state snapshot, keyed
|
||||
// to RITM via customfield_26001. Powers chart #17 (Jira status durations). The
|
||||
// board's column order is the workflow order used to render the chart.
|
||||
const boardState = cs.jira_board_state?.boardState ?? {};
|
||||
const jiraColumns = (boardState.columns ?? []).map(c => c?.name).filter(Boolean);
|
||||
for (const issues of Object.values(boardState.colIssues ?? {})) {
|
||||
for (const iss of (issues ?? [])) {
|
||||
const ritm = iss?.fields?.customfield_26001;
|
||||
if (!ritm || typeof ritm !== 'string') continue;
|
||||
const sd = iss._statusDurations;
|
||||
if (!sd || typeof sd !== 'object') continue;
|
||||
const e = jira[ritm] ?? (jira[ritm] = { status: null, statusChangedAt: null, key: null, url: null, assignee: null });
|
||||
e.statusDurations = sd; // { statusName: milliseconds }
|
||||
if (!e.key) e.key = iss.key ?? null;
|
||||
if (!e.status && iss.fields?.status?.name) e.status = iss.fields.status.name;
|
||||
}
|
||||
}
|
||||
|
||||
// --- merged closed ---------------------------------------------------------
|
||||
const byNumTM = {};
|
||||
for (const t of ticketsMeta) if (t?.number) byNumTM[t.number] = t;
|
||||
|
||||
const closed = {};
|
||||
const nums = new Set([...Object.keys(metaCache), ...Object.keys(byNumTM)]);
|
||||
for (const num of nums) {
|
||||
const mc = metaCache[num] ?? {};
|
||||
const tm = byNumTM[num] ?? {};
|
||||
closed[num] = {
|
||||
// analytics (ticketsMeta wins, fall back to meta_cache)
|
||||
state: tm.state ?? mc.state ?? '',
|
||||
shortDesc: tm.shortDesc ?? mc.shortDesc ?? '',
|
||||
assignedTo: tm.assignedTo ?? mc.assignedTo ?? null,
|
||||
brand: tm.brand ?? mc.brand ?? null,
|
||||
market: tm.market ?? mc.market ?? null,
|
||||
businessUnit: tm.businessUnit ?? mc.businessUnit ?? null,
|
||||
finalCost: tm.finalCost ?? mc.finalCost ?? null,
|
||||
currencyCode: tm.currencyCode ?? mc.currencyCode ?? null,
|
||||
ttfrMinutes: tm.ttfrMinutes ?? mc.ttfrMinutes ?? null,
|
||||
clientRespMinutes: tm.clientRespMinutes ?? mc.clientRespMinutes ?? null,
|
||||
firstReplyAt: tm.firstReplyAt ?? mc.firstReplyAt ?? null,
|
||||
firstAssignedDate: tm.firstAssignedDate ?? mc.firstAssignedDate ?? null,
|
||||
fulfillmentDate: tm.fulfillmentDate ?? mc.fulfillmentDate ?? null,
|
||||
openedBy: tm.openedBy ?? null,
|
||||
openedDate: tm.openedDate ?? null,
|
||||
closedDate: tm.closedDate ?? null,
|
||||
toDoAt: tm.toDoAt ?? null,
|
||||
inUatAt: tm.inUatAt ?? null,
|
||||
jiraKey: tm.jiraKey ?? null,
|
||||
year: tm.year ?? null,
|
||||
// operational (meta_cache)
|
||||
requestedFor: mc.requestedFor ?? null,
|
||||
openedAt: mc.openedAt ?? tm.openedDate ?? null,
|
||||
stateChangedAt: mc.stateChangedAt ?? null,
|
||||
stateChangedBy: mc.stateChangedBy ?? null,
|
||||
lastActivityAt: mc.lastActivityAt ?? null,
|
||||
lastActivityBy: mc.lastActivityBy ?? null,
|
||||
_updated: mc._updated ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- config bundle ---------------------------------------------------------
|
||||
const parseWl = (k, fallback) => { try { return JSON.parse(wl[k]); } catch { return fallback; } };
|
||||
const config = {
|
||||
insightsThresholds: cs.insights_thresholds ?? {},
|
||||
fxRates: cs.fx_rates_cache?.rates ?? { GBP: 1, EUR: 1.2, MXN: 20 },
|
||||
sizeThresholds: parseWl('otd_cost_thresh', { XS: 120, S: 300, M: 600, L: 1200, XL: 3000, XXL: 9000 }),
|
||||
norms: {
|
||||
otd: parseWl('otd_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
|
||||
avgdays: parseWl('avgdays_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
|
||||
asla: parseWl('asla_day_norms', { XS: 1, S: 1, M: 1, L: 1, XL: 1, XXL: 1 }),
|
||||
psla: parseWl('psla_day_norms', { XS: 3, S: 6, M: 9, L: 15, XL: 30, XXL: 30 }),
|
||||
ttfr: parseWl('lisr_ttfr_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
|
||||
cresp: parseWl('lisr_cresp_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
|
||||
},
|
||||
brandColors: cs.brand_colors ?? {},
|
||||
snowColors: cs.snow_colors ?? {},
|
||||
statesOrder: cs.sn_states_order ?? [],
|
||||
displayCurrency: cs.display_currency ?? 'GBP',
|
||||
colleagues: String(cs.colleague_names ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
||||
latamAssignees: String(cs.latam_assignees ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
||||
jiraColumns,
|
||||
};
|
||||
|
||||
// --- finance (per-ticket cost / PO / invoiced, from the SharePoint xlsx) ------
|
||||
// Column names are data-driven; map the known ones case-insensitively.
|
||||
const financeRaw = cs.finance_xlsx_data?.rows ?? {};
|
||||
const cell = (row, ...names) => {
|
||||
for (const k of Object.keys(row)) if (names.some(n => k.toLowerCase() === n.toLowerCase())) return row[k];
|
||||
return null;
|
||||
};
|
||||
const finance = { rows: {} };
|
||||
for (const [num, row] of Object.entries(financeRaw)) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const po = cell(row, 'PO');
|
||||
finance.rows[num] = {
|
||||
cost: cell(row, 'Cost'),
|
||||
currency: cell(row, 'Currency'),
|
||||
po: po == null ? '' : String(po).trim(), // '' = in finance but no PO; key for waiting-PO
|
||||
invoiced: cell(row, 'Invoiced'),
|
||||
milestone: cell(row, 'Milestone'),
|
||||
state: cell(row, 'State'),
|
||||
pm: cell(row, 'PM'),
|
||||
};
|
||||
}
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
writeFileSync(path.join(dataDir, 'finance.json'), JSON.stringify({ schema: 1, generatedAt, count: Object.keys(finance.rows).length, rows: finance.rows }));
|
||||
writeFileSync(path.join(dataDir, 'active_archive.json'),
|
||||
JSON.stringify({ schema: 2, generatedAt, count: sn.length, tickets: sn, jira }));
|
||||
writeFileSync(path.join(dataDir, 'closed_archive.json'),
|
||||
JSON.stringify({ schema: 2, generatedAt, count: Object.keys(closed).length, tickets: closed }));
|
||||
writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(config, null, 2));
|
||||
|
||||
const overlap = Object.keys(closed).filter(n => sn.some(t => t.number === n)).length;
|
||||
console.log(`active=${sn.length} closed(merged meta_cache∪ticketsMeta)=${Object.keys(closed).length} jira=${Object.keys(jira).length} overlap=${overlap} (active wins)`);
|
||||
console.log(`ticketsMeta analytics rows folded in: ${Object.keys(byNumTM).length}`);
|
||||
console.log('Wrote server/data/{active,closed}_archive.json + config.json — now run: npm run reseed');
|
||||
Executable
+381
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# push-to-nas.sh — push the working tree to the Synology NAS over SSH and
|
||||
# run the on-NAS deploy, streaming every stage live and
|
||||
# ending with a clear PASS/FAIL result.
|
||||
#
|
||||
# This is the LOCAL-side counterpart to deploy.sh (which runs ON the NAS).
|
||||
# It is what `npm run deploy` invokes.
|
||||
#
|
||||
# What it does (4 stages, each with a banner you can watch):
|
||||
# [0/4] Preflight — verify rsync/ssh exist, the SSH key is present, and a
|
||||
# one-shot SSH connection to the NAS succeeds (BatchMode,
|
||||
# so it fails fast instead of prompting for a password)
|
||||
# [1/4] Test — run the test suite (`npm test` -> vitest run) as a GATE.
|
||||
# Runs BEFORE anything touches the NAS, so a red suite
|
||||
# aborts the deploy fast and locally. CI-safe (vitest run,
|
||||
# not watch). Skip only with SKIP_TESTS=1 (escape hatch).
|
||||
# [2/4] Sync — `rsync` the working tree to $NAS_PATH on the NAS
|
||||
# (delete-extraneous, but excluding build/runtime cruft
|
||||
# and — critically — the NAS-local .env and backups)
|
||||
# [3/4] Deploy — run `scripts/deploy.sh` ON the NAS over SSH, forwarding
|
||||
# any extra args you pass (e.g. --fresh / --pull)
|
||||
#
|
||||
# Why identity is pinned on every SSH hop:
|
||||
# In some environments (notably IDE-spawned shells such as WebStorm's npm
|
||||
# runner) ssh-agent offers MANY identities. The server can hit MaxAuthTries
|
||||
# and reject the connection BEFORE the correct key is tried — "Permission
|
||||
# denied" even though the key is loaded. Passing an explicit `-i $NAS_KEY`
|
||||
# together with `-o IdentitiesOnly=yes` makes ssh offer ONLY that one key,
|
||||
# so auth is deterministic regardless of how many identities the agent has.
|
||||
# This is applied to the preflight ssh, the rsync transport, AND the remote
|
||||
# deploy ssh — every outbound hop.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - rsync and ssh on the local PATH (macOS/Linux ship both).
|
||||
# - An SSH key that can log into the NAS as $NAS_USER (default
|
||||
# ~/.ssh/id_ed25519; override with NAS_KEY). Key-based auth must already
|
||||
# work — this script does not set up keys.
|
||||
# - deploy.sh + docker-compose.yml + .env already present (or about to be
|
||||
# rsynced) under $NAS_PATH on the NAS. NOTE: .env is intentionally NOT
|
||||
# synced (it is NAS-local and gitignored); create it on the NAS once.
|
||||
#
|
||||
# How to run:
|
||||
# ./scripts/push-to-nas.sh # sync + remote deploy
|
||||
# ./scripts/push-to-nas.sh --fresh # extra args pass through to
|
||||
# # deploy.sh on the NAS
|
||||
# ./scripts/push-to-nas.sh --help # show usage
|
||||
#
|
||||
# Or simply: npm run deploy
|
||||
#
|
||||
# Config (override via environment):
|
||||
# NAS_HOST NAS hostname/IP (default: mycloud.dp.ua)
|
||||
# NAS_USER SSH user on the NAS (default: d.tkachenko)
|
||||
# NAS_PORT SSH port (default: 2323)
|
||||
# NAS_PATH Deploy dir on the NAS (default: /volume1/docker/forge)
|
||||
# NAS_KEY SSH private key to authenticate (default: ~/.ssh/id_ed25519)
|
||||
#
|
||||
# Safety:
|
||||
# - rsync excludes .git, node_modules, dist, the local .env, and backups,
|
||||
# so we never clobber NAS-local secrets/data or push local build cruft.
|
||||
# - Read-only locally; the only mutation is on the NAS via deploy.sh, which
|
||||
# is itself non-destructive (no down/prune/volume drops).
|
||||
# - No secrets echoed.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 push + remote deploy OK
|
||||
# 1 preflight / sync / remote-deploy failure (remote exit code propagated)
|
||||
# 2 usage error (bad flag) / missing prerequisite
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Resolve repo root (works regardless of CWD; tolerates spaces) --------
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Optional per-machine overrides. If .deploy.env exists (gitignored), load it so
|
||||
# its NAS_HOST/NAS_USER/NAS_PORT/NAS_PATH/NAS_KEY win over the baked defaults
|
||||
# below. Not required — the defaults already target this NAS.
|
||||
if [[ -f .deploy.env ]]; then set -a; . ./.deploy.env; set +a; fi
|
||||
|
||||
# --- Config (baked defaults for this NAS; override via env or .deploy.env) --
|
||||
NAS_HOST="${NAS_HOST:-mycloud.dp.ua}"
|
||||
NAS_USER="${NAS_USER:-d.tkachenko}"
|
||||
NAS_PORT="${NAS_PORT:-2323}"
|
||||
NAS_PATH="${NAS_PATH:-/volume1/docker/forge}"
|
||||
NAS_KEY="${NAS_KEY:-$HOME/.ssh/id_ed25519}"
|
||||
|
||||
# --- Flags ----------------------------------------------------------------
|
||||
# Everything we don't recognise is forwarded verbatim to deploy.sh on the
|
||||
# NAS (so `--fresh` and future deploy.sh flags Just Work). --help is local.
|
||||
DEPLOY_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--help|-h)
|
||||
sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
DEPLOY_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Colour (only on a TTY; degrade to empty strings head-less) -----------
|
||||
if [[ -t 1 ]]; then
|
||||
C_RESET=$'\033[0m'
|
||||
C_RED=$'\033[31m'
|
||||
C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'
|
||||
C_BOLD=$'\033[1m'
|
||||
else
|
||||
C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD=''
|
||||
fi
|
||||
|
||||
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
banner() {
|
||||
# banner "[1/3]" "Syncing files"
|
||||
printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET"
|
||||
}
|
||||
|
||||
log() { printf '[push %s] %s\n' "$(ts)" "$*"; }
|
||||
warn() { printf '%s[push %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; }
|
||||
|
||||
# Print a FAILED banner with a stage label, then exit non-zero.
|
||||
fail() {
|
||||
# fail "<stage>" "<message>" [<exit-code>]
|
||||
local code="${3:-1}"
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
printf '%s%s PUSH FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET"
|
||||
printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# --- SSH options shared by every outbound hop -----------------------------
|
||||
# Pin the exact key + IdentitiesOnly so ssh offers ONLY this identity. This is
|
||||
# the whole point of the script: deterministic auth regardless of how many keys
|
||||
# ssh-agent holds (the WebStorm-vs-Terminal discrepancy). Kept as an array so
|
||||
# the args survive quoting/word-splitting cleanly when expanded.
|
||||
SSH_OPTS=(-p "$NAS_PORT" -i "$NAS_KEY" -o IdentitiesOnly=yes)
|
||||
|
||||
# Single STRING form of the same pinned-key ssh command, for RSYNC_RSH only:
|
||||
# a real (samba) rsync wants ONE remote-shell string, not an array. Single-
|
||||
# quote the key path defensively so an odd path can't word-split.
|
||||
RSYNC_RSH_CMD="ssh -p $NAS_PORT -i '$NAS_KEY' -o IdentitiesOnly=yes"
|
||||
|
||||
# Transfer-hop ssh: pinned identity PLUS BatchMode so a password prompt fails
|
||||
# fast instead of hanging mid-deploy. Used by the tar fallback hops.
|
||||
SSH_TRANSFER=(ssh "${SSH_OPTS[@]}" -o BatchMode=yes)
|
||||
|
||||
# --- Stage 0: preflight ----------------------------------------------------
|
||||
preflight() {
|
||||
banner "[0/4]" "Preflight checks"
|
||||
|
||||
if ! command -v rsync >/dev/null 2>&1; then
|
||||
fail "preflight" "rsync not found on PATH. Install rsync (macOS: it ships by default; Linux: your package manager)." 2
|
||||
fi
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
fail "preflight" "ssh not found on PATH." 2
|
||||
fi
|
||||
|
||||
# The key MUST exist as a file before we attempt any connection — otherwise
|
||||
# ssh silently falls back to agent identities and we lose the determinism
|
||||
# this whole script exists to provide.
|
||||
if [[ ! -f "$NAS_KEY" ]]; then
|
||||
fail "preflight" "SSH key $NAS_KEY not found — set NAS_KEY or generate one (ssh-keygen -t ed25519)." 2
|
||||
fi
|
||||
|
||||
log "target: $NAS_USER@$NAS_HOST:$NAS_PORT"
|
||||
log "path: $NAS_PATH"
|
||||
log "ssh key: $NAS_KEY"
|
||||
|
||||
# Fail-fast connectivity check. BatchMode=yes => never prompt for a password
|
||||
# (so a misconfigured key fails here, loudly, instead of hanging).
|
||||
log "verifying SSH connectivity (pinned key, no password prompt)..."
|
||||
if ! ssh "${SSH_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=8 \
|
||||
"$NAS_USER@$NAS_HOST" 'true'; then
|
||||
fail "preflight" "SSH connection to $NAS_USER@$NAS_HOST:$NAS_PORT failed with key $NAS_KEY. Confirm the key is authorized on the NAS (ssh-copy-id) and the host/port are correct." 1
|
||||
fi
|
||||
log "SSH connectivity OK"
|
||||
}
|
||||
|
||||
# --- Stage 1: test gate ----------------------------------------------------
|
||||
# Run the suite BEFORE any NAS interaction so a red build fails fast and local,
|
||||
# never leaving a half-synced tree or a broken image on the NAS. `npm test`
|
||||
# resolves to `cd client && npm test` -> `vitest run` (CI mode, non-watch — it
|
||||
# exits, it does not hang). Headless-safe: vitest run needs no TTY. The only
|
||||
# escape hatch is SKIP_TESTS=1 for emergencies; it warns loudly when used.
|
||||
# No secrets are involved or echoed here.
|
||||
stage_test() {
|
||||
banner "[1/4]" "Running test gate (npm test)"
|
||||
|
||||
if [[ "${SKIP_TESTS:-0}" == "1" ]]; then
|
||||
warn "SKIP_TESTS=1 set — BYPASSING the test gate. Deploying UNTESTED code."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
fail "test" "npm not found on PATH — cannot run the test gate. Install Node/npm or set SKIP_TESTS=1 to bypass (not recommended)." 2
|
||||
fi
|
||||
|
||||
log "running: npm test (vitest run, non-watch)"
|
||||
# Run directly so the real exit code propagates; set -e would also abort, but
|
||||
# this gives a clean stage-labelled failure banner instead of a bare trap.
|
||||
local rc=0
|
||||
npm test || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
fail "test" "Test suite failed (npm test exited $rc). Deploy aborted before touching the NAS. Fix the tests (or SKIP_TESTS=1 to force, not recommended)." "$rc"
|
||||
fi
|
||||
log "tests passed — proceeding to sync"
|
||||
}
|
||||
|
||||
# --- Transport detection: find a usable (NON-openrsync) rsync --------------
|
||||
# macOS now ships Apple's "openrsync" as /usr/bin/rsync. openrsync IGNORES both
|
||||
# the `-e` remote-shell option AND the RSYNC_RSH env var, so our pinned ssh key
|
||||
# never reaches ssh, auth falls back to (non-existent) password, and the sync
|
||||
# fails. Detect that here: a usable rsync is one whose `--version` does NOT say
|
||||
# "openrsync". We also probe the common Homebrew install paths in case the user
|
||||
# `brew install rsync`d a real (samba) rsync. Sets $RSYNC_BIN if one is found.
|
||||
detect_usable_rsync() {
|
||||
RSYNC_BIN=""
|
||||
local candidates=()
|
||||
local path_rsync
|
||||
if path_rsync="$(command -v rsync 2>/dev/null)"; then
|
||||
candidates+=("$path_rsync")
|
||||
fi
|
||||
candidates+=(/opt/homebrew/bin/rsync /usr/local/bin/rsync)
|
||||
|
||||
local cand
|
||||
for cand in "${candidates[@]}"; do
|
||||
[[ -x "$cand" ]] || continue
|
||||
if ! "$cand" --version 2>/dev/null | head -1 | grep -qi 'openrsync'; then
|
||||
RSYNC_BIN="$cand"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Stage 2: sync the working tree (rsync if usable, else tar-over-ssh) ---
|
||||
stage_sync() {
|
||||
banner "[2/4]" "Syncing working tree to $NAS_USER@$NAS_HOST:$NAS_PATH"
|
||||
|
||||
if detect_usable_rsync; then
|
||||
sync_via_rsync
|
||||
else
|
||||
sync_via_tar
|
||||
fi
|
||||
log "sync complete"
|
||||
}
|
||||
|
||||
# Preferred path: a real rsync. --delete keeps the NAS tree a mirror of local;
|
||||
# the excludes protect NAS-local state (.env* secrets, backups DB dumps) and
|
||||
# local-only build cruft. Trailing slash on the source copies the CONTENTS of
|
||||
# REPO_ROOT into NAS_PATH. RSYNC_RSH carries our pinned-key ssh (incl. BatchMode
|
||||
# so it fails fast rather than hanging on a password prompt).
|
||||
sync_via_rsync() {
|
||||
log "using rsync at $RSYNC_BIN"
|
||||
if ! RSYNC_RSH="$RSYNC_RSH_CMD -o BatchMode=yes" "$RSYNC_BIN" -av --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude '._*' \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'client/node_modules/' \
|
||||
--exclude 'dist/' \
|
||||
--exclude 'client/dist/' \
|
||||
--exclude '.env' \
|
||||
--exclude '.env.*' \
|
||||
--exclude '.deploy.env' \
|
||||
--exclude 'storage-dump.json' \
|
||||
--exclude '.idea/' \
|
||||
--exclude 'backups/' \
|
||||
--exclude 'logs/' \
|
||||
--exclude 'prototype/' \
|
||||
--exclude 'prototype.zip' \
|
||||
--exclude '.claude/' \
|
||||
--exclude 'claude_artifacts/' \
|
||||
"$REPO_ROOT/" \
|
||||
"$NAS_USER@$NAS_HOST:$NAS_PATH/"; then
|
||||
fail "sync" "rsync to $NAS_HOST:$NAS_PATH failed. See rsync output above."
|
||||
fi
|
||||
}
|
||||
|
||||
# Fallback path (e.g. macOS openrsync): stream a tar of the working tree over
|
||||
# the proven pinned-key ssh and extract it on the NAS. No rsync involved, so
|
||||
# the -e/RSYNC_RSH-ignoring openrsync problem is sidestepped entirely.
|
||||
#
|
||||
# Tradeoff: tar extraction is additive — it does NOT delete stale remote files
|
||||
# the way `rsync --delete` does. Documented loudly below.
|
||||
sync_via_tar() {
|
||||
warn "openrsync detected (Apple's rsync ignores -e/RSYNC_RSH); using tar-over-ssh."
|
||||
warn "Note: stale remote files are NOT deleted in this mode — \`brew install rsync\` to enable --delete."
|
||||
|
||||
if ! "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "mkdir -p '$NAS_PATH'"; then
|
||||
fail "sync" "could not create remote dir $NAS_PATH on $NAS_HOST (tar-over-ssh)."
|
||||
fi
|
||||
|
||||
# Pipe local tar -> remote tar extract, capturing the exit code of BOTH sides.
|
||||
# Snapshot ${PIPESTATUS[@]} into an array in the very next command on BOTH
|
||||
# branches so it is read before anything overwrites it (and `set -e` never
|
||||
# aborts mid-stage).
|
||||
local pipe_status=()
|
||||
{ COPYFILE_DISABLE=1 tar --no-mac-metadata -czf - \
|
||||
--exclude='._*' \
|
||||
--exclude='./.git' \
|
||||
--exclude='./node_modules' \
|
||||
--exclude='./client/node_modules' \
|
||||
--exclude='./dist' \
|
||||
--exclude='./client/dist' \
|
||||
--exclude='./.env' \
|
||||
--exclude='./.env.*' \
|
||||
--exclude='./.deploy.env' \
|
||||
--exclude='./storage-dump.json' \
|
||||
--exclude='./.idea' \
|
||||
--exclude='./backups' \
|
||||
--exclude='./logs' \
|
||||
--exclude='./prototype' \
|
||||
--exclude='./prototype.zip' \
|
||||
--exclude='./.claude' \
|
||||
--exclude='./claude_artifacts' \
|
||||
-C "$REPO_ROOT" . \
|
||||
| "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "tar -xzf - -C '$NAS_PATH'" ; } \
|
||||
&& pipe_status=("${PIPESTATUS[@]}") \
|
||||
|| pipe_status=("${PIPESTATUS[@]}")
|
||||
|
||||
local tar_rc="${pipe_status[0]}"
|
||||
local ssh_rc="${pipe_status[1]}"
|
||||
|
||||
if [[ "$tar_rc" -ne 0 ]]; then
|
||||
fail "sync" "local tar failed (exit $tar_rc) streaming working tree to $NAS_HOST."
|
||||
fi
|
||||
if [[ "$ssh_rc" -ne 0 ]]; then
|
||||
fail "sync" "remote tar extract on $NAS_HOST failed (exit $ssh_rc) (tar-over-ssh)."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Stage 3: run deploy.sh on the NAS ------------------------------------
|
||||
stage_deploy() {
|
||||
banner "[3/4]" "Running deploy.sh on the NAS"
|
||||
|
||||
# -t allocates a TTY so deploy.sh's live streaming + colour come through.
|
||||
# printf %q on each forwarded arg makes the remote command robust to args
|
||||
# containing spaces/quotes; the array may be empty, which is fine.
|
||||
local remote_args=""
|
||||
if [[ ${#DEPLOY_ARGS[@]} -gt 0 ]]; then
|
||||
remote_args="$(printf ' %q' "${DEPLOY_ARGS[@]}")"
|
||||
fi
|
||||
|
||||
local remote_cmd
|
||||
remote_cmd="cd $(printf '%q' "$NAS_PATH") && ./scripts/deploy.sh${remote_args}"
|
||||
|
||||
# Run directly (not under `if ! ...`) so we can capture the REAL remote exit
|
||||
# code: after `if ! cmd`, $? is the negation's status (0), not the command's.
|
||||
local rc=0
|
||||
ssh -t "${SSH_OPTS[@]}" "$NAS_USER@$NAS_HOST" "$remote_cmd" || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
fail "deploy" "Remote deploy.sh on $NAS_HOST exited non-zero (code $rc). See the deploy output above." "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Final OK banner -------------------------------------------------------
|
||||
success_banner() {
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s PUSH + DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
log "target: $NAS_USER@$NAS_HOST:$NAS_PATH"
|
||||
log "remote deploy completed successfully"
|
||||
}
|
||||
|
||||
# --- Main ------------------------------------------------------------------
|
||||
main() {
|
||||
preflight
|
||||
stage_test
|
||||
stage_sync
|
||||
stage_deploy
|
||||
success_banner
|
||||
}
|
||||
|
||||
main
|
||||
Reference in New Issue
Block a user