功能:新增云管API与Web控制台并完成现场接入

This commit is contained in:
songyiming committed 2026-11-09 17:54:52 +08:00
1 parent 18a9ce995d
commit 3fb7b4da8c
79 files changed
+17367 -3

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const string = { type: 'string' }, integer = { type: 'integer' }, number = { type: 'number' }, boolean = { type: 'boolean' }
const date = { type: 'string', format: 'date-time' }
const ref = name => ({ $ref: `#/components/schemas/${name}` })
const array = items => ({ type: 'array', items })
const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false })
const schemas = {
Error: object({ code: string, message: string, requestId: string }),
Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }),
LoginInput: object({ username: string, password: string }),
LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }),
Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }),
Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }),
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
Settings: object({ mode: { type: 'string', enum: ['demo', 'production'] }, computeProvider: string, pveConfigured: boolean, terminalTransport: string, capabilities: array(string) }),
ImportInput: object({ name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, endpoint: string, width: integer, height: integer, fps: integer }, ['name', 'os', 'node', 'vmid']),
DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }),
EnrollmentInput: object({ sn: string, name: string, group: string }),
Enrollment: object({ id: string, activationSecret: string }),
CreateUserInput: object({ username: string, displayName: string }),
BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }),
DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }),
TerminalAction: object({ action: { type: 'string', enum: ['reboot', 'reset-session', 'collect-logs'] } }),
RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }),
RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }),
HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']),
Accepted: object({ accepted: boolean }),
HeartbeatResult: object({ accepted: boolean, serverTime: date }),
DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }),
TicketInput: object({ desktopId: string }),
Ticket: object({ ticket: string, desktopId: string, expiresAt: date, credentialMode: string }),
ConsumeInput: object({ ticket: string }),
Connection: object({ desktopId: string, sessionId: string, protocol: string, endpoint: string, app: string, width: integer, height: integer, fps: integer, credentialMode: string }),
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']),
}
// Older frontends can omit this display field; the API always supplies it.
schemas.Terminal.properties.sessionState = string
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) {
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
}
const json = schema => ({ 'application/json': { schema } })
const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({
operationId: id,
security: security ? [{ [security]: [] }] : [],
...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}),
parameters: [
...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []),
...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []),
...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []),
],
responses: {
[status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) },
...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])),
},
})
const paths = {
'/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) },
'/auth/me': { get: operation('me', 'Administrator') },
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
'/overview': { get: operation('overview', 'Overview') },
'/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
'/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) },
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
'/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) },
'/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) },
'/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) },
'/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) },
'/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) },
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true }) },
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
'/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) },
'/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) },
'/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) },
'/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) },
'/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) },
'/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) },
}
const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } }
const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
mkdirSync(directory, { recursive: true })
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)
console.log('Updated docs/openapi/platform.v1.json')
+53
View File
@@ -0,0 +1,53 @@
"""Exercise device registration/heartbeat/command receipts without controlling real hardware."""
import argparse
import getpass
import json
import os
import time
from urllib.request import Request, urlopen
from urllib.error import HTTPError
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--server', default='http://127.0.0.1:8088')
parser.add_argument('--sn', required=True)
args = parser.parse_args()
server = args.server.rstrip('/')
token = ''
def request(path, body=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
req = Request(server + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
try:
with urlopen(req, timeout=15) as response:
return json.load(response)
except HTTPError as error:
detail = json.load(error)
raise RuntimeError(detail.get('message', 'API request failed')) from None
if request('/healthz').get('mode') != 'demo':
raise SystemExit('This simulator only runs against VDI_MODE=demo.')
secret = os.environ.get('VDI_DEVICE_ACTIVATION_SECRET') or getpass.getpass('One-time activation secret: ')
result = request('/api/v1/terminal/register', {'sn': args.sn, 'activationSecret': secret, 'agentVersion': 'simulator-0.1'})
token = result['accessToken']
print('Registered terminal:', result['terminalId'])
print('Simulator sends heartbeat only; all requested device operations return failed.')
print('Press Ctrl+C to stop. No reboot, session change, or log collection is performed.')
while True:
request('/api/v1/terminal/heartbeat', {'agentVersion': 'simulator-0.1', 'fps': None, 'rtt': None})
desktops = request('/api/v1/terminal/desktops')['items']
print('Authorized desktops:', ', '.join(d['name'] for d in desktops) or '(none)')
for command in request('/api/v1/terminal/commands')['items']:
request('/api/v1/terminal/commands/' + command['id'] + '/ack', {'status': 'failed', 'message': 'Device simulator does not execute operations'})
print('Returned failed receipt for:', command['action'], command['id'])
time.sleep(10)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\nSimulator stopped.')
+43
View File
@@ -0,0 +1,43 @@
"""Generate private local configuration and a CA for the device HTTPS channel."""
import argparse
import datetime
import ipaddress
import json
import pathlib
import secrets
import os
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
p = argparse.ArgumentParser()
p.add_argument('--ip', required=True)
p.add_argument('--pve-hostname', required=True, help='DNS name present in the PVE certificate')
p.add_argument('--pve-ip', required=True)
args = p.parse_args()
ip = ipaddress.ip_address(args.ip)
root = pathlib.Path(__file__).resolve().parent.parent
target = root / '.local/live'
target.mkdir(parents=True, exist_ok=True)
if (target / '.env').exists():
raise RuntimeError('Existing configuration retained; edit it deliberately instead of regenerating secrets')
access = json.loads((target / 'pve-access.json').read_text())
access['PVE_URL']=f'https://{args.pve_hostname}:8006'
now = datetime.datetime.now(datetime.timezone.utc)
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'VDI local device CA')])
ca = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=3650)).add_extension(x509.BasicConstraints(ca=True,path_length=0),critical=True).add_extension(x509.KeyUsage(True,False,False,False,False,True,True,False,False),critical=True).sign(key,hashes.SHA256())
server_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
server = x509.CertificateBuilder().subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,str(ip))])).issuer_name(name).public_key(server_key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=365)).add_extension(x509.BasicConstraints(ca=False,path_length=None),critical=True).add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip),x509.DNSName('localhost')]),critical=False).add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),critical=False).sign(key,hashes.SHA256())
tls = target / 'tls'; tls.mkdir(exist_ok=True)
def save_private(path, data):
path.write_bytes(data); os.chmod(path, 0o600)
for name, obj in [('ca',key),('server',server_key)]:
save_private(tls/f'{name}-key.pem', obj.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
(tls/'ca.pem').write_bytes(ca.public_bytes(serialization.Encoding.PEM))
(tls/'server.pem').write_bytes(server.public_bytes(serialization.Encoding.PEM)+ca.public_bytes(serialization.Encoding.PEM))
env = dict(VDI_DB_PASSWORD=secrets.token_hex(24),VDI_JWT_SECRET=secrets.token_hex(48),VDI_ADMIN_USERNAME='admin',VDI_ADMIN_PASSWORD=secrets.token_urlsafe(24),VDI_LIVE_BIND_IP=str(ip),PVE_HOSTNAME=args.pve_hostname,PVE_HOST_IP=str(ipaddress.ip_address(args.pve_ip)),**access)
save_private(target/'.env', ''.join(f'{k}={v}\n' for k,v in env.items()).encode())
save_private(target/'admin-login.txt', f'Console: http://127.0.0.1:5182\nUsername: {env["VDI_ADMIN_USERNAME"]}\nPassword: {env["VDI_ADMIN_PASSWORD"]}\nDevice HTTPS: https://{ip}:8443\n'.encode())
print('Private environment, admin login, CA and HTTPS certificate generated in .local/live')
+62
View File
@@ -0,0 +1,62 @@
"""Create scoped VDI API access through a known-host-verified SSH connection.
Requires paramiko. Password is prompted or read from VDI_PVE_SSH_PASSWORD.
The one-time API secret is written only to the requested private output file.
"""
import argparse
import getpass
import json
import os
import pathlib
import shlex
import paramiko
def main():
p = argparse.ArgumentParser()
p.add_argument('--host', required=True)
p.add_argument('--vmids', nargs='+', type=int, required=True)
p.add_argument('--output', required=True)
args = p.parse_args()
target = pathlib.Path(args.output)
if target.exists():
raise RuntimeError('Output already exists; reuse the existing token rather than replace it')
ssh = paramiko.SSHClient()
ssh.load_system_host_keys(str(pathlib.Path.home() / '.ssh/known_hosts'))
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
password = os.environ.get('VDI_PVE_SSH_PASSWORD') or getpass.getpass('PVE SSH password: ')
ssh.connect(args.host, username='root', password=password, look_for_keys=False, allow_agent=False, timeout=10)
def run(*parts):
_, out, err = ssh.exec_command(shlex.join(parts), timeout=30)
body = out.read().decode()
if out.channel.recv_exit_status() != 0:
raise RuntimeError(err.read().decode())
return body
try:
user, role, token = 'vdi-cloud@pve', 'VDICloudPower', 'console'
users = json.loads(run('pveum', 'user', 'list', '--output-format', 'json'))
if any(u['userid'] == user for u in users):
raise RuntimeError('VDI account already exists. Inspect it and recover the existing token first')
roles = json.loads(run('pveum', 'role', 'list', '--output-format', 'json'))
if not any(r['roleid'] == role for r in roles):
run('pveum', 'role', 'add', role, '--privs', 'VM.Audit VM.PowerMgmt')
elif set(next(r for r in roles if r['roleid'] == role)['privs'].split(',')) != {'VM.Audit','VM.PowerMgmt'}:
raise RuntimeError('Existing VDICloudPower role has unexpected privileges; inspect it first')
run('pveum', 'user', 'add', user, '--comment', 'VDI cloud API; scoped power management')
run('pveum', 'acl', 'modify', '/', '--users', user, '--roles', 'PVEAuditor')
for vmid in args.vmids:
run('pveum', 'acl', 'modify', f'/vms/{vmid}', '--users', user, '--roles', role)
result = json.loads(run('pveum', 'user', 'token', 'add', user, token, '--privsep', '0', '--output-format', 'json'))
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('x', encoding='utf-8') as f:
json.dump({'PVE_URL': f'https://{args.host}:8006', 'PVE_TOKEN_ID': result['full-tokenid'], 'PVE_TOKEN_SECRET': result['value']}, f)
os.chmod(target, 0o600)
with ssh.open_sftp() as sftp:
sftp.get('/etc/pve/pve-root-ca.pem', str(target.parent / 'pve-ca.pem'))
print(f'Created dedicated account and VM power scopes {args.vmids}; credentials saved privately')
finally:
ssh.close()
if __name__ == '__main__':
main()
+42
View File
@@ -0,0 +1,42 @@
param([switch]$SkipInstall, [int]$ApiPort=8088, [int]$ConsolePort=5180)
$ErrorActionPreference = 'Stop'
$vdiWorkspace = Split-Path -Parent $PSScriptRoot
$vdiLocalDir = Join-Path $vdiWorkspace '.local'
New-Item -ItemType Directory -Force -Path $vdiLocalDir | Out-Null
$vdiGoCommand = Get-Command go -ErrorAction SilentlyContinue
$vdiGoPath = if ($vdiGoCommand) { $vdiGoCommand.Source } else { Join-Path $env:LOCALAPPDATA 'vdi-cloud-dev\go\bin\go.exe' }
if (!(Test-Path -LiteralPath $vdiGoPath)) { throw 'Go 1.25+ is required. Install Go and add it to PATH.' }
foreach ($vdiPort in @($ApiPort,$ConsolePort)) {
if (Get-NetTCPConnection -State Listen -LocalPort $vdiPort -ErrorAction SilentlyContinue) { throw "Port $vdiPort is already in use. Stop the existing development instance before starting another." }
}
Push-Location $vdiWorkspace
try {
docker compose -f deploy/compose.dev.yml up -d postgres
if ($LASTEXITCODE -ne 0) { throw 'PostgreSQL startup failed' }
$vdiDatabaseReady = $false
for ($vdiAttempt = 0; $vdiAttempt -lt 40; $vdiAttempt++) {
docker exec vdi-cloud-dev-postgres-1 pg_isready -U vdi -d vdi 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { $vdiDatabaseReady = $true; break }
Start-Sleep -Milliseconds 500
}
if (!$vdiDatabaseReady) { throw 'PostgreSQL did not become ready' }
Push-Location platform
try { & $vdiGoPath build -o (Join-Path $vdiLocalDir 'vdi-api.exe') ./cmd/api-gateway; if ($LASTEXITCODE -ne 0) { throw 'API build failed' } } finally { Pop-Location }
if (!$SkipInstall) { Push-Location console; try { npm.cmd ci; if ($LASTEXITCODE -ne 0) { throw 'Console install failed' } } finally { Pop-Location } }
$vdiEnvironment = @{
VDI_MODE = 'demo'; VDI_LISTEN = "127.0.0.1:$ApiPort";
DATABASE_URL = 'postgres://vdi:vdi-local-demo@127.0.0.1:55438/vdi?sslmode=disable';
VDI_JWT_SECRET = 'local-demo-only-change-before-any-production-use-2026';
PVE_URL = ''; PVE_TOKEN_ID = ''; PVE_TOKEN_SECRET = ''; PVE_CA_FILE = ''; VITE_API_MODE = 'api'; VDI_API_PROXY = "http://127.0.0.1:$ApiPort"
}
$vdiOriginalEnvironment = @{}
try {
foreach ($vdiKey in $vdiEnvironment.Keys) { $vdiOriginalEnvironment[$vdiKey] = [Environment]::GetEnvironmentVariable($vdiKey,'Process'); [Environment]::SetEnvironmentVariable($vdiKey,$vdiEnvironment[$vdiKey],'Process') }
$vdiAPIProcess = Start-Process -FilePath (Join-Path $vdiLocalDir 'vdi-api.exe') -WorkingDirectory $vdiWorkspace -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'api.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'api.stderr.log') -PassThru
$vdiConsoleProcess = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d','/c',"npm.cmd run dev -- --port $ConsolePort" -WorkingDirectory (Join-Path $vdiWorkspace 'console') -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'console.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'console.stderr.log') -PassThru
@{ apiPid = $vdiAPIProcess.Id; consolePid = $vdiConsoleProcess.Id; startedAt = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content (Join-Path $vdiLocalDir 'cloud-dev.json')
} finally { foreach ($vdiKey in $vdiOriginalEnvironment.Keys) { [Environment]::SetEnvironmentVariable($vdiKey,$vdiOriginalEnvironment[$vdiKey],'Process') } }
Write-Host "Console: http://127.0.0.1:$ConsolePort"
Write-Host 'Demo users: admin / operator / viewer; password: Demo@2026'
Write-Host 'Development process IDs and logs: .local/'
} finally { Pop-Location }
+13
View File
@@ -0,0 +1,13 @@
param([switch]$Build)
$ErrorActionPreference='Stop'
$vdiRoot=Split-Path -Parent $PSScriptRoot
Push-Location $vdiRoot
try {
if (!(Test-Path -LiteralPath '.local/live/.env')) { throw 'Configure PVE access and run tools/prepare-live-config.py first. See docs/cloud-live-integration.md.' }
$vdiArgs=@('compose','--env-file','.local/live/.env','-f','deploy/compose.live.yml','up','-d')
if ($Build) { $vdiArgs+='--build' }
& docker @vdiArgs
if ($LASTEXITCODE -ne 0) { throw 'Live platform startup failed' }
Write-Host 'Real platform: http://127.0.0.1:5182'
Write-Host 'Private initial administrator credentials: .local/live/admin-login.txt'
} finally { Pop-Location }