Add scripted live website deploy helper
This commit is contained in:
parent
e16e311ec0
commit
5d2e975514
13 changed files with 861 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
74
website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs
Normal file
74
website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
299
website/scripts/run-vps-same-origin-live-deploy-lib.mjs
Normal file
299
website/scripts/run-vps-same-origin-live-deploy-lib.mjs
Normal 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 }
|
||||
129
website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs
Normal file
129
website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
293
website/scripts/run-vps-same-origin-live-deploy.mjs
Normal file
293
website/scripts/run-vps-same-origin-live-deploy.mjs
Normal 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)
|
||||
})
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue