Add scripted live website deploy helper

This commit is contained in:
axiomlogicnexus 2026-06-22 08:07:39 +00:00
parent e16e311ec0
commit 5d2e975514
13 changed files with 861 additions and 3 deletions

View file

@ -395,6 +395,14 @@ the shared VPS passed with `runtime.mode: mixed`, `public_origin_ready: true`,
`webhookSecretConfigured: false`, and all release-manifest platforms still
unconfigured for anonymous viewers.
The same package now also ships a bounded root-owned
`website/scripts/run-vps-same-origin-live-deploy.mjs` helper. That helper
stages the committed checkout, uploads the rendered bundle, installs env,
builds the website, replaces the live `systemd` plus `nginx` files, and then
validates the public origin through the same runtime-readiness lane. It was
re-proved idempotently against the already-live `hypertwist.app` preview
deployment on `2026-06-22`.
## Explicit non-claims
This packet does not claim any of the following:

View file

@ -95,6 +95,10 @@ when a built website bundle exists:
- the website package now also ships `npm run render:same-origin-bundle` so a
single manifest can own the exact public origin and emit validated frontend
env, server env, `systemd`, and `nginx` outputs before installation
- the website package now also ships `npm run run:vps-same-origin-live-deploy`
so the same manifest/bundle lane can execute the bounded root-owned
checkout/env/build/systemd/nginx replacement flow and then validate the live
public origin automatically
## Preview-tier rehearsal posture

View file

@ -196,6 +196,38 @@ Live cutover result on `2026-06-22`:
- the remaining gap is launch-tier value completion, not root-owned website
host cutover
### 3B. Scripted live deploy after proof
The repo now also carries a bounded root-owned deploy helper that codifies the
same checkout/env/build/systemd/nginx sequence that was used for the live
preview cutover:
```bash
cd /home/dev/src/HyperTwist/website
npm run run:vps-same-origin-live-deploy -- \
--manifest /tmp/hypertwist.preview.bundle.json \
--identity-file /path/to/shared-vps-key \
--json
```
Current behavior of that helper:
- stages the committed `website/` tree into the live checkout root
- uploads the rendered bundle outputs
- creates the runtime user/group and owned state directories when needed
- installs `website/.env` plus `website/server/.env`
- runs `npm ci`, `npm --prefix server ci`, and `npm run build` as the service user
- verifies the rendered `systemd` unit, replaces the live `systemd` plus
`nginx` files, restarts the service, reloads NGINX, and then validates the
public origin through the runtime-readiness lane
- supports `--dry-run`, `--json`, `--archive-source worktree`,
`--skip-live-validation`, and `--keep-bundle-dir`
Latest helper result on `2026-06-22`:
- passed against the already-live shared-VPS preview deployment
- confirmed the helper is idempotent for the current preview-tier host state
### 4. Install the systemd service
Preferred validated bundle-generation path:

View file

@ -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. The same deployment lane now also distinguishes `launch` from `preview` posture so honest missing checkout/download/webhook/release values are accepted only for non-public rehearsal while placeholder strings still fail, `runtime.mode: mixed` plus `public_origin_ready: true` counts as valid preview-host proof, and the staging helper can archive either committed `HEAD` or the live worktree through `--archive-source worktree`. An isolated VPS-local staging proof then confirmed that both the committed HyperTwist website lane and the later preview-tier worktree packet can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and a later root-owned cutover then replaced the public placeholder site with the live first-party same-origin preview deployment on `https://hypertwist.app`. 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 or after root-owned cutover. |
| 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. The same deployment lane now also distinguishes `launch` from `preview` posture so honest missing checkout/download/webhook/release values are accepted only for non-public rehearsal while placeholder strings still fail, `runtime.mode: mixed` plus `public_origin_ready: true` counts as valid preview-host proof, and the staging helper can archive either committed `HEAD` or the live worktree through `--archive-source worktree`. An isolated VPS-local staging proof then confirmed that both the committed HyperTwist website lane and the later preview-tier worktree packet can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and a later root-owned cutover then replaced the public placeholder site with the live first-party same-origin preview deployment on `https://hypertwist.app`. The package now also ships a bounded root-owned live-deploy helper that stages the committed checkout, uploads the rendered bundle, installs env, rebuilds the site, replaces the live `systemd` plus `nginx` files, and validates the public origin; that helper has already been re-proved idempotently against the live host. 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 or after 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. |

