fix: tfcode web fails after npm install — copy app dist in postinstall

The web command crashed because postinstall scripts never copied the
app/dist directory from the platform package. Added copyAppDir() to
both postinstall scripts, multi-path resolution in findAppDir() and
server static serving, and updated branding to use local favicon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gab
2026-04-15 13:57:44 +10:00
parent c3e504f036
commit 3639218879
15 changed files with 251 additions and 175 deletions

View File

@@ -381,7 +381,7 @@
},
"packages/tfcode": {
"name": "@toothfairyai/tfcode",
"version": "1.0.33",
"version": "1.0.36",
"bin": {
"tfcode": "./bin/tfcode",
},

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TF Code</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />

BIN
packages/app/public/favicon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -67,7 +67,7 @@ const notify: Platform["notify"] = async (title, description, href) => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
icon: "/favicon.png",
})
notification.onclick = () => {

View File

@@ -43,9 +43,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={
props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
}
src={props.project.id === OPENCODE_PROJECT_ID ? "/favicon.png" : props.project.icon?.override}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
classList={{ "badge-mask": notify() }}

View File

@@ -1,11 +1,12 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.33",
"version": "1.0.36",
"name": "@toothfairyai/tfcode",
"type": "module",
"license": "MIT",
"files": [
"bin",
"app",
"python",
"postinstall.mjs",
"LICENSE"

View File

@@ -164,6 +164,10 @@ for (const item of targets) {
}
await $`rm -rf ./dist/${name}/bin/tui`
const appDistDir = path.resolve(dir, "../app/dist")
if (fs.existsSync(appDistDir)) {
fs.cpSync(appDistDir, path.join(dir, "dist", name, "bin", "app", "dist"), { recursive: true })
}
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{

View File

@@ -181,6 +181,15 @@ async function downloadBinary() {
console.log(`Installed tfcode to ${targetBinary}`)
}
// Move app directory
const extractedAppDir = path.join(tmpDir, "app")
const targetAppDir = path.join(binDir, "app")
if (fs.existsSync(path.join(extractedAppDir, "dist", "index.html"))) {
if (fs.existsSync(targetAppDir)) fs.rmSync(targetAppDir, { recursive: true, force: true })
fs.cpSync(extractedAppDir, targetAppDir, { recursive: true })
console.log("Installed web app")
}
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true })
}
@@ -202,6 +211,16 @@ function setupBinary(sourcePath, platform) {
fs.chmodSync(targetBinary, 0o755)
console.log(`tfcode installed to ${targetBinary}`)
// Also copy app dir from platform package
const sourceDir = path.dirname(sourcePath)
const srcAppDir = path.join(sourceDir, "app")
const targetAppDir = path.join(binDir, "app")
if (fs.existsSync(path.join(srcAppDir, "dist", "index.html"))) {
if (fs.existsSync(targetAppDir)) fs.rmSync(targetAppDir, { recursive: true, force: true })
fs.cpSync(srcAppDir, targetAppDir, { recursive: true })
console.log("Web app copied from platform package")
}
}
async function main() {

View File

@@ -1,171 +1,225 @@
#!/usr/bin/env node
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { spawn, execSync } = require("child_process")
const fs = require("fs")
const path = require("path")
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const RED = '\x1b[31m';
const CYAN = '\x1b[36m';
const DIM = '\x1b[90m';
const RESET = "\x1b[0m"
const BOLD = "\x1b[1m"
const GREEN = "\x1b[32m"
const YELLOW = "\x1b[33m"
const RED = "\x1b[31m"
const CYAN = "\x1b[36m"
const DIM = "\x1b[90m"
function log(msg) {
console.log(msg);
console.log(msg)
}
function logSuccess(msg) {
console.log(`${GREEN}${RESET} ${msg}`);
console.log(`${GREEN}${RESET} ${msg}`)
}
function logError(msg) {
console.error(`${RED}${RESET} ${msg}`);
console.error(`${RED}${RESET} ${msg}`)
}
function logInfo(msg) {
console.log(`${CYAN}${RESET} ${msg}`);
console.log(`${CYAN}${RESET} ${msg}`)
}
function logWarning(msg) {
console.log(`${YELLOW}!${RESET} ${msg}`);
console.log(`${YELLOW}!${RESET} ${msg}`)
}
function checkPython() {
const commands = ['python3', 'python'];
const commands = ["python3", "python"]
for (const cmd of commands) {
try {
const result = execSync(`${cmd} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
const match = result.match(/Python (\d+)\.(\d+)/);
const result = execSync(`${cmd} --version`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] })
const match = result.match(/Python (\d+)\.(\d+)/)
if (match) {
const major = parseInt(match[1]);
const minor = parseInt(match[2]);
const major = parseInt(match[1])
const minor = parseInt(match[2])
if (major >= 3 && minor >= 10) {
return { cmd, version: result.trim() };
return { cmd, version: result.trim() }
}
}
} catch {}
}
return null;
return null
}
function installPythonDeps(pythonCmd) {
return new Promise((resolve, reject) => {
const packages = ['toothfairyai', 'pydantic', 'httpx', 'rich'];
log(`${DIM}Installing Python packages: ${packages.join(', ')}...${RESET}`);
const packages = ["toothfairyai", "pydantic", "httpx", "rich"]
log(`${DIM}Installing Python packages: ${packages.join(", ")}...${RESET}`)
// Try with --user first, then --break-system-packages if needed
const args = ['-m', 'pip', 'install', '--user', ...packages];
const args = ["-m", "pip", "install", "--user", ...packages]
const proc = spawn(pythonCmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32'
});
proc.on('close', (code) => {
stdio: "inherit",
shell: process.platform === "win32",
})
proc.on("close", (code) => {
if (code === 0) {
resolve();
resolve()
} else {
// Try with --break-system-packages flag
log(`${DIM}Retrying with --break-system-packages...${RESET}`);
const retryArgs = ['-m', 'pip', 'install', '--user', '--break-system-packages', ...packages];
log(`${DIM}Retrying with --break-system-packages...${RESET}`)
const retryArgs = ["-m", "pip", "install", "--user", "--break-system-packages", ...packages]
const retry = spawn(pythonCmd, retryArgs, {
stdio: 'inherit',
shell: process.platform === 'win32'
});
retry.on('close', (retryCode) => {
stdio: "inherit",
shell: process.platform === "win32",
})
retry.on("close", (retryCode) => {
if (retryCode === 0) {
resolve();
resolve()
} else {
reject(new Error(`pip install exited with code ${retryCode}`));
reject(new Error(`pip install exited with code ${retryCode}`))
}
});
retry.on('error', (err) => {
reject(err);
});
})
retry.on("error", (err) => {
reject(err)
})
}
});
proc.on('error', (err) => {
reject(err);
});
});
})
proc.on("error", (err) => {
reject(err)
})
})
}
function copyAppDir() {
let platform
switch (process.platform) {
case "darwin":
platform = "darwin"
break
case "linux":
platform = "linux"
break
case "win32":
platform = "windows"
break
default:
platform = process.platform
}
let arch
switch (process.arch) {
case "x64":
arch = "x64"
break
case "arm64":
arch = "arm64"
break
default:
arch = process.arch
}
const pkgName = `@toothfairyai/tfcode-${platform}-${arch}`
const appDir = path.join(__dirname, "app")
if (fs.existsSync(path.join(appDir, "dist", "index.html"))) {
logSuccess("Web app already present")
return
}
try {
const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`))
const srcAppDir = path.join(pkgDir, "bin", "app")
if (!fs.existsSync(path.join(srcAppDir, "dist", "index.html"))) {
logWarning("Web app dist not found in platform package")
return
}
fs.cpSync(srcAppDir, appDir, { recursive: true })
logSuccess("Web app copied from platform package")
} catch (e) {
logWarning(`Could not copy web app: ${e.message}`)
}
}
async function main() {
log('');
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
log(`${BOLD} tfcode - ToothFairyAI's official coding agent${RESET}`);
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
log('');
log("")
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`)
log(`${BOLD} tfcode - ToothFairyAI's official coding agent${RESET}`)
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`)
log("")
copyAppDir()
// Check for Python
logInfo('Checking Python installation...');
const python = checkPython();
logInfo("Checking Python installation...")
const python = checkPython()
if (!python) {
log('');
logError('Python 3.10+ is required but not found.');
log('');
log(`${BOLD}Please install Python 3.10 or later:${RESET}`);
log('');
log(` ${CYAN}macOS:${RESET} brew install python@3.12`);
log(` ${CYAN}Ubuntu:${RESET} sudo apt-get install python3.12`);
log(` ${CYAN}Windows:${RESET} Download from https://python.org/downloads`);
log('');
log(`${DIM}After installing Python, run: npm rebuild tfcode${RESET}`);
log('');
process.exit(1);
log("")
logError("Python 3.10+ is required but not found.")
log("")
log(`${BOLD}Please install Python 3.10 or later:${RESET}`)
log("")
log(` ${CYAN}macOS:${RESET} brew install python@3.12`)
log(` ${CYAN}Ubuntu:${RESET} sudo apt-get install python3.12`)
log(` ${CYAN}Windows:${RESET} Download from https://python.org/downloads`)
log("")
log(`${DIM}After installing Python, run: npm rebuild tfcode${RESET}`)
log("")
process.exit(1)
}
logSuccess(`Found ${python.version} (${python.cmd})`);
log('');
logSuccess(`Found ${python.version} (${python.cmd})`)
log("")
// Install Python dependencies
logInfo('Installing ToothFairyAI Python SDK...');
logInfo("Installing ToothFairyAI Python SDK...")
try {
await installPythonDeps(python.cmd);
logSuccess('Python dependencies installed');
await installPythonDeps(python.cmd)
logSuccess("Python dependencies installed")
} catch (err) {
logWarning(`Failed to install Python dependencies: ${err.message}`);
log('');
log(`${DIM}You can install them manually with:${RESET}`);
log(` ${CYAN}${python.cmd} -m pip install toothfairyai pydantic httpx rich${RESET}`);
log('');
logWarning(`Failed to install Python dependencies: ${err.message}`)
log("")
log(`${DIM}You can install them manually with:${RESET}`)
log(` ${CYAN}${python.cmd} -m pip install toothfairyai pydantic httpx rich${RESET}`)
log("")
// Don't exit - user might install manually later
}
log('');
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
log(`${GREEN}✓ tfcode installed successfully!${RESET}`);
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
log('');
log(`${BOLD}Quick Start:${RESET}`);
log('');
log(` ${CYAN}1.${RESET} Set your ToothFairyAI credentials:`);
log(` ${DIM}export TF_WORKSPACE_ID="your-workspace-id"${RESET}`);
log(` ${DIM}export TF_API_KEY="your-api-key"${RESET}`);
log('');
log(` ${CYAN}2.${RESET} Validate your credentials:`);
log(` ${DIM}tfcode validate${RESET}`);
log('');
log(` ${CYAN}3.${RESET} Sync tools from your workspace:`);
log(` ${DIM}tfcode sync${RESET}`);
log('');
log(` ${CYAN}4.${RESET} Start coding:`);
log(` ${DIM}tfcode${RESET}`);
log('');
log(`${DIM}Documentation: https://toothfairyai.com/developers/tfcode${RESET}`);
log('');
log("")
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`)
log(`${GREEN}✓ tfcode installed successfully!${RESET}`)
log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`)
log("")
log(`${BOLD}Quick Start:${RESET}`)
log("")
log(` ${CYAN}1.${RESET} Set your ToothFairyAI credentials:`)
log(` ${DIM}export TF_WORKSPACE_ID="your-workspace-id"${RESET}`)
log(` ${DIM}export TF_API_KEY="your-api-key"${RESET}`)
log("")
log(` ${CYAN}2.${RESET} Validate your credentials:`)
log(` ${DIM}tfcode validate${RESET}`)
log("")
log(` ${CYAN}3.${RESET} Sync tools from your workspace:`)
log(` ${DIM}tfcode sync${RESET}`)
log("")
log(` ${CYAN}4.${RESET} Start coding:`)
log(` ${DIM}tfcode${RESET}`)
log("")
log(`${DIM}Documentation: https://toothfairyai.com/developers/tfcode${RESET}`)
log("")
}
main().catch((err) => {
logError(`Installation failed: ${err.message}`);
process.exit(1);
});
logError(`Installation failed: ${err.message}`)
process.exit(1)
})

View File

@@ -11,9 +11,6 @@ export const ServeCommand = cmd({
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless tfcode server",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`tfcode server listening on http://${server.hostname}:${server.port}`)

View File

@@ -27,8 +27,22 @@ function getNetworkIPs() {
return results
}
function findAppDir(): string {
const binDir = path.dirname(process.execPath)
const candidates = [
path.join(binDir, "app"),
path.join(binDir, "..", "app"),
path.join(import.meta.dirname, "..", "app"),
path.join(import.meta.dirname, "..", "..", "..", "..", "app"),
]
for (const dir of candidates) {
if (existsSync(path.join(dir, "dist", "index.html"))) return dir
}
return candidates[0]
}
async function ensureAppBuilt() {
const appDir = path.join(import.meta.dirname, "..", "..", "..", "..", "app")
const appDir = findAppDir()
const distDir = path.join(appDir, "dist")
if (!existsSync(distDir) || !existsSync(path.join(distDir, "index.html"))) {
@@ -44,10 +58,6 @@ export const WebCommand = cmd({
builder: (yargs) => withNetworkOptions(yargs),
describe: "start tfcode server and open web interface",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
await ensureAppBuilt()
const opts = await resolveNetworkOptions(args)

View File

@@ -5,6 +5,7 @@ import { cors } from "hono/cors"
import { basicAuth } from "hono/basic-auth"
import z from "zod"
import path from "path"
import { existsSync } from "fs"
import { Provider } from "../provider/provider"
import { NamedError } from "@opencode-ai/util/error"
import { LSP } from "../lsp"
@@ -530,8 +531,15 @@ export namespace Server {
)
.all("/*", async (c) => {
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path
const appDist = path.join(import.meta.dirname, "..", "..", "..", "app", "dist")
const publicDir = path.join(import.meta.dirname, "..", "..", "..", "app", "public")
const binDir = path.dirname(process.execPath)
const appDistCandidates = [
path.join(binDir, "app", "dist"),
path.join(binDir, "..", "app", "dist"),
path.join(import.meta.dirname, "..", "app", "dist"),
path.join(import.meta.dirname, "..", "..", "..", "app", "dist"),
]
const appDist = appDistCandidates.find((d) => existsSync(path.join(d, "index.html"))) ?? appDistCandidates[0]
const publicDir = path.join(appDist, "..", "public")
let filePath = path.join(appDist, reqPath)
let file = Bun.file(filePath)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,4 +1,13 @@
[data-component="logo-mark"] {
width: 16px;
aspect-ratio: 4/5;
aspect-ratio: 137/120;
object-fit: contain;
}
[data-component="logo-splash"] {
object-fit: contain;
}
[data-component="logo-full"] {
object-fit: contain;
}

View File

@@ -1,60 +1,39 @@
import { ComponentProps } from "solid-js"
import tfIcon from "../assets/favicon.png"
export const Mark = (props: { class?: string }) => {
return (
<svg
<img
data-component="logo-mark"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 16 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path data-slot="logo-logo-mark-shadow" d="M12 16H4V8H12V16Z" fill="var(--icon-weak-base)" />
<path data-slot="logo-logo-mark-o" d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="var(--icon-strong-base)" />
</svg>
src={tfIcon}
alt="ToothFairyAI"
draggable={false}
/>
)
}
export const Splash = (props: Pick<ComponentProps<"svg">, "ref" | "class">) => {
export const Splash = (props: Pick<ComponentProps<"img">, "ref" | "class">) => {
return (
<svg
<img
ref={props.ref}
data-component="logo-splash"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 80 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M60 80H20V40H60V80Z" fill="var(--icon-base)" />
<path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="var(--icon-strong-base)" />
</svg>
src={tfIcon}
alt="ToothFairyAI"
draggable={false}
/>
)
}
export const Logo = (props: { class?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 180 42"
fill="none"
<img
data-component="logo-full"
classList={{ [props.class ?? ""]: !!props.class }}
>
<g>
<path d="M18 30H6V18H18V30Z" fill="var(--icon-weak-base)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--icon-weak-base)" />
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--icon-base)" />
<text
x="66"
y="32"
font-size="28"
font-weight="600"
fill="var(--icon-strong-base)"
font-family="var(--font-family-sans)"
>
Code
</text>
</g>
</svg>
src={tfIcon}
alt="ToothFairyAI"
draggable={false}
/>
)
}