spaps 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AI_TOOLS.json +10 -11
  2. package/README.md +216 -36
  3. package/assets/local-runtime/Dockerfile +28 -0
  4. package/assets/local-runtime/alembic/env.py +101 -0
  5. package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
  6. package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
  7. package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
  8. package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
  9. package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
  10. package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
  11. package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
  12. package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
  13. package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
  14. package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
  15. package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
  16. package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
  17. package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
  18. package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
  19. package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
  20. package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
  21. package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
  22. package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
  23. package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
  24. package/assets/local-runtime/alembic.ini +47 -0
  25. package/assets/local-runtime/docker-compose.yml +61 -0
  26. package/assets/local-runtime/manifest.json +8 -0
  27. package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
  28. package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
  29. package/assets/local-runtime/scripts/run-migrations.sh +96 -0
  30. package/package.json +2 -1
  31. package/src/ai-helper.js +176 -234
  32. package/src/ai-tool-spec.js +52 -20
  33. package/src/auth/api-key.js +119 -0
  34. package/src/auth/client-id.js +136 -0
  35. package/src/auth/client.js +169 -0
  36. package/src/auth/credentials.js +110 -0
  37. package/src/auth/device-flow.js +159 -0
  38. package/src/auth/env.js +57 -0
  39. package/src/auth/handlers.js +462 -0
  40. package/src/auth/http.js +74 -0
  41. package/src/cli-dispatcher.js +134 -21
  42. package/src/docs-system.js +7 -7
  43. package/src/fixture-kernel.js +1143 -0
  44. package/src/handlers.js +202 -11
  45. package/src/help-system.js +2 -0
  46. package/src/local-runtime.js +258 -0
  47. package/src/local-server.js +597 -199
  48. package/src/project-scaffolder.js +185 -45
@@ -0,0 +1,54 @@
1
+ """issue reporting platform
2
+
3
+ Revision ID: 000000000016
4
+ Revises: 000000000015
5
+ Create Date: 2026-03-29
6
+
7
+ Creates the issue_reports table for shared end-user issue reporting linked to
8
+ the support_telemetry_platform support-case substrate.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000016"
14
+ down_revision = "000000000015"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ CREATE TABLE IF NOT EXISTS issue_reports (
22
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
24
+ reporter_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25
+ reporter_role_hint VARCHAR(64),
26
+ component_key VARCHAR(255) NOT NULL,
27
+ component_label VARCHAR(255) NOT NULL,
28
+ page_url VARCHAR(500) NOT NULL,
29
+ surface_ref VARCHAR(255),
30
+ target_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
31
+ note TEXT NOT NULL,
32
+ parent_issue_report_id UUID REFERENCES issue_reports(id) ON DELETE SET NULL,
33
+ support_case_id UUID NOT NULL REFERENCES support_cases(id) ON DELETE RESTRICT,
34
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
35
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ );
37
+ """)
38
+
39
+ op.execute("""
40
+ CREATE INDEX IF NOT EXISTS ix_issue_reports_app_reporter_created
41
+ ON issue_reports (application_id, reporter_user_id, created_at);
42
+ """)
43
+ op.execute("""
44
+ CREATE INDEX IF NOT EXISTS ix_issue_reports_support_case
45
+ ON issue_reports (support_case_id);
46
+ """)
47
+ op.execute("""
48
+ CREATE INDEX IF NOT EXISTS ix_issue_reports_parent
49
+ ON issue_reports (parent_issue_report_id);
50
+ """)
51
+
52
+
53
+ def downgrade() -> None:
54
+ op.execute("DROP TABLE IF EXISTS issue_reports CASCADE;")
@@ -0,0 +1,44 @@
1
+ """issue reporting platform import tracking
2
+
3
+ Revision ID: 000000000017
4
+ Revises: 000000000016
5
+ Create Date: 2026-03-29
6
+
7
+ Adds replay-safe legacy source tracking to issue_reports so first-adopter
8
+ cutovers can import HTMA issue logs without duplicating issue reports.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000017"
14
+ down_revision = "000000000016"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ ALTER TABLE issue_reports
22
+ ADD COLUMN IF NOT EXISTS source_app VARCHAR(64);
23
+ """)
24
+ op.execute("""
25
+ ALTER TABLE issue_reports
26
+ ADD COLUMN IF NOT EXISTS source_record_id VARCHAR(255);
27
+ """)
28
+ op.execute("""
29
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_issue_reports_source_ref
30
+ ON issue_reports (application_id, source_app, source_record_id)
31
+ WHERE source_app IS NOT NULL AND source_record_id IS NOT NULL;
32
+ """)
33
+
34
+
35
+ def downgrade() -> None:
36
+ op.execute("DROP INDEX IF EXISTS uq_issue_reports_source_ref;")
37
+ op.execute("""
38
+ ALTER TABLE issue_reports
39
+ DROP COLUMN IF EXISTS source_record_id;
40
+ """)
41
+ op.execute("""
42
+ ALTER TABLE issue_reports
43
+ DROP COLUMN IF EXISTS source_app;
44
+ """)
@@ -0,0 +1,76 @@
1
+ """authorization policy engine
2
+
3
+ Revision ID: 000000000018
4
+ Revises: 000000000017
5
+ Create Date: 2026-03-31
6
+
7
+ Creates the policies and policy_evaluation_logs tables for the composable
8
+ authorization policy engine.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000018"
14
+ down_revision = "000000000017"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ CREATE TABLE IF NOT EXISTS policies (
22
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
24
+ name VARCHAR(255) NOT NULL,
25
+ description TEXT,
26
+ effect VARCHAR(10) NOT NULL DEFAULT 'allow',
27
+ conditions JSONB NOT NULL DEFAULT '{}'::jsonb,
28
+ priority INTEGER NOT NULL DEFAULT 0,
29
+ is_active BOOLEAN NOT NULL DEFAULT true,
30
+ metadata JSONB DEFAULT '{}'::jsonb,
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
32
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
33
+ );
34
+ """)
35
+
36
+ # Unique constraint: one policy name per application
37
+ op.execute("""
38
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_policies_app_name
39
+ ON policies (application_id, name);
40
+ """)
41
+
42
+ op.execute("""
43
+ CREATE INDEX IF NOT EXISTS ix_policies_app_active
44
+ ON policies (application_id, is_active)
45
+ WHERE is_active = true;
46
+ """)
47
+
48
+ op.execute("""
49
+ CREATE TABLE IF NOT EXISTS policy_evaluation_logs (
50
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
51
+ policy_id UUID REFERENCES policies(id) ON DELETE SET NULL,
52
+ application_id UUID NOT NULL,
53
+ user_id UUID,
54
+ policy_name VARCHAR(255) NOT NULL,
55
+ decision VARCHAR(10) NOT NULL,
56
+ reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
57
+ evaluation_ms INTEGER NOT NULL DEFAULT 0,
58
+ context_snapshot JSONB,
59
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
60
+ );
61
+ """)
62
+
63
+ op.execute("""
64
+ CREATE INDEX IF NOT EXISTS ix_policy_eval_logs_app_created
65
+ ON policy_evaluation_logs (application_id, created_at DESC);
66
+ """)
67
+
68
+ op.execute("""
69
+ CREATE INDEX IF NOT EXISTS ix_policy_eval_logs_policy_name
70
+ ON policy_evaluation_logs (application_id, policy_name);
71
+ """)
72
+
73
+
74
+ def downgrade() -> None:
75
+ op.execute("DROP TABLE IF EXISTS policy_evaluation_logs;")
76
+ op.execute("DROP TABLE IF EXISTS policies;")
@@ -0,0 +1,47 @@
1
+ # SPAPS Alembic configuration
2
+ # Migrations live at repo root: sweet-potato/alembic/
3
+ # Models are imported from: spaps_server_quickstart.domains
4
+
5
+ [alembic]
6
+ script_location = %(here)s/alembic
7
+ prepend_sys_path = packages/python-server-quickstart/src
8
+ path_separator = os
9
+
10
+ # DATABASE_URL is set via env.py from settings
11
+ sqlalchemy.url =
12
+
13
+ [post_write_hooks]
14
+
15
+ [loggers]
16
+ keys = root,sqlalchemy,alembic
17
+
18
+ [handlers]
19
+ keys = console
20
+
21
+ [formatters]
22
+ keys = generic
23
+
24
+ [logger_root]
25
+ level = WARN
26
+ handlers = console
27
+ qualname =
28
+
29
+ [logger_sqlalchemy]
30
+ level = WARN
31
+ handlers =
32
+ qualname = sqlalchemy.engine
33
+
34
+ [logger_alembic]
35
+ level = INFO
36
+ handlers =
37
+ qualname = alembic
38
+
39
+ [handler_console]
40
+ class = StreamHandler
41
+ args = (sys.stderr,)
42
+ level = NOTSET
43
+ formatter = generic
44
+
45
+ [formatter_generic]
46
+ format = %(levelname)-5.5s [%(name)s] %(message)s
47
+ datefmt = %H:%M:%S
@@ -0,0 +1,61 @@
1
+ services:
2
+ spaps-dev-api:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ args:
7
+ SPAPS_SERVER_QUICKSTART_VERSION: ${SPAPS_SERVER_QUICKSTART_VERSION:-0.5.0}
8
+ restart: unless-stopped
9
+ ports:
10
+ - "${SPAPS_LOCAL_PORT:-3301}:8000"
11
+ environment:
12
+ ENV: dev
13
+ DATABASE_URL: postgresql+asyncpg://postgres:postgres@spaps-dev-db:5432/spaps
14
+ REDIS_URL: redis://spaps-dev-redis:6379/0
15
+ SPAPS_LOCAL_MODE: "${SPAPS_LOCAL_MODE:-true}"
16
+ JWT_SECRET: "${JWT_SECRET:-spaps_local_dev_jwt_secret}"
17
+ REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET:-spaps_local_dev_refresh_secret}"
18
+ SELF_SERVICE_PASSWORD: "${SELF_SERVICE_PASSWORD:-spaps_local_self_service_password}"
19
+ ALEMBIC_RUN_ON_START_ROLE: primary
20
+ LOG_LEVEL: DEBUG
21
+ LOG_FORMAT: console
22
+ AUTH_BASE_URL: "${SPAPS_AUTH_BASE_URL:-http://localhost:5173}"
23
+ CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-*}"
24
+ LEGACY_API_KEY_AUTH_ENABLED: "${LEGACY_API_KEY_AUTH_ENABLED:-true}"
25
+ depends_on:
26
+ spaps-dev-db:
27
+ condition: service_healthy
28
+ spaps-dev-redis:
29
+ condition: service_started
30
+ healthcheck:
31
+ test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/health'); r.raise_for_status()"]
32
+ interval: 10s
33
+ timeout: 5s
34
+ retries: 5
35
+ start_period: 15s
36
+
37
+ spaps-dev-db:
38
+ image: ankane/pgvector:v0.5.1
39
+ restart: unless-stopped
40
+ environment:
41
+ POSTGRES_USER: postgres
42
+ POSTGRES_PASSWORD: postgres
43
+ POSTGRES_DB: spaps
44
+ volumes:
45
+ - spaps-dev-pgdata:/var/lib/postgresql/data
46
+ healthcheck:
47
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
48
+ interval: 10s
49
+ timeout: 5s
50
+ retries: 5
51
+
52
+ spaps-dev-redis:
53
+ image: redis:7-alpine
54
+ restart: unless-stopped
55
+ command: redis-server --appendonly yes
56
+ volumes:
57
+ - spaps-dev-redisdata:/data
58
+
59
+ volumes:
60
+ spaps-dev-pgdata:
61
+ spaps-dev-redisdata:
@@ -0,0 +1,8 @@
1
+ {
2
+ "spaps_server_quickstart_version": "0.5.0",
3
+ "portable_runtime": {
4
+ "default_local_mode": true,
5
+ "default_auth_base_url": "http://localhost:5173",
6
+ "default_self_service_password": "spaps_local_self_service_password"
7
+ }
8
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="${APP_ROOT:-/app}"
5
+ MIGRATION_RUNNER="${ROOT}/scripts/run-migrations.sh"
6
+
7
+ if [[ -x "${MIGRATION_RUNNER}" ]]; then
8
+ "${MIGRATION_RUNNER}"
9
+ else
10
+ echo "⚠️ ${MIGRATION_RUNNER} not executable; skipping migrations." >&2
11
+ fi
12
+
13
+ exec "$@"
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Fetch SPAPS production database dump via SSH (read-only on production).
5
+ # Output: ${SPAPS_CACHE_ROOT:-~/.cache/spaps}/db/prod.sql.gz
6
+ #
7
+ # Environment variables:
8
+ # SPAPS_PROD_DB_FRESH=1 Force re-fetch even if a cached dump exists
9
+ # SPAPS_CACHE_ROOT Override cache directory
10
+ # SPAPS_PROD_HOST Primary SSH host
11
+ # SPAPS_PROD_HOST_FALLBACK Fallback SSH host
12
+ # SPAPS_SSH_BIN SSH binary path
13
+
14
+ PRIMARY_PROD_HOST="${SPAPS_PROD_HOST:-aiops@sweet-potato-prod}"
15
+ FALLBACK_PROD_HOST="${SPAPS_PROD_HOST_FALLBACK:-root@104.131.188.214}"
16
+ PROD_HOST=""
17
+ SSH_BIN="${SPAPS_SSH_BIN:-ssh}"
18
+ CACHE_DIR="${SPAPS_CACHE_ROOT:-${HOME}/.cache/spaps}/db"
19
+ PROD_DUMP="${CACHE_DIR}/prod.sql.gz"
20
+ FRESH="${SPAPS_PROD_DB_FRESH:-0}"
21
+
22
+ info() {
23
+ printf '\n%s %s\n' "🔧" "$1"
24
+ }
25
+
26
+ err() {
27
+ printf '\n%s %s\n' "❌" "$1" >&2
28
+ }
29
+
30
+ resolve_prod_host() {
31
+ local candidate
32
+ local candidates=("${PRIMARY_PROD_HOST}")
33
+ if [[ -n "${FALLBACK_PROD_HOST}" && "${FALLBACK_PROD_HOST}" != "${PRIMARY_PROD_HOST}" ]]; then
34
+ candidates+=("${FALLBACK_PROD_HOST}")
35
+ fi
36
+
37
+ for candidate in "${candidates[@]}"; do
38
+ info "Testing SSH connection to ${candidate}..."
39
+ if "${SSH_BIN}" -o ConnectTimeout=5 -o BatchMode=yes "${candidate}" "echo ok" >/dev/null 2>&1; then
40
+ PROD_HOST="${candidate}"
41
+ return 0
42
+ fi
43
+ done
44
+
45
+ return 1
46
+ }
47
+
48
+ if [[ -f "${PROD_DUMP}" ]]; then
49
+ case "${FRESH}" in
50
+ 1|true|TRUE|yes|YES)
51
+ info "SPAPS_PROD_DB_FRESH=1 set, fetching fresh dump from production..."
52
+ ;;
53
+ *)
54
+ DUMP_AGE=$(( $(date +%s) - $(stat -f %m "${PROD_DUMP}" 2>/dev/null || stat -c %Y "${PROD_DUMP}" 2>/dev/null) ))
55
+ DUMP_AGE_HOURS=$(( DUMP_AGE / 3600 ))
56
+ info "Using cached prod dump (${DUMP_AGE_HOURS}h old): ${PROD_DUMP}"
57
+ info "Run with SPAPS_PROD_DB_FRESH=1 to fetch fresh data."
58
+ exit 0
59
+ ;;
60
+ esac
61
+ fi
62
+
63
+ mkdir -p "${CACHE_DIR}"
64
+
65
+ if ! command -v "${SSH_BIN}" >/dev/null 2>&1; then
66
+ err "ssh command not found: ${SSH_BIN}"
67
+ exit 1
68
+ fi
69
+
70
+ if ! resolve_prod_host; then
71
+ err "Cannot connect to any configured production host."
72
+ err "Tried: ${PRIMARY_PROD_HOST} ${FALLBACK_PROD_HOST}"
73
+ err "Check your SSH key is loaded: ssh-add -l"
74
+ err "Try manually: ssh ${PRIMARY_PROD_HOST}"
75
+ exit 1
76
+ fi
77
+ info "Using production SSH host: ${PROD_HOST}"
78
+
79
+ info "Fetching SPAPS production database dump via SSH (read-only)..."
80
+ info "This may take a minute..."
81
+
82
+ set +e
83
+ "${SSH_BIN}" "${PROD_HOST}" "docker exec spaps-python-db pg_dump -U spaps spaps" 2>"${PROD_DUMP}.err" | gzip > "${PROD_DUMP}.tmp"
84
+ pipe_status=("${PIPESTATUS[@]}")
85
+ set -e
86
+ ssh_status=${pipe_status[0]:-1}
87
+ gzip_status=${pipe_status[1]:-1}
88
+
89
+ if [[ $ssh_status -ne 0 || $gzip_status -ne 0 ]]; then
90
+ err "Failed to dump production database (ssh exit ${ssh_status}, gzip exit ${gzip_status})"
91
+ if [[ $gzip_status -ne 0 ]]; then
92
+ err "Local compression/write failed (check disk space and file permissions)."
93
+ fi
94
+ if [[ -s "${PROD_DUMP}.err" ]]; then
95
+ err "Error output:"
96
+ cat "${PROD_DUMP}.err" >&2
97
+ fi
98
+ rm -f "${PROD_DUMP}.tmp" "${PROD_DUMP}.err"
99
+ exit 1
100
+ fi
101
+ rm -f "${PROD_DUMP}.err"
102
+
103
+ if [[ ! -s "${PROD_DUMP}.tmp" ]]; then
104
+ err "Dump file is empty"
105
+ rm -f "${PROD_DUMP}.tmp"
106
+ exit 1
107
+ fi
108
+
109
+ mv "${PROD_DUMP}.tmp" "${PROD_DUMP}"
110
+
111
+ DUMP_SIZE=$(du -h "${PROD_DUMP}" | cut -f1)
112
+ info "Production dump saved: ${PROD_DUMP} (${DUMP_SIZE})"
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="${APP_ROOT:-/app}"
5
+ PYTHON_BIN="${PYTHON_BIN:-python}"
6
+
7
+ if [[ ! -d "${ROOT}" ]]; then
8
+ echo "⚠️ Application root ${ROOT} not found; skipping migrations." >&2
9
+ exit 0
10
+ fi
11
+
12
+ cd "${ROOT}"
13
+
14
+ if [[ ! -f "${ROOT}/alembic.ini" ]]; then
15
+ echo "⚠️ alembic.ini not present in ${ROOT}; skipping migrations." >&2
16
+ exit 0
17
+ fi
18
+
19
+ if [[ "${ALEMBIC_SKIP_ON_START:-0}" == "1" ]]; then
20
+ echo "ℹ️ ALEMBIC_SKIP_ON_START=1; skipping migrations."
21
+ exit 0
22
+ fi
23
+
24
+ ROLE="${ALEMBIC_RUN_ON_START_ROLE:-primary}"
25
+
26
+ if [[ "${ROLE}" == "primary" ]]; then
27
+ echo "🏃 Applying database migrations..."
28
+ else
29
+ echo "ℹ️ ALEMBIC_RUN_ON_START_ROLE=${ROLE}; verifying schema without applying upgrades."
30
+ fi
31
+
32
+ normalize_revisions() {
33
+ matches="$(printf '%s\n' "$1" | grep -Eo '[0-9a-f]{12,}' | sort -u | paste -sd ',' -)"
34
+ if [[ -n "${matches}" ]]; then
35
+ printf '%s' "${matches}"
36
+ fi
37
+ }
38
+
39
+ restore_database_url() {
40
+ if [[ "${ORIGINAL_DATABASE_URL}" != "__unset__" ]]; then
41
+ export DATABASE_URL="${ORIGINAL_DATABASE_URL}"
42
+ else
43
+ unset DATABASE_URL
44
+ fi
45
+ }
46
+
47
+ assert_single_head() {
48
+ local heads_raw heads
49
+ heads_raw="$("${PYTHON_BIN}" -m alembic heads || true)"
50
+ heads="$(normalize_revisions "${heads_raw:-}")"
51
+ if [[ -n "${heads}" && "${heads}" == *","* ]]; then
52
+ echo "❌ Multiple Alembic heads detected (${heads})." >&2
53
+ echo " HINT: Align migration down_revision values or create a merge revision to restore a single head." >&2
54
+ restore_database_url
55
+ exit 1
56
+ fi
57
+ }
58
+
59
+ # Get sync (non-async) database URL from SPAPS settings
60
+ SYNC_URL="$("${PYTHON_BIN}" - <<'PY'
61
+ from spaps_server_quickstart.spaps_settings import get_spaps_settings
62
+ print(get_spaps_settings().sync_database_url, end="")
63
+ PY
64
+ )"
65
+
66
+ ORIGINAL_DATABASE_URL="${DATABASE_URL-__unset__}"
67
+ if [[ -n "${SYNC_URL}" ]]; then
68
+ export DATABASE_URL="${SYNC_URL}"
69
+ fi
70
+
71
+ assert_single_head
72
+
73
+ if [[ "${ROLE}" == "primary" ]]; then
74
+ "${PYTHON_BIN}" -m alembic upgrade head
75
+ else
76
+ echo "ℹ️ Skipping alembic upgrade (role=${ROLE})."
77
+ fi
78
+
79
+ current_raw="$("${PYTHON_BIN}" -m alembic current || true)"
80
+ heads_raw="$("${PYTHON_BIN}" -m alembic heads || true)"
81
+
82
+ current="$(normalize_revisions "${current_raw:-}")"
83
+ heads="$(normalize_revisions "${heads_raw:-}")"
84
+
85
+ if [[ -n "${heads}" && "${current}" != "${heads}" ]]; then
86
+ echo "❌ Alembic drift detected (current=${current:-none} expected=${heads:-none})." >&2
87
+ if [[ "${ROLE}" != "primary" ]]; then
88
+ echo " HINT: Ensure the primary migration runner has applied upgrades." >&2
89
+ fi
90
+ restore_database_url
91
+ exit 1
92
+ fi
93
+
94
+ restore_database_url
95
+
96
+ echo "✅ Alembic migrations current (${current:-none})."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -52,6 +52,7 @@
52
52
  "files": [
53
53
  "bin",
54
54
  "src",
55
+ "assets",
55
56
  "AI_TOOLS.json",
56
57
  "client.js",
57
58
  "README.md"