View file

@ -285,7 +285,10 @@ Current consolidated milestone snapshot:
host before commit, and a later root-owned cutover then replaced the old
`/var/www/hypertwist` placeholder site with the live first-party same-origin
preview deployment on `https://hypertwist.app` while launch-tier commercial
and release values remain intentionally pending,
and release values remain intentionally pending, with that same root-owned
checkout/env/build/systemd/nginx replacement flow now codified in a
first-party live-deploy helper that has been re-proved idempotently on the
already-live host,
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

View file

@ -160,6 +160,10 @@ Use the runtime-readiness command before public launch or deployment approval:
and proven on the real shared host before any root-owned live cutover, with
optional `--archive-source worktree` support when the proof should validate
the in-progress local packet before commit rather than `HEAD`
- the repo now also includes `npm run run:vps-same-origin-live-deploy` so the
same manifest/bundle lane can stage the committed checkout, install env,
build the site, replace the `systemd` plus `nginx` files, and validate the
public origin in one bounded root-owned flow
- 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
@ -171,6 +175,8 @@ Live site status on `2026-06-22`:
instead of the older placeholder rollout page
- live `/health`, `/api/auth/health`, `/api/releases/manifest`, and root-shell
verification are green on that public origin
- the new live-deploy helper has also been re-proved idempotently against that
already-cut-over host
The repo bootstrap CI now also validates this lane through:

View file

