Add scripted VPS website staging proof
This commit is contained in:
parent
3da1fabbcd
commit
f2815ba53e
13 changed files with 836 additions and 5 deletions
|
|
@ -373,6 +373,12 @@ the first-party website shell on the real shared host behind `3011`, so the
|
|||
remaining work is root-owned cutover and production-value application rather
|
||||
than repository-side website behavior.
|
||||
|
||||
The repo now also ships that host-proof path as a first-party helper under
|
||||
`website/scripts/run-vps-same-origin-staging-proof.mjs`, allowing future
|
||||
sessions to restage the current committed `website/` tree into a temporary VPS
|
||||
checkout, prove the live `3011` behavior, and clean the temp lane back up
|
||||
without rebuilding the SSH/archive/build/start sequence manually.
|
||||
|
||||
## Explicit non-claims
|
||||
|
||||
This packet does not claim any of the following:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,39 @@ cd /srv/hypertwist/current/website/server
|
|||
npm ci
|
||||
```
|
||||
|
||||
### 3A. Recommended non-root staging proof before cutover
|
||||
|
||||
From the local HyperTwist repo on the Linux side, run the first-party staging
|
||||
proof helper against the real VPS before any root-owned vhost or service change:
|
||||
|
||||
```bash
|
||||
cd /home/dev/src/HyperTwist/website
|
||||
npm run run:vps-same-origin-staging-proof -- \
|
||||
--manifest deploy/hypertwist.same-origin.bundle.json \
|
||||
--identity-file /path/to/shared-vps-key \
|
||||
--json
|
||||
```
|
||||
|
||||
Current behavior of that helper:
|
||||
|
||||
- archives the current committed `website/` tree
|
||||
- stages it into a temporary VPS checkout under `/tmp/`
|
||||
- rewrites the manifest for temporary checkout ownership and port `3011`
|
||||
- installs deps, renders the same-origin bundle, builds the frontend, and boots
|
||||
the auth server on the shared-host-safe staging port
|
||||
- verifies:
|
||||
- `GET /health`
|
||||
- `GET /api/auth/health`
|
||||
- `GET /api/releases/manifest`
|
||||
- root shell marker plus `#root` mount
|
||||
- cleans the temporary checkout and staging listener back up on success
|
||||
|
||||
Latest proof result on `2026-06-22`:
|
||||
|
||||
- passed on the real shared VPS
|
||||
- confirmed green health/auth/release/shell behavior on `3011`
|
||||
- left no retained temp checkout afterward
|
||||
|
||||
### 4. Install the systemd service
|
||||
|
||||
Preferred validated bundle-generation path:
|
||||
|
|
@ -236,8 +269,9 @@ public-host cutover has not yet been applied.
|
|||
That remaining cutover gap is now narrower than before: an isolated VPS-local
|
||||
staging proof on the same host succeeded on `3011` for `/health`,
|
||||
`/api/auth/health`, `/api/releases/manifest`, and the first-party shell marker,
|
||||
so the remaining work is root-owned enablement and vhost replacement rather
|
||||
than unresolved repo-side runtime behavior.
|
||||
and the new first-party staging-proof helper now reproduces that flow
|
||||
programmatically, so the remaining work is root-owned enablement and vhost
|
||||
replacement rather than unresolved repo-side runtime behavior.
|
||||
|
||||
### 7. Run the first-party readiness verifier
|
||||
|
||||
|
|
|
|||
|
|
@ -119,11 +119,23 @@ Operational reading:
|
|||
|
||||
- the repo-side same-origin website/auth-server lane is now proved on the real
|
||||
shared VPS behind the new host-safe `3011` port
|
||||
- the repo now also ships
|
||||
`website/scripts/run-vps-same-origin-staging-proof.mjs` so that same
|
||||
isolated-host proof can be rerun from the local HyperTwist repo without
|
||||
hand-assembling the SSH/archive/build/boot sequence again
|
||||
- the remaining gap is the root-owned live cutover from static
|
||||
`/var/www/hypertwist` NGINX serving into the long-lived HyperTwist
|
||||
website/auth-server process plus real production values, not another missing
|
||||
repository-side deployment behavior
|
||||
|
||||
Follow-up proof on `2026-06-22`:
|
||||
|
||||
- the new scripted staging-proof helper reproduced that same `3011` host proof
|
||||
successfully against the live VPS
|
||||
- on success it removed the temporary staging checkout again
|
||||
- on the final cleanup check, no `3011` listener remained and the temp checkout
|
||||
path was absent
|
||||
|
||||
## Related authorities
|
||||
|
||||
- `docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md`
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ repo.
|
|||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The live website lane now also owns route-aware title/description/canonical/Open-Graph/Twitter metadata for the real `hypertwist.app` marketing surface so deployed public pages no longer remain on a single generic SPA title/description, plus first-party `robots.txt` and `sitemap.xml` assets for the public route set while keeping `/app`, `/login`, and `/register` out of crawler posture. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, built-shell serving, and the public anonymous release-manifest posture for the shared desktop release lane. The same verifier now also probes the deployed root-shell marker and can explicitly fail when the public origin is still serving the older placeholder rollout page instead of the first-party website/auth-server lane, while the repo now also carries first-party `website/deploy/` `nginx` plus `systemd` handoff templates, a concrete same-origin public-host cutover guide, a deployment-file renderer that emits resolved operator outputs from real checkout paths, and a manifest-driven bundle renderer that lets one authoritative input own the public origin while emitting validated env plus install artifacts together, with the shared-VPS-safe default upstream moved to `3011` after live host inspection confirmed `3001` is already occupied by FamiliarOS. An isolated VPS-local staging proof then also confirmed that the committed HyperTwist website lane can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, so the remaining gap is root-owned live cutover rather than missing repo-side website behavior. |
|
||||
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The live website lane now also owns route-aware title/description/canonical/Open-Graph/Twitter metadata for the real `hypertwist.app` marketing surface so deployed public pages no longer remain on a single generic SPA title/description, plus first-party `robots.txt` and `sitemap.xml` assets for the public route set while keeping `/app`, `/login`, and `/register` out of crawler posture. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, built-shell serving, and the public anonymous release-manifest posture for the shared desktop release lane. The same verifier now also probes the deployed root-shell marker and can explicitly fail when the public origin is still serving the older placeholder rollout page instead of the first-party website/auth-server lane, while the repo now also carries first-party `website/deploy/` `nginx` plus `systemd` handoff templates, a concrete same-origin public-host cutover guide, a deployment-file renderer that emits resolved operator outputs from real checkout paths, and a manifest-driven bundle renderer that lets one authoritative input own the public origin while emitting validated env plus install artifacts together, with the shared-VPS-safe default upstream moved to `3011` after live host inspection confirmed `3001` is already occupied by FamiliarOS. An isolated VPS-local staging proof then also confirmed that the committed HyperTwist website lane can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and the repo now also ships that host-proof flow as a first-party staging helper so future sessions can rerun the temp checkout/build/boot verification path directly before root-owned cutover. |
|
||||
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, support-topic fallback routing when live checkout is not configured, desktop-link verify-url/dashboard readiness behavior, and explicit `noindex,nofollow` posture on protected/auth browser surfaces. The validation lane now also has a bounded signed test-session harness under `TEST_MODE=testing` that proves `/api/auth/me` and `/api/auth/desktop-link` behavior through the live spawned auth-server process without widening production auth posture. |
|
||||
| Desktop download posture and browser-to-desktop pairing | Implemented now | first-party `website/` app + `website/server` desktop-link endpoints | Public download targets, dashboard-side release posture, and short-lived desktop-link token generation/verification are now first-party owned. The current server posture now enforces exact website-origin matching, bounded per-user issuance, one-time token consumption, and billing-backed plan/download entitlement resolution with focused `website/server` tests green on `2026-06-22`, and the verify handshake now returns the same resolved download-entitlement posture the dashboard sees instead of only identity plus plan/role. The same lane now also owns a shared `GET /api/releases/manifest` runtime authority for release version/channel/build/published/file-size/checksum/docs/source metadata, with anonymous callers intentionally denied raw download URLs while entitled session-backed callers receive the configured direct platform URL. The public `/download` page now keeps raw download URLs behind the protected dashboard instead of exposing them directly, preserves requested platform continuity through `/app/downloads?platform=...`, and surfaces that requested target again after auth handoff inside the protected release lane. Both the public and protected download surfaces now also carry first-party rollout steps plus release/notices/source references so the desktop setup lane is more than a generic link bucket, and the dashboard plus public launch-status callouts now consume the same manifest-backed Windows download truth instead of only static frontend config. Actual release URLs remain deployment configuration rather than hardcoded product truth. |
|
||||
| Paddle-ready pricing and billing webhook seam | Implemented now | first-party `website/` app + `website/server` billing endpoint | The public pricing surface now exists with plan structure, checkout-link configuration seams, and the same `/api/billing/paddle/webhook` endpoint family used by the broader product website lane. The current server now verifies `Paddle-Signature` against `PADDLE_WEBHOOK_SECRET` using the documented raw-body HMAC flow, persists a bounded first-party billing state file, and applies verified Paddle events into account/download entitlement state that the browser dashboard consumes, with focused `website/server` tests green on `2026-06-22`. A spawned live-process proof now also verifies that a real signed webhook updates processed-event health and persisted billing state through the actual auth-server runtime, not only helper-level store tests, and transaction events no longer leak their id into stored `subscriptionId` state. Production checkout URLs, secret management, and broader operator/admin billing workflows remain deployment/application tasks, not shipped-code omissions. |
|
||||
|
|
|
|||
|
|
@ -274,7 +274,9 @@ Current consolidated milestone snapshot:
|
|||
FamiliarOS already occupies `3001`, and an isolated VPS-local staging proof
|
||||
then confirmed the current committed HyperTwist `website/` lane can serve
|
||||
green health, release-manifest, and first-party shell responses on that real
|
||||
shared host behind `3011`,
|
||||
shared host behind `3011`, with that same non-root host-proof flow now
|
||||
codified in a first-party staging-proof helper so future sessions can rerun
|
||||
the temp checkout/build/boot verification path directly,
|
||||
and the live public website lane now also owns route-aware
|
||||
title/description/canonical/Open-Graph/Twitter metadata for the real
|
||||
`hypertwist.app` marketing surface while marking protected/auth routes as
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ Use the runtime-readiness command before public launch or deployment approval:
|
|||
env, server env, `systemd`, and `nginx` outputs together before VPS cutover
|
||||
- the shared-VPS deployment defaults now use upstream port `3011` because the
|
||||
current host already has FamiliarOS bound to `3001`
|
||||
- the repo now also includes `npm run run:vps-same-origin-staging-proof` so the
|
||||
current committed `website/` tree can be staged into a temporary VPS checkout
|
||||
and proven on the real shared host before any root-owned live cutover
|
||||
- it now warns when same-origin public deployment leaves static website serving mode ambiguous
|
||||
- request-level server tests now also pin that same-origin shell behavior instead of relying only on helper-level assertions
|
||||
- the public pricing/download/notices pages now also surface preview-versus-launch posture directly from the same bounded launch checklist
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Files:
|
|||
- `systemd/hypertwist-website-auth-server.service.example`
|
||||
- `../scripts/render-same-origin-deployment.mjs`
|
||||
- `../scripts/render-same-origin-bundle.mjs`
|
||||
- `../scripts/run-vps-same-origin-staging-proof.mjs`
|
||||
|
||||
Expected deployment posture:
|
||||
|
||||
|
|
@ -26,6 +27,12 @@ Before using these templates:
|
|||
- preferred path: copy `hypertwist.same-origin.bundle.example.json`, replace the
|
||||
placeholder values, and render a validated deployment bundle with
|
||||
`npm run render:same-origin-bundle -- --manifest <file> --out-dir <dir>`
|
||||
- before any root-owned cutover, you can now also run
|
||||
`npm run run:vps-same-origin-staging-proof -- --manifest <file> --identity-file <key>`
|
||||
from the local repo to stage the current committed `website/` tree into a VPS
|
||||
temp checkout, boot it on the host-safe staging port, verify the real
|
||||
`/health`, `/api/auth/health`, `/api/releases/manifest`, and root shell
|
||||
surfaces, and clean the temp lane back up on success
|
||||
- set `publicOrigin` to the exact live origin that should own both the website
|
||||
and auth cookies; for the canonical production lane that value is
|
||||
`https://hypertwist.app`
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"test": "vitest run",
|
||||
"check:runtime-readiness": "node scripts/check-runtime-readiness.mjs",
|
||||
"render:same-origin-deployment": "node scripts/render-same-origin-deployment.mjs",
|
||||
"render:same-origin-bundle": "node scripts/render-same-origin-bundle.mjs"
|
||||
"render:same-origin-bundle": "node scripts/render-same-origin-bundle.mjs",
|
||||
"run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
|
||||
const scriptPath = path.join(websiteRoot, 'scripts', 'run-vps-same-origin-staging-proof.mjs')
|
||||
|
||||
describe('run-vps-same-origin-staging-proof CLI', () => {
|
||||
it('supports dry-run json output with a sanitized manifest preview', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-staging-proof-cli-'))
|
||||
const manifestPath = path.join(tempDir, 'bundle.json')
|
||||
const identityFile = path.join(tempDir, 'id_ed25519')
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
checkoutRoot: '/srv/hypertwist/current',
|
||||
publicOrigin: 'https://hypertwist.app',
|
||||
supportEmail: 'hello@hypertwist.app',
|
||||
publicDocsUrl: 'https://hypertwist.app/docs',
|
||||
releaseNotesUrl: 'https://hypertwist.app/changelog',
|
||||
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
|
||||
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
|
||||
operatorCheckoutUrl: 'https://buy.paddle.com/operator',
|
||||
studioCheckoutUrl: 'https://buy.paddle.com/studio',
|
||||
windowsDownloadUrl: 'https://downloads.hypertwist.app/windows.exe',
|
||||
releaseManifestVersion: '1.0.0',
|
||||
windowsRelease: {
|
||||
buildId: 'win64-1000',
|
||||
sha256: 'abc123',
|
||||
publishedAt: '2026-06-22T00:00:00.000Z',
|
||||
},
|
||||
server: {
|
||||
paddleWebhookSecret: 'super-secret-token',
|
||||
},
|
||||
}, null, 2))
|
||||
fs.writeFileSync(identityFile, 'fake-key', 'utf8')
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
scriptPath,
|
||||
'--manifest',
|
||||
manifestPath,
|
||||
'--identity-file',
|
||||
identityFile,
|
||||
'--dry-run',
|
||||
'--json',
|
||||
],
|
||||
{
|
||||
cwd: websiteRoot,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
|
||||
const payload = JSON.parse(result.stdout)
|
||||
expect(payload.vpsHost).toBe('212.227.13.220')
|
||||
expect(payload.port).toBe(3011)
|
||||
expect(payload.remoteManifest.server.paddleWebhookSecret).toBe('su***en')
|
||||
expect(payload.remoteScript).toContain('__HYPERTWIST_STAGING_SUMMARY__')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
390
website/scripts/run-vps-same-origin-staging-proof-lib.mjs
Normal file
390
website/scripts/run-vps-same-origin-staging-proof-lib.mjs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function normalizeTrimmed(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
const normalized = normalizeTrimmed(value).toLowerCase()
|
||||
if (!normalized) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
|
||||
}
|
||||
|
||||
function requireNonEmpty(value, label) {
|
||||
const normalized = normalizeTrimmed(value)
|
||||
if (!normalized) {
|
||||
throw new Error(`${label} is required.`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, label) {
|
||||
const parsed = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label} must be a positive integer.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function maskSecretValue(value) {
|
||||
const normalized = String(value ?? '')
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 4) {
|
||||
return '****'
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`
|
||||
}
|
||||
|
||||
function maskManifestSecrets(value, key = '') {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => maskManifestSecrets(item))
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([entryKey, entryValue]) => [entryKey, maskManifestSecrets(entryValue, entryKey)]),
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && /secret/i.test(key)) {
|
||||
return maskSecretValue(value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseStagingProofArgs(argv) {
|
||||
const options = {
|
||||
manifest: '',
|
||||
identityFile: '',
|
||||
vpsHost: '212.227.13.220',
|
||||
vpsUser: 'dev',
|
||||
remoteDir: '',
|
||||
port: '3011',
|
||||
waitSeconds: '20',
|
||||
keepRemoteDir: false,
|
||||
json: false,
|
||||
dryRun: false,
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
const next = argv[index + 1] || ''
|
||||
|
||||
switch (argument) {
|
||||
case '--manifest':
|
||||
options.manifest = next
|
||||
index += 1
|
||||
break
|
||||
case '--identity-file':
|
||||
options.identityFile = next
|
||||
index += 1
|
||||
break
|
||||
case '--vps-host':
|
||||
options.vpsHost = next
|
||||
index += 1
|
||||
break
|
||||
case '--vps-user':
|
||||
options.vpsUser = next
|
||||
index += 1
|
||||
break
|
||||
case '--remote-dir':
|
||||
options.remoteDir = next
|
||||
index += 1
|
||||
break
|
||||
case '--port':
|
||||
options.port = next
|
||||
index += 1
|
||||
break
|
||||
case '--wait-seconds':
|
||||
options.waitSeconds = next
|
||||
index += 1
|
||||
break
|
||||
case '--keep-remote-dir':
|
||||
options.keepRemoteDir = true
|
||||
break
|
||||
case '--json':
|
||||
options.json = true
|
||||
break
|
||||
case '--dry-run':
|
||||
options.dryRun = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export function loadStagingProofManifest(filePath) {
|
||||
const absolutePath = path.resolve(filePath)
|
||||
const raw = fs.readFileSync(absolutePath, 'utf8')
|
||||
return {
|
||||
path: absolutePath,
|
||||
manifest: JSON.parse(raw),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultRemoteDir(gitRef = 'head') {
|
||||
const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
return `/tmp/hypertwist-website-staging-${normalized || 'head'}`
|
||||
}
|
||||
|
||||
export function resolveStagingProofOptions({
|
||||
manifestPath,
|
||||
identityFile,
|
||||
vpsHost,
|
||||
vpsUser,
|
||||
remoteDir,
|
||||
port,
|
||||
waitSeconds,
|
||||
keepRemoteDir,
|
||||
json,
|
||||
dryRun,
|
||||
gitRef,
|
||||
}) {
|
||||
return {
|
||||
manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')),
|
||||
identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')),
|
||||
vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'),
|
||||
vpsUser: requireNonEmpty(vpsUser || 'dev', 'vpsUser'),
|
||||
remoteDir: normalizeTrimmed(remoteDir) || buildDefaultRemoteDir(gitRef),
|
||||
port: requirePositiveInteger(port || '3011', 'port'),
|
||||
waitSeconds: requirePositiveInteger(waitSeconds || '20', 'waitSeconds'),
|
||||
keepRemoteDir: normalizeBoolean(keepRemoteDir, false),
|
||||
json: normalizeBoolean(json, false),
|
||||
dryRun: normalizeBoolean(dryRun, false),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRemoteStagingManifest(manifest, {
|
||||
remoteDir,
|
||||
port,
|
||||
}) {
|
||||
const server = manifest?.server && typeof manifest.server === 'object'
|
||||
? manifest.server
|
||||
: {}
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
checkoutRoot: remoteDir,
|
||||
server: {
|
||||
...server,
|
||||
port: String(port),
|
||||
serveStaticWebsite: true,
|
||||
websiteDistPath: `${remoteDir}/website/dist`,
|
||||
billingStatePath: `${remoteDir}/hypertwist-billing-state.json`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeStagingProofManifest(manifest) {
|
||||
return maskManifestSecrets(manifest)
|
||||
}
|
||||
|
||||
export function buildRemoteStagingProofScript({
|
||||
remoteDir,
|
||||
waitSeconds,
|
||||
keepRemoteDir,
|
||||
manifest,
|
||||
}) {
|
||||
const manifestJson = JSON.stringify(manifest, null, 2)
|
||||
const port = String(manifest.server?.port || '')
|
||||
|
||||
return `set -euo pipefail
|
||||
REMOTE_DIR=${JSON.stringify(remoteDir)}
|
||||
WAIT_SECONDS=${Number(waitSeconds)}
|
||||
KEEP_REMOTE_DIR=${keepRemoteDir ? '1' : '0'}
|
||||
PORT=${JSON.stringify(port)}
|
||||
WEBSITE_DIR="$REMOTE_DIR/website"
|
||||
MANIFEST_PATH="$REMOTE_DIR/bundle.json"
|
||||
BUNDLE_OUT="$REMOTE_DIR/rendered"
|
||||
LOG_DIR="$REMOTE_DIR/logs"
|
||||
SERVER_PID=""
|
||||
PROOF_OK=0
|
||||
cleanup() {
|
||||
LISTENER_PIDS=$(ss -ltnp 2>/dev/null | sed -n "s/.*:$PORT[^0-9].*pid=\\([0-9]\\+\\).*/\\1/p" | sort -u || true)
|
||||
if [ -n "$LISTENER_PIDS" ]; then
|
||||
for listener_pid in $LISTENER_PIDS; do
|
||||
kill "$listener_pid" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "\${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$KEEP_REMOTE_DIR" = "1" ] || [ "$PROOF_OK" != "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
rm -rf "$REMOTE_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REMOTE_DIR" "$LOG_DIR"
|
||||
cd "$WEBSITE_DIR"
|
||||
|
||||
printf "[remote_dir] %s\\n" "$REMOTE_DIR"
|
||||
printf "[proof_port] %s\\n" "$PORT"
|
||||
|
||||
cat > "$MANIFEST_PATH" <<'__HYPERTWIST_STAGING_MANIFEST__'
|
||||
${manifestJson}
|
||||
__HYPERTWIST_STAGING_MANIFEST__
|
||||
|
||||
npm ci > "$LOG_DIR/website-npm-ci.log" 2>&1
|
||||
npm --prefix server ci > "$LOG_DIR/server-npm-ci.log" 2>&1
|
||||
npm run render:same-origin-bundle -- --manifest "$MANIFEST_PATH" --out-dir "$BUNDLE_OUT" --summary > "$LOG_DIR/bundle-summary.log" 2>&1
|
||||
cp "$BUNDLE_OUT/website.env" .env
|
||||
cp "$BUNDLE_OUT/server.env" server/.env
|
||||
npm run build > "$LOG_DIR/build.log" 2>&1
|
||||
systemd-analyze verify "$BUNDLE_OUT/hypertwist-website-auth-server.service" > "$LOG_DIR/systemd-verify.log" 2>&1
|
||||
npm --prefix server start > "$LOG_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
for attempt in $(seq 1 "$WAIT_SECONDS"); do
|
||||
if curl --silent --show-error "http://127.0.0.1:$PORT/health" > "$LOG_DIR/health.json" 2> "$LOG_DIR/health.stderr"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ ! -s "$LOG_DIR/health.json" ]; then
|
||||
printf "Health endpoint never became ready on port %s.\\n" "$PORT" >&2
|
||||
sed -n '1,200p' "$LOG_DIR/server.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --silent --show-error "http://127.0.0.1:$PORT/api/auth/health" > "$LOG_DIR/auth-health.json"
|
||||
curl --silent --show-error "http://127.0.0.1:$PORT/api/releases/manifest" > "$LOG_DIR/release-manifest.json"
|
||||
curl --silent --show-error "http://127.0.0.1:$PORT/" > "$LOG_DIR/root.html"
|
||||
ss -ltnp | rg ":$PORT" > "$LOG_DIR/listener.txt" || true
|
||||
|
||||
node - "$REMOTE_DIR" <<'__HYPERTWIST_STAGING_SUMMARY__'
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const remoteDir = process.argv[2]
|
||||
const logDir = path.join(remoteDir, 'logs')
|
||||
|
||||
function readJson(fileName) {
|
||||
return JSON.parse(fs.readFileSync(path.join(logDir, fileName), 'utf8'))
|
||||
}
|
||||
|
||||
function readText(fileName) {
|
||||
return fs.readFileSync(path.join(logDir, fileName), 'utf8')
|
||||
}
|
||||
|
||||
const health = readJson('health.json')
|
||||
const authHealth = readJson('auth-health.json')
|
||||
const releaseManifest = readJson('release-manifest.json')
|
||||
const rootHtml = readText('root.html')
|
||||
const listenerOutput = fs.existsSync(path.join(logDir, 'listener.txt'))
|
||||
? readText('listener.txt')
|
||||
: ''
|
||||
|
||||
const markerPresent = rootHtml.includes('hypertwist-site-shell')
|
||||
const rootMountPresent = rootHtml.includes('id="root"')
|
||||
const releasePlatforms = Array.isArray(releaseManifest?.manifest?.platforms)
|
||||
? releaseManifest.manifest.platforms.map((platform) => ({
|
||||
platform_key: platform.platform_key,
|
||||
configured: Boolean(platform.configured),
|
||||
download_available: Boolean(platform.download_available),
|
||||
}))
|
||||
: []
|
||||
|
||||
const failures = []
|
||||
if (!health?.ok) failures.push('/health did not return ok=true.')
|
||||
if (!authHealth?.ok) failures.push('/api/auth/health did not return ok=true.')
|
||||
if (!authHealth?.supertokens?.ready) failures.push('SuperTokens did not report ready=true.')
|
||||
if (!authHealth?.runtime?.public_origin_ready) failures.push('Runtime did not report public_origin_ready=true.')
|
||||
if (!releaseManifest?.ok) failures.push('/api/releases/manifest did not return ok=true.')
|
||||
if (!markerPresent) failures.push('Root shell marker was not present in /.')
|
||||
if (!rootMountPresent) failures.push('Root app mount was not present in /.')
|
||||
if (!listenerOutput.trim()) failures.push('Expected listener on the staging port was not visible in ss output.')
|
||||
|
||||
const summary = {
|
||||
ok: failures.length === 0,
|
||||
remoteDir,
|
||||
health,
|
||||
authHealth: {
|
||||
ok: Boolean(authHealth?.ok),
|
||||
supertokens: authHealth?.supertokens || null,
|
||||
fallback: authHealth?.fallback || null,
|
||||
billing: authHealth?.billing || null,
|
||||
runtime: authHealth?.runtime || null,
|
||||
},
|
||||
releaseManifest: {
|
||||
ok: Boolean(releaseManifest?.ok),
|
||||
support_email: releaseManifest?.manifest?.support_email || null,
|
||||
viewer: releaseManifest?.manifest?.viewer || null,
|
||||
platforms: releasePlatforms,
|
||||
},
|
||||
rootShell: {
|
||||
markerPresent,
|
||||
rootMountPresent,
|
||||
},
|
||||
listenerOutput: listenerOutput.trim(),
|
||||
failures,
|
||||
}
|
||||
|
||||
process.stdout.write(\`__HYPERTWIST_STAGING_SUMMARY__\${JSON.stringify(summary)}\\n\`)
|
||||
if (!summary.ok) {
|
||||
process.exit(1)
|
||||
}
|
||||
__HYPERTWIST_STAGING_SUMMARY__
|
||||
|
||||
PROOF_OK=1
|
||||
printf "[proof_result] ok\\n"
|
||||
`
|
||||
}
|
||||
|
||||
export function buildStageArchiveRemoteCommand(remoteDir) {
|
||||
return `rm -rf ${JSON.stringify(remoteDir)} && mkdir -p ${JSON.stringify(remoteDir)} && tar -xf - -C ${JSON.stringify(remoteDir)}`
|
||||
}
|
||||
|
||||
export function buildSshArgs({
|
||||
identityFile,
|
||||
vpsUser,
|
||||
vpsHost,
|
||||
remoteCommand,
|
||||
}) {
|
||||
const args = [
|
||||
'-i',
|
||||
identityFile,
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
`${vpsUser}@${vpsHost}`,
|
||||
]
|
||||
|
||||
if (remoteCommand) {
|
||||
args.push(remoteCommand)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
export function extractStagingProofSummary(output) {
|
||||
const marker = '__HYPERTWIST_STAGING_SUMMARY__'
|
||||
const lines = String(output || '').split('\n')
|
||||
const summaryLine = [...lines].reverse().find((line) => line.startsWith(marker))
|
||||
if (!summaryLine) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(summaryLine.slice(marker.length))
|
||||
}
|
||||
140
website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs
Normal file
140
website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildDefaultRemoteDir,
|
||||
buildRemoteStagingManifest,
|
||||
buildRemoteStagingProofScript,
|
||||
buildSshArgs,
|
||||
buildStageArchiveRemoteCommand,
|
||||
extractStagingProofSummary,
|
||||
resolveStagingProofOptions,
|
||||
sanitizeStagingProofManifest,
|
||||
} from './run-vps-same-origin-staging-proof-lib.mjs'
|
||||
|
||||
function createManifest() {
|
||||
return {
|
||||
checkoutRoot: '/srv/hypertwist/current',
|
||||
publicOrigin: 'https://hypertwist.app',
|
||||
supportEmail: 'hello@hypertwist.app',
|
||||
publicDocsUrl: 'https://hypertwist.app/docs',
|
||||
releaseNotesUrl: 'https://hypertwist.app/changelog',
|
||||
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
|
||||
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
|
||||
operatorCheckoutUrl: 'https://buy.paddle.com/operator',
|
||||
studioCheckoutUrl: 'https://buy.paddle.com/studio',
|
||||
windowsDownloadUrl: 'https://downloads.hypertwist.app/windows.exe',
|
||||
releaseManifestVersion: '1.0.0',
|
||||
windowsRelease: {
|
||||
buildId: 'win64-1000',
|
||||
sha256: 'abc123',
|
||||
publishedAt: '2026-06-22T00:00:00.000Z',
|
||||
},
|
||||
server: {
|
||||
port: '3011',
|
||||
websiteDistPath: '/srv/hypertwist/current/website/dist',
|
||||
billingStatePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json',
|
||||
paddleWebhookSecret: 'super-secret-token',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveStagingProofOptions', () => {
|
||||
it('normalizes the required CLI inputs', () => {
|
||||
const options = resolveStagingProofOptions({
|
||||
manifestPath: 'deploy/hypertwist.same-origin.bundle.json',
|
||||
identityFile: '/tmp/key',
|
||||
vpsHost: '212.227.13.220',
|
||||
vpsUser: 'dev',
|
||||
remoteDir: '',
|
||||
port: '3011',
|
||||
waitSeconds: '30',
|
||||
keepRemoteDir: true,
|
||||
json: true,
|
||||
dryRun: false,
|
||||
gitRef: 'abc1234',
|
||||
})
|
||||
|
||||
expect(options.remoteDir).toBe('/tmp/hypertwist-website-staging-abc1234')
|
||||
expect(options.port).toBe(3011)
|
||||
expect(options.waitSeconds).toBe(30)
|
||||
expect(options.keepRemoteDir).toBe(true)
|
||||
expect(options.json).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRemoteStagingManifest', () => {
|
||||
it('rewrites runtime-owned paths for the remote staging checkout', () => {
|
||||
const manifest = buildRemoteStagingManifest(createManifest(), {
|
||||
remoteDir: '/tmp/hypertwist-stage',
|
||||
port: 3011,
|
||||
})
|
||||
|
||||
expect(manifest.checkoutRoot).toBe('/tmp/hypertwist-stage')
|
||||
expect(manifest.server.port).toBe('3011')
|
||||
expect(manifest.server.websiteDistPath).toBe('/tmp/hypertwist-stage/website/dist')
|
||||
expect(manifest.server.billingStatePath).toBe('/tmp/hypertwist-stage/hypertwist-billing-state.json')
|
||||
expect(manifest.server.serveStaticWebsite).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeStagingProofManifest', () => {
|
||||
it('masks secret-like values before dry-run output', () => {
|
||||
const sanitized = sanitizeStagingProofManifest(createManifest())
|
||||
|
||||
expect(sanitized.server.paddleWebhookSecret).toBe('su***en')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRemoteStagingProofScript', () => {
|
||||
it('includes the expected remote proof steps and summary marker', () => {
|
||||
const script = buildRemoteStagingProofScript({
|
||||
remoteDir: '/tmp/hypertwist-stage',
|
||||
waitSeconds: 20,
|
||||
keepRemoteDir: false,
|
||||
manifest: buildRemoteStagingManifest(createManifest(), {
|
||||
remoteDir: '/tmp/hypertwist-stage',
|
||||
port: 3011,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(script).toContain('npm run render:same-origin-bundle')
|
||||
expect(script).toContain('systemd-analyze verify "$BUNDLE_OUT/hypertwist-website-auth-server.service"')
|
||||
expect(script).toContain('http://127.0.0.1:$PORT/api/auth/health')
|
||||
expect(script).toContain('LISTENER_PIDS=$(ss -ltnp')
|
||||
expect(script).toContain('__HYPERTWIST_STAGING_SUMMARY__')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractStagingProofSummary', () => {
|
||||
it('parses the emitted summary marker from remote output', () => {
|
||||
const summary = extractStagingProofSummary([
|
||||
'[remote_dir] /tmp/hypertwist-stage',
|
||||
'__HYPERTWIST_STAGING_SUMMARY__{"ok":true,"remoteDir":"/tmp/hypertwist-stage"}',
|
||||
].join('\n'))
|
||||
|
||||
expect(summary).toEqual({
|
||||
ok: true,
|
||||
remoteDir: '/tmp/hypertwist-stage',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh helpers', () => {
|
||||
it('builds the archive and ssh command shape', () => {
|
||||
expect(buildDefaultRemoteDir('abc1234')).toBe('/tmp/hypertwist-website-staging-abc1234')
|
||||
expect(buildStageArchiveRemoteCommand('/tmp/hypertwist-stage')).toContain('tar -xf - -C "/tmp/hypertwist-stage"')
|
||||
expect(buildSshArgs({
|
||||
identityFile: '/tmp/key',
|
||||
vpsUser: 'dev',
|
||||
vpsHost: '212.227.13.220',
|
||||
remoteCommand: 'echo hello',
|
||||
})).toEqual([
|
||||
'-i',
|
||||
'/tmp/key',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'dev@212.227.13.220',
|
||||
'echo hello',
|
||||
])
|
||||
})
|
||||
})
|
||||
160
website/scripts/run-vps-same-origin-staging-proof.mjs
Normal file
160
website/scripts/run-vps-same-origin-staging-proof.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildRemoteStagingManifest,
|
||||
buildRemoteStagingProofScript,
|
||||
buildSshArgs,
|
||||
buildStageArchiveRemoteCommand,
|
||||
extractStagingProofSummary,
|
||||
loadStagingProofManifest,
|
||||
parseStagingProofArgs,
|
||||
resolveStagingProofOptions,
|
||||
sanitizeStagingProofManifest,
|
||||
} from './run-vps-same-origin-staging-proof-lib.mjs'
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
|
||||
const repoRoot = path.resolve(websiteRoot, '..')
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
...options,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function main() {
|
||||
const rawArgs = parseStagingProofArgs(process.argv.slice(2))
|
||||
const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
|
||||
const options = resolveStagingProofOptions({
|
||||
manifestPath: rawArgs.manifest,
|
||||
identityFile: rawArgs.identityFile,
|
||||
vpsHost: rawArgs.vpsHost,
|
||||
vpsUser: rawArgs.vpsUser,
|
||||
remoteDir: rawArgs.remoteDir,
|
||||
port: rawArgs.port,
|
||||
waitSeconds: rawArgs.waitSeconds,
|
||||
keepRemoteDir: rawArgs.keepRemoteDir,
|
||||
json: rawArgs.json,
|
||||
dryRun: rawArgs.dryRun,
|
||||
gitRef,
|
||||
})
|
||||
|
||||
const { path: manifestPath, manifest } = loadStagingProofManifest(options.manifestPath)
|
||||
const remoteManifest = buildRemoteStagingManifest(manifest, {
|
||||
remoteDir: options.remoteDir,
|
||||
port: options.port,
|
||||
})
|
||||
|
||||
const remoteScript = buildRemoteStagingProofScript({
|
||||
remoteDir: options.remoteDir,
|
||||
waitSeconds: options.waitSeconds,
|
||||
keepRemoteDir: options.keepRemoteDir,
|
||||
manifest: remoteManifest,
|
||||
})
|
||||
|
||||
const archiveCommand = buildStageArchiveRemoteCommand(options.remoteDir)
|
||||
const sanitizedManifest = sanitizeStagingProofManifest(remoteManifest)
|
||||
|
||||
if (options.dryRun) {
|
||||
const payload = {
|
||||
repoRoot,
|
||||
websiteRoot,
|
||||
manifestPath,
|
||||
vpsHost: options.vpsHost,
|
||||
vpsUser: options.vpsUser,
|
||||
remoteDir: options.remoteDir,
|
||||
port: options.port,
|
||||
waitSeconds: options.waitSeconds,
|
||||
keepRemoteDir: options.keepRemoteDir,
|
||||
archiveCommand,
|
||||
remoteManifest: sanitizedManifest,
|
||||
remoteScript,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
|
||||
} else {
|
||||
process.stdout.write(`[staging-proof] manifest: ${manifestPath}\n`)
|
||||
process.stdout.write(`[staging-proof] vps: ${options.vpsUser}@${options.vpsHost}\n`)
|
||||
process.stdout.write(`[staging-proof] remote dir: ${options.remoteDir}\n`)
|
||||
process.stdout.write(`[staging-proof] port: ${options.port}\n`)
|
||||
process.stdout.write(`[staging-proof] archive command: ${archiveCommand}\n`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const archiveBuffer = execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], {
|
||||
cwd: repoRoot,
|
||||
encoding: null,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
|
||||
const stageResult = runCommand('ssh', buildSshArgs({
|
||||
identityFile: options.identityFile,
|
||||
vpsUser: options.vpsUser,
|
||||
vpsHost: options.vpsHost,
|
||||
remoteCommand: archiveCommand,
|
||||
}), {
|
||||
input: archiveBuffer,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (stageResult.status !== 0) {
|
||||
process.stderr.write(stageResult.stderr || '')
|
||||
process.exit(stageResult.status || 1)
|
||||
}
|
||||
|
||||
const proofResult = runCommand('ssh', [
|
||||
...buildSshArgs({
|
||||
identityFile: options.identityFile,
|
||||
vpsUser: options.vpsUser,
|
||||
vpsHost: options.vpsHost,
|
||||
}),
|
||||
'bash',
|
||||
'-s',
|
||||
], {
|
||||
input: remoteScript,
|
||||
})
|
||||
|
||||
if (!options.json) {
|
||||
process.stdout.write(proofResult.stdout || '')
|
||||
}
|
||||
process.stderr.write(proofResult.stderr || '')
|
||||
|
||||
const summary = extractStagingProofSummary(proofResult.stdout || '')
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: proofResult.status === 0,
|
||||
manifestPath,
|
||||
vpsHost: options.vpsHost,
|
||||
vpsUser: options.vpsUser,
|
||||
remoteDir: options.remoteDir,
|
||||
port: options.port,
|
||||
keepRemoteDir: options.keepRemoteDir,
|
||||
summary,
|
||||
}, null, 2)}\n`)
|
||||
}
|
||||
|
||||
if (proofResult.status !== 0) {
|
||||
process.exit(proofResult.status || 1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
@ -95,6 +95,10 @@ The server now also supports a bounded first-party same-origin deployment mode:
|
|||
- the website package now also includes `npm run render:same-origin-bundle`
|
||||
so one manifest file can own the public origin and emit validated frontend
|
||||
env, server env, `systemd`, and `nginx` outputs together before installation
|
||||
- the website package now also includes
|
||||
`npm run run:vps-same-origin-staging-proof` so the same bundle manifest can
|
||||
be exercised through a temporary VPS checkout on the real shared host before
|
||||
root-owned service/vhost cutover
|
||||
|
||||
The website package now also ships a deploy-time verification command:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue