feat: implement oauth with remote server

This commit is contained in:
Ralph Khreish
2025-09-02 23:32:40 +02:00
parent 930652e523
commit 88105b7f37
13 changed files with 413 additions and 119 deletions

View File

@@ -108,6 +108,12 @@ export class AuthCommand extends Command {
if (!result.success) {
process.exit(1);
}
// Exit cleanly after successful authentication
// Small delay to ensure all output is flushed
setTimeout(() => {
process.exit(0);
}, 100);
} catch (error: any) {
this.handleError(error);
process.exit(1);

139
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"commander": "^11.1.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"dotenv-mono": "^1.5.1",
"express": "^4.21.2",
"fastmcp": "^3.5.0",
"figlet": "^1.8.0",
@@ -100,7 +101,6 @@
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
"inquirer": "^9.2.10",
"open": "^10.2.0",
"ora": "^8.1.0"
},
"devDependencies": {
@@ -8948,6 +8948,80 @@
"version": "0.0.22",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.71.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz",
"integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.4.tgz",
"integrity": "sha512-e/FYIWjvQJHOCNACWehnKvg26zosju3694k0NMUNb+JGLdvHJzEa29ZVVLmawd2kvx4hdbv8mxSqfttRnH3+DA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
"integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.57.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.0.tgz",
"integrity": "sha512-h9ttcL0MY4h+cGqZl95F/RuqccuRBjHU9B7Qqvw0Da+pPK2sUlU1/UdvyqUGj37UsnSphr9pdGfeXjesYkBcyA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.71.1",
"@supabase/functions-js": "2.4.5",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.21.3",
"@supabase/realtime-js": "2.15.4",
"@supabase/storage-js": "^2.10.4"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"dev": true,
@@ -9344,6 +9418,12 @@
"form-data": "^4.0.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.8",
"dev": true,
@@ -9401,6 +9481,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"dev": true,
@@ -12834,6 +12923,49 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.2.tgz",
"integrity": "sha512-lXpXz2ZE1cea1gL4sz2Ipj8y4PiVjytYr3Ij0SWoms1PGxIv7m2CRKuRuCRtHdVuvM/hNJPMxt5PbhboNC4dPQ==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-mono": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dotenv-mono/-/dotenv-mono-1.5.1.tgz",
"integrity": "sha512-dt7bK/WKQvL0gcdTxjI7wD4MhVR5F4bCk70XMAgnrbWN3fdhpyhWCypYbZalr/vjLURLA7Ib9/VCzazRLJnp1Q==",
"license": "BSD-2-Clause",
"dependencies": {
"cross-spawn": "^7.0.6",
"dotenv": "^17.2.0",
"dotenv-expand": "^12.0.2",
"minimist": "^1.2.8"
},
"bin": {
"dotenv": "dist/cli.js",
"dotenv-mono": "dist/cli.js"
}
},
"node_modules/dotenv-mono/node_modules/dotenv": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
@@ -19593,9 +19725,7 @@
},
"node_modules/minimist": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -25880,7 +26010,6 @@
},
"node_modules/ws": {
"version": "8.18.2",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -26163,6 +26292,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@supabase/supabase-js": "^2.57.0",
"chalk": "^5.3.0",
"open": "^10.2.0",
"zod": "^3.22.4"
@@ -26171,6 +26301,7 @@
"@biomejs/biome": "^1.9.4",
"@types/node": "^20.11.30",
"@vitest/coverage-v8": "^2.0.5",
"dotenv-mono": "^1.5.1",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3",

View File

@@ -74,6 +74,7 @@
"commander": "^11.1.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"dotenv-mono": "^1.5.1",
"express": "^4.21.2",
"fastmcp": "^3.5.0",
"figlet": "^1.8.0",

View File

@@ -26,6 +26,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@supabase/supabase-js": "^2.57.0",
"chalk": "^5.3.0",
"open": "^10.2.0",
"zod": "^3.22.4"
@@ -34,6 +35,7 @@
"@biomejs/biome": "^1.9.4",
"@types/node": "^20.11.30",
"@vitest/coverage-v8": "^2.0.5",
"dotenv-mono": "^1.5.1",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3",

View File

@@ -9,8 +9,8 @@ import {
AuthConfig
} from './types';
import { CredentialStore } from './credential-store';
import { ApiClient } from './api-client';
import { OAuthService } from './oauth-service';
import { SupabaseAuthClient } from '../clients/supabase-client';
/**
* Authentication manager class
@@ -18,17 +18,13 @@ import { OAuthService } from './oauth-service';
export class AuthManager {
private static instance: AuthManager;
private credentialStore: CredentialStore;
private apiClient: ApiClient;
private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient;
private constructor(config?: Partial<AuthConfig>) {
this.credentialStore = new CredentialStore(config);
this.apiClient = new ApiClient(config);
this.oauthService = new OAuthService(
this.apiClient,
this.credentialStore,
config
);
this.supabaseClient = new SupabaseAuthClient();
this.oauthService = new OAuthService(this.credentialStore, config);
}
/**
@@ -64,56 +60,24 @@ export class AuthManager {
return this.oauthService.getAuthorizationUrl();
}
/**
* Authenticate with email and password
*/
async authenticateWithCredentials(
email: string,
password: string
): Promise<AuthCredentials> {
try {
const response = await this.apiClient.login(email, password);
// Save authentication data
const authData: AuthCredentials = {
token: response.token,
refreshToken: response.refreshToken,
userId: response.userId,
email: email,
expiresAt: response.expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
this.credentialStore.saveCredentials(authData);
return authData;
} catch (error) {
throw error;
}
}
/**
* Authenticate with API key
* Note: This would require a custom implementation or Supabase RLS policies
*/
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
try {
const response = await this.apiClient.validateApiKey(apiKey);
// TODO: Implement API key validation if needed
// For now, we can store the API key directly as a token
const authData: AuthCredentials = {
token: apiKey,
tokenType: 'api_key',
userId: 'api-user',
email: undefined,
expiresAt: undefined, // API keys don't expire
savedAt: new Date().toISOString()
};
// Save authentication data
const authData: AuthCredentials = {
token: apiKey,
tokenType: 'api_key',
userId: response.userId,
email: response.email,
expiresAt: undefined, // API keys don't expire
savedAt: new Date().toISOString()
};
this.credentialStore.saveCredentials(authData);
return authData;
} catch (error) {
throw error;
}
this.credentialStore.saveCredentials(authData);
return authData;
}
/**
@@ -130,12 +94,16 @@ export class AuthManager {
}
try {
const response = await this.apiClient.refreshToken(authData.refreshToken);
// Use Supabase client to refresh the token
const response = await this.supabaseClient.refreshSession(
authData.refreshToken
);
// Update authentication data
const newAuthData: AuthCredentials = {
...authData,
token: response.token,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
savedAt: new Date().toISOString()
};

View File

@@ -6,10 +6,13 @@ import os from 'os';
import path from 'path';
import { AuthConfig } from './types';
// Centralized URL configuration - change these for different environments
// For production, use: https://tryhamster.com
// For local testing, use: http://localhost:8080
const BASE_DOMAIN = 'http://localhost:8080'; // 'https://tryhamster.com';
// Use build-time value if available, otherwise use runtime env or default
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
// Runtime: TM_BASE_DOMAIN or HAMSTER_BASE_URL from user's environment
// Default: https://tryhamster.com for production
const BASE_DOMAIN =
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup
'https://tryhamster.com';
/**
* Default authentication configuration

View File

@@ -4,7 +4,6 @@
export { AuthManager } from './auth-manager';
export { CredentialStore } from './credential-store';
export { ApiClient } from './api-client';
export { OAuthService } from './oauth-service';
export type {

View File

@@ -6,8 +6,6 @@ import http from 'http';
import { URL } from 'url';
import crypto from 'crypto';
import os from 'os';
import fs from 'fs';
import path from 'path';
import open from 'open';
import {
AuthCredentials,
@@ -16,32 +14,26 @@ import {
AuthConfig,
CliData
} from './types';
import { ApiClient } from './api-client';
import { CredentialStore } from './credential-store';
import {
getSuccessHtml,
getErrorHtml,
getSecurityErrorHtml
} from './templates';
import { SupabaseAuthClient } from '../clients/supabase-client';
import { getAuthConfig } from './config';
import { getLogger } from '../logger';
import packageJson from '../../../../package.json' with { type: 'json' };
export class OAuthService {
private logger = getLogger('OAuthService');
private apiClient: ApiClient;
private credentialStore: CredentialStore;
private supabaseClient: SupabaseAuthClient;
private webBaseUrl: string;
private authorizationUrl: string | null = null;
private originalState: string | null = null;
constructor(
apiClient: ApiClient,
credentialStore: CredentialStore,
config: Partial<AuthConfig> = {}
) {
this.apiClient = apiClient;
this.credentialStore = credentialStore;
this.supabaseClient = new SupabaseAuthClient();
const authConfig = getAuthConfig(config);
this.webBaseUrl = authConfig.webBaseUrl;
}
@@ -129,7 +121,7 @@ export class OAuthService {
// Store the original state for verification
this.originalState = state;
// Prepare CLI data object
// Prepare CLI data object (server handles OAuth/PKCE)
const cliData: CliData = {
callback: callbackUrl,
state: state,
@@ -211,16 +203,20 @@ export class OAuthService {
resolve: (value: AuthCredentials) => void,
reject: (error: any) => void
): Promise<void> {
const code = url.searchParams.get('code');
// Server now returns tokens directly instead of code
const type = url.searchParams.get('type');
const returnedState = url.searchParams.get('state');
const accessToken = url.searchParams.get('access_token');
const refreshToken = url.searchParams.get('refresh_token');
const expiresIn = url.searchParams.get('expires_in');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Send response to browser
res.writeHead(200, { 'Content-Type': 'text/html' });
// Server handles displaying success/failure, just close connection
res.writeHead(200);
res.end();
if (error) {
res.end(getErrorHtml(errorDescription || error));
if (!serverClosed) {
server.close();
reject(
@@ -235,7 +231,6 @@ export class OAuthService {
// Verify state parameter for CSRF protection
if (returnedState !== this.originalState) {
res.end(getSecurityErrorHtml());
if (!serverClosed) {
server.close();
reject(
@@ -245,20 +240,29 @@ export class OAuthService {
return;
}
if (code) {
res.end(getSuccessHtml());
// Handle direct token response from server
if (
accessToken &&
(type === 'oauth_success' || type === 'session_transfer')
) {
try {
// Exchange authorization code for tokens
const response = await this.apiClient.exchangeCode(code);
this.logger.info(`Received tokens via ${type}`);
// Get user info using the access token if possible
const user = await this.supabaseClient.getUser(accessToken);
// Calculate expiration time
const expiresAt = expiresIn
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
: undefined;
// Save authentication data
const authData: AuthCredentials = {
token: response.token,
refreshToken: response.refreshToken,
userId: response.userId,
email: response.email,
expiresAt: response.expiresAt,
token: accessToken,
refreshToken: refreshToken || undefined,
userId: user?.id || 'unknown',
email: user?.email,
expiresAt: expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
@@ -275,6 +279,11 @@ export class OAuthService {
reject(error);
}
}
} else {
if (!serverClosed) {
server.close();
reject(new AuthenticationError('No access token received', 'NO_TOKEN'));
}
}
}

View File

@@ -0,0 +1,5 @@
/**
* Client exports
*/
export { SupabaseAuthClient } from './supabase-client';

View File

@@ -0,0 +1,154 @@
/**
* Supabase client for authentication
*/
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
import { AuthenticationError } from '../auth/types';
import { getLogger } from '../logger';
export class SupabaseAuthClient {
private client: SupabaseClient | null = null;
private logger = getLogger('SupabaseAuthClient');
/**
* Initialize Supabase client
*/
private getClient(): SupabaseClient {
if (!this.client) {
// Get Supabase configuration from environment
const supabaseUrl = process.env.SUPABASE_URL || 'http://localhost:8080';
const supabaseAnonKey =
process.env.SUPABASE_ANON_KEY ||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';
if (!supabaseUrl || !supabaseAnonKey) {
throw new AuthenticationError(
'Supabase configuration missing. Please set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.',
'CONFIG_MISSING'
);
}
this.client = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: false, // We handle persistence ourselves
detectSessionInUrl: false
}
});
}
return this.client;
}
/**
* Note: Code exchange is now handled server-side
* The server returns tokens directly to avoid PKCE issues
* This method is kept for potential future use
*/
async exchangeCodeForSession(_code: string): Promise<{
token: string;
refreshToken?: string;
userId: string;
email?: string;
expiresAt?: string;
}> {
throw new AuthenticationError(
'Code exchange is handled server-side. CLI receives tokens directly.',
'NOT_SUPPORTED'
);
}
/**
* Refresh an access token
*/
async refreshSession(refreshToken: string): Promise<{
token: string;
refreshToken?: string;
expiresAt?: string;
}> {
try {
const client = this.getClient();
this.logger.info('Refreshing session...');
// Set the session with refresh token
const { data, error } = await client.auth.refreshSession({
refresh_token: refreshToken
});
if (error) {
this.logger.error('Failed to refresh session:', error);
throw new AuthenticationError(
`Failed to refresh session: ${error.message}`,
'REFRESH_FAILED'
);
}
if (!data.session) {
throw new AuthenticationError(
'No session data returned',
'INVALID_RESPONSE'
);
}
this.logger.info('Successfully refreshed session');
return {
token: data.session.access_token,
refreshToken: data.session.refresh_token,
expiresAt: data.session.expires_at
? new Date(data.session.expires_at * 1000).toISOString()
: undefined
};
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Failed to refresh session: ${(error as Error).message}`,
'REFRESH_FAILED'
);
}
}
/**
* Get user details from token
*/
async getUser(token: string): Promise<User | null> {
try {
const client = this.getClient();
// Get user with the token
const { data, error } = await client.auth.getUser(token);
if (error) {
this.logger.warn('Failed to get user:', error);
return null;
}
return data.user;
} catch (error) {
this.logger.error('Error getting user:', error);
return null;
}
}
/**
* Sign out (revoke tokens)
*/
async signOut(token: string): Promise<void> {
try {
const client = this.getClient();
// Sign out using the token
const { error } = await client.auth.admin.signOut(token);
if (error) {
this.logger.warn('Failed to sign out:', error);
}
} catch (error) {
this.logger.error('Error during sign out:', error);
}
}
}

View File

@@ -25,7 +25,7 @@ export interface LoggerConfig {
export class Logger {
private config: Required<LoggerConfig>;
private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = {
level: LogLevel.INFO,
level: LogLevel.WARN,
silent: false,
prefix: '',
timestamp: false,

View File

@@ -1,4 +1,18 @@
import { defineConfig } from 'tsup';
import { dotenvLoad } from 'dotenv-mono';
dotenvLoad();
// Get all TM_PUBLIC_* env variables for build-time injection
const getBuildTimeEnvs = () => {
const envs: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('TM_PUBLIC_')) {
// Return the actual value, not JSON.stringify'd
envs[key] = value || '';
}
}
return envs;
};
export default defineConfig({
entry: {
@@ -20,7 +34,13 @@ export default defineConfig({
target: 'es2022',
tsconfig: './tsconfig.json',
outDir: 'dist',
external: ['zod'],
// Replace process.env.TM_PUBLIC_* with actual values at build time
env: getBuildTimeEnvs(),
// Auto-external all dependencies from package.json
external: [
// External all node_modules - everything not starting with . or /
/^[^./]/
],
esbuildOptions(options) {
options.conditions = ['module'];
}

View File

@@ -1,4 +1,20 @@
import { defineConfig } from 'tsup';
import { dotenvLoad } from 'dotenv-mono';
// Load .env from root level (monorepo support)
dotenvLoad();
// Get all TM_PUBLIC_* env variables for build-time injection
const getBuildTimeEnvs = () => {
const envs: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('TM_PUBLIC_')) {
// Return the actual value, not JSON.stringify'd
envs[key] = value || '';
}
}
return envs;
};
export default defineConfig({
entry: {
@@ -18,6 +34,8 @@ export default defineConfig({
'.js': 'jsx',
'.ts': 'ts'
},
// Replace process.env.TM_PUBLIC_* with actual values at build time
env: getBuildTimeEnvs(),
esbuildOptions(options) {
options.platform = 'node';
// Allow importing TypeScript from JavaScript
@@ -25,31 +43,9 @@ export default defineConfig({
},
// Bundle our monorepo packages but keep node_modules external
noExternal: [/@tm\/.*/],
external: [
// Keep native node modules external
'fs',
'path',
'child_process',
'crypto',
'os',
'url',
'util',
'stream',
'http',
'https',
'events',
'assert',
'buffer',
'querystring',
'readline',
'zlib',
'tty',
'net',
'dgram',
'dns',
'tls',
'cluster',
'process',
'module'
]
// Don't bundle any other dependencies (auto-external all node_modules)
// This regex matches anything that doesn't start with . or /
external: [/^[^./]/],
// Add success message for debugging
onSuccess: 'echo "✅ Build completed successfully"'
});