Initial docs, ported scripts from picknplay
This commit is contained in:
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Agent Guide
|
||||
|
||||
Also see the other related technical documentation: TECH.md and possibly other markdown files.
|
||||
|
||||
## Rules
|
||||
|
||||
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
|
||||
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
|
||||
- web.config in the server is different than locally, it must be exluded from deployment.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
|
||||
173
TECH.md
Normal file
173
TECH.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# TECH - Kickoff Blueprint
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
- ASP.NET Core Minimal API on .NET 10.
|
||||
- EF Core + Database in current project (single-node deployment).
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- A minimal frontend framework supporting mixing 3D graphics with 2D elements, or a modern framework-less alternative (HTMl/CSS/TypeScript).
|
||||
- OpenAPI generated from backend and consumed by generated client.
|
||||
- xUnit integration-heavy test suite with in-memory Database and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
|
||||
### 2.1 API shape and layering
|
||||
|
||||
- Route mapping in thin endpoint modules (`MapXEndpoints` per feature area).
|
||||
- Domain logic in workflow services (`*WorkflowService`) instead of endpoint lambdas.
|
||||
- Service responses normalized via `ServiceResult<T>` + `ServiceError`, then mapped to HTTP at the edge.
|
||||
- Consistent `ProblemDetails` payloads with `error` extension for machine-usable errors.
|
||||
- Endpoint-level concerns handled by endpoint filters (`AdminOnlyFilter`, `PhaseRequirementFilter`, `PhaseOrJokerFilter`).
|
||||
|
||||
Keep this split:
|
||||
- Endpoint adapters: auth, deserialization, HTTP mapping only.
|
||||
- Workflow services: validation, query/update rules, transactions.
|
||||
- Helpers: shared utility and security-sensitive routines.
|
||||
|
||||
### 2.2 Middleware pipeline discipline
|
||||
|
||||
- Security and behavior depend on middleware ordering; keep explicit ordering.
|
||||
- Important current order:
|
||||
1. Forwarded headers
|
||||
2. Rate limiter
|
||||
3. HSTS + HTTPS redirect (prod)
|
||||
4. Security headers writer
|
||||
5. Base path
|
||||
6. Global exception handling
|
||||
7. Authentication
|
||||
8. Ensure player still exists
|
||||
9. CSRF origin/referer checks
|
||||
10. Authorization
|
||||
11. State change notifier middleware
|
||||
12. Static files
|
||||
13. Endpoint mapping
|
||||
|
||||
### 2.3 State synchronization
|
||||
|
||||
- Event-driven invalidation with SSE (`/api/events/state`) plus heartbeats.
|
||||
- Conditional reads for state (`ETag` + `If-None-Match`) to return `304`.
|
||||
- In-process notifier (`StateChangeNotifier`) with monotonic version and etag stamp.
|
||||
- Mutation middleware (`StateChangeNotificationMiddleware`) emits invalidation only for successful mutating API calls.
|
||||
|
||||
This pattern is a strong baseline for low to medium scale and should be the default in the new app.
|
||||
|
||||
### 2.4 Security baseline
|
||||
|
||||
- Cookie auth with short/medium session sliding expiration plus absolute lifetime cap.
|
||||
- Explicit same-origin CSRF checks for authenticated mutating API calls.
|
||||
- Rate limiting on auth-sensitive and admin-sensitive surfaces with custom `429` payload.
|
||||
- Security headers on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- Forwarded headers restricted to configured trusted proxies/networks only.
|
||||
- Owner/admin protection rules enforced in business logic and DB constraints.
|
||||
- Destructive admin operations require password re-confirmation.
|
||||
- Password hashing is versioned and supports transparent upgrade on successful auth.
|
||||
- Current hash defaults to Argon2id, with forward compatibility retained via versioning.
|
||||
|
||||
### 2.5 Data and invariants
|
||||
|
||||
- Strong DB models
|
||||
- DB-level guardrails (trigger) to complement app-level checks
|
||||
- EF patterns:
|
||||
- `AsNoTracking()` for read-only queries
|
||||
- `ExecuteUpdateAsync` / `ExecuteDeleteAsync` for efficient bulk operations
|
||||
- Explicit transactions for multi-step destructive/admin operations
|
||||
- Conflict handling around unique constraints
|
||||
|
||||
### 2.6 Frontend architecture
|
||||
|
||||
- modules split by concern:
|
||||
- API wrapper
|
||||
- Data loaders
|
||||
- UI composition
|
||||
- Feature-specific renderers/handlers
|
||||
- Shared utils and runtime dependency injection
|
||||
- Single runtime state object with deliberate clear/reset logic.
|
||||
- Refresh scheduler:
|
||||
- Serialized refreshes (no overlap)
|
||||
- Adaptive polling backoff
|
||||
- SSE-triggered immediate refresh for state mutations
|
||||
- Visibility-aware refresh suppression
|
||||
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
|
||||
- Internationalization:
|
||||
- translation file validation at startup
|
||||
- language-specific FAQ markdown loading with fallback to default language
|
||||
|
||||
### 2.7 Testing strategy patterns
|
||||
|
||||
- Full-stack integration tests via `WebApplicationFactory`.
|
||||
- Real migrations applied to in-memory Database during test host startup.
|
||||
- HTTP side effects mocked deterministically (`StubHttpMessageHandler` and `IHttpClientFactory` replacement).
|
||||
- Coverage-focused tests for:
|
||||
- auth/security rules
|
||||
- middleware behavior
|
||||
- filter behavior
|
||||
- link/vote/result edge cases
|
||||
- OpenAPI operation id stability
|
||||
- CI-local parity script (`scripts/ci-local.ps1`) mirrors pipeline flow.
|
||||
|
||||
### 2.8 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/RpgRoller.json`).
|
||||
- Client generated from OpenAPI with required operation-id checks.
|
||||
- Separate lint + format + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set
|
||||
|
||||
Use this as a reusable "starter scope menu" for the new app:
|
||||
|
||||
- Auth:
|
||||
- register/login/logout
|
||||
- owner bootstrap via admin key
|
||||
- auth options endpoint for registration UX
|
||||
- Identity/session:
|
||||
- cookie claim identity with admin claim
|
||||
- stale/deleted-account cookie invalidation
|
||||
- absolute session lifetime enforcement
|
||||
- State:
|
||||
- `/api/state`, `/api/me`
|
||||
- SSE state invalidation
|
||||
- etag conditional state reads
|
||||
|
||||
## 4) New-project starter checklist
|
||||
|
||||
- Security:
|
||||
- cookie or token strategy finalized with CSRF model
|
||||
- rate limiting partitions and thresholds defined
|
||||
- strict CSP and security headers in first commit
|
||||
- versioned password hashing with migration strategy
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- generated client wired into frontend
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
- transaction boundaries for multi-entity admin actions
|
||||
- Frontend:
|
||||
- module boundaries and state refresh model defined
|
||||
- escaping/url-safe helpers mandatory
|
||||
- i18n structure and fallback behavior in place
|
||||
- Testing:
|
||||
- integration test host with real migrations
|
||||
- deterministic stubs for network dependencies
|
||||
- coverage gate enforced in local + CI scripts
|
||||
|
||||
## 7) Keep/avoid quick reference
|
||||
|
||||
Keep:
|
||||
- Thin endpoints + workflow services.
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- Generated API client from OpenAPI.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
Avoid:
|
||||
- Hard-coded workflow transitions scattered in backend/frontend.
|
||||
- Boolean-only role model for long-term products.
|
||||
- Unbounded in-memory caches.
|
||||
- Synchronous external network checks on hot write paths.
|
||||
- Manual API contract duplication between docs/frontend/backend.
|
||||
|
||||
17
deploy.ps1
Normal file
17
deploy.ps1
Normal file
@@ -0,0 +1,17 @@
|
||||
param(
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
|
||||
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
|
||||
|
||||
& $scriptPath `
|
||||
-ProfilePath $profilePath `
|
||||
-Password $Password `
|
||||
-SkipRecycle:$SkipRecycle `
|
||||
-SkipMigrations:$SkipMigrations
|
||||
43
scripts/check-coverage.ps1
Normal file
43
scripts/check-coverage.ps1
Normal file
@@ -0,0 +1,43 @@
|
||||
param(
|
||||
[double]$MinLineRate = 0.90,
|
||||
[double]$MinBranchRate = 0.70,
|
||||
[string]$ResultsRoot = "RpgRoller.Tests/TestResults"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $ResultsRoot)) {
|
||||
throw "Coverage results folder not found: $ResultsRoot"
|
||||
}
|
||||
|
||||
$coverageFile = Get-ChildItem -Path $ResultsRoot -Recurse -Filter "coverage.cobertura.xml" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($null -eq $coverageFile) {
|
||||
throw "No coverage.cobertura.xml found under $ResultsRoot"
|
||||
}
|
||||
|
||||
[xml]$xml = Get-Content -Path $coverageFile.FullName
|
||||
$coverage = $xml.coverage
|
||||
|
||||
if ($null -eq $coverage) {
|
||||
throw "Coverage XML is missing root coverage node: $($coverageFile.FullName)"
|
||||
}
|
||||
|
||||
[double]$lineRate = [double]$coverage.'line-rate'
|
||||
[double]$branchRate = [double]$coverage.'branch-rate'
|
||||
|
||||
$linePercent = [Math]::Round($lineRate * 100, 2)
|
||||
$branchPercent = [Math]::Round($branchRate * 100, 2)
|
||||
$minLinePercent = [Math]::Round($MinLineRate * 100, 2)
|
||||
$minBranchPercent = [Math]::Round($MinBranchRate * 100, 2)
|
||||
|
||||
Write-Host "Coverage source: $($coverageFile.FullName)"
|
||||
Write-Host ("Line coverage: {0}% (required >= {1}%)" -f $linePercent, $minLinePercent)
|
||||
Write-Host ("Branch coverage: {0}% (required >= {1}%)" -f $branchPercent, $minBranchPercent)
|
||||
|
||||
if ($lineRate -lt $MinLineRate -or $branchRate -lt $MinBranchRate) {
|
||||
throw "Coverage thresholds failed."
|
||||
}
|
||||
75
scripts/ci-local.ps1
Normal file
75
scripts/ci-local.ps1
Normal file
@@ -0,0 +1,75 @@
|
||||
param(
|
||||
[switch]$SkipNpmInstall,
|
||||
[switch]$SkipDotnetRestore,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
|
||||
Write-Host "==> $Name"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Step failed: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if (-not $SkipNpmInstall) {
|
||||
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
|
||||
npm install
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipDotnetRestore) {
|
||||
Invoke-Step -Name "Restore .NET solution" -Action {
|
||||
dotnet restore RpgRoller.sln
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Invoke-Step -Name "Build .NET solution (warnings as errors)" -Action {
|
||||
dotnet build RpgRoller.sln --no-restore -warnaserror
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
|
||||
npm run generate:api-client
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Run tests" -Action {
|
||||
if ($SkipBuild) {
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
}
|
||||
else {
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Enforce coverage thresholds" -Action {
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
}
|
||||
|
||||
Write-Host "CI checks passed."
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
23
scripts/deploy-ftp.profile.psd1
Normal file
23
scripts/deploy-ftp.profile.psd1
Normal file
@@ -0,0 +1,23 @@
|
||||
@{
|
||||
ProjectPath = "..\RpgRoller\RpgRoller.csproj"
|
||||
Configuration = "Release"
|
||||
Runtime = "win-x64"
|
||||
PublishDir = "%TEMP%\RpgRoller-publish"
|
||||
SelfContained = $false
|
||||
|
||||
WinScpPath = "C:\Users\frank\AppData\Local\Programs\WinSCP\WinSCP.com"
|
||||
RemoteDir = "/httpdocs/rpgroller"
|
||||
BasePath = "/rpgroller"
|
||||
FtpHost = "xTr1m.com"
|
||||
FtpUser = "xTr1m"
|
||||
|
||||
RecycleAppPool = $true
|
||||
AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
|
||||
WinRmComputer = "xTr1m.com"
|
||||
WinRmCredentialUser = "Administrator"
|
||||
UseWinRmHttps = $true
|
||||
WinRmAuth = "Basic"
|
||||
|
||||
RunEfMigrations = $false
|
||||
RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\rpgroller"
|
||||
}
|
||||
342
scripts/deploy-ftp.ps1
Normal file
342
scripts/deploy-ftp.ps1
Normal file
@@ -0,0 +1,342 @@
|
||||
param(
|
||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Publish the app and mirror output to an FTP-deployed IIS site.
|
||||
|
||||
.DESCRIPTION
|
||||
- Reads environment-specific settings from a PowerShell data file profile.
|
||||
- Builds with dotnet publish.
|
||||
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
|
||||
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-Tool {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "Required tool '$Name' not found. Install it or update your deploy profile."
|
||||
}
|
||||
}
|
||||
|
||||
function Require-ConfigValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][hashtable]$Config,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
|
||||
throw "Missing required deploy profile value '$Key'."
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ProfilePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BaseDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$PathValue
|
||||
)
|
||||
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
|
||||
if ([System.IO.Path]::IsPathRooted($expanded)) {
|
||||
return $expanded
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
|
||||
}
|
||||
|
||||
function Normalize-BasePath {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$normalized = $Value.Trim()
|
||||
if (-not $normalized.StartsWith("/")) {
|
||||
$normalized = "/$normalized"
|
||||
}
|
||||
|
||||
if ($normalized.Length -gt 1) {
|
||||
$normalized = $normalized.TrimEnd("/")
|
||||
}
|
||||
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Infer-BasePathFromRemoteDir {
|
||||
param([string]$RemoteDir)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($segments.Count -eq 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$candidate = $segments[$segments.Count - 1]
|
||||
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return Normalize-BasePath $candidate
|
||||
}
|
||||
|
||||
function Resolve-AppBasePath {
|
||||
param([Parameter(Mandatory = $true)][hashtable]$Config)
|
||||
|
||||
if ($Config.ContainsKey("BasePath")) {
|
||||
$configured = Normalize-BasePath ([string]$Config.BasePath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($configured)) {
|
||||
return $configured
|
||||
}
|
||||
}
|
||||
|
||||
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
|
||||
}
|
||||
|
||||
function Set-FrontendAppBaseMeta {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PublishDir,
|
||||
[Parameter(Mandatory = $true)][string]$BasePath
|
||||
)
|
||||
|
||||
$candidatePaths = @(
|
||||
(Join-Path $PublishDir "wwwroot\index.html"),
|
||||
(Join-Path $PublishDir "index.html")
|
||||
)
|
||||
|
||||
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace($indexPath)) {
|
||||
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
|
||||
}
|
||||
|
||||
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
|
||||
$content = Get-Content -Path $indexPath -Raw
|
||||
if ($content -notmatch $pattern) {
|
||||
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
|
||||
}
|
||||
|
||||
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
|
||||
$updated = [System.Text.RegularExpressions.Regex]::Replace(
|
||||
$content,
|
||||
$pattern,
|
||||
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
|
||||
1
|
||||
)
|
||||
|
||||
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
|
||||
}
|
||||
|
||||
function Read-PlainOrPrompt {
|
||||
param(
|
||||
[string]$Value,
|
||||
[Parameter(Mandatory = $true)][string]$Prompt,
|
||||
[bool]$Secure = $false
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $Value
|
||||
}
|
||||
|
||||
if ($Secure) {
|
||||
$pwd = Read-Host -Prompt $Prompt -AsSecureString
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
|
||||
try {
|
||||
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
|
||||
}
|
||||
finally {
|
||||
if ($ptr -ne [IntPtr]::Zero) {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Read-Host -Prompt $Prompt
|
||||
}
|
||||
|
||||
function Invoke-WinRmScript {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][hashtable]$Config,
|
||||
[Parameter(Mandatory = $true)][string]$PasswordValue,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
|
||||
[object[]]$ArgumentList = @()
|
||||
)
|
||||
|
||||
Require-ConfigValue $Config "WinRmComputer"
|
||||
Require-ConfigValue $Config "WinRmCredentialUser"
|
||||
|
||||
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
|
||||
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
|
||||
|
||||
$invokeParams = @{
|
||||
ComputerName = $Config.WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = $ScriptBlock
|
||||
ArgumentList = $ArgumentList
|
||||
}
|
||||
|
||||
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
|
||||
$invokeParams["UseSSL"] = $true
|
||||
}
|
||||
|
||||
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
|
||||
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
|
||||
}
|
||||
|
||||
Invoke-Command @invokeParams
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ProfilePath)) {
|
||||
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
|
||||
}
|
||||
|
||||
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
|
||||
$profileDirectory = Split-Path -Parent $resolvedProfilePath
|
||||
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
|
||||
|
||||
Require-ConfigValue $config "ProjectPath"
|
||||
Require-ConfigValue $config "Configuration"
|
||||
Require-ConfigValue $config "Runtime"
|
||||
Require-ConfigValue $config "PublishDir"
|
||||
Require-ConfigValue $config "WinScpPath"
|
||||
Require-ConfigValue $config "RemoteDir"
|
||||
|
||||
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
|
||||
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
|
||||
|
||||
if (-not $useStoredSession) {
|
||||
Require-ConfigValue $config "FtpHost"
|
||||
Require-ConfigValue $config "FtpUser"
|
||||
}
|
||||
|
||||
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
|
||||
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
|
||||
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
|
||||
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
|
||||
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
|
||||
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
|
||||
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
|
||||
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
|
||||
|
||||
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
|
||||
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
|
||||
$needsFtpPassword = -not $useStoredSession
|
||||
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
|
||||
$sharedPassword = ""
|
||||
|
||||
if ($needsFtpPassword -or $needsWinRmPassword) {
|
||||
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
|
||||
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
|
||||
}
|
||||
|
||||
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
|
||||
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
|
||||
|
||||
Assert-Tool "dotnet"
|
||||
Assert-Tool $winScpPath
|
||||
|
||||
Write-Host "1) Publishing..." -ForegroundColor Cyan
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
|
||||
|
||||
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
|
||||
if (-not $selfContained) {
|
||||
$publishArgs += "--self-contained=false"
|
||||
}
|
||||
dotnet @publishArgs
|
||||
|
||||
$appBasePath = Resolve-AppBasePath -Config $config
|
||||
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
|
||||
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
|
||||
|
||||
if ($recycleAppPool) {
|
||||
Require-ConfigValue $config "AppPoolName"
|
||||
$appPoolName = [string]$config.AppPoolName
|
||||
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
|
||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
|
||||
$openCommand = if ($useStoredSession) {
|
||||
"open `"$winScpSessionName`""
|
||||
}
|
||||
else {
|
||||
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
|
||||
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
|
||||
$ftpHost = [string]$config.FtpHost
|
||||
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
|
||||
}
|
||||
|
||||
$tempScript = New-TemporaryFile
|
||||
@(
|
||||
"option batch continue"
|
||||
"option confirm off"
|
||||
$openCommand
|
||||
"lcd `"$publishDir`""
|
||||
"cd $([string]$config.RemoteDir)"
|
||||
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
|
||||
"exit"
|
||||
) | Set-Content -Path $tempScript -Encoding UTF8
|
||||
|
||||
& $winScpPath "/ini=nul" "/script=$tempScript"
|
||||
Remove-Item $tempScript -ErrorAction SilentlyContinue
|
||||
|
||||
if ($recycleAppPool) {
|
||||
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Start-WebAppPool -Name $poolName
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM start failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
if ($runEfMigrations) {
|
||||
Require-ConfigValue $config "RemoteSitePath"
|
||||
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($sitePath)
|
||||
Set-Location $sitePath
|
||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet is not available on remote host."
|
||||
}
|
||||
|
||||
dotnet ef database update --no-build
|
||||
} -ArgumentList @([string]$config.RemoteSitePath)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Done." -ForegroundColor Green
|
||||
14
scripts/deploy-ftp1.ps1
Normal file
14
scripts/deploy-ftp1.ps1
Normal file
@@ -0,0 +1,14 @@
|
||||
param(
|
||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
|
||||
|
||||
& $scriptPath `
|
||||
-ProfilePath $ProfilePath `
|
||||
-Password $Password `
|
||||
-SkipRecycle:$SkipRecycle `
|
||||
-SkipMigrations:$SkipMigrations
|
||||
Reference in New Issue
Block a user