mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge branch 'v0.13.0rc' into feat/react-query
Merged latest changes from v0.13.0rc into feat/react-query while preserving React Query migration. Key merge decisions: - Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.) - Added backlog plan handling to running-agents-view stop functionality - Imported both SkeletonPulse and Spinner for CLI status components - Used Spinner for refresh buttons across all settings sections - Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel - Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
@@ -185,7 +185,13 @@ export function getAuthenticatedImageUrl(
|
||||
if (apiKey) {
|
||||
params.set('apiKey', apiKey);
|
||||
}
|
||||
// Note: Session token auth relies on cookies which are sent automatically by the browser
|
||||
|
||||
// Web mode: also add session token as query param for image loads
|
||||
// This ensures images load correctly even if cookies aren't sent (e.g., cross-origin proxy scenarios)
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
params.set('token', sessionToken);
|
||||
}
|
||||
|
||||
return `${serverUrl}/api/fs/image?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,10 @@ export interface SpecRegenerationAPI {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
sync: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -491,10 +495,12 @@ export interface AutoModeAPI {
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
isAutoLoopRunning?: boolean;
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
runningProjects?: string[];
|
||||
runningCount?: number;
|
||||
maxConcurrency?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
runFeature: (
|
||||
@@ -550,6 +556,88 @@ export interface SaveImageResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Notifications API interface
|
||||
import type {
|
||||
Notification,
|
||||
StoredEvent,
|
||||
StoredEventSummary,
|
||||
EventHistoryFilter,
|
||||
EventReplayResult,
|
||||
} from '@automaker/types';
|
||||
|
||||
export interface NotificationsAPI {
|
||||
list: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
notifications?: Notification[];
|
||||
error?: string;
|
||||
}>;
|
||||
getUnreadCount: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
count?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
markAsRead: (
|
||||
projectPath: string,
|
||||
notificationId?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
notification?: Notification;
|
||||
count?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
dismiss: (
|
||||
projectPath: string,
|
||||
notificationId?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
dismissed?: boolean;
|
||||
count?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Event History API interface
|
||||
export interface EventHistoryAPI {
|
||||
list: (
|
||||
projectPath: string,
|
||||
filter?: EventHistoryFilter
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
events?: StoredEventSummary[];
|
||||
total?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
get: (
|
||||
projectPath: string,
|
||||
eventId: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
event?: StoredEvent;
|
||||
error?: string;
|
||||
}>;
|
||||
delete: (
|
||||
projectPath: string,
|
||||
eventId: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
clear: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
cleared?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
replay: (
|
||||
projectPath: string,
|
||||
eventId: string,
|
||||
hookIds?: string[]
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: EventReplayResult;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
@@ -639,7 +727,30 @@ export interface ElectronAPI {
|
||||
model?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
|
||||
status: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
savedPlan?: {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: {
|
||||
changes: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
featureId?: string;
|
||||
feature?: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
summary: string;
|
||||
dependencyUpdates: Array<{
|
||||
featureId: string;
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
error?: string;
|
||||
}>;
|
||||
apply: (
|
||||
projectPath: string,
|
||||
plan: {
|
||||
@@ -658,6 +769,7 @@ export interface ElectronAPI {
|
||||
},
|
||||
branchName?: string
|
||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||
clear: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||
};
|
||||
// Setup API surface is implemented by the main process and mirrored by HttpApiClient.
|
||||
@@ -736,6 +848,8 @@ export interface ElectronAPI {
|
||||
}>;
|
||||
};
|
||||
ideation?: IdeationAPI;
|
||||
notifications?: NotificationsAPI;
|
||||
eventHistory?: EventHistoryAPI;
|
||||
codex?: {
|
||||
getUsage: () => Promise<CodexUsageResponse>;
|
||||
getModels: (refresh?: boolean) => Promise<{
|
||||
@@ -1484,10 +1598,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return { success: true, worktrees: [] };
|
||||
},
|
||||
|
||||
listAll: async (projectPath: string, includeDetails?: boolean) => {
|
||||
listAll: async (
|
||||
projectPath: string,
|
||||
includeDetails?: boolean,
|
||||
forceRefreshGitHub?: boolean
|
||||
) => {
|
||||
console.log('[Mock] Listing all worktrees:', {
|
||||
projectPath,
|
||||
includeDetails,
|
||||
forceRefreshGitHub,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
@@ -1735,6 +1854,56 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
getAvailableTerminals: async () => {
|
||||
console.log('[Mock] Getting available terminals');
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
terminals: [
|
||||
{ id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
|
||||
{ id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getDefaultTerminal: async () => {
|
||||
console.log('[Mock] Getting default terminal');
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
terminalId: 'iterm2',
|
||||
terminalName: 'iTerm2',
|
||||
terminalCommand: 'open -a iTerm',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
refreshTerminals: async () => {
|
||||
console.log('[Mock] Refreshing available terminals');
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
terminals: [
|
||||
{ id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
|
||||
{ id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
|
||||
],
|
||||
message: 'Found 2 available terminals',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
openInExternalTerminal: async (worktreePath: string, terminalId?: string) => {
|
||||
console.log('[Mock] Opening in external terminal:', worktreePath, terminalId);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${terminalId ?? 'default terminal'}`,
|
||||
terminalName: terminalId ?? 'Terminal',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
initGit: async (projectPath: string) => {
|
||||
console.log('[Mock] Initializing git:', projectPath);
|
||||
return {
|
||||
@@ -2634,6 +2803,30 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
sync: async (projectPath: string) => {
|
||||
if (mockSpecRegenerationRunning) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Spec sync is already running',
|
||||
};
|
||||
}
|
||||
|
||||
mockSpecRegenerationRunning = true;
|
||||
console.log(`[Mock] Syncing spec for: ${projectPath}`);
|
||||
|
||||
// Simulate async spec sync (similar to feature generation but simpler)
|
||||
setTimeout(() => {
|
||||
emitSpecRegenerationEvent({
|
||||
type: 'spec_regeneration_complete',
|
||||
message: 'Spec synchronized successfully',
|
||||
projectPath,
|
||||
});
|
||||
mockSpecRegenerationRunning = false;
|
||||
}, 1000);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
stop: async (_projectPath?: string) => {
|
||||
mockSpecRegenerationRunning = false;
|
||||
mockSpecRegenerationPhase = '';
|
||||
@@ -3085,7 +3278,7 @@ function createMockGitHubAPI(): GitHubAPI {
|
||||
estimatedComplexity: 'moderate' as const,
|
||||
},
|
||||
projectPath,
|
||||
model: model || 'sonnet',
|
||||
model: model || 'claude-sonnet',
|
||||
})
|
||||
);
|
||||
}, 2000);
|
||||
@@ -3151,6 +3344,8 @@ export interface Project {
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
|
||||
fontFamilySans?: string; // Per-project UI/sans font override
|
||||
fontFamilyMono?: string; // Per-project code/mono font override
|
||||
isFavorite?: boolean; // Pin project to top of dashboard
|
||||
icon?: string; // Lucide icon name for project identification
|
||||
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
|
||||
|
||||
@@ -32,7 +32,10 @@ import type {
|
||||
CreateIdeaInput,
|
||||
UpdateIdeaInput,
|
||||
ConvertToFeatureOptions,
|
||||
NotificationsAPI,
|
||||
EventHistoryAPI,
|
||||
} from './electron';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
@@ -153,8 +156,16 @@ const getServerUrl = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
// In web mode (not Electron), use relative URL to leverage Vite proxy
|
||||
// This avoids CORS issues since requests appear same-origin
|
||||
if (!window.electron) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return 'http://localhost:3008';
|
||||
// Use VITE_HOSTNAME if set, otherwise default to localhost
|
||||
const hostname = import.meta.env.VITE_HOSTNAME || 'localhost';
|
||||
return `http://${hostname}:3008`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -168,8 +179,24 @@ let apiKeyInitialized = false;
|
||||
let apiKeyInitPromise: Promise<void> | null = null;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
// Only used in-memory after fresh login; on refresh we rely on HTTP-only cookies
|
||||
// Persisted to localStorage to survive page reloads
|
||||
let cachedSessionToken: string | null = null;
|
||||
const SESSION_TOKEN_KEY = 'automaker:sessionToken';
|
||||
|
||||
// Initialize cached session token from localStorage on module load
|
||||
// This ensures web mode survives page reloads with valid authentication
|
||||
const initSessionToken = (): void => {
|
||||
if (typeof window === 'undefined') return; // Skip in SSR
|
||||
try {
|
||||
cachedSessionToken = window.localStorage.getItem(SESSION_TOKEN_KEY);
|
||||
} catch {
|
||||
// localStorage might be disabled or unavailable
|
||||
cachedSessionToken = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on module load
|
||||
initSessionToken();
|
||||
|
||||
// Get API key for Electron mode (returns cached value after initialization)
|
||||
// Exported for use in WebSocket connections that need auth
|
||||
@@ -189,14 +216,30 @@ export const waitForApiKeyInit = (): Promise<void> => {
|
||||
// Get session token for Web mode (returns cached value after login)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
// Set session token (called after login)
|
||||
// Set session token (called after login) - persists to localStorage for page reload survival
|
||||
export const setSessionToken = (token: string | null): void => {
|
||||
cachedSessionToken = token;
|
||||
if (typeof window === 'undefined') return; // Skip in SSR
|
||||
try {
|
||||
if (token) {
|
||||
window.localStorage.setItem(SESSION_TOKEN_KEY, token);
|
||||
} else {
|
||||
window.localStorage.removeItem(SESSION_TOKEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage might be disabled; continue with in-memory cache
|
||||
}
|
||||
};
|
||||
|
||||
// Clear session token (called on logout)
|
||||
export const clearSessionToken = (): void => {
|
||||
cachedSessionToken = null;
|
||||
if (typeof window === 'undefined') return; // Skip in SSR
|
||||
try {
|
||||
window.localStorage.removeItem(SESSION_TOKEN_KEY);
|
||||
} catch {
|
||||
// localStorage might be disabled
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -478,6 +521,7 @@ export const verifySession = async (): Promise<boolean> => {
|
||||
*/
|
||||
export const checkSandboxEnvironment = async (): Promise<{
|
||||
isContainerized: boolean;
|
||||
skipSandboxWarning?: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
@@ -493,7 +537,10 @@ export const checkSandboxEnvironment = async (): Promise<{
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { isContainerized: data.isContainerized ?? false };
|
||||
return {
|
||||
isContainerized: data.isContainerized ?? false,
|
||||
skipSandboxWarning: data.skipSandboxWarning ?? false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Sandbox environment check failed:', error);
|
||||
return { isContainerized: false, error: 'Network error' };
|
||||
@@ -514,7 +561,8 @@ type EventType =
|
||||
| 'worktree:init-completed'
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped';
|
||||
| 'dev-server:stopped'
|
||||
| 'notification:created';
|
||||
|
||||
/**
|
||||
* Dev server log event payloads for WebSocket streaming
|
||||
@@ -553,6 +601,7 @@ export interface DevServerLogsResponse {
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
@@ -1717,8 +1766,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/worktree/status', { projectPath, featureId }),
|
||||
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
||||
this.post('/api/worktree/list', { projectPath, includeDetails }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean, forceRefreshGitHub?: boolean) =>
|
||||
this.post('/api/worktree/list', { projectPath, includeDetails, forceRefreshGitHub }),
|
||||
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/create', {
|
||||
projectPath,
|
||||
@@ -1759,6 +1808,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
||||
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
|
||||
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
|
||||
getAvailableTerminals: () => this.get('/api/worktree/available-terminals'),
|
||||
getDefaultTerminal: () => this.get('/api/worktree/default-terminal'),
|
||||
refreshTerminals: () => this.post('/api/worktree/refresh-terminals', {}),
|
||||
openInExternalTerminal: (worktreePath: string, terminalId?: string) =>
|
||||
this.post('/api/worktree/open-in-external-terminal', { worktreePath, terminalId }),
|
||||
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||
@@ -1875,6 +1929,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
projectPath,
|
||||
maxFeatures,
|
||||
}),
|
||||
sync: (projectPath: string) => this.post('/api/spec-regeneration/sync', { projectPath }),
|
||||
stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
|
||||
status: (projectPath?: string) =>
|
||||
this.get(
|
||||
@@ -2171,6 +2226,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
worktreePanelVisible?: boolean;
|
||||
showInitScriptIndicator?: boolean;
|
||||
defaultDeleteBranchWithWorktree?: boolean;
|
||||
autoDismissInitScriptIndicator?: boolean;
|
||||
lastSelectedSessionId?: string;
|
||||
};
|
||||
error?: string;
|
||||
@@ -2325,8 +2383,32 @@ export class HttpApiClient implements ElectronAPI {
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/stop', {}),
|
||||
|
||||
status: (): Promise<{ success: boolean; isRunning?: boolean; error?: string }> =>
|
||||
this.get('/api/backlog-plan/status'),
|
||||
status: (
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
savedPlan?: {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: {
|
||||
changes: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
featureId?: string;
|
||||
feature?: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
summary: string;
|
||||
dependencyUpdates: Array<{
|
||||
featureId: string;
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
error?: string;
|
||||
}> => this.get(`/api/backlog-plan/status?projectPath=${encodeURIComponent(projectPath)}`),
|
||||
|
||||
apply: (
|
||||
projectPath: string,
|
||||
@@ -2348,6 +2430,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
|
||||
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
|
||||
|
||||
clear: (projectPath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/clear', { projectPath }),
|
||||
|
||||
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||
},
|
||||
@@ -2413,6 +2498,43 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
// Notifications API - project-level notifications
|
||||
notifications: NotificationsAPI & {
|
||||
onNotificationCreated: (callback: (notification: any) => void) => () => void;
|
||||
} = {
|
||||
list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }),
|
||||
|
||||
getUnreadCount: (projectPath: string) =>
|
||||
this.post('/api/notifications/unread-count', { projectPath }),
|
||||
|
||||
markAsRead: (projectPath: string, notificationId?: string) =>
|
||||
this.post('/api/notifications/mark-read', { projectPath, notificationId }),
|
||||
|
||||
dismiss: (projectPath: string, notificationId?: string) =>
|
||||
this.post('/api/notifications/dismiss', { projectPath, notificationId }),
|
||||
|
||||
onNotificationCreated: (callback: (notification: any) => void): (() => void) => {
|
||||
return this.subscribeToEvent('notification:created', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Event History API - stored events for debugging and replay
|
||||
eventHistory: EventHistoryAPI = {
|
||||
list: (projectPath: string, filter?: EventHistoryFilter) =>
|
||||
this.post('/api/event-history/list', { projectPath, filter }),
|
||||
|
||||
get: (projectPath: string, eventId: string) =>
|
||||
this.post('/api/event-history/get', { projectPath, eventId }),
|
||||
|
||||
delete: (projectPath: string, eventId: string) =>
|
||||
this.post('/api/event-history/delete', { projectPath, eventId }),
|
||||
|
||||
clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }),
|
||||
|
||||
replay: (projectPath: string, eventId: string, hookIds?: string[]) =>
|
||||
this.post('/api/event-history/replay', { projectPath, eventId, hookIds }),
|
||||
};
|
||||
|
||||
// MCP API - Test MCP server connections and list tools
|
||||
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
|
||||
// drive-by command execution attacks. Servers must be saved first.
|
||||
|
||||
@@ -124,3 +124,67 @@ export const isMac =
|
||||
: typeof navigator !== 'undefined' &&
|
||||
(/Mac/.test(navigator.userAgent) ||
|
||||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in data-testid attributes.
|
||||
* Creates a deterministic, URL-safe identifier from any input string.
|
||||
*
|
||||
* Transformations:
|
||||
* - Convert to lowercase
|
||||
* - Replace spaces with hyphens
|
||||
* - Remove all non-alphanumeric characters (except hyphens)
|
||||
* - Collapse multiple consecutive hyphens into a single hyphen
|
||||
* - Trim leading/trailing hyphens
|
||||
*
|
||||
* @param name - The string to sanitize (e.g., project name, feature title)
|
||||
* @returns A sanitized string safe for CSS selectors and test IDs
|
||||
*
|
||||
* @example
|
||||
* sanitizeForTestId("My Awesome Project!") // "my-awesome-project"
|
||||
* sanitizeForTestId("test-project-123") // "test-project-123"
|
||||
* sanitizeForTestId(" Foo Bar ") // "foo-bar"
|
||||
*/
|
||||
export function sanitizeForTestId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 string.
|
||||
*
|
||||
* Uses crypto.randomUUID() when available (secure contexts: HTTPS or localhost).
|
||||
* Falls back to crypto.getRandomValues() for non-secure contexts (e.g., Docker via HTTP).
|
||||
*
|
||||
* @returns A RFC 4122 compliant UUID v4 string (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
// Use native randomUUID if available (secure contexts: HTTPS or localhost)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback using crypto.getRandomValues() (works in all modern browsers, including non-secure contexts)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Set version (4) and variant (RFC 4122) bits
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
|
||||
|
||||
// Convert to hex string with proper UUID format
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// Last resort fallback using Math.random() - less secure but ensures functionality
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user