diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index ae12e47..eac7522 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -6,8 +6,12 @@ on: branches: [main, master] paths: - "apps/site/**" + - "apps/web/**" + - "packages/**" - "scripts/check-release-publication.mjs" - "scripts/deploy-site.mjs" + - "scripts/deploy-demo.mjs" + - "scripts/deploy-site-bundle.mjs" - "scripts/dispatch-site-deployment.mjs" - "scripts/generate-release-manifest.mjs" - "scripts/inspect-linux-update.mjs" @@ -153,9 +157,18 @@ jobs: ref: ${{ steps.immutable_source.outputs.source_sha }} persist-credentials: false + - name: Check out trusted demo source + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ steps.source.outputs.trusted_sha }} + path: .trusted-demo-source + persist-credentials: false - name: Install dependencies if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} - run: pnpm install --frozen-lockfile + run: | + pnpm install --frozen-lockfile + pnpm --dir .trusted-demo-source install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} @@ -169,5 +182,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} + T4_IMMUTABLE_SITE_SOURCE: ${{ github.workspace }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} - run: pnpm deploy:site + run: pnpm --dir .trusted-demo-source deploy:site-bundle diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 28721bb..2e0a544 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -17,7 +17,7 @@ import { getShellData, useShellData } from "../state/shell-data.ts"; import { RAIL_OVERLAY_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts"; import { isEditableTarget, resolveShortcut } from "../keyboard/shortcuts.ts"; import { buildProjectGroups, listVisibleSessionIds } from "../lib/session-tree.ts"; -import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; +import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { RAIL_COLLAPSED_WIDTH, RAIL_WIDTH, selectSessionView } from "../state/workspace-store.ts"; import { CommandPalette } from "./CommandPalette.tsx"; import { CollapsedRail, Rail } from "./Rail.tsx"; @@ -195,6 +195,20 @@ export function AppShell() { }} railToggle={railToggle} /> + {rendererPlatform.demo && ( +
+ Sample data + + + Explore freely. No live hosts, accounts, or files are connected. + +
+ )}
{!railOverlaid && !focusMode && ( <> diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index 6059f73..63f10c3 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -38,7 +38,7 @@ import { PaneContent } from "../features/panes/PaneContent.tsx"; import { TerminalDrawer } from "../features/terminal/TerminalDrawer.tsx"; import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx"; import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts"; -import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; +import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; import { resolveLiveSession } from "../platform/live-workspace.ts"; import { useShellData } from "../state/shell-data.ts"; @@ -275,12 +275,13 @@ export function SessionScreen({ const runtimeSnapshot = useDesktopRuntimeSnapshot(); const previewAddress = runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id); - const previewCount = - previewAddress === null + const previewCount = rendererPlatform.demo + ? 1 + : previewAddress === null ? 0 - : (runtimeSnapshot?.projection.sessions - .get(`${previewAddress.hostId}\u0000${previewAddress.sessionId}`) - ?.previews.size ?? 0); + : (runtimeSnapshot?.projection.sessions.get( + `${previewAddress.hostId}\u0000${previewAddress.sessionId}`, + )?.previews.size ?? 0); const [panePreviewWidth, setPanePreviewWidth] = useState(null); // Transcript scroll ownership lives in TranscriptTimeline (virtualized diff --git a/apps/web/src/features/agent-view/AgentViewScreen.tsx b/apps/web/src/features/agent-view/AgentViewScreen.tsx index 52f5d0c..ee650b9 100644 --- a/apps/web/src/features/agent-view/AgentViewScreen.tsx +++ b/apps/web/src/features/agent-view/AgentViewScreen.tsx @@ -57,18 +57,23 @@ function AgentCard({ group, nowMs, row, + sampleMode, snapshot, onCancel, }: { readonly group: AgentViewGroup; readonly nowMs: number; readonly row: AgentViewRow; - readonly snapshot: DesktopRuntimeSnapshot; + readonly sampleMode: boolean; + readonly snapshot: DesktopRuntimeSnapshot | null; readonly onCancel: () => void; }) { const { node } = row; const style = AGENT_STATE_STYLES[node.state]; - const availability = agentCancelAvailability(snapshot, group.viewId, node); + const availability = + sampleMode || snapshot === null + ? { enabled: false, reason: "Sample data is local and cannot stop an agent." } + : agentCancelAvailability(snapshot, group.viewId, node); const elapsed = node.state === "running" ? formatElapsed(node.startedAt, nowMs) : ""; const contextPercent = node.contextUsed === null || node.contextLimit === null || node.contextLimit === 0 @@ -143,28 +148,43 @@ function AgentCard({ ); } +type AgentViewFixtureProps = + | { + readonly fixtureGroups?: never; + readonly fixtureNowMs?: never; + } + | { + readonly fixtureGroups: readonly AgentViewGroup[]; + readonly fixtureNowMs: number; + }; + +interface AgentViewScreenProps { + readonly controller: AgentViewRuntime | null; + readonly snapshot: DesktopRuntimeSnapshot | null; + readonly onBack: () => void; + readonly onOpenSession: (sessionId: string) => void; +} + export function AgentViewScreen({ controller, + fixtureGroups, + fixtureNowMs, snapshot, onBack, onOpenSession, -}: { - readonly controller: AgentViewRuntime | null; - readonly snapshot: DesktopRuntimeSnapshot | null; - readonly onBack: () => void; - readonly onOpenSession: (sessionId: string) => void; -}) { +}: AgentViewScreenProps & AgentViewFixtureProps) { const groups = useMemo( - () => (snapshot === null ? [] : deriveAgentViewGroups(snapshot)), - [snapshot], + () => (snapshot === null ? (fixtureGroups ?? []) : deriveAgentViewGroups(snapshot)), + [fixtureGroups, snapshot], ); + const sampleMode = snapshot === null && fixtureGroups !== undefined; const agentCount = groups.reduce((sum, group) => sum + group.agents.length, 0); const runningCount = groups.reduce( - (sum, group) => - sum + group.agents.filter(({ node }) => node.state === "running").length, + (sum, group) => sum + group.agents.filter(({ node }) => node.state === "running").length, 0, ); - const nowMs = useNowTick(runningCount > 0); + const liveNowMs = useNowTick(runningCount > 0 && !sampleMode); + const nowMs = sampleMode && fixtureNowMs !== undefined ? fixtureNowMs : liveNowMs; const [pending, setPending] = useState(null); const [sending, setSending] = useState(false); const [error, setError] = useState(null); @@ -211,7 +231,7 @@ export function AgentViewScreen({ {announcement}

- {snapshot === null ? ( + {snapshot === null && fixtureGroups === undefined ? ( Agent View requires the desktop runtime @@ -282,6 +302,7 @@ export function AgentViewScreen({ setPending({ group, row }); }} row={row} + sampleMode={sampleMode} snapshot={snapshot} /> ))} diff --git a/apps/web/src/features/agent-view/fixtures.ts b/apps/web/src/features/agent-view/fixtures.ts new file mode 100644 index 0000000..f25007a --- /dev/null +++ b/apps/web/src/features/agent-view/fixtures.ts @@ -0,0 +1,33 @@ +import { SHELL_FIXTURE } from "../../fixture/data.ts"; +import { FIXTURE_EPOCH_MS, fixtureAgentsForSession } from "../panes/fixtures.ts"; +import type { AgentViewGroup } from "./model.ts"; + +const VISIBLE_SESSION_IDS: Readonly> = { + "sess-stream": true, + "sess-bundle": true, +}; + +export const AGENT_VIEW_FIXTURE_NOW_MS = FIXTURE_EPOCH_MS; + +/** Global Agent View sample built from the same agents shown in session panes. */ +export const AGENT_VIEW_FIXTURE_GROUPS: readonly AgentViewGroup[] = SHELL_FIXTURE.sessions + .filter((session) => VISIBLE_SESSION_IDS[session.id] === true) + .map((session) => ({ + viewId: session.id, + session, + projectName: + SHELL_FIXTURE.projects.find((project) => project.id === session.projectId)?.name ?? + "Sample project", + agents: fixtureAgentsForSession(session.id).map((node) => ({ + node, + task: + node.kind === "main" + ? "Coordinate the reconnect investigation and consolidate verified findings." + : node.kind === "batch" + ? "Inspect the replay boundary, soak behavior, and documentation in parallel." + : node.path === null + ? "Report a bounded finding back to the parent session." + : `Inspect ${node.path} and report evidence.`, + resumable: node.state === "parked", + })), + })); diff --git a/apps/web/src/features/panes/fixtures.ts b/apps/web/src/features/panes/fixtures.ts index fc66747..8073c31 100644 --- a/apps/web/src/features/panes/fixtures.ts +++ b/apps/web/src/features/panes/fixtures.ts @@ -793,6 +793,11 @@ function agentsForSession(sessionId: string): readonly AgentNode[] { ); } +/** Shared deterministic agent corpus for fixture-only surfaces such as Agent View. */ +export function fixtureAgentsForSession(sessionId: string): readonly AgentNode[] { + return agentsForSession(sessionId); +} + function fixtureController(api: InspectorStoreApi, clock: () => number): InspectorController { const editedFiles = new Map(); return { diff --git a/apps/web/src/features/preview/FixturePreviewWorkspace.tsx b/apps/web/src/features/preview/FixturePreviewWorkspace.tsx new file mode 100644 index 0000000..c767836 --- /dev/null +++ b/apps/web/src/features/preview/FixturePreviewWorkspace.tsx @@ -0,0 +1,189 @@ +import { Badge, Button, cn } from "@t4-code/ui"; +import { + ArrowLeft, + ChevronLeft, + ChevronRight, + LockKeyhole, + RotateCw, + type LucideIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import type { WorkspaceProject, WorkspaceSession } from "../../lib/workspace-data.ts"; + +const SAMPLE_PREVIEW_URL = "https://preview.example.test/reconnect"; +const SAMPLE_NAVIGATION_ACTIONS: readonly { + readonly label: string; + readonly Icon: LucideIcon; +}[] = [ + { label: "Back", Icon: ChevronLeft }, + { label: "Forward", Icon: ChevronRight }, + { label: "Reload", Icon: RotateCw }, +]; + +export function FixturePreviewWorkspace({ + session, + project, +}: { + readonly session: WorkspaceSession; + readonly project: WorkspaceProject; +}) { + const navigate = useNavigate(); + const [scale, setScale] = useState<"fit" | "actual">("fit"); + + return ( +
+
+ +
+ +
+
+
+
+ +
+ {SAMPLE_NAVIGATION_ACTIONS.map(({ label, Icon }) => ( + + ))} +
+
+

+

+
+ +
+
+ Snapshot + Read-only fixture + + + +
+
+
+
+
+ T4 +
+
+

Reconnect diagnostics

+

+ Fixture dashboard · captured 9:12 AM +

+
+ + Healthy + +
+
+ {[ + ["Active sessions", "12", "All replay cursors current"], + ["Buffered frames", "0", "No duplicate sequence IDs"], + ["Reconnect p95", "184 ms", "Within the 250 ms target"], + ].map(([label, value, detail]) => ( +
+

{label}

+

{value}

+

{detail}

+
+ ))} +
+
+
+ Recent reconnect + Result +
+ {[ + ["dev-server · epoch 8", "Recovered"], + ["This machine · epoch 21", "No gap"], + ["test-runner · epoch 5", "Recovered"], + ].map(([name, result]) => ( +
+ {name} + {result} +
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/platform/bridge.ts b/apps/web/src/platform/bridge.ts index 274f3ab..18aa807 100644 --- a/apps/web/src/platform/bridge.ts +++ b/apps/web/src/platform/bridge.ts @@ -11,9 +11,15 @@ import { WORKSPACE_STORAGE_KEY } from "../state/workspace-store.ts"; export type ShellPlatform = "linux" | "darwin"; +export interface RendererPlatformOptions { + readonly forceFixture?: boolean; +} + export interface RendererPlatform { /** "desktop" when the Electron preload injected the shell port. */ readonly mode: "desktop" | "browser"; + /** True only for the explicit, read-only public demo build. */ + readonly demo: boolean; readonly platform: ShellPlatform; /** Workspace view-state persistence; always renderer-local. */ readonly persistence: WorkspacePersistence; @@ -33,23 +39,27 @@ function injectedShell(): DesktopShellPort | null { return shell !== undefined && shell.kind === "desktop" ? shell : null; } -export function resolveRendererPlatform(platformOverride?: ShellPlatform): RendererPlatform { - const shell = injectedShell(); +export function resolveRendererPlatform( + platformOverride?: ShellPlatform, + options: RendererPlatformOptions = {}, +): RendererPlatform { + const forceFixture = options.forceFixture === true; + const shell = forceFixture ? null : injectedShell(); const platform = shell?.platform ?? platformOverride ?? (typeof navigator !== "undefined" && /mac/i.test(navigator.platform) ? "darwin" : "linux"); - // Browser mode: try to create a browser-direct shell port that connects - // to the OMP appserver over WebSocket. If no backend config is detected, - // fall through to the original browser mode (fixture/demo data). + // The public demo must stay on deterministic fixtures even if a URL or + // injected global tries to supply a live backend. let resolvedShell: DesktopShellPort | null = shell; - if (resolvedShell === null) { + if (resolvedShell === null && !forceFixture) { resolvedShell = createBrowserShellPort(); } return { mode: resolvedShell === null ? "browser" : "desktop", + demo: forceFixture, platform, persistence: createLocalStoragePersistence(WORKSPACE_STORAGE_KEY), shell: resolvedShell, diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 3e4cf4d..6ec3c39 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -25,7 +25,12 @@ import { AppShell } from "./components/AppShell.tsx"; import { HomePane } from "./components/HomePane.tsx"; import { SessionScreen } from "./components/SessionScreen.tsx"; import { AgentViewScreen } from "./features/agent-view/AgentViewScreen.tsx"; +import { + AGENT_VIEW_FIXTURE_GROUPS, + AGENT_VIEW_FIXTURE_NOW_MS, +} from "./features/agent-view/fixtures.ts"; import { PreviewWorkspace } from "./features/preview/PreviewWorkspace.tsx"; +import { FixturePreviewWorkspace } from "./features/preview/FixturePreviewWorkspace.tsx"; import { LiveAttentionInbox } from "./features/attention/index.ts"; import { LiveTranscriptSearch } from "./features/transcript-search/index.ts"; import { TRANSCRIPT_SEARCH_ROUTE } from "./features/transcript-search/route.ts"; @@ -263,7 +268,13 @@ function PreviewRoute() { const { sessionId } = useParams({ from: "/sessions/$sessionId/preview" }); return ( - {(session, project) => } + {(session, project) => + rendererPlatform.demo ? ( + + ) : ( + + ) + } ); } @@ -287,6 +298,12 @@ function AgentViewRoute() { return ( { if (activeSessionId === null) void navigate({ to: "/" }); else void navigate({ params: { sessionId: activeSessionId }, to: "/sessions/$sessionId" }); diff --git a/apps/web/src/state/store-instance.ts b/apps/web/src/state/store-instance.ts index a2b0001..f48d789 100644 --- a/apps/web/src/state/store-instance.ts +++ b/apps/web/src/state/store-instance.ts @@ -24,8 +24,11 @@ const bootOptions = parseFixtureBootOptions( typeof window === "undefined" ? "" : window.location.search, ); +const demoMode = import.meta.env.MODE === "demo"; + export const rendererPlatform: RendererPlatform = resolveRendererPlatform( bootOptions.platform ?? undefined, + { forceFixture: demoMode }, ); const browserMode = rendererPlatform.mode === "browser"; diff --git a/apps/web/test/browser-platform.test.ts b/apps/web/test/browser-platform.test.ts index c2d6b1c..90aace0 100644 --- a/apps/web/test/browser-platform.test.ts +++ b/apps/web/test/browser-platform.test.ts @@ -83,6 +83,24 @@ describe("browser platform boundary", () => { Object.defineProperty(globalThis, "window", { configurable: true, value: undefined }); const platform = resolveRendererPlatform("linux"); expect(platform.mode).toBe("browser"); + expect(platform.demo).toBe(false); + expect(platform.shell).toBeNull(); + }); + + it("forces the public demo onto fixtures despite live backend inputs", () => { + Object.defineProperty(globalThis, "document", { configurable: true, value: undefined }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { search: "?backend=wss%3A%2F%2Fomp.example%2Fv1%2Fws" }, + ompShell: { kind: "desktop", platform: "darwin" }, + }, + }); + + const platform = resolveRendererPlatform("linux", { forceFixture: true }); + expect(platform.mode).toBe("browser"); + expect(platform.platform).toBe("linux"); + expect(platform.demo).toBe(true); expect(platform.shell).toBeNull(); }); diff --git a/apps/web/test/panes-fixtures.test.ts b/apps/web/test/panes-fixtures.test.ts index 2b17b2f..456e0fd 100644 --- a/apps/web/test/panes-fixtures.test.ts +++ b/apps/web/test/panes-fixtures.test.ts @@ -3,6 +3,10 @@ // reproducible instants — no module-load wall clock anywhere. import { describe, expect, it } from "vite-plus/test"; +import { + AGENT_VIEW_FIXTURE_GROUPS, + AGENT_VIEW_FIXTURE_NOW_MS, +} from "../src/features/agent-view/fixtures.ts"; import { FIXTURE_EPOCH_MS, installFixtureInspector, @@ -75,3 +79,21 @@ describe("fixture determinism", () => { expect(store.getState().files.draftsByPath[path]?.baseRevision).toBe("fixture-revision-1"); }); }); + +describe("global Agent View fixtures", () => { + it("exposes deterministic multi-session lifecycle samples", () => { + expect(AGENT_VIEW_FIXTURE_NOW_MS).toBe(FIXTURE_EPOCH_MS); + expect(AGENT_VIEW_FIXTURE_GROUPS.map(({ viewId }) => viewId)).toEqual([ + "sess-stream", + "sess-bundle", + ]); + + const agents = AGENT_VIEW_FIXTURE_GROUPS.flatMap(({ agents }) => agents); + expect(agents).toHaveLength(11); + expect(new Set(agents.map(({ node }) => node.state))).toEqual( + new Set(["running", "waiting", "parked", "completed", "failed", "queued"]), + ); + expect(agents.some(({ node }) => node.path === "packages/client/src/replay.ts")).toBe(true); + expect(agents.find(({ node }) => node.state === "parked")?.resumable).toBe(true); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ce27c72..6c71db6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,7 +2,18 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -export default defineConfig({ - base: "./", - plugins: [react(), tailwindcss()], -}); +export default defineConfig(({ mode }) => ({ + base: mode === "demo" ? "/demo/" : "./", + plugins: [ + react(), + tailwindcss(), + ...(mode === "demo" + ? [ + { + name: "t4-demo-document-root", + transformIndexHtml: (html: string) => html.replaceAll('="./', '="/demo/'), + }, + ] + : []), + ], +})); diff --git a/package.json b/package.json index 0cd0284..078a1ce 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "build:web": "pnpm --filter @t4-code/web build", "build:desktop": "pnpm --filter @t4-code/desktop build", "build:site": "pnpm --filter @t4-code/site build", + "build:demo": "pnpm --filter @t4-code/web exec vp build --mode demo --base /demo/ --outDir ../site/dist/demo --emptyOutDir", "deploy:site": "node scripts/deploy-site.mjs", + "deploy:demo": "node scripts/deploy-demo.mjs", + "deploy:site-bundle": "node scripts/deploy-site-bundle.mjs", "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", @@ -23,7 +26,7 @@ "inspect:package": "node scripts/inspect-package.mjs", "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs", - "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", + "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", "check:release": "node scripts/check-release-consistency.mjs", "check:provenance": "node scripts/check-provenance.mjs", "lint": "vp lint --deny-warnings", diff --git a/scripts/deploy-demo.mjs b/scripts/deploy-demo.mjs new file mode 100644 index 0000000..3041825 --- /dev/null +++ b/scripts/deploy-demo.mjs @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveDeployConfig } from "./deploy-site.mjs"; + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env: process.env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status ?? "unknown"}`); + } +} + +const DOCUMENT_URL_PATTERN = /\b(?:href|src)="([^"]+)"/gu; + +export function assertDemoDocumentPaths(document) { + const urls = [...document.matchAll(DOCUMENT_URL_PATTERN)].map((match) => match[1]); + const localUrls = urls.filter( + (url) => + url !== undefined && + !url.startsWith("data:") && + !url.startsWith("http:") && + !url.startsWith("https:") && + !url.startsWith("#"), + ); + if (localUrls.length === 0) throw new Error("demo index does not reference local assets"); + const escaped = localUrls.find((url) => !url.startsWith("/demo/")); + if (escaped !== undefined) throw new Error(`demo asset escapes /demo/: ${escaped}`); +} + +export function validateDemoBuild(repoRoot) { + const document = readFileSync(resolve(repoRoot, "apps/site/dist/demo/index.html"), "utf8"); + assertDemoDocumentPaths(document); +} + +export function deployDemo( + config, + repoRoot = resolve(import.meta.dirname, ".."), + runCommand = run, + validateBuild = validateDemoBuild, +) { + const destination = `s3://${config.bucket}/demo`; + runCommand("pnpm", ["build:demo"], repoRoot); + validateBuild(repoRoot); + runCommand( + "aws", + [ + "s3", + "sync", + "apps/site/dist/demo/assets", + `${destination}/assets`, + "--cache-control", + "public,max-age=31536000,immutable", + "--only-show-errors", + ], + repoRoot, + ); + runCommand( + "aws", + [ + "s3", + "sync", + "apps/site/dist/demo", + destination, + "--delete", + "--exclude", + "assets/*", + "--cache-control", + "public,max-age=0,must-revalidate", + "--only-show-errors", + ], + repoRoot, + ); + runCommand( + "aws", + [ + "cloudfront", + "create-invalidation", + "--distribution-id", + config.distributionId, + "--paths", + "/demo", + "/demo/*", + ], + repoRoot, + ); +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + deployDemo(resolveDeployConfig()); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/deploy-demo.test.mjs b/scripts/deploy-demo.test.mjs new file mode 100644 index 0000000..a619a40 --- /dev/null +++ b/scripts/deploy-demo.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { assertDemoDocumentPaths, deployDemo } from "./deploy-demo.mjs"; +import { deploySiteBundle } from "./deploy-site-bundle.mjs"; + +test("demo deploy replaces only the demo prefix after immutable assets", () => { + const calls = []; + deployDemo( + { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" }, + "/repo", + (command, args, cwd) => calls.push({ command, args, cwd }), + () => undefined, + ); + + assert.equal(calls.length, 4); + assert.deepEqual(calls[0], { command: "pnpm", args: ["build:demo"], cwd: "/repo" }); + assert.equal(calls[1].args[2], "apps/site/dist/demo/assets"); + assert.equal(calls[1].args[3], "s3://t4code-net-site-595529182031/demo/assets"); + assert.equal(calls[1].args.includes("--delete"), false); + assert.equal(calls[2].args[2], "apps/site/dist/demo"); + assert.equal(calls[2].args[3], "s3://t4code-net-site-595529182031/demo"); + assert.equal(calls[2].args.includes("--delete"), true); + assert.deepEqual(calls[3].args.slice(-3), ["--paths", "/demo", "/demo/*"]); + assert.deepEqual( + calls.map(({ cwd }) => cwd), + ["/repo", "/repo", "/repo", "/repo"], + ); +}); + +test("demo build keeps every local document URL under /demo", () => { + assert.doesNotThrow(() => + assertDemoDocumentPaths( + '', + ), + ); + assert.throws( + () => assertDemoDocumentPaths(''), + /demo asset escapes/u, + ); + assert.throws(() => assertDemoDocumentPaths("
No assets
"), /does not reference/u); +}); + +test("site bundle preserves the demo while deploying immutable release content", () => { + const calls = []; + const config = { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" }; + deploySiteBundle( + config, + "/release-source", + "/trusted-demo-source", + (receivedConfig, root) => calls.push({ kind: "site", config: receivedConfig, root }), + (receivedConfig, root) => calls.push({ kind: "demo", config: receivedConfig, root }), + ); + + assert.deepEqual(calls, [ + { kind: "site", config, root: "/release-source" }, + { kind: "demo", config, root: "/trusted-demo-source" }, + ]); + assert.throws( + () => deploySiteBundle(config, "relative-release-source"), + /T4_IMMUTABLE_SITE_SOURCE must be an absolute path/u, + ); +}); diff --git a/scripts/deploy-site-bundle.mjs b/scripts/deploy-site-bundle.mjs new file mode 100644 index 0000000..df0dc5e --- /dev/null +++ b/scripts/deploy-site-bundle.mjs @@ -0,0 +1,31 @@ +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { deployDemo } from "./deploy-demo.mjs"; +import { deploySite, resolveDeployConfig } from "./deploy-site.mjs"; + +export function deploySiteBundle( + config, + immutableSiteRoot, + demoRoot = resolve(import.meta.dirname, ".."), + deploySiteCommand = deploySite, + deployDemoCommand = deployDemo, +) { + if (!isAbsolute(immutableSiteRoot)) { + throw new Error("T4_IMMUTABLE_SITE_SOURCE must be an absolute path"); + } + deploySiteCommand(config, immutableSiteRoot); + deployDemoCommand(config, demoRoot); +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + const immutableSiteRoot = process.env.T4_IMMUTABLE_SITE_SOURCE?.trim() ?? ""; + deploySiteBundle(resolveDeployConfig(), immutableSiteRoot); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs index efd3cb7..fe903fd 100644 --- a/scripts/deploy-site.mjs +++ b/scripts/deploy-site.mjs @@ -69,6 +69,8 @@ export function deploySite( "--delete", "--exclude", "assets/*", + "--exclude", + "demo/*", "--cache-control", "public,max-age=0,must-revalidate", "--only-show-errors", @@ -89,7 +91,8 @@ export function deploySite( ); } -const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (isMain) { try { deploySite(resolveDeployConfig()); diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs index 58f1260..3468b02 100644 --- a/scripts/deploy-site.test.mjs +++ b/scripts/deploy-site.test.mjs @@ -46,7 +46,10 @@ test("site deploy uploads immutable assets before switching entry documents", () ); assert.equal(calls.length, 5); - assert.deepEqual(calls.map(({ command }) => command), ["pnpm", "node", "aws", "aws", "aws"]); + assert.deepEqual( + calls.map(({ command }) => command), + ["pnpm", "node", "aws", "aws", "aws"], + ); assert.deepEqual(calls[1].args, [ "scripts/generate-release-manifest.mjs", "--version", @@ -58,5 +61,14 @@ test("site deploy uploads immutable assets before switching entry documents", () assert.equal(calls[3].args[2], "apps/site/dist"); assert.equal(calls[2].args.includes("--delete"), false); assert.equal(calls[3].args.includes("--delete"), true); - assert.deepEqual(calls.map(({ cwd }) => cwd), ["/repo", "/repo", "/repo", "/repo", "/repo"]); + assert.deepEqual( + calls[3].args.flatMap((argument, index) => + argument === "--exclude" ? [calls[3].args[index + 1]] : [], + ), + ["assets/*", "demo/*"], + ); + assert.deepEqual( + calls.map(({ cwd }) => cwd), + ["/repo", "/repo", "/repo", "/repo", "/repo"], + ); });