From 6a6bb745327e46eb328193083e567400de34c8be Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Fri, 31 Jul 2026 17:59:17 -0500 Subject: [PATCH 1/3] Release 3.1.17 --- .claude-plugin/marketplace.json | 4 ++-- package.json | 2 +- packages/adapter/package.json | 2 +- packages/aigateway/package.json | 2 +- packages/analytics/package.json | 2 +- packages/api/package.json | 2 +- packages/claude-code/.claude-plugin/plugin.json | 2 +- packages/claude-code/package.json | 2 +- packages/cli/package.json | 2 +- packages/client/package.json | 2 +- packages/coder-tui/package.json | 2 +- packages/coder/package.json | 2 +- packages/config/package.json | 2 +- packages/core/package.json | 2 +- packages/create-agentuity/package.json | 2 +- packages/db/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email/package.json | 2 +- packages/genesis/package.json | 2 +- packages/hono/package.json | 2 +- packages/keyvalue/package.json | 2 +- packages/migrate/package.json | 2 +- packages/opencode/package.json | 2 +- packages/pi/package.json | 2 +- packages/postgres/package.json | 2 +- packages/queue/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sandbox/package.json | 2 +- packages/schedule/package.json | 2 +- packages/schema/package.json | 2 +- packages/server/package.json | 2 +- packages/skills/package.json | 2 +- packages/storage/package.json | 2 +- packages/stream/package.json | 2 +- packages/task/package.json | 2 +- packages/telemetry/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vector/package.json | 2 +- packages/vite/package.json | 2 +- packages/vscode/package.json | 2 +- packages/webhook/package.json | 2 +- 41 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d4d059f6a..53c1a94ff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Agentuity plugins for Claude Code", - "version": "3.1.16" + "version": "3.1.17" }, "plugins": [ { "name": "agentuity", "source": "./packages/claude-code", "description": "Deploy websites, apps, and AI agents to Agentuity — with managed databases, storage, queues, and more", - "version": "3.1.16", + "version": "3.1.17", "author": { "name": "Agentuity" }, diff --git a/package.json b/package.json index d469b493c..6be276f69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sdk-mono", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "packageManager": "bun@1.3.11", "private": true, diff --git a/packages/adapter/package.json b/packages/adapter/package.json index ed6fec3b4..f631e8aa5 100644 --- a/packages/adapter/package.json +++ b/packages/adapter/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/adapter", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "HTTP client adapter for Agentuity service clients", diff --git a/packages/aigateway/package.json b/packages/aigateway/package.json index 928001265..553b34613 100644 --- a/packages/aigateway/package.json +++ b/packages/aigateway/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/aigateway", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 4e69a0a4f..d3e41879b 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/analytics", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Browser analytics for Agentuity applications", diff --git a/packages/api/package.json b/packages/api/package.json index 554107b50..5d1ecc52d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/api", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Platform HTTP client for Agentuity control-plane APIs", diff --git a/packages/claude-code/.claude-plugin/plugin.json b/packages/claude-code/.claude-plugin/plugin.json index 705962449..c0b875659 100644 --- a/packages/claude-code/.claude-plugin/plugin.json +++ b/packages/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentuity", - "version": "3.1.16", + "version": "3.1.17", "description": "Deploy websites, apps, and AI agents to Agentuity — with managed databases, storage, queues, and more", "author": { "name": "Agentuity", diff --git a/packages/claude-code/package.json b/packages/claude-code/package.json index 5f16c9c47..02065e8a3 100644 --- a/packages/claude-code/package.json +++ b/packages/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/claude-code", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity", "description": "Claude Code plugin for deploying websites, apps, and AI agents to Agentuity — with managed databases, storage, sandboxes, queues, cron, email, and more", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a96750ab..81733573e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/cli", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/client/package.json b/packages/client/package.json index 24f0b625a..52832f7a1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/client", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Shared client wiring for Agentuity service packages", diff --git a/packages/coder-tui/package.json b/packages/coder-tui/package.json index 0c9a20ae0..03249d243 100644 --- a/packages/coder-tui/package.json +++ b/packages/coder-tui/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/coder-tui", - "version": "3.1.16", + "version": "3.1.17", "description": "Agentuity Coder Hub extension for Pi coding agent", "license": "Apache-2.0", "author": "Agentuity employees and contributors", diff --git a/packages/coder/package.json b/packages/coder/package.json index 2c0d8edb0..98b7ae8c1 100644 --- a/packages/coder/package.json +++ b/packages/coder/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/coder", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/config/package.json b/packages/config/package.json index 2090c3172..860432e2e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/config", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Region and service URL resolution for Agentuity clients", diff --git a/packages/core/package.json b/packages/core/package.json index f8ef7e565..dbf48b271 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/core", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/create-agentuity/package.json b/packages/create-agentuity/package.json index 8c78a5b82..7fe57fcb9 100644 --- a/packages/create-agentuity/package.json +++ b/packages/create-agentuity/package.json @@ -1,6 +1,6 @@ { "name": "create-agentuity", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Create a new Agentuity project", diff --git a/packages/db/package.json b/packages/db/package.json index c71a663ad..f29ca5a5b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/db", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 85a34cf2b..9168c3674 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,7 +1,7 @@ { "name": "@agentuity/drizzle", "deprecated": "Use drizzle-orm with pg directly against your DATABASE_URL.", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/email/package.json b/packages/email/package.json index afa1dbe8b..7ee3e891a 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/email", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/genesis/package.json b/packages/genesis/package.json index a41163e16..97488eb69 100644 --- a/packages/genesis/package.json +++ b/packages/genesis/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/genesis", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Genesis SSO upstream identity verification for managed app middleware", diff --git a/packages/hono/package.json b/packages/hono/package.json index aedb05388..1354b35d3 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/hono", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/keyvalue/package.json b/packages/keyvalue/package.json index 69f085e9e..547706427 100644 --- a/packages/keyvalue/package.json +++ b/packages/keyvalue/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/keyvalue", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 623807355..173d4a238 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/migrate", - "version": "3.1.16", + "version": "3.1.17", "description": "Migration tool for Agentuity SDK (v1→v2 and v2→v3)", "license": "Apache-2.0", "author": "Agentuity employees and contributors", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cc6687937..358070e17 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/opencode", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Agentuity Open Code plugin with specialized AI coding agents", diff --git a/packages/pi/package.json b/packages/pi/package.json index 2c665ce3d..620ece6ee 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/pi", - "version": "3.1.16", + "version": "3.1.17", "description": "Agentuity AI Gateway provider plugin for Pi coding agent", "license": "Apache-2.0", "author": "Agentuity employees and contributors", diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 3babcac57..90d5f01d3 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,7 +1,7 @@ { "name": "@agentuity/postgres", "deprecated": "Use pg or drizzle-orm/node-postgres directly against your DATABASE_URL.", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/queue/package.json b/packages/queue/package.json index fbf66e727..18b2841de 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/queue", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c91668fd7..ae2ab486f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/runtime", - "version": "3.1.16", + "version": "3.1.17", "deprecated": "Use a dedicated framework (Hono, Next.js, etc.) with @agentuity/hono middleware instead. See https://agentuity.dev/docs/migration", "license": "Apache-2.0", "author": "Agentuity employees and contributors", diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 7bf176306..edb767734 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/sandbox", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/schedule/package.json b/packages/schedule/package.json index 85cbbc170..995b15b41 100644 --- a/packages/schedule/package.json +++ b/packages/schedule/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/schedule", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/schema/package.json b/packages/schema/package.json index e32418089..5698f5bfd 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/schema", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/server/package.json b/packages/server/package.json index 5b94d5232..5e1397000 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/server", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/skills/package.json b/packages/skills/package.json index 6c084c4a8..3aeb87e83 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/skills", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity", "description": "Agent Skills for AI coding agents working with the Agentuity SDK", diff --git a/packages/storage/package.json b/packages/storage/package.json index daaefe486..0b0693966 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/storage", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "description": "Dual-runtime S3 client for Agentuity storage buckets. Bun backend uses Bun.S3Client; Node backend uses @aws-sdk/client-s3.", diff --git a/packages/stream/package.json b/packages/stream/package.json index 3b3797c71..08903236f 100644 --- a/packages/stream/package.json +++ b/packages/stream/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/stream", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/task/package.json b/packages/task/package.json index 73d2b3235..9e25ef841 100644 --- a/packages/task/package.json +++ b/packages/task/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/task", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 77c75f29b..0e759a8d5 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/telemetry", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 4bac410f3..0f8068883 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/test-utils", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "private": true, "type": "module", diff --git a/packages/vector/package.json b/packages/vector/package.json index 8749b7323..05fe562e5 100644 --- a/packages/vector/package.json +++ b/packages/vector/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/vector", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", diff --git a/packages/vite/package.json b/packages/vite/package.json index ff932a75d..25c221959 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/vite", - "version": "3.1.16", + "version": "3.1.17", "description": "Vite plugin for the Agentuity dev public-URL tunnel", "license": "Apache-2.0", "author": "Agentuity employees and contributors", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 09928ab1a..f92e57afd 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "Agentuity VSCode Extension", "description": "Build, deploy, and manage AI agents with Agentuity", - "version": "3.1.16", + "version": "3.1.17", "publisher": "agentuity", "license": "Apache-2.0", "repository": { diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 242ef54b0..38995801a 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@agentuity/webhook", - "version": "3.1.16", + "version": "3.1.17", "license": "Apache-2.0", "author": "Agentuity employees and contributors", "type": "module", From 7f79bf3febd50d6d6dde4e1349f259c118cefd76 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Fri, 31 Jul 2026 18:47:35 -0500 Subject: [PATCH 2/3] fix(cli): CDN Next public/ assets and rewrite origin-absolute URLs Next assetPrefix only covers /_next/*; public/ files (e.g. /next.svg) stayed on the app origin and broke when bootstrap was cold. Stage public/ for CDN upload via launch.static.include, rewrite packaged HTML/JS refs to baseUrl, and leave a shared public-cdn helper for Remix-class split layouts. --- packages/cli/src/cmd/build/adapters/nextjs.ts | 26 +++ packages/cli/src/cmd/build/adapters/types.ts | 7 + packages/cli/src/cmd/build/package/index.ts | 12 +- packages/cli/src/cmd/build/package/launch.ts | 64 ++++++- .../cli/src/cmd/build/package/public-cdn.ts | 175 ++++++++++++++++++ .../cli/test/cmd/build/package/launch.test.ts | 39 ++++ .../test/cmd/build/package/public-cdn.test.ts | 94 ++++++++++ 7 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/cmd/build/package/public-cdn.ts create mode 100644 packages/cli/test/cmd/build/package/public-cdn.test.ts diff --git a/packages/cli/src/cmd/build/adapters/nextjs.ts b/packages/cli/src/cmd/build/adapters/nextjs.ts index c4f602641..4f3988bf1 100644 --- a/packages/cli/src/cmd/build/adapters/nextjs.ts +++ b/packages/cli/src/cmd/build/adapters/nextjs.ts @@ -17,6 +17,8 @@ import { copyRuntimeManifests, installDependencies, runBuildCommand } from './ge import { prepareNextCdnBuild } from './cdn-recipes.ts'; import { toPosixPath } from '../deploy-ignore.ts'; import { resetOutputDir } from './reset-output-dir.ts'; +import { rewritePublicAssetUrlsInTree } from '../package/public-cdn.ts'; +import { resolveAgentuityCdnBase } from './cdn-origin.ts'; /** * Walk the standalone tree for the first `server.js`, skipping @@ -238,10 +240,33 @@ export const nextjsAdapter: BuildAdapter = { } // Copy `public/` next to server.js for the same reason. + // Next assetPrefix only rewrites /_next/* — public/ files + // (e.g. /next.svg) stay origin-relative unless we CDN them + // and rewrite references when a CDN base is known. + let packagedPublicDir: string | undefined; if (existsSync(publicPath)) { const publicDst = join(serverDir, 'public'); mkdirSync(publicDst, { recursive: true }); cpSync(publicPath, publicDst, { recursive: true }); + packagedPublicDir = publicDst; + } + + // When CDN base is set, rewrite root-absolute public URLs in + // the packaged tree so logos/images hit the CDN, not origin. + const cdnBase = + cdnPrep.cdnBase ?? + resolveAgentuityCdnBase({ + cdnBaseUrl: options.cdnBaseUrl, + deploymentId: options.deploymentId, + }); + if (cdnBase && packagedPublicDir) { + const rw = rewritePublicAssetUrlsInTree(serverDir, packagedPublicDir, cdnBase); + if (rw.publicFileCount > 0) { + logs.push( + `✓ Next.js CDN: rewrote public/ refs in ${rw.filesChanged}/${rw.filesScanned} files ` + + `(${rw.publicFileCount} public asset(s) → ${cdnBase})` + ); + } } logs.push( @@ -257,6 +282,7 @@ export const nextjsAdapter: BuildAdapter = { workingDirectory, staticDir: packagedStaticDir, staticAssetPublicPath: framework.staticAssetPublicPath, + publicStaticDir: packagedPublicDir, port: framework.port ?? 3000, duration: Date.now() - started, logs, diff --git a/packages/cli/src/cmd/build/adapters/types.ts b/packages/cli/src/cmd/build/adapters/types.ts index a474a37cb..2f9738a89 100644 --- a/packages/cli/src/cmd/build/adapters/types.ts +++ b/packages/cli/src/cmd/build/adapters/types.ts @@ -42,6 +42,13 @@ export interface BuildResult { /** Public URL path prefix for files inside staticDir */ staticAssetPublicPath?: string; + /** + * Absolute path to packaged root-public assets (e.g. Next `public/`). + * Used when {@link staticAssetPublicPath} is a non-empty built prefix + * (`_next/static`) so CDN upload + HTML rewrite can also ship `/logo.svg`. + */ + publicStaticDir?: string; + /** Port the app listens on */ port?: number; diff --git a/packages/cli/src/cmd/build/package/index.ts b/packages/cli/src/cmd/build/package/index.ts index 3783da9f4..33c94b893 100644 --- a/packages/cli/src/cmd/build/package/index.ts +++ b/packages/cli/src/cmd/build/package/index.ts @@ -78,4 +78,14 @@ export async function packageBuildOutput( } // Re-export -export type { LaunchMetadata, LaunchStaticAssets, ProcessDefinition } from './launch.ts'; +export type { + LaunchMetadata, + LaunchStaticAssets, + LaunchStaticRoot, + ProcessDefinition, +} from './launch.ts'; +export { + listPublicRelativeFiles, + rewritePublicAssetUrlsInText, + rewritePublicAssetUrlsInTree, +} from './public-cdn.ts'; diff --git a/packages/cli/src/cmd/build/package/launch.ts b/packages/cli/src/cmd/build/package/launch.ts index bfe349417..da314c998 100644 --- a/packages/cli/src/cmd/build/package/launch.ts +++ b/packages/cli/src/cmd/build/package/launch.ts @@ -55,6 +55,13 @@ const UserLaunchProcessSchema = z }) .passthrough(); +const UserLaunchStaticRootSchema = z + .object({ + directory: z.string(), + publicPath: z.string(), + }) + .passthrough(); + const UserLaunchStaticSchema = z .object({ directory: z.string(), @@ -63,6 +70,10 @@ const UserLaunchStaticSchema = z .string() .nullish() .transform((v) => v ?? undefined), + include: z + .array(UserLaunchStaticRootSchema) + .nullish() + .transform((v) => v ?? undefined), }) .passthrough(); @@ -205,23 +216,50 @@ export interface ProcessDefinition { * used the same prefix. When omitted, the platform may still upload * files under `publicPath` using its own CDN root. */ -export interface LaunchStaticAssets { +/** + * One CDN upload root. Consumers compose: + * `{baseUrl}{publicPath}/{pathWithinDirectory}` + * with empty publicPath meaning files live at the CDN base root. + */ +export interface LaunchStaticRoot { /** - * Directory of static assets relative to the process working - * directory (or deploy root when no workingDirectory is set). - * Posix separators. Example: `.next/static`, `dist`, `.output/public`. + * Directory relative to the process working directory (or deploy root). + * Posix separators. Example: `.next/static`, `public`, `dist`. */ directory: string; /** * URL path prefix for files inside `directory` (no leading slash). - * Framework-defined; e.g. `_next/static` for Next.js, `''` for Vite SPA. + * e.g. `_next/static` for Next built assets, `''` for Next `public/`. */ publicPath: string; +} + +/** + * Static/CDN asset locations recorded in launch.json. + * + * Consumers (CDN upload, pilot) compose public URLs as: + * `{baseUrl}{publicPath}/{pathWithinDirectory}` + * + * The primary `directory`/`publicPath` is the framework build tree. + * Optional `include` lists extra roots (Next `public/`) that must also + * be uploaded when CDN base is set — `assetPrefix` alone does not cover them. + * + * `baseUrl` is optional: when set (from `--cdn-base-url` or the platform + * default), frameworks that bake asset URLs at build time should have + * used the same prefix. When omitted, the platform may still upload + * files under `publicPath` using its own CDN root. + */ +export interface LaunchStaticAssets extends LaunchStaticRoot { /** * Absolute CDN base URL with trailing slash when known at package time. * Example: `https://cdn.agentuity.com/org_123/assets/`. */ baseUrl?: string; + /** + * Extra CDN roots (e.g. Next `public/` with publicPath `""`). + * Deploy clients should upload each root the same way as the primary. + */ + include?: LaunchStaticRoot[]; } /** @@ -312,10 +350,26 @@ export function resolveLaunchStatic( // then AGENTUITY_CDN_BASE_URL / AGENTUITY_CDN_ORIGIN / deployment id. const baseUrl = resolveAgentuityCdnBase({ cdnBaseUrl }); + // Extra roots (Next public/): only when packaging staged them and the + // primary publicPath is a non-empty build prefix (split CDN layout). + const include: LaunchStaticRoot[] = []; + if (buildResult.publicStaticDir && publicPath !== '') { + const workRel = buildResult.workingDirectory ?? monorepo?.subpath; + const processRoot = workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir; + let pubRel = toPosixPath(relative(processRoot, buildResult.publicStaticDir)); + if (!pubRel || pubRel === '') { + pubRel = '.'; + } + if (!pubRel.startsWith('..') && pubRel !== directory) { + include.push({ directory: pubRel, publicPath: '' }); + } + } + return { directory, publicPath, ...(baseUrl ? { baseUrl } : {}), + ...(include.length > 0 ? { include } : {}), }; } diff --git a/packages/cli/src/cmd/build/package/public-cdn.ts b/packages/cli/src/cmd/build/package/public-cdn.ts new file mode 100644 index 000000000..0bf55cc3b --- /dev/null +++ b/packages/cli/src/cmd/build/package/public-cdn.ts @@ -0,0 +1,175 @@ +/** + * CDN packaging helpers for framework "public/" (or root-static) files. + * + * Next.js `assetPrefix` only rewrites `/_next/*`. Files under `public/` + * (e.g. `/next.svg`) stay origin-relative unless we: + * 1. List them for CDN upload (`launch.static.include`) + * 2. Rewrite root-absolute references to `{cdnBase}{relpath}` + * + * Same pattern applies to any framework where staticAssetPublicPath is a + * non-empty prefix of the built tree and loose public files live beside it. + */ + +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { toPosixPath } from '../deploy-ignore.ts'; + +const REWRITE_EXTENSIONS = new Set([ + '.html', + '.htm', + '.js', + '.mjs', + '.cjs', + '.json', + '.css', + '.rsc', + '.map', + '.txt', +]); + +/** + * List files under `publicDir` as posix paths relative to that root + * (e.g. `next.svg`, `icons/logo.png`). Empty if dir missing. + */ +export function listPublicRelativeFiles(publicDir: string): string[] { + if (!existsSync(publicDir) || !statSync(publicDir).isDirectory()) { + return []; + } + const out: string[] = []; + const stack: string[] = [publicDir]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (entry === '.' || entry === '..') continue; + const full = join(dir, entry); + let isDir: boolean; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + if (isDir) { + stack.push(full); + continue; + } + const rel = toPosixPath(relative(publicDir, full)); + if (!rel || rel.startsWith('..')) continue; + out.push(rel); + } + } + return out.sort(); +} + +/** + * Escape a path for use inside a RegExp character class-safe pattern. + */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Rewrite root-absolute references to known public files so they load from CDN. + * + * Matches `/file.svg`, `"/file.svg"`, `'/file.svg'`, `` `/file.svg` ``, and + * `url(/file.svg)` style, only for exact relative paths listed in `publicFiles`. + * + * @param content - file text + * @param publicFiles - posix relpaths from public root (no leading slash) + * @param cdnBase - absolute CDN base **with** trailing slash + */ +export function rewritePublicAssetUrlsInText( + content: string, + publicFiles: readonly string[], + cdnBase: string +): string { + const base = cdnBase.endsWith('/') ? cdnBase : `${cdnBase}/`; + if (!publicFiles.length) return content; + + // Longest paths first so `icons/a.svg` wins over a hypothetical `a.svg` collision + // when used as a suffix (we match full path after /). + const sorted = [...publicFiles].sort((a, b) => b.length - a.length); + let out = content; + for (const rel of sorted) { + const esc = escapeRegExp(rel); + // Require a boundary before the leading slash so we don't match + // `https://cdn…/genesis/next.svg` again or `/_next/static/…`. + // Preceding char: start, quote, =, (, whitespace, or >. + const re = new RegExp(`(^|["'\`=(\\s>])\\/${esc}(?=["'\`\\s),>\\?#]|$)`, 'g'); + out = out.replace(re, `$1${base}${rel}`); + } + return out; +} + +export interface RewritePublicAssetsResult { + filesScanned: number; + filesChanged: number; + publicFileCount: number; +} + +/** + * Walk `treeRoot` (typically Next server dir or package root), rewrite text + * files that reference `/publicRel` paths to `{cdnBase}{publicRel}`. + */ +export function rewritePublicAssetUrlsInTree( + treeRoot: string, + publicDir: string, + cdnBase: string +): RewritePublicAssetsResult { + const publicFiles = listPublicRelativeFiles(publicDir); + if (!publicFiles.length || !existsSync(treeRoot)) { + return { filesScanned: 0, filesChanged: 0, publicFileCount: publicFiles.length }; + } + + let filesScanned = 0; + let filesChanged = 0; + const stack: string[] = [treeRoot]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git') continue; + const full = join(dir, entry); + let isDir: boolean; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + if (isDir) { + // Skip the public dir itself (no HTML there to rewrite) + if (full === publicDir) continue; + stack.push(full); + continue; + } + const lower = entry.toLowerCase(); + const dot = lower.lastIndexOf('.'); + const ext = dot >= 0 ? lower.slice(dot) : ''; + if (!REWRITE_EXTENSIONS.has(ext)) continue; + + filesScanned++; + let text: string; + try { + text = readFileSync(full, 'utf-8'); + } catch { + continue; + } + const next = rewritePublicAssetUrlsInText(text, publicFiles, cdnBase); + if (next !== text) { + writeFileSync(full, next, 'utf-8'); + filesChanged++; + } + } + } + return { filesScanned, filesChanged, publicFileCount: publicFiles.length }; +} diff --git a/packages/cli/test/cmd/build/package/launch.test.ts b/packages/cli/test/cmd/build/package/launch.test.ts index 3c0dfdb72..5d88e08b6 100644 --- a/packages/cli/test/cmd/build/package/launch.test.ts +++ b/packages/cli/test/cmd/build/package/launch.test.ts @@ -240,6 +240,45 @@ describe('Launch Metadata', () => { }); }); + test('includes public/ root when publicStaticDir set (Next split CDN)', () => { + const framework: DetectedFramework = { + name: 'nextjs', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticDir: '.next/static', + staticAssetPublicPath: '_next/static', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: '/tmp/output', + startCommand: 'node server.js', + workingDirectory: 'test-nextjs', + staticDir: '/tmp/output/test-nextjs/.next/static', + staticAssetPublicPath: '_next/static', + publicStaticDir: '/tmp/output/test-nextjs/public', + duration: 1000, + logs: [], + }; + + const metadata = generateLaunchMetadata( + framework, + buildResult, + null, + undefined, + 'https://cdn.agentcompany.com/genesis/' + ); + expect(metadata.static).toEqual({ + directory: '.next/static', + publicPath: '_next/static', + baseUrl: 'https://cdn.agentcompany.com/genesis/', + include: [{ directory: 'public', publicPath: '' }], + }); + }); + test('omits static when framework has no static dir', () => { const framework: DetectedFramework = { name: 'nestjs', diff --git a/packages/cli/test/cmd/build/package/public-cdn.test.ts b/packages/cli/test/cmd/build/package/public-cdn.test.ts new file mode 100644 index 000000000..c8761cff4 --- /dev/null +++ b/packages/cli/test/cmd/build/package/public-cdn.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + listPublicRelativeFiles, + rewritePublicAssetUrlsInText, + rewritePublicAssetUrlsInTree, +} from '../../../../src/cmd/build/package/public-cdn.ts'; + +function makeDir(): string { + const dir = join(tmpdir(), `public-cdn-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('listPublicRelativeFiles', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + test('lists nested public files as posix paths', () => { + const dir = makeDir(); + dirs.push(dir); + writeFileSync(join(dir, 'next.svg'), ''); + mkdirSync(join(dir, 'icons'), { recursive: true }); + writeFileSync(join(dir, 'icons', 'logo.png'), 'png'); + expect(listPublicRelativeFiles(dir)).toEqual(['icons/logo.png', 'next.svg']); + }); + + test('empty when missing', () => { + expect(listPublicRelativeFiles(join(tmpdir(), 'no-such-public-dir-xyz'))).toEqual([]); + }); +}); + +describe('rewritePublicAssetUrlsInText', () => { + const files = ['next.svg', 'vercel.svg', 'icons/a.png']; + const base = 'https://cdn.agentcompany.com/genesis/'; + + test('rewrites quoted src and href', () => { + const in_ = `x`; + const out = rewritePublicAssetUrlsInText(in_, files, base); + expect(out).toContain('src="https://cdn.agentcompany.com/genesis/next.svg"'); + expect(out).toContain("href='https://cdn.agentcompany.com/genesis/vercel.svg'"); + }); + + test('does not rewrite _next static paths', () => { + const in_ = `src="https://cdn.agentcompany.com/genesis/_next/static/chunks/a.js"`; + expect(rewritePublicAssetUrlsInText(in_, files, base)).toBe(in_); + }); + + test('does not double-rewrite already absolute CDN public urls', () => { + const in_ = `src="https://cdn.agentcompany.com/genesis/next.svg"`; + // Leading char before /next.svg is 's' of genesis — our pattern requires boundary + // before /. Absolute CDN URLs should not match as root-absolute /next.svg. + expect(rewritePublicAssetUrlsInText(in_, files, base)).toBe(in_); + }); + + test('rewrites nested public path', () => { + const in_ = `url(/icons/a.png)`; + const out = rewritePublicAssetUrlsInText(in_, files, base); + expect(out).toBe('url(https://cdn.agentcompany.com/genesis/icons/a.png)'); + }); +}); + +describe('rewritePublicAssetUrlsInTree', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + test('rewrites HTML under server tree', () => { + const root = makeDir(); + dirs.push(root); + const pub = join(root, 'public'); + const server = join(root, '.next', 'server', 'app'); + mkdirSync(pub, { recursive: true }); + mkdirSync(server, { recursive: true }); + writeFileSync(join(pub, 'next.svg'), ''); + writeFileSync( + join(server, 'index.html'), + `logo` + ); + const r = rewritePublicAssetUrlsInTree(root, pub, 'https://cdn.agentcompany.com/genesis/'); + expect(r.publicFileCount).toBe(1); + expect(r.filesChanged).toBe(1); + const html = readFileSync(join(server, 'index.html'), 'utf-8'); + expect(html).toContain('https://cdn.agentcompany.com/genesis/next.svg'); + expect(html).toContain('https://cdn.x/genesis/_next/static/x.js'); + }); +}); From 170421fc3dfd6d017df3d8ff3df242b076c59798 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Fri, 31 Jul 2026 19:06:03 -0500 Subject: [PATCH 3/3] fix(cli): centralize Next public CDN rewrite in packaging Move public asset URL rewrite and launch.static.include emission into packageBuildOutput so adapters only stage publicStaticDir. Gate include on baseUrl, enumerate public roots in deploy-metadata, and expand tests. --- packages/cli/src/cmd/build/adapters/nextjs.ts | 26 +- packages/cli/src/cmd/build/package/index.ts | 60 ++++- packages/cli/src/cmd/build/package/launch.ts | 122 ++++++---- .../cli/src/cmd/build/package/public-cdn.ts | 132 +++++----- packages/cli/src/cmd/build/run.ts | 1 + packages/cli/src/deploy-metadata.ts | 22 +- .../cli/test/cmd/build/package/launch.test.ts | 226 +++++++++++++++++- .../test/cmd/build/package/public-cdn.test.ts | 60 +++++ .../cli/test/cmd/build/static-assets.test.ts | 67 ++++++ 9 files changed, 571 insertions(+), 145 deletions(-) diff --git a/packages/cli/src/cmd/build/adapters/nextjs.ts b/packages/cli/src/cmd/build/adapters/nextjs.ts index 4f3988bf1..20a22035d 100644 --- a/packages/cli/src/cmd/build/adapters/nextjs.ts +++ b/packages/cli/src/cmd/build/adapters/nextjs.ts @@ -17,8 +17,6 @@ import { copyRuntimeManifests, installDependencies, runBuildCommand } from './ge import { prepareNextCdnBuild } from './cdn-recipes.ts'; import { toPosixPath } from '../deploy-ignore.ts'; import { resetOutputDir } from './reset-output-dir.ts'; -import { rewritePublicAssetUrlsInTree } from '../package/public-cdn.ts'; -import { resolveAgentuityCdnBase } from './cdn-origin.ts'; /** * Walk the standalone tree for the first `server.js`, skipping @@ -241,8 +239,8 @@ export const nextjsAdapter: BuildAdapter = { // Copy `public/` next to server.js for the same reason. // Next assetPrefix only rewrites /_next/* — public/ files - // (e.g. /next.svg) stay origin-relative unless we CDN them - // and rewrite references when a CDN base is known. + // (e.g. /next.svg) stay origin-relative. Packaging owns CDN + // rewrite + launch.static.include when publicStaticDir is set. let packagedPublicDir: string | undefined; if (existsSync(publicPath)) { const publicDst = join(serverDir, 'public'); @@ -251,24 +249,6 @@ export const nextjsAdapter: BuildAdapter = { packagedPublicDir = publicDst; } - // When CDN base is set, rewrite root-absolute public URLs in - // the packaged tree so logos/images hit the CDN, not origin. - const cdnBase = - cdnPrep.cdnBase ?? - resolveAgentuityCdnBase({ - cdnBaseUrl: options.cdnBaseUrl, - deploymentId: options.deploymentId, - }); - if (cdnBase && packagedPublicDir) { - const rw = rewritePublicAssetUrlsInTree(serverDir, packagedPublicDir, cdnBase); - if (rw.publicFileCount > 0) { - logs.push( - `✓ Next.js CDN: rewrote public/ refs in ${rw.filesChanged}/${rw.filesScanned} files ` + - `(${rw.publicFileCount} public asset(s) → ${cdnBase})` - ); - } - } - logs.push( `✓ Standalone output packaged (server entry: ${ workingDirectory ? `${workingDirectory}/${serverEntryRel}` : serverEntryRel @@ -293,6 +273,8 @@ export const nextjsAdapter: BuildAdapter = { // and the package manifests Hadron needs to install production // dependencies before launch. This path is brittle (`next start` // needs the full Next.js install) so we warn the user. + // Public CDN rewrite / launch.static.include require standalone + // packaging (publicStaticDir is only set on that path). logger.debug('No standalone output found — copying full .next directory'); const nextDst = join(outputDir, '.next'); cpSync(join(projectDir, '.next'), nextDst, { recursive: true }); diff --git a/packages/cli/src/cmd/build/package/index.ts b/packages/cli/src/cmd/build/package/index.ts index 33c94b893..06b8c4b45 100644 --- a/packages/cli/src/cmd/build/package/index.ts +++ b/packages/cli/src/cmd/build/package/index.ts @@ -2,22 +2,31 @@ * Build output packaging. * * After a framework adapter builds the project, the packager: - * 1. Generates launch metadata (how to start the app) - * 2. Writes launch.json + * 1. Rewrites root-public asset URLs onto the CDN base when applicable + * 2. Generates launch metadata (how to start the app) + * 3. Writes launch.json * * The output is a self-contained directory ready for deployment * as a buildpack image layer or traditional zip upload. + * + * Public CDN policy lives here (not in framework adapters): adapters only + * stage files and set `BuildResult.publicStaticDir`; packaging owns + * rewrite + `launch.static.include`. */ import type { DetectedFramework } from '../detect/types.ts'; import type { MonorepoContext } from '../detect/monorepo.ts'; import type { BuildResult } from '../adapters/types.ts'; +import { resolveAgentuityCdnBase } from '../adapters/cdn-origin.ts'; import { generateLaunchMetadata, + isSplitCdnLayout, readUserLaunchOverride, + resolveProcessRoot, writeLaunchMetadata, type LaunchMetadata, } from './launch.ts'; +import { rewritePublicAssetUrlsInTree } from './public-cdn.ts'; export interface PackageResult { /** Absolute path to the packaged output */ @@ -31,6 +40,9 @@ export interface PackageResult { /** Path to static assets (if any) */ staticDir?: string; + + /** Human-readable packaging log lines (e.g. public URL rewrites) */ + logs: string[]; } export interface PackageBuildOutputOptions { @@ -38,6 +50,32 @@ export interface PackageBuildOutputOptions { cdnBaseUrl?: string; } +/** + * When CDN base is known and packaging staged a split-layout public root, + * rewrite root-absolute `/file` refs under the process tree to the CDN base. + */ +function rewriteStagedPublicAssets( + buildResult: BuildResult, + monorepo: MonorepoContext | undefined, + cdnBase: string +): string[] { + const logs: string[] = []; + if (!buildResult.publicStaticDir) return logs; + + const publicPath = buildResult.staticAssetPublicPath ?? ''; + if (!isSplitCdnLayout(publicPath)) return logs; + + const treeRoot = resolveProcessRoot(buildResult, monorepo); + const rw = rewritePublicAssetUrlsInTree(treeRoot, buildResult.publicStaticDir, cdnBase); + if (rw.publicFileCount > 0) { + logs.push( + `✓ CDN: rewrote public/ refs in ${rw.filesChanged}/${rw.filesScanned} files ` + + `(${rw.publicFileCount} public asset(s) → ${cdnBase})` + ); + } + return logs; +} + /** * Package a build result into a deployment-ready directory. * @@ -54,6 +92,14 @@ export async function packageBuildOutput( options?: PackageBuildOutputOptions ): Promise { const override = projectDir ? await readUserLaunchOverride(projectDir) : null; + const logs: string[] = []; + + // Same CDN chain as launch.static.baseUrl / adapters. Rewrite before + // writing launch.json so staged HTML/JS already point at the CDN. + const cdnBase = resolveAgentuityCdnBase({ cdnBaseUrl: options?.cdnBaseUrl }); + if (cdnBase) { + logs.push(...rewriteStagedPublicAssets(buildResult, monorepo, cdnBase)); + } // Generate launch metadata (with optional user override applied). // In monorepo mode, every process inherits the subpackage as its @@ -74,18 +120,16 @@ export async function packageBuildOutput( launch, hasStaticAssets: !!buildResult.staticDir || !!launch.static, staticDir: buildResult.staticDir, + logs, }; } -// Re-export +// Re-export public launch contract types (not rewrite helpers — those stay +// package-internal / testable via their module path). export type { LaunchMetadata, LaunchStaticAssets, LaunchStaticRoot, ProcessDefinition, } from './launch.ts'; -export { - listPublicRelativeFiles, - rewritePublicAssetUrlsInText, - rewritePublicAssetUrlsInTree, -} from './public-cdn.ts'; +export { joinCdnAssetUrl } from './launch.ts'; diff --git a/packages/cli/src/cmd/build/package/launch.ts b/packages/cli/src/cmd/build/package/launch.ts index da314c998..2785c2f38 100644 --- a/packages/cli/src/cmd/build/package/launch.ts +++ b/packages/cli/src/cmd/build/package/launch.ts @@ -206,20 +206,11 @@ export interface ProcessDefinition { } /** - * Static/CDN asset locations recorded in launch.json. - * - * Consumers (CDN upload, pilot) compose public URLs as: - * `{baseUrl}{publicPath}/{pathWithinDirectory}` + * One CDN upload root. * - * `baseUrl` is optional: when set (from `--cdn-base-url` or the platform - * default), frameworks that bake asset URLs at build time should have - * used the same prefix. When omitted, the platform may still upload - * files under `publicPath` using its own CDN root. - */ -/** - * One CDN upload root. Consumers compose: - * `{baseUrl}{publicPath}/{pathWithinDirectory}` - * with empty publicPath meaning files live at the CDN base root. + * Compose the public object URL with {@link joinCdnAssetUrl}: + * - non-empty publicPath → `{baseUrl}{publicPath}/{pathWithinDirectory}` + * - empty publicPath → `{baseUrl}{pathWithinDirectory}` (CDN base root) */ export interface LaunchStaticRoot { /** @@ -237,17 +228,17 @@ export interface LaunchStaticRoot { /** * Static/CDN asset locations recorded in launch.json. * - * Consumers (CDN upload, pilot) compose public URLs as: - * `{baseUrl}{publicPath}/{pathWithinDirectory}` - * * The primary `directory`/`publicPath` is the framework build tree. * Optional `include` lists extra roots (Next `public/`) that must also - * be uploaded when CDN base is set — `assetPrefix` alone does not cover them. + * be uploaded when `baseUrl` is set — `assetPrefix` alone does not cover them. * * `baseUrl` is optional: when set (from `--cdn-base-url` or the platform * default), frameworks that bake asset URLs at build time should have * used the same prefix. When omitted, the platform may still upload * files under `publicPath` using its own CDN root. + * + * `include` is only emitted when `baseUrl` is known and packaging staged a + * split-layout public root (`publicStaticDir` + non-empty primary publicPath). */ export interface LaunchStaticAssets extends LaunchStaticRoot { /** @@ -258,10 +249,29 @@ export interface LaunchStaticAssets extends LaunchStaticRoot { /** * Extra CDN roots (e.g. Next `public/` with publicPath `""`). * Deploy clients should upload each root the same way as the primary. + * Present only when {@link baseUrl} is set. */ include?: LaunchStaticRoot[]; } +/** + * Join a CDN object URL without double-slashes when `publicPath` is empty. + * + * @param baseUrl - absolute CDN base (trailing slash optional) + * @param publicPath - URL path prefix, no leading slash (`''` → base root) + * @param pathWithinDirectory - path relative to that root + */ +export function joinCdnAssetUrl( + baseUrl: string, + publicPath: string, + pathWithinDirectory: string +): string { + const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const prefix = publicPath.replace(/^\/+|\/+$/g, ''); + const rel = pathWithinDirectory.replace(/^\/+/, ''); + return prefix ? `${base}${prefix}/${rel}` : `${base}${rel}`; +} + /** * Complete launch metadata written to the output directory. */ @@ -305,6 +315,47 @@ function toPosixPath(p: string): string { return p.split('\\').join('/'); } +/** + * Absolute process cwd used for launch-relative paths: workingDirectory + * when set, else monorepo subpath, else the deploy/output root. + */ +export function resolveProcessRoot( + buildResult: Pick, + monorepo?: MonorepoContext +): string { + const workRel = buildResult.workingDirectory ?? monorepo?.subpath; + return workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir; +} + +/** + * Relativize an absolute staged path under `processRoot` (posix). + * Returns `undefined` if the path escapes the root (and optional + * output-root fallback when `allowOutputFallback` is set). + */ +export function relativeToProcessRoot( + absPath: string, + processRoot: string, + options?: { allowOutputFallback?: boolean; outputDir?: string } +): string | undefined { + let rel = toPosixPath(relative(processRoot, absPath)); + if (!rel || rel === '') return '.'; + if (!rel.startsWith('..')) return rel; + if (options?.allowOutputFallback && options.outputDir) { + // Fallback: static staged outside subpath but still under output. + rel = toPosixPath(relative(options.outputDir, absPath)); + if (!rel.startsWith('..')) return rel || '.'; + } + return undefined; +} + +/** + * True when the build uses a split CDN layout: built assets under a + * non-empty publicPath (e.g. `_next/static`) plus a separate public root. + */ +export function isSplitCdnLayout(publicPath: string): boolean { + return publicPath !== ''; +} + /** * Resolve the static asset block for launch.json from the staged build * result (preferred) or framework detection fallback. @@ -324,20 +375,11 @@ export function resolveLaunchStatic( // (adapter packaging layout wins; monorepo.subpath is the fallback // for adapters that do not set workingDirectory); otherwise to the // deploy/output root. - const workRel = buildResult.workingDirectory ?? monorepo?.subpath; - const processRoot = workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir; - let rel = toPosixPath(relative(processRoot, buildResult.staticDir)); - if (!rel || rel === '') { - directory = '.'; - } else if (!rel.startsWith('..')) { - directory = rel; - } else { - // Fallback: relative to output root (e.g. static staged outside subpath). - rel = toPosixPath(relative(buildResult.outputDir, buildResult.staticDir)); - if (!rel.startsWith('..')) { - directory = rel || '.'; - } - } + const processRoot = resolveProcessRoot(buildResult, monorepo); + directory = relativeToProcessRoot(buildResult.staticDir, processRoot, { + allowOutputFallback: true, + outputDir: buildResult.outputDir, + }); } else if (framework.staticDir) { // Build did not resolve an absolute staged path, but detection knew // where assets should live relative to the project/working dir. @@ -350,17 +392,15 @@ export function resolveLaunchStatic( // then AGENTUITY_CDN_BASE_URL / AGENTUITY_CDN_ORIGIN / deployment id. const baseUrl = resolveAgentuityCdnBase({ cdnBaseUrl }); - // Extra roots (Next public/): only when packaging staged them and the - // primary publicPath is a non-empty build prefix (split CDN layout). + // Extra roots (Next public/): only when CDN base is known, packaging + // staged publicStaticDir, and primary publicPath is a non-empty build + // prefix (split CDN layout). Without baseUrl, deployers use the primary + // root only — include would be meaningless without a CDN target. const include: LaunchStaticRoot[] = []; - if (buildResult.publicStaticDir && publicPath !== '') { - const workRel = buildResult.workingDirectory ?? monorepo?.subpath; - const processRoot = workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir; - let pubRel = toPosixPath(relative(processRoot, buildResult.publicStaticDir)); - if (!pubRel || pubRel === '') { - pubRel = '.'; - } - if (!pubRel.startsWith('..') && pubRel !== directory) { + if (baseUrl && buildResult.publicStaticDir && isSplitCdnLayout(publicPath)) { + const processRoot = resolveProcessRoot(buildResult, monorepo); + const pubRel = relativeToProcessRoot(buildResult.publicStaticDir, processRoot); + if (pubRel && pubRel !== directory) { include.push({ directory: pubRel, publicPath: '' }); } } diff --git a/packages/cli/src/cmd/build/package/public-cdn.ts b/packages/cli/src/cmd/build/package/public-cdn.ts index 0bf55cc3b..a0a1c5dde 100644 --- a/packages/cli/src/cmd/build/package/public-cdn.ts +++ b/packages/cli/src/cmd/build/package/public-cdn.ts @@ -8,9 +8,12 @@ * * Same pattern applies to any framework where staticAssetPublicPath is a * non-empty prefix of the built tree and loose public files live beside it. + * + * Call sites: packaging (`packageBuildOutput`) owns rewrite + include emission; + * adapters only stage `publicStaticDir` on {@link BuildResult}. */ -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync, type Dirent } from 'node:fs'; import { join, relative } from 'node:path'; import { toPosixPath } from '../deploy-ignore.ts'; @@ -27,50 +30,61 @@ const REWRITE_EXTENSIONS = new Set([ '.txt', ]); +const SKIP_DIR_NAMES = new Set(['node_modules', '.git']); + /** - * List files under `publicDir` as posix paths relative to that root - * (e.g. `next.svg`, `icons/logo.png`). Empty if dir missing. + * Escape a path for use inside a RegExp. */ -export function listPublicRelativeFiles(publicDir: string): string[] { - if (!existsSync(publicDir) || !statSync(publicDir).isDirectory()) { - return []; - } - const out: string[] = []; - const stack: string[] = [publicDir]; +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Walk `root` depth-first, invoking `onFile` for each regular file. + * Skips `node_modules` / `.git` and optional absolute dirs in `skipDirs`. + */ +function walkFiles( + root: string, + onFile: (fullPath: string, entryName: string) => void, + skipDirs?: ReadonlySet +): void { + if (!existsSync(root)) return; + const stack: string[] = [root]; while (stack.length > 0) { const dir = stack.pop()!; - let entries: string[]; + let entries: Dirent[]; try { - entries = readdirSync(dir); + entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; } catch { continue; } for (const entry of entries) { - if (entry === '.' || entry === '..') continue; - const full = join(dir, entry); - let isDir: boolean; - try { - isDir = statSync(full).isDirectory(); - } catch { - continue; - } - if (isDir) { + if (SKIP_DIR_NAMES.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (skipDirs?.has(full)) continue; stack.push(full); continue; } - const rel = toPosixPath(relative(publicDir, full)); - if (!rel || rel.startsWith('..')) continue; - out.push(rel); + if (!entry.isFile()) continue; + onFile(full, entry.name); } } - return out.sort(); } /** - * Escape a path for use inside a RegExp character class-safe pattern. + * List files under `publicDir` as posix paths relative to that root + * (e.g. `next.svg`, `icons/logo.png`). Empty if dir missing. */ -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export function listPublicRelativeFiles(publicDir: string): string[] { + if (!existsSync(publicDir)) return []; + const out: string[] = []; + walkFiles(publicDir, (full) => { + const rel = toPosixPath(relative(publicDir, full)); + if (!rel || rel.startsWith('..')) return; + out.push(rel); + }); + return out.sort(); } /** @@ -91,19 +105,15 @@ export function rewritePublicAssetUrlsInText( const base = cdnBase.endsWith('/') ? cdnBase : `${cdnBase}/`; if (!publicFiles.length) return content; - // Longest paths first so `icons/a.svg` wins over a hypothetical `a.svg` collision - // when used as a suffix (we match full path after /). + // Longest paths first so `icons/a.svg` wins over a hypothetical shorter + // collision when used as a suffix (we match full path after /). const sorted = [...publicFiles].sort((a, b) => b.length - a.length); - let out = content; - for (const rel of sorted) { - const esc = escapeRegExp(rel); - // Require a boundary before the leading slash so we don't match - // `https://cdn…/genesis/next.svg` again or `/_next/static/…`. - // Preceding char: start, quote, =, (, whitespace, or >. - const re = new RegExp(`(^|["'\`=(\\s>])\\/${esc}(?=["'\`\\s),>\\?#]|$)`, 'g'); - out = out.replace(re, `$1${base}${rel}`); - } - return out; + const alt = sorted.map(escapeRegExp).join('|'); + // Require a boundary before the leading slash so we don't match + // `https://cdn…/genesis/next.svg` again or `/_next/static/…`. + // Preceding char: start, quote, =, (, whitespace, or >. + const re = new RegExp(`(^|["'\`=(\\s>])\\/(${alt})(?=["'\`\\s),>\\?#]|$)`, 'g'); + return content.replace(re, `$1${base}$2`); } export interface RewritePublicAssetsResult { @@ -113,8 +123,9 @@ export interface RewritePublicAssetsResult { } /** - * Walk `treeRoot` (typically Next server dir or package root), rewrite text + * Walk `treeRoot` (typically the process working directory), rewrite text * files that reference `/publicRel` paths to `{cdnBase}{publicRel}`. + * Skips the public directory itself (no HTML to rewrite there). */ export function rewritePublicAssetUrlsInTree( treeRoot: string, @@ -128,48 +139,31 @@ export function rewritePublicAssetUrlsInTree( let filesScanned = 0; let filesChanged = 0; - const stack: string[] = [treeRoot]; - while (stack.length > 0) { - const dir = stack.pop()!; - let entries: string[]; - try { - entries = readdirSync(dir); - } catch { - continue; - } - for (const entry of entries) { - if (entry === 'node_modules' || entry === '.git') continue; - const full = join(dir, entry); - let isDir: boolean; - try { - isDir = statSync(full).isDirectory(); - } catch { - continue; - } - if (isDir) { - // Skip the public dir itself (no HTML there to rewrite) - if (full === publicDir) continue; - stack.push(full); - continue; - } - const lower = entry.toLowerCase(); + const skipDirs = new Set([publicDir]); + + walkFiles( + treeRoot, + (full, entryName) => { + const lower = entryName.toLowerCase(); const dot = lower.lastIndexOf('.'); const ext = dot >= 0 ? lower.slice(dot) : ''; - if (!REWRITE_EXTENSIONS.has(ext)) continue; + if (!REWRITE_EXTENSIONS.has(ext)) return; filesScanned++; let text: string; try { text = readFileSync(full, 'utf-8'); } catch { - continue; + return; } const next = rewritePublicAssetUrlsInText(text, publicFiles, cdnBase); if (next !== text) { writeFileSync(full, next, 'utf-8'); filesChanged++; } - } - } + }, + skipDirs + ); + return { filesScanned, filesChanged, publicFileCount: publicFiles.length }; } diff --git a/packages/cli/src/cmd/build/run.ts b/packages/cli/src/cmd/build/run.ts index 5fc430071..469195632 100644 --- a/packages/cli/src/cmd/build/run.ts +++ b/packages/cli/src/cmd/build/run.ts @@ -235,6 +235,7 @@ export async function runBuildPipeline(input: BuildPipelineInput): Promise { }); }); - test('includes public/ root when publicStaticDir set (Next split CDN)', () => { + test('includes public/ root when publicStaticDir set with CDN base (Next split CDN)', () => { const framework: DetectedFramework = { name: 'nextjs', runtime: 'node', @@ -279,6 +280,112 @@ describe('Launch Metadata', () => { }); }); + test('omits include when publicStaticDir set but no CDN baseUrl', () => { + const framework: DetectedFramework = { + name: 'nextjs', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticDir: '.next/static', + staticAssetPublicPath: '_next/static', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: '/tmp/output', + startCommand: 'node server.js', + workingDirectory: 'test-nextjs', + staticDir: '/tmp/output/test-nextjs/.next/static', + staticAssetPublicPath: '_next/static', + publicStaticDir: '/tmp/output/test-nextjs/public', + duration: 1000, + logs: [], + }; + + const metadata = generateLaunchMetadata(framework, buildResult); + expect(metadata.static).toEqual({ + directory: '.next/static', + publicPath: '_next/static', + }); + expect(metadata.static?.include).toBeUndefined(); + }); + + test('omits include when publicPath is empty (Vite-style single root)', () => { + const framework: DetectedFramework = { + name: 'vite', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'vite build', + buildOutput: 'dist', + startCommand: 'node server.js', + staticDir: 'dist', + staticAssetPublicPath: '', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: '/tmp/output', + startCommand: 'node server.js', + staticDir: '/tmp/output/dist', + staticAssetPublicPath: '', + // Even if someone set this, empty publicPath is not split layout + publicStaticDir: '/tmp/output/public', + duration: 1000, + logs: [], + }; + + const metadata = generateLaunchMetadata( + framework, + buildResult, + null, + undefined, + 'https://cdn.agentcompany.com/genesis/' + ); + expect(metadata.static).toEqual({ + directory: 'dist', + publicPath: '', + baseUrl: 'https://cdn.agentcompany.com/genesis/', + }); + expect(metadata.static?.include).toBeUndefined(); + }); + + test('omits include when publicStaticDir is outside process root', () => { + const framework: DetectedFramework = { + name: 'nextjs', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticDir: '.next/static', + staticAssetPublicPath: '_next/static', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: '/tmp/output', + startCommand: 'node server.js', + workingDirectory: 'app', + staticDir: '/tmp/output/app/.next/static', + staticAssetPublicPath: '_next/static', + publicStaticDir: '/tmp/elsewhere/public', + duration: 1000, + logs: [], + }; + + const metadata = generateLaunchMetadata( + framework, + buildResult, + null, + undefined, + 'https://cdn.agentcompany.com/genesis/' + ); + expect(metadata.static?.include).toBeUndefined(); + expect(metadata.static?.baseUrl).toBe('https://cdn.agentcompany.com/genesis/'); + }); + test('omits static when framework has no static dir', () => { const framework: DetectedFramework = { name: 'nestjs', @@ -550,9 +657,126 @@ describe('Launch Metadata', () => { }); }); + // ── joinCdnAssetUrl ── + + describe('joinCdnAssetUrl', () => { + test('joins non-empty publicPath without double slashes', () => { + expect( + joinCdnAssetUrl('https://cdn.agentcompany.com/genesis/', '_next/static', 'chunks/a.js') + ).toBe('https://cdn.agentcompany.com/genesis/_next/static/chunks/a.js'); + }); + + test('empty publicPath places file at CDN base root', () => { + expect(joinCdnAssetUrl('https://cdn.agentcompany.com/genesis/', '', 'next.svg')).toBe( + 'https://cdn.agentcompany.com/genesis/next.svg' + ); + // base without trailing slash still normalizes + expect(joinCdnAssetUrl('https://cdn.example.com/x', '', 'icons/a.png')).toBe( + 'https://cdn.example.com/x/icons/a.png' + ); + }); + }); + // ── packageBuildOutput ── describe('packageBuildOutput', () => { + test('rewrites public asset URLs and emits include when CDN base set', async () => { + const staticDir = join(testDir, '.next', 'static'); + const publicDir = join(testDir, 'public'); + const serverApp = join(testDir, '.next', 'server', 'app'); + mkdirSync(staticDir, { recursive: true }); + mkdirSync(publicDir, { recursive: true }); + mkdirSync(serverApp, { recursive: true }); + writeFileSync(join(publicDir, 'next.svg'), ''); + writeFileSync( + join(serverApp, 'page.html'), + `logo` + ); + + const framework: DetectedFramework = { + name: 'nextjs', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticDir: '.next/static', + staticAssetPublicPath: '_next/static', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: testDir, + startCommand: 'node server.js', + staticDir, + staticAssetPublicPath: '_next/static', + publicStaticDir: publicDir, + duration: 1000, + logs: [], + }; + + const cdn = 'https://cdn.agentcompany.com/genesis/'; + const result = await packageBuildOutput( + framework, + buildResult, + testDir, + undefined, + undefined, + { cdnBaseUrl: cdn } + ); + + expect(result.launch.static).toEqual({ + directory: '.next/static', + publicPath: '_next/static', + baseUrl: cdn, + include: [{ directory: 'public', publicPath: '' }], + }); + expect(result.logs.some((l) => l.includes('rewrote public/'))).toBe(true); + + const html = readFileSync(join(serverApp, 'page.html'), 'utf-8'); + expect(html).toContain(`${cdn}next.svg`); + // Root-absolute _next path is not a listed public file — left alone + expect(html).toContain('src="/_next/static/x.js"'); + }); + + test('does not rewrite when CDN base is unset', async () => { + const publicDir = join(testDir, 'public'); + const serverApp = join(testDir, '.next', 'server'); + mkdirSync(publicDir, { recursive: true }); + mkdirSync(serverApp, { recursive: true }); + writeFileSync(join(publicDir, 'next.svg'), ''); + const htmlPath = join(serverApp, 'page.html'); + const original = ``; + writeFileSync(htmlPath, original); + + const framework: DetectedFramework = { + name: 'nextjs', + runtime: 'node', + packageManager: 'npm', + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticAssetPublicPath: '_next/static', + confidence: 'high', + }; + + const buildResult: BuildResult = { + outputDir: testDir, + startCommand: 'node server.js', + staticDir: join(testDir, '.next', 'static'), + staticAssetPublicPath: '_next/static', + publicStaticDir: publicDir, + duration: 1000, + logs: [], + }; + mkdirSync(buildResult.staticDir!, { recursive: true }); + + const result = await packageBuildOutput(framework, buildResult, testDir); + expect(result.launch.static?.include).toBeUndefined(); + expect(result.logs).toEqual([]); + expect(readFileSync(htmlPath, 'utf-8')).toBe(original); + }); + test('returns hasStaticAssets when staticDir exists', async () => { const staticDir = join(testDir, 'static'); mkdirSync(staticDir, { recursive: true }); diff --git a/packages/cli/test/cmd/build/package/public-cdn.test.ts b/packages/cli/test/cmd/build/package/public-cdn.test.ts index c8761cff4..2de8ef8ab 100644 --- a/packages/cli/test/cmd/build/package/public-cdn.test.ts +++ b/packages/cli/test/cmd/build/package/public-cdn.test.ts @@ -33,6 +33,15 @@ describe('listPublicRelativeFiles', () => { test('empty when missing', () => { expect(listPublicRelativeFiles(join(tmpdir(), 'no-such-public-dir-xyz'))).toEqual([]); }); + + test('skips node_modules under public', () => { + const dir = makeDir(); + dirs.push(dir); + writeFileSync(join(dir, 'ok.svg'), 'x'); + mkdirSync(join(dir, 'node_modules', 'pkg'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', 'pkg', 'x.js'), 'x'); + expect(listPublicRelativeFiles(dir)).toEqual(['ok.svg']); + }); }); describe('rewritePublicAssetUrlsInText', () => { @@ -63,6 +72,35 @@ describe('rewritePublicAssetUrlsInText', () => { const out = rewritePublicAssetUrlsInText(in_, files, base); expect(out).toBe('url(https://cdn.agentcompany.com/genesis/icons/a.png)'); }); + + test('rewrites srcset and query/hash boundaries', () => { + const in_ = `srcset="/next.svg 1x, /vercel.svg 2x" href="/next.svg?v=1#top"`; + const out = rewritePublicAssetUrlsInText(in_, files, base); + expect(out).toContain(`${base}next.svg 1x`); + expect(out).toContain(`${base}vercel.svg 2x`); + expect(out).toContain(`${base}next.svg?v=1#top`); + }); + + test('longest path wins for nested names', () => { + const nested = ['a.png', 'icons/a.png']; + const in_ = `url(/icons/a.png) url(/a.png)`; + const out = rewritePublicAssetUrlsInText(in_, nested, base); + expect(out).toBe(`url(${base}icons/a.png) url(${base}a.png)`); + }); + + test('normalizes base without trailing slash', () => { + const out = rewritePublicAssetUrlsInText( + `src="/next.svg"`, + files, + 'https://cdn.agentcompany.com/genesis' + ); + expect(out).toBe(`src="https://cdn.agentcompany.com/genesis/next.svg"`); + }); + + test('no-op when public file list is empty', () => { + const in_ = `src="/next.svg"`; + expect(rewritePublicAssetUrlsInText(in_, [], base)).toBe(in_); + }); }); describe('rewritePublicAssetUrlsInTree', () => { @@ -91,4 +129,26 @@ describe('rewritePublicAssetUrlsInTree', () => { expect(html).toContain('https://cdn.agentcompany.com/genesis/next.svg'); expect(html).toContain('https://cdn.x/genesis/_next/static/x.js'); }); + + test('skips public dir itself and node_modules', () => { + const root = makeDir(); + dirs.push(root); + const pub = join(root, 'public'); + const nm = join(root, 'node_modules', 'pkg'); + mkdirSync(pub, { recursive: true }); + mkdirSync(nm, { recursive: true }); + writeFileSync(join(pub, 'next.svg'), ''); + // If we rewrote under public/, this would change — ensure we skip it + writeFileSync(join(pub, 'readme.txt'), 'see /next.svg'); + writeFileSync(join(nm, 'x.js'), `import "/next.svg"`); + writeFileSync(join(root, 'app.js'), `const u = "/next.svg"`); + + const r = rewritePublicAssetUrlsInTree(root, pub, 'https://cdn.example.com/'); + expect(r.filesChanged).toBe(1); + expect(readFileSync(join(pub, 'readme.txt'), 'utf-8')).toBe('see /next.svg'); + expect(readFileSync(join(nm, 'x.js'), 'utf-8')).toBe(`import "/next.svg"`); + expect(readFileSync(join(root, 'app.js'), 'utf-8')).toBe( + `const u = "https://cdn.example.com/next.svg"` + ); + }); }); diff --git a/packages/cli/test/cmd/build/static-assets.test.ts b/packages/cli/test/cmd/build/static-assets.test.ts index 3540e276c..654f16d70 100644 --- a/packages/cli/test/cmd/build/static-assets.test.ts +++ b/packages/cli/test/cmd/build/static-assets.test.ts @@ -403,6 +403,73 @@ describe('Static Asset CDN Upload', () => { expect(script?.sourcePath).toBe('.next/static/chunks/app-abcdef12.js'); }, 30_000); + test('split-layout publicStaticDir enumerates at CDN base root', async () => { + // Hand-built BuildResult (no full Next build): primary `_next/static` + // plus staged `public/` as publicStaticDir. + const staticDir = join(outputDir, '.next', 'static', 'chunks'); + const publicDir = join(outputDir, 'public'); + mkdirSync(staticDir, { recursive: true }); + mkdirSync(publicDir, { recursive: true }); + writeFileSync(join(staticDir, 'app.js'), 'app()'); + writeFileSync(join(publicDir, 'next.svg'), ''); + mkdirSync(join(publicDir, 'icons'), { recursive: true }); + writeFileSync(join(publicDir, 'icons', 'logo.png'), 'png'); + + const framework = { + name: 'nextjs', + runtime: 'node' as const, + packageManager: 'npm' as const, + buildCommand: 'next build', + buildOutput: '.next', + startCommand: 'node server.js', + staticDir: '.next/static', + staticAssetPublicPath: '_next/static', + confidence: 'high' as const, + }; + + const buildResult = { + outputDir, + startCommand: 'node server.js', + staticDir: join(outputDir, '.next', 'static'), + staticAssetPublicPath: '_next/static', + publicStaticDir: publicDir, + duration: 1, + logs: [] as string[], + }; + + const packageResult = await packageBuildOutput( + framework, + buildResult, + outputDir, + undefined, + undefined, + { + cdnBaseUrl: 'https://cdn.agentcompany.com/genesis/', + } + ); + expect(packageResult.launch.static?.include).toEqual([ + { directory: 'public', publicPath: '' }, + ]); + + const metadata = await generateDeployMetadata({ + buildResult, + packageResult, + projectDir: testDir, + projectId: 'test-project', + orgId: 'test-org', + region: 'us-east-1', + deploymentId: 'test-deployment', + logger, + }); + + const filenames = metadata.assets.map((a) => a.filename); + expect(filenames).toContain('_next/static/chunks/app.js'); + expect(filenames).toContain('next.svg'); + expect(filenames).toContain('icons/logo.png'); + const logo = metadata.assets.find((a) => a.filename === 'icons/logo.png'); + expect(logo?.sourcePath).toBe('public/icons/logo.png'); + }); + // ── No staticDir → no CDN assets ── test('generic project without staticDir produces no CDN assets', async () => {