Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfbf80f327 | ||
|
|
96958c53a3 | ||
|
|
13509b68f2 | ||
|
|
f689409990 | ||
|
|
c69f4f7fc7 | ||
|
|
10f993e686 | ||
|
|
a02ddfbfbc | ||
|
|
7a85c93631 | ||
|
|
a3d11cfca3 | ||
|
|
74279fc443 | ||
|
|
7e3a72961f | ||
|
|
ca2179352c | ||
|
|
8ddac065c6 | ||
|
|
a930bd35a5 | ||
|
|
6b8f3ff806 | ||
|
|
3323f392e3 | ||
|
|
f8f2b80641 | ||
|
|
1729443cc3 | ||
|
|
6b5ac8a8b3 | ||
|
|
b12aca2757 | ||
|
|
39cc3a780a |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -16,9 +16,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const redirect = (path) => {
|
||||
window.location = path;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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,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) -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||