From dd2cf288b96eda18be335e9f6470b60dfefaa40b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Sun, 8 Mar 2026 08:53:23 -0400 Subject: [PATCH] feat: bundle skills and tools into packaged whitelabel builds Build-time: - loader.ts copies skill directories and tool binaries referenced in whitelabel.yaml into src/whitelabel-resources/ - Rewrites paths in baked config to use __WHITELABEL_RESOURCES__ sentinel - forge.config.ts conditionally includes whitelabel-resources in extraResource Runtime: - main.ts passes GOOSE_RESOURCES_PATH to renderer via appConfig - sessions.ts resolves __WHITELABEL_RESOURCES__ sentinel to actual resources path when building the whitelabel recipe Absolute paths (dev mode) pass through unchanged. --- ui/desktop/.gitignore | 3 + ui/desktop/forge.config.ts | 8 ++- ui/desktop/src/main.ts | 1 + ui/desktop/src/sessions.ts | 11 +++- ui/desktop/src/whitelabel/constants.ts | 5 ++ ui/desktop/src/whitelabel/loader.ts | 79 +++++++++++++++++++++++++- 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 ui/desktop/src/whitelabel/constants.ts diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore index 2e53ae74cb..2bfd87cfbe 100644 --- a/ui/desktop/.gitignore +++ b/ui/desktop/.gitignore @@ -11,3 +11,6 @@ src/bin/goose-npm/ src/bin/temporal.db # Signing credentials .env.signing + +# Whitelabel bundled assets (build artifact) +src/whitelabel-resources/ diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index a97c286a30..6b2b44ef88 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -1,10 +1,16 @@ const { FusesPlugin } = require('@electron-forge/plugin-fuses'); const { FuseV1Options, FuseVersion } = require('@electron/fuses'); const { resolve } = require('path'); +const fs = require('fs'); + +const extraResource = ['src/bin', 'src/images', 'src/sandbox']; +if (fs.existsSync(resolve(__dirname, 'src/whitelabel-resources'))) { + extraResource.push('src/whitelabel-resources'); +} let cfg = { asar: true, - extraResource: ['src/bin', 'src/images', 'src/sandbox'], + extraResource, icon: 'src/images/icon', // Windows specific configuration win32: { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index cdd5e670d5..0fd83a4a6f 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -618,6 +618,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { ...appConfig, GOOSE_API_HOST: baseUrl, GOOSE_WORKING_DIR: workingDir, + GOOSE_RESOURCES_PATH: app.isPackaged ? process.resourcesPath : process.cwd(), REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: baseUrlShare, GOOSE_VERSION: version, diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index c7ff4899cb..69979fe386 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -10,6 +10,7 @@ import { AppEvents } from './constants/events'; import { decodeRecipe, Recipe } from './recipe'; import type { WhiteLabelConfig } from './whitelabel/types'; import { DEFAULT_WHITELABEL_CONFIG } from './whitelabel/defaults'; +import { RESOURCES_PREFIX } from './whitelabel/constants'; function getWhiteLabelConfig(): WhiteLabelConfig { try { @@ -19,6 +20,12 @@ function getWhiteLabelConfig(): WhiteLabelConfig { } } +function resolveResourcePath(p: string): string { + if (!p.startsWith(RESOURCES_PREFIX)) return p; + const resourcesPath = (window.appConfig?.get('GOOSE_RESOURCES_PATH') as string) || ''; + return p.replace(RESOURCES_PREFIX, resourcesPath + '/whitelabel-resources'); +} + /** * Build a Recipe from the whitelabel config. * This replaces the default system prompt entirely — the agent becomes @@ -48,7 +55,7 @@ function buildWhiteLabelRecipe(): Recipe | undefined { for (const skill of defaults.skills) { lines.push(`## ${skill.name}`); lines.push(skill.description); - lines.push(`Skill directory: \`${skill.path}\``); + lines.push(`Skill directory: \`${resolveResourcePath(skill.path)}\``); lines.push( 'Read the SKILL.md in this directory for detailed instructions before starting this type of work.' ); @@ -63,7 +70,7 @@ function buildWhiteLabelRecipe(): Recipe | undefined { for (const tool of defaults.tools) { lines.push(`## \`${tool.name}\``); lines.push(tool.description); - lines.push(`Binary: \`${tool.path}\``); + lines.push(`Binary: \`${resolveResourcePath(tool.path)}\``); if (tool.env) { const envList = Object.entries(tool.env) .map(([k, v]) => ` - \`${k}\`: ${v || '(required)'}`) diff --git a/ui/desktop/src/whitelabel/constants.ts b/ui/desktop/src/whitelabel/constants.ts new file mode 100644 index 0000000000..3c274908c3 --- /dev/null +++ b/ui/desktop/src/whitelabel/constants.ts @@ -0,0 +1,5 @@ +/** + * Sentinel prefix used in baked config paths for bundled assets. + * At runtime, replaced with the actual Electron resources path. + */ +export const RESOURCES_PREFIX = '__WHITELABEL_RESOURCES__'; diff --git a/ui/desktop/src/whitelabel/loader.ts b/ui/desktop/src/whitelabel/loader.ts index 2881decb6a..6b8dca26d1 100644 --- a/ui/desktop/src/whitelabel/loader.ts +++ b/ui/desktop/src/whitelabel/loader.ts @@ -34,6 +34,78 @@ function deepMerge(target: any, source: any): any { } /* eslint-enable @typescript-eslint/no-explicit-any */ +import { RESOURCES_PREFIX } from './constants'; + +function copyDirRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Bundle skills, tools, and other assets referenced by the whitelabel config + * into src/whitelabel-resources/ so they get picked up by extraResource. + * Rewrites paths in the config to use RESOURCES_PREFIX. + */ +function bundleAssets(config: WhiteLabelConfig, configDir: string, projectRoot: string): void { + const resourcesDir = path.join(projectRoot, 'src', 'whitelabel-resources'); + + // Clean previous bundle + if (fs.existsSync(resourcesDir)) { + fs.rmSync(resourcesDir, { recursive: true }); + } + + let needsBundle = false; + + // Bundle skill directories + if (config.defaults.skills) { + for (const skill of config.defaults.skills) { + const resolved = path.resolve(configDir, skill.path); + if (!fs.existsSync(resolved)) { + console.warn(`[WhiteLabel] Skill path not found: ${resolved}`); + continue; + } + const destName = path.basename(resolved); + const dest = path.join(resourcesDir, 'skills', destName); + console.log(`[WhiteLabel] Bundling skill: ${resolved} → ${dest}`); + copyDirRecursive(resolved, dest); + skill.path = `${RESOURCES_PREFIX}/skills/${destName}`; + needsBundle = true; + } + } + + // Bundle tool binaries + if (config.defaults.tools) { + for (const tool of config.defaults.tools) { + const resolved = path.resolve(configDir, tool.path); + if (!fs.existsSync(resolved)) { + console.warn(`[WhiteLabel] Tool binary not found: ${resolved}`); + continue; + } + const destName = path.basename(resolved); + const dest = path.join(resourcesDir, 'tools', destName); + console.log(`[WhiteLabel] Bundling tool: ${resolved} → ${dest}`); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(resolved, dest); + // Preserve executable permission + fs.chmodSync(dest, 0o755); + tool.path = `${RESOURCES_PREFIX}/tools/${destName}`; + needsBundle = true; + } + } + + if (!needsBundle && fs.existsSync(resourcesDir)) { + fs.rmSync(resourcesDir, { recursive: true }); + } +} + export function loadWhiteLabelConfig(projectRoot: string): WhiteLabelConfig { // Allow override via env var — resolve relative paths from CWD (not projectRoot) const envPath = process.env.WHITELABEL_CONFIG; @@ -49,6 +121,11 @@ export function loadWhiteLabelConfig(projectRoot: string): WhiteLabelConfig { console.log(`[WhiteLabel] Loading config from ${configPath}`); const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = yaml.parse(raw) as Partial; + const config = deepMerge(DEFAULT_WHITELABEL_CONFIG, parsed) as WhiteLabelConfig; - return deepMerge(DEFAULT_WHITELABEL_CONFIG, parsed) as WhiteLabelConfig; + // Bundle assets referenced by the config (skills, tools) + const configDir = path.dirname(configPath); + bundleAssets(config, configDir, projectRoot); + + return config; }