@ -40,6 +40,11 @@ Before using these templates:
surfaces, and clean the temp lane back up on success; add
`--archive-source worktree` when the proof should use the in-progress local
worktree rather than committed `HEAD`
- after staging proof or for direct root-owned rollout, you can now also run
`npm run run:vps-same-origin-live-deploy -- --manifest <file> --identity-file <key>`
to stage the committed checkout, upload the rendered bundle, install env,
build the site, replace the live `systemd` plus `nginx` files, and validate
the public origin through the same readiness lane
- 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`

View file

@ -13,7 +13,8 @@
"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",
"run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs"
"run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs",
"run:vps-same-origin-live-deploy": "node scripts/run-vps-same-origin-live-deploy.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.83.0",

View file

@ -0,0 +1,74 @@
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-live-deploy.mjs')
describe('run-vps-same-origin-live-deploy CLI', () => {
it('supports dry-run json output with a sanitized manifest preview', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-cli-'))
const manifestPath = path.join(tempDir, 'bundle.json')
const identityFile = path.join(tempDir, 'id_ed25519')
fs.writeFileSync(manifestPath, JSON.stringify({
deploymentTier: 'preview',
checkoutRoot: '/srv/hypertwist/current',
publicOrigin: 'https://hypertwist.app',
supportEmail: 'hello@hypertwist.app',
publicDocsUrl: 'https://hypertwist.app/resources',
releaseNotesUrl: 'https://hypertwist.app/changelog',
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
operatorCheckoutUrl: '',
windowsDownloadUrl: '',
releaseManifestVersion: '',
windowsRelease: {
buildId: '',
sha256: '',
publishedAt: '',
},
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,
'--archive-source',
'worktree',
'--dry-run',
'--json',
],
{
cwd: websiteRoot,
encoding: 'utf8',
},
)
expect(result.status).toBe(0)
expect(result.stderr).toBe('')
const payload = JSON.parse(result.stdout)
expect(payload.vpsUser).toBe('root')
expect(payload.archiveSource).toBe('worktree')
expect(payload.manifest.server.paddleWebhookSecret).toBe('su***en')
expect(payload.deployScript).toContain('__HYPERTWIST_LIVE_DEPLOY_SUMMARY__')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,299 @@
import fs from 'node:fs'
import path from 'node:path'
import posixPath from 'node:path/posix'
import { buildSshArgs, sanitizeStagingProofManifest } from './run-vps-same-origin-staging-proof-lib.mjs'
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 normalizeArchiveSource(value, fallback = 'git-head') {
const normalized = normalizeTrimmed(value).toLowerCase()
if (normalized === 'worktree') {
return 'worktree'
}
if (normalized === 'git' || normalized === 'head' || normalized === 'git-head') {
return 'git-head'
}
return fallback
}
export function parseLiveDeployArgs(argv) {
const options = {
manifest: '',
identityFile: '',
vpsHost: '212.227.13.220',
vpsUser: 'root',
archiveSource: 'git-head',
bundleDir: '',
healthUrl: '',
waitSeconds: '30',
keepBundleDir: false,
skipLiveValidation: 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 '--archive-source':
options.archiveSource = next
index += 1
break
case '--bundle-dir':
options.bundleDir = next
index += 1
break
case '--health-url':
options.healthUrl = next
index += 1
break
case '--wait-seconds':
options.waitSeconds = next
index += 1
break
case '--keep-bundle-dir':
options.keepBundleDir = true
break
case '--skip-live-validation':
options.skipLiveValidation = true
break
case '--json':
options.json = true
break
case '--dry-run':
options.dryRun = true
break
default:
break
}
}
return options
}
export function loadLiveDeployManifest(filePath) {
const absolutePath = path.resolve(filePath)
const raw = fs.readFileSync(absolutePath, 'utf8')
return {
path: absolutePath,
manifest: JSON.parse(raw),
}
}
export function buildDefaultLiveDeployBundleDir(gitRef = 'head') {
const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-')
return `/tmp/hypertwist-live-deploy-${normalized || 'head'}`
}
export function resolveLiveDeployOptions({
manifestPath,
identityFile,
vpsHost,
vpsUser,
archiveSource,
bundleDir,
healthUrl,
waitSeconds,
keepBundleDir,
skipLiveValidation,
json,
dryRun,
manifest,
gitRef,
}) {
const checkoutRoot = requireNonEmpty(manifest?.checkoutRoot || '/srv/hypertwist/current', 'manifest.checkoutRoot')
const publicOrigin = requireNonEmpty(manifest?.publicOrigin || '', 'manifest.publicOrigin')
return {
manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')),
identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')),
vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'),
vpsUser: requireNonEmpty(vpsUser || 'root', 'vpsUser'),
archiveSource: normalizeArchiveSource(archiveSource, 'git-head'),
bundleDir: normalizeTrimmed(bundleDir) || buildDefaultLiveDeployBundleDir(gitRef),
checkoutRoot,
healthUrl: normalizeTrimmed(healthUrl) || publicOrigin,
waitSeconds: requirePositiveInteger(waitSeconds || '30', 'waitSeconds'),
keepBundleDir: normalizeBoolean(keepBundleDir, false),
skipLiveValidation: normalizeBoolean(skipLiveValidation, false),
json: normalizeBoolean(json, false),
dryRun: normalizeBoolean(dryRun, false),
}
}
export function buildStageCheckoutRemoteCommand(checkoutRoot) {
const parentDir = posixPath.dirname(checkoutRoot)
return `rm -rf ${JSON.stringify(checkoutRoot)} && mkdir -p ${JSON.stringify(parentDir)} ${JSON.stringify(checkoutRoot)} && tar -xf - -C ${JSON.stringify(checkoutRoot)}`
}
export function buildStageBundleRemoteCommand(bundleDir) {
return `rm -rf ${JSON.stringify(bundleDir)} && mkdir -p ${JSON.stringify(bundleDir)} && tar -xf - -C ${JSON.stringify(bundleDir)}`
}
export function sanitizeLiveDeployManifest(manifest) {
return sanitizeStagingProofManifest(manifest)
}
export function buildRemoteLiveDeployScript({
checkoutRoot,
bundleDir,
keepBundleDir,
manifest,
}) {
const serviceUser = normalizeTrimmed(manifest?.serviceUser || 'hypertwist')
const serviceGroup = normalizeTrimmed(manifest?.serviceGroup || serviceUser)
const billingStatePath = normalizeTrimmed(manifest?.server?.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json')
const billingStateDir = posixPath.dirname(billingStatePath)
return `set -euo pipefail
CHECKOUT_ROOT=${JSON.stringify(checkoutRoot)}
BUNDLE_DIR=${JSON.stringify(bundleDir)}
SERVICE_USER=${JSON.stringify(serviceUser)}
SERVICE_GROUP=${JSON.stringify(serviceGroup)}
BILLING_STATE_DIR=${JSON.stringify(billingStateDir)}
KEEP_BUNDLE_DIR=${keepBundleDir ? '1' : '0'}
SERVICE_NAME="hypertwist-website-auth-server.service"
NGINX_CONF_NAME="hypertwist-app.conf"
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
CHECKOUT_PARENT=$(dirname "$CHECKOUT_ROOT")
WEBSITE_DIR="$CHECKOUT_ROOT/website"
SERVER_DIR="$WEBSITE_DIR/server"
NGINX_BACKUP=""
PLACEHOLDER_BACKUP=""
cleanup() {
if [ "$KEEP_BUNDLE_DIR" = "1" ]; then
return
fi
rm -rf "$BUNDLE_DIR"
}
trap cleanup EXIT
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP"
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --create-home --home-dir "$CHECKOUT_PARENT" --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
fi
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$CHECKOUT_PARENT" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" "$BILLING_STATE_DIR"
cp "$BUNDLE_DIR/website.env" "$WEBSITE_DIR/.env"
cp "$BUNDLE_DIR/server.env" "$SERVER_DIR/.env"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")"
runuser -u "$SERVICE_USER" -- bash -lc "cd $(printf '%q' "$WEBSITE_DIR") && npm ci && npm --prefix server ci && npm run build"
systemd-analyze verify "$BUNDLE_DIR/hypertwist-website-auth-server.service"
if [ -f "/etc/nginx/sites-available/$NGINX_CONF_NAME" ]; then
NGINX_BACKUP="/etc/nginx/sites-available/$NGINX_CONF_NAME.bak-$STAMP"
cp "/etc/nginx/sites-available/$NGINX_CONF_NAME" "$NGINX_BACKUP"
fi
if [ -f "/var/www/hypertwist/index.html" ]; then
PLACEHOLDER_BACKUP="/var/www/hypertwist/index.html.pre-same-origin-$STAMP"
cp "/var/www/hypertwist/index.html" "$PLACEHOLDER_BACKUP"
fi
cp "$BUNDLE_DIR/hypertwist-website-auth-server.service" "/etc/systemd/system/$SERVICE_NAME"
cp "$BUNDLE_DIR/hypertwist.app.conf" "/etc/nginx/sites-available/$NGINX_CONF_NAME"
ln -sf "/etc/nginx/sites-available/$NGINX_CONF_NAME" "/etc/nginx/sites-enabled/$NGINX_CONF_NAME"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
nginx -t
systemctl reload nginx
SERVICE_STATUS=$(systemctl is-active "$SERVICE_NAME")
NGINX_STATUS=$(systemctl is-active nginx)
node - "$CHECKOUT_ROOT" "$BUNDLE_DIR" "$SERVICE_STATUS" "$NGINX_STATUS" "$NGINX_BACKUP" "$PLACEHOLDER_BACKUP" <<'__HYPERTWIST_LIVE_DEPLOY_SUMMARY__'
const checkoutRoot = process.argv[2]
const bundleDir = process.argv[3]
const serviceStatus = process.argv[4]
const nginxStatus = process.argv[5]
const nginxBackup = process.argv[6]
const placeholderBackup = process.argv[7]
const summary = {
ok: serviceStatus === 'active' && nginxStatus === 'active',
checkoutRoot,
bundleDir,
serviceStatus,
nginxStatus,
nginxBackup: nginxBackup || null,
placeholderBackup: placeholderBackup || null,
}
process.stdout.write(\`__HYPERTWIST_LIVE_DEPLOY_SUMMARY__\${JSON.stringify(summary)}\\n\`)
if (!summary.ok) {
process.exit(1)
}
__HYPERTWIST_LIVE_DEPLOY_SUMMARY__
`
}
export function extractLiveDeploySummary(output) {
const marker = '__HYPERTWIST_LIVE_DEPLOY_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))
}
export { buildSshArgs }

