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.
This commit is contained in:
Michael Neale
2026-03-08 08:53:23 -04:00
parent 4369079e52
commit dd2cf288b9
6 changed files with 103 additions and 4 deletions
+3
View File
@@ -11,3 +11,6 @@ src/bin/goose-npm/
src/bin/temporal.db
# Signing credentials
.env.signing
# Whitelabel bundled assets (build artifact)
src/whitelabel-resources/
+7 -1
View File
@@ -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: {
+1
View File
@@ -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,
+9 -2
View File
@@ -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)'}`)
+5
View File
@@ -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__';
+78 -1
View File
@@ -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<WhiteLabelConfig>;
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;
}