Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
"@open-codesign/shared": "workspace:*",
"@open-codesign/templates": "workspace:*",
"@open-codesign/ui": "workspace:*",
"@types/ssh2": "^1.15.5",
"better-sqlite3": "^12.9.0",
"electron-log": "^5",
"electron-updater": "^6.3.9",
"lucide-react": "^1.8.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"smol-toml": "^1.6.1",
"ssh2": "^1.17.0",
"zip-lib": "^1.0.4",
"zustand": "^5.0.2"
},
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/codex-oauth-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ async function persistProviderMutation(
activeModel: cfg?.activeModel ?? '',
secrets: cfg?.secrets ?? {},
providers: nextProviders,
sshProfiles: cfg?.sshProfiles ?? {},
...(cfg?.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
});
await writeConfig(next);
Expand All @@ -130,6 +131,7 @@ async function claimActiveProviderIfUnset(): Promise<void> {
activeModel: CHATGPT_CODEX_PROVIDER.defaultModel,
secrets: cfg.secrets,
providers: cfg.providers,
sshProfiles: cfg.sshProfiles ?? {},
...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
});
await writeConfig(next);
Expand Down
76 changes: 52 additions & 24 deletions apps/desktop/src/main/design-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type StoredDesignSystem,
} from '@open-codesign/shared';

const IGNORED_DIRS = new Set([
export const IGNORED_DESIGN_SYSTEM_DIRS = new Set([
'.git',
'.idea',
'.next',
Expand All @@ -19,7 +19,7 @@ const IGNORED_DIRS = new Set([
'out',
]);

const CANDIDATE_EXTS = new Set([
export const DESIGN_SYSTEM_CANDIDATE_EXTS = new Set([
'.css',
'.scss',
'.sass',
Expand Down Expand Up @@ -53,6 +53,11 @@ interface CandidateFile {
score: number;
}

export interface DesignSystemSourceFile {
relativePath: string;
content: string;
}

function pushUnique(target: string[], value: string, max: number): void {
if (!value || target.includes(value) || target.length >= max) return;
target.push(value);
Expand All @@ -65,7 +70,7 @@ function cleanValue(value: string): string {
.trim();
}

function scoreCandidate(relativePath: string): number {
export function scoreDesignSystemCandidate(relativePath: string): number {
const fileName = basename(relativePath);
let score = 1;
for (const pattern of PRIORITY_PATTERNS) {
Expand All @@ -77,6 +82,11 @@ function scoreCandidate(relativePath: string): number {
return score;
}

export function isDesignSystemCandidateFile(fileName: string): boolean {
const extension = extname(fileName).toLowerCase();
return DESIGN_SYSTEM_CANDIDATE_EXTS.has(extension) || /tailwind\.config/i.test(fileName);
}

async function collectCandidateFiles(
rootPath: string,
dirPath: string,
Expand All @@ -95,16 +105,15 @@ async function collectCandidateFiles(
if (files.length >= MAX_FILES) return;
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) {
if (!IGNORED_DESIGN_SYSTEM_DIRS.has(entry.name)) {
await collectCandidateFiles(rootPath, fullPath, files);
}
continue;
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (!CANDIDATE_EXTS.has(extension) && !/tailwind\.config/i.test(entry.name)) continue;
if (!isDesignSystemCandidateFile(entry.name)) continue;
const relativePath = relative(rootPath, fullPath).replace(/\\/g, '/');
files.push({ fullPath, relativePath, score: scoreCandidate(relativePath) });
files.push({ fullPath, relativePath, score: scoreDesignSystemCandidate(relativePath) });
}
}

Expand Down Expand Up @@ -184,35 +193,33 @@ function buildSummary(
return parts.join(' ');
}

export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSystem> {
const candidates: CandidateFile[] = [];
await collectCandidateFiles(rootPath, rootPath, candidates);

const selected = candidates
.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
.slice(0, MAX_SELECTED_FILES);

export function buildDesignSystemSnapshot(
rootPath: string,
files: DesignSystemSourceFile[],
extra: Partial<
Pick<StoredDesignSystem, 'sourceKind' | 'sshProfileId' | 'sshHost' | 'sshPort' | 'sshUsername'>
> = {},
): StoredDesignSystem {
const colors: string[] = [];
const fonts: string[] = [];
const spacing: string[] = [];
const radius: string[] = [];
const shadows: string[] = [];

for (const file of selected) {
let raw = '';
try {
raw = await readFile(file.fullPath, 'utf8');
} catch {
continue;
}
const snippet = raw.slice(0, MAX_FILE_CHARS);
for (const file of files) {
const snippet = file.content.slice(0, MAX_FILE_CHARS);
collectCssVarValues(snippet, colors, spacing, radius, shadows);
collectLooseValues(snippet, colors, fonts, spacing, radius, shadows);
}

const baseSnapshot = {
rootPath,
sourceFiles: selected.map((file) => file.relativePath),
sourceKind: extra.sourceKind ?? 'local',
...(extra.sshProfileId !== undefined ? { sshProfileId: extra.sshProfileId } : {}),
...(extra.sshHost !== undefined ? { sshHost: extra.sshHost } : {}),
...(extra.sshPort !== undefined ? { sshPort: extra.sshPort } : {}),
...(extra.sshUsername !== undefined ? { sshUsername: extra.sshUsername } : {}),
sourceFiles: files.map((file) => file.relativePath),
colors,
fonts,
spacing,
Expand All @@ -227,3 +234,24 @@ export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSy
extractedAt: new Date().toISOString(),
};
}

export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSystem> {
const candidates: CandidateFile[] = [];
await collectCandidateFiles(rootPath, rootPath, candidates);

const selected = candidates
.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
.slice(0, MAX_SELECTED_FILES);

const files: DesignSystemSourceFile[] = [];
for (const file of selected) {
try {
files.push({
relativePath: file.relativePath,
content: await readFile(file.fullPath, 'utf8'),
});
} catch {}
}

return buildDesignSystemSnapshot(rootPath, files);
}
74 changes: 74 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { resolveActiveModel } from './provider-settings';
import { withRun } from './runContext';
import { safeInitSnapshotsDb } from './snapshots-db';
import { registerSnapshotsIpc, registerSnapshotsUnavailableIpc } from './snapshots-ipc';
import { createRemoteAttachment, exportToRemote, scanRemoteDesignSystem } from './ssh-remote';
import { initStorageSettings } from './storage-settings';

// ESM shim: package.json "type": "module" means the built bundle is ESM and
Expand Down Expand Up @@ -416,6 +417,20 @@ function registerIpcHandlers(): void {
);
});

ipcMain.handle('remote:v1:attach-file', async (_e, raw: unknown) => {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError('remote:v1:attach-file expects an object payload', 'IPC_BAD_INPUT');
}
const r = raw as Record<string, unknown>;
if (typeof r['profileId'] !== 'string' || r['profileId'].trim().length === 0) {
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof r['path'] !== 'string' || r['path'].trim().length === 0) {
throw new CodesignError('path must be a non-empty string', 'IPC_BAD_INPUT');
}
return createRemoteAttachment(r['profileId'].trim(), r['path'].trim());
});

ipcMain.handle('codesign:pick-design-system-directory', async () => {
const result = mainWindow
? await dialog.showOpenDialog(mainWindow, {
Expand All @@ -439,12 +454,71 @@ function registerIpcHandlers(): void {
return nextState;
});

ipcMain.handle('remote:v1:link-design-system', async (_e, raw: unknown) => {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError(
'remote:v1:link-design-system expects an object payload',
'IPC_BAD_INPUT',
);
}
const r = raw as Record<string, unknown>;
if (typeof r['profileId'] !== 'string' || r['profileId'].trim().length === 0) {
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof r['path'] !== 'string' || r['path'].trim().length === 0) {
throw new CodesignError('path must be a non-empty string', 'IPC_BAD_INPUT');
}
const profileId = r['profileId'].trim();
const rootPath = r['path'].trim();
logIpc.info('designSystem.ssh.scan.start', { profileId, rootPath });
const snapshot = await scanRemoteDesignSystem(profileId, rootPath);
const nextState = await setDesignSystem(snapshot);
logIpc.info('designSystem.ssh.scan.ok', {
profileId,
rootPath: snapshot.rootPath,
sourceFiles: snapshot.sourceFiles.length,
colors: snapshot.colors.length,
fonts: snapshot.fonts.length,
});
return nextState;
});

ipcMain.handle('codesign:clear-design-system', async () => {
const nextState = await setDesignSystem(null);
logIpc.info('designSystem.clear');
return nextState;
});

ipcMain.handle('remote:v1:export', async (_e, raw: unknown) => {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError('remote:v1:export expects an object payload', 'IPC_BAD_INPUT');
}
const r = raw as Record<string, unknown>;
const format = r['format'];
const htmlContent = r['htmlContent'];
const profileId = r['profileId'];
const remotePath = r['remotePath'];
if (
format !== 'html' &&
format !== 'pdf' &&
format !== 'pptx' &&
format !== 'zip' &&
format !== 'markdown'
) {
throw new CodesignError(`Unknown export format: ${String(format)}`, 'IPC_BAD_INPUT');
}
if (typeof htmlContent !== 'string' || htmlContent.length === 0) {
throw new CodesignError('htmlContent must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof profileId !== 'string' || profileId.trim().length === 0) {
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof remotePath !== 'string' || remotePath.trim().length === 0) {
throw new CodesignError('remotePath must be a non-empty string', 'IPC_BAD_INPUT');
}
return exportToRemote(profileId.trim(), remotePath.trim(), format, htmlContent);
});

ipcMain.handle('codesign:v1:generate', async (_e, raw: unknown) => {
const payload = GeneratePayloadV1.parse(raw);
const id = payload.generationId;
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ vi.mock('./imports/claude-code-config', () => ({
readClaudeCodeSettings: vi.fn(async () => null),
}));

vi.mock('./ssh-remote', () => ({
testSshConnection: vi.fn(async () => undefined),
testSavedSshProfile: vi.fn(async () => undefined),
}));

vi.mock('@open-codesign/providers', () => ({
pingProvider: vi.fn(async () => ({ ok: true, modelCount: 1 })),
}));
Expand Down Expand Up @@ -244,6 +249,7 @@ describe('getApiKeyForProvider — API key retrieval', () => {
defaultModel: 'claude-sonnet-4-6',
},
},
sshProfiles: {},
provider: 'anthropic',
modelPrimary: 'claude-sonnet-4-6',
baseUrls: {},
Expand Down Expand Up @@ -384,6 +390,7 @@ describe('config:v1:import-claude-code-config — user-type branching', () => {
defaultModel: 'claude-sonnet-4-6',
},
},
sshProfiles: {},
provider: 'anthropic',
modelPrimary: 'claude-sonnet-4-6',
baseUrls: {},
Expand Down Expand Up @@ -478,6 +485,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => {
envKey: ENV_NAME,
},
},
sshProfiles: {},
provider: 'fallback-test',
modelPrimary: 'x',
baseUrls: {},
Expand Down Expand Up @@ -509,6 +517,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => {
envKey: ENV_NAME,
},
},
sshProfiles: {},
provider: 'no-key',
modelPrimary: 'x',
baseUrls: {},
Expand Down
Loading
Loading