View file

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import {
buildDefaultLiveDeployBundleDir,
buildRemoteLiveDeployScript,
buildStageBundleRemoteCommand,
buildStageCheckoutRemoteCommand,
buildSshArgs,
extractLiveDeploySummary,
resolveLiveDeployOptions,
sanitizeLiveDeployManifest,
} from './run-vps-same-origin-live-deploy-lib.mjs'
function createManifest() {
return {
deploymentTier: 'preview',
checkoutRoot: '/srv/hypertwist/current',
publicOrigin: 'https://hypertwist.app',
serviceUser: 'hypertwist',
serviceGroup: 'hypertwist',
supportEmail: 'hello@hypertwist.app',
publicDocsUrl: 'https://hypertwist.app/resources',
releaseNotesUrl: 'https://hypertwist.app/changelog',
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
operatorCheckoutUrl: '',
windowsDownloadUrl: '',
releaseManifestVersion: '',
windowsRelease: {
buildId: '',
sha256: '',
publishedAt: '',
},
server: {
port: '3011',
billingStatePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json',
paddleWebhookSecret: 'super-secret-token',
},
}
}
describe('resolveLiveDeployOptions', () => {
it('normalizes the required CLI inputs', () => {
const options = resolveLiveDeployOptions({
manifestPath: 'deploy/hypertwist.same-origin.bundle.json',
identityFile: '/tmp/key',
vpsHost: '212.227.13.220',
vpsUser: 'root',
archiveSource: 'worktree',
bundleDir: '',
healthUrl: '',
waitSeconds: '45',
keepBundleDir: true,
skipLiveValidation: false,
json: true,
dryRun: false,
manifest: createManifest(),
gitRef: 'abc1234',
})
expect(options.vpsUser).toBe('root')
expect(options.archiveSource).toBe('worktree')
expect(options.bundleDir).toBe('/tmp/hypertwist-live-deploy-abc1234')
expect(options.healthUrl).toBe('https://hypertwist.app')
expect(options.waitSeconds).toBe(45)
expect(options.keepBundleDir).toBe(true)
})
})
describe('sanitizeLiveDeployManifest', () => {
it('masks secret-like values before dry-run output', () => {
const sanitized = sanitizeLiveDeployManifest(createManifest())
expect(sanitized.server.paddleWebhookSecret).toBe('su***en')
})
})
describe('buildRemoteLiveDeployScript', () => {
it('includes the expected root-owned deployment steps and summary marker', () => {
const script = buildRemoteLiveDeployScript({
checkoutRoot: '/srv/hypertwist/current',
bundleDir: '/tmp/hypertwist-live-deploy-abc1234',
keepBundleDir: false,
manifest: createManifest(),
})
expect(script).toContain('useradd --system')
expect(script).toContain('npm ci && npm --prefix server ci && npm run build')
expect(script).toContain('systemctl restart "$SERVICE_NAME"')
expect(script).toContain('nginx -t')
expect(script).toContain('__HYPERTWIST_LIVE_DEPLOY_SUMMARY__')
})
})
describe('extractLiveDeploySummary', () => {
it('parses the emitted summary marker from remote output', () => {
const summary = extractLiveDeploySummary([
'[live_checkout_root] /srv/hypertwist/current',
'__HYPERTWIST_LIVE_DEPLOY_SUMMARY__{"ok":true,"serviceStatus":"active","nginxStatus":"active"}',
].join('\n'))
expect(summary).toEqual({
ok: true,
serviceStatus: 'active',
nginxStatus: 'active',
})
})
})
describe('ssh helpers', () => {
it('builds the archive and ssh command shapes', () => {
expect(buildDefaultLiveDeployBundleDir('abc1234')).toBe('/tmp/hypertwist-live-deploy-abc1234')
expect(buildStageCheckoutRemoteCommand('/srv/hypertwist/current')).toContain('tar -xf - -C "/srv/hypertwist/current"')
expect(buildStageBundleRemoteCommand('/tmp/hypertwist-live-deploy-abc1234')).toContain('tar -xf - -C "/tmp/hypertwist-live-deploy-abc1234"')
expect(buildSshArgs({
identityFile: '/tmp/key',
vpsUser: 'root',
vpsHost: '212.227.13.220',
remoteCommand: 'echo hello',
})).toEqual([
'-i',
'/tmp/key',
'-o',
'StrictHostKeyChecking=no',
'root@212.227.13.220',
'echo hello',
])
})
})

