From 85e79338ae2ace7df239121ee077fa07810ccfde Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 Date: Tue, 21 Apr 2026 21:25:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87=20?= =?UTF-8?q?SSH=20=E5=85=B3=E8=81=94=E8=BF=9C=E7=A8=8B=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持保存并测试 SSH Profile\n- 支持关联远程 design system 与附加远程文件\n- 支持将当前预览 HTML 推送到 SSH 服务器\n- 补充共享类型、IPC、store 与前端入口\n- 调整 Advanced 页面 SSH 区块文案与分界线表现 Signed-off-by: Sun-sunshine06 --- apps/desktop/package.json | 2 + apps/desktop/src/main/codex-oauth-ipc.ts | 2 + apps/desktop/src/main/design-system.ts | 76 ++- apps/desktop/src/main/index.ts | 74 +++ apps/desktop/src/main/onboarding-ipc.test.ts | 9 + apps/desktop/src/main/onboarding-ipc.ts | 222 +++++++- apps/desktop/src/main/prompt-context.test.ts | 4 +- apps/desktop/src/main/prompt-context.ts | 53 +- .../src/main/provider-settings.test.ts | 1 + apps/desktop/src/main/ssh-remote.ts | 485 ++++++++++++++++++ apps/desktop/src/preload/index.ts | 39 ++ .../src/components/PreviewToolbar.tsx | 29 +- .../src/components/RemotePathModal.tsx | 152 ++++++ .../src/renderer/src/components/Settings.tsx | 127 ++++- .../src/renderer/src/components/Sidebar.tsx | 100 +++- .../src/components/SshProfileModal.tsx | 267 ++++++++++ .../renderer/src/components/chat/AddMenu.tsx | 36 +- .../src/components/chat/ChatMessageList.tsx | 2 +- .../src/renderer/src/hooks/useDesignFiles.ts | 1 + .../src/store.generationStage.test.ts | 1 + apps/desktop/src/renderer/src/store.test.ts | 2 + apps/desktop/src/renderer/src/store.ts | 70 ++- packages/core/src/design-skills/chat-ui.jsx | 2 +- packages/core/src/generate.test.ts | 1 + packages/shared/src/config.test.ts | 1 + packages/shared/src/config.ts | 58 +++ packages/shared/src/error-codes.ts | 35 ++ packages/shared/src/index.ts | 31 +- pnpm-lock.yaml | 79 +++ 29 files changed, 1871 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/main/ssh-remote.ts create mode 100644 apps/desktop/src/renderer/src/components/RemotePathModal.tsx create mode 100644 apps/desktop/src/renderer/src/components/SshProfileModal.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ff5e1426..d954d9ec 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "@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", @@ -34,6 +35,7 @@ "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" }, diff --git a/apps/desktop/src/main/codex-oauth-ipc.ts b/apps/desktop/src/main/codex-oauth-ipc.ts index dcf5d03f..d6c7c0ad 100644 --- a/apps/desktop/src/main/codex-oauth-ipc.ts +++ b/apps/desktop/src/main/codex-oauth-ipc.ts @@ -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); @@ -130,6 +131,7 @@ async function claimActiveProviderIfUnset(): Promise { activeModel: CHATGPT_CODEX_PROVIDER.defaultModel, secrets: cfg.secrets, providers: cfg.providers, + sshProfiles: cfg.sshProfiles ?? {}, ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), }); await writeConfig(next); diff --git a/apps/desktop/src/main/design-system.ts b/apps/desktop/src/main/design-system.ts index 114892d6..c1d8babe 100644 --- a/apps/desktop/src/main/design-system.ts +++ b/apps/desktop/src/main/design-system.ts @@ -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', @@ -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', @@ -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); @@ -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) { @@ -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, @@ -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) }); } } @@ -184,35 +193,33 @@ function buildSummary( return parts.join(' '); } -export async function scanDesignSystem(rootPath: string): Promise { - 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 { 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, @@ -227,3 +234,24 @@ export async function scanDesignSystem(rootPath: string): Promise { + 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); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c0ca2579..c6833eb1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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 @@ -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; + 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, { @@ -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; + 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; + 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; diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 787bb145..b72a336a 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -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 })), })); @@ -244,6 +249,7 @@ describe('getApiKeyForProvider — API key retrieval', () => { defaultModel: 'claude-sonnet-4-6', }, }, + sshProfiles: {}, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', baseUrls: {}, @@ -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: {}, @@ -478,6 +485,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => { envKey: ENV_NAME, }, }, + sshProfiles: {}, provider: 'fallback-test', modelPrimary: 'x', baseUrls: {}, @@ -509,6 +517,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => { envKey: ENV_NAME, }, }, + sshProfiles: {}, provider: 'no-key', modelPrimary: 'x', baseUrls: {}, diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index 84e015bd..2ae862e2 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -8,6 +8,8 @@ import { type ProviderEntry, type ReasoningLevel, ReasoningLevelSchema, + type SshAuthMethod, + type SshProfile, StoredDesignSystem, type StoredDesignSystem as StoredDesignSystemValue, type SupportedOnboardingProvider, @@ -16,6 +18,7 @@ import { hydrateConfig, isSupportedOnboardingProvider, modelsEndpointUrl, + summarizeSshProfile, } from '@open-codesign/shared'; import { defaultConfigDir, readConfig, writeConfig } from './config'; import { dialog, ipcMain, shell } from './electron-runtime'; @@ -31,6 +34,7 @@ import { isKeylessProviderAllowed, toProviderRows, } from './provider-settings'; +import { testSavedSshProfile, testSshConnection } from './ssh-remote'; import { type AppPaths, type StorageKind, @@ -156,6 +160,7 @@ function toState(cfg: Config | null): OnboardingState { modelPrimary: null, baseUrl: null, designSystem: null, + sshProfiles: [], }; } const active = cfg.activeProvider; @@ -167,6 +172,7 @@ function toState(cfg: Config | null): OnboardingState { modelPrimary: null, baseUrl: null, designSystem: cfg.designSystem ?? null, + sshProfiles: Object.values(cfg.sshProfiles ?? {}).map(summarizeSshProfile), }; } return { @@ -175,6 +181,16 @@ function toState(cfg: Config | null): OnboardingState { modelPrimary: cfg.activeModel, baseUrl: cfg.providers[active]?.baseUrl ?? null, designSystem: cfg.designSystem ?? null, + sshProfiles: Object.values(cfg.sshProfiles ?? {}).map(summarizeSshProfile), + }; +} + +function carryConfigExtras(cfg: Config | null): Pick & { + designSystem?: StoredDesignSystemValue; +} { + return { + sshProfiles: cfg?.sshProfiles ?? {}, + ...(cfg?.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), }; } @@ -198,6 +214,7 @@ export async function setDesignSystem( activeModel: cfg.activeModel, secrets: cfg.secrets, providers: cfg.providers, + sshProfiles: cfg.sshProfiles ?? {}, ...(designSystem !== null ? { designSystem: StoredDesignSystem.parse(designSystem) } : {}), }); await writeConfig(next); @@ -345,9 +362,7 @@ async function runSetProviderAndModels(input: SetProviderAndModelsInput): Promis activeModel: nextActiveModel, secrets: nextSecrets, providers: nextProviders, - ...(cachedConfig?.designSystem !== undefined - ? { designSystem: cachedConfig.designSystem } - : {}), + ...carryConfigExtras(cachedConfig), }); await writeConfig(next); cachedConfig = next; @@ -397,7 +412,7 @@ async function runDeleteProvider(raw: unknown): Promise { activeModel: '', secrets: {}, providers: nextProviders, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...carryConfigExtras(cfg), }); await writeConfig(emptyNext); cachedConfig = emptyNext; @@ -410,7 +425,7 @@ async function runDeleteProvider(raw: unknown): Promise { activeModel: modelPrimary, secrets: nextSecrets, providers: nextProviders, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...carryConfigExtras(cfg), }); await writeConfig(next); cachedConfig = next; @@ -441,7 +456,7 @@ async function runSetActiveProvider(raw: unknown): Promise { activeModel: modelPrimary, secrets: cfg.secrets, providers: cfg.providers, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...carryConfigExtras(cfg), }); await writeConfig(next); cachedConfig = next; @@ -509,10 +524,164 @@ async function runResetOnboarding(): Promise { activeModel: cfg.activeModel, secrets: {}, providers: cfg.providers, + ...carryConfigExtras(cfg), + }); + await writeConfig(next); + cachedConfig = next; +} + +interface SaveSshProfileInput { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + keyPath?: string; + passphrase?: string; + basePath?: string; +} + +function parseSaveSshProfile(raw: unknown): SaveSshProfileInput { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('remote:v1:save-profile expects an object', ERROR_CODES.IPC_BAD_INPUT); + } + const r = raw as Record; + const id = r['id']; + const name = r['name']; + const host = r['host']; + const port = r['port']; + const username = r['username']; + const authMethod = r['authMethod']; + if (typeof id !== 'string' || id.trim().length === 0) { + throw new CodesignError('SSH profile id must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT); + } + if (typeof name !== 'string' || name.trim().length === 0) { + throw new CodesignError( + 'SSH profile name must be a non-empty string', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + if (typeof host !== 'string' || host.trim().length === 0) { + throw new CodesignError('SSH host must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT); + } + const parsedPort = + typeof port === 'number' + ? port + : typeof port === 'string' && port.trim().length > 0 + ? Number(port) + : 22; + if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) { + throw new CodesignError( + 'SSH port must be an integer between 1 and 65535', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + if (typeof username !== 'string' || username.trim().length === 0) { + throw new CodesignError('SSH username must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT); + } + if (authMethod !== 'password' && authMethod !== 'privateKey') { + throw new CodesignError( + 'SSH auth method must be password or privateKey', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + const out: SaveSshProfileInput = { + id: id.trim(), + name: name.trim(), + host: host.trim(), + port: parsedPort, + username: username.trim(), + authMethod, + }; + if (typeof r['password'] === 'string' && r['password'].trim().length > 0) { + out.password = r['password'].trim(); + } + if (typeof r['keyPath'] === 'string' && r['keyPath'].trim().length > 0) { + out.keyPath = r['keyPath'].trim(); + } + if (typeof r['passphrase'] === 'string' && r['passphrase'].trim().length > 0) { + out.passphrase = r['passphrase']; + } + if (typeof r['basePath'] === 'string' && r['basePath'].trim().length > 0) { + out.basePath = r['basePath'].trim(); + } + return out; +} + +async function runSaveSshProfile(input: SaveSshProfileInput): Promise { + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError('No configuration found', ERROR_CODES.CONFIG_MISSING); + } + const existing = cfg.sshProfiles?.[input.id]; + const profile: SshProfile = { + id: input.id, + name: input.name, + host: input.host, + port: input.port, + username: input.username, + authMethod: input.authMethod, + ...(input.keyPath !== undefined ? { keyPath: input.keyPath } : {}), + ...(input.basePath !== undefined ? { basePath: input.basePath } : {}), + }; + if (input.authMethod === 'password') { + if (input.password !== undefined) { + profile.password = buildSecretRef(input.password); + } else if (existing?.password !== undefined) { + profile.password = existing.password; + } else { + throw new CodesignError('Password auth requires a password', ERROR_CODES.IPC_BAD_INPUT); + } + } else { + if (!profile.keyPath) { + throw new CodesignError('Private key auth requires a key path', ERROR_CODES.IPC_BAD_INPUT); + } + if (input.passphrase !== undefined) { + profile.passphrase = buildSecretRef(input.passphrase); + } else if (existing?.passphrase !== undefined) { + profile.passphrase = existing.passphrase; + } + } + + const next = hydrateConfig({ + version: 3, + activeProvider: cfg.activeProvider, + activeModel: cfg.activeModel, + secrets: cfg.secrets, + providers: cfg.providers, + sshProfiles: { ...(cfg.sshProfiles ?? {}), [profile.id]: profile }, ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), }); await writeConfig(next); cachedConfig = next; + return toState(cachedConfig); +} + +async function runDeleteSshProfile(raw: unknown): Promise { + if (typeof raw !== 'string' || raw.trim().length === 0) { + throw new CodesignError('remote:v1:delete-profile expects an id', ERROR_CODES.IPC_BAD_INPUT); + } + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError('No configuration found', ERROR_CODES.CONFIG_MISSING); + } + const nextProfiles = { ...(cfg.sshProfiles ?? {}) }; + delete nextProfiles[raw]; + const designSystem = cfg.designSystem?.sshProfileId === raw ? undefined : cfg.designSystem; + const next = hydrateConfig({ + version: 3, + activeProvider: cfg.activeProvider, + activeModel: cfg.activeModel, + secrets: cfg.secrets, + providers: cfg.providers, + sshProfiles: nextProfiles, + ...(designSystem !== undefined ? { designSystem } : {}), + }); + await writeConfig(next); + cachedConfig = next; + return toState(cachedConfig); } // ── v3 custom provider helpers ──────────────────────────────────────────── @@ -621,9 +790,7 @@ async function runAddCustomProvider(input: AddCustomProviderInput): Promise { activeModel, secrets: nextSecrets, providers: nextProviders, - ...(cachedConfig?.designSystem !== undefined - ? { designSystem: cachedConfig.designSystem } - : {}), + ...carryConfigExtras(cachedConfig), }); await writeConfig(next); cachedConfig = next; @@ -859,9 +1024,7 @@ async function runImportClaudeCode(imported: ClaudeCodeImport): Promise => { + await testSshConnection(parseSaveSshProfile(raw)); + return { ok: true }; + }); + + ipcMain.handle( + 'remote:v1:test-saved-profile', + async (_e, raw: unknown): Promise<{ ok: true }> => { + if (typeof raw !== 'string' || raw.trim().length === 0) { + throw new CodesignError( + 'remote:v1:test-saved-profile expects an id', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + await testSavedSshProfile(raw.trim()); + return { ok: true }; + }, + ); + + ipcMain.handle('remote:v1:save-profile', async (_e, raw: unknown): Promise => { + return runSaveSshProfile(parseSaveSshProfile(raw)); + }); + + ipcMain.handle('remote:v1:delete-profile', async (_e, raw: unknown): Promise => { + return runDeleteSshProfile(raw); + }); + // ── Settings v1 channels ──────────────────────────────────────────────────── ipcMain.handle( diff --git a/apps/desktop/src/main/prompt-context.test.ts b/apps/desktop/src/main/prompt-context.test.ts index 2fb9d318..4f456285 100644 --- a/apps/desktop/src/main/prompt-context.test.ts +++ b/apps/desktop/src/main/prompt-context.test.ts @@ -9,7 +9,7 @@ describe('preparePromptContext', () => { it('throws a CodesignError when an attachment cannot be read', async () => { await expect( preparePromptContext({ - attachments: [{ path: 'Z:/missing/brief.md', name: 'brief.md', size: 12 }], + attachments: [{ kind: 'local', path: 'Z:/missing/brief.md', name: 'brief.md', size: 12 }], }), ).rejects.toMatchObject({ name: 'CodesignError', @@ -20,7 +20,7 @@ describe('preparePromptContext', () => { it('throws a CodesignError when an attachment is too large', async () => { await expect( preparePromptContext({ - attachments: [{ path: 'C:/repo/huge.txt', name: 'huge.txt', size: 300_000 }], + attachments: [{ kind: 'local', path: 'C:/repo/huge.txt', name: 'huge.txt', size: 300_000 }], }), ).rejects.toMatchObject({ name: 'CodesignError', diff --git a/apps/desktop/src/main/prompt-context.ts b/apps/desktop/src/main/prompt-context.ts index e0862559..9f1bd658 100644 --- a/apps/desktop/src/main/prompt-context.ts +++ b/apps/desktop/src/main/prompt-context.ts @@ -7,6 +7,7 @@ import { type LocalInputFile, type StoredDesignSystem, } from '@open-codesign/shared'; +import { readRemoteAttachment } from './ssh-remote'; const TEXT_EXTS = new Set([ '.css', @@ -67,36 +68,50 @@ async function readAttachment(file: LocalInputFile): Promise } let buffer: Buffer; - let handle: Awaited> | null = null; - try { - handle = await open(file.path, 'r'); - const length = Math.max(1, Math.min(file.size || MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES)); - const readBuffer = Buffer.alloc(length); - const { bytesRead } = await handle.read(readBuffer, 0, readBuffer.length, 0); - buffer = readBuffer.subarray(0, bytesRead); - } catch (error) { - throw new CodesignError( - `Failed to read attachment "${file.path}"`, - ERROR_CODES.ATTACHMENT_READ_FAILED, - { - cause: error, - }, - ); - } finally { - await handle?.close(); + if (file.kind === 'ssh') { + try { + buffer = await readRemoteAttachment(file.profileId, file.path, MAX_ATTACHMENT_BYTES); + } catch (error) { + throw new CodesignError( + `Failed to read remote attachment "${file.path}"`, + ERROR_CODES.ATTACHMENT_READ_FAILED, + { + cause: error, + }, + ); + } + } else { + let handle: Awaited> | null = null; + try { + handle = await open(file.path, 'r'); + const length = Math.max(1, Math.min(file.size || MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES)); + const readBuffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(readBuffer, 0, readBuffer.length, 0); + buffer = readBuffer.subarray(0, bytesRead); + } catch (error) { + throw new CodesignError( + `Failed to read attachment "${file.path}"`, + ERROR_CODES.ATTACHMENT_READ_FAILED, + { + cause: error, + }, + ); + } finally { + await handle?.close(); + } } if (!isProbablyText(buffer, extension)) { return { name: file.name, - path: file.path, + path: file.kind === 'ssh' ? (file.displayPath ?? file.path) : file.path, note: `Binary or unsupported format (${extension || 'unknown'}). Use the filename as a hint, not quoted content.`, }; } return { name: file.name, - path: file.path, + path: file.kind === 'ssh' ? (file.displayPath ?? file.path) : file.path, excerpt: cleanText(buffer.toString('utf8'), MAX_ATTACHMENT_CHARS), note: buffer.length > MAX_ATTACHMENT_CHARS diff --git a/apps/desktop/src/main/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts index ce046297..ede27e55 100644 --- a/apps/desktop/src/main/provider-settings.test.ts +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -48,6 +48,7 @@ function makeCfg(input: { activeModel: input.modelPrimary, secrets: input.secrets ?? {}, providers, + sshProfiles: {}, }); } diff --git a/apps/desktop/src/main/ssh-remote.ts b/apps/desktop/src/main/ssh-remote.ts new file mode 100644 index 00000000..af59b374 --- /dev/null +++ b/apps/desktop/src/main/ssh-remote.ts @@ -0,0 +1,485 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, posix } from 'node:path'; +import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters'; +import { + CodesignError, + ERROR_CODES, + type LocalInputFile, + type SshAuthMethod, + type StoredDesignSystem, +} from '@open-codesign/shared'; +import { Client } from 'ssh2'; +import type { ConnectConfig, FileEntry, SFTPWrapper } from 'ssh2'; +import { + IGNORED_DESIGN_SYSTEM_DIRS, + buildDesignSystemSnapshot, + isDesignSystemCandidateFile, + scoreDesignSystemCandidate, +} from './design-system'; +import { decryptSecret } from './keychain'; +import { getCachedConfig } from './onboarding-ipc'; + +const MAX_REMOTE_SCAN_FILES = 160; +const MAX_REMOTE_SCAN_SELECTED_FILES = 12; +const S_IFDIR = 0o040000; + +export interface SshProfileInput { + id: string; + name: string; + host: string; + port?: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + keyPath?: string; + passphrase?: string; + basePath?: string; +} + +interface ResolvedProfile { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + keyPath?: string; + passphrase?: string; + basePath?: string; +} + +function normalizeRemotePath(path: string): string { + const trimmed = path.trim().replace(/\\/g, '/'); + if (trimmed.length === 0) { + throw new CodesignError('Remote path cannot be empty', ERROR_CODES.SSH_REMOTE_PATH_INVALID); + } + if (trimmed.includes('\0')) { + throw new CodesignError( + 'Remote path contains invalid characters', + ERROR_CODES.SSH_REMOTE_PATH_INVALID, + ); + } + const normalized = posix.normalize(trimmed); + if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw new CodesignError( + 'Remote path traversal is not allowed', + ERROR_CODES.SSH_REMOTE_PATH_INVALID, + ); + } + return normalized; +} + +function resolveProfilePath(profile: { basePath?: string }, remotePath: string): string { + const normalized = normalizeRemotePath(remotePath); + if (normalized.startsWith('/')) return normalized; + if (profile.basePath && profile.basePath.trim().length > 0) { + return normalizeRemotePath(posix.join(profile.basePath.replace(/\\/g, '/'), normalized)); + } + return normalized; +} + +function resolveStoredProfile(profileId: string): ResolvedProfile { + const cfg = getCachedConfig(); + const stored = cfg?.sshProfiles?.[profileId]; + if (!stored) { + throw new CodesignError( + `SSH profile "${profileId}" not found`, + ERROR_CODES.SSH_PROFILE_NOT_FOUND, + ); + } + return { + id: stored.id, + name: stored.name, + host: stored.host, + port: stored.port, + username: stored.username, + authMethod: stored.authMethod, + ...(stored.password ? { password: decryptSecret(stored.password.ciphertext) } : {}), + ...(stored.keyPath ? { keyPath: stored.keyPath } : {}), + ...(stored.passphrase ? { passphrase: decryptSecret(stored.passphrase.ciphertext) } : {}), + ...(stored.basePath ? { basePath: stored.basePath } : {}), + }; +} + +async function toConnectConfig(profile: ResolvedProfile): Promise { + const base: ConnectConfig = { + host: profile.host, + port: profile.port, + username: profile.username, + readyTimeout: 10_000, + }; + if (profile.authMethod === 'password') { + if (!profile.password || profile.password.length === 0) { + throw new CodesignError( + `SSH profile "${profile.name}" is missing a password`, + ERROR_CODES.SSH_CONNECT_FAILED, + ); + } + return { ...base, password: profile.password }; + } + if (!profile.keyPath || profile.keyPath.trim().length === 0) { + throw new CodesignError( + `SSH profile "${profile.name}" is missing a private key path`, + ERROR_CODES.SSH_KEY_READ_FAILED, + ); + } + try { + const privateKey = await readFile(profile.keyPath, 'utf8'); + return { + ...base, + privateKey, + ...(profile.passphrase ? { passphrase: profile.passphrase } : {}), + }; + } catch (error) { + throw new CodesignError( + `Failed to read SSH private key at ${profile.keyPath}`, + ERROR_CODES.SSH_KEY_READ_FAILED, + { cause: error }, + ); + } +} + +async function connectSftp( + profile: ResolvedProfile, +): Promise<{ client: Client; sftp: SFTPWrapper }> { + const config = await toConnectConfig(profile); + const client = new Client(); + await new Promise((resolve, reject) => { + client + .once('ready', () => resolve()) + .once('error', (error) => + reject( + new CodesignError( + `Failed to connect to SSH server ${profile.host}:${profile.port}`, + ERROR_CODES.SSH_CONNECT_FAILED, + { cause: error }, + ), + ), + ) + .connect(config); + }); + try { + const sftp = await new Promise((resolve, reject) => { + client.sftp((error, next) => { + if (error || !next) { + reject( + new CodesignError( + `Failed to start SFTP session for ${profile.host}`, + ERROR_CODES.SSH_SFTP_FAILED, + { cause: error }, + ), + ); + return; + } + resolve(next); + }); + }); + return { client, sftp }; + } catch (error) { + client.end(); + throw error; + } +} + +async function withSftp( + profile: ResolvedProfile, + run: (sftp: SFTPWrapper) => Promise, +): Promise { + const { client, sftp } = await connectSftp(profile); + try { + return await run(sftp); + } finally { + client.end(); + } +} + +function isDirectoryMode(mode: number | undefined): boolean { + return typeof mode === 'number' && (mode & S_IFDIR) === S_IFDIR; +} + +function statRemote( + sftp: SFTPWrapper, + remotePath: string, +): Promise<{ size: number; isDirectory: boolean }> { + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (error, stats) => { + if (error || !stats) { + reject( + new CodesignError( + `Failed to stat remote path ${remotePath}`, + ERROR_CODES.SSH_REMOTE_READ_FAILED, + { cause: error }, + ), + ); + return; + } + resolve({ size: stats.size, isDirectory: isDirectoryMode(stats.mode) }); + }); + }); +} + +function readDirRemote(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(remotePath, (error, list) => { + if (error || !list) { + reject( + new CodesignError( + `Failed to read remote directory ${remotePath}`, + ERROR_CODES.SSH_REMOTE_READ_FAILED, + { cause: error }, + ), + ); + return; + } + resolve(list); + }); + }); +} + +function readRemoteFileBuffer( + sftp: SFTPWrapper, + remotePath: string, + maxBytes = Number.POSITIVE_INFINITY, +): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + const stream = sftp.createReadStream(remotePath); + stream.on('data', (chunk: Buffer | string) => { + const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += next.length; + if (total > maxBytes) { + stream.destroy( + new CodesignError( + `Remote file ${remotePath} exceeds the allowed size`, + ERROR_CODES.ATTACHMENT_TOO_LARGE, + ), + ); + return; + } + chunks.push(next); + }); + stream.once('error', (error: unknown) => { + reject( + error instanceof CodesignError + ? error + : new CodesignError( + `Failed to read remote file ${remotePath}`, + ERROR_CODES.SSH_REMOTE_READ_FAILED, + { cause: error }, + ), + ); + }); + stream.once('end', () => resolve(Buffer.concat(chunks))); + }); +} + +async function ensureRemoteDir(sftp: SFTPWrapper, dirPath: string): Promise { + if (!dirPath || dirPath === '.' || dirPath === '/') return; + const target = normalizeRemotePath(dirPath); + const parts = target.split('/').filter(Boolean); + let current = target.startsWith('/') ? '/' : ''; + for (const part of parts) { + current = current === '/' ? `/${part}` : current ? `${current}/${part}` : part; + try { + const stat = await statRemote(sftp, current); + if (!stat.isDirectory) { + throw new CodesignError( + `${current} exists but is not a directory`, + ERROR_CODES.SSH_REMOTE_WRITE_FAILED, + ); + } + } catch (error) { + if (error instanceof CodesignError && error.code !== ERROR_CODES.SSH_REMOTE_READ_FAILED) { + throw error; + } + await new Promise((resolve, reject) => { + sftp.mkdir(current, (mkdirError: unknown) => { + if (!mkdirError) { + resolve(); + return; + } + reject( + new CodesignError( + `Failed to create remote directory ${current}`, + ERROR_CODES.SSH_REMOTE_WRITE_FAILED, + { cause: mkdirError }, + ), + ); + }); + }); + } + } +} + +function writeRemoteBuffer(sftp: SFTPWrapper, remotePath: string, content: Buffer): Promise { + return new Promise((resolve, reject) => { + const stream = sftp.createWriteStream(remotePath); + stream.once('error', (error: unknown) => + reject( + new CodesignError( + `Failed to write remote file ${remotePath}`, + ERROR_CODES.SSH_REMOTE_WRITE_FAILED, + { cause: error }, + ), + ), + ); + stream.once('close', () => resolve()); + stream.end(content); + }); +} + +function fileEntryIsDirectory(entry: FileEntry): boolean { + return isDirectoryMode(entry.attrs.mode) || entry.longname.startsWith('d'); +} + +async function collectRemoteCandidates( + sftp: SFTPWrapper, + rootPath: string, + dirPath: string, + files: Array<{ fullPath: string; relativePath: string; score: number }>, +): Promise { + if (files.length >= MAX_REMOTE_SCAN_FILES) return; + let entries: FileEntry[] = []; + try { + entries = await readDirRemote(sftp, dirPath); + } catch { + return; + } + for (const entry of entries) { + if (files.length >= MAX_REMOTE_SCAN_FILES) return; + const fullPath = posix.join(dirPath, entry.filename); + if (fileEntryIsDirectory(entry)) { + if (!IGNORED_DESIGN_SYSTEM_DIRS.has(entry.filename)) { + await collectRemoteCandidates(sftp, rootPath, fullPath, files); + } + continue; + } + if (!isDesignSystemCandidateFile(entry.filename)) continue; + const relativePath = posix.relative(rootPath, fullPath).replace(/\\/g, '/'); + files.push({ + fullPath, + relativePath, + score: scoreDesignSystemCandidate(relativePath), + }); + } +} + +export async function testSshConnection(input: SshProfileInput): Promise { + const profile: ResolvedProfile = { + id: input.id, + name: input.name, + host: input.host.trim(), + port: input.port ?? 22, + username: input.username.trim(), + authMethod: input.authMethod, + ...(input.password ? { password: input.password } : {}), + ...(input.keyPath ? { keyPath: input.keyPath.trim() } : {}), + ...(input.passphrase ? { passphrase: input.passphrase } : {}), + ...(input.basePath ? { basePath: input.basePath.trim() } : {}), + }; + await withSftp(profile, async () => undefined); +} + +export async function testSavedSshProfile(profileId: string): Promise { + const profile = resolveStoredProfile(profileId); + await withSftp(profile, async () => undefined); +} + +export async function createRemoteAttachment( + profileId: string, + remotePath: string, +): Promise { + const profile = resolveStoredProfile(profileId); + const resolvedPath = resolveProfilePath(profile, remotePath); + return withSftp(profile, async (sftp) => { + const stat = await statRemote(sftp, resolvedPath); + if (stat.isDirectory) { + throw new CodesignError( + `${resolvedPath} is a directory, not a file`, + ERROR_CODES.SSH_REMOTE_PATH_INVALID, + ); + } + return { + kind: 'ssh', + profileId, + path: resolvedPath, + name: posix.basename(resolvedPath), + size: stat.size, + displayPath: `${profile.username}@${profile.host}:${resolvedPath}`, + }; + }); +} + +export async function readRemoteAttachment( + profileId: string, + remotePath: string, + maxBytes: number, +): Promise { + const profile = resolveStoredProfile(profileId); + return withSftp(profile, async (sftp) => + readRemoteFileBuffer(sftp, resolveProfilePath(profile, remotePath), maxBytes), + ); +} + +export async function scanRemoteDesignSystem( + profileId: string, + remoteRootPath: string, +): Promise { + const profile = resolveStoredProfile(profileId); + const rootPath = resolveProfilePath(profile, remoteRootPath); + return withSftp(profile, async (sftp) => { + const candidates: Array<{ fullPath: string; relativePath: string; score: number }> = []; + await collectRemoteCandidates(sftp, rootPath, rootPath, candidates); + const selected = candidates + .sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)) + .slice(0, MAX_REMOTE_SCAN_SELECTED_FILES); + const files = await Promise.all( + selected.map(async (file) => ({ + relativePath: file.relativePath, + content: (await readRemoteFileBuffer(sftp, file.fullPath)).toString('utf8'), + })), + ); + return buildDesignSystemSnapshot(rootPath, files, { + sourceKind: 'ssh', + sshProfileId: profile.id, + sshHost: profile.host, + sshPort: profile.port, + sshUsername: profile.username, + }); + }); +} + +export async function writeRemoteFile( + profileId: string, + remotePath: string, + content: Buffer, +): Promise<{ path: string; bytes: number }> { + const profile = resolveStoredProfile(profileId); + const resolvedPath = resolveProfilePath(profile, remotePath); + return withSftp(profile, async (sftp) => { + await ensureRemoteDir(sftp, posix.dirname(resolvedPath)); + await writeRemoteBuffer(sftp, resolvedPath, content); + return { path: resolvedPath, bytes: content.length }; + }); +} + +export async function exportToRemote( + profileId: string, + remotePath: string, + format: ExporterFormat, + htmlContent: string, +): Promise<{ path: string; bytes: number }> { + const ext = format === 'markdown' ? 'md' : format; + const tempPath = join(tmpdir(), `open-codesign-remote-${randomUUID()}.${ext}`); + try { + const result = await exportArtifact(format, htmlContent, tempPath); + const body = await readFile(result.path); + return writeRemoteFile(profileId, remotePath, body); + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 96a39e9d..49982c08 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -16,6 +16,7 @@ import type { ReasoningLevel, SelectedElement, SnapshotCreateInput, + SshAuthMethod, SupportedOnboardingProvider, WireApi, } from '@open-codesign/shared'; @@ -48,6 +49,24 @@ export interface ExportInvokeResponse { bytes?: number; } +export interface RemoteExportResponse { + path: string; + bytes: number; +} + +export interface SaveSshProfileInput { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + keyPath?: string; + passphrase?: string; + basePath?: string; +} + export interface ProviderRow { provider: string; maskedKey: string; @@ -231,6 +250,26 @@ const api = { ipcRenderer.on('codesign:update-available', listener); return () => ipcRenderer.removeListener('codesign:update-available', listener); }, + remote: { + testProfile: (input: SaveSshProfileInput) => + ipcRenderer.invoke('remote:v1:test-profile', input) as Promise<{ ok: true }>, + testSavedProfile: (id: string) => + ipcRenderer.invoke('remote:v1:test-saved-profile', id) as Promise<{ ok: true }>, + saveProfile: (input: SaveSshProfileInput) => + ipcRenderer.invoke('remote:v1:save-profile', input) as Promise, + deleteProfile: (id: string) => + ipcRenderer.invoke('remote:v1:delete-profile', id) as Promise, + attachFile: (input: { profileId: string; path: string }) => + ipcRenderer.invoke('remote:v1:attach-file', input) as Promise, + linkDesignSystem: (input: { profileId: string; path: string }) => + ipcRenderer.invoke('remote:v1:link-design-system', input) as Promise, + export: (input: { + format: ExportFormat; + htmlContent: string; + profileId: string; + remotePath: string; + }) => ipcRenderer.invoke('remote:v1:export', input) as Promise, + }, onboarding: { getState: () => ipcRenderer.invoke('onboarding:get-state') as Promise, validateKey: (input: { diff --git a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx index 58cc9a66..51e5cfc7 100644 --- a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx +++ b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx @@ -2,8 +2,8 @@ import { useT } from '@open-codesign/i18n'; import { Download, MessageSquare } from 'lucide-react'; import { type ReactElement, useEffect, useRef, useState } from 'react'; import type { ExportFormat } from '../../../preload/index'; -import type { PreviewViewport } from '../store'; import { useCodesignStore } from '../store'; +import { RemotePathModal } from './RemotePathModal'; interface ExportItem { format: ExportFormat; @@ -18,15 +18,17 @@ export function PreviewToolbar(): ReactElement { const t = useT(); const previewHtml = useCodesignStore((s) => s.previewHtml); const exportActive = useCodesignStore((s) => s.exportActive); + const exportRemote = useCodesignStore((s) => s.exportRemote); const toastMessage = useCodesignStore((s) => s.toastMessage); const dismissToast = useCodesignStore((s) => s.dismissToast); - const previewViewport = useCodesignStore((s) => s.previewViewport); const previewZoom = useCodesignStore((s) => s.previewZoom); const setPreviewZoom = useCodesignStore((s) => s.setPreviewZoom); const interactionMode = useCodesignStore((s) => s.interactionMode); const setInteractionMode = useCodesignStore((s) => s.setInteractionMode); + const config = useCodesignStore((s) => s.config); const [open, setOpen] = useState(false); const [zoomOpen, setZoomOpen] = useState(false); + const [remoteOpen, setRemoteOpen] = useState(false); const ref = useRef(null); const zoomRef = useRef(null); @@ -55,6 +57,7 @@ export function PreviewToolbar(): ReactElement { }, [toastMessage, dismissToast]); const disabled = !previewHtml; + const sshProfiles = config?.sshProfiles ?? []; const commentActive = interactionMode === 'comment'; const exportItems: ExportItem[] = [ { @@ -152,6 +155,14 @@ export function PreviewToolbar(): ReactElement {
+
+ {remoteOpen ? ( + setRemoteOpen(false)} + onConfirm={(profileId, path) => + exportRemote({ format: 'html', profileId, remotePath: path }) + } + /> + ) : null} ); } diff --git a/apps/desktop/src/renderer/src/components/RemotePathModal.tsx b/apps/desktop/src/renderer/src/components/RemotePathModal.tsx new file mode 100644 index 00000000..73e0be12 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/RemotePathModal.tsx @@ -0,0 +1,152 @@ +import type { SshProfileSummary } from '@open-codesign/shared'; +import { Button } from '@open-codesign/ui'; +import { X } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface Props { + title: string; + actionLabel: string; + pathLabel: string; + profiles: SshProfileSummary[]; + defaultProfileId?: string; + defaultPath?: string; + description?: string; + onClose: () => void; + onConfirm: (profileId: string, path: string) => Promise | void; +} + +export function RemotePathModal({ + title, + actionLabel, + pathLabel, + profiles, + defaultProfileId, + defaultPath, + description, + onClose, + onConfirm, +}: Props) { + const [profileId, setProfileId] = useState(defaultProfileId ?? profiles[0]?.id ?? ''); + const [path, setPath] = useState(defaultPath ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (defaultPath !== undefined) setPath(defaultPath); + }, [defaultPath]); + + async function handleConfirm() { + if (!profileId || !path.trim()) return; + setSaving(true); + setError(null); + try { + await onConfirm(profileId, path.trim()); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + return ( +
e.key === 'Escape' && onClose()} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > +
+

+ {title} +

+ +
+ + {description ? ( +

+ {description} +

+ ) : null} + + {profiles.length === 0 ? ( +

+ 还没有可用的 SSH Profile。请先到设置里的 Advanced 添加一个。 +

+ ) : ( + <> + + + + + + setPath(e.target.value)} + placeholder="/srv/www/index.html" + className="w-full h-9 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]" + /> + + + )} + + {error ?

{error}

: null} + +
+ + +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 9302f3d7..2b7f4a8b 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -26,6 +26,7 @@ import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../p import { useCodesignStore } from '../store'; import { AddCustomProviderModal } from './AddCustomProviderModal'; import { ChatgptLoginCard } from './ChatgptLoginCard'; +import { SshProfileModal } from './SshProfileModal'; type Tab = 'models' | 'appearance' | 'storage' | 'advanced'; @@ -36,7 +37,7 @@ const TABS: ReadonlyArray<{ id: Tab; icon: typeof Cpu }> = [ { id: 'advanced', icon: Sliders }, ]; -// ─── Tiny primitives ───────────────────────────────────────────────────────── +// 闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁?Tiny primitives 闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻? function Label({ children }: { children: React.ReactNode }) { return ( @@ -140,7 +141,7 @@ function NativeSelect({ ); } -// ─── Models tab ────────────────────────────────────────────────────────────── +// 闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁?Models tab 闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍楣冩⒑閸愭彃甯ㄩ柛瀣崌閺屽秹宕楁径濠佸闂備礁鍟块崢婊堝磻閹剧粯鐓冮柛蹇擃槸娴滈箖姊洪崘鎻掑辅闁稿鎹囬弻宥夊礂婢跺﹣澹曢梻浣稿暱閸樻粓宕戦幘缁樼厓闁稿繐顦禍? function ProviderOverflowMenu({ isActive, @@ -468,7 +469,7 @@ function ReasoningDepthSelector({ const t = useT(); const pushToast = useCodesignStore((s) => s.pushToast); const [saving, setSaving] = useState(false); - // Controlled local state — optimistic so the dropdown reflects the user's + // Controlled local state 闂?optimistic so the dropdown reflects the user's // choice immediately, before the IPC round-trip resolves. Without this, // the setAuthMethod(value)} + className="accent-[var(--color-accent)]" + /> + + {value === 'privateKey' ? '私钥' : '密码'} + + + ))} + + + + {authMethod === 'privateKey' ? ( +
+ + + + + + +
+ ) : ( + + + + )} + + {duplicateName ? ( +

+ 已经有同名 SSH Profile,建议换一个更容易区分的名称。 +

+ ) : null} + +
+ + {testState.kind === 'ok' ? ( + 连接成功 + ) : null} + {testState.kind === 'error' ? ( + + {testState.message} + + ) : null} +
+ + {error ?

{error}

: null} + +
+ + +
+ + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +function TextInput({ + value, + onChange, + placeholder, + type, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full h-9 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]" + /> + ); +} diff --git a/apps/desktop/src/renderer/src/components/chat/AddMenu.tsx b/apps/desktop/src/renderer/src/components/chat/AddMenu.tsx index 62e3ed69..1dc4d8ef 100644 --- a/apps/desktop/src/renderer/src/components/chat/AddMenu.tsx +++ b/apps/desktop/src/renderer/src/components/chat/AddMenu.tsx @@ -1,6 +1,6 @@ import { useT } from '@open-codesign/i18n'; import { IconButton, Tooltip } from '@open-codesign/ui'; -import { FolderOpen, Link2, Paperclip, Plus } from 'lucide-react'; +import { FolderOpen, HardDriveUpload, Link2, Paperclip, Plus, Server } from 'lucide-react'; import { type KeyboardEvent as ReactKeyboardEvent, useEffect, @@ -11,10 +11,13 @@ import { export interface AddMenuProps { onAttachFiles: () => void; + onAttachRemoteFile: () => void; onLinkDesignSystem: () => void; + onLinkRemoteDesignSystem: () => void; referenceUrl: string; onReferenceUrlChange: (value: string) => void; hasDesignSystem: boolean; + hasRemoteProfiles: boolean; disabled?: boolean; } @@ -23,14 +26,17 @@ export interface AddMenuProps { * link/refresh design system repo, reference URL. Replaces the former inline * button row so the composer area stays quiet until the user wants context. * - * Lightweight popover (no Radix dep) — closes on outside click or Escape. + * Lightweight popover (no Radix dep) - closes on outside click or Escape. */ export function AddMenu({ onAttachFiles, + onAttachRemoteFile, onLinkDesignSystem, + onLinkRemoteDesignSystem, referenceUrl, onReferenceUrlChange, hasDesignSystem, + hasRemoteProfiles, disabled, }: AddMenuProps) { const t = useT(); @@ -104,6 +110,19 @@ export function AddMenu({ /> {t('sidebar.attachLocalFiles')} + +
el.removeEventListener('scroll', onScroll); }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-scrolls on new messages or streaming text useEffect(() => { if (!stickToBottomRef.current) return; bottomRef.current?.scrollIntoView({ block: 'end' }); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-scrolls on new messages or streaming text }, [messages.length, streamingText]); if (loading && messages.length === 0 && !streamingText) { diff --git a/apps/desktop/src/renderer/src/hooks/useDesignFiles.ts b/apps/desktop/src/renderer/src/hooks/useDesignFiles.ts index eb188cdc..7cd772e9 100644 --- a/apps/desktop/src/renderer/src/hooks/useDesignFiles.ts +++ b/apps/desktop/src/renderer/src/hooks/useDesignFiles.ts @@ -33,6 +33,7 @@ export function useDesignFiles(designId: string | null): UseDesignFilesResult { // Look up the latest snapshot timestamp for the current design so the Files // panel can show "N minutes ago" next to the sole `index.html` row. We // debounce on designId + previewHtml so a fresh generation refreshes it. + // biome-ignore lint/correctness/useExhaustiveDependencies: previewHtml is an intentional trigger — new generation output should refresh the snapshot list useEffect(() => { let cancelled = false; if (!designId || !window.codesign) { diff --git a/apps/desktop/src/renderer/src/store.generationStage.test.ts b/apps/desktop/src/renderer/src/store.generationStage.test.ts index 7aec69c6..a7069e8d 100644 --- a/apps/desktop/src/renderer/src/store.generationStage.test.ts +++ b/apps/desktop/src/renderer/src/store.generationStage.test.ts @@ -9,6 +9,7 @@ const READY_CONFIG: OnboardingState = { modelPrimary: 'claude-sonnet-4-6', baseUrl: null, designSystem: null, + sshProfiles: [], }; const initialState = useCodesignStore.getState(); diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts index 3e00ebc9..823d6049 100644 --- a/apps/desktop/src/renderer/src/store.test.ts +++ b/apps/desktop/src/renderer/src/store.test.ts @@ -9,6 +9,7 @@ const READY_CONFIG: OnboardingState = { modelPrimary: 'claude-sonnet-4-6', baseUrl: null, designSystem: null, + sshProfiles: [], }; const initialState = useCodesignStore.getState(); @@ -382,6 +383,7 @@ describe('useCodesignStore active provider routing', () => { modelPrimary: 'gpt-4o', baseUrl: null, designSystem: null, + sshProfiles: [], }; // Simulate setActiveProvider result updating the store config. diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 3c1901fd..f8d37824 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -219,12 +219,19 @@ interface CodesignState { clearIframeErrors: () => void; pushIframeError: (message: string) => void; exportActive: (format: ExportFormat) => Promise; + exportRemote: (input: { + format: ExportFormat; + profileId: string; + remotePath: string; + }) => Promise; pickInputFiles: () => Promise; + attachRemoteFile: (profileId: string, path: string) => Promise; removeInputFile: (path: string) => void; clearInputFiles: () => void; setReferenceUrl: (value: string) => void; pickDesignSystemDirectory: () => Promise; + linkRemoteDesignSystem: (profileId: string, path: string) => Promise; clearDesignSystem: () => Promise; selectCanvasElement: (selection: SelectedElement) => void; @@ -446,13 +453,18 @@ function uniqueFiles(files: LocalInputFile[]): LocalInputFile[] { const seen = new Set(); const result: LocalInputFile[] = []; for (const file of files) { - if (seen.has(file.path)) continue; - seen.add(file.path); + const key = getInputFileKey(file); + if (seen.has(key)) continue; + seen.add(key); result.push(file); } return result; } +export function getInputFileKey(file: LocalInputFile): string { + return file.kind === 'ssh' ? `ssh:${file.profileId}:${file.path}` : `local:${file.path}`; +} + function tr(key: string, options?: Record): string { return i18n.t(key, options ?? {}) as string; } @@ -1097,8 +1109,14 @@ export const useCodesignStore = create((set, get) => ({ set((s) => ({ inputFiles: uniqueFiles([...s.inputFiles, ...files]) })); }, + async attachRemoteFile(profileId, path) { + if (!window.codesign?.remote) return; + const file = await window.codesign.remote.attachFile({ profileId, path }); + set((s) => ({ inputFiles: uniqueFiles([...s.inputFiles, file]) })); + }, + removeInputFile(path) { - set((s) => ({ inputFiles: s.inputFiles.filter((file) => file.path !== path) })); + set((s) => ({ inputFiles: s.inputFiles.filter((file) => getInputFileKey(file) !== path) })); }, clearInputFiles() { @@ -1131,6 +1149,28 @@ export const useCodesignStore = create((set, get) => ({ } }, + async linkRemoteDesignSystem(profileId, path) { + if (!window.codesign?.remote) return; + try { + const next = await window.codesign.remote.linkDesignSystem({ profileId, path }); + set({ config: next }); + if (next.designSystem) { + get().pushToast({ + variant: 'success', + title: tr('notifications.designSystemLinked'), + description: next.designSystem.summary, + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : tr('errors.generic'); + get().pushToast({ + variant: 'error', + title: tr('notifications.designSystemScanFailed'), + description: message, + }); + } + }, + async clearDesignSystem() { if (!window.codesign) return; try { @@ -1471,6 +1511,30 @@ export const useCodesignStore = create((set, get) => ({ } }, + async exportRemote(input) { + const html = get().previewHtml; + if (!html) { + set({ toastMessage: tr('notifications.noDesignToExport') }); + return; + } + if (!window.codesign?.remote) { + set({ errorMessage: tr('errors.rendererDisconnected') }); + return; + } + try { + const res = await window.codesign.remote.export({ + format: input.format, + htmlContent: html, + profileId: input.profileId, + remotePath: input.remotePath, + }); + set({ toastMessage: tr('notifications.exportedTo', { path: res.path }) }); + } catch (err) { + const msg = err instanceof Error ? err.message : tr('errors.unknown'); + set({ toastMessage: msg, errorMessage: msg, lastError: msg }); + } + }, + selectCanvasElement(selection) { set({ selectedElement: selection }); }, diff --git a/packages/core/src/design-skills/chat-ui.jsx b/packages/core/src/design-skills/chat-ui.jsx index 134cd000..748e4f16 100644 --- a/packages/core/src/design-skills/chat-ui.jsx +++ b/packages/core/src/design-skills/chat-ui.jsx @@ -399,9 +399,9 @@ function ChatUI() { const [input, setInput] = React.useState(''); const [messages, setMessages] = React.useState(DEMO_MESSAGES); const bottomRef = React.useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: demo file — effect intentionally re-runs on new messages to scroll into view React.useEffect(() => { if (bottomRef.current) bottomRef.current.parentNode.scrollTop = bottomRef.current.offsetTop; - // biome-ignore lint/correctness/useExhaustiveDependencies: demo file — effect intentionally re-runs on new messages to scroll into view }, [messages]); const send = () => { diff --git a/packages/core/src/generate.test.ts b/packages/core/src/generate.test.ts index b45203a6..4837fffb 100644 --- a/packages/core/src/generate.test.ts +++ b/packages/core/src/generate.test.ts @@ -55,6 +55,7 @@ ${SAMPLE_HTML} const DESIGN_SYSTEM: StoredDesignSystem = { schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION, rootPath: '/repo', + sourceKind: 'local', summary: 'Muted neutrals with warm copper accents.', extractedAt: '2026-04-18T00:00:00.000Z', sourceFiles: ['tailwind.config.ts'], diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index fd1f893b..84e22266 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -162,6 +162,7 @@ describe('hydrateConfig / toPersistedV3', () => { activeProvider: 'anthropic', activeModel: 'claude-sonnet-4-6', secrets: {}, + sshProfiles: {}, providers: { anthropic: { ...BUILTIN_PROVIDERS.anthropic, baseUrl: 'http://localhost:4000' }, }, diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index d682dd2f..b21f1951 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -44,11 +44,50 @@ export const BaseUrlRef = z.object({ }); export type BaseUrlRef = z.infer; +export const RemoteSourceKindSchema = z.enum(['local', 'ssh']); +export type RemoteSourceKind = z.infer; + +export const SshAuthMethodSchema = z.enum(['password', 'privateKey']); +export type SshAuthMethod = z.infer; + +export const SshProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + host: z.string().min(1), + port: z.number().int().positive().max(65535).default(22), + username: z.string().min(1), + authMethod: SshAuthMethodSchema, + keyPath: z.string().min(1).optional(), + password: SecretRef.optional(), + passphrase: SecretRef.optional(), + basePath: z.string().min(1).optional(), +}); +export type SshProfile = z.infer; + +export const SshProfileSummarySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + host: z.string().min(1), + port: z.number().int().positive().max(65535), + username: z.string().min(1), + authMethod: SshAuthMethodSchema, + keyPath: z.string().min(1).optional(), + hasPassword: z.boolean(), + hasPassphrase: z.boolean(), + basePath: z.string().min(1).optional(), +}); +export type SshProfileSummary = z.infer; + export const STORED_DESIGN_SYSTEM_SCHEMA_VERSION = 1 as const; const StoredDesignSystemShape = z.object({ schemaVersion: z.literal(STORED_DESIGN_SYSTEM_SCHEMA_VERSION), rootPath: z.string().min(1), + sourceKind: RemoteSourceKindSchema.default('local'), + sshProfileId: z.string().min(1).optional(), + sshHost: z.string().min(1).optional(), + sshPort: z.number().int().positive().max(65535).optional(), + sshUsername: z.string().min(1).optional(), summary: z.string().min(1), extractedAt: z.string().min(1), sourceFiles: z.array(z.string().min(1)).max(24).default([]), @@ -152,6 +191,7 @@ export const ConfigV3Schema = z.object({ activeModel: z.string(), secrets: z.record(z.string(), SecretRef).default({}), providers: z.record(z.string(), ProviderEntrySchema).default({}), + sshProfiles: z.record(z.string(), SshProfileSchema).default({}), designSystem: StoredDesignSystem.optional(), }); export type ConfigV3 = z.infer; @@ -212,6 +252,7 @@ export function migrateLegacyToV3(legacy: LegacyConfig): ConfigV3 { activeModel: legacy.modelPrimary, secrets, providers, + sshProfiles: {}, }; if (legacy.designSystem !== undefined) out.designSystem = legacy.designSystem; return out; @@ -265,6 +306,7 @@ export function toPersistedV3(cfg: Config | ConfigV3): ConfigV3 { activeModel: cfg.activeModel, secrets: cfg.secrets, providers: cfg.providers, + sshProfiles: cfg.sshProfiles ?? {}, ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), }; } @@ -277,6 +319,22 @@ export interface OnboardingState { modelPrimary: string | null; baseUrl: string | null; designSystem: StoredDesignSystem | null; + sshProfiles: SshProfileSummary[]; +} + +export function summarizeSshProfile(profile: SshProfile): SshProfileSummary { + return { + id: profile.id, + name: profile.name, + host: profile.host, + port: profile.port, + username: profile.username, + authMethod: profile.authMethod, + ...(profile.keyPath !== undefined ? { keyPath: profile.keyPath } : {}), + hasPassword: profile.password !== undefined, + hasPassphrase: profile.passphrase !== undefined, + ...(profile.basePath !== undefined ? { basePath: profile.basePath } : {}), + }; } export interface ProviderShortlist { diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts index 29e20402..7c44dddf 100644 --- a/packages/shared/src/error-codes.ts +++ b/packages/shared/src/error-codes.ts @@ -63,6 +63,13 @@ export const ERROR_CODES = { REFERENCE_URL_FETCH_FAILED: 'REFERENCE_URL_FETCH_FAILED', REFERENCE_URL_FETCH_TIMEOUT: 'REFERENCE_URL_FETCH_TIMEOUT', REFERENCE_URL_UNSUPPORTED: 'REFERENCE_URL_UNSUPPORTED', + SSH_PROFILE_NOT_FOUND: 'SSH_PROFILE_NOT_FOUND', + SSH_KEY_READ_FAILED: 'SSH_KEY_READ_FAILED', + SSH_CONNECT_FAILED: 'SSH_CONNECT_FAILED', + SSH_SFTP_FAILED: 'SSH_SFTP_FAILED', + SSH_REMOTE_PATH_INVALID: 'SSH_REMOTE_PATH_INVALID', + SSH_REMOTE_READ_FAILED: 'SSH_REMOTE_READ_FAILED', + SSH_REMOTE_WRITE_FAILED: 'SSH_REMOTE_WRITE_FAILED', // Preferences PREFERENCES_READ_FAIL: 'PREFERENCES_READ_FAIL', @@ -256,6 +263,34 @@ export const ERROR_CODE_DESCRIPTIONS: Record; -export const LocalInputFile = z.object({ +const LocalInputFileLocalShape = z.object({ + kind: z.literal('local'), path: z.string().min(1), name: z.string().min(1), size: z.number().int().nonnegative(), }); + +const LocalInputFileSshShape = z.object({ + kind: z.literal('ssh'), + profileId: z.string().min(1), + path: z.string().min(1), + name: z.string().min(1), + size: z.number().int().nonnegative(), + displayPath: z.string().min(1).optional(), +}); + +export const LocalInputFile = z.preprocess( + (raw) => { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw; + const record = raw as Record; + if ('kind' in record) return record; + return { kind: 'local', ...record }; + }, + z.discriminatedUnion('kind', [LocalInputFileLocalShape, LocalInputFileSshShape]), +); export type LocalInputFile = z.infer; export const ElementSelectionRect = z.object({ @@ -237,8 +257,12 @@ export { ConfigSchema, ConfigV3Schema, PROVIDER_SHORTLIST, + RemoteSourceKindSchema, ProviderEntrySchema, ReasoningLevelSchema, + SshAuthMethodSchema, + SshProfileSchema, + SshProfileSummarySchema, SUPPORTED_ONBOARDING_PROVIDERS, SecretRef, STORED_DESIGN_SYSTEM_SCHEMA_VERSION, @@ -249,6 +273,7 @@ export { isSupportedOnboardingProvider, migrateLegacyToV3, parseConfigFlexible, + summarizeSshProfile, toPersistedV3, } from './config'; export type { @@ -258,6 +283,10 @@ export type { ProviderEntry, ProviderShortlist, ReasoningLevel, + RemoteSourceKind, + SshAuthMethod, + SshProfile, + SshProfileSummary, SupportedOnboardingProvider, WireApi, } from './config'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3615ca7..aa80c3f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@open-codesign/ui': specifier: workspace:* version: link:../../packages/ui + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 better-sqlite3: specifier: ^12.9.0 version: 12.9.0 @@ -92,6 +95,9 @@ importers: smol-toml: specifier: ^1.6.1 version: 1.6.1 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 zip-lib: specifier: ^1.0.4 version: 1.3.2 @@ -2007,6 +2013,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} @@ -2027,6 +2036,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2269,6 +2281,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -2377,6 +2392,9 @@ packages: resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -2439,6 +2457,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + builder-util-runtime@9.5.1: resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} engines: {node: '>=12.0.0'} @@ -2591,6 +2613,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -3749,6 +3775,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nan@2.26.2: + resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4312,6 +4341,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + ssri@12.0.0: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4493,6 +4526,9 @@ packages: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -4505,6 +4541,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6798,6 +6837,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 @@ -6822,6 +6865,10 @@ snapshots: '@types/retry@0.12.0': {} + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -7118,6 +7165,10 @@ snapshots: array-union@2.1.0: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assert-plus@1.0.0: optional: true @@ -7193,6 +7244,10 @@ snapshots: basic-ftp@5.3.0: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -7259,6 +7314,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + builder-util-runtime@9.5.1: dependencies: debug: 4.4.3 @@ -7424,6 +7482,12 @@ snapshots: core-util-is@1.0.3: {} + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.26.2 + optional: true + crc@3.8.0: dependencies: buffer: 5.7.1 @@ -8935,6 +8999,9 @@ snapshots: ms@2.1.3: {} + nan@2.26.2: + optional: true + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} @@ -9573,6 +9640,14 @@ snapshots: sprintf-js@1.1.3: optional: true + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.26.2 + ssri@12.0.0: dependencies: minipass: 7.1.3 @@ -9793,6 +9868,8 @@ snapshots: '@turbo/windows-64': 2.9.6 '@turbo/windows-arm64': 2.9.6 + tweetnacl@0.14.5: {} + type-fest@0.13.1: optional: true @@ -9800,6 +9877,8 @@ snapshots: typescript@5.9.3: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici@7.25.0: {}