Compare commits

...
Author SHA1 Message Date
Jeppe B cfbf80f327 Reject unsafe FTPS path components 2026-07-20 16:06:37 +02:00
Jeppe B 96958c53a3 Document FTPS directory-index recovery 2026-07-20 16:03:35 +02:00
Jeppe B 13509b68f2 Add rollback-capable public htaccess repair 2026-07-20 16:03:34 +02:00
Jeppe B f689409990 Add guarded FTPS root repair workflow 2026-07-20 16:03:32 +02:00
Jeppe B c69f4f7fc7 Run cPanel recovery and deploy from stable runner (#189)
Use the stable self-hosted runner for cPanel API calls and install lftp job-locally without sudo.
2026-07-20 15:54:45 +02:00
Jeppe B 10f993e686 Support direct cPanel UAPI responses (#188) 2026-07-20 15:47:52 +02:00
Jeppe B a02ddfbfbc Normalize cPanel success results (#187) 2026-07-20 15:41:15 +02:00
Jeppe B 7a85c93631 Use Android artwork for iOS app icon
Replace the iOS AppIcon variants with the exact Android launcher artwork
from `public/favicons/web-app-manifest-512x512.png`, so the physical
iPhone home-screen icon matches Android. Verified all declared icon
dimensions and focused iOS icon test.
2026-07-20 15:41:02 +02:00
Jeppe B a3d11cfca3 Use relative account home in cPanel audit (#186) 2026-07-20 15:36:14 +02:00
Jeppe B 74279fc443 Address cPanel deployment review findings (#183) 2026-07-20 15:30:54 +02:00
Jeppe B 7e3a72961f Add complete iOS app icon asset set (#184)
Add standard iPhone, iPad, and marketing AppIcon variants with asset regression coverage.
2026-07-20 15:23:39 +02:00
Jeppe B ca2179352c Fix customer booking product restrictions and responsive layout (#182)
## What changed

- load customer product rules for the booking customer and keep
selection fail-closed while they resolve
- prevent restricted products, add-ons, pickup, and wash certificates
from remaining selected or being submitted
- improve product-grid containment, selected add-on styling, unavailable
messaging, and tablet/mobile rendering
- preserve booking details when the API rejects stale restricted items
- add unit and Playwright coverage for loading, retry, exact
restrictions, automatic products, layout, and stale backend recovery

## Why

The customer booking flow could use stale or unrelated POS customer-rule
state, expose restricted items, and lose useful form state after a
server rejection. The desktop product area also overflowed and switched
to an inconsistent compact layout after selection.

## Validation

- desktop booking Playwright suite: 12 passed
- mobile booking Playwright suite: 4 passed, 8 expected desktop-only
skips
- relevant unit tests: 35 passed
- ESLint and i18n checks
- production Vite build

## Related backend PR

The coordinated API PR enforces the same rule at the write boundary.
2026-07-20 14:41:47 +02:00
Jeppe B 8ddac065c6 Guard iOS debug signing with live CORS (#181)
Verify the stable API and exact Capacitor iOS CORS contract before signing device-debug IPAs, with regression coverage and troubleshooting guidance.
2026-07-20 14:37:17 +02:00
Jeppe B a930bd35a5 Add atomic cPanel frontend deployment (#178)
Build and archive the tested frontend, upload it through dedicated FTPS credentials, and atomically activate it through cPanel after CI succeeds.
2026-07-20 13:53:49 +02:00
Jeppe B 6b8f3ff806 Avoid Xcode version broken pipe in iOS debug workflow (#180)
Capture the full Xcode version output before selecting its first line so Xcode 26 cannot abort on a closed pipe.
2026-07-20 12:37:42 +02:00
Jeppe B 3323f392e3 Add signed iOS device debug workflow (#179)
Add a protected development-signing workflow, isolated debug app identity, Linux USB device tooling, documentation, and focused validation coverage.
2026-07-20 12:07:10 +02:00
Jeppe B f8f2b80641 Align restricted POS add-on controls and registration label (#177)
Align restricted desktop and mobile POS controls, scope the Danish primary registration label to POS, and preserve generic registration wording across shared surfaces and other locales.
2026-07-20 10:57:17 +02:00
Jeppe B 1729443cc3 Resolve frontend Qodana critical and high findings (#176)
Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
2026-07-17 06:22:51 +02:00
Jeppe B 6b5ac8a8b3 Document frontend branch protection activation (#175)
## Purpose

After-activation canary for repository ruleset `19051697`.

## Evidence before activation

- preparation PR #172 established stable `Required CI`
- strict-current-base remediation PR #174 passed `Required CI` and
Qodana
- merged master run `29501928124` completed with 43 successful jobs and
one intentional skip
- effective master rules were audited after activation

## Canary acceptance

- merge is blocked while `Required CI` is pending
- the exact `Required CI` check comes from GitHub Actions integration
`15368`
- only squash merge is accepted
- the branch is deleted automatically after merge
2026-07-16 20:45:00 +02:00
Jeppe B b12aca2757 Fix customer-rule i18n coverage gate (#174)
* fix: make customer rule translations statically auditable

* test: scope draft filter coverage to desktop layouts
2026-07-16 15:22:35 +02:00
Jeppe B 39cc3a780a Configure advisory Qodana analysis (#173) 2026-07-16 14:21:02 +02:00
589 changed files with 10944 additions and 6236 deletions
+8
View File
@@ -35,6 +35,14 @@ pull-request reviews.
If validation exposes a blocker, disable the ruleset rather than deleting it so
its configuration and history remain available.
## Activation record
Repository ruleset `19051697` was activated on 2026-07-16 after preparation
PR #172 established `Required CI`, remediation PR #174 passed the strict
current-base gate, and merged master run `29501928124` completed with all 43
executed jobs successful. This documentation update is the after-activation
canary for the normal protected pull-request path.
## Normal publishing flow
Create a scoped feature branch, open a pull request to `master`, wait for
+46 -45
View File
@@ -1,65 +1,66 @@
name: Qodana Configuration Upload
name: Qodana
on:
push:
branches: [master, beta, canary, internal]
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
workflow_dispatch:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v5
# v5.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Detect Qodana upload prerequisites
id: qodana-upload-prerequisites
- name: Require Qodana project token
shell: bash
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
set -euo pipefail
config_present=false
token_present=false
[[ -f qodana-global-configurations.yaml ]] && config_present=true
[[ -n "${QODANA_CONFIGURATIONS_TOKEN:-}" ]] && token_present=true
if [[ "$config_present" == true && "$token_present" == true ]]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
echo "reason=all prerequisites are configured" >> "$GITHUB_OUTPUT"
elif [[ "$config_present" != true && "$token_present" != true ]]; then
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=qodana-global-configurations.yaml and QODANA_CONFIGURATIONS_TOKEN are missing" >> "$GITHUB_OUTPUT"
elif [[ "$config_present" != true ]]; then
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=qodana-global-configurations.yaml is missing" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=QODANA_CONFIGURATIONS_TOKEN is missing" >> "$GITHUB_OUTPUT"
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Run Qodana Configuration Uploader
if: ${{ steps.qodana-upload-prerequisites.outputs.ready == 'true' }}
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v "$(pwd):/workspace" \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
- name: Skip Qodana Configuration Upload
if: ${{ steps.qodana-upload-prerequisites.outputs.ready != 'true' }}
env:
QODANA_SKIP_REASON: ${{ steps.qodana-upload-prerequisites.outputs.reason }}
run: echo "Skipping Qodana configuration upload because ${QODANA_SKIP_REASON}."
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+99
View File
@@ -0,0 +1,99 @@
name: cPanel Root Audit and Restore
on:
workflow_dispatch:
inputs:
mode:
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
required: true
default: audit
type: choice
options:
- audit
- restore
recovery:
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
required: false
type: string
state_token:
description: Exact 64-character audit-metadata state token reported by the audit.
required: false
type: string
confirmation:
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
required: false
type: string
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-restore:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Audit cPanel primary webroot
if: inputs.mode == 'audit'
id: audit
run: node scripts/release/cpanel-root.mjs audit
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
- name: Validate restore inputs
if: inputs.mode == 'restore'
env:
RECOVERY: ${{ inputs.recovery }}
STATE_TOKEN: ${{ inputs.state_token }}
CONFIRMATION: ${{ inputs.confirmation }}
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
run: |
test -n "$RECOVERY"
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
- name: Restore retained cPanel webroot
if: inputs.mode == 'restore'
run: node scripts/release/cpanel-root.mjs restore
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
- name: Upload cPanel root report
if: always()
uses: actions/upload-artifact@v4
with:
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root
if-no-files-found: ignore
retention-days: 30
+72
View File
@@ -0,0 +1,72 @@
name: Frontend Root FTPS Repair
on:
workflow_dispatch:
inputs:
mode:
description: Audit downloads and hashes .htaccess; repair backs it up and activates the reviewed file.
required: true
default: audit
type: choice
options:
- audit
- repair
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-repair:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Install secure FTP client without system changes
run: |
if command -v lftp >/dev/null 2>&1; then
exit 0
fi
package_root="$RUNNER_TEMP/lftp-package"
mkdir -p "$package_root"
(
cd "$package_root"
apt-get download lftp
dpkg-deb --extract ./lftp_*.deb root
)
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
- name: Audit or repair live root .htaccess
run: bash scripts/release/repair-public-htaccess.sh "${{ inputs.mode }}"
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
ROOT_REPAIR_REPORT_DIR: output/cpanel-root-ftps
- name: Upload FTPS root report
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-root-ftps-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root-ftps
if-no-files-found: ignore
retention-days: 30
+624
View File
@@ -0,0 +1,624 @@
name: iOS Device Debug IPA
on:
workflow_dispatch:
inputs:
source_ref:
description: Same-repository branch, tag, or commit to build
required: true
default: master
type: string
expected_sha:
description: Full 40-character SHA that source_ref must resolve to
required: true
type: string
confirmation:
description: Type SIGN IOS DEBUG IPA
required: true
type: string
permissions:
contents: read
concurrency:
group: ios-device-debug-${{ github.run_id }}
cancel-in-progress: false
jobs:
resolve:
name: Resolve and verify source
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
source_sha: ${{ steps.resolve.outputs.source_sha }}
steps:
- name: Validate dispatch confirmation
shell: bash
env:
CONFIRMATION: ${{ inputs.confirmation }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
SOURCE_REF: ${{ inputs.source_ref }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]; then
echo "The signing workflow must be dispatched from the master workflow ref" >&2
exit 1
fi
if [[ "$CONFIRMATION" != "SIGN IOS DEBUG IPA" ]]; then
echo "confirmation must exactly match SIGN IOS DEBUG IPA" >&2
exit 1
fi
if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "expected_sha must be a full 40-character commit SHA" >&2
exit 1
fi
if [[ -z "$SOURCE_REF" || "$SOURCE_REF" =~ [[:space:]] ]]; then
echo "source_ref must be non-empty and contain no whitespace" >&2
exit 1
fi
if [[ "$SOURCE_REF" == refs/pull/* || "$SOURCE_REF" == pull/* ]]; then
echo "Pull-request refs are not eligible for device-debug signing" >&2
exit 1
fi
- name: Checkout same-repository history
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve immutable commit
id: resolve
shell: bash
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
SOURCE_REF: ${{ inputs.source_ref }}
run: |
set -euo pipefail
expected_sha="${EXPECTED_SHA,,}"
if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
candidate="$SOURCE_REF"
elif [[ "$SOURCE_REF" == refs/heads/* ]]; then
candidate="refs/remotes/origin/${SOURCE_REF#refs/heads/}"
elif [[ "$SOURCE_REF" == refs/tags/* ]]; then
candidate="$SOURCE_REF"
elif git show-ref --verify --quiet "refs/remotes/origin/$SOURCE_REF"; then
candidate="refs/remotes/origin/$SOURCE_REF"
elif git show-ref --verify --quiet "refs/tags/$SOURCE_REF"; then
candidate="refs/tags/$SOURCE_REF"
else
echo "source_ref does not identify a same-repository branch, tag, or fetched commit" >&2
exit 1
fi
source_sha="$(git rev-parse --verify "${candidate}^{commit}" 2>/dev/null || true)"
source_sha="${source_sha,,}"
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "source_ref could not be resolved to a commit" >&2
exit 1
fi
if [[ "$source_sha" != "$expected_sha" ]]; then
echo "source_ref resolved to a SHA different from expected_sha" >&2
exit 1
fi
reachable=false
while IFS= read -r repository_ref; do
if git merge-base --is-ancestor "$source_sha" "$repository_ref" 2>/dev/null; then
reachable=true
break
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin refs/tags)
if [[ "$reachable" != true ]]; then
echo "The requested commit is not reachable from a same-repository branch or tag" >&2
exit 1
fi
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "Resolved source_ref to $source_sha" >> "$GITHUB_STEP_SUMMARY"
build:
name: Build development-signed IPA
needs: resolve
runs-on: macos-26
timeout-minutes: 90
environment:
name: mobile-device-debug
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_DEBUG_BUNDLE_ID: ${{ vars.IOS_DEBUG_BUNDLE_ID || 'io.truckwash.app.debug' }}
IOS_DEBUG_API_URL: ${{ vars.IOS_DEBUG_API_URL || 'https://api-v2.truckwash.io/master/api' }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
MOBILE_VERSION_NAME: 0.0.${{ github.run_number }}
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
steps:
- name: Checkout resolved source
uses: actions/checkout@v5
with:
ref: ${{ needs.resolve.outputs.source_sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify runner and resolve build number
shell: bash
run: |
set -euo pipefail
xcode_version_output="$(xcodebuild -version)"
IFS= read -r xcode_version <<< "$xcode_version_output"
xcode_major="$(awk '{split($2, version, "."); print version[1]}' <<< "$xcode_version")"
if [[ ! "$xcode_major" =~ ^[0-9]+$ ]] || (( xcode_major < 26 )); then
echo "Xcode 26 or newer is required; found $xcode_version" >&2
exit 1
fi
build_number="$((10#$GITHUB_RUN_NUMBER * 100 + 10#$GITHUB_RUN_ATTEMPT))"
if [[ ! "$build_number" =~ ^[1-9][0-9]{0,17}$ ]]; then
echo "Derived build number is outside Apple's supported integer format" >&2
exit 1
fi
echo "MOBILE_VERSION_CODE=$build_number" >> "$GITHUB_ENV"
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Verify stable API and Capacitor iOS CORS
shell: bash
run: |
set -euo pipefail
api_base="${IOS_DEBUG_API_URL%/}"
curl --fail --silent --show-error --location \
--connect-timeout 10 --max-time 20 \
--header 'Accept: application/json' \
--output /dev/null \
"$api_base/ping"
cors_headers="$RUNNER_TEMP/ios-debug-cors-headers.txt"
cors_body="$RUNNER_TEMP/ios-debug-cors-body.txt"
cors_status="$(curl --silent --show-error \
--connect-timeout 10 --max-time 20 \
--request OPTIONS \
--header 'Origin: capacitor://localhost' \
--header 'Access-Control-Request-Method: POST' \
--header 'Access-Control-Request-Headers: authorization,content-type' \
--dump-header "$cors_headers" \
--output "$cors_body" \
--write-out '%{http_code}' \
"$api_base/ping")"
if [[ ! "$cors_status" =~ ^2[0-9][0-9]$ ]]; then
echo "Stable API rejected the Capacitor iOS CORS preflight with HTTP $cors_status" >&2
sed -n '1,20p' "$cors_body" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-origin:[[:space:]]*capacitor://localhost[[:space:]]*$' "$cors_headers"; then
echo "Stable API did not allow the exact capacitor://localhost origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-credentials:[[:space:]]*true[[:space:]]*$' "$cors_headers"; then
echo "Stable API did not allow credentialed Capacitor requests" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-methods:.*[[:space:],]POST([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow POST from the Capacitor origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Authorization([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow the Authorization header from the Capacitor origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Content-Type([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow the Content-Type header from the Capacitor origin" >&2
exit 1
fi
- name: Build stable production web payload
env:
RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}
VITE_API_URL: ${{ env.IOS_DEBUG_API_URL }}
VITE_RELEASE_MANAGER_CONTROL_API_URL: ${{ env.IOS_DEBUG_API_URL }}
VITE_RELEASE_PUBLIC_GATEWAY_API_URL: https://api-v2.truckwash.io
run: |
npm run build
node -e "const manifest = require('./dist/release-manifest.json'); if (manifest.commit_sha !== process.env.RESOLVED_SOURCE_SHA) { throw new Error('Web release manifest source SHA mismatch'); }"
- name: Sync and validate iOS shell
shell: bash
run: |
set -euo pipefail
npx cap sync ios
npm run mobile:permissions:check
if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then
echo "Unexpected Xcode shell-script build phase detected" >&2
exit 1
fi
xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Validate native Debug and Release settings
shell: bash
run: |
set -euo pipefail
debug_settings="$RUNNER_TEMP/ios-debug-build-settings.txt"
release_settings="$RUNNER_TEMP/ios-release-build-settings.txt"
xcodebuild -showBuildSettings \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Debug \
CODE_SIGNING_ALLOWED=NO > "$debug_settings"
xcodebuild -showBuildSettings \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO > "$release_settings"
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app\.debug$' "$debug_settings"
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash Debug$' "$debug_settings"
grep -Eq '^[[:space:]]*PRODUCT_NAME = TruckWashDebug$' "$debug_settings"
grep -Eq '^[[:space:]]*CAPACITOR_DEBUG = true$' "$debug_settings"
grep -Eq '^[[:space:]]*DEBUG_INFORMATION_FORMAT = dwarf-with-dsym$' "$debug_settings"
grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$debug_settings"
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app$' "$release_settings"
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$release_settings"
grep -Eq '^[[:space:]]*PRODUCT_NAME = App$' "$release_settings"
- name: Install and validate Apple development signing assets
shell: bash
env:
IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}
IOS_DEBUG_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEBUG_CERTIFICATE_PASSWORD }}
IOS_DEBUG_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_DEBUG_PROVISION_PROFILE_BASE64 }}
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
run: |
set -euo pipefail
node scripts/mobile/check-ios-debug-signing-env.mjs
certificate_path="$RUNNER_TEMP/ios-debug-development.p12"
profile_path="$RUNNER_TEMP/ios-debug-development.mobileprovision"
profile_plist="$RUNNER_TEMP/ios-debug-development-profile.plist"
keychain_path="$RUNNER_TEMP/ios-debug-signing.keychain-db"
keychain_password="$(openssl rand -base64 48 | tr -d '\n')"
echo "::add-mask::$keychain_password"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_CERTIFICATE_BASE64.replace(/\\s/g, ''), 'base64'))" "$certificate_path"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_PROVISION_PROFILE_BASE64.replace(/\\s/g, ''), 'base64'))" "$profile_path"
chmod 600 "$certificate_path" "$profile_path"
security cms -D -i "$profile_path" > "$profile_plist"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-P "$IOS_DEBUG_CERTIFICATE_PASSWORD" \
-A \
-t cert \
-f pkcs12 \
-k "$keychain_path"
security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list \
-S apple-tool:,apple: \
-s \
-k "$keychain_password" \
"$keychain_path"
signing_identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Development/ {print $2; exit}')"
if [[ ! "$signing_identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
echo "The PKCS#12 file does not contain a valid Apple Development signing identity" >&2
exit 1
fi
IOS_SIGNING_IDENTITY_SHA="$signing_identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY'
import datetime
import hashlib
import os
import plistlib
import re
import sys
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
profile = plistlib.load(handle)
team_id = os.environ["APPLE_TEAM_ID"]
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
entitlements = profile.get("Entitlements", {})
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
provisioned = set(profile.get("ProvisionedDevices", []))
expiration = profile.get("ExpirationDate")
now = datetime.datetime.now(datetime.timezone.utc)
if expiration and expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
checks = {
"profile team identifier": team_id in profile.get("TeamIdentifier", []),
"application identifier": entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
"entitlement team identifier": entitlements.get("com.apple.developer.team-identifier") == team_id,
"development entitlement": entitlements.get("get-task-allow") is True,
"profile expiration": expiration is not None and expiration > now,
"registered devices": bool(allowed) and allowed <= provisioned,
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
"developer certificate": bool(profile.get("DeveloperCertificates")),
"profile UUID": isinstance(profile.get("UUID"), str) and re.fullmatch(r"[0-9A-Fa-f-]{36}", profile["UUID"]) is not None,
"safe profile name": isinstance(profile.get("Name"), str) and not any(char in profile["Name"] for char in "\r\n"),
}
identity_sha = os.environ["IOS_SIGNING_IDENTITY_SHA"].upper()
certificate_hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}
checks["certificate belongs to profile"] = identity_sha in certificate_hashes
failures = [label for label, passed in checks.items() if not passed]
if failures:
print("Development provisioning profile validation failed:", file=sys.stderr)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
sys.exit(1)
PY
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")"
profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$profile_plist")"
profile_expiration="$(PROFILE_PLIST="$profile_plist" python3 - <<'PY'
import datetime
import os
import plistlib
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
expiration = plistlib.load(handle)["ExpirationDate"]
if expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
print(expiration.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"))
PY
)"
profile_install_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
profile_install_path="$profile_install_dir/$profile_uuid.mobileprovision"
mkdir -p "$profile_install_dir"
cp "$profile_path" "$profile_install_path"
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
echo "IOS_PROFILE_INSTALL_PATH=$profile_install_path" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_EXPIRATION=$profile_expiration" >> "$GITHUB_ENV"
- name: Archive Debug app with Apple Development signing
shell: bash
run: |
set -euo pipefail
archive_path="$RUNNER_TEMP/TruckWashDebug.xcarchive"
xcodebuild \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Debug \
-destination "generic/platform=iOS" \
-archivePath "$archive_path" \
archive \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Development" \
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
PRODUCT_BUNDLE_IDENTIFIER="$IOS_DEBUG_BUNDLE_ID" \
MARKETING_VERSION="$MOBILE_VERSION_NAME" \
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" \
DEBUG_INFORMATION_FORMAT="dwarf-with-dsym" \
ONLY_ACTIVE_ARCH=NO
echo "IOS_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
- name: Export development IPA
shell: bash
run: |
set -euo pipefail
export_options="$RUNNER_TEMP/ios-debug-ExportOptions.plist"
export_path="$RUNNER_TEMP/ios-debug-export"
EXPORT_OPTIONS="$export_options" python3 <<'PY'
import os
import plistlib
options = {
"method": "development",
"signingStyle": "manual",
"teamID": os.environ["APPLE_TEAM_ID"],
"provisioningProfiles": {
os.environ["IOS_DEBUG_BUNDLE_ID"]: os.environ["IOS_PROFILE_NAME"],
},
"stripSwiftSymbols": True,
"manageAppVersionAndBuildNumber": False,
}
with open(os.environ["EXPORT_OPTIONS"], "wb") as handle:
plistlib.dump(options, handle)
PY
xcodebuild \
-exportArchive \
-archivePath "$IOS_ARCHIVE_PATH" \
-exportPath "$export_path" \
-exportOptionsPlist "$export_options"
shopt -s nullglob
ipa_files=("$export_path"/*.ipa)
if [[ ${#ipa_files[@]} -ne 1 ]]; then
echo "Expected exactly one exported IPA; found ${#ipa_files[@]}" >&2
exit 1
fi
echo "IOS_EXPORTED_IPA=${ipa_files[0]}" >> "$GITHUB_ENV"
- name: Validate exported IPA and embedded signature
shell: bash
env:
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
run: |
set -euo pipefail
inspect_dir="$RUNNER_TEMP/ios-debug-inspect"
mkdir -p "$inspect_dir"
unzip -q "$IOS_EXPORTED_IPA" -d "$inspect_dir"
shopt -s nullglob
app_bundles=("$inspect_dir"/Payload/*.app)
if [[ ${#app_bundles[@]} -ne 1 ]]; then
echo "Expected exactly one Payload app; found ${#app_bundles[@]}" >&2
exit 1
fi
app_path="${app_bundles[0]}"
app_info="$app_path/Info.plist"
embedded_profile="$RUNNER_TEMP/ios-debug-embedded-profile.plist"
signature_entitlements="$RUNNER_TEMP/ios-debug-signature-entitlements.plist"
signature_details="$RUNNER_TEMP/ios-debug-signature-details.txt"
security cms -D -i "$app_path/embedded.mobileprovision" > "$embedded_profile"
codesign --verify --deep --strict "$app_path"
codesign -d --entitlements :- "$app_path" > "$signature_entitlements"
codesign -dvv "$app_path" > /dev/null 2> "$signature_details"
grep -Fq "TeamIdentifier=$APPLE_TEAM_ID" "$signature_details"
grep -Eq '^Authority=Apple Development:' "$signature_details"
executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_info")"
architectures="$(lipo -archs "$app_path/$executable_name")"
if [[ " $architectures " != *" arm64 "* ]]; then
echo "Exported executable does not contain arm64" >&2
exit 1
fi
APP_INFO="$app_info" PROFILE_PLIST="$embedded_profile" SIGNATURE_ENTITLEMENTS="$signature_entitlements" python3 <<'PY'
import datetime
import os
import plistlib
import sys
def load(path):
with open(path, "rb") as handle:
return plistlib.load(handle)
info = load(os.environ["APP_INFO"])
profile = load(os.environ["PROFILE_PLIST"])
signature = load(os.environ["SIGNATURE_ENTITLEMENTS"])
profile_entitlements = profile.get("Entitlements", {})
team_id = os.environ["APPLE_TEAM_ID"]
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
provisioned = set(profile.get("ProvisionedDevices", []))
expiration = profile.get("ExpirationDate")
now = datetime.datetime.now(datetime.timezone.utc)
if expiration and expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
checks = {
"bundle identifier": info.get("CFBundleIdentifier") == bundle_id,
"display name": info.get("CFBundleDisplayName") == "Truck Wash Debug",
"debug executable": info.get("CFBundleExecutable") == "TruckWashDebug",
"marketing version": info.get("CFBundleShortVersionString") == os.environ["MOBILE_VERSION_NAME"],
"build number": info.get("CFBundleVersion") == os.environ["MOBILE_VERSION_CODE"],
"minimum iOS": info.get("MinimumOSVersion") == "15.0",
"profile UUID": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"],
"profile team": team_id in profile.get("TeamIdentifier", []),
"profile application identifier": profile_entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
"development profile": profile_entitlements.get("get-task-allow") is True,
"signature application identifier": signature.get("application-identifier") == f"{team_id}.{bundle_id}",
"signature team identifier": signature.get("com.apple.developer.team-identifier") == team_id,
"debuggable signature": signature.get("get-task-allow") is True,
"profile expiration": expiration is not None and expiration > now,
"registered devices": bool(allowed) and allowed <= provisioned,
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
}
failures = [label for label, passed in checks.items() if not passed]
if failures:
print("Exported development IPA validation failed:", file=sys.stderr)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
sys.exit(1)
PY
- name: Assemble debug artifact
shell: bash
env:
SOURCE_REF: ${{ inputs.source_ref }}
run: |
set -euo pipefail
short_sha="${RESOLVED_SOURCE_SHA:0:12}"
artifact_name="truck-wash-debug-${MOBILE_VERSION_NAME}-${short_sha}"
artifact_dir="$RUNNER_TEMP/device-debug-artifact"
ipa_filename="$artifact_name.ipa"
dsym_filename="$artifact_name.dSYM.zip"
mkdir -p "$artifact_dir"
cp "$IOS_EXPORTED_IPA" "$artifact_dir/$ipa_filename"
shopt -s nullglob
dsym_bundles=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM)
if [[ ${#dsym_bundles[@]} -eq 0 ]]; then
echo "The Debug archive did not contain any dSYM bundles" >&2
exit 1
fi
ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact_dir/$dsym_filename"
capacitor_version="$(node -p "require('./node_modules/@capacitor/core/package.json').version")"
BUILT_AT_UTC="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
CAPACITOR_VERSION="$capacitor_version" \
DSYM_FILENAME="$dsym_filename" \
IPA_FILENAME="$ipa_filename" \
MANIFEST_PATH="$artifact_dir/manifest.json" \
python3 <<'PY'
import json
import os
manifest = {
"schema_version": 1,
"repository": os.environ["GITHUB_REPOSITORY"],
"source_ref": os.environ["SOURCE_REF"],
"source_sha": os.environ["RESOLVED_SOURCE_SHA"],
"workflow_run": int(os.environ["GITHUB_RUN_NUMBER"]),
"workflow_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
"built_at_utc": os.environ["BUILT_AT_UTC"],
"api_url": os.environ["IOS_DEBUG_API_URL"],
"release_manager_control_api_url": os.environ["IOS_DEBUG_API_URL"],
"bundle_id": os.environ["IOS_DEBUG_BUNDLE_ID"],
"display_name": "Truck Wash Debug",
"executable_name": "TruckWashDebug",
"version": os.environ["MOBILE_VERSION_NAME"],
"build": os.environ["MOBILE_VERSION_CODE"],
"minimum_ios": "15.0",
"capacitor_version": os.environ["CAPACITOR_VERSION"],
"xcode_version": os.environ["XCODE_VERSION"],
"signing_method": "development",
"profile_expiration_utc": os.environ["IOS_PROFILE_EXPIRATION"],
"ipa_filename": os.environ["IPA_FILENAME"],
"dsym_filename": os.environ["DSYM_FILENAME"],
}
with open(os.environ["MANIFEST_PATH"], "w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
handle.write("\n")
PY
(
cd "$artifact_dir"
shasum -a 256 "$ipa_filename" "$dsym_filename" manifest.json > SHA256SUMS
)
echo "IOS_DEBUG_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV"
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
- name: Upload device-debug artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
if-no-files-found: error
retention-days: 7
- name: Clean up Apple signing assets
if: always()
shell: bash
run: |
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$IOS_KEYCHAIN_PATH" || true
else
security delete-keychain "$RUNNER_TEMP/ios-debug-signing.keychain-db" || true
fi
if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then
rm -f "$IOS_PROFILE_INSTALL_PATH"
fi
rm -f \
"$RUNNER_TEMP/ios-debug-development.p12" \
"$RUNNER_TEMP/ios-debug-development.mobileprovision" \
"$RUNNER_TEMP/ios-debug-development-profile.plist" \
"$RUNNER_TEMP/ios-debug-embedded-profile.plist" \
"$RUNNER_TEMP/ios-debug-signature-entitlements.plist" \
"$RUNNER_TEMP/ios-debug-signature-details.txt"
+239 -79
View File
@@ -14,49 +14,57 @@ permissions:
actions: read
concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
group: frontend-production
cancel-in-progress: false
jobs:
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
build-release:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
outputs:
current: ${{ steps.branch-head.outputs.current }}
build_id: ${{ steps.package.outputs.build_id }}
artifact_name: ${{ steps.package-names.outputs.artifact_name }}
archive_name: ${{ steps.package.outputs.archive_name }}
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
release_id: ${{ steps.package.outputs.release_id }}
steps:
- name: Checkout repository
- name: Check release commit is current
id: branch-head
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const { data: branch } = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "master",
});
const expected = process.env.RELEASE_EXPECTED_COMMIT;
const current = branch.commit.sha === expected;
core.setOutput("current", String(current));
core.info(
current
? `Release commit ${expected} is current for master.`
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
);
- name: Checkout tested commit
if: steps.branch-head.outputs.current == 'true'
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
@@ -79,16 +87,22 @@ jobs:
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build
- name: Record pre-gate dist inventory
if: steps.branch-head.outputs.current == 'true'
run: |
inventory="$RUNNER_TEMP/dist-before-production-gate.txt"
while IFS= read -r -d '' file; do
relative_path="${file#dist/}"
printf '%s\t%s\t%s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"$(stat --format='%s' "$file")" \
"$relative_path"
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
- name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs chromium
@@ -96,56 +110,186 @@ jobs:
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
env:
PLAYWRIGHT_PROD_WEBKIT: "0"
- name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 3
- name: Request Release Manager auto sync
- name: Confirm production gate did not mutate dist
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
inventory="$RUNNER_TEMP/dist-after-production-gate.txt"
while IFS= read -r -d '' file; do
relative_path="${file#dist/}"
printf '%s\t%s\t%s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"$(stat --format='%s' "$file")" \
"$relative_path"
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
cmp "$RUNNER_TEMP/dist-before-production-gate.txt" "$inventory"
- name: Wait for Coolify release artifact
- name: Package and validate release
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload
id: package
run: node scripts/release/package-dist.mjs
env:
RELEASE_OUTPUT_DIR: release-artifacts
- name: Resolve package metadata
if: steps.branch-head.outputs.current == 'true'
id: package-names
env:
BUILD_ID: ${{ steps.package.outputs.build_id }}
CHECKSUM_PATH: ${{ steps.package.outputs.checksum_path }}
INVENTORY_PATH: ${{ steps.package.outputs.inventory_path }}
run: |
echo "artifact_name=frontend-release-$BUILD_ID" >> "$GITHUB_OUTPUT"
echo "checksum_name=$(basename -- "$CHECKSUM_PATH")" >> "$GITHUB_OUTPUT"
echo "inventory_name=$(basename -- "$INVENTORY_PATH")" >> "$GITHUB_OUTPUT"
- name: Upload release package
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.package-names.outputs.artifact_name }}
path: |
${{ steps.package.outputs.archive_path }}
${{ steps.package.outputs.checksum_path }}
${{ steps.package.outputs.inventory_path }}
if-no-files-found: error
retention-days: 14
deploy-frontend-production:
needs: build-release
if: needs.build-release.outputs.current == 'true'
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 90
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
env:
RELEASE_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
RELEASE_EXPECTED_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
RELEASE_ID: ${{ needs.build-release.outputs.release_id }}
RELEASE_STRICT_BUILD_ID: "true"
RELEASE_REQUIRE_CACHE_HEADERS: "true"
RELEASE_WAIT_INITIAL_SECONDS: 0
RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_POLL_INTERVAL_SECONDS: 5
steps:
- name: Checkout tested commit
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install secure FTP client without system changes
run: |
if command -v lftp >/dev/null 2>&1; then
exit 0
fi
package_root="$RUNNER_TEMP/lftp-package"
mkdir -p "$package_root"
(
cd "$package_root"
apt-get download lftp
dpkg-deb --extract ./lftp_*.deb root
)
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
- name: Install Playwright Chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Download validated release package
uses: actions/download-artifact@v4
with:
name: ${{ needs.build-release.outputs.artifact_name }}
path: release-artifacts
- name: Resolve downloaded release package
env:
ARCHIVE_NAME: ${{ needs.build-release.outputs.archive_name }}
CHECKSUM_NAME: ${{ needs.build-release.outputs.checksum_name }}
INVENTORY_NAME: ${{ needs.build-release.outputs.inventory_name }}
run: |
[[ -n "$ARCHIVE_NAME" && "$ARCHIVE_NAME" == "$(basename -- "$ARCHIVE_NAME")" ]]
[[ -n "$CHECKSUM_NAME" && "$CHECKSUM_NAME" == "$(basename -- "$CHECKSUM_NAME")" ]]
[[ -n "$INVENTORY_NAME" && "$INVENTORY_NAME" == "$(basename -- "$INVENTORY_NAME")" ]]
archive_path="$GITHUB_WORKSPACE/release-artifacts/$ARCHIVE_NAME"
checksum_path="$GITHUB_WORKSPACE/release-artifacts/$CHECKSUM_NAME"
inventory_path="$GITHUB_WORKSPACE/release-artifacts/$INVENTORY_NAME"
[[ -f "$archive_path" && -f "$checksum_path" && -f "$inventory_path" ]]
echo "RELEASE_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
echo "RELEASE_ARCHIVE_SHA256_PATH=$checksum_path" >> "$GITHUB_ENV"
echo "RELEASE_INVENTORY_PATH=$inventory_path" >> "$GITHUB_ENV"
- name: Check release commit is still current
id: branch-head
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const { data: branch } = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "master",
});
const expected = process.env.RELEASE_EXPECTED_COMMIT;
const current = branch.commit.sha === expected;
core.setOutput("current", String(current));
core.info(
current
? `Release commit ${expected} is current immediately before activation.`
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
);
- name: Deploy atomically and verify cPanel release
if: steps.branch-head.outputs.current == 'true'
id: deploy
timeout-minutes: 15
run: node scripts/release/deploy-cpanel.mjs
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_GITHUB_TOKEN: ${{ github.token }}
- name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
timeout-minutes: 10
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
timeout-minutes: 15
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
@@ -157,6 +301,23 @@ jobs:
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Roll back after live verification failure
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
timeout-minutes: 10
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: |
@@ -166,12 +327,11 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
@@ -189,4 +349,4 @@ jobs:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 3
retention-days: 14
+3 -1
View File
@@ -202,7 +202,9 @@ App Store Connect.
See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and
local mobile checks. See `docs/app-store-release.md` for App Store Connect
release preparation and review notes.
release preparation and review notes. For a separate development-signed IPA
that can be installed on an approved iPhone from Ubuntu over USB, see
`docs/ios-device-debug.md`.
## Bubblewrap (TWA) Build and Install
+262
View File
@@ -0,0 +1,262 @@
# cPanel frontend deployment
This runbook covers the production deployment of `pleno-vue` only. The API is
not uploaded to cPanel and continues to use its existing release process and
hosts.
## Release flow
`.github/workflows/release.yml` starts only after the `Automated Tests`
workflow succeeds for a push to `master` in this repository. It then:
1. Rechecks that the tested commit is still the head of `master`.
2. Checks out that exact commit without persisting GitHub credentials.
3. Installs dependencies, runs source checks, and builds `dist` once.
4. Runs the local-production Playwright gate against that existing `dist`.
5. Creates an immutable ZIP, SHA-256 sidecar, and file inventory, then verifies
a local archive round trip.
6. Uploads the package as a required GitHub Actions artifact.
7. Enters the protected `frontend-production` GitHub environment and rechecks
`master` immediately before deployment.
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
uploaded `.part` files are downloaded and hashed before they are renamed.
9. Uses the cPanel Fileman API to extract into a new inactive release. The
extracted tree is downloaded and compared byte-for-byte with the validated
inventory, then `master` is checked again through the read-only workflow
token.
10. Replaces the `current` symlink with a single server-side rename. Public
manifest, asset-integrity, cache-header, API-ping, and role gates run after
activation. A failed public or role gate restores the previous symlink.
The fixed `frontend-production` concurrency group is not cancellable. A newer
push therefore cannot interrupt an in-progress switch or rollback.
## GitHub environment
Create the environment `frontend-production`, restrict deployment branches to
protected branches, and keep `master` protected by the required CI checks.
Production approvals can be added as an environment protection rule.
Add these environment **secrets**:
- `PRODUCTION_FTP_HOST`
- `PRODUCTION_FTP_USER`
- `PRODUCTION_FTP_PASSWORD`
- `PRODUCTION_FTP_PATH`
- `PRODUCTION_CPANEL_USER`
- `PRODUCTION_CPANEL_API_TOKEN`
The API `.env` contains legacy values under the first four names, but production
frontend deployment uses a dedicated cPanel FTP account jailed to
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
deployment unchanged.
The cPanel token is separate from the FTP password. Create it in cPanel under
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
uses cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI
replacement for the required extract, symlink, and rename operations. Revoke
and rotate the token if it is ever exposed.
Add these environment **variables**:
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
fallback. The cPanel URL and path deliberately fail closed when absent. The
production environment must keep the explicit
`https://server.red-block.com:2083` cPanel origin: the public origin serves
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
cPanel JSON API.
### Create the dedicated FTP credentials
1. Open **Files -> FTP Accounts** in the `truckwash` cPanel account.
2. Create `github-pleno-vue@truckwash.io` with a generated, unique password.
3. Set its directory to `frontend-deployments`, which cPanel resolves to
`/home/truckwash/frontend-deployments`, and leave quota unlimited.
4. Add `server.red-block.com` as `PRODUCTION_FTP_HOST`. Do not use
`truckwash.io`: the FTPS certificate is issued to the server hostname.
5. Add the full account login as `PRODUCTION_FTP_USER`, the generated password
as `PRODUCTION_FTP_PASSWORD`, and `/` as `PRODUCTION_FTP_PATH`. `/` is the
root of this jailed FTP account, not the cPanel account home.
6. Verify explicit FTPS login and directory listing before merging. Never copy
these frontend-only credentials back into the API `.env`.
### Create the missing cPanel credentials
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
separately; do not reuse the FTP password as an API token.
1. Sign in to the cPanel account that owns the frontend deployment root.
2. Record the exact cPanel account username shown in **General Information**.
Add it to the `frontend-production` environment as the
`PRODUCTION_CPANEL_USER` secret.
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
hosting provider to enable API Tokens in WHM Feature Manager.
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
expiration date that matches the team's rotation policy. Expiration cannot
be edited later, so add a reminder before that date.
5. Click **Create**, copy the token immediately, and add it to the same GitHub
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
again after leaving the page.
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
the GitHub secret has been saved.
7. Before merging, run the deployment preflight against the configured API
origin. It must be able to call cPanel API2 `Fileman::fileop` for extract,
symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider
restricts those operations, request the required account feature access;
do not broaden the token or deployment root beyond this cPanel account.
The current production token is named `github-pleno-vue-production` and
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
secret before that date, then revoke the replaced token in cPanel.
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
Use **Add secret** for credentials and **Add variable** for the two URLs and the
cPanel deployment path.
Environment values are available only to the deployment job that names this
environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. GitHub-hosted deploy runners install
`lftp` and Playwright Chromium during the job; the existing self-hosted build
runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and
`sha256sum`.
## cPanel layout and one-time bootstrap
The production FTP account is jailed directly to the deployment root, so its
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
relative to the cPanel account home. The helper creates this layout below it:
```text
archives/
releases/
<commit>-<github-run>-<attempt>/
dist/
staging/
current -> releases/<release-id>/dist
```
The domain's document root must resolve to
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
root itself. This stable document-root path is what makes replacing `current`
atomic: every HTTP request resolves either the complete old release or the
complete new release, never a partly uploaded directory.
Before merging the workflow change, perform a one-time bootstrap in cPanel:
1. Back up the existing cPanel webroot and confirm the frontend hostname does
not serve API/PHP files from this location.
2. Create `archives`, `releases`, and `staging` below the dedicated deployment
root.
3. Put one complete, validated frontend build at
`releases/<commit>-<build-id>/dist`. Its `release-manifest.json` must contain
that full 40-character commit and the same build ID used in the directory
name.
4. Create `current` as a relative symlink to that release's `dist` directory.
5. Make the frontend domain document root resolve to the stable `current` path.
For a cPanel primary domain whose configured document root remains
`/home/truckwash/public_html`, make `public_html` a symlink to
`frontend-deployments/current`. Exchange the old directory and prepared
symlink atomically, and retain the old directory as a recovery copy.
6. Confirm the release `.htaccess` contains `DirectoryIndex index.html` so a
symlinked primary-domain root serves the Vue shell instead of a directory
listing.
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
and the API health request work at `PRODUCTION_FRONTEND_URL`.
8. Test the cPanel token against the exact host and port. The workflow performs
a disposable symlink-replacement preflight and refuses deployment if the
filesystem or hosting policy cannot replace a symlink atomically.
The automatic deployer intentionally refuses to create the first `current`
pointer. This prevents a missing or misconfigured bootstrap from turning the
first automated run into an unreviewed production cutover.
### Auditing or restoring the primary webroot
Use the protected **cPanel Root Audit and Restore** workflow if the primary
domain starts showing a directory index or returns 404 for files that cPanel
lists in `public_html`. The `audit` mode is read-only: it reports the exact
`public_html` entry, whether the internal `current` link can serve the required
release files, domain document roots, and retained recovery candidates without
printing the cPanel token. API2 does not expose a documented symlink-target
field, so the audit deliberately reports `rootTargetVerified: false` instead
of claiming that an arbitrary `public_html` link follows `current`; the live
HTTP checks remain the source of truth for service health. The audit fails
closed if any domain record lacks an identity or document root, and restore is
blocked while an addon or subdomain is rooted below `public_html`.
If the regression followed the one-time webroot exchange, select `restore`
and copy one exact recovery entry from the audit, including the retained
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
The workflow requires the
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
exact token string from that audit. The token is an optimistic-concurrency
guard over the cPanel metadata visible to the audit; it is not a content hash
or a substitute for validating the selected recovery. Restore also rejects an
unreadable physical directory. An unreadable root is eligible only when the
independent account-home listing identifies it as a symbolic link. It renames
the current entry to a run-specific `public_html.failed-*` path, restores the retained entry, and
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
any mutation response is lost or any check fails, it reconciles the observed
account-home entries and reinstates the pre-restore cPanel state. It never
deletes the recovery or displaced webroot, and reports manual intervention if
the expected entries cannot be proven after compensation.
If Imunify360 blocks the cPanel API before the audit can read Fileman, use the
protected **Frontend Root FTPS Repair** workflow. Start with `audit`: it reads
and hashes only the active `.htaccess`. The `repair` mode stages the reviewed
`public/.htaccess`, verifies its checksum, rechecks that the live file has not
changed since the audit, and retains the original as a run-specific backup
before activation. It then verifies the public root, `index.html`, release
manifest, and a deep Vue route; a failed checksum or live check restores the
original file and retains the failed candidate for inspection.
This FTPS repair is deliberately limited to `.htaccess`. It restores direct
SPA loading when a valid release is already present but does not replace the
atomic cPanel release workflow. The hosting administrator must still whitelist
the automation source in Imunify360 WebShield before normal deployments can
resume.
## Caching and compatibility
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
one-year immutable policy. `index.html`, release metadata, web manifests, and
service-worker control files always revalidate. The deployer retains at least
the active and rollback releases and keeps five recent release directories by
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
falls outside that validated retention set, its directory and matching ZIP and
checksum are removed over FTPS. Cleanup failure is reported without rolling
back an otherwise verified deployment.
Because the document root switches as one symlink, an already-loaded page may
still request an asset from its previous release after activation. The current
implementation keeps previous release directories for rollback, but does not
publish their asset paths through the new `current` pointer. Treat long-lived
open-tab compatibility as a separate CDN/shared-assets enhancement if product
usage requires it; the deployment itself does not serve mixed files.
## Failure and rollback behavior
- Any error before the symlink rename leaves the current release untouched.
- The deploy helper immediately verifies the public release after the rename.
A failure restores the captured previous release.
- A later public or credentialed Playwright failure runs the explicit rollback
step with the previous immutable target emitted by the deploy step.
- A stale workflow run exits before activation when `master` has advanced.
- Release Manager is record-only (`auto_sync: false`); it no longer deploys the
frontend through the API/Coolify path.
For manual rollback from a controlled runner, provide the same GitHub
environment settings plus the target recorded in the successful deployment:
```bash
RELEASE_ROLLBACK_TARGET=releases/<release-id>/dist npm run release:deploy:cpanel:rollback
```
Never point this command outside `releases/<release-id>/dist`; the helper rejects
path traversal and operations outside the configured deployment root.
+400
View File
@@ -0,0 +1,400 @@
# Cable-Connected iPhone Debug IPA Runbook
This runbook covers development-signed iOS builds installed from an Ubuntu
workstation over USB. It is separate from the public App Store release path in
`docs/app-store-release.md`.
The device build is deliberately a second app:
- Debug bundle ID: `io.truckwash.app.debug`
- Debug display name: `Truck Wash Debug`
- Production bundle ID: `io.truckwash.app`
- Capacitor/Android app ID: `io.truckwash.twa`
- API: `https://api-v2.truckwash.io/master/api`
Installing or uninstalling the debug app must not replace or remove the
production app. The debug workflow builds the Vue application in production
mode against the stable API; it does not use Vite's development `/api` default
or a live-reload server.
## What Ubuntu Can And Cannot Do
The current Ubuntu workstation already has `usbmuxd`, the libimobiledevice
utilities, and `ideviceinstaller`. The current iPhone has previously been
trusted and paired. Run the repository doctor before every install because the
phone can still be locked, trust can be reset, or Developer Mode can be off.
This workflow supports:
- Inspecting pairing, activation, lock, Developer Mode, and install-service
readiness.
- Installing and upgrading a valid development-signed IPA.
- Reading filtered device syslog and copying crash reports.
Apple does not provide Xcode, LLDB device debugging, or Safari Web Inspector on
Linux. `CAPACITOR_DEBUG` and `get-task-allow` make the IPA suitable for a
development device, but they do not create an official Linux LLDB or WebKit
debugger. Use a physical Mac with Xcode/Safari for breakpoints or Web Inspector.
Use TestFlight or a physical Mac if a new or beta iOS release is incompatible
with libimobiledevice; never weaken device security or signing validation as a
workaround.
## One-Time iPhone Preparation
1. Connect the iPhone directly with a data-capable USB cable.
2. Unlock the phone and keep it awake. Tap **Trust** if iOS asks whether to
trust this computer, then enter the device passcode.
3. On iOS 16 or newer, open **Settings -> Privacy & Security -> Developer
Mode**, turn Developer Mode on, and accept the restart. iOS 15 does not have
this setting and the helper does not require it there.
4. After the restart, unlock the phone, confirm **Turn On** in the Developer
Mode prompt, and enter the passcode again.
5. Reconnect the cable and run the doctor described below.
Developer Mode is an iOS security control and cannot be bypassed from Ubuntu.
If the Developer Mode setting is absent, connect the phone once to a physical
Mac and use Apple's supported Xcode or Apple Configurator device preparation,
then return to Ubuntu after the phone has restarted and Developer Mode is on.
Trust, pairing, and Developer Mode can be cleared by device resets, iOS updates,
or privacy/location resets. Repeat these steps if the doctor reports that the
previously working device is no longer ready.
## Apple Developer Setup
This requires the paid Truck Wash ApS Apple Developer team and a user permitted
to manage certificates, identifiers, and devices.
### Register the device and debug App ID
1. Connect and unlock the iPhone, then get its UDID locally with
`idevice_id -l`. Treat the full UDID as sensitive operational data: do not
commit it or paste it into ordinary build logs.
2. In Apple Developer **Certificates, Identifiers & Profiles -> Devices**, add
the iPhone using that UDID.
3. Under **Identifiers**, create an explicit App ID for
`io.truckwash.app.debug`.
4. Enable only capabilities required by the current Xcode project. Do not copy
unrelated production entitlements into the debug App ID.
### Create the certificate and development profile
1. Create a dedicated **Apple Development** certificate for CI device-debug
signing. Keep its private key under the team's normal credential controls.
2. Export the certificate and private key together as a password-protected
`.p12` file.
3. Create an **iOS App Development** provisioning profile that selects:
- App ID `io.truckwash.app.debug`
- The dedicated Apple Development certificate
- Every approved physical test iPhone, including the cable-connected device
4. Download the `.mobileprovision` file.
5. Confirm the profile has not expired, includes the intended device UDIDs, and
grants `get-task-allow=true`. An App Store or ad-hoc profile is not valid for
this workflow.
Base64-encode both files without line wrapping before adding them to GitHub. On
Ubuntu, for example:
```sh
base64 -w 0 TruckWashDebug.p12 > TruckWashDebug.p12.base64
base64 -w 0 TruckWashDebug.mobileprovision > TruckWashDebug.mobileprovision.base64
```
Store the encoded values in GitHub immediately, verify one successful build,
then securely remove the local `.p12`, profile, encoded copies, CSR, and any
other private-key intermediates that are no longer required. Never commit
signing files or their encoded contents.
## GitHub Environment And Dispatch Approval
Create a repository environment named `mobile-device-debug`. Store the debug
signing configuration only in that environment. Required environment reviewers
are not available for this private repository's current GitHub plan, so the
manual `workflow_dispatch` inputs are the signing approval boundary.
Restrict the environment's custom deployment branches to the exact `master`
branch. The checked-in workflow also refuses any other workflow ref. This keeps
signing secrets behind the reviewed workflow on `master`, while `source_ref`
can still select a separately inspected same-repository commit to build.
Add these environment variables exactly:
- `APPLE_TEAM_ID`
- `IOS_DEBUG_BUNDLE_ID=io.truckwash.app.debug`
- `IOS_DEBUG_API_URL=https://api-v2.truckwash.io/master/api`
Add these environment secrets exactly:
- `IOS_DEBUG_CERTIFICATE_BASE64`: base64 of the password-protected `.p12`
- `IOS_DEBUG_CERTIFICATE_PASSWORD`: password used to export the `.p12`
- `IOS_DEBUG_PROVISION_PROFILE_BASE64`: base64 of the development
`.mobileprovision`
- `IOS_DEBUG_ALLOWED_UDIDS`: newline-delimited UDIDs for every device that the
profile is expected to contain
The workflow generates and masks a new random password for its temporary macOS
keychain on every run. Do not create or store an
`IOS_DEBUG_KEYCHAIN_PASSWORD` secret.
Do not reuse the `mobile-store-production` distribution secrets. The debug job
must use an Apple Development certificate and iOS App Development profile; the
existing `io.truckwash.app` App Store workflow remains unchanged.
The person dispatching a run must inspect the intended commit first. Do not
dispatch when:
- The exact 40-character SHA is not the branch, tag, or commit intended.
- The source comes from a fork or another repository.
- The requested change is not appropriate to sign for a physical device.
- The signing profile is expired or no longer covers the intended device.
The workflow independently resolves `source_ref` inside this repository and
requires it to equal `expected_sha`. It also requires the exact typed
confirmation `SIGN IOS DEBUG IPA`. A missing/mismatched SHA or confirmation
stops the unprivileged resolver before the environment signing secrets are used.
## Build And Download A Debug IPA
1. Open **Actions -> iOS Device Debug IPA -> Run workflow** and keep **Use
workflow from** set to `master`.
2. Inspect the intended commit and copy its complete 40-character SHA.
3. Enter `source_ref`. It may be a branch, tag, or commit in this repository and
defaults to `master`.
4. Enter the complete SHA as `expected_sha` and enter the exact confirmation
`SIGN IOS DEBUG IPA`. Submitting these inputs is approval to sign that source.
5. The resolver pins `source_ref` inside this repository and verifies it equals
`expected_sha`. A mismatch stops the run before signing.
6. Wait for the signed macOS job to finish. It builds a Debug archive against
`https://api-v2.truckwash.io/master/api`, exports it with method
`development`, validates the embedded profile and app identity, and never
uploads the result to App Store Connect.
7. Download the `truck-wash-debug-<version>-<12-character-SHA>` GitHub Actions
artifact for the run. Keep its same-prefix `.ipa`, `.dSYM.zip`,
`manifest.json`, and `SHA256SUMS` together in one directory.
8. From that directory, verify the download before connecting it to a device:
```sh
sha256sum --check SHA256SUMS
```
Do not install an artifact after a checksum failure. The manifest records the
source ref and SHA, build/run numbers, bundle identity, stable API, minimum iOS,
Xcode/Capacitor versions, signing method, and provisioning-profile expiration.
It intentionally does not contain device UDIDs or secrets.
Artifacts are retained for seven days. Keep the zipped dSYM with any crash
report from that build so a Mac/Xcode crash-symbolication path remains
available.
## Ubuntu Device Commands
Run commands from the repository root. The npm interface is:
```sh
npm run mobile:ios:device -- <command>
```
If the npm wrapper is unavailable, use the equivalent direct entrypoint:
```sh
node scripts/mobile/ios-device.mjs <command>
```
The helper uses USB devices only. With one connected iPhone, omit `--udid`.
With multiple devices connected, provide `--udid ID`; the command fails instead
of guessing. Normal output redacts full UDIDs.
### Check readiness
Unlock the phone and run:
```sh
npm run mobile:ios:device -- doctor
```
The doctor verifies required host commands, USB discovery, pairing, activation,
unlocked state, Developer Mode, and installation-proxy access. Resolve every
reported failure before attempting an install.
For a specific connected device:
```sh
npm run mobile:ios:device -- doctor --udid DEVICE_UDID
```
### Install or upgrade
Keep the downloaded artifact files together and run:
```sh
npm run mobile:ios:device -- install ./truck-wash-debug-VERSION-SHA.ipa --manifest ./manifest.json
```
The helper requires and verifies `SHA256SUMS` and the complete workflow
manifest, then inspects the IPA and its embedded profile. It rejects missing or
mismatched artifact metadata, the wrong repository/source/API/bundle/executable,
an App Store/ad-hoc or expired profile, `get-task-allow=false`, a profile
missing the connected UDID, or an invalid app payload before calling
`ideviceinstaller`.
If `io.truckwash.app.debug` is absent, the helper installs it. If it is already
present, the helper upgrades it and confirms the resulting version/build on the
phone. It never uninstalls or replaces `io.truckwash.app`.
Launch **Truck Wash Debug** manually from the iPhone Home Screen. Keep the phone
online for the first launch so iOS can perform Apple's PPQ validation for the
provisioning profile. A firewall, DNS filter, VPN, or captive portal that
blocks Apple's validation service can prevent a correctly signed development
app from opening.
### Collect filtered logs
Start logging, then reproduce the issue on the phone:
```sh
npm run mobile:ios:device -- logs
npm run mobile:ios:device -- logs --output ./truck-wash-debug.log
```
The debug executable is deliberately named `TruckWashDebug`, distinct from the
production executable. The helper verifies that exact name and filters
`idevicesyslog` output for it. It streams child-tool output through UDID
redaction; `--output` files are written by the helper with mode `0600` after
redaction. Logs should make it possible to correlate the app with its source SHA
and stable API target without exposing signing secrets or full device IDs.
### Copy crash reports
Create a destination directory and copy reports from the phone:
```sh
mkdir -p ./ios-crashes
npm run mobile:ios:device -- crashes ./ios-crashes
```
Crash retrieval always keeps the original reports on the iPhone. Preserve the
matching IPA manifest and dSYM with each report.
### Remove only the debug app
Uninstall requires the exact debug bundle ID as typed confirmation:
```sh
npm run mobile:ios:device -- uninstall --confirm io.truckwash.app.debug
```
The helper refuses to remove the production bundle or any other bundle ID.
## Adding Devices And Renewing Signing
A provisioning profile is a snapshot. Registering another iPhone in Apple
Developer does not update an already downloaded profile.
When adding a device:
1. Obtain its UDID locally and register it in the Apple Developer portal.
2. Regenerate the `io.truckwash.app.debug` iOS App Development profile with the
new and existing approved devices selected.
3. Replace `IOS_DEBUG_PROVISION_PROFILE_BASE64`.
4. Add the UDID to the newline-delimited `IOS_DEBUG_ALLOWED_UDIDS` secret.
5. Dispatch a new build; an existing IPA does not gain access to the new device.
Monitor the profile expiration recorded in each artifact manifest and the Apple
Development certificate expiration in the portal. Before either expires,
create/renew the signing material, regenerate the profile, replace the affected
GitHub secrets, and prove the result with a new build and real-device install.
Revoked or expired signing material invalidates later installation and can stop
an already installed development build from launching.
## Troubleshooting
### No device, device locked, or installation proxy unavailable
- Use a direct data-capable cable and avoid an unreliable hub.
- Unlock the iPhone, keep its screen awake, reconnect it, and rerun `doctor`.
- Close other tools that may be exclusively interacting with the device.
- Do not repeatedly retry installation while the doctor reports a lock/service
failure.
### Pairing or trust failure
- Unlock the phone and accept the Trust prompt.
- If no prompt appears and `doctor` reports invalid pairing, use the explicit
repair guidance printed by the helper, reconnect, and confirm trust again.
- Device privacy resets and some iOS updates require a new trust decision.
### Developer Mode is disabled or absent on iOS 16 or newer
- Enable it under **Settings -> Privacy & Security -> Developer Mode**, restart,
and confirm after the reboot.
- If the switch is absent, use a Mac with Xcode or Apple Configurator for
Apple's supported one-time preparation. There is no Ubuntu bypass.
### IPA, profile, certificate, or UDID mismatch
- Confirm the IPA is from **iOS Device Debug IPA**, not **Mobile Store Artifacts**.
- Check `manifest.json` for `io.truckwash.app.debug`, development signing, the
intended source SHA, and a future profile expiration.
- Regenerate the development profile when a device was added, a certificate was
replaced, or the profile expired; then replace the GitHub secret and rebuild.
- Never suppress the helper's profile, entitlement, bundle, or checksum checks.
### App installs but will not launch
- Keep the phone online for Apple's initial PPQ validation.
- Check whether VPN, DNS, firewall, captive-portal, or device-management policy
blocks Apple developer-app verification.
- Confirm Developer Mode is still on and the certificate/profile has not expired
or been revoked.
- Collect syslog and crash reports before reinstalling so evidence is preserved.
### App reports `No response was received`
- Keep the API base set to `https://api-v2.truckwash.io/master/api`. The bare
`https://api-v2.truckwash.io` host is the public gateway, not the application
API base.
- Confirm `https://api-v2.truckwash.io/master/api/ping` responds before
investigating the device or app.
- Capacitor serves bundled iOS content from `capacitor://localhost`. The stable
API must return `Access-Control-Allow-Origin: capacitor://localhost` for that
exact origin, including authenticated preflight requests.
- The signing workflow checks API reachability and this CORS contract before
compiling or signing. If it fails, deploy the backend CORS policy fix before
dispatching another IPA; do not replace the API URL or use an unsupported
HTTP/HTTPS `iosScheme` workaround.
- An already-built IPA starts using a corrected server-side CORS policy without
modification. Build and install a higher version when recording a verified
device-test result for the fix.
### iOS beta or new major iOS version breaks device tools
- Record the device model, exact iOS version, helper error, source SHA, and IPA
checksum.
- Update libimobiledevice only through a trusted package/source and rerun the
doctor. Do not install arbitrary device images or disable signing checks.
- If compatibility remains broken, distribute through TestFlight or install and
debug from a physical Mac with a compatible Xcode version.
## Real-Device Acceptance Checklist
For the first setup, after signing changes, and after major iOS upgrades:
- `doctor` passes while the phone is unlocked.
- `SHA256SUMS` verifies and the manifest identifies the intended immutable SHA.
- **Truck Wash Debug** installs as `io.truckwash.app.debug` while the production
app and its data remain unchanged.
- Authentication, camera/QR permission, and foreground-location behavior work.
- Logs show the expected build/source context and stable API target.
- The debug app still launches and reaches the API after the cable is removed.
- A higher-numbered IPA upgrades the debug app without clearing its local state.
- Crash reports are copied without being deleted from the phone.
- The test record includes artifact checksum, source SHA, device model, iOS
version, outcome, and any residual iOS/libimobiledevice compatibility risk.
## References
- [Apple: enable Developer Mode on a device](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device)
- [Apple: run an app on a physical device](https://developer.apple.com/documentation/Xcode/running-your-app-on-simulated-or-physical-devices)
- [Apple: register a single device](https://developer.apple.com/help/account/devices/register-a-single-device/)
- [Apple: create a development provisioning profile](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile/)
- [libimobiledevice project](https://github.com/libimobiledevice/libimobiledevice)
+6 -3
View File
@@ -7,6 +7,9 @@ const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
CanvasImageSource: "readonly",
EventListener: "readonly",
PositionCallback: "readonly",
grecaptcha: "readonly",
};
@@ -101,12 +104,12 @@ export default [
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-mutating-props": ["warn", { shallowOnly: true }],
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
@@ -133,7 +136,7 @@ export default [
},
},
rules: {
"no-empty": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
},
},
];
+1 -1
View File
@@ -436,7 +436,7 @@
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession = false;
var hasStoredSession;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
+5 -3
View File
@@ -217,7 +217,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -298,6 +298,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
APP_DISPLAY_NAME = "Truck Wash Debug";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -309,8 +310,8 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug;
PRODUCT_NAME = TruckWashDebug;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -320,6 +321,7 @@
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_DISPLAY_NAME = "Truck Wash";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,14 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
"images": [
{ "filename": "AppIcon-20@2x.png", "idiom": "iphone", "scale": "2x", "size": "20x20" },
{ "filename": "AppIcon-20@3x.png", "idiom": "iphone", "scale": "3x", "size": "20x20" },
{ "filename": "AppIcon-29@2x.png", "idiom": "iphone", "scale": "2x", "size": "29x29" },
{ "filename": "AppIcon-29@3x.png", "idiom": "iphone", "scale": "3x", "size": "29x29" },
{ "filename": "AppIcon-40@2x.png", "idiom": "iphone", "scale": "2x", "size": "40x40" },
{ "filename": "AppIcon-40@3x.png", "idiom": "iphone", "scale": "3x", "size": "40x40" },
{ "filename": "AppIcon-60@2x.png", "idiom": "iphone", "scale": "2x", "size": "60x60" },
{ "filename": "AppIcon-60@3x.png", "idiom": "iphone", "scale": "3x", "size": "60x60" },
{ "filename": "AppIcon-20@1x.png", "idiom": "ipad", "scale": "1x", "size": "20x20" },
{ "filename": "AppIcon-20-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "20x20" },
{ "filename": "AppIcon-29@1x.png", "idiom": "ipad", "scale": "1x", "size": "29x29" },
{ "filename": "AppIcon-29-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "29x29" },
{ "filename": "AppIcon-40@1x.png", "idiom": "ipad", "scale": "1x", "size": "40x40" },
{ "filename": "AppIcon-40-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "40x40" },
{ "filename": "AppIcon-76@1x.png", "idiom": "ipad", "scale": "1x", "size": "76x76" },
{ "filename": "AppIcon-76@2x.png", "idiom": "ipad", "scale": "2x", "size": "76x76" },
{ "filename": "AppIcon-83.5@2x.png", "idiom": "ipad", "scale": "2x", "size": "83.5x83.5" },
{ "filename": "AppIcon-1024.png", "idiom": "ios-marketing", "scale": "1x", "size": "1024x1024" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
"info": { "author": "xcode", "version": 1 }
}
+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Truck Wash</string>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+1410 -2374
View File
File diff suppressed because it is too large Load Diff
+17 -6
View File
@@ -1,6 +1,7 @@
{
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"web-types": "./web-types.json",
"private": true,
"type": "module",
"scripts": {
@@ -49,6 +50,11 @@
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"release:package": "node scripts/release/package-dist.mjs",
"release:deploy:cpanel": "node scripts/release/deploy-cpanel.mjs",
"release:deploy:cpanel:rollback": "node scripts/release/deploy-cpanel.mjs --rollback",
"release:cpanel-root:audit": "node scripts/release/cpanel-root.mjs audit",
"release:cpanel-root:restore": "node scripts/release/cpanel-root.mjs restore",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
@@ -67,11 +73,12 @@
"mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
},
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@creativebulma/bulma-badge": "^1.0.1",
@@ -91,7 +98,7 @@
"@xterm/addon-fit": "^0.11.0",
"animate.css": "^4.1.1",
"apexcharts": "^5.10.4",
"axios": "1.13.5",
"axios": "1.18.1",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
"bulma-block-list": "^1.1.0",
@@ -122,7 +129,7 @@
"vue3-apexcharts": "^1.11.1",
"vue3-cookies": "^1.0.6",
"vuex": "^4.1.0",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xterm": "^5.3.0"
},
"devDependencies": {
@@ -149,9 +156,13 @@
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite": "8.1.5",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^7.5.4",
"vitest": "^2.1.9"
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.3.0"
},
"overrides": {
"tar": "7.5.19"
}
}
+17
View File
@@ -1,3 +1,5 @@
DirectoryIndex index.html
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
@@ -6,6 +8,21 @@
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_headers.c>
# Fingerprinted build assets are content-addressed and safe to retain across
# atomic release switches. Mutable application shells and PWA control files
# below override this policy and must always be revalidated.
<FilesMatch "[._-][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^(?:index\.html|manifest\.json|manifest\.webmanifest|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js)$">
Header set Cache-Control "no-cache, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
+44 -40
View File
@@ -1,46 +1,50 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
linter: jetbrains/qodana-js:2026.1
#Specify inspection profile for code analysis
profile:
name: qodana.starter
name: qodana.recommended
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
bootstrap: npm ci --legacy-peer-deps
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
include:
- name: Eslint
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:2025.3
exclude:
# These flows deliberately funnel synchronous validation failures into the same
# catch blocks that normalize asynchronous API/auth failures for the UI.
- name: ExceptionCaughtLocallyJS
paths:
- scripts/mobile/upload-google-play.mjs
- src/components/displays/buttons/ActionSettingsWheelButton.vue
- src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue
- src/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue
- src/components/displays/superuser/tables/rolesTable.vue
- src/components/displays/superuser/tables/usersTable.vue
- src/components/session/token/SessionUser/Objects/DepartmentDailyReportComplaints.vue
- src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue
- src/components/session/token/SessionUser/Objects/Subusers.vue
- src/components/shop/POSDepartmentProcess.vue
- src/components/timebookings/displays/CalendarController.vue
- src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue
- src/composables/useSelfServeLogic.js
- src/features/edgeGateways/EdgeGatewayManager.vue
- src/services/PasskeyAuthService.js
- src/services/releaseChannelAvailability.js
- src/views/backoffice/components/LimitedBackofficeEmployeesManager.vue
- src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue
- src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue
- src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue
- src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue
- src/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue
- src/views/dashboards/superUserDashboard/statistics/displays/overview/StatisticsIncomeCard.vue
- src/views/dashboards/userDashboard/profile/displays/Passkeys/PasskeyManagement.vue
- name: All
paths:
- src/i18n/generated
- node_modules.codex-backup
- output
- .gradle
- playwright/.cache
- android
- ios
- app
+2 -2
View File
@@ -9,8 +9,8 @@ const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
+2 -11
View File
@@ -7,8 +7,8 @@ const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
@@ -138,15 +138,6 @@ const slugify = (token, usedKeys) => {
return key;
};
const isCapitalizedToken = (token) => {
const lower = token.toLocaleLowerCase();
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
};
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
const applyTextModifier = (value, modifier) => {
@@ -0,0 +1,93 @@
import { argv, env, exit } from "node:process";
const failures = [];
const expectedBundleId = "io.truckwash.app.debug";
const expectedApiUrl = "https://api-v2.truckwash.io/master/api";
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const decodeBase64 = (name) => {
const encoded = String(env[name] ?? "").replace(/\s/g, "");
if (!encoded) {
return null;
}
if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
failures.push(`${name} is not valid base64`);
return null;
}
const decoded = Buffer.from(encoded, "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
};
for (const name of [
"APPLE_TEAM_ID",
"IOS_DEBUG_BUNDLE_ID",
"IOS_DEBUG_API_URL",
"IOS_DEBUG_CERTIFICATE_BASE64",
"IOS_DEBUG_CERTIFICATE_PASSWORD",
"IOS_DEBUG_PROVISION_PROFILE_BASE64",
"IOS_DEBUG_ALLOWED_UDIDS",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
]) {
requireVariable(name);
}
decodeBase64("IOS_DEBUG_CERTIFICATE_BASE64");
decodeBase64("IOS_DEBUG_PROVISION_PROFILE_BASE64");
if (env.APPLE_TEAM_ID && !/^[A-Z0-9]{10}$/.test(env.APPLE_TEAM_ID)) {
failures.push("APPLE_TEAM_ID must be a 10-character Apple team identifier");
}
if (env.IOS_DEBUG_BUNDLE_ID && env.IOS_DEBUG_BUNDLE_ID !== expectedBundleId) {
failures.push(`IOS_DEBUG_BUNDLE_ID must be ${expectedBundleId}`);
}
if (env.IOS_DEBUG_API_URL && env.IOS_DEBUG_API_URL !== expectedApiUrl) {
failures.push(`IOS_DEBUG_API_URL must be ${expectedApiUrl}`);
}
if (env.MOBILE_VERSION_NAME && !/^0\.0\.[1-9]\d*$/.test(env.MOBILE_VERSION_NAME)) {
failures.push("MOBILE_VERSION_NAME must use the deterministic 0.0.<run number> format");
}
if (env.MOBILE_VERSION_CODE && !/^[1-9]\d{0,17}$/.test(env.MOBILE_VERSION_CODE)) {
failures.push("MOBILE_VERSION_CODE must be a positive integer of at most 18 digits");
}
if (env.IOS_DEBUG_ALLOWED_UDIDS) {
const lines = env.IOS_DEBUG_ALLOWED_UDIDS.split(/\r?\n/).map((value) => value.trim());
const udids = lines.filter(Boolean);
if (udids.length === 0) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS must contain at least one device UDID");
} else if (udids.some((udid) => !/^[A-Za-z0-9-]{16,64}$/.test(udid))) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS contains an invalid device UDID");
} else if (new Set(udids).size !== udids.length) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS contains duplicate device UDIDs");
}
}
if (argv.length > 2) {
failures.push("This check does not accept command-line arguments");
}
if (failures.length > 0) {
console.error("iOS device-debug signing environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("iOS device-debug signing environment is configured.");
+856
View File
@@ -0,0 +1,856 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { constants as fsConstants, createWriteStream, fchmodSync, openSync } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { finished } from "node:stream/promises";
import { fileURLToPath } from "node:url";
export const DEBUG_BUNDLE_ID = "io.truckwash.app.debug";
export const DEBUG_DISPLAY_NAME = "Truck Wash Debug";
export const DEBUG_EXECUTABLE_NAME = "TruckWashDebug";
export const DEBUG_API_URL = "https://api-v2.truckwash.io/master/api";
const BASE_TOOLS = ["idevice_id", "idevicepair", "ideviceinfo"];
const IPA_TOOLS = ["unzip", "openssl", "python3"];
const DOCTOR_TOOLS = [...BASE_TOOLS, ...IPA_TOOLS, "ideviceinstaller", "idevicesyslog", "idevicecrashreport"];
const PLIST_TO_JSON = String.raw`
import datetime
import json
import plistlib
import sys
def encode(value):
if isinstance(value, (datetime.datetime, datetime.date)):
encoded = value.isoformat()
return (encoded + "Z") if value.tzinfo is None else encoded.replace("+00:00", "Z")
if isinstance(value, bytes):
return {"type": "data", "length": len(value)}
raise TypeError(f"Unsupported plist value: {type(value).__name__}")
with open(sys.argv[1], "rb") as source:
print(json.dumps(plistlib.load(source), default=encode))
`;
export class CliError extends Error {
constructor(message, { exitCode = 1, cause } = {}) {
super(message, { cause });
this.name = "CliError";
this.exitCode = exitCode;
}
}
function usage() {
return `Usage:
node scripts/mobile/ios-device.mjs doctor [--udid ID]
node scripts/mobile/ios-device.mjs install IPA [--manifest FILE] [--udid ID]
node scripts/mobile/ios-device.mjs logs [--output FILE] [--udid ID]
node scripts/mobile/ios-device.mjs crashes DIRECTORY [--udid ID]
node scripts/mobile/ios-device.mjs uninstall --confirm ${DEBUG_BUNDLE_ID} [--udid ID]`;
}
export function parseArgs(argv) {
const args = [...argv];
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
return { help: true };
}
const command = args.shift();
const supported = new Set(["doctor", "install", "logs", "crashes", "uninstall"]);
if (!supported.has(command)) {
throw new CliError(`Unknown command: ${command}\n\n${usage()}`);
}
const options = { command, positionals: [] };
const valueOptions = new Set(["--udid", "--manifest", "--output", "--confirm"]);
while (args.length > 0) {
const arg = args.shift();
if (!arg.startsWith("--")) {
options.positionals.push(arg);
continue;
}
if (!valueOptions.has(arg)) {
throw new CliError(`Unknown option for ${command}: ${arg}`);
}
const key = arg.slice(2);
if (Object.hasOwn(options, key)) {
throw new CliError(`Option may only be supplied once: ${arg}`);
}
const value = args.shift();
if (!value || value.startsWith("--")) {
throw new CliError(`Option requires a value: ${arg}`);
}
options[key] = value;
}
const allowedOptions = {
doctor: new Set(["udid"]),
install: new Set(["udid", "manifest"]),
logs: new Set(["udid", "output"]),
crashes: new Set(["udid"]),
uninstall: new Set(["udid", "confirm"]),
};
for (const key of ["udid", "manifest", "output", "confirm"]) {
if (Object.hasOwn(options, key) && !allowedOptions[command].has(key)) {
throw new CliError(`--${key} is not valid for ${command}`);
}
}
const expectedPositionals = command === "install" || command === "crashes" ? 1 : 0;
if (options.positionals.length !== expectedPositionals) {
const expectation = expectedPositionals === 0 ? "no positional arguments" : "exactly one path";
throw new CliError(`${command} requires ${expectation}.\n\n${usage()}`);
}
if (command === "uninstall" && options.confirm !== DEBUG_BUNDLE_ID) {
throw new CliError(`Refusing to uninstall. Supply --confirm ${DEBUG_BUNDLE_ID} exactly.`);
}
return options;
}
export function redactUdids(value, udids = []) {
let redacted = String(value ?? "");
for (const udid of udids) {
if (udid) redacted = redacted.split(udid).join("<redacted-udid>");
}
return redacted
.replace(/\b[0-9a-f]{40}\b/giu, "<redacted-udid>")
.replace(/\b[0-9a-f]{8}-[0-9a-f]{16}\b/giu, "<redacted-udid>");
}
export function selectUsbDevice(rawOutput, requestedUdid) {
const devices = [
...new Set(
String(rawOutput)
.split(/\r?\n/u)
.map((item) => item.trim())
.filter(Boolean)
),
];
if (devices.length === 0) {
throw new CliError("No cable-connected iPhone was found. Connect and unlock the phone, then retry.");
}
if (requestedUdid) {
if (!devices.includes(requestedUdid)) {
throw new CliError("The requested device is not connected over USB.");
}
return { udid: requestedUdid, allUdids: devices };
}
if (devices.length !== 1) {
throw new CliError(`Found ${devices.length} USB devices. Select one explicitly with --udid ID.`);
}
return { udid: devices[0], allUdids: devices };
}
export function createRedactedLineWriter(output, udids) {
const decoder = new StringDecoder("utf8");
let pending = "";
let ended = false;
const flushCompleteLines = () => {
const newline = Math.max(pending.lastIndexOf("\n"), pending.lastIndexOf("\r"));
if (newline < 0) return;
output.write(redactUdids(pending.slice(0, newline + 1), udids));
pending = pending.slice(newline + 1);
};
return {
write(chunk) {
pending += decoder.write(chunk);
flushCompleteLines();
},
end() {
if (ended) return;
ended = true;
pending += decoder.end();
if (pending) output.write(redactUdids(pending, udids));
},
};
}
export function spawnCommand(command, args, { inherit = false, streamRedactedUdids, redactedStdoutFile } = {}) {
return new Promise((resolvePromise, rejectPromise) => {
const streamRedacted = Array.isArray(streamRedactedUdids);
if (redactedStdoutFile && !streamRedacted) {
rejectPromise(new CliError("redactedStdoutFile requires streamed UDID redaction."));
return;
}
const stdoutFileDescriptor = redactedStdoutFile ? openSync(redactedStdoutFile, "w", 0o600) : null;
if (stdoutFileDescriptor !== null) fchmodSync(stdoutFileDescriptor, 0o600);
const stdoutTarget = redactedStdoutFile
? createWriteStream(redactedStdoutFile, { fd: stdoutFileDescriptor, autoClose: true })
: process.stdout;
const stdoutFinished = redactedStdoutFile
? finished(stdoutTarget).then(
() => null,
(error) => error
)
: null;
const child = spawn(command, args, {
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
});
if (redactedStdoutFile) {
stdoutTarget.once("error", (error) => {
child.kill("SIGTERM");
rejectPromise(error);
});
}
const stdout = [];
const stderr = [];
let finishStreamedStdout = () => {};
if (!inherit) {
if (streamRedacted) {
const stdoutWriter = createRedactedLineWriter(stdoutTarget, streamRedactedUdids);
const stderrWriter = createRedactedLineWriter(process.stderr, streamRedactedUdids);
child.stdout.on("data", (chunk) => stdoutWriter.write(chunk));
child.stderr.on("data", (chunk) => stderrWriter.write(chunk));
finishStreamedStdout = () => {
stdoutWriter.end();
if (redactedStdoutFile && !stdoutTarget.writableEnded) stdoutTarget.end();
};
child.stdout.on("end", finishStreamedStdout);
child.stderr.on("end", () => stderrWriter.end());
} else {
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
}
}
child.once("error", (error) => {
finishStreamedStdout();
rejectPromise(error);
});
child.once("close", async (code, signal) => {
finishStreamedStdout();
const outputError = stdoutFinished ? await stdoutFinished : null;
if (outputError) {
rejectPromise(outputError);
return;
}
resolvePromise({
code: code ?? (signal ? 1 : 0),
signal,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
});
});
});
}
async function defaultCommandExists(command) {
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
for (const entry of pathEntries) {
try {
await access(join(entry, command), fsConstants.X_OK);
return true;
} catch {
// Continue through PATH.
}
}
return false;
}
function commandFailure(command, args, result, udids) {
const stderr = redactUdids(
Buffer.from(result.stderr ?? "")
.toString("utf8")
.trim(),
udids
);
const detail = stderr ? `: ${stderr}` : "";
return new CliError(`${command} ${args.join(" ")} failed${detail}`);
}
async function runChecked(context, command, args, options = {}) {
let result;
try {
result = await context.run(command, args, options);
} catch (error) {
if (error?.code === "ENOENT") {
throw new CliError(`Required command is not installed: ${command}`, { cause: error });
}
throw error;
}
if (result.code !== 0) {
throw commandFailure(command, args, result, context.knownUdids);
}
return result;
}
async function requireTools(context, tools) {
const missing = [];
for (const tool of tools) {
if (!(await context.commandExists(tool))) missing.push(tool);
}
if (missing.length > 0) {
throw new CliError(`Missing required command${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
}
}
async function discoverDevice(context, requestedUdid) {
const result = await runChecked(context, "idevice_id", ["-l"]);
const selection = selectUsbDevice(result.stdout, requestedUdid);
context.knownUdids = selection.allUdids;
return selection.udid;
}
function lockedOrTrustHint(error) {
const message = String(error?.message ?? error).toLowerCase();
if (message.includes("password protected") || message.includes("passcode") || message.includes("locked")) {
return "The iPhone is locked. Unlock it, keep the screen awake, and retry.";
}
if (message.includes("pair") || message.includes("trust") || message.includes("invalid host")) {
return "Pairing is not valid. Unlock the phone, accept Trust This Computer, then run doctor again.";
}
return null;
}
async function validatePairing(context, udid) {
try {
await runChecked(context, "idevicepair", ["-u", udid, "validate"]);
} catch (error) {
throw new CliError(lockedOrTrustHint(error) ?? `Pairing validation failed: ${error.message}`, {
cause: error,
});
}
}
async function queryDeviceValue(context, udid, key) {
const result = await runChecked(context, "ideviceinfo", ["-u", udid, "-k", key]);
return Buffer.from(result.stdout).toString("utf8").trim();
}
async function assertInstallationProxy(context, udid) {
try {
await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
]);
} catch (error) {
throw new CliError(
lockedOrTrustHint(error) ??
"The installation service is unavailable. Unlock the phone, reconnect the cable, and retry.",
{ cause: error }
);
}
}
function developerModeEnabled(output) {
return /\benabled\b/iu.test(String(output)) && !/\bdisabled\b/iu.test(String(output));
}
async function assertDeveloperMode(context, udid) {
const result = await runChecked(context, "idevicedevmodectl", ["-u", udid, "list"]);
if (!developerModeEnabled(Buffer.from(result.stdout).toString("utf8"))) {
throw new CliError(
"Developer Mode is disabled. Enable Settings > Privacy & Security > Developer Mode, restart the iPhone, and confirm Enable."
);
}
}
async function runDoctor(context, options) {
await requireTools(context, DOCTOR_TOOLS);
const udid = await discoverDevice(context, options.udid);
await validatePairing(context, udid);
const activationState = await queryDeviceValue(context, udid, "ActivationState");
if (activationState !== "Activated") {
throw new CliError(`The iPhone is not activated (state: ${activationState || "unknown"}).`);
}
await assertInstallationProxy(context, udid);
const [model, iosVersion] = await Promise.all([
queryDeviceValue(context, udid, "ProductType"),
queryDeviceValue(context, udid, "ProductVersion"),
]);
if (requiresDeveloperMode(iosVersion)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
context.out(`Ready: ${model || "iPhone"}, iOS ${iosVersion || "unknown"}`);
context.out(
`Pairing: valid; activation: active; installation service: available; Developer Mode: ${
requiresDeveloperMode(iosVersion) ? "enabled" : "not required before iOS 16"
}.`
);
}
function bufferText(value) {
return Buffer.from(value ?? "").toString("utf8");
}
async function parsePlistFile(context, plistPath) {
const result = await runChecked(context, "python3", ["-c", PLIST_TO_JSON, plistPath]);
try {
return JSON.parse(bufferText(result.stdout));
} catch (error) {
throw new CliError(`Could not parse plist ${basename(plistPath)}.`, { cause: error });
}
}
export function selectIpaPayload(entries) {
const normalized = entries.map((entry) => String(entry).trim()).filter(Boolean);
const infoPlists = normalized.filter((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(entry));
if (infoPlists.length !== 1) {
throw new CliError(`IPA must contain exactly one app payload; found ${infoPlists.length}.`);
}
const appRoot = dirname(infoPlists[0]);
const profilePath = `${appRoot}/embedded.mobileprovision`;
if (!normalized.includes(profilePath)) {
throw new CliError("IPA does not contain an embedded provisioning profile.");
}
return { appRoot, infoPlistPath: infoPlists[0], profilePath };
}
async function extractZipEntry(context, ipaPath, entry, destination) {
const result = await runChecked(context, "unzip", ["-p", ipaPath, entry]);
await writeFile(destination, Buffer.from(result.stdout));
}
async function fileExists(path) {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
export async function verifySha256File(targetPath, checksumPath) {
const content = await readFile(checksumPath, "utf8");
const targetName = basename(targetPath);
const matches = content
.split(/\r?\n/u)
.map((line) => line.match(/^([0-9a-f]{64})\s+\*?(.+)$/iu))
.filter(Boolean)
.filter((match) => basename(match[2].trim()) === targetName);
if (matches.length !== 1) {
throw new CliError(`${basename(checksumPath)} must contain exactly one checksum for ${targetName}.`);
}
const hash = createHash("sha256");
hash.update(await readFile(targetPath));
const actual = hash.digest("hex");
if (actual.toLowerCase() !== matches[0][1].toLowerCase()) {
throw new CliError(`Checksum verification failed for ${targetName}.`);
}
}
export function validateManifest(manifest, artifact, ipaPath) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new CliError("manifest.json must contain a JSON object.");
}
if (manifest.schema_version !== 1) {
throw new CliError("manifest.json must use schema_version 1.");
}
if (manifest.signing_method !== "development") {
throw new CliError("manifest.json must identify the signing method as development.");
}
if (manifest.repository !== "copenhagentruckwash/pleno-vue") {
throw new CliError("manifest.json repository must be copenhagentruckwash/pleno-vue.");
}
if (typeof manifest.source_ref !== "string" || !manifest.source_ref.trim()) {
throw new CliError("manifest.json must contain a non-empty source_ref.");
}
if (typeof manifest.source_sha !== "string" || !/^[0-9a-f]{40}$/iu.test(manifest.source_sha)) {
throw new CliError("manifest.json must contain a full 40-character source_sha.");
}
if (manifest.api_url !== DEBUG_API_URL || manifest.release_manager_control_api_url !== DEBUG_API_URL) {
throw new CliError(`manifest.json API URLs must both be ${DEBUG_API_URL}.`);
}
const expectations = [
{
name: "bundle identifier",
value: manifest.bundle_id,
actual: artifact.info.CFBundleIdentifier,
},
{
name: "display name",
value: manifest.display_name,
actual: artifact.info.CFBundleDisplayName ?? artifact.info.CFBundleName,
},
{
name: "executable name",
value: manifest.executable_name,
actual: artifact.info.CFBundleExecutable,
},
{
name: "version",
value: manifest.version,
actual: artifact.info.CFBundleShortVersionString,
},
{
name: "build",
value: manifest.build,
actual: artifact.info.CFBundleVersion,
},
{
name: "IPA filename",
value: manifest.ipa_filename,
actual: basename(ipaPath),
},
{
name: "minimum iOS version",
value: manifest.minimum_ios,
actual: artifact.info.MinimumOSVersion,
},
];
for (const expectation of expectations) {
if (expectation.value === undefined || expectation.value === null || expectation.value === "") {
throw new CliError(`manifest.json is missing ${expectation.name}.`);
}
if (String(expectation.value) !== String(expectation.actual ?? "")) {
throw new CliError(
`Manifest ${expectation.name} does not match the IPA (${expectation.value} != ${
expectation.actual ?? "missing"
}).`
);
}
}
const manifestExpiration = new Date(manifest.profile_expiration_utc).getTime();
const profileExpiration = new Date(artifact.profile.ExpirationDate).getTime();
if (
!Number.isFinite(manifestExpiration) ||
!Number.isFinite(profileExpiration) ||
manifestExpiration !== profileExpiration
) {
throw new CliError("Manifest profile expiration does not match the embedded profile.");
}
}
function profileApplicationIdentifier(profile) {
return profile?.Entitlements?.["application-identifier"];
}
function parseNumericVersion(value, label) {
const normalized = String(value ?? "").trim();
const match = normalized.match(/^(\d+(?:\.\d+)*)(?:[^.\d].*)?$/u);
if (!match) {
throw new CliError(`${label} is malformed: ${normalized || "missing"}.`);
}
const components = match[1].split(".").map((component) => Number(component));
if (components.some((component) => !Number.isSafeInteger(component))) {
throw new CliError(`${label} contains an unsupported numeric component: ${normalized}.`);
}
return components;
}
export function assertMinimumIosCompatible(minimumIos, deviceIos) {
const minimum = parseNumericVersion(minimumIos, "IPA minimum iOS version");
const device = parseNumericVersion(deviceIos, "Connected iPhone iOS version");
const componentCount = Math.max(minimum.length, device.length);
for (let index = 0; index < componentCount; index += 1) {
const minimumComponent = minimum[index] ?? 0;
const deviceComponent = device[index] ?? 0;
if (minimumComponent < deviceComponent) return;
if (minimumComponent > deviceComponent) {
throw new CliError(`IPA requires iOS ${minimumIos}, but the connected iPhone runs iOS ${deviceIos}.`);
}
}
}
export function requiresDeveloperMode(deviceIos) {
return parseNumericVersion(deviceIos, "Connected iPhone iOS version")[0] >= 16;
}
export function validateDevelopmentArtifact(artifact, connectedUdid, now = new Date()) {
const { info, profile } = artifact;
if (info.CFBundleIdentifier !== DEBUG_BUNDLE_ID) {
throw new CliError(
`Refusing IPA with bundle identifier ${info.CFBundleIdentifier ?? "missing"}; expected ${DEBUG_BUNDLE_ID}.`
);
}
const displayName = info.CFBundleDisplayName ?? info.CFBundleName;
if (displayName !== DEBUG_DISPLAY_NAME) {
throw new CliError(`Refusing IPA with display name ${displayName ?? "missing"}; expected ${DEBUG_DISPLAY_NAME}.`);
}
if (!info.CFBundleExecutable || !info.CFBundleShortVersionString || !info.CFBundleVersion || !info.MinimumOSVersion) {
throw new CliError("IPA Info.plist is missing executable, version, build, or minimum iOS metadata.");
}
if (info.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(`Refusing IPA with executable ${info.CFBundleExecutable}; expected ${DEBUG_EXECUTABLE_NAME}.`);
}
const entitlements = profile?.Entitlements ?? {};
if (entitlements["get-task-allow"] !== true) {
throw new CliError("IPA is not development-signed: get-task-allow is not true.");
}
const applicationIdentifier = profileApplicationIdentifier(profile);
if (typeof applicationIdentifier !== "string" || !applicationIdentifier.endsWith(`.${DEBUG_BUNDLE_ID}`)) {
throw new CliError("Provisioning profile application identifier does not match the debug bundle.");
}
const teamIdentifier = entitlements["com.apple.developer.team-identifier"];
if (typeof teamIdentifier !== "string" || applicationIdentifier !== `${teamIdentifier}.${DEBUG_BUNDLE_ID}`) {
throw new CliError("Provisioning profile team identifier is inconsistent with its application identifier.");
}
if (!Array.isArray(profile.TeamIdentifier) || !profile.TeamIdentifier.includes(teamIdentifier)) {
throw new CliError("Provisioning profile does not include its entitlement team identifier.");
}
if (profile.ProvisionsAllDevices === true) {
throw new CliError("Enterprise provisioning profiles are not accepted for cable debug installation.");
}
if (!Array.isArray(profile.ProvisionedDevices) || profile.ProvisionedDevices.length === 0) {
throw new CliError("Provisioning profile contains no registered development devices.");
}
if (!profile.ProvisionedDevices.includes(connectedUdid)) {
throw new CliError("The connected iPhone is not included in the provisioning profile.");
}
const expiresAt = new Date(profile.ExpirationDate);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {
throw new CliError("The provisioning profile is expired or has an invalid expiration date.");
}
return {
bundleId: info.CFBundleIdentifier,
displayName,
executable: info.CFBundleExecutable,
version: String(info.CFBundleShortVersionString),
build: String(info.CFBundleVersion),
minimumIos: String(info.MinimumOSVersion),
expiresAt: expiresAt.toISOString(),
};
}
export async function inspectIpa(context, ipaInput, { manifestPath, connectedUdid }) {
const ipaPath = resolve(ipaInput);
if (extname(ipaPath).toLowerCase() !== ".ipa" || !(await fileExists(ipaPath))) {
throw new CliError(`IPA file not found: ${ipaInput}`);
}
await requireTools(context, IPA_TOOLS);
const candidateManifest = manifestPath ? resolve(manifestPath) : join(dirname(ipaPath), "manifest.json");
if (!(await fileExists(candidateManifest))) {
throw new CliError(
`Manifest file not found: ${manifestPath ?? candidateManifest}. Keep manifest.json with the workflow IPA.`
);
}
const siblingChecksum = join(dirname(ipaPath), "SHA256SUMS");
if (!(await fileExists(siblingChecksum))) {
throw new CliError(`Checksum file not found: ${siblingChecksum}. Keep SHA256SUMS with the workflow IPA.`);
}
await verifySha256File(ipaPath, siblingChecksum);
await verifySha256File(candidateManifest, siblingChecksum);
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-device-"));
try {
const listing = await runChecked(context, "unzip", ["-Z1", ipaPath]);
const payload = selectIpaPayload(bufferText(listing.stdout).split(/\r?\n/u));
const infoPath = join(temporaryDirectory, "Info.plist");
const profileCmsPath = join(temporaryDirectory, "embedded.mobileprovision");
const profilePlistPath = join(temporaryDirectory, "profile.plist");
await extractZipEntry(context, ipaPath, payload.infoPlistPath, infoPath);
await extractZipEntry(context, ipaPath, payload.profilePath, profileCmsPath);
const decodedProfile = await runChecked(context, "openssl", [
"smime",
"-inform",
"der",
"-verify",
"-noverify",
"-in",
profileCmsPath,
]);
await writeFile(profilePlistPath, Buffer.from(decodedProfile.stdout));
const artifact = {
info: await parsePlistFile(context, infoPath),
profile: await parsePlistFile(context, profilePlistPath),
};
artifact.validated = validateDevelopmentArtifact(artifact, connectedUdid, context.now());
let manifest;
try {
manifest = JSON.parse(await readFile(candidateManifest, "utf8"));
} catch (error) {
throw new CliError(`Could not parse ${basename(candidateManifest)} as JSON.`, { cause: error });
}
validateManifest(manifest, artifact, ipaPath);
return { ...artifact, ipaPath };
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function parsePlistBuffer(context, buffer, label) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-plist-"));
const plistPath = join(temporaryDirectory, "value.plist");
try {
await writeFile(plistPath, Buffer.from(buffer));
return await parsePlistFile(context, plistPath);
} catch (error) {
throw new CliError(`Could not parse ${label}.`, { cause: error });
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
function findInstalledApp(plist) {
if (!plist || typeof plist !== "object") return null;
if (plist[DEBUG_BUNDLE_ID] && typeof plist[DEBUG_BUNDLE_ID] === "object") {
return plist[DEBUG_BUNDLE_ID];
}
const candidates = Array.isArray(plist) ? plist : Object.values(plist);
return candidates.find((item) => item?.CFBundleIdentifier === DEBUG_BUNDLE_ID) ?? null;
}
async function getInstalledDebugApp(context, udid) {
const result = await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"--xml",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
"-a",
"CFBundleExecutable",
"-a",
"CFBundleShortVersionString",
"-a",
"CFBundleVersion",
]);
return findInstalledApp(await parsePlistBuffer(context, result.stdout, "installed-app list"));
}
async function prepareDevice(context, requestedUdid, tools) {
await requireTools(context, [...BASE_TOOLS, ...tools]);
const udid = await discoverDevice(context, requestedUdid);
await validatePairing(context, udid);
return udid;
}
async function runInstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", ...IPA_TOOLS]);
const deviceIos = await queryDeviceValue(context, udid, "ProductVersion");
if (requiresDeveloperMode(deviceIos)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
await assertInstallationProxy(context, udid);
const artifact = await context.inspectIpa(context, options.positionals[0], {
manifestPath: options.manifest,
connectedUdid: udid,
});
assertMinimumIosCompatible(artifact.validated.minimumIos, deviceIos);
const installed = await context.getInstalledDebugApp(context, udid);
const action = installed ? "upgrade" : "install";
context.out(`${action === "upgrade" ? "Upgrading" : "Installing"} ${DEBUG_DISPLAY_NAME}...`);
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", action, artifact.ipaPath], {
streamRedactedUdids: context.knownUdids,
});
const verified = await context.getInstalledDebugApp(context, udid);
if (!verified) {
throw new CliError("Installation command completed, but the debug app is not present on the iPhone.");
}
const actualVersion = String(verified.CFBundleShortVersionString ?? "");
const actualBuild = String(verified.CFBundleVersion ?? "");
if (actualVersion !== artifact.validated.version || actualBuild !== artifact.validated.build) {
throw new CliError(
`Installed version verification failed (expected ${artifact.validated.version} (${
artifact.validated.build
}), found ${actualVersion || "missing"} (${actualBuild || "missing"})).`
);
}
context.out(`Installed ${DEBUG_DISPLAY_NAME} ${actualVersion} (${actualBuild}).`);
}
async function requireInstalledDebugApp(context, udid) {
const app = await context.getInstalledDebugApp(context, udid);
if (!app) {
throw new CliError(`${DEBUG_DISPLAY_NAME} is not installed.`);
}
if (app.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(
`Installed debug app executable is ${
app.CFBundleExecutable ?? "missing"
}; expected ${DEBUG_EXECUTABLE_NAME}. Reinstall the current device-debug IPA.`
);
}
return app;
}
async function runLogs(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicesyslog", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
const args = ["-u", udid, "--no-colors", "-p", String(app.CFBundleExecutable)];
context.out(`Streaming logs for ${app.CFBundleExecutable}; press Ctrl-C to stop.`);
await runChecked(context, "idevicesyslog", args, {
streamRedactedUdids: context.knownUdids,
...(options.output ? { redactedStdoutFile: resolve(options.output) } : {}),
});
}
async function runCrashes(context, options) {
const destination = resolve(options.positionals[0]);
await mkdir(destination, { recursive: true });
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicecrashreport", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
await runChecked(
context,
"idevicecrashreport",
["-u", udid, "--keep", "--extract", "--filter", String(app.CFBundleExecutable), destination],
{ streamRedactedUdids: context.knownUdids }
);
context.out(`Copied crash reports to ${destination}; reports were kept on the iPhone.`);
}
async function runUninstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "python3"]);
const installed = await context.getInstalledDebugApp(context, udid);
if (!installed) {
context.out(`${DEBUG_DISPLAY_NAME} is not installed; nothing to remove.`);
return;
}
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", "uninstall", DEBUG_BUNDLE_ID], {
streamRedactedUdids: context.knownUdids,
});
const remaining = await context.getInstalledDebugApp(context, udid);
if (remaining) throw new CliError(`Uninstall completed, but ${DEBUG_DISPLAY_NAME} is still present.`);
context.out(`Removed ${DEBUG_DISPLAY_NAME}. The production app was not touched.`);
}
function createContext(overrides = {}) {
return {
run: overrides.run ?? spawnCommand,
commandExists: overrides.commandExists ?? defaultCommandExists,
inspectIpa: overrides.inspectIpa ?? inspectIpa,
getInstalledDebugApp: overrides.getInstalledDebugApp ?? getInstalledDebugApp,
now: overrides.now ?? (() => new Date()),
out: overrides.out ?? ((message) => process.stdout.write(`${message}\n`)),
err: overrides.err ?? ((message) => process.stderr.write(`${message}\n`)),
knownUdids: [],
};
}
export async function main(argv = process.argv.slice(2), overrides = {}) {
const context = createContext(overrides);
try {
const options = parseArgs(argv);
if (options.help) {
context.out(usage());
return 0;
}
if (options.command === "doctor") await runDoctor(context, options);
if (options.command === "install") await runInstall(context, options);
if (options.command === "logs") await runLogs(context, options);
if (options.command === "crashes") await runCrashes(context, options);
if (options.command === "uninstall") await runUninstall(context, options);
return 0;
} catch (error) {
const message = redactUdids(error?.message ?? error, context.knownUdids);
context.err(`Error: ${message}`);
return error?.exitCode ?? 1;
}
}
const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
process.exitCode = await main();
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
import crypto from "node:crypto";
import { DeploymentError, deriveCpanelRoot, normalizeRemoteRoot } from "./cpanel-deploy-lib.mjs";
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function succeeded(value) {
return value === 1 || value === "1" || value === true;
}
function required(env, name) {
const value = env[name];
const hasControlCharacter =
typeof value === "string" &&
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
});
if (typeof value !== "string" || !value || hasControlCharacter) {
throw new DeploymentError(`Missing or invalid ${name}.`);
}
return value;
}
function httpsUrl(value, name) {
let url;
try {
url = new URL(value);
} catch {
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
}
if (url.protocol !== "https:" || url.username || url.password) {
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
}
return url.href.endsWith("/") ? url.href : `${url.href}/`;
}
export function readRootConfig(env = process.env) {
const user = required(env, "PRODUCTION_CPANEL_USER");
if (!SAFE_COMPONENT.test(user)) {
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
}
const webroot = normalizeRemoteRoot(env.PRODUCTION_CPANEL_WEBROOT || "public_html", "PRODUCTION_CPANEL_WEBROOT");
if (webroot.includes("/")) {
throw new DeploymentError("PRODUCTION_CPANEL_WEBROOT must be one account-home entry.");
}
return {
user,
token: required(env, "PRODUCTION_CPANEL_API_TOKEN"),
apiUrl: httpsUrl(required(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
deploymentRoot: deriveCpanelRoot(required(env, "PRODUCTION_CPANEL_PATH"), user),
webroot,
frontendUrl: httpsUrl(env.PRODUCTION_FRONTEND_URL || "https://truckwash.io", "PRODUCTION_FRONTEND_URL"),
};
}
function entryName(entry) {
return String(entry?.file || entry?.name || entry?.basename || "");
}
function normalizedType(entry) {
const value = String(entry?.type || entry?.filetype || "").toLowerCase();
if (value.includes("link") || entry?.islink) return "link";
if (value.includes("dir") || entry?.isdir) return "dir";
if (value.includes("file") || entry?.isfile) return "file";
return value || "unknown";
}
function normalizeAccountPath(config, value) {
const normalized = String(value || "")
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "");
const homePrefix = `home/${config.user}/`;
return normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : normalized;
}
function safeAccountPath(value, label = "cPanel path") {
const normalized = String(value || "").replace(/^\/+/, "");
if (!normalized || /[\\,]/.test(normalized) || normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))) {
throw new DeploymentError(`${label} is unsafe.`);
}
return normalized;
}
function uapiResult(payload) {
return payload?.result && typeof payload.result === "object" ? payload.result : payload;
}
function responseShape(payload) {
const result = uapiResult(payload);
const rootKeys = payload && typeof payload === "object" ? Object.keys(payload).sort().join(",") : typeof payload;
const resultKeys = result && typeof result === "object" ? Object.keys(result).sort().join(",") : typeof result;
const dataType = Array.isArray(result?.data) ? "array" : typeof result?.data;
return `response shape root=[${rootKeys}] result=[${resultKeys}] statusType=${typeof result?.status} dataType=${dataType}`;
}
function responseError(payload) {
return (
payload?.cpanelresult?.error ||
payload?.cpanelresult?.event?.reason ||
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.reason ||
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.error ||
payload?.result?.errors?.[0] ||
payload?.result?.messages?.[0] ||
payload?.errors?.[0] ||
payload?.messages?.[0] ||
payload?.error ||
payload?.message ||
"unknown cPanel error"
);
}
export class CpanelAccountClient {
constructor(config, options = {}) {
this.config = config;
this.fetch = options.fetchImpl || globalThis.fetch;
this.timeoutMs = options.timeoutMs || 30_000;
this.allowedMutable = new Set(options.allowedMutable || []);
}
redact(value) {
let result = String(value || "");
for (const secret of [this.config.user, this.config.token, this.config.apiUrl].filter(Boolean)) {
result = result.replaceAll(secret, "[redacted]");
}
return result;
}
async request(url) {
let response;
try {
response = await this.fetch(url, {
headers: {
Accept: "application/json",
Authorization: `cpanel ${this.config.user}:${this.config.token}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
throw new DeploymentError("cPanel request failed.", { cause: error });
}
if (!response.ok) throw new DeploymentError(`cPanel returned HTTP ${response.status}.`);
try {
return await response.json();
} catch (error) {
throw new DeploymentError("cPanel returned invalid JSON.", { cause: error });
}
}
async api2(functionName, parameters) {
const url = new URL("json-api/cpanel", this.config.apiUrl);
url.searchParams.set("cpanel_jsonapi_user", this.config.user);
url.searchParams.set("cpanel_jsonapi_apiversion", "2");
url.searchParams.set("cpanel_jsonapi_module", "Fileman");
url.searchParams.set("cpanel_jsonapi_func", functionName);
for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, String(value));
const payload = await this.request(url);
const result = payload?.cpanelresult;
const failed = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
if (!succeeded(result?.event?.result) || failed) {
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseError(payload))}`);
}
return [...(result?.data || []), ...(result?.files || []), ...(result?.dirs || [])];
}
async list(directory) {
const dir = directory === "." ? "." : safeAccountPath(directory);
const url = new URL("execute/Fileman/list_files", this.config.apiUrl);
url.searchParams.set("dir", dir);
url.searchParams.set("include_mime", "0");
url.searchParams.set("include_permissions", "1");
url.searchParams.set("limit_to_list", "0");
url.searchParams.set("show_hidden", "1");
url.searchParams.set("types", "dir|file|link");
const payload = await this.request(url);
const result = uapiResult(payload);
if (!succeeded(result?.status)) {
const error = responseError(payload);
const detail = error === "unknown cPanel error" ? `${error}; ${responseShape(payload)}` : error;
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(detail)}`);
}
if (!Array.isArray(result.data)) {
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
}
return result.data;
}
async domains() {
const url = new URL("execute/DomainInfo/domains_data", this.config.apiUrl);
url.searchParams.set("format", "list");
const payload = await this.request(url);
const result = uapiResult(payload);
if (!succeeded(result?.status)) {
throw new DeploymentError(`cPanel DomainInfo failed: ${responseError(payload)}`);
}
if (!Array.isArray(result.data)) {
throw new DeploymentError("cPanel DomainInfo returned an unexpected data shape.");
}
return result.data;
}
assertMutable(remotePath) {
const safe = safeAccountPath(remotePath);
if (!this.allowedMutable.has(safe)) {
throw new DeploymentError(`cPanel mutation outside the explicit restore allowlist: ${safe}.`);
}
return safe;
}
async rename(source, destination) {
return await this.api2("fileop", {
op: "rename",
sourcefiles: this.assertMutable(source),
destfiles: this.assertMutable(destination),
doubledecode: 0,
});
}
}
function find(entries, name) {
return entries.find((entry) => entryName(entry) === name);
}
function publicEntry(entry) {
if (!entry) return null;
return {
name: entryName(entry),
type: normalizedType(entry),
mode: String(entry.mode || entry.permissions || ""),
modified: String(entry.mtime || entry.modified || ""),
size: String(entry.size ?? entry.filesize ?? ""),
};
}
function domainRoot(config, domain) {
return normalizeAccountPath(config, domain?.documentroot || domain?.document_root || domain?.docroot || "");
}
function recoveryPattern(webroot) {
return new RegExp(`^${webroot}[-._](?:recovery|backup|before[-._]atomic)[-._][A-Za-z0-9._-]+$`, "i");
}
function inspectedDomain(config, domain, index) {
const name = String(domain?.domain || domain?.servername || "").trim();
const documentRoot = domainRoot(config, domain);
if (!name || !documentRoot) {
throw new DeploymentError(`cPanel DomainInfo returned incomplete domain data at index ${index}.`);
}
return {
domain: name,
type: String(domain?.domain_type || domain?.type || ""),
documentRoot,
};
}
export async function auditRoot(config, options = {}) {
const client = options.client || new CpanelAccountClient(config, options);
const [homeEntries, deploymentEntries, domains] = await Promise.all([
client.list("."),
client.list(config.deploymentRoot),
client.domains(),
]);
const currentEntry = find(deploymentEntries, "current");
const currentPath = `${config.deploymentRoot}/current`;
const requiredReleaseFiles = ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"];
const current = {
path: currentPath,
type: normalizedType(currentEntry),
accessible: false,
missingFiles: requiredReleaseFiles,
error: "",
};
if (currentEntry && normalizedType(currentEntry) === "link") {
try {
const currentEntries = await client.list(currentPath);
current.accessible = true;
current.missingFiles = requiredReleaseFiles.filter((name) => !find(currentEntries, name));
} catch (error) {
current.error = error instanceof Error ? error.message : "Could not follow the current link.";
}
} else {
current.error = currentEntry
? "The deployment current entry is not a symbolic link."
: "The deployment current entry is missing.";
}
const root = publicEntry(find(homeEntries, config.webroot));
const rootAccess = { accessible: false, error: "" };
let rootEntries = [];
if (root) {
try {
rootEntries = await client.list(config.webroot);
rootAccess.accessible = true;
} catch (error) {
rootAccess.error = error instanceof Error ? error.message : "Could not inspect the primary webroot.";
}
} else {
rootAccess.error = "The primary webroot entry is missing.";
}
const recoveryCandidates = homeEntries
.map((entry) => publicEntry(entry))
.filter((entry) => entry && recoveryPattern(config.webroot).test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name));
const domainRoots = domains
.map((domain, index) => inspectedDomain(config, domain, index))
.sort((left, right) =>
[left.domain, left.documentRoot, left.type]
.join("\0")
.localeCompare([right.domain, right.documentRoot, right.type].join("\0"))
);
const nestedDomainRoots = domainRoots.filter(({ documentRoot }) => documentRoot.startsWith(`${config.webroot}/`));
const state = {
webroot: root,
webrootAccess: rootAccess,
webrootEntries: rootEntries.map((entry) => publicEntry(entry)).filter(Boolean),
current,
recoveryCandidates,
domainRoots,
nestedDomainRoots,
};
const stateToken = crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex");
return {
...state,
stateToken,
rootTargetVerified: false,
healthy: false,
};
}
export async function verifyFrontend(config, options = {}) {
const fetchImpl = options.fetchImpl || globalThis.fetch;
const checks = [
["", "html"],
["index.html", "html"],
["release-manifest.json", "json"],
["guest/book/wash", "html"],
];
for (const [pathname, expected] of checks) {
const requestedUrl = new URL(pathname, config.frontendUrl);
const response = await fetchImpl(requestedUrl, {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
redirect: "follow",
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
});
if (!response.ok) throw new DeploymentError(`Live ${pathname || "/"} returned HTTP ${response.status}.`);
if (response.url && new URL(response.url).origin !== requestedUrl.origin) {
throw new DeploymentError(`Live ${pathname || "/"} redirected outside the production frontend origin.`);
}
const body = await response.text();
if (body.includes("Index of /")) throw new DeploymentError("The production root still exposes a directory index.");
if (expected === "json") {
let manifest;
try {
manifest = JSON.parse(body);
} catch {
throw new DeploymentError(`Live ${pathname} did not return JSON.`);
}
if (!/^[a-f0-9]{40}$/i.test(String(manifest?.commit_sha || "")) || !String(manifest?.build_id || "")) {
throw new DeploymentError(`Live ${pathname} did not identify a packaged frontend release.`);
}
} else if (!/<div\s+id=["']app["']/i.test(body) || !/<title>[^<]*truck\s*wash/i.test(body)) {
throw new DeploymentError(`Live ${pathname || "/"} did not return the frontend HTML shell.`);
}
}
}
async function homeNames(client) {
return new Set((await client.list(".")).map((entry) => entryName(entry)).filter(Boolean));
}
async function renameWithReconciliation(client, source, destination) {
try {
await client.rename(source, destination);
return;
} catch (error) {
let names;
try {
names = await homeNames(client);
} catch (inspectionError) {
throw new DeploymentError(`Could not reconcile the cPanel rename from ${source} to ${destination}.`, {
cause: new AggregateError([error, inspectionError]),
});
}
const sourceExists = names.has(source);
const destinationExists = names.has(destination);
if (!sourceExists && destinationExists) return;
if (sourceExists && !destinationExists) {
throw new DeploymentError(`cPanel did not rename ${source} to ${destination}; the source remains in place.`, {
cause: error,
});
}
throw new DeploymentError(`The cPanel rename from ${source} to ${destination} left an ambiguous account state.`, {
cause: error,
});
}
}
async function reinstatePreRestoreState(client, webroot, recovery, failed) {
const errors = [];
let names;
try {
names = await homeNames(client);
} catch (error) {
return [error];
}
const restored = () => names.has(webroot) && names.has(recovery) && !names.has(failed);
if (restored()) return errors;
if (names.has(webroot) && !names.has(recovery) && names.has(failed)) {
try {
await renameWithReconciliation(client, webroot, recovery);
names = await homeNames(client);
} catch (error) {
errors.push(error);
return errors;
}
}
if (!names.has(webroot) && names.has(recovery) && names.has(failed)) {
try {
await renameWithReconciliation(client, failed, webroot);
names = await homeNames(client);
} catch (error) {
errors.push(error);
return errors;
}
}
if (!restored()) {
errors.push(
new DeploymentError(
`Automatic rollback could not prove the required entries: ${webroot}, ${recovery}, and no ${failed}.`
)
);
}
return errors;
}
export async function restoreRoot(config, recovery, expectedStateToken, confirmation, options = {}) {
const safeRecovery = safeAccountPath(recovery, "recovery path");
if (!recoveryPattern(config.webroot).test(safeRecovery)) {
throw new DeploymentError(
"Recovery path must name a retained public_html recovery, backup, or before-atomic entry."
);
}
if (!/^[a-f0-9]{64}$/.test(expectedStateToken)) {
throw new DeploymentError("Restore requires the exact state token emitted by the audit.");
}
const expectedConfirmation = `RESTORE ${safeRecovery} TO ${config.webroot} STATE ${expectedStateToken}`;
if (confirmation !== expectedConfirmation) {
throw new DeploymentError(`Confirmation must exactly match: ${expectedConfirmation}`);
}
const runId = String(
options.runId || [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join("-") || Date.now()
);
if (!SAFE_COMPONENT.test(runId)) throw new DeploymentError("Restore run ID is unsafe.");
const failed = `${config.webroot}.failed-${runId}`;
const allowedMutable = [config.webroot, safeRecovery, failed];
const client = options.client || new CpanelAccountClient(config, { ...options, allowedMutable });
if (client.allowedMutable instanceof Set) {
for (const item of allowedMutable) client.allowedMutable.add(item);
}
const before = await auditRoot(config, { ...options, client });
if (before.stateToken !== expectedStateToken) {
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
}
if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) {
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
}
if (!before.webroot)
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
if (!new Set(["dir", "link"]).has(before.webroot.type)) {
throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore.");
}
if (!before.webrootAccess.accessible && before.webroot.type !== "link") {
throw new DeploymentError(
"The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery."
);
}
if (before.nestedDomainRoots.length > 0) {
throw new DeploymentError(
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots
.map(({ domain, documentRoot }) => `${domain}=${documentRoot}`)
.join(", ")}.`
);
}
if (find(await client.list("."), failed)) {
throw new DeploymentError(`The displaced-state path ${failed} already exists; refusing to overwrite it.`);
}
try {
await renameWithReconciliation(client, config.webroot, failed);
await renameWithReconciliation(client, safeRecovery, config.webroot);
await (options.verify || verifyFrontend)(config, options);
} catch (error) {
const rollbackErrors = await reinstatePreRestoreState(client, config.webroot, safeRecovery, failed);
if (rollbackErrors.length > 0) {
throw new DeploymentError(
"The retained webroot failed and automatic rollback was incomplete; both retained cPanel entries were preserved for manual recovery.",
{ cause: new AggregateError([error, ...rollbackErrors]) }
);
}
throw new DeploymentError("The retained webroot failed live verification; the pre-restore state was reinstated.", {
cause: error,
});
}
return { restored: safeRecovery, displaced: failed };
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { DeploymentError } from "./cpanel-deploy-lib.mjs";
import { auditRoot, readRootConfig, restoreRoot } from "./cpanel-root-lib.mjs";
async function writeReport(report) {
const reportPath = process.env.CPANEL_ROOT_REPORT_PATH;
if (!reportPath) return;
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
}
async function main() {
const mode = process.argv[2] || "audit";
if (!new Set(["audit", "restore"]).has(mode) || process.argv.length > 3) {
throw new DeploymentError("Usage: cpanel-root.mjs [audit|restore]");
}
const config = readRootConfig(process.env);
if (mode === "audit") {
const report = await auditRoot(config);
await writeReport(report);
console.log(JSON.stringify(report, null, 2));
if (process.env.GITHUB_OUTPUT) {
await fs.appendFile(
process.env.GITHUB_OUTPUT,
`healthy=${report.healthy}\nstate_token=${report.stateToken}\nrecovery_count=${report.recoveryCandidates.length}\n`
);
}
return;
}
const result = await restoreRoot(
config,
process.env.CPANEL_ROOT_RECOVERY || "",
process.env.CPANEL_ROOT_STATE_TOKEN || "",
process.env.CPANEL_ROOT_CONFIRMATION || ""
);
await writeReport(result);
console.log(`Restored ${result.restored}; retained displaced state as ${result.displaced}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : "Unknown cPanel root failure.");
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import { DeploymentError, deployRelease, readDeploymentConfig, rollbackRelease } from "./cpanel-deploy-lib.mjs";
async function main() {
const mode = process.argv[2] || "deploy";
if (!new Set(["deploy", "--rollback"]).has(mode) || process.argv.length > 3) {
throw new DeploymentError("Usage: deploy-cpanel.mjs [--rollback]");
}
if (mode === "--rollback") {
const target = process.env.RELEASE_ROLLBACK_TARGET;
if (!target) {
throw new DeploymentError("RELEASE_ROLLBACK_TARGET is required for --rollback.");
}
const config = readDeploymentConfig(process.env, { rollbackOnly: true });
const result = await rollbackRelease(config, target);
console.log(`Frontend rollback completed: active release ${result.activeTarget.split("/")[1]}.`);
return;
}
const config = readDeploymentConfig(process.env);
const result = await deployRelease(config);
console.log(
`Frontend deployment completed: active release ${config.releaseId}; retained rollback release ${
result.previousTarget.split("/")[1]
}.`
);
if (result.removed.length > 0) {
console.log(`Pruned ${result.removed.length} inactive release(s).`);
}
if (result.retentionWarning) {
console.warn(result.retentionWarning);
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : "Unknown deployment failure.";
console.error(message);
process.exit(1);
});
+400
View File
@@ -0,0 +1,400 @@
import crypto from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
function comparePaths(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
function sha256(contents) {
return crypto.createHash("sha256").update(contents).digest("hex");
}
function assertSafeRelativePath(relativePath, label = "release path") {
if (typeof relativePath !== "string" || relativePath.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (relativePath.includes("\\") || /[\0\r\n]/.test(relativePath)) {
throw new Error(`${label} contains unsafe characters: ${JSON.stringify(relativePath)}`);
}
if (path.posix.isAbsolute(relativePath)) {
throw new Error(`${label} must be relative: ${relativePath}`);
}
const segments = relativePath.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error(`${label} contains an unsafe path segment: ${relativePath}`);
}
}
function assertSafeFileName(relativePath) {
assertSafeRelativePath(relativePath, "dist file path");
if (relativePath !== ".htaccess" && SERVER_EXECUTABLE_EXTENSION_PATTERN.test(path.posix.basename(relativePath))) {
throw new Error(`dist contains a server-executable file: ${relativePath}`);
}
}
function manifestFilePath(distDirectory, assetUrl, label) {
if (typeof assetUrl !== "string" || assetUrl.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl) || assetUrl.includes("?") || assetUrl.includes("#")) {
throw new Error(`${label} must reference a local release file: ${assetUrl}`);
}
let decodedPath;
try {
decodedPath = decodeURIComponent(assetUrl.replace(/^\/+/, ""));
} catch {
throw new Error(`${label} is not valid URL-encoded text: ${assetUrl}`);
}
assertSafeFileName(decodedPath);
const absolutePath = path.resolve(distDirectory, ...decodedPath.split("/"));
const root = path.resolve(distDirectory);
if (!absolutePath.startsWith(`${root}${path.sep}`)) {
throw new Error(`${label} escapes dist: ${assetUrl}`);
}
return { absolutePath, relativePath: decodedPath };
}
async function readJson(filePath, label) {
let contents;
try {
contents = await fsp.readFile(filePath, "utf8");
} catch (error) {
throw new Error(`${label} could not be read: ${error instanceof Error ? error.message : error}`, { cause: error });
}
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`${label} does not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
function assertStringArray(value, label) {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
throw new Error(`${label} must be an array of non-empty strings.`);
}
return value;
}
export async function collectDistInventory(distDirectory) {
const root = path.resolve(distDirectory);
const rootStat = await fsp.lstat(root).catch(() => null);
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error(`dist directory does not exist or is not a real directory: ${root}`);
}
const files = [];
const walk = async (relativeDirectory = "") => {
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
for (const name of names) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
assertSafeRelativePath(relativePath, "dist path");
const absolutePath = path.join(root, ...relativePath.split("/"));
const stat = await fsp.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error(`dist contains a symbolic link: ${relativePath}`);
}
if (stat.isDirectory()) {
await walk(relativePath);
continue;
}
if (!stat.isFile()) {
throw new Error(`dist contains an unsupported filesystem entry: ${relativePath}`);
}
assertSafeFileName(relativePath);
const contents = await fsp.readFile(absolutePath);
files.push({
path: `dist/${relativePath}`,
bytes: contents.length,
sha256: sha256(contents),
});
}
};
await walk();
if (files.length === 0) {
throw new Error("dist does not contain any files.");
}
files.sort((left, right) => comparePaths(left.path, right.path));
return {
schema_version: 1,
files,
};
}
export async function validateReleaseMetadata(distDirectory, { expectedCommitSha, expectedBuildId }) {
if (typeof expectedCommitSha !== "string" || !COMMIT_SHA_PATTERN.test(expectedCommitSha)) {
throw new Error("RELEASE_COMMIT_SHA must be the full 40-character hexadecimal commit SHA.");
}
if (typeof expectedBuildId !== "string" || expectedBuildId.length === 0) {
throw new Error("RELEASE_BUILD_ID is required.");
}
const root = path.resolve(distDirectory);
const manifest = await readJson(path.join(root, "release-manifest.json"), "release-manifest.json");
const releaseEntry = await readJson(path.join(root, "release-entry.json"), "release-entry.json");
if (manifest.schema_version !== 1) {
throw new Error(`release-manifest.json schema_version must be 1, got ${JSON.stringify(manifest.schema_version)}.`);
}
if (String(manifest.commit_sha || "").toLowerCase() !== expectedCommitSha.toLowerCase()) {
throw new Error(
`release-manifest.json commit_sha ${
manifest.commit_sha || "(missing)"
} does not exactly match ${expectedCommitSha}.`
);
}
if (manifest.build_id !== expectedBuildId) {
throw new Error(
`release-manifest.json build_id ${manifest.build_id || "(missing)"} does not exactly match ${expectedBuildId}.`
);
}
if (typeof releaseEntry.entry !== "string" || releaseEntry.entry.length === 0) {
throw new Error("release-entry.json entry must be a non-empty string.");
}
const entryCss = assertStringArray(releaseEntry.css || [], "release-entry.json css");
const manifestCss = assertStringArray(manifest.css || [], "release-manifest.json css");
if (releaseEntry.entry !== manifest.entry || JSON.stringify(entryCss) !== JSON.stringify(manifestCss)) {
throw new Error("release-entry.json entry/css does not match release-manifest.json.");
}
const assetUrls = assertStringArray(manifest.asset_urls, "release-manifest.json asset_urls");
if (!manifest.asset_hashes || typeof manifest.asset_hashes !== "object" || Array.isArray(manifest.asset_hashes)) {
throw new Error("release-manifest.json asset_hashes must be an object.");
}
const requiredAssets = ["index.html", "release-entry.json", releaseEntry.entry, ...entryCss];
for (const requiredAsset of requiredAssets) {
if (!assetUrls.includes(requiredAsset)) {
throw new Error(`release-manifest.json asset_urls is missing required asset ${requiredAsset}.`);
}
}
for (const assetUrl of assetUrls) {
if (!Object.hasOwn(manifest.asset_hashes, assetUrl)) {
throw new Error(`release-manifest.json asset_hashes is missing ${assetUrl}.`);
}
}
const hashEntries = Object.entries(manifest.asset_hashes).sort(([left], [right]) => comparePaths(left, right));
if (hashEntries.length === 0) {
throw new Error("release-manifest.json asset_hashes must not be empty.");
}
for (const [assetUrl, expected] of hashEntries) {
const { absolutePath } = manifestFilePath(root, assetUrl, `release asset ${assetUrl}`);
const stat = await fsp.lstat(absolutePath).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) {
throw new Error(`release asset is missing or is not a regular file: ${assetUrl}`);
}
if (
!expected ||
typeof expected !== "object" ||
typeof expected.sha256 !== "string" ||
!SHA256_PATTERN.test(expected.sha256) ||
!Number.isSafeInteger(expected.bytes) ||
expected.bytes < 0
) {
throw new Error(`release-manifest.json contains invalid hash metadata for ${assetUrl}.`);
}
const contents = await fsp.readFile(absolutePath);
const actualHash = sha256(contents);
if (contents.length !== expected.bytes || actualHash !== expected.sha256.toLowerCase()) {
throw new Error(
`release asset integrity mismatch for ${assetUrl}: expected ${expected.bytes} bytes/${expected.sha256}, got ${contents.length} bytes/${actualHash}.`
);
}
}
return { manifest, releaseEntry };
}
function assertMatchingInventories(expected, actual, label) {
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${label} does not match the validated dist inventory.`);
}
}
async function validateArchiveEntries(archivePath) {
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
const entries = stdout.split(/\r?\n/).filter(Boolean);
if (entries.length === 0) {
throw new Error("release archive is empty.");
}
for (const archiveEntry of entries) {
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
if (normalizedEntry === "dist") {
continue;
}
if (!normalizedEntry.startsWith("dist/")) {
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
}
assertSafeRelativePath(normalizedEntry, "archive entry");
}
}
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
if (!githubOutput) {
return;
}
const lines = Object.entries(values).map(([key, value]) => {
const normalizedValue = String(value);
if (/\r|\n/.test(normalizedValue)) {
throw new Error(`GitHub output ${key} contains a newline.`);
}
return `${key}=${normalizedValue}`;
});
fs.appendFileSync(githubOutput, `${lines.join("\n")}\n`);
}
export async function createReleaseArchive({
distDirectory = "dist",
outputDirectory = "release-artifacts",
expectedCommitSha,
expectedBuildId,
runId,
runAttempt,
githubOutput,
} = {}) {
if (!/^\d+$/.test(String(runId || "")) || !/^\d+$/.test(String(runAttempt || ""))) {
throw new Error("GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be numeric.");
}
const normalizedCommitSha = String(expectedCommitSha || "").toLowerCase();
const expectedRunBuildId = `${runId}-${runAttempt}`;
if (expectedBuildId !== expectedRunBuildId) {
throw new Error(`RELEASE_BUILD_ID must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT (${expectedRunBuildId}).`);
}
const resolvedDistDirectory = path.resolve(distDirectory);
const resolvedOutputDirectory = path.resolve(outputDirectory);
if (
resolvedOutputDirectory === resolvedDistDirectory ||
resolvedOutputDirectory.startsWith(`${resolvedDistDirectory}${path.sep}`)
) {
throw new Error("RELEASE_OUTPUT_DIR must not be inside dist.");
}
const inventory = await collectDistInventory(resolvedDistDirectory);
for (const requiredPath of [
"dist/.htaccess",
"dist/index.html",
"dist/release-entry.json",
"dist/release-manifest.json",
]) {
if (!inventory.files.some((file) => file.path === requiredPath)) {
throw new Error(`dist is missing required release file: ${requiredPath.slice("dist/".length)}`);
}
}
await validateReleaseMetadata(resolvedDistDirectory, { expectedCommitSha: normalizedCommitSha, expectedBuildId });
const releaseId = `${normalizedCommitSha}-${runId}-${runAttempt}`;
const archiveName = `pleno-vue-${releaseId}.zip`;
const archivePath = path.join(resolvedOutputDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const inventoryPath = path.join(resolvedOutputDirectory, `pleno-vue-${releaseId}.inventory.json`);
await fsp.mkdir(resolvedOutputDirectory, { recursive: true });
for (const outputPath of [archivePath, checksumPath, inventoryPath]) {
if (await fsp.lstat(outputPath).catch(() => null)) {
throw new Error(`refusing to overwrite existing release artifact: ${outputPath}`);
}
}
const temporaryDirectory = await fsp.mkdtemp(path.join(path.dirname(resolvedOutputDirectory), ".pleno-release-"));
try {
const sourceRoot = path.join(temporaryDirectory, "source");
const copiedDistDirectory = path.join(sourceRoot, "dist");
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
const extractionRoot = path.join(temporaryDirectory, "extracted");
await fsp.mkdir(sourceRoot, { recursive: true });
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
const copiedInventory = await collectDistInventory(copiedDistDirectory);
assertMatchingInventories(inventory, copiedInventory, "archive source");
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
cwd: sourceRoot,
maxBuffer: 10 * 1024 * 1024,
});
await validateArchiveEntries(temporaryArchivePath);
await fsp.mkdir(extractionRoot);
await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], {
maxBuffer: 10 * 1024 * 1024,
});
const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist"));
assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive");
const archiveContents = await fsp.readFile(temporaryArchivePath);
const archiveSha256 = sha256(archiveContents);
await fsp.copyFile(temporaryArchivePath, archivePath, fs.constants.COPYFILE_EXCL);
await fsp.writeFile(checksumPath, `${archiveSha256} ${archiveName}\n`, { flag: "wx" });
await fsp.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" });
const outputs = {
build_id: expectedBuildId,
archive_name: archiveName,
archive_path: archivePath,
checksum_path: checksumPath,
inventory_path: inventoryPath,
release_id: releaseId,
archive_sha256: archiveSha256,
};
appendGithubOutputs(outputs, githubOutput);
return { ...outputs, inventory };
} catch (error) {
await Promise.all(
[archivePath, checksumPath, inventoryPath].map((outputPath) => fsp.unlink(outputPath).catch(() => {}))
);
throw error;
} finally {
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function main() {
const result = await createReleaseArchive({
distDirectory: process.env.RELEASE_DIST_DIR || "dist",
outputDirectory: process.env.RELEASE_OUTPUT_DIR || "release-artifacts",
expectedCommitSha: process.env.RELEASE_COMMIT_SHA,
expectedBuildId: process.env.RELEASE_BUILD_ID,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
});
console.log(
`Validated and packaged ${result.inventory.files.length} files as ${result.archive_name} (${result.archive_sha256}).`
);
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exitCode = 1;
});
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-audit}"
FTP_HOST="${PRODUCTION_FTP_HOST:-}"
FTP_USER="${PRODUCTION_FTP_USER:-}"
FTP_PASSWORD="${PRODUCTION_FTP_PASSWORD:-}"
FTP_PATH="${PRODUCTION_FTP_PATH:-}"
WEBROOT="${PRODUCTION_CPANEL_WEBROOT:-public_html}"
FRONTEND_URL="${PRODUCTION_FRONTEND_URL:-https://truckwash.io}"
SOURCE_HTACCESS="${SOURCE_HTACCESS:-public/.htaccess}"
REPORT_DIR="${ROOT_REPAIR_REPORT_DIR:-output/cpanel-root-ftps}"
RUN_ID="${GITHUB_RUN_ID:-local}"
RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}"
if [[ "$MODE" != "audit" && "$MODE" != "repair" ]]; then
echo "Usage: $0 [audit|repair]" >&2
exit 2
fi
if [[ -z "$FTP_HOST" || -z "$FTP_USER" || -z "$FTP_PASSWORD" || -z "$FTP_PATH" ]]; then
echo "Production FTPS credentials and path are required." >&2
exit 2
fi
if [[ "$FTP_USER" == *$'\n'* || "$FTP_USER" == *$'\r'* || "$FTP_PASSWORD" == *$'\n'* || "$FTP_PASSWORD" == *$'\r'* ]]; then
echo "Production FTPS credentials contain unsupported control characters." >&2
exit 2
fi
if [[ ! "$FTP_HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
echo "PRODUCTION_FTP_HOST is invalid." >&2
exit 2
fi
normalized_ftp_path="${FTP_PATH#/}"
normalized_ftp_path="${normalized_ftp_path%/}"
if [[ ! "$normalized_ftp_path" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]; then
echo "PRODUCTION_FTP_PATH must contain only safe path components." >&2
exit 2
fi
if [[ ! "$WEBROOT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "PRODUCTION_CPANEL_WEBROOT must be one safe path component." >&2
exit 2
fi
if [[ ! "$RUN_ID" =~ ^[A-Za-z0-9._-]+$ || ! "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]; then
echo "GitHub run identity is invalid." >&2
exit 2
fi
if [[ ! -f "$SOURCE_HTACCESS" ]] || ! grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$SOURCE_HTACCESS"; then
echo "The reviewed source .htaccess must declare DirectoryIndex index.html." >&2
exit 2
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required." >&2
exit 2
fi
mkdir -p "$REPORT_DIR"
original="$REPORT_DIR/original.htaccess"
remote_part_copy="$REPORT_DIR/uploaded-part.htaccess"
pre_activation_copy="$REPORT_DIR/pre-activation.htaccess"
active_copy="$REPORT_DIR/active.htaccess"
backup_name=".htaccess.before-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
part_name=".htaccess.directory-index-${RUN_ID}-${RUN_ATTEMPT}.part"
failed_name=".htaccess.failed-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
write_connection() {
printf 'set cmd:fail-exit yes\n'
printf 'set cmd:interactive no\n'
printf 'set ftp:ssl-allow yes\n'
printf 'set ftp:ssl-force yes\n'
printf 'set ftp:ssl-protect-data yes\n'
printf 'set ssl:verify-certificate yes\n'
printf 'set ssl:check-hostname yes\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
printf 'open -u %s,%s %s\n' \
"$(lftp_quote "$FTP_USER")" \
"$(lftp_quote "$FTP_PASSWORD")" \
"$(lftp_quote "ftp://$FTP_HOST")"
printf 'cd %s\n' "$(lftp_quote "$FTP_PATH")"
IFS='/' read -r -a path_components <<< "$normalized_ftp_path"
for _component in "${path_components[@]}"; do
printf "cd '..'\n"
done
printf 'cd %s\n' "$(lftp_quote "$WEBROOT")"
}
run_lftp() {
{
write_connection
printf '%s\n' "$@"
printf 'bye\n'
} | lftp -f /dev/stdin
}
write_report() {
local state="$1"
local original_sha source_sha active_sha has_directory_index
original_sha="$(sha256sum "$original" | awk '{print $1}')"
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
active_sha=""
if [[ -f "$active_copy" ]]; then
active_sha="$(sha256sum "$active_copy" | awk '{print $1}')"
fi
has_directory_index=false
if grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$original"; then
has_directory_index=true
fi
REPORT_STATE="$state" \
REPORT_WEBROOT="$WEBROOT" \
REPORT_BACKUP="$backup_name" \
REPORT_ORIGINAL_SHA="$original_sha" \
REPORT_SOURCE_SHA="$source_sha" \
REPORT_ACTIVE_SHA="$active_sha" \
REPORT_HAS_DIRECTORY_INDEX="$has_directory_index" \
node --input-type=module <<'NODE' > "$REPORT_DIR/report.json"
const report = {
state: process.env.REPORT_STATE,
webroot: process.env.REPORT_WEBROOT,
backup: process.env.REPORT_BACKUP,
originalSha256: process.env.REPORT_ORIGINAL_SHA,
expectedSha256: process.env.REPORT_SOURCE_SHA,
activeSha256: process.env.REPORT_ACTIVE_SHA || null,
originalHasDirectoryIndex: process.env.REPORT_HAS_DIRECTORY_INDEX === "true",
};
console.log(`${JSON.stringify(report, null, 2)}\n`);
NODE
}
verify_live() {
VERIFY_BASE_URL="$FRONTEND_URL" VERIFY_RUN_ID="$RUN_ID" node --input-type=module <<'NODE'
const baseUrl = new URL(process.env.VERIFY_BASE_URL);
const checks = ["/", "/index.html", "/release-manifest.json", "/guest/book/wash"];
for (const path of checks) {
const url = new URL(path, baseUrl);
url.searchParams.set("root-repair", process.env.VERIFY_RUN_ID);
const response = await fetch(url, { headers: { Accept: "text/html,application/json" } });
if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}`);
const body = await response.text();
if ((path === "/" || path === "/index.html" || path === "/guest/book/wash") &&
(!body.includes('<div id="app"') || body.includes("Index of /"))) {
throw new Error(`${path} did not render the Vue application shell`);
}
}
console.log("Live root, index, manifest, and deep route verified.");
NODE
}
run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$original")"
write_report "audited"
if [[ "$MODE" == "audit" ]]; then
cat "$REPORT_DIR/report.json"
exit 0
fi
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
original_sha="$(sha256sum "$original" | awk '{print $1}')"
if [[ "$source_sha" == "$original_sha" ]]; then
cp "$original" "$active_copy"
verify_live
write_report "already-current"
cat "$REPORT_DIR/report.json"
exit 0
fi
run_lftp \
"put $(lftp_quote "$SOURCE_HTACCESS") -o $(lftp_quote "$part_name")" \
"get $(lftp_quote "$part_name") -o $(lftp_quote "$remote_part_copy")" \
"get $(lftp_quote '.htaccess') -o $(lftp_quote "$pre_activation_copy")"
if [[ "$(sha256sum "$remote_part_copy" | awk '{print $1}')" != "$source_sha" ]]; then
echo "Uploaded .htaccess staging file failed checksum verification." >&2
exit 1
fi
if [[ "$(sha256sum "$pre_activation_copy" | awk '{print $1}')" != "$original_sha" ]]; then
echo "Live .htaccess changed after audit; refusing to overwrite it." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote '.htaccess') $(lftp_quote "$backup_name")"; then
echo "Could not retain the original .htaccess; no activation was attempted." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote "$part_name") $(lftp_quote '.htaccess')"; then
run_lftp "mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "FTPS activation failed; rollback was attempted." >&2
exit 1
fi
if ! run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$active_copy")"; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Could not verify the active .htaccess; rollback was attempted." >&2
exit 1
fi
if [[ "$(sha256sum "$active_copy" | awk '{print $1}')" != "$source_sha" ]]; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Active .htaccess checksum mismatched; rollback was attempted." >&2
exit 1
fi
if ! verify_live; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Live verification failed; the original .htaccess was restored." >&2
exit 1
fi
write_report "repaired"
cat "$REPORT_DIR/report.json"
+91 -27
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -40,12 +41,15 @@ function pathUrl(baseUrl, assetPath) {
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
async function fetchBytes(url, deadline) {
const configuredTimeout = Math.max(1, numberEnv("RELEASE_FETCH_TIMEOUT_SECONDS", 30)) * 1000;
const remaining = deadline ? Math.max(1, deadline - Date.now()) : configuredTimeout;
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal: AbortSignal.timeout(Math.min(configuredTimeout, remaining)),
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
@@ -55,9 +59,9 @@ async function fetchBytes(url) {
};
}
async function fetchJson(baseUrl, assetPath) {
async function fetchJson(baseUrl, assetPath, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const result = await fetchBytes(url, deadline);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
@@ -72,7 +76,9 @@ async function fetchJson(baseUrl, assetPath) {
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
@@ -96,9 +102,40 @@ function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
function isMutableReleaseFile(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:index\.html|release-(?:entry|manifest)\.json|manifest(?:\.json|\.webmanifest)|registerSW\.js|sw\.js)$/i.test(
normalized
);
}
function isContentAddressedAsset(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:workbox-)?[^/]*[-.][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$/i.test(
normalized
);
}
export function verifyCachePolicy(assetPath, response) {
if (!booleanEnv("RELEASE_REQUIRE_CACHE_HEADERS")) {
return;
}
const cacheControl = response.headers.get("cache-control") || "";
const mutable = isMutableReleaseFile(assetPath);
if (mutable && !/(?:no-store|no-cache|max-age=0)/i.test(cacheControl)) {
throw new Error(
`${assetPath} must be served with a revalidating Cache-Control policy (got ${cacheControl || "missing"})`
);
}
if (!mutable && isContentAddressedAsset(assetPath) && !/immutable/i.test(cacheControl)) {
throw new Error(`${assetPath} must be served with immutable caching (got ${cacheControl || "missing"})`);
}
}
async function verifyAsset(baseUrl, assetPath, expectedHash, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const result = await fetchBytes(url, deadline);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
@@ -110,6 +147,7 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
verifyCachePolicy(assetPath, result.response);
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
@@ -118,8 +156,8 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
async function verifyShell(baseUrl, shellPath, deadline) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath), deadline);
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
@@ -132,23 +170,41 @@ async function verifyShell(baseUrl, shellPath) {
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
if (!containsVueAppRoot(body)) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
export function containsVueAppRoot(body) {
return /<div\b[^>]*\bid=(["'])app\1[^>]*>/i.test(String(body));
}
async function runWithConcurrency(values, concurrency, operation) {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
await operation(values[index]);
}
});
await Promise.all(workers);
}
async function verifyRelease(baseUrl, deadline) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
const manifest = await fetchJson(baseUrl, "release-manifest.json", deadline);
const releaseEntry = await fetchJson(baseUrl, "release-entry.json", deadline);
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
throw new Error(
`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`
);
}
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
@@ -163,12 +219,15 @@ async function verifyRelease(baseUrl) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
const shellPaths = unique(
(process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim())
);
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
await verifyShell(baseUrl, shellPath, deadline);
}
const assetUrls = unique([
"index.html",
"release-manifest.json",
"release-entry.json",
manifest.entry,
@@ -178,12 +237,15 @@ async function verifyRelease(baseUrl) {
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
const concurrency = Math.max(1, Math.min(32, numberEnv("RELEASE_VERIFY_CONCURRENCY", 8)));
await runWithConcurrency(assetUrls, concurrency, async (assetUrl) => {
await verifyAsset(
baseUrl,
assetUrl,
manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`],
deadline
);
});
return {
build_id: manifest.build_id,
@@ -210,7 +272,7 @@ async function main() {
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
const result = await verifyRelease(baseUrl, deadline);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
@@ -226,14 +288,16 @@ async function main() {
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
await sleep(Math.min(pollIntervalSeconds * 1000, Math.max(0, deadline - Date.now())));
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
}
+2 -1
View File
@@ -122,7 +122,8 @@ function normalizePositiveInt(value, fallback) {
}
function stripAnsi(input) {
return input.replace(/\u001b\[[0-9;]*m/g, "");
// eslint-disable-next-line no-control-regex -- ANSI escape sequences intentionally start with ESC.
return input.replace(new RegExp("\\x1b\\[[0-9;]*m", "g"), "");
}
function formatDuration(durationMs) {
-4
View File
@@ -152,10 +152,6 @@ function getArtifactNamespace(suffix) {
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
}
function buildProjectArgs(projects) {
return projects.flatMap((project) => ["--project", project]);
}
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
+4 -3
View File
@@ -38,7 +38,7 @@ const SUSPICIOUS_PATTERNS = [
/ÃÂ/g,
/Ã./g,
/Â(?=\S)/g,
/â€(?:[™œžŸ"“”‘’•–—…])/g,
/â€[™œžŸ"“”‘’•–—…]/g,
/’/g,
/“/g,
/”/g,
@@ -49,7 +49,8 @@ const SUSPICIOUS_PATTERNS = [
];
const REGIONAL_CHAR_REGEX = /[æøåÆØÅäöÄÖüÜßéÉèÈáÁàÀóÓúÚíÍñÑçÇ]/g;
const LATIN_EXTENDED_CHAR_REGEX = /[\u00C0-\u024F]/g;
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
// eslint-disable-next-line no-control-regex -- these are precisely the invalid control characters this scanner detects.
const CONTROL_CHAR_REGEX = new RegExp("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]", "g");
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function countMatches(text, regex) {
@@ -227,7 +228,7 @@ async function collectScanFiles(rootDir) {
const files = [];
async function walk(currentDir) {
let entries = [];
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
+1 -5
View File
@@ -184,8 +184,6 @@ const generateSquareAround = (matches: colorMatch[], canvasId: string) => {
}
combinedCtx.drawImage(canvas, minX, minY, combinedWidth, combinedHeight, 0, 0, combinedWidth, combinedHeight);
// Get the image data of the combined square
const combinedImageData = combinedCtx.getImageData(0, 0, combinedWidth, combinedHeight);
const combinedData = combinedImageData.data;
// Console.log the base64 representation of the combined square
const base64Combined = combinedCanvas.toDataURL();
console.log(`Base64 representation of combined square:`, base64Combined);
@@ -490,8 +488,6 @@ const findLicensePlate = (object: gridObject) => {
location: { x: (i / 4) % canvas.width, y: Math.floor((i / 4) / canvas.width) }
});
// Check if there's black text around the found color
const x = (i / 4) % canvas.width;
const y = Math.floor((i / 4) / canvas.width);
const textFound = data.slice(i - 100, i + 100).some((value, index) => {
if (index % 4 === 0) { // Check only the red channel
return value < 50; // Assuming black text is below a certain threshold
@@ -589,4 +585,4 @@ const findLicensePlate = (object: gridObject) => {
outline: none;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
</style>
</style>
+11 -11
View File
@@ -20,7 +20,7 @@ const props = withDefaults(defineProps<{
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (node: TreeNode) => Promise<TreeNode[]>;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
@@ -41,16 +41,16 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(event: "update:selected", value: any): void;
(event: "update:expandedKeys", value: any[]): void;
(event: "update:checkedKeys", value: any[]): void;
(event: "select", node: TreeNode, key: any): void;
(event: "check", node: TreeNode, key: any, checkedKeys: any[]): void;
(event: "expand", node: TreeNode, key: any): void;
(event: "collapse", node: TreeNode, key: any): void;
(event: "node-click", node: TreeNode, key: any): void;
(event: "load-start", node: TreeNode, key: any): void;
(event: "load-error", error: unknown, node: TreeNode, key: any): void;
"update:selected": [value: any];
"update:expandedKeys": [value: any[]];
"update:checkedKeys": [value: any[]];
select: [node: TreeNode, key: any];
check: [node: TreeNode, key: any, checkedKeys: any[]];
expand: [node: TreeNode, key: any];
collapse: [node: TreeNode, key: any];
"node-click": [node: TreeNode, key: any];
"load-start": [node: TreeNode, key: any];
"load-error": [error: unknown, node: TreeNode, key: any];
}>();
const resolvedFields = computed(() => ({
+1 -3
View File
@@ -16,9 +16,7 @@ defineProps({
},
});
const redirect = (path) => {
window.location = path;
}
</script>
<template>
+8 -10
View File
@@ -315,11 +315,11 @@ const onFieldChange = async (field, value) => {
console.log(field, value);
}
const getFieldName = (field) => {
const _getFieldName = (field) => {
try {
return SessionUser.objects.forms.meta.labels.fields.names[field.name].label;
}
catch (e) {
catch {
return field.name;
}
}
@@ -364,7 +364,7 @@ const getFieldOptions = (field) => {
console.log(result);
return result;
}
catch (e) {
catch {
return {};
}
}
@@ -376,7 +376,7 @@ const getFieldMetadata = (field) => {
...props.form_field_options[field.name] ?? {}
};
}
catch (e) {
catch {
return {};
}
}
@@ -435,7 +435,6 @@ const getForm = () => {
}
// Parse the types
for (const field_index in fields.value) {
const key = field_index;
const field = fields.value[field_index];
const validator = getValidator(field.validation);
//console.log(field, validator);
@@ -459,7 +458,6 @@ const setDefaultValues = () => {
//console.log('setDefaultValues');
//console.log(fields.value);
for (const field_index in fields.value) {
const key = field_index;
const field = fields.value[field_index];
const validator = getValidator(field.validation);
//console.log(field, validator);
@@ -555,7 +553,7 @@ const isFieldSelect = (field) => {
}
const isFieldMultiple = (field) => {
return field.metadata.allow_multiple === true ?? false;
return field.metadata.allow_multiple === true;
}
const doesMultipleFieldContainValue = (field, index) => {
@@ -614,7 +612,7 @@ const debugGetForm = () => {
<!-- Multiple select -->
<template v-if="isFieldMultiple(field)">
<div class="buttons is-multiline">
<template v-for="(option, index) in getFieldOptions(field)">
<template v-for="(option, index) in getFieldOptions(field)" :key="`${field.id}-${index}`">
<div
class="button"
:class="{
@@ -638,7 +636,7 @@ const debugGetForm = () => {
<template v-else>
<select :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)">
<!-- Loop through the options, while creating the select options -->
<template v-for="(option, index) in getFieldOptions(field)">
<template v-for="(option, index) in getFieldOptions(field)" :key="`${field.id}-${index}`">
<option :value="index">{{ option }}</option>
</template>
</select>
@@ -661,7 +659,7 @@ const debugGetForm = () => {
<!-- Text -->
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'text'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" v-bind:disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder">
<!-- Email -->
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'email'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder" v-if="isFieldDisplayable(field)">
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'email' && isFieldDisplayable(field)" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder">
<!-- Checkbox -->
<div class="control" v-else-if="getValidator(field.validation).type === 'checkbox'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.checked)">
<input class="switch" :type="getValidator(field.validation).type" v-bind:required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.checked)" :checked="isCheckboxChecked(field)">
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup>
import ProductsPagination from "@/components/displays/pagination/models/SuperUserDashboard/ProductsPagination.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { showCreateProductForm } from "@/components/forms/superUser/createProductForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import { useI18n } from 'vue-i18n';
@@ -14,7 +14,7 @@ const props = defineProps({
tabs: {
type: Array,
required: true,
default: []
default: () => []
},
/**
* The default active tab (slot name)
@@ -129,13 +129,12 @@ if (props.forceActiveTab !== null && props.forceActiveTab !== undefined) {
>
<ul>
<template
v-if="props.tabs"
v-for="(tab, index) in props.tabs"
:key="index"
>
<li
:class="{ 'is-active': tab.slot === activeTab }"
v-if="isTabVisible(tab)"
v-if="props.tabs && isTabVisible(tab)"
>
<a @click="onClickTab(tab.slot)">
<!-- Display the tab icon (if any) -->
+62 -13
View File
@@ -84,7 +84,7 @@ const showAddonPopper = (addon, element) => {
), element);
};
const hideAddonPopper = (addon) => {
const hideAddonPopper = () => {
removePopperIfOpen();
};
@@ -252,13 +252,26 @@ const orderByOrderPriority = (addons) => {
<template v-else>
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p>
</template>
<span
v-if="isProductRestricted()"
class="tag is-danger is-light is-small mt-1"
:data-testid="`pos-product-restriction-${id}`"
<BTooltip
v-if="isProductRestricted()"
class="pos-product-restriction-tooltip mt-1"
:label="getProductRestrictionMessage()"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
{{ getProductRestrictionMessage() }}
</span>
<span
class="tag is-danger is-light is-small pos-product-restriction-badge"
tabindex="0"
:aria-label="getProductRestrictionMessage()"
:data-testid="`pos-product-restriction-${id}`"
>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span class="pos-product-restriction-badge__label">{{ t('pos.restrictions.unavailable') }}</span>
</span>
</BTooltip>
</div>
</div>
<!-- Price -->
@@ -306,7 +319,7 @@ const orderByOrderPriority = (addons) => {
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id">
<template v-if="!isAddonRestricted(addon)">
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap pos-addon-control-row"
:class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }"
>
<!-- Addon ( + ) -->
@@ -324,7 +337,7 @@ const orderByOrderPriority = (addons) => {
</button>
<!-- quantity (if any), Addon name, price -->
<button
class="button is-small is-fullwidth"
class="button is-small is-fullwidth pos-addon-control-row__name"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
:class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }"
@click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))"
@@ -355,7 +368,7 @@ const orderByOrderPriority = (addons) => {
<!-- Addon is restricted -->
<template v-else>
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap pos-addon-control-row"
:data-testid="`pos-addon-restriction-${getAddonTargetId(addon)}`"
>
<BTooltip
@@ -381,6 +394,7 @@ const orderByOrderPriority = (addons) => {
</span>
</BTooltip>
<BTooltip
class="pos-restriction-tooltip--grow"
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
@@ -394,7 +408,7 @@ const orderByOrderPriority = (addons) => {
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}`"
>
<button
class="button is-small is-fullwidth is-danger is-light"
class="button is-small is-fullwidth is-danger is-light pos-addon-control-row__name"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
disabled
>
@@ -503,9 +517,44 @@ const orderByOrderPriority = (addons) => {
display: inline-flex;
}
.pos-restriction-tooltip-trigger--grow {
flex: 1 1 auto;
.pos-product-restriction-tooltip {
display: block;
min-width: 0;
max-width: 100%;
}
.pos-product-restriction-badge {
display: inline-flex;
min-width: 0;
max-width: 100%;
color: #cc0f35 !important;
}
.pos-product-restriction-badge__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pos-addon-control-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.pos-addon-control-row__name {
min-width: 0;
}
.pos-restriction-tooltip--grow {
min-width: 0;
width: 100%;
}
.pos-restriction-tooltip-trigger--grow {
display: flex;
min-width: 0;
width: 100%;
}
.pos-restriction-tooltip-trigger--grow > .button {
+1 -1
View File
@@ -6,7 +6,7 @@ const isSlotEmpty = () => {
return Object.keys(slots).length === 0;
}
const props = defineProps({
const _props = defineProps({
hasBorder: {
type: Boolean,
default: false
@@ -62,7 +62,7 @@ const props = defineProps({
* - toggle (boolean): Emitted when the card is toggled open or closed.
*/
const emit = defineEmits<{
(e: 'toggle', isOpen: boolean): void;
toggle: [isOpen: boolean];
}>();
const isOpen = ref(props.defaultOpen);
@@ -376,9 +376,7 @@ const updateDropdownLayout = async () => {
const menuHeight = Math.ceil(dropdownContent.value.scrollHeight);
lockDropdownPlacement(triggerRect, menuHeight, viewportInsets);
const availableHeight = getDropdownAvailableHeight(triggerRect, viewportInsets);
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
dropdownMaxHeight.value = nextMaxHeight;
dropdownMaxHeight.value = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
await nextTick();
@@ -564,7 +562,7 @@ const showSetCustomerPassword = (userId) => {
icon: "success",
});
})
.catch((error) => {
.catch(() => {
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_changing_password"),
@@ -1374,7 +1372,7 @@ const downloadAttachment = async (attachment) => {
try {
const downloadLink = await resolveAttachmentDownloadLink(attachment);
downloadAttachmentFile(downloadLink, attachment);
} catch (error) {
} catch {
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
@@ -119,11 +119,11 @@ const getStyle = () => {
return styles[props.template] ?? styles.default;
}
const getButtonClass = () => {
const _getButtonClass = () => {
return getStyle().button;
}
const getIconClass = () => {
const _getIconClass = () => {
return getStyle().icon.icon;
}
@@ -102,11 +102,11 @@ const getStyle = () => {
return styles[props.template] ?? styles.default;
}
const getButtonClass = () => {
const _getButtonClass = () => {
return getStyle().button;
}
const getIconClass = () => {
const _getIconClass = () => {
return getStyle().icon.icon;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted, Component } from 'vue';
import { ref, watch, computed, onMounted } from 'vue';
import '@creativebulma/bulma-divider/dist/bulma-divider.min.css';
const props = defineProps({
label: {
@@ -216,24 +216,8 @@ const label_shortened = computed(() => {
<template v-else-if="error">
<i :class="[error_icon_class, error_class]"></i>
</template>
<!-- Not loading and not hovered -->
<template v-else-if="!is_loading && !is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Not loading and hovered -->
<template v-else-if="!is_loading && is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Loading and hovered -->
<template v-else-if="is_loading && is_currently_hovered">
<i class="fas fa-circle-notch fa-spin"></i>
</template>
<!-- Not loading and hovered -->
<template v-else-if="!is_loading && is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Not loading and not hovered -->
<template v-else-if="!is_loading && !is_currently_hovered">
<!-- Default state -->
<template v-else>
<i :class="[icon_class, color_class]"></i>
</template>
</span>
@@ -360,4 +344,4 @@ const label_shortened = computed(() => {
background-color: var(--button-hover-bg-color, #f5f5f5);
color: var(--button-hover-text-color, #363636);
}
</style>
</style>
@@ -373,7 +373,7 @@ const otherDropdownActions = computed<DropdownAction[]>(() => {
onClick: () => applyShortcut(shortcut),
}));
if (visibility.value.showShortCuts !== false && shortcuts.value.length > 0) {
if (visibility.value.showShortCuts && shortcuts.value.length > 0) {
actions.unshift({
id: "other_month",
label: t("global.text.other_month"),
@@ -33,7 +33,7 @@ const props = defineProps({
// Theme can be 'divided' or 'simple'
theme: {
type: String,
validator(value, props) {
validator(value) {
return ['divided', 'simple'].includes(value);
},
default: () => {
@@ -156,8 +156,8 @@ const onClickLogout = () => {
const addNewBooking = async () => {
// Get the dateTime for now, and add 1 hour
const now = new Date();
const startTime = new Date(now.getTime() + 1 * 60 * 60 * 1000); // Add 1 hour
const endTime = new Date(startTime.getTime() + 1 * 60 * 60 * 1000); // Add 1 hour
const startTime = new Date(now.getTime() + 60 * 60 * 1000); // Add 1 hour
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // Add 1 hour
try {
const eventDetails = {
subject: "New Booking",
@@ -194,4 +194,4 @@ export {
};
</script>
</script>
@@ -62,7 +62,7 @@ const props = defineProps({
},
// Determines the available time slots.
availableTimeSlots: {
type: Object,
type: Array,
required: false,
default: () => [],
}
@@ -76,7 +76,7 @@ const shownDate = ref<Date>(props.shownDate);
// The current locale
const locale = ref<string>(props.locale);
// The first day of the week
const firstDayOfWeek = ref<number>(props.firstDayOfWeek);
// The minimum date that can be selected
const minDate = ref<Date | null>(props.minDate);
// The maximum date that can be selected
@@ -104,11 +104,7 @@ const navigateToMonthRelative = (modifier: Number): void => {
newDate.setMonth(newDate.getMonth() + Number(modifier));
shownDate.value = newDate;
};
const navigateToMonthAbsolute = (month: Number): void => {
const newDate = new Date(shownDate.value);
newDate.setMonth(Number(month));
shownDate.value = newDate;
};
// Date selection function
const selectDate = (date: Date): void => {
// Check if the date is valid
@@ -130,9 +126,7 @@ const monthName = computed<string>(() => {
return shownDate.value.toLocaleString(locale.value, { month: 'long' });
});
// The year of the shown date
const year = computed<number>(() => {
return shownDate.value.getFullYear();
});
// The days in the shown month
const daysInMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + 1, 0).getDate();
@@ -142,9 +136,7 @@ const firstDayOfMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth(), 1).getDay();
});
// The last day of the shown month (0 = Sunday, 1 = Monday, etc.)
const lastDayOfMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + daysInMonth.value - 1).getDay();
});
// Function to get the first character of a string
const getFirstCharacter = (str: string): string => {
return str.charAt(0);
@@ -166,8 +158,6 @@ const getFillerDays = (): number[] => {
// and returns that many days from the next month
const getFillerDaysNextMonth = (): number[] => {
const fillerDays: number[] = [];
const nextMonth = new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + 1, 1);
const daysInNextMonth = new Date(nextMonth.getFullYear(), nextMonth.getMonth() + 1, 0).getDate();
const totalDaysShown = daysInMonth.value + firstDayOfMonth.value;
const daysNeeded = ((7 - (totalDaysShown % 7)) % 7);
// If there's 7 days needed, it means the month ends perfectly
@@ -224,7 +214,7 @@ const needsFillerDaysNextMonth = computed<boolean>(() => {
<thead>
<tr>
<!-- Weekday names -->
<th v-for="(day, index) in 7" :key="index" class="has-text-centered is-narrow px-0">
<th v-for="index in 7" :key="index" class="has-text-centered is-narrow px-0">
{{ getFirstCharacter(new Date(new Date().getFullYear(), 0, index + 1).toLocaleDateString(locale, { weekday: 'short' })) }}
</th>
</tr>
@@ -297,4 +287,4 @@ const needsFillerDaysNextMonth = computed<boolean>(() => {
.row-borderless td, .row-borderless th {
border: none;
}
</style>
</style>
@@ -1,11 +1,11 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps(['bookings']);
import { ref } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page
@@ -19,36 +19,14 @@ import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps
import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue";
import PosDepartmentStepMobile3 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile3.vue";
import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import "@/components/viewport/elements/icons/UnknownCustomer.vue";
import "@/components/viewport/elements/icons/BookedCustomer.vue";
import PosDepartmentStepMobilePopupRenderer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue";
import { applyPosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
const router = useRouter();
const debugVehicles = () => {
pos.vehicles.select(
1,
{
reg: "AB123CD",
status: VerifiedCustomer
}
);
pos.vehicles.select(
2,
{
reg: "EF456GH",
status: UnknownCustomer
}
);
pos.vehicles.select(
3,
{
reg: "IJ789KL",
status: BookedCustomer
}
);
};
const routeStateHandlers = {
setOrderId,
@@ -1,12 +1,6 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import { getDepartment, selectScan } from "@/components/shop/POSDepartmentProcess.vue";
import {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
@@ -23,21 +17,12 @@ import {
import {useElementVisibility} from "@vueuse/core";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {ref, watch, onMounted, useTemplateRef} from 'vue';
import {popperBox, showPopper, showPopperWithContent, removePopperIfOpen} from "@/components/displays/PopperDefault.vue";
import {popperBox, showPopper, removePopperIfOpen} from "@/components/displays/PopperDefault.vue";
import { Colors } from "@/ThemeConfig.vue";
const activeTab = ref(null);
const panel_tabs = ref([
{
name: t('tables.pos.scanned_plates'),
active: true
},
{
name: t('global.search'),
active: false
}
]);
const scansInfinityScroll = ref(null);
const highestPage = ref(0); // The highest page that has been loaded (This is used to prevent loading the same page multiple times)
@@ -60,17 +45,7 @@ const onChanged = () => {
};
/** Whenever the tab changes, update the items per page, and reset the search */
const changeTab = (tabIndex) => {
// Reset the infinite scroll
scansInfinityScroll.value = null;
// Set the highest page to 0
highestPage.value = 0;
search('', false);
// Set the page to 1
setPage(1);
// Load the list
loadList();
};
/** Whenever the search changes, reset the infinite scroll */
watch(metaSearch, () => {
@@ -93,56 +68,9 @@ watch(list, (newList) => {
}
});
const activePopperPlate = ref({
plate: '',
isLoaded: false
});
const tmp = {
"registration_number": "BA95215",
"status": "Registreret",
"status_date": "2016-04-04T14:12:14.000+02:00",
"type": "Stor personbil",
"use": "Busku00f8rsel",
"first_registration": "2016-04-04+02:00",
"vin": "YV3T2U829G1177642",
"own_weight": 15900,
"cerb_weight": 16500,
"total_weight": 24350,
"axels": 3,
"pulling_axels": 1,
"seats": 54,
"coupling": true,
"trailer_maxweight_nobrakes": 0,
"trailer_maxweight_withbrakes": 2800,
"doors": null,
"make": "VOLVO",
"model": "9700",
"variant": "9700HD (9711R 13,83)",
"model_type": "B6SC",
"model_year": 0,
"color": null,
"chassis_type": "",
"engine_cylinders": 6,
"engine_volume": 10837,
"engine_power": 345,
"fuel_type": "Diesel",
"is_hybrid": false,
"hybrid_type": "mild",
"registration_zipcode": "",
"vehicle_id": 9000000001742548,
"mot_info": {
"type": "PeriodiskSyn",
"date": "2024-02-22",
"result": "Godkendt",
"status": "Aktiv",
"status_date": "2024-02-22",
"mileage": 546000
},
"is_leasing": false,
"leasing_from": null,
"leasing_to": null
};
const decodeMotorAPIString = (string) => {
@@ -441,4 +369,4 @@ setTimeout(() => {
.input.has-placeholder-italic::placeholder {
font-style: italic;
}
</style>
</style>
@@ -4,7 +4,7 @@ import RequiresPermission from "@/components/displays/permissionbased/RequiresPe
const props = defineProps(['notes', 'isAddFormVisible', 'isOldNotesVisible', 'isLabelVisible']);
import { ref } from 'vue';
import { addCustomerNote, isCustomerSelected, showDeleteNoteDialog } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -37,7 +37,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import {
customer_name,
customer_name as processCustomerName,
customerRequiresReferenceNumber,
fetchAttachments,
customerUsesPONumbers,
@@ -98,7 +98,7 @@ const titleClasses = computed(() => ({
"is-6": isSidebarVariant.value,
"pos-order-items__title": true,
}));
const displayCustomerName = computed(() => props.customer_name || customer_name.value || "");
const displayCustomerName = computed(() => props.customer_name || processCustomerName.value || "");
const showHeaderActions = computed(() => slots.headerActions !== undefined);
const pendingRemovedOrderItemIds = ref([]);
const selectedOrderItem = ref(null);
@@ -294,7 +294,7 @@ if (props.orderId === -1) {
};
registrationNumbersLoading.value = false;
loadedRegistrationNumbersForOrderId.value = null;
customer_name.value = props.customer_name;
processCustomerName.value = props.customer_name;
watch(
() => props.orderId,
(newOrderId) => {
@@ -6,14 +6,6 @@ import {
customer_attributes,
department_id,
loadCustomerAttributes,
hideDiscountsCatalog,
hidePricesCatalog,
setHidePricesCatalog,
setHideDiscountsCatalog,
getHideDiscountsCatalog,
getHidePricesCatalog,
getUserDiscounts,
user_discounts,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createAttribute, deleteAttribute } from "@/components/shop/CustomerAttributes.vue";
import { computed, ref, watch } from "vue";
@@ -242,11 +234,11 @@ const customer_data_has_empty_details = () => {
</ul>
</div>
</div>
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
<template v-for="detail in details" :key="detail.name">
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
<a
class="panel-block pos-selected-customer__row"
v-if="customer_data[detail.prop] || isCustomerDetailsExpanded"
v-if="panel_tabs[0].active && (customer_data[detail.prop] || isCustomerDetailsExpanded)"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="detail.icon" aria-hidden="true"></i>
@@ -325,8 +317,8 @@ const customer_data_has_empty_details = () => {
</template>
</RequiresPermission>
<!-- Shortcuts -->
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
<RequiresPermission permission="access_super_user">
<template v-for="shortcut in shortcuts" :key="shortcut.name">
<RequiresPermission v-if="panel_tabs[2].active" permission="access_super_user">
<a class="panel-block pos-selected-customer__row">
<span class="panel-icon pos-selected-customer__icon">
<i :class="shortcut.icon" aria-hidden="true"></i>
@@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import { getUserDiscounts, user_discounts, customer_id } from "@/components/shop/POSDepartmentProcess.vue";
import { getUserDiscounts, customer_id } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
// The discounts
@@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
// Will use the product prop to get the product (To avoid multiple API calls)
@@ -11,7 +11,7 @@ const props = defineProps({
// The Customer discounts
customer_discounts: {
type: Array,
default: []
default: () => []
},
});
@@ -110,7 +110,7 @@ const setFixedProductPrice = (fixedPrice) => {
// Debugging function
const getDiscountDebug = () => {
const _getDiscountDebug = () => {
let line = '#####################'
console.log(line);
console.log("Product: ", props.product);
@@ -132,8 +132,8 @@ const parseCustomerDiscounts = () => {
fixed_product_price.value = null;
const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : [];
const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0)
const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1);
const tmp_discount_product = customerDiscounts.find((discount) => Number(discount.product_or_category_id) === Number(props.product.id) && Number(discount.is_category) === 0)
const tmp_discount_category = customerDiscounts.find((discount) => Number(discount.product_or_category_id) === Number(props.product.category) && Number(discount.is_category) === 1);
const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1);
//console.log("Product discount: ", tmp_discount_product);
@@ -5,9 +5,9 @@ import productImage02 from "/src/assets/piktogrammer/02.png";
import productImage03 from "/src/assets/piktogrammer/03.png"; // Replaced by 3
import productImage3 from "/src/assets/piktogrammer/3.png"; // Sættevognstræk
import productImage04 from "/src/assets/piktogrammer/04.png";
import productImage05 from "/src/assets/piktogrammer/05.png"; // Deprecated - use 5
import "/src/assets/piktogrammer/05.png"; // Deprecated - use 5
import productImage5 from "/src/assets/piktogrammer/5.png"; // Forvogn
import productImage06 from "/src/assets/piktogrammer/06.png"; // Deprecated - use 6
import "/src/assets/piktogrammer/06.png"; // Deprecated - use 6
import productImage6 from "/src/assets/piktogrammer/6.png"; // Forvogn med hænger
import productImage07 from "/src/assets/piktogrammer/07.png";
import productImage08 from "/src/assets/piktogrammer/08.png";
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, useSlots, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
@@ -46,7 +46,7 @@ const props = defineProps({
},
});
const slots = useSlots();
const fetchedSummaryOrder = ref(null);
const departmentTabsLoaded = ref(false);
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import "@/components/displays/buttons/EditableTableColumn.vue";
import "@/components/displays/superuser/configuration/ConfigurationInput.vue";
const props = defineProps({
orderItems: {
@@ -56,14 +56,7 @@ const calculateTax = (price) => {
}
return price * (props.taxPercentage / 100);
};
const calculateTotalPrice = (price, quantity) => {
if (!price || !quantity) {
return 0;
}
const totalPrice = price * quantity;
const tax = calculateTax(totalPrice, props.taxPercentage);
return totalPrice + tax;
};
const getOrderItemProductName = (orderItem) => {
return orderItem?.product?.name || t('global.no_data');
@@ -86,7 +79,7 @@ const getOrderItemBasePrice = (orderItem) => {
</tr>
</thead>
<tbody>
<tr v-if="props.isLoading" v-for="n in 5" :key="n">
<tr v-for="n in 5" v-show="props.isLoading" :key="n">
<!-- Skeleton-lines 5 rows -->
<td v-for="n in 5" :key="n">
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -95,7 +88,7 @@ const getOrderItemBasePrice = (orderItem) => {
<tr
v-for="orderItem in props.orderItems"
:key="orderItem.id"
v-else
v-show="!props.isLoading"
:id="`order-item-${orderItem.id}`"
:class="{ 'order-item-highlight': Number(orderItem.id) === Number(props.highlightOrderItemId) }"
>
@@ -56,10 +56,10 @@ const props = defineProps({
},
});
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, onMounted, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { departments, getDepartments, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
@@ -69,7 +69,7 @@ import Swal from "sweetalert2";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import InvoicingBillingPeriodInvoiceProgressBar from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodInvoiceProgressBar.vue";
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import PosDepartmentStepMobileAttachment from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachment.vue";
import "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachment.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
@@ -125,9 +125,7 @@ const isOrderInvoicedWithStripe = (order) => {
return getStripeInvoiceModule(order) !== null;
};
const isOrderInvoiced = (order) => {
return isOrderInvoicedWithEconomic(order) || isOrderInvoicedWithStripe(order);
};
const doesOrderHaveInvoiceCollection = (order) => {
return getInvoiceCollection(order) !== null;
@@ -193,61 +191,9 @@ const getOrderInvoiceStatusBarColor = (order) => {
return color_class;
};
const getOrderInvoiceStatusContent = (order) => {
const content = [];
const economicInvoiceModule = getEconomicInvoiceModule(order);
const stripeInvoiceModule = getStripeInvoiceModule(order);
const invoiceCollection = getInvoiceCollection(order);
if (isOrderInvoiced(order)) {
// If the order is invoiced with e-conomic, show the invoice id
if (isOrderInvoicedWithEconomic(order)) {
isOrderEconomicInvoiceBooked(order)
? content.push("Bogført i e-conomic")
: content.push("Gemt som kladde i e-conomic");
isOrderEconomicInvoiceBooked(order)
? content.push("Faktura ID: <strong>" + (economicInvoiceModule?.invoice_id ?? "-") + "</strong>")
: content.push("Faktura Kladde ID: <strong>" + (economicInvoiceModule?.invoice_draft_id ?? "-") + "</strong>");
}
// If the order is invoiced with stripe, show the invoice id
if (isOrderInvoicedWithStripe(order)) {
isOrderStripeInvoicePaid(order)
? content.push("Fakturaen er <strong>betalt</strong>")
: content.push("Fakturaen er <strong>ikke betalt</strong>");
content.push("Faktura ID: <strong>" + (stripeInvoiceModule?.id ?? "-") + "</strong>");
}
content.push(""); // Add a line break
isOrderInvoicedWithEconomic(order)
? content.push('<span class="has-text-grey">Faktureret med e-conomic</span>')
: content.push('<span class="has-text-grey">Faktureret med stripe</span>');
} else {
// Check if the order has an invoice collection
if (doesOrderHaveInvoiceCollection(order)) {
// Check if the invoice collection is closed
if (isOrderInvoiceCollectionClosed(order)) {
content.push("Faktura samlingen er <strong>lukket</strong>");
// Check if the invoice collection is booked
if (isOrderInvoiceCollectionBooked(order)) {
content.push("Faktura samlingen er <strong>bogført</strong>");
}
}
content.push(""); // Add a line break
content.push("Faktura samling ID: <strong>" + (invoiceCollection?.id ?? "-") + "</strong>");
// Add the Error message if it exists
if (doesOrderHaveErrorMessage(order)) {
content.push('Fejl: <strong class="has-text-danger">' + order.error_message + "</strong>");
}
} else {
content.push(""); // Add a line break
content.push('<strong class="has-text-grey">Transaktionen er ikke faktureret</strong>');
}
}
// Return the list of content as a string, with line breaks between each item
return content.join("<br>");
};
const getOrderInvoiceStatusTitle = (order) => {
return isOrderInvoiced(order) ? "Faktura status" : "Transaktion status";
};
const clickUser = (customer_id) => {
if (!SessionUser.canAccessSuperUser()) {
@@ -394,7 +340,7 @@ const tableHeaders = ref([
{
name: "po",
title: SessionUser.objects.orders.columns.po.label,
visible: (order) => {
visible: () => {
return canSeePoField() || SessionUser.canAccessAdmin();
},
},
@@ -1258,7 +1204,7 @@ const formatCashierName = (order) => {
{{
SessionUser.functions.currency.toLocal(
getOrdersByInvoiceCollection(order.invoice_collection_id)
.reduce((sum, order) => sum + order.total_net_amount, 0)
.reduce((sum, invoiceOrder) => sum + invoiceOrder.total_net_amount, 0)
.toFixed(2)
)
}}
@@ -1492,7 +1438,7 @@ const formatCashierName = (order) => {
column="reg_1"
:parse-function="(value) => value || t('global.no_data')"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permissionCheckFunction="(orderobj) => canEditTransaction(order)"
:permissionCheckFunction="() => canEditTransaction(order)"
/>
</p>
<p v-if="order.reg_2 || order.reg_3" class="pos-order-vehicle-registration-line">
@@ -1504,7 +1450,7 @@ const formatCashierName = (order) => {
column="reg_2"
:parse-function="(value) => value || '-'"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditTransaction(order)"
:permissionCheckFunction="() => canEditTransaction(order)"
/>
<span v-if="order.reg_2 && order.reg_3" class="pos-order-vehicle-registration-separator">/</span>
<EditableTableColumn
@@ -1514,7 +1460,7 @@ const formatCashierName = (order) => {
column="reg_3"
:parse-function="(value) => value || '-'"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditTransaction(order)"
:permissionCheckFunction="() => canEditTransaction(order)"
/>
</p>
</td>
@@ -1885,7 +1831,7 @@ const formatCashierName = (order) => {
>{{
SessionUser.functions.currency.toLocal(
getOrdersByInvoiceCollection(order.invoice_collection_id)
.reduce((sum, order) => sum + order.total_net_amount, 0)
.reduce((sum, invoiceOrder) => sum + invoiceOrder.total_net_amount, 0)
.toFixed(2)
)
}}
@@ -33,11 +33,6 @@ import PosDesktopDuplicateWarning from "@/components/displays/department/pos/ste
import { todayLocalDateOnly } from "@/services/dateOnly.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
getOrderBookingReferenceValue,
getOrderBookingServiceText,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const { t, locale } = useI18n();
const routeState = parsePosRouteSearch(window.location.search);
@@ -144,7 +139,7 @@ const getDepartmentVersion = () => {
return POS_STEP_1_VERSION;
};
const department_version = ref(getDepartmentVersion());
onMounted(() => {
focusOnReg1();
@@ -232,22 +227,7 @@ watch(
{ immediate: true }
);
const formatBookingDateTime = (booking) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const formatDuplicateOrderDate = (value) => {
if (!value) {
@@ -273,34 +253,7 @@ const getDuplicateOrderContent = (order) => {
].join(" • ");
};
const bookingSelectionObjects = computed(() => {
return desktopStep1Context.value.bookingMatches.map((booking) => {
const plateText = [booking?.reg_1, booking?.reg_2].filter(Boolean).join(" / ");
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
];
return {
id: booking.id,
label: t("admin.pos.order_booking_selector.option_title", {
id: booking.id,
datetime: formatBookingDateTime(booking),
}),
content: contentSegments.join(" • "),
buttons: [
{
label: t("common.select"),
action: () => handleBookingSelection(booking),
color: "primary",
testId: `pos-desktop-order-booking-use-${booking.id}`,
},
],
};
});
});
const duplicateDetailsObjects = computed(() => {
return duplicateOrders.value.map((order) => ({
@@ -408,7 +361,7 @@ const fetchDuplicateOrdersForContext = async (context) => {
}
};
const ensureDuplicateWarningState = async (context, options = {}) => {
const ensureDuplicateWarningState = async (context) => {
const normalizedContext = normalizeDuplicateContext(context);
if (!normalizedContext.reg1) {
duplicateOrders.value = [];
@@ -230,11 +230,7 @@ const fetchBookingDetails = async (bookings) => {
return false;
}
if (loadingBookingIds.value.includes(bookingId)) {
return false;
}
return true;
return !loadingBookingIds.value.includes(bookingId);
});
if (bookingsToLoad.length === 0) {
@@ -141,7 +141,7 @@ const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
const quick = getLPRFrameFingerprint(image);
const getContentFingerprint = getLPRFrameContentFingerprint(image);
const entry: ParsedFrameFingerprint = {
lastParsedImage.value = {
content: getContentFingerprint === null ? quick : null,
contentFingerprintPromise: null,
getContentFingerprint,
@@ -151,7 +151,6 @@ const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
visual: getLPRFrameVisualFingerprint(image),
};
lastParsedImage.value = entry;
};
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
@@ -41,7 +41,6 @@ import {
customer_id,
customer_attributes,
customer_attributes_status,
getCustomerEmail,
customer_name,
getAddonRestriction,
getProductRestriction,
@@ -429,7 +428,7 @@ const fetchPrimaryItemProduct = () => {
}
transactionItems.setPrimaryItem(product);
})
.catch((error) => (vehicleSelection.value = true)); // If there's an error, force the user to select a product.
.catch(() => (vehicleSelection.value = true)); // If there's an error, force the user to select a product.
};
const fetchLastOrder = (vehicleIndex: number) => {
@@ -1081,7 +1080,12 @@ const syncCurrentTransactionToOrder = async () => {
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({ kind, relatedKey, skip_price_override, ...shape }) => shape);
const comparableDesiredShapes = desiredShapes.map(({
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skipPriceOverride,
...shape
}) => shape);
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import Swal from 'sweetalert2';
const props = defineProps({
results: {
@@ -14,7 +14,7 @@ const props = withDefaults(defineProps<{
});
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
"location-updated": [coords: { latitude: number | null; longitude: number | null }];
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
@@ -53,16 +53,7 @@ const additionalSelectionLoading = ref(false);
const canSelectAdditionalItems = computed(() => true);
const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order
function generateSummary(order: PosOrder): string {
if (!order || !order.items || order.items.length === 0) {
return "Denne ordre har ingen varer.";
}
const itemNames = order.items.map((item) => item?.product?.name || "Ukendt vare");
const uniqueItems = Array.from(new Set(itemNames));
return uniqueItems.length > 1
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
: uniqueItems[0];
}
// Functions to determine the display values
const displayLabel = computed(() => {
@@ -70,9 +61,7 @@ const displayLabel = computed(() => {
? `Vasket d. ${SessionUser.functions.date.toLocal(new Date(props?.lastOrder?.created_at))}`
: props.label;
});
const displaySubtitle = computed(() => {
return props.lastOrder ? generateSummary(props.lastOrder) : props.subtitle;
});
// Emit event on toggle
function onToggle(isOpen: boolean) {
checked.value = isOpen;
@@ -83,7 +72,7 @@ function onToggle(isOpen: boolean) {
}
/** Additional item management logic */
const exampleProducts = ref<PosProduct[]>([
const _exampleProducts = ref<PosProduct[]>([
{
id: 1,
name: "Extra Towel",
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from "vue";
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { Addon } from "../objects/PosAddon.vue";
import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import { useI18n } from "vue-i18n";
@@ -12,8 +12,8 @@ const { t } = useI18n();
const props = defineProps({
label: {
type: String,
default: "Add-ons",
required: true,
default: null,
required: false,
},
addons: {
type: Array as () => Addon[],
@@ -48,6 +48,8 @@ const getLabel = (addon: Addon) => {
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
};
const getSectionLabel = () => props.label === null ? t("common.addons") : props.label;
/**
* When double-clicking on the product, it will add 1 to the quantity
*/
@@ -147,7 +149,7 @@ const getAddonRestrictionMessage = (addon: Addon) => {
<template>
<template v-if="addons.length > 0">
<!-- If there are add-ons available -->
<p class="custom-label">{{ props.label }}</p>
<p v-if="getSectionLabel()" class="custom-label">{{ getSectionLabel() }}</p>
<div>
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
<PosDepartmentStepMobile2CategoryProduct
@@ -159,6 +161,7 @@ const getAddonRestrictionMessage = (addon: Addon) => {
:showPrices="props.showPrices"
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
:selected="Number(addon.quantity ?? addon.product?.quantity ?? 0) > 0"
@addProduct="onAddProduct(addon)"
:customButton="true"
>
@@ -26,6 +26,10 @@ const props = defineProps({
default: false,
required: false
},
selected: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
@@ -85,9 +89,13 @@ const imageStyleCompact = {
<div
style="border-bottom: 1px solid #e5e5e5; padding: 10px 0"
class="mb-1"
:class="{ 'pos-mobile-category-product--disabled': props.disabled }"
:class="{
'pos-mobile-category-product--disabled': props.disabled,
'pos-mobile-category-product--selected': props.selected && !props.disabled,
}"
:data-selected="props.selected && !props.disabled ? 'true' : 'false'"
>
<div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
<div class="columns is-vcentered is-mobile" :class="{ 'has-quantity': props.selected && !props.disabled }">
<div class="column is-narrow">
<button
type="button"
@@ -108,6 +116,7 @@ const imageStyleCompact = {
<div class="column pos-mobile-category-product__name-column">
<BTooltip
v-if="props.disabled && props.restrictionMessage"
class="pos-mobile-category-product__name-tooltip"
:label="props.restrictionMessage"
:triggers="['hover', 'focus', 'click']"
multilined
@@ -225,6 +234,12 @@ const imageStyleCompact = {
cursor: not-allowed;
}
.pos-mobile-category-product__name-tooltip {
display: flex;
min-width: 0;
width: 100%;
}
.pos-mobile-category-product__name-column,
.pos-mobile-category-product__name-button,
.pos-mobile-category-product__tooltip-trigger {
@@ -1,25 +1,17 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { PosOrder } from "../objects/PosOrder.vue";
import { computed } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import {
transactionItems
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep2MobileVehicleSelection
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { order_id } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
@@ -43,7 +35,7 @@ const displayLabel = computed(() => {
return props.label;
})
const displaySubtitle = computed(() => {
const _displaySubtitle = computed(() => {
return props.subtitle;
})
@@ -75,4 +67,4 @@ const onClick = () => {
</div>
</template>
<style scoped>
</style>
</style>
@@ -82,8 +82,8 @@ const itemNames = computed(() => {
</div>
</div>
<!-- Product addons -->
<template v-for="addon in item.addons" :key="addon.id" v-if="item.addons && item.addons.length > 0 && isAddonsVisible">
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0 && addon?.restricted !== true">
<template v-for="addon in item.addons" :key="addon.id">
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="item.addons && item.addons.length > 0 && isAddonsVisible && addon?.quantity > 0 && addon?.restricted !== true">
<div class="column"><!-- Product addons -->
<p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p>
</div>
@@ -31,7 +31,7 @@ const props = defineProps({
});
const emit = defineEmits<{
(e: "copy"): void;
copy: [];
}>();
const orderItems = computed(() => (Array.isArray(props.lastOrder?.items) ? props.lastOrder.items : []));
@@ -47,11 +47,11 @@ const props = defineProps({
});
const emit = defineEmits<{
(e: "open-additional-items"): void;
"open-additional-items": [];
}>();
const productName = computed(() => props?.product?.name || "");
const productPrice = computed(() => SessionUser.functions.currency.toLocal(props?.product?.price || 0));
const productImage = computed(() => getPicture(props?.product?.piktogram || props?.product?.id || ""));
const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle").toLowerCase()}`);
const productChangeHint = computed(() => t("pos.hold_to_change_vehicle"));
@@ -35,7 +35,7 @@ const props = defineProps({
default: "primary",
},
});
const slots = defineSlots();
/** As Addons **/
const mergedAddons = ref<Addon[]>([]);
@@ -81,10 +81,9 @@ watch(
);
const getProductRestrictionForRow = (product: PosProduct) => {
const restriction = getProductRestriction(product, props.restrictionContext === "standaloneAdditionalService"
return getProductRestriction(product, props.restrictionContext === "standaloneAdditionalService"
? { isStandaloneAdditionalService: true }
: {});
return restriction;
};
const getProductRestrictionMessage = (product: PosProduct) => {
@@ -5,10 +5,10 @@ import PosDepartmentStepMobile1RegistrationNumber2
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
import PosDepartmentStepMobile1RegistrationNumber3
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
import { reg_1, reg_2, reg_3, loadOrderDetails, department_id } from "@/components/shop/POSDepartmentProcess.vue";
import { reg_1, reg_2, reg_3 } from "@/components/shop/POSDepartmentProcess.vue";
import { pos, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import { order_id } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const layout = {
classes: <string[]>["m-0", "p-0"],
@@ -16,7 +16,7 @@ const layout = {
}
/** Click handler for the registration number component */
const onClick = (field: string, value: string) => {
const onClick = (field: string) => {
// Get the vehicle number (after reg_*)
const vehicle_number = parseInt(field.split('_')[1]);
console.warn("Vehicle number", vehicle_number);
@@ -34,14 +34,7 @@ const onClick = (field: string, value: string) => {
* 'card' => $cardPaymentRequired,
* 'unknown' => !$customer_number && !$isKnown && !$isBooked
*/
const parseVehicleStatus = (status: any) => {
if (status.booked) return 'booked';
if (status.card) return 'card';
if (status.verified) return 'verified';
if (status.unknown) return 'unknown';
if (status.known) return 'known';
return 'unknown';
};
</script>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -65,7 +65,7 @@ const props = defineProps({
required: true
},
getPreviewLink: {
type: Function as () => (id: number) => Promise<string>,
type: Function as () => (_id: number) => Promise<string>,
required: true
},
showAttachWashCertificateAction: {
@@ -74,10 +74,10 @@ const props = defineProps({
}
})
const emits = defineEmits<{
(e: 'delete', id: number): void;
(e: 'upload', payload: { filename: string; base64String: string | ArrayBuffer | null }): void;
(e: 'download', id: number): void;
(e: 'attach-wash-certificate'): void;
delete: [id: number];
upload: [payload: { filename: string; base64String: string | ArrayBuffer | null }];
download: [id: number];
"attach-wash-certificate": [];
}>();
const onClickDelete = (id: number) => {
@@ -407,4 +407,3 @@ const onClickAttachWashCertificate = () => {
<style scoped>
</style>
@@ -3,7 +3,7 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { reference } from "@/components/shop/POSDepartmentProcess.vue";
import {
metadata,
camera,
attachments,
transactionHistory,
@@ -72,7 +72,7 @@ const props = defineProps({
},
});
/** Emits */
const emits = defineEmits([]); // No emits for now
// No emits for now
/** State */
const isAttachmentSelectorVisible = ref(false); // When enabled, the attach and reference buttons are hidden, and the attachment selector is shown
/** Reference */
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref } from "vue";
import { reference } from "@/components/shop/POSDepartmentProcess.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { metadata, camera, attachments } from "../objects/PosDepartmentStepMobileFlow.vue";
import Swal from "sweetalert2";
import "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { attachments } from "../objects/PosDepartmentStepMobileFlow.vue";
import "sweetalert2";
import {popups} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
</script>
@@ -74,4 +74,4 @@ import {popups} from "@/components/displays/department/pos/steps/mobile/objects/
/**border-bottom: 1px solid #ddd; **/
white-space: nowrap;
}
</style>
</style>
@@ -427,7 +427,7 @@ const onClick = async () => {
}
// Proceed to the next step
switch (step.value) {
case 1:
case 1: {
if (!(await step1())) {
return;
}
@@ -453,6 +453,7 @@ const onClick = async () => {
bookingId: metadata.getBookingId?.() ?? null,
});
break;
}
case 2:
if (!(await step1())) {
return;
@@ -9,7 +9,7 @@ const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
const { t } = useI18n();
const emit = defineEmits<{
(e: "close"): void;
close: [];
}>();
const {

Some files were not shown because too many files have changed in this diff Show More