View file

@ -0,0 +1,293 @@
#!/usr/bin/env node
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
buildRuntimeReadinessReport,
fetchLiveAuthHealth,
fetchLiveReleaseManifest,
fetchLiveWebsiteShell,
formatRuntimeReadinessReport,
parseEnvFile,
} from './runtime-readiness-lib.mjs'
import { writeSameOriginBundle } from './render-same-origin-bundle-lib.mjs'
import {
buildDefaultLiveDeployBundleDir,
buildRemoteLiveDeployScript,
buildSshArgs,
buildStageBundleRemoteCommand,
buildStageCheckoutRemoteCommand,
extractLiveDeploySummary,
loadLiveDeployManifest,
parseLiveDeployArgs,
resolveLiveDeployOptions,
sanitizeLiveDeployManifest,
} from './run-vps-same-origin-live-deploy-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 buildArchiveBuffer(repoRootPath, archiveSource) {
if (archiveSource === 'worktree') {
return execFileSync('tar', [
'--exclude=website/node_modules',
'--exclude=website/dist',
'--exclude=website/coverage',
'--exclude=website/server/node_modules',
'--exclude=website/server/dist',
'-cf',
'-',
'website',
], {
cwd: repoRootPath,
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
return execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], {
cwd: repoRootPath,
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
function buildRenderedBundleArchive(renderedDir) {
return execFileSync('tar', ['-cf', '-', '-C', renderedDir, '.'], {
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function waitForLiveAuthHealth(baseUrl, waitSeconds) {
let lastError = null
for (let attempt = 1; attempt <= waitSeconds; attempt += 1) {
try {
return await fetchLiveAuthHealth(baseUrl)
} catch (error) {
lastError = error
if (attempt < waitSeconds) {
await sleep(1000)
}
}
}
throw lastError || new Error(`Live auth health did not become ready within ${waitSeconds} seconds.`)
}
async function main() {
const rawArgs = parseLiveDeployArgs(process.argv.slice(2))
const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim()
const { path: manifestPath, manifest } = loadLiveDeployManifest(rawArgs.manifest)
const options = resolveLiveDeployOptions({
manifestPath,
identityFile: rawArgs.identityFile,
vpsHost: rawArgs.vpsHost,
vpsUser: rawArgs.vpsUser,
archiveSource: rawArgs.archiveSource,
bundleDir: rawArgs.bundleDir,
healthUrl: rawArgs.healthUrl,
waitSeconds: rawArgs.waitSeconds,
keepBundleDir: rawArgs.keepBundleDir,
skipLiveValidation: rawArgs.skipLiveValidation,
json: rawArgs.json,
dryRun: rawArgs.dryRun,
manifest,
gitRef,
})
const deployScript = buildRemoteLiveDeployScript({
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
keepBundleDir: options.keepBundleDir,
manifest,
})
const checkoutStageCommand = buildStageCheckoutRemoteCommand(options.checkoutRoot)
const bundleStageCommand = buildStageBundleRemoteCommand(options.bundleDir)
const sanitizedManifest = sanitizeLiveDeployManifest(manifest)
if (options.dryRun) {
const payload = {
repoRoot,
websiteRoot,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
archiveSource: options.archiveSource,
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
healthUrl: options.healthUrl,
waitSeconds: options.waitSeconds,
keepBundleDir: options.keepBundleDir,
skipLiveValidation: options.skipLiveValidation,
checkoutStageCommand,
bundleStageCommand,
manifest: sanitizedManifest,
deployScript,
defaultBundleDirForGitRef: buildDefaultLiveDeployBundleDir(gitRef),
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
} else {
process.stdout.write(`[live-deploy] manifest: ${manifestPath}\n`)
process.stdout.write(`[live-deploy] vps: ${options.vpsUser}@${options.vpsHost}\n`)
process.stdout.write(`[live-deploy] archive source: ${options.archiveSource}\n`)
process.stdout.write(`[live-deploy] checkout root: ${options.checkoutRoot}\n`)
process.stdout.write(`[live-deploy] bundle dir: ${options.bundleDir}\n`)
process.stdout.write(`[live-deploy] health url: ${options.healthUrl}\n`)
}
return
}
const localBundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-'))
try {
const rendered = writeSameOriginBundle({
manifest,
outDir: localBundleDir,
})
const frontendEnv = parseEnvFile(rendered.frontendEnvContent)
const serverEnv = parseEnvFile(rendered.serverEnvContent)
const checkoutArchiveBuffer = buildArchiveBuffer(repoRoot, options.archiveSource)
const bundleArchiveBuffer = buildRenderedBundleArchive(localBundleDir)
const checkoutStageResult = runCommand('ssh', buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
remoteCommand: checkoutStageCommand,
}), {
input: checkoutArchiveBuffer,
encoding: 'utf8',
})
if (checkoutStageResult.status !== 0) {
process.stderr.write(checkoutStageResult.stderr || '')
process.exit(checkoutStageResult.status || 1)
}
const bundleStageResult = runCommand('ssh', buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
remoteCommand: bundleStageCommand,
}), {
input: bundleArchiveBuffer,
encoding: 'utf8',
})
if (bundleStageResult.status !== 0) {
process.stderr.write(bundleStageResult.stderr || '')
process.exit(bundleStageResult.status || 1)
}
const deployResult = runCommand('ssh', [
...buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
}),
'bash',
'-s',
], {
input: deployScript,
})
process.stderr.write(deployResult.stderr || '')
const deploySummary = extractLiveDeploySummary(deployResult.stdout || '')
let liveHealth = null
let liveReleaseManifest = null
let liveWebsiteShell = null
const attemptedLiveValidation = !options.skipLiveValidation
if (!options.skipLiveValidation) {
liveHealth = await waitForLiveAuthHealth(options.healthUrl, options.waitSeconds)
liveReleaseManifest = await fetchLiveReleaseManifest(options.healthUrl)
liveWebsiteShell = await fetchLiveWebsiteShell(options.healthUrl)
}
const readinessReport = buildRuntimeReadinessReport({
frontendEnv,
serverEnv,
liveHealth,
liveReleaseManifest,
liveWebsiteShell,
liveHealthAttempted: attemptedLiveValidation,
liveReleaseManifestAttempted: attemptedLiveValidation,
liveWebsiteShellAttempted: attemptedLiveValidation,
frontendEnvPath: path.join(localBundleDir, 'website.env'),
serverEnvPath: path.join(localBundleDir, 'server.env'),
healthBaseUrl: options.healthUrl,
deploymentTier: manifest.deploymentTier || manifest?.server?.deploymentTier || '',
})
const payload = {
ok: deployResult.status === 0 && readinessReport.ok,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
archiveSource: options.archiveSource,
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
healthUrl: options.healthUrl,
deploySummary,
readinessReport,
liveHealth,
liveReleaseManifest,
liveWebsiteShell,
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
} else {
if (deploySummary) {
process.stdout.write(`[live-deploy] service status: ${deploySummary.serviceStatus}\n`)
process.stdout.write(`[live-deploy] nginx status: ${deploySummary.nginxStatus}\n`)
}
process.stdout.write(`${formatRuntimeReadinessReport(readinessReport)}\n`)
}
if (deployResult.status !== 0 || !readinessReport.ok) {
process.exit(deployResult.status || 1)
}
} finally {
fs.rmSync(localBundleDir, { recursive: true, force: true })
}
}
main().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})

View file

@ -105,6 +105,10 @@ The server now also supports a bounded first-party same-origin deployment mode:
while public launch still requires real checkout/download/webhook values
- the staging-proof helper now also supports `--archive-source worktree` when
operators need real host proof for the exact in-progress packet before commit
- the same package now also ships `npm run run:vps-same-origin-live-deploy`
for the bounded root-owned checkout/env/build/systemd/nginx replacement flow
that was used to cut `https://hypertwist.app` over to the first-party
same-origin preview lane and then re-proved idempotently
The website package now also ships a deploy-time verification command: