Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ test-results
.tsbuildinfo
src/routeTree.gen.ts
.og-preview/
.wave-state.md
worker-configuration.d.ts
25 changes: 25 additions & 0 deletions docker/forge-sandbox/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Forge sandbox image: a baked template + Codex CLI, so a forge run starts
# from an already-scaffolded, already-installed TanStack Start app instead of
# paying scaffold/install cost on every session. The container's disk is
# ephemeral (Cloudflare containers report snapshots: false), so this image
# IS the warm starting point every sandbox boots from.
#
# Pin base tag to the @cloudflare/sandbox version in package.json.
FROM docker.io/cloudflare/sandbox:0.12.1

# Codex CLI + TanStack CLI baked (native binaries are OPTIONAL deps → --include=optional).
RUN npm i -g @openai/codex @tanstack/cli --include=optional && codex --version

# The expensive part, ONCE: scaffold + install + write Intent skill mappings.
# `project-name` must be URL-friendly (no path separators) — the actual
# filesystem location is controlled by --target-dir.
RUN tanstack create app --framework react --no-examples --intent -y --target-dir /workspace/app

# Vite preview-readiness: the forge preview URL hits the dev server on a
# Cloudflare-assigned host, not localhost, so Vite must bind all interfaces
# and allow that host. Runs AFTER scaffold — vite.config.ts only exists once
# the app has been created above. Idempotent by design (safe to re-run).
COPY patch-vite-config.mjs /tmp/patch-vite-config.mjs
RUN node /tmp/patch-vite-config.mjs /workspace/app/vite.config.ts

WORKDIR /workspace/app
323 changes: 323 additions & 0 deletions docker/forge-sandbox/patch-vite-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
#!/usr/bin/env node
// Idempotently patches a scaffolded vite.config.ts so the dev server accepts
// requests proxied through the forge preview host: adds/merges
// `server: { host: true, allowedHosts: true }` into the object passed to
// `defineConfig(...)`.
//
// Usage: node patch-vite-config.mjs <path/to/vite.config.ts>
//
// No dependencies (the sandbox image doesn't guarantee a TypeScript parser
// is installed), so this uses careful brace-depth scanning instead of an AST.
// It only ever inserts text — it never rewrites or reformats code it doesn't
// need to touch, so running it twice is a no-op.

import fs from 'node:fs'

const filePath = process.argv[2]

if (!filePath) {
console.error(
'patch-vite-config: missing argument. Usage: node patch-vite-config.mjs <path/to/vite.config.ts>',
)
process.exit(1)
}

if (!fs.existsSync(filePath)) {
console.error(`patch-vite-config: file not found: ${filePath}`)
process.exit(1)
}

const source = fs.readFileSync(filePath, 'utf8')

/**
* Finds the index of the `{` that opens the object literal passed as the
* first argument to `defineConfig(`, and the index of its matching `}`.
* Returns null if `defineConfig(...)` can't be located or its first
* argument isn't an object literal (config shape we don't recognize).
*/
function findDefineConfigObjectRange(text) {
const callMatch = text.match(/defineConfig\s*\(/)
if (!callMatch) return null

const afterCallParen = callMatch.index + callMatch[0].length
// Skip whitespace to find the first argument.
let i = afterCallParen
while (i < text.length && /\s/.test(text[i])) i++

if (text[i] !== '{') {
// First arg isn't an inline object literal (e.g. a variable or a
// function call) — not a shape we can safely merge into.
return null
}

const objectStart = i
const objectEnd = findMatchingBrace(text, objectStart)
if (objectEnd === null) return null

return { objectStart, objectEnd }
}

/** Given the index of an opening `{`, returns the index of its matching `}`. */
function findMatchingBrace(text, openIndex) {
let depth = 0
let inString = null // one of `'`, `"`, `` ` ``, or null
let inLineComment = false
let inBlockComment = false

for (let i = openIndex; i < text.length; i++) {
const ch = text[i]
const prev = text[i - 1]

if (inLineComment) {
if (ch === '\n') inLineComment = false
continue
}
if (inBlockComment) {
if (prev === '*' && ch === '/') inBlockComment = false
continue
}
if (inString) {
if (ch === '\\') {
i++ // skip escaped char
continue
}
if (ch === inString) inString = null
continue
}

if (ch === '/' && text[i + 1] === '/') {
inLineComment = true
continue
}
if (ch === '/' && text[i + 1] === '*') {
inBlockComment = true
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inString = ch
continue
}

if (ch === '{') depth++
if (ch === '}') {
depth--
if (depth === 0) return i
}
}

return null
}

/**
* Finds a top-level `server:` (or `server :`) key within the object body
* (the text strictly between objectStart+1 and objectEnd), scanning only at
* brace-depth 0 relative to that body so nested `server` keys in other
* objects aren't matched. Returns the range of the server object's `{...}`
* (braceStart/braceEnd), or null if no top-level `server` key exists.
*/
function findTopLevelServerBlock(text, bodyStart, bodyEnd) {
let depth = 0
let inString = null
let inLineComment = false
let inBlockComment = false

const keyRegex = /\bserver\s*:/g

for (let i = bodyStart; i < bodyEnd; i++) {
const ch = text[i]
const prev = text[i - 1]

if (inLineComment) {
if (ch === '\n') inLineComment = false
continue
}
if (inBlockComment) {
if (prev === '*' && ch === '/') inBlockComment = false
continue
}
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === inString) inString = null
continue
}

if (ch === '/' && text[i + 1] === '/') {
inLineComment = true
continue
}
if (ch === '/' && text[i + 1] === '*') {
inBlockComment = true
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inString = ch
continue
}

if (ch === '{' || ch === '(' || ch === '[') depth++
if (ch === '}' || ch === ')' || ch === ']') depth--

if (depth === 0) {
keyRegex.lastIndex = i
const match = keyRegex.exec(text)
if (match && match.index === i) {
// Found a top-level `server:` key. Find the `{` that starts its value.
let j = i + match[0].length
while (j < bodyEnd && /\s/.test(text[j])) j++
if (text[j] !== '{') {
// `server` value isn't an inline object — unrecognized shape.
return { unrecognized: true }
}
const braceEnd = findMatchingBrace(text, j)
if (braceEnd === null) return { unrecognized: true }
return { keyStart: i, braceStart: j, braceEnd }
}
}
}

return null
}

/**
* Checks whether a top-level key (e.g. `host` or `allowedHosts`) already
* exists directly inside the object body spanning (bodyStart, bodyEnd)
* (exclusive of the surrounding braces), ignoring nested objects/strings/comments.
*/
function hasTopLevelKey(text, bodyStart, bodyEnd, keyName) {
let depth = 0
let inString = null
let inLineComment = false
let inBlockComment = false
const keyRegex = new RegExp(`\\b${keyName}\\s*:`)

for (let i = bodyStart; i < bodyEnd; i++) {
const ch = text[i]
const prev = text[i - 1]

if (inLineComment) {
if (ch === '\n') inLineComment = false
continue
}
if (inBlockComment) {
if (prev === '*' && ch === '/') inBlockComment = false
continue
}
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === inString) inString = null
continue
}

if (ch === '/' && text[i + 1] === '/') {
inLineComment = true
continue
}
if (ch === '/' && text[i + 1] === '*') {
inBlockComment = true
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inString = ch
continue
}

if (ch === '{' || ch === '(' || ch === '[') depth++
if (ch === '}' || ch === ')' || ch === ']') depth--

if (depth === 0 && keyRegex.test(text.slice(i, i + keyName.length + 8))) {
const slice = text.slice(i)
const m = slice.match(new RegExp(`^${keyName}\\s*:`))
if (m) return true
}
}

return false
}

/** Returns the indentation (leading whitespace) of the line containing index. */
function indentOfLineAt(text, index) {
let lineStart = text.lastIndexOf('\n', index - 1) + 1
let i = lineStart
let indent = ''
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
indent += text[i]
i++
}
return indent
}

const configRange = findDefineConfigObjectRange(source)

if (!configRange) {
console.error(
`patch-vite-config: could not locate defineConfig({ ... }) with an inline object argument in ${filePath}. ` +
'This script only knows how to patch that shape.',
)
process.exit(1)
}

const { objectStart, objectEnd } = configRange
const bodyStart = objectStart + 1
const bodyEnd = objectEnd

const serverBlock = findTopLevelServerBlock(source, bodyStart, bodyEnd)

if (serverBlock && serverBlock.unrecognized) {
console.error(
`patch-vite-config: found a top-level "server" key in ${filePath} but its value isn't an inline object literal. ` +
'Refusing to guess how to merge — patch it by hand.',
)
process.exit(1)
}

let output = source

if (serverBlock) {
// Merge host/allowedHosts into the existing server block.
const { braceStart, braceEnd } = serverBlock
const serverBodyStart = braceStart + 1
const serverBodyEnd = braceEnd

const hasHost = hasTopLevelKey(source, serverBodyStart, serverBodyEnd, 'host')
const hasAllowedHosts = hasTopLevelKey(
source,
serverBodyStart,
serverBodyEnd,
'allowedHosts',
)

if (hasHost && hasAllowedHosts) {
// Already patched — no-op, keep output === source.
} else {
const indent = indentOfLineAt(source, braceStart) + ' '
const additions = []
if (!hasAllowedHosts) additions.push(`${indent}allowedHosts: true,\n`)
if (!hasHost) additions.push(`${indent}host: true,\n`)
const insertion = additions.join('')

// Insert right after the opening brace of the server block; the rest of
// the original server body (and everything after it) follows unchanged.
output =
source.slice(0, serverBodyStart) + '\n' + insertion + source.slice(serverBodyStart)
}
} else {
// No top-level `server` key at all — add the whole block.
const indent = indentOfLineAt(source, objectStart) + ' '
const serverBlockText = `${indent}server: { host: true, allowedHosts: true },\n`

output = source.slice(0, bodyStart) + '\n' + serverBlockText + source.slice(bodyStart)
}

if (output === source) {
console.log(`patch-vite-config: ${filePath} already has server.host and server.allowedHosts — no changes made.`)
process.exit(0)
}

fs.writeFileSync(filePath, output, 'utf8')
console.log(`patch-vite-config: patched ${filePath}`)
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@
"test:forge-run-recovery": "tsx scripts/verify-forge-run-recovery.ts",
"test:forge-run-summary-title": "tsx scripts/verify-forge-run-summary-title.ts",
"test:forge-run-terminal-projection": "tsx scripts/verify-forge-run-terminal-projection.ts",
"test:forge-sandbox-e2e": "tsx scripts/verify-forge-sandbox-e2e.ts",
"test:forge-sandbox-collect-workspace": "tsx scripts/verify-forge-sandbox-collect-workspace.ts",
"test:forge-sandbox-definition": "tsx scripts/verify-forge-sandbox-definition.ts",
"test:forge-sandbox-harness-config": "tsx scripts/verify-forge-sandbox-harness-config.ts",
"test:forge-sandbox-harness-selection": "tsx scripts/verify-forge-sandbox-harness-selection.ts",
"test:forge-sandbox-materialize": "tsx scripts/verify-forge-sandbox-materialize.ts",
"test:forge-sandbox-event-translation": "tsx scripts/verify-forge-sandbox-event-translation.ts",
"test:forge-sandbox-preview-tool": "tsx scripts/verify-forge-sandbox-preview-tool.ts",
"test:forge-sandbox-worker-routing": "tsx scripts/verify-forge-sandbox-worker-routing.ts",
"test:forge-session-workspace-scope": "tsx scripts/verify-forge-session-workspace-scope.ts",
"test:forge-snapshot-projection": "tsx scripts/verify-forge-snapshot-projection.ts",
"test:forge-system-snapshot": "tsx scripts/verify-forge-system-snapshot.ts",
Expand Down Expand Up @@ -109,8 +118,12 @@
"@takumi-rs/helpers": "^1.1.2",
"@takumi-rs/image-response": "^1.1.2",
"@takumi-rs/wasm": "1.3.0",
"@tanstack/ai": "^0.20.1",
"@cloudflare/sandbox": "^0.12.1",
"@tanstack/ai": "^0.39.0",
"@tanstack/ai-anthropic": "^0.10.1",
"@tanstack/ai-codex": "^0.2.0",
"@tanstack/ai-sandbox": "^0.2.0",
"@tanstack/ai-sandbox-cloudflare": "^0.2.0",
"@tanstack/ai-client": "^0.11.3",
"@tanstack/ai-openai": "^0.9.5",
"@tanstack/create": "^0.68.4",
Expand Down
Loading